chore: remove cached
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
CPM_ENV=development
|
||||
CPM_HTTP_PORT=8080
|
||||
CPM_DB_PATH=./data/cpm.db
|
||||
CPM_CADDY_ADMIN_API=http://localhost:2019
|
||||
CPM_CADDY_CONFIG_DIR=./data/caddy
|
||||
@@ -1,19 +0,0 @@
|
||||
# Backend Service
|
||||
|
||||
This folder contains the Go API for CaddyProxyManager+.
|
||||
|
||||
## Prerequisites
|
||||
- Go 1.24+
|
||||
|
||||
## Getting started
|
||||
```bash
|
||||
cp .env.example .env # optional
|
||||
cd backend
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
## Tests
|
||||
```bash
|
||||
cd backend
|
||||
go test ./...
|
||||
```
|
||||
@@ -1,116 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/routes"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/database"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/server"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Setup logging with rotation
|
||||
logDir := "/app/data/logs"
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
// Fallback to local directory if /app/data fails (e.g. local dev)
|
||||
logDir = "data/logs"
|
||||
_ = os.MkdirAll(logDir, 0755)
|
||||
}
|
||||
|
||||
logFile := filepath.Join(logDir, "cpmp.log")
|
||||
rotator := &lumberjack.Logger{
|
||||
Filename: logFile,
|
||||
MaxSize: 10, // megabytes
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28, // days
|
||||
Compress: true,
|
||||
}
|
||||
|
||||
// Log to both stdout and file
|
||||
mw := io.MultiWriter(os.Stdout, rotator)
|
||||
log.SetOutput(mw)
|
||||
gin.DefaultWriter = mw
|
||||
|
||||
// Handle CLI commands
|
||||
if len(os.Args) > 1 && os.Args[1] == "reset-password" {
|
||||
if len(os.Args) != 4 {
|
||||
log.Fatalf("Usage: %s reset-password <email> <new-password>", os.Args[0])
|
||||
}
|
||||
email := os.Args[2]
|
||||
newPassword := os.Args[3]
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.Connect(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
log.Fatalf("connect database: %v", err)
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.Where("email = ?", email).First(&user).Error; err != nil {
|
||||
log.Fatalf("user not found: %v", err)
|
||||
}
|
||||
|
||||
if err := user.SetPassword(newPassword); err != nil {
|
||||
log.Fatalf("failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
// Unlock account if locked
|
||||
user.LockedUntil = nil
|
||||
user.FailedLoginAttempts = 0
|
||||
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
log.Fatalf("failed to save user: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Password updated successfully for user %s", email)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("starting %s backend on version %s", version.Name, version.Full())
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.Connect(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
log.Fatalf("connect database: %v", err)
|
||||
}
|
||||
|
||||
router := server.NewRouter(cfg.FrontendDir)
|
||||
|
||||
// Pass config to routes for auth service and certificate service
|
||||
if err := routes.Register(router, db, cfg); err != nil {
|
||||
log.Fatalf("register routes: %v", err)
|
||||
}
|
||||
|
||||
// Register import handler with config dependencies
|
||||
routes.RegisterImportHandler(router, db, cfg.CaddyBinary, cfg.ImportDir, cfg.ImportCaddyfile)
|
||||
|
||||
// Check for mounted Caddyfile on startup
|
||||
if err := handlers.CheckMountedImport(db, cfg.ImportCaddyfile, cfg.CaddyBinary, cfg.ImportDir); err != nil {
|
||||
log.Printf("WARNING: failed to process mounted Caddyfile: %v", err)
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%s", cfg.HTTPPort)
|
||||
log.Printf("starting %s backend on %s", version.Name, addr)
|
||||
|
||||
if err := router.Run(addr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Connect to database
|
||||
db, err := gorm.Open(sqlite.Open("./data/cpm.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
// Auto migrate
|
||||
if err := db.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.ProxyHost{},
|
||||
&models.CaddyConfig{},
|
||||
&models.RemoteServer{},
|
||||
&models.SSLCertificate{},
|
||||
&models.AccessList{},
|
||||
&models.Setting{},
|
||||
&models.ImportSession{},
|
||||
); err != nil {
|
||||
log.Fatal("Failed to migrate database:", err)
|
||||
}
|
||||
|
||||
fmt.Println("✓ Database migrated successfully")
|
||||
|
||||
// Seed Remote Servers
|
||||
remoteServers := []models.RemoteServer{
|
||||
{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Local Docker Registry",
|
||||
Provider: "docker",
|
||||
Host: "localhost",
|
||||
Port: 5000,
|
||||
Scheme: "http",
|
||||
Description: "Local Docker container registry",
|
||||
Enabled: true,
|
||||
Reachable: false,
|
||||
},
|
||||
{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Development API Server",
|
||||
Provider: "generic",
|
||||
Host: "192.168.1.100",
|
||||
Port: 8080,
|
||||
Scheme: "http",
|
||||
Description: "Main development API backend",
|
||||
Enabled: true,
|
||||
Reachable: false,
|
||||
},
|
||||
{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Staging Web App",
|
||||
Provider: "vm",
|
||||
Host: "staging.internal",
|
||||
Port: 3000,
|
||||
Scheme: "http",
|
||||
Description: "Staging environment web application",
|
||||
Enabled: true,
|
||||
Reachable: false,
|
||||
},
|
||||
{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Database Admin",
|
||||
Provider: "docker",
|
||||
Host: "localhost",
|
||||
Port: 8081,
|
||||
Scheme: "http",
|
||||
Description: "PhpMyAdmin or similar DB management tool",
|
||||
Enabled: false,
|
||||
Reachable: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, server := range remoteServers {
|
||||
result := db.Where("host = ? AND port = ?", server.Host, server.Port).FirstOrCreate(&server)
|
||||
if result.Error != nil {
|
||||
log.Printf("Failed to seed remote server %s: %v", server.Name, result.Error)
|
||||
} else if result.RowsAffected > 0 {
|
||||
fmt.Printf("✓ Created remote server: %s (%s:%d)\n", server.Name, server.Host, server.Port)
|
||||
} else {
|
||||
fmt.Printf(" Remote server already exists: %s\n", server.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed Proxy Hosts
|
||||
proxyHosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Development App",
|
||||
DomainNames: "app.local.dev",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 3000,
|
||||
SSLForced: false,
|
||||
WebsocketSupport: true,
|
||||
HSTSEnabled: false,
|
||||
BlockExploits: true,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "API Server",
|
||||
DomainNames: "api.local.dev",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "192.168.1.100",
|
||||
ForwardPort: 8080,
|
||||
SSLForced: false,
|
||||
WebsocketSupport: false,
|
||||
HSTSEnabled: false,
|
||||
BlockExploits: true,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Docker Registry",
|
||||
DomainNames: "docker.local.dev",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 5000,
|
||||
SSLForced: false,
|
||||
WebsocketSupport: false,
|
||||
HSTSEnabled: false,
|
||||
BlockExploits: true,
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, host := range proxyHosts {
|
||||
result := db.Where("domain_names = ?", host.DomainNames).FirstOrCreate(&host)
|
||||
if result.Error != nil {
|
||||
log.Printf("Failed to seed proxy host %s: %v", host.DomainNames, result.Error)
|
||||
} else if result.RowsAffected > 0 {
|
||||
fmt.Printf("✓ Created proxy host: %s -> %s://%s:%d\n",
|
||||
host.DomainNames, host.ForwardScheme, host.ForwardHost, host.ForwardPort)
|
||||
} else {
|
||||
fmt.Printf(" Proxy host already exists: %s\n", host.DomainNames)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed Settings
|
||||
settings := []models.Setting{
|
||||
{
|
||||
Key: "app_name",
|
||||
Value: "Caddy Proxy Manager+",
|
||||
Type: "string",
|
||||
Category: "general",
|
||||
},
|
||||
{
|
||||
Key: "default_scheme",
|
||||
Value: "http",
|
||||
Type: "string",
|
||||
Category: "general",
|
||||
},
|
||||
{
|
||||
Key: "enable_ssl_by_default",
|
||||
Value: "false",
|
||||
Type: "bool",
|
||||
Category: "security",
|
||||
},
|
||||
}
|
||||
|
||||
for _, setting := range settings {
|
||||
result := db.Where("key = ?", setting.Key).FirstOrCreate(&setting)
|
||||
if result.Error != nil {
|
||||
log.Printf("Failed to seed setting %s: %v", setting.Key, result.Error)
|
||||
} else if result.RowsAffected > 0 {
|
||||
fmt.Printf("✓ Created setting: %s = %s\n", setting.Key, setting.Value)
|
||||
} else {
|
||||
fmt.Printf(" Setting already exists: %s\n", setting.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed default admin user (for future authentication)
|
||||
user := models.User{
|
||||
UUID: uuid.NewString(),
|
||||
Email: "admin@localhost",
|
||||
Name: "Administrator",
|
||||
PasswordHash: "$2a$10$example_hashed_password", // This would be properly hashed in production
|
||||
Role: "admin",
|
||||
Enabled: true,
|
||||
}
|
||||
result := db.Where("email = ?", user.Email).FirstOrCreate(&user)
|
||||
if result.Error != nil {
|
||||
log.Printf("Failed to seed user: %v", result.Error)
|
||||
} else if result.RowsAffected > 0 {
|
||||
fmt.Printf("✓ Created default user: %s\n", user.Email)
|
||||
} else {
|
||||
fmt.Printf(" User already exists: %s\n", user.Email)
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ Database seeding completed successfully!")
|
||||
fmt.Println(" You can now start the application and see sample data.")
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
module github.com/Wikid82/CaddyProxyManagerPlus/backend
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/docker/docker v28.5.2+incompatible
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.45.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containrrr/shoutrrr v0.8.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/fatih/color v1.15.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
200
backend/go.sum
200
backend/go.sum
@@ -1,200 +0,0 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
|
||||
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
|
||||
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
|
||||
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
|
||||
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
|
||||
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
|
||||
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc=
|
||||
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
|
||||
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
@@ -1,111 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService *services.AuthService) *AuthHandler {
|
||||
return &AuthHandler{authService: authService}
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.authService.Login(req.Email, req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set cookie
|
||||
c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.Register(req.Email, req.Password, req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
c.SetCookie("auth_token", "", -1, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
role, _ := c.Get("role")
|
||||
|
||||
u, err := h.authService.GetUserByID(userID.(uint))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": userID,
|
||||
"role": role,
|
||||
"name": u.Name,
|
||||
"email": u.Email,
|
||||
})
|
||||
}
|
||||
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
var req ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupAuthHandler(t *testing.T) (*AuthHandler, *gorm.DB) {
|
||||
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.AutoMigrate(&models.User{}, &models.Setting{})
|
||||
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
return NewAuthHandler(authService), db
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login(t *testing.T) {
|
||||
handler, db := setupAuthHandler(t)
|
||||
|
||||
// Create user
|
||||
user := &models.User{
|
||||
UUID: uuid.NewString(),
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
}
|
||||
user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/login", handler.Login)
|
||||
|
||||
// Success
|
||||
body := map[string]string{
|
||||
"email": "test@example.com",
|
||||
"password": "password123",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "token")
|
||||
}
|
||||
|
||||
func TestAuthHandler_Login_Errors(t *testing.T) {
|
||||
handler, _ := setupAuthHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/login", handler.Login)
|
||||
|
||||
// 1. Invalid JSON
|
||||
req := httptest.NewRequest("POST", "/login", bytes.NewBufferString("invalid"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// 2. Invalid Credentials
|
||||
body := map[string]string{
|
||||
"email": "nonexistent@example.com",
|
||||
"password": "wrong",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req = httptest.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthHandler_Register(t *testing.T) {
|
||||
handler, _ := setupAuthHandler(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/register", handler.Register)
|
||||
|
||||
body := map[string]string{
|
||||
"email": "new@example.com",
|
||||
"password": "password123",
|
||||
"name": "New User",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "new@example.com")
|
||||
}
|
||||
|
||||
func TestAuthHandler_Register_Duplicate(t *testing.T) {
|
||||
handler, db := setupAuthHandler(t)
|
||||
db.Create(&models.User{UUID: uuid.NewString(), Email: "dup@example.com", Name: "Dup"})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/register", handler.Register)
|
||||
|
||||
body := map[string]string{
|
||||
"email": "dup@example.com",
|
||||
"password": "password123",
|
||||
"name": "Dup User",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthHandler_Logout(t *testing.T) {
|
||||
handler, _ := setupAuthHandler(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/logout", handler.Logout)
|
||||
|
||||
req := httptest.NewRequest("POST", "/logout", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Logged out")
|
||||
// Check cookie
|
||||
cookie := w.Result().Cookies()[0]
|
||||
assert.Equal(t, "auth_token", cookie.Name)
|
||||
assert.Equal(t, -1, cookie.MaxAge)
|
||||
}
|
||||
|
||||
func TestAuthHandler_Me(t *testing.T) {
|
||||
handler, db := setupAuthHandler(t)
|
||||
|
||||
// Create user that matches the middleware ID
|
||||
user := &models.User{
|
||||
UUID: uuid.NewString(),
|
||||
Email: "me@example.com",
|
||||
Name: "Me User",
|
||||
Role: "admin",
|
||||
}
|
||||
db.Create(user)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// Simulate middleware
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("role", user.Role)
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/me", handler.Me)
|
||||
|
||||
req := httptest.NewRequest("GET", "/me", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Equal(t, float64(user.ID), resp["user_id"])
|
||||
assert.Equal(t, "admin", resp["role"])
|
||||
assert.Equal(t, "Me User", resp["name"])
|
||||
assert.Equal(t, "me@example.com", resp["email"])
|
||||
}
|
||||
|
||||
func TestAuthHandler_Me_NotFound(t *testing.T) {
|
||||
handler, _ := setupAuthHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", uint(999)) // Non-existent ID
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/me", handler.Me)
|
||||
|
||||
req := httptest.NewRequest("GET", "/me", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthHandler_ChangePassword(t *testing.T) {
|
||||
handler, db := setupAuthHandler(t)
|
||||
|
||||
// Create user
|
||||
user := &models.User{
|
||||
UUID: uuid.NewString(),
|
||||
Email: "change@example.com",
|
||||
Name: "Change User",
|
||||
}
|
||||
user.SetPassword("oldpassword")
|
||||
db.Create(user)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// Simulate middleware
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", user.ID)
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/change-password", handler.ChangePassword)
|
||||
|
||||
body := map[string]string{
|
||||
"old_password": "oldpassword",
|
||||
"new_password": "newpassword123",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Password updated successfully")
|
||||
|
||||
// Verify password changed
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.True(t, updatedUser.CheckPassword("newpassword123"))
|
||||
}
|
||||
|
||||
func TestAuthHandler_ChangePassword_WrongOld(t *testing.T) {
|
||||
handler, db := setupAuthHandler(t)
|
||||
user := &models.User{UUID: uuid.NewString(), Email: "wrong@example.com"}
|
||||
user.SetPassword("correct")
|
||||
db.Create(user)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", user.ID)
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/change-password", handler.ChangePassword)
|
||||
|
||||
body := map[string]string{
|
||||
"old_password": "wrong",
|
||||
"new_password": "newpassword",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthHandler_ChangePassword_Errors(t *testing.T) {
|
||||
handler, _ := setupAuthHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/change-password", handler.ChangePassword)
|
||||
|
||||
// 1. BindJSON error (checked before auth)
|
||||
req, _ := http.NewRequest("POST", "/change-password", bytes.NewBufferString("invalid json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// 2. Unauthorized (valid JSON but no user in context)
|
||||
body := map[string]string{
|
||||
"old_password": "oldpassword",
|
||||
"new_password": "newpassword123",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req, _ = http.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type BackupHandler struct {
|
||||
service *services.BackupService
|
||||
}
|
||||
|
||||
func NewBackupHandler(service *services.BackupService) *BackupHandler {
|
||||
return &BackupHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *BackupHandler) List(c *gin.Context) {
|
||||
backups, err := h.service.ListBackups()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backups"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, backups)
|
||||
}
|
||||
|
||||
func (h *BackupHandler) Create(c *gin.Context) {
|
||||
filename, err := h.service.CreateBackup()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"filename": filename, "message": "Backup created successfully"})
|
||||
}
|
||||
|
||||
func (h *BackupHandler) Delete(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
if err := h.service.DeleteBackup(filename); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Backup deleted"})
|
||||
}
|
||||
|
||||
func (h *BackupHandler) Download(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
path, err := h.service.GetBackupPath(filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.File(path)
|
||||
}
|
||||
|
||||
func (h *BackupHandler) Restore(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
if err := h.service.RestoreBackup(filename); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore backup: " + err.Error()})
|
||||
return
|
||||
}
|
||||
// In a real scenario, we might want to trigger a restart here
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Backup restored successfully. Please restart the container."})
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string) {
|
||||
t.Helper()
|
||||
|
||||
// Create temp directories
|
||||
tmpDir, err := os.MkdirTemp("", "cpm-backup-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Structure: tmpDir/data/cpm.db
|
||||
// BackupService expects DatabasePath to be .../data/cpm.db
|
||||
// It sets DataDir to filepath.Dir(DatabasePath) -> .../data
|
||||
// It sets BackupDir to .../data/backups (Wait, let me check the code again)
|
||||
|
||||
// Code: backupDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "backups")
|
||||
// So if DatabasePath is /tmp/data/cpm.db, DataDir is /tmp/data, BackupDir is /tmp/data/backups.
|
||||
|
||||
dataDir := filepath.Join(tmpDir, "data")
|
||||
err = os.MkdirAll(dataDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbPath := filepath.Join(dataDir, "cpm.db")
|
||||
// Create a dummy DB file to back up
|
||||
err = os.WriteFile(dbPath, []byte("dummy db content"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
svc := services.NewBackupService(cfg)
|
||||
h := NewBackupHandler(svc)
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
// Manually register routes since we don't have a RegisterRoutes method on the handler yet?
|
||||
// Wait, I didn't check if I added RegisterRoutes to BackupHandler.
|
||||
// In routes.go I did:
|
||||
// backupHandler := handlers.NewBackupHandler(backupService)
|
||||
// backups := api.Group("/backups")
|
||||
// backups.GET("", backupHandler.List)
|
||||
// ...
|
||||
// So the handler doesn't have RegisterRoutes. I'll register manually here.
|
||||
|
||||
backups := api.Group("/backups")
|
||||
backups.GET("", h.List)
|
||||
backups.POST("", h.Create)
|
||||
backups.POST("/:filename/restore", h.Restore)
|
||||
backups.DELETE("/:filename", h.Delete)
|
||||
backups.GET("/:filename/download", h.Download)
|
||||
|
||||
return r, svc, tmpDir
|
||||
}
|
||||
|
||||
func TestBackupLifecycle(t *testing.T) {
|
||||
router, _, tmpDir := setupBackupTest(t)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 1. List backups (should be empty)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
// Check empty list
|
||||
// ...
|
||||
|
||||
// 2. Create backup
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusCreated, resp.Code)
|
||||
|
||||
var result map[string]string
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
filename := result["filename"]
|
||||
require.NotEmpty(t, filename)
|
||||
|
||||
// 3. List backups (should have 1)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
// Verify list contains filename
|
||||
|
||||
// 4. Restore backup
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// 5. Download backup
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
// Content-Type might vary depending on implementation (application/octet-stream or zip)
|
||||
// require.Equal(t, "application/zip", resp.Header().Get("Content-Type"))
|
||||
|
||||
// 6. Delete backup
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// 7. List backups (should be empty again)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
var list []interface{}
|
||||
json.Unmarshal(resp.Body.Bytes(), &list)
|
||||
require.Empty(t, list)
|
||||
|
||||
// 8. Delete non-existent backup
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
|
||||
// 9. Restore non-existent backup
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/missing.zip/restore", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
|
||||
// 10. Download non-existent backup
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/missing.zip/download", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
}
|
||||
|
||||
func TestBackupHandler_Errors(t *testing.T) {
|
||||
router, svc, tmpDir := setupBackupTest(t)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 1. List Error (remove backup dir to cause ReadDir error)
|
||||
os.RemoveAll(svc.BackupDir)
|
||||
// Create a file with same name to cause ReadDir to fail (if it expects dir)
|
||||
// Or just make it unreadable
|
||||
// os.Chmod(svc.BackupDir, 0000) // Might not work as expected in all envs
|
||||
// Simpler: if BackupDir doesn't exist, ListBackups returns error?
|
||||
// os.ReadDir returns error if dir doesn't exist.
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusInternalServerError, resp.Code)
|
||||
|
||||
// 2. Create Error (make backup dir read-only or non-existent)
|
||||
// If we removed it above, CreateBackup might try to create it?
|
||||
// NewBackupService creates it. CreateBackup uses it.
|
||||
// If we create a file named "backups" where the dir should be, MkdirAll might fail?
|
||||
// Or just make the parent dir read-only.
|
||||
|
||||
// Let's try path traversal for Download/Delete/Restore to cover those errors
|
||||
|
||||
// 3. Create Error (make backup dir read-only)
|
||||
// We can't easily make the dir read-only for the service without affecting other tests or requiring root.
|
||||
// But we can mock the service or use a different config.
|
||||
// If we set BackupDir to a non-existent dir that cannot be created?
|
||||
// NewBackupService creates it.
|
||||
// If we set BackupDir to a file?
|
||||
|
||||
// Let's skip Create error for now and focus on what we can test.
|
||||
// We can test Download Not Found (already covered).
|
||||
|
||||
// 4. Delete Error (Not Found)
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
type CertificateHandler struct {
|
||||
service *services.CertificateService
|
||||
notificationService *services.NotificationService
|
||||
}
|
||||
|
||||
func NewCertificateHandler(service *services.CertificateService, ns *services.NotificationService) *CertificateHandler {
|
||||
return &CertificateHandler{
|
||||
service: service,
|
||||
notificationService: ns,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CertificateHandler) List(c *gin.Context) {
|
||||
certs, err := h.service.ListCertificates()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, certs)
|
||||
}
|
||||
|
||||
type UploadCertificateRequest struct {
|
||||
Name string `form:"name" binding:"required"`
|
||||
Certificate string `form:"certificate"` // PEM content
|
||||
PrivateKey string `form:"private_key"` // PEM content
|
||||
}
|
||||
|
||||
func (h *CertificateHandler) Upload(c *gin.Context) {
|
||||
// Handle multipart form
|
||||
name := c.PostForm("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read files
|
||||
certFile, err := c.FormFile("certificate_file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
keyFile, err := c.FormFile("key_file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Open and read content
|
||||
certSrc, err := certFile.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
|
||||
return
|
||||
}
|
||||
defer certSrc.Close()
|
||||
|
||||
keySrc, err := keyFile.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
|
||||
return
|
||||
}
|
||||
defer keySrc.Close()
|
||||
|
||||
// Read to string
|
||||
// Limit size to avoid DoS (e.g. 1MB)
|
||||
certBytes := make([]byte, 1024*1024)
|
||||
n, _ := certSrc.Read(certBytes)
|
||||
certPEM := string(certBytes[:n])
|
||||
|
||||
keyBytes := make([]byte, 1024*1024)
|
||||
n, _ = keySrc.Read(keyBytes)
|
||||
keyPEM := string(keyBytes[:n])
|
||||
|
||||
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Send Notification
|
||||
if h.notificationService != nil {
|
||||
h.notificationService.SendExternal(
|
||||
"cert",
|
||||
"Certificate Uploaded",
|
||||
fmt.Sprintf("Certificate %s uploaded", cert.Name),
|
||||
map[string]interface{}{
|
||||
"Name": cert.Name,
|
||||
"Domains": cert.Domains,
|
||||
"Action": "uploaded",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, cert)
|
||||
}
|
||||
|
||||
func (h *CertificateHandler) Delete(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.DeleteCertificate(uint(id)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Send Notification
|
||||
if h.notificationService != nil {
|
||||
h.notificationService.SendExternal(
|
||||
"cert",
|
||||
"Certificate Deleted",
|
||||
fmt.Sprintf("Certificate ID %d deleted", id),
|
||||
map[string]interface{}{
|
||||
"ID": id,
|
||||
"Action": "deleted",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func generateTestCert(t *testing.T, domain string) []byte {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate private key: %v", err)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: domain,
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
}
|
||||
|
||||
func TestCertificateHandler_List(t *testing.T) {
|
||||
// Setup temp dir
|
||||
tmpDir := t.TempDir()
|
||||
caddyDir := filepath.Join(tmpDir, "caddy", "certificates", "acme-v02.api.letsencrypt.org-directory")
|
||||
err := os.MkdirAll(caddyDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Setup in-memory DB
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
|
||||
|
||||
service := services.NewCertificateService(tmpDir, db)
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := NewCertificateHandler(service, ns)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/certificates", handler.List)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/certificates", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var certs []services.CertificateInfo
|
||||
err = json.Unmarshal(w.Body.Bytes(), &certs)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, certs)
|
||||
}
|
||||
|
||||
func TestCertificateHandler_Upload(t *testing.T) {
|
||||
// Setup
|
||||
tmpDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
|
||||
|
||||
service := services.NewCertificateService(tmpDir, db)
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := NewCertificateHandler(service, ns)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/certificates", handler.Upload)
|
||||
|
||||
// Prepare Multipart Request
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
|
||||
_ = writer.WriteField("name", "Test Cert")
|
||||
|
||||
certPEM := generateTestCert(t, "test.com")
|
||||
part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
|
||||
part.Write(certPEM)
|
||||
|
||||
part, _ = writer.CreateFormFile("key_file", "key.pem")
|
||||
part.Write([]byte("FAKE KEY")) // Service doesn't validate key structure strictly yet, just PEM decoding?
|
||||
// Actually service does: block, _ := pem.Decode([]byte(certPEM)) for cert.
|
||||
// It doesn't seem to validate keyPEM in UploadCertificate, just stores it.
|
||||
|
||||
writer.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", "/certificates", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var cert models.SSLCertificate
|
||||
err = json.Unmarshal(w.Body.Bytes(), &cert)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Test Cert", cert.Name)
|
||||
}
|
||||
|
||||
func TestCertificateHandler_Delete(t *testing.T) {
|
||||
// Setup
|
||||
tmpDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
|
||||
|
||||
// Seed a cert
|
||||
cert := models.SSLCertificate{
|
||||
UUID: "test-uuid",
|
||||
Name: "To Delete",
|
||||
}
|
||||
err = db.Create(&cert).Error
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, cert.ID)
|
||||
|
||||
service := services.NewCertificateService(tmpDir, db)
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := NewCertificateHandler(service, ns)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.DELETE("/certificates/:id", handler.Delete)
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/certificates/"+strconv.Itoa(int(cert.ID)), nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify deletion
|
||||
var deletedCert models.SSLCertificate
|
||||
err = db.First(&deletedCert, cert.ID).Error
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type DockerHandler struct {
|
||||
dockerService *services.DockerService
|
||||
}
|
||||
|
||||
func NewDockerHandler(dockerService *services.DockerService) *DockerHandler {
|
||||
return &DockerHandler{dockerService: dockerService}
|
||||
}
|
||||
|
||||
func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.GET("/docker/containers", h.ListContainers)
|
||||
}
|
||||
|
||||
func (h *DockerHandler) ListContainers(c *gin.Context) {
|
||||
host := c.Query("host")
|
||||
containers, err := h.dockerService.ListContainers(c.Request.Context(), host)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, containers)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDockerHandler_ListContainers(t *testing.T) {
|
||||
// We can't easily mock the DockerService without an interface,
|
||||
// and the DockerService depends on the real Docker client.
|
||||
// So we'll just test that the handler is wired up correctly,
|
||||
// even if it returns an error because Docker isn't running in the test env.
|
||||
|
||||
svc, _ := services.NewDockerService()
|
||||
// svc might be nil if docker is not available, but NewDockerHandler handles nil?
|
||||
// Actually NewDockerHandler just stores it.
|
||||
// If svc is nil, ListContainers will panic.
|
||||
// So we only run this if svc is not nil.
|
||||
|
||||
if svc == nil {
|
||||
t.Skip("Docker not available")
|
||||
}
|
||||
|
||||
h := NewDockerHandler(svc)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
h.RegisterRoutes(r.Group("/"))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/docker/containers", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// It might return 200 or 500 depending on if ListContainers succeeds
|
||||
assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, w.Code)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DomainHandler struct {
|
||||
DB *gorm.DB
|
||||
notificationService *services.NotificationService
|
||||
}
|
||||
|
||||
func NewDomainHandler(db *gorm.DB, ns *services.NotificationService) *DomainHandler {
|
||||
return &DomainHandler{
|
||||
DB: db,
|
||||
notificationService: ns,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DomainHandler) List(c *gin.Context) {
|
||||
var domains []models.Domain
|
||||
if err := h.DB.Order("name asc").Find(&domains).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch domains"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, domains)
|
||||
}
|
||||
|
||||
func (h *DomainHandler) Create(c *gin.Context) {
|
||||
var input struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
domain := models.Domain{
|
||||
Name: input.Name,
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&domain).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create domain"})
|
||||
return
|
||||
}
|
||||
|
||||
// Send Notification
|
||||
if h.notificationService != nil {
|
||||
h.notificationService.SendExternal(
|
||||
"domain",
|
||||
"Domain Added",
|
||||
fmt.Sprintf("Domain %s added", domain.Name),
|
||||
map[string]interface{}{
|
||||
"Name": domain.Name,
|
||||
"Action": "created",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, domain)
|
||||
}
|
||||
|
||||
func (h *DomainHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var domain models.Domain
|
||||
if err := h.DB.Where("uuid = ?", id).First(&domain).Error; err == nil {
|
||||
// Send Notification before delete (or after if we keep the name)
|
||||
if h.notificationService != nil {
|
||||
h.notificationService.SendExternal(
|
||||
"domain",
|
||||
"Domain Deleted",
|
||||
fmt.Sprintf("Domain %s deleted", domain.Name),
|
||||
map[string]interface{}{
|
||||
"Name": domain.Name,
|
||||
"Action": "deleted",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.DB.Where("uuid = ?", id).Delete(&models.Domain{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete domain"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Domain deleted"})
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupDomainTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.Domain{}))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
h := NewDomainHandler(db, ns)
|
||||
r := gin.New()
|
||||
|
||||
// Manually register routes since DomainHandler doesn't have a RegisterRoutes method yet
|
||||
// or we can just register them here for testing
|
||||
r.GET("/api/v1/domains", h.List)
|
||||
r.POST("/api/v1/domains", h.Create)
|
||||
r.DELETE("/api/v1/domains/:id", h.Delete)
|
||||
|
||||
return r, db
|
||||
}
|
||||
|
||||
func TestDomainLifecycle(t *testing.T) {
|
||||
router, _ := setupDomainTestRouter(t)
|
||||
|
||||
// 1. Create Domain
|
||||
body := `{"name":"example.com"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusCreated, resp.Code)
|
||||
|
||||
var created models.Domain
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
|
||||
require.Equal(t, "example.com", created.Name)
|
||||
require.NotEmpty(t, created.UUID)
|
||||
|
||||
// 2. List Domains
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var list []models.Domain
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
|
||||
require.Len(t, list, 1)
|
||||
require.Equal(t, "example.com", list[0].Name)
|
||||
|
||||
// 3. Delete Domain
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/domains/"+created.UUID, nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// 4. Verify Deletion
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
|
||||
require.Len(t, list, 0)
|
||||
}
|
||||
|
||||
func TestDomainErrors(t *testing.T) {
|
||||
router, _ := setupDomainTestRouter(t)
|
||||
|
||||
// 1. Create Invalid JSON
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{invalid}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
// 2. Create Missing Name
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupTestDB() *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect to test database")
|
||||
}
|
||||
|
||||
// Auto migrate
|
||||
db.AutoMigrate(
|
||||
&models.ProxyHost{},
|
||||
&models.Location{},
|
||||
&models.RemoteServer{},
|
||||
&models.ImportSession{},
|
||||
)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRemoteServerHandler_List(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB()
|
||||
|
||||
// Create test server
|
||||
server := &models.RemoteServer{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Test Server",
|
||||
Provider: "docker",
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewRemoteServerHandler(db, ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
// Test List
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/remote-servers", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var servers []models.RemoteServer
|
||||
err := json.Unmarshal(w.Body.Bytes(), &servers)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, servers, 1)
|
||||
assert.Equal(t, "Test Server", servers[0].Name)
|
||||
}
|
||||
|
||||
func TestRemoteServerHandler_Create(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB()
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewRemoteServerHandler(db, ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
// Test Create
|
||||
serverData := map[string]interface{}{
|
||||
"name": "New Server",
|
||||
"provider": "generic",
|
||||
"host": "192.168.1.100",
|
||||
"port": 3000,
|
||||
"enabled": true,
|
||||
}
|
||||
body, _ := json.Marshal(serverData)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var server models.RemoteServer
|
||||
err := json.Unmarshal(w.Body.Bytes(), &server)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "New Server", server.Name)
|
||||
assert.NotEmpty(t, server.UUID)
|
||||
}
|
||||
|
||||
func TestRemoteServerHandler_TestConnection(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB()
|
||||
|
||||
// Create test server
|
||||
server := &models.RemoteServer{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Test Server",
|
||||
Provider: "docker",
|
||||
Host: "localhost",
|
||||
Port: 99999, // Invalid port to test failure
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewRemoteServerHandler(db, ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
// Test connection
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/remote-servers/"+server.UUID+"/test", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &result)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, result["reachable"].(bool))
|
||||
assert.NotEmpty(t, result["error"])
|
||||
}
|
||||
|
||||
func TestRemoteServerHandler_Get(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB()
|
||||
|
||||
// Create test server
|
||||
server := &models.RemoteServer{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Test Server",
|
||||
Provider: "docker",
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewRemoteServerHandler(db, ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
// Test Get
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var fetched models.RemoteServer
|
||||
err := json.Unmarshal(w.Body.Bytes(), &fetched)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, server.UUID, fetched.UUID)
|
||||
}
|
||||
|
||||
func TestRemoteServerHandler_Update(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB()
|
||||
|
||||
// Create test server
|
||||
server := &models.RemoteServer{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Test Server",
|
||||
Provider: "docker",
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewRemoteServerHandler(db, ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
// Test Update
|
||||
updateData := map[string]interface{}{
|
||||
"name": "Updated Server",
|
||||
"provider": "generic",
|
||||
"host": "10.0.0.1",
|
||||
"port": 9000,
|
||||
"enabled": false,
|
||||
}
|
||||
body, _ := json.Marshal(updateData)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("PUT", "/api/v1/remote-servers/"+server.UUID, bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var updated models.RemoteServer
|
||||
err := json.Unmarshal(w.Body.Bytes(), &updated)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Updated Server", updated.Name)
|
||||
assert.Equal(t, "generic", updated.Provider)
|
||||
assert.False(t, updated.Enabled)
|
||||
}
|
||||
|
||||
func TestRemoteServerHandler_Delete(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB()
|
||||
|
||||
// Create test server
|
||||
server := &models.RemoteServer{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Test Server",
|
||||
Provider: "docker",
|
||||
Host: "localhost",
|
||||
Port: 8080,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(server)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewRemoteServerHandler(db, ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
// Test Delete
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE", "/api/v1/remote-servers/"+server.UUID, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
|
||||
// Verify Delete
|
||||
w2 := httptest.NewRecorder()
|
||||
req2, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, nil)
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w2.Code)
|
||||
}
|
||||
|
||||
func TestProxyHostHandler_List(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB()
|
||||
|
||||
// Create test proxy host
|
||||
host := &models.ProxyHost{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Test Host",
|
||||
DomainNames: "test.local",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 3000,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(host)
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewProxyHostHandler(db, nil, ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
// Test List
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/proxy-hosts", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var hosts []models.ProxyHost
|
||||
err := json.Unmarshal(w.Body.Bytes(), &hosts)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, hosts, 1)
|
||||
assert.Equal(t, "Test Host", hosts[0].Name)
|
||||
}
|
||||
|
||||
func TestProxyHostHandler_Create(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB()
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewProxyHostHandler(db, nil, ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
// Test Create
|
||||
hostData := map[string]interface{}{
|
||||
"name": "New Host",
|
||||
"domain_names": "new.local",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "192.168.1.200",
|
||||
"forward_port": 8080,
|
||||
"enabled": true,
|
||||
}
|
||||
body, _ := json.Marshal(hostData)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/proxy-hosts", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var host models.ProxyHost
|
||||
err := json.Unmarshal(w.Body.Bytes(), &host)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "New Host", host.Name)
|
||||
assert.Equal(t, "new.local", host.DomainNames)
|
||||
assert.NotEmpty(t, host.UUID)
|
||||
}
|
||||
|
||||
func TestHealthHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.GET("/health", handlers.HealthHandler)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/health", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var result map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &result)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ok", result["status"])
|
||||
}
|
||||
|
||||
func TestRemoteServerHandler_Errors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB()
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewRemoteServerHandler(db, ns)
|
||||
router := gin.New()
|
||||
handler.RegisterRoutes(router.Group("/api/v1"))
|
||||
|
||||
// Get non-existent
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/remote-servers/non-existent", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Update non-existent
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/non-existent", strings.NewReader(`{}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Delete non-existent
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/version"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HealthHandler responds with basic service metadata for uptime checks.
|
||||
func HealthHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"service": version.Name,
|
||||
"version": version.Version,
|
||||
"git_commit": version.GitCommit,
|
||||
"build_time": version.BuildTime,
|
||||
})
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHealthHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/health", HealthHandler)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ok", resp["status"])
|
||||
assert.NotEmpty(t, resp["version"])
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
// ImportHandler handles Caddyfile import operations.
|
||||
type ImportHandler struct {
|
||||
db *gorm.DB
|
||||
proxyHostSvc *services.ProxyHostService
|
||||
importerservice *caddy.Importer
|
||||
importDir string
|
||||
mountPath string
|
||||
}
|
||||
|
||||
// NewImportHandler creates a new import handler.
|
||||
func NewImportHandler(db *gorm.DB, caddyBinary, importDir, mountPath string) *ImportHandler {
|
||||
return &ImportHandler{
|
||||
db: db,
|
||||
proxyHostSvc: services.NewProxyHostService(db),
|
||||
importerservice: caddy.NewImporter(caddyBinary),
|
||||
importDir: importDir,
|
||||
mountPath: mountPath,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers import-related routes.
|
||||
func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/import/status", h.GetStatus)
|
||||
router.GET("/import/preview", h.GetPreview)
|
||||
router.POST("/import/upload", h.Upload)
|
||||
router.POST("/import/commit", h.Commit)
|
||||
router.DELETE("/import/cancel", h.Cancel)
|
||||
}
|
||||
|
||||
// GetStatus returns current import session status.
|
||||
func (h *ImportHandler) GetStatus(c *gin.Context) {
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
First(&session).Error
|
||||
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusOK, gin.H{"has_pending": false})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"has_pending": true,
|
||||
"session": gin.H{
|
||||
"id": session.UUID,
|
||||
"state": session.Status,
|
||||
"created_at": session.CreatedAt,
|
||||
"updated_at": session.UpdatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetPreview returns parsed hosts and conflicts for review.
|
||||
func (h *ImportHandler) GetPreview(c *gin.Context) {
|
||||
var session models.ImportSession
|
||||
err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
|
||||
Order("created_at DESC").
|
||||
First(&session).Error
|
||||
|
||||
if err == nil {
|
||||
// DB session found
|
||||
var result caddy.ImportResult
|
||||
if err := json.Unmarshal([]byte(session.ParsedData), &result); err == nil {
|
||||
// Update status to reviewing
|
||||
session.Status = "reviewing"
|
||||
h.db.Save(&session)
|
||||
|
||||
// Read original Caddyfile content if available
|
||||
var caddyfileContent string
|
||||
if session.SourceFile != "" {
|
||||
if content, err := os.ReadFile(session.SourceFile); err == nil {
|
||||
caddyfileContent = string(content)
|
||||
} else {
|
||||
backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile))
|
||||
if content, err := os.ReadFile(backupPath); err == nil {
|
||||
caddyfileContent = string(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session": gin.H{
|
||||
"id": session.UUID,
|
||||
"state": session.Status,
|
||||
"created_at": session.CreatedAt,
|
||||
"updated_at": session.UpdatedAt,
|
||||
"source_file": session.SourceFile,
|
||||
},
|
||||
"preview": result,
|
||||
"caddyfile_content": caddyfileContent,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No DB session found or failed to parse session. Try transient preview from mountPath.
|
||||
if h.mountPath != "" {
|
||||
if _, err := os.Stat(h.mountPath); err == nil {
|
||||
// Parse mounted Caddyfile transiently
|
||||
transient, err := h.importerservice.ImportFile(h.mountPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build a transient session id (not persisted)
|
||||
sid := uuid.NewString()
|
||||
var caddyfileContent string
|
||||
if content, err := os.ReadFile(h.mountPath); err == nil {
|
||||
caddyfileContent = string(content)
|
||||
}
|
||||
|
||||
// Check for conflicts with existing hosts and append raw domain names
|
||||
existingHosts, _ := h.proxyHostSvc.List()
|
||||
existingDomains := make(map[string]bool)
|
||||
for _, eh := range existingHosts {
|
||||
existingDomains[eh.DomainNames] = true
|
||||
}
|
||||
for _, ph := range transient.Hosts {
|
||||
if existingDomains[ph.DomainNames] {
|
||||
transient.Conflicts = append(transient.Conflicts, ph.DomainNames)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath},
|
||||
"preview": transient,
|
||||
"caddyfile_content": caddyfileContent,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
|
||||
}
|
||||
|
||||
// Upload handles manual Caddyfile upload or paste.
|
||||
func (h *ImportHandler) Upload(c *gin.Context) {
|
||||
var req struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save upload to import/uploads/<uuid>.caddyfile and return transient preview (do not persist yet)
|
||||
sid := uuid.NewString()
|
||||
uploadsDir := filepath.Join(h.importDir, "uploads")
|
||||
if err := os.MkdirAll(uploadsDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
|
||||
return
|
||||
}
|
||||
|
||||
tempPath := filepath.Join(uploadsDir, fmt.Sprintf("%s.caddyfile", sid))
|
||||
if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse uploaded file transiently
|
||||
result, err := h.importerservice.ImportFile(tempPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Check for conflicts with existing hosts and append raw domain names
|
||||
existingHosts, _ := h.proxyHostSvc.List()
|
||||
existingDomains := make(map[string]bool)
|
||||
for _, eh := range existingHosts {
|
||||
existingDomains[eh.DomainNames] = true
|
||||
}
|
||||
for _, ph := range result.Hosts {
|
||||
if existingDomains[ph.DomainNames] {
|
||||
result.Conflicts = append(result.Conflicts, ph.DomainNames)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"session": gin.H{"id": sid, "state": "transient", "source_file": tempPath},
|
||||
"preview": result,
|
||||
})
|
||||
}
|
||||
|
||||
// Commit finalizes the import with user's conflict resolutions.
|
||||
func (h *ImportHandler) Commit(c *gin.Context) {
|
||||
var req struct {
|
||||
SessionUUID string `json:"session_uuid" binding:"required"`
|
||||
Resolutions map[string]string `json:"resolutions"` // domain -> action (skip, rename, merge)
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find a DB-backed session first
|
||||
var session models.ImportSession
|
||||
var result *caddy.ImportResult
|
||||
if err := h.db.Where("uuid = ? AND status = ?", req.SessionUUID, "reviewing").First(&session).Error; err == nil {
|
||||
// DB session found
|
||||
if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// No DB session: check for uploaded temp file
|
||||
uploadsPath := filepath.Join(h.importDir, "uploads", fmt.Sprintf("%s.caddyfile", req.SessionUUID))
|
||||
if _, err := os.Stat(uploadsPath); err == nil {
|
||||
r, err := h.importerservice.ImportFile(uploadsPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse uploaded file"})
|
||||
return
|
||||
}
|
||||
result = r
|
||||
// We'll create a committed DB session after applying
|
||||
session = models.ImportSession{UUID: req.SessionUUID, SourceFile: uploadsPath}
|
||||
} else if h.mountPath != "" {
|
||||
if _, err := os.Stat(h.mountPath); err == nil {
|
||||
r, err := h.importerservice.ImportFile(h.mountPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
|
||||
return
|
||||
}
|
||||
result = r
|
||||
session = models.ImportSession{UUID: req.SessionUUID, SourceFile: h.mountPath}
|
||||
} else {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Convert parsed hosts to ProxyHost models
|
||||
proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
|
||||
log.Printf("Import Commit: Parsed %d hosts, converted to %d proxy hosts", len(result.Hosts), len(proxyHosts))
|
||||
|
||||
created := 0
|
||||
skipped := 0
|
||||
errors := []string{}
|
||||
|
||||
for _, host := range proxyHosts {
|
||||
action := req.Resolutions[host.DomainNames]
|
||||
|
||||
if action == "skip" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if action == "rename" {
|
||||
host.DomainNames = host.DomainNames + "-imported"
|
||||
}
|
||||
|
||||
host.UUID = uuid.NewString()
|
||||
|
||||
if err := h.proxyHostSvc.Create(&host); err != nil {
|
||||
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
|
||||
errors = append(errors, errMsg)
|
||||
log.Printf("Import Commit Error: %s", errMsg)
|
||||
} else {
|
||||
created++
|
||||
log.Printf("Import Commit Success: Created host %s", host.DomainNames)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist an import session record now that user confirmed
|
||||
now := time.Now()
|
||||
session.Status = "committed"
|
||||
session.CommittedAt = &now
|
||||
session.UserResolutions = string(mustMarshal(req.Resolutions))
|
||||
// If ParsedData/ConflictReport not set, fill from result
|
||||
if session.ParsedData == "" {
|
||||
session.ParsedData = string(mustMarshal(result))
|
||||
}
|
||||
if session.ConflictReport == "" {
|
||||
session.ConflictReport = string(mustMarshal(result.Conflicts))
|
||||
}
|
||||
if err := h.db.Save(&session).Error; err != nil {
|
||||
log.Printf("Warning: failed to save import session: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"created": created,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
})
|
||||
}
|
||||
|
||||
// Cancel discards a pending import session.
|
||||
func (h *ImportHandler) Cancel(c *gin.Context) {
|
||||
sessionUUID := c.Query("session_uuid")
|
||||
if sessionUUID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
|
||||
return
|
||||
}
|
||||
|
||||
var session models.ImportSession
|
||||
if err := h.db.Where("uuid = ?", sessionUUID).First(&session).Error; err == nil {
|
||||
session.Status = "rejected"
|
||||
h.db.Save(&session)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
|
||||
return
|
||||
}
|
||||
|
||||
// If no DB session, check for uploaded temp file and delete it
|
||||
uploadsPath := filepath.Join(h.importDir, "uploads", fmt.Sprintf("%s.caddyfile", sessionUUID))
|
||||
if _, err := os.Stat(uploadsPath); err == nil {
|
||||
os.Remove(uploadsPath)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"})
|
||||
return
|
||||
}
|
||||
|
||||
// If neither exists, return not found
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
}
|
||||
|
||||
// processImport handles the import logic for both mounted and uploaded files.
|
||||
func (h *ImportHandler) processImport(caddyfilePath, originalName string) error {
|
||||
// Validate Caddy binary
|
||||
if err := h.importerservice.ValidateCaddyBinary(); err != nil {
|
||||
return fmt.Errorf("caddy binary not available: %w", err)
|
||||
}
|
||||
|
||||
// Parse and extract hosts
|
||||
result, err := h.importerservice.ImportFile(caddyfilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("import failed: %w", err)
|
||||
}
|
||||
|
||||
// Check for conflicts with existing hosts
|
||||
existingHosts, _ := h.proxyHostSvc.List()
|
||||
existingDomains := make(map[string]bool)
|
||||
for _, host := range existingHosts {
|
||||
existingDomains[host.DomainNames] = true
|
||||
}
|
||||
|
||||
for _, parsed := range result.Hosts {
|
||||
if existingDomains[parsed.DomainNames] {
|
||||
// Append the raw domain name so frontend can match conflicts against domain strings
|
||||
result.Conflicts = append(result.Conflicts, parsed.DomainNames)
|
||||
}
|
||||
}
|
||||
|
||||
// Create import session
|
||||
session := models.ImportSession{
|
||||
UUID: uuid.NewString(),
|
||||
SourceFile: originalName,
|
||||
Status: "pending",
|
||||
ParsedData: string(mustMarshal(result)),
|
||||
ConflictReport: string(mustMarshal(result.Conflicts)),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&session).Error; err != nil {
|
||||
return fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
// Backup original file
|
||||
if _, err := caddy.BackupCaddyfile(caddyfilePath, filepath.Join(h.importDir, "backups")); err != nil {
|
||||
// Non-fatal, log and continue
|
||||
fmt.Printf("Warning: failed to backup Caddyfile: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckMountedImport checks for mounted Caddyfile on startup.
|
||||
func CheckMountedImport(db *gorm.DB, mountPath, caddyBinary, importDir string) error {
|
||||
if _, err := os.Stat(mountPath); os.IsNotExist(err) {
|
||||
// If mount is gone, remove any pending/reviewing sessions created previously for this mount
|
||||
db.Where("source_file = ? AND status IN ?", mountPath, []string{"pending", "reviewing"}).Delete(&models.ImportSession{})
|
||||
return nil // No mounted file, nothing to import
|
||||
}
|
||||
|
||||
// Check if already processed (includes committed to avoid re-imports)
|
||||
var count int64
|
||||
db.Model(&models.ImportSession{}).Where("source_file = ? AND status IN ?",
|
||||
mountPath, []string{"pending", "reviewing", "committed"}).Count(&count)
|
||||
|
||||
if count > 0 {
|
||||
return nil // Already processed
|
||||
}
|
||||
|
||||
// Do not create a DB session automatically for mounted imports; preview will be transient.
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustMarshal(v interface{}) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func setupImportTestDB(t *testing.T) *gorm.DB {
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect to test database")
|
||||
}
|
||||
db.AutoMigrate(&models.ImportSession{}, &models.ProxyHost{}, &models.Location{})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestImportHandler_GetStatus(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
|
||||
// Case 1: No active session
|
||||
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
|
||||
router := gin.New()
|
||||
router.GET("/import/status", handler.GetStatus)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/import/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, false, resp["has_pending"])
|
||||
|
||||
// Case 2: Active session
|
||||
session := models.ImportSession{
|
||||
UUID: uuid.NewString(),
|
||||
Status: "pending",
|
||||
ParsedData: `{"hosts": []}`,
|
||||
}
|
||||
db.Create(&session)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
err = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, true, resp["has_pending"])
|
||||
}
|
||||
|
||||
func TestImportHandler_GetPreview(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
|
||||
router := gin.New()
|
||||
router.GET("/import/preview", handler.GetPreview)
|
||||
|
||||
// Case 1: No session
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/import/preview", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Case 2: Active session
|
||||
session := models.ImportSession{
|
||||
UUID: uuid.NewString(),
|
||||
Status: "pending",
|
||||
ParsedData: `{"hosts": [{"domain_names": "example.com"}]}`,
|
||||
}
|
||||
db.Create(&session)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/import/preview", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &result)
|
||||
|
||||
preview := result["preview"].(map[string]interface{})
|
||||
hosts := preview["hosts"].([]interface{})
|
||||
assert.Len(t, hosts, 1)
|
||||
|
||||
// Verify status changed to reviewing
|
||||
var updatedSession models.ImportSession
|
||||
db.First(&updatedSession, session.ID)
|
||||
assert.Equal(t, "reviewing", updatedSession.Status)
|
||||
}
|
||||
|
||||
func TestImportHandler_Cancel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
|
||||
router := gin.New()
|
||||
router.DELETE("/import/cancel", handler.Cancel)
|
||||
|
||||
session := models.ImportSession{
|
||||
UUID: "test-uuid",
|
||||
Status: "pending",
|
||||
}
|
||||
db.Create(&session)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=test-uuid", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var updatedSession models.ImportSession
|
||||
db.First(&updatedSession, session.ID)
|
||||
assert.Equal(t, "rejected", updatedSession.Status)
|
||||
}
|
||||
|
||||
func TestImportHandler_Commit(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
|
||||
router := gin.New()
|
||||
router.POST("/import/commit", handler.Commit)
|
||||
|
||||
session := models.ImportSession{
|
||||
UUID: "test-uuid",
|
||||
Status: "reviewing",
|
||||
ParsedData: `{"hosts": [{"domain_names": "example.com", "forward_host": "127.0.0.1", "forward_port": 8080}]}`,
|
||||
}
|
||||
db.Create(&session)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"session_uuid": "test-uuid",
|
||||
"resolutions": map[string]string{
|
||||
"example.com": "import",
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify host created
|
||||
var host models.ProxyHost
|
||||
err := db.Where("domain_names = ?", "example.com").First(&host).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "127.0.0.1", host.ForwardHost)
|
||||
|
||||
// Verify session committed
|
||||
var updatedSession models.ImportSession
|
||||
db.First(&updatedSession, session.ID)
|
||||
assert.Equal(t, "committed", updatedSession.Status)
|
||||
}
|
||||
|
||||
func TestImportHandler_Upload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
|
||||
// Use fake caddy script
|
||||
cwd, _ := os.Getwd()
|
||||
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
|
||||
os.Chmod(fakeCaddy, 0755)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
|
||||
router := gin.New()
|
||||
router.POST("/import/upload", handler.Upload)
|
||||
|
||||
payload := map[string]string{
|
||||
"content": "example.com",
|
||||
"filename": "Caddyfile",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// The fake caddy script returns empty JSON, so import might fail or succeed with empty result
|
||||
// But processImport calls ImportFile which calls ParseCaddyfile which calls caddy adapt
|
||||
// fake_caddy.sh echoes `{"apps":{}}`
|
||||
// ExtractHosts will return empty result
|
||||
// processImport should succeed
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestImportHandler_GetPreview_WithContent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
|
||||
router := gin.New()
|
||||
router.GET("/import/preview", handler.GetPreview)
|
||||
|
||||
// Case: Active session with source file
|
||||
content := "example.com {\n reverse_proxy localhost:8080\n}"
|
||||
sourceFile := filepath.Join(tmpDir, "source.caddyfile")
|
||||
err := os.WriteFile(sourceFile, []byte(content), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Case: Active session with source file
|
||||
session := models.ImportSession{
|
||||
UUID: uuid.NewString(),
|
||||
Status: "pending",
|
||||
ParsedData: `{"hosts": []}`,
|
||||
SourceFile: sourceFile,
|
||||
}
|
||||
db.Create(&session)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/import/preview", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &result)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, content, result["caddyfile_content"])
|
||||
}
|
||||
|
||||
func TestImportHandler_Commit_Errors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
|
||||
router := gin.New()
|
||||
router.POST("/import/commit", handler.Commit)
|
||||
|
||||
// Case 1: Invalid JSON
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBufferString("invalid"))
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// Case 2: Session not found
|
||||
payload := map[string]interface{}{
|
||||
"session_uuid": "non-existent",
|
||||
"resolutions": map[string]string{},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Case 3: Invalid ParsedData
|
||||
session := models.ImportSession{
|
||||
UUID: "invalid-data-uuid",
|
||||
Status: "reviewing",
|
||||
ParsedData: "invalid-json",
|
||||
}
|
||||
db.Create(&session)
|
||||
|
||||
payload = map[string]interface{}{
|
||||
"session_uuid": "invalid-data-uuid",
|
||||
"resolutions": map[string]string{},
|
||||
}
|
||||
body, _ = json.Marshal(payload)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
|
||||
func TestImportHandler_Cancel_Errors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
|
||||
router := gin.New()
|
||||
router.DELETE("/import/cancel", handler.Cancel)
|
||||
|
||||
// Case 1: Session not found
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestCheckMountedImport(t *testing.T) {
|
||||
db := setupImportTestDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
|
||||
|
||||
// Use fake caddy script
|
||||
cwd, _ := os.Getwd()
|
||||
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy.sh")
|
||||
os.Chmod(fakeCaddy, 0755)
|
||||
|
||||
// Case 1: File does not exist
|
||||
err := handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Case 2: File exists, not processed
|
||||
err = os.WriteFile(mountPath, []byte("example.com"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if session created (transient preview behavior: no DB session should be created)
|
||||
var count int64
|
||||
db.Model(&models.ImportSession{}).Where("source_file = ?", mountPath).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
|
||||
// Case 3: Already processed
|
||||
err = handlers.CheckMountedImport(db, mountPath, fakeCaddy, tmpDir)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestImportHandler_Upload_Failure(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
|
||||
// Use fake caddy script that fails
|
||||
cwd, _ := os.Getwd()
|
||||
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_fail.sh")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
|
||||
router := gin.New()
|
||||
router.POST("/import/upload", handler.Upload)
|
||||
|
||||
payload := map[string]string{
|
||||
"content": "invalid caddyfile",
|
||||
"filename": "Caddyfile",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
// The error message comes from processImport -> ImportFile -> "import failed: ..."
|
||||
assert.Contains(t, resp["error"], "import failed")
|
||||
}
|
||||
|
||||
func TestImportHandler_Upload_Conflict(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
|
||||
// Pre-create a host to cause conflict
|
||||
db.Create(&models.ProxyHost{
|
||||
DomainNames: "example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 9090,
|
||||
})
|
||||
|
||||
// Use fake caddy script that returns hosts
|
||||
cwd, _ := os.Getwd()
|
||||
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
|
||||
router := gin.New()
|
||||
router.POST("/import/upload", handler.Upload)
|
||||
|
||||
payload := map[string]string{
|
||||
"content": "example.com",
|
||||
"filename": "Caddyfile",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify response contains conflict in preview (upload is transient)
|
||||
var resp map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NoError(t, err)
|
||||
preview := resp["preview"].(map[string]interface{})
|
||||
conflicts := preview["conflicts"].([]interface{})
|
||||
found := false
|
||||
for _, c := range conflicts {
|
||||
if c.(string) == "example.com" || strings.Contains(c.(string), "example.com") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "expected conflict for example.com in preview")
|
||||
}
|
||||
|
||||
func TestImportHandler_GetPreview_BackupContent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
handler := handlers.NewImportHandler(db, "echo", tmpDir, "")
|
||||
router := gin.New()
|
||||
router.GET("/import/preview", handler.GetPreview)
|
||||
|
||||
// Create backup file
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
os.MkdirAll(backupDir, 0755)
|
||||
content := "backup content"
|
||||
backupFile := filepath.Join(backupDir, "source.caddyfile")
|
||||
os.WriteFile(backupFile, []byte(content), 0644)
|
||||
|
||||
// Case: Active session with missing source file but existing backup
|
||||
session := models.ImportSession{
|
||||
UUID: uuid.NewString(),
|
||||
Status: "pending",
|
||||
ParsedData: `{"hosts": []}`,
|
||||
SourceFile: "/non/existent/source.caddyfile",
|
||||
}
|
||||
db.Create(&session)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/import/preview", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var result map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &result)
|
||||
|
||||
assert.Equal(t, content, result["caddyfile_content"])
|
||||
}
|
||||
|
||||
func TestImportHandler_RegisterRoutes(t *testing.T) {
|
||||
db := setupImportTestDB(t)
|
||||
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
|
||||
router := gin.New()
|
||||
api := router.Group("/api/v1")
|
||||
handler.RegisterRoutes(api)
|
||||
|
||||
// Verify routes exist by making requests
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/import/status", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.NotEqual(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestImportHandler_GetPreview_TransientMount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
|
||||
|
||||
// Create a mounted Caddyfile
|
||||
content := "example.com"
|
||||
err := os.WriteFile(mountPath, []byte(content), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Use fake caddy script
|
||||
cwd, _ := os.Getwd()
|
||||
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
|
||||
os.Chmod(fakeCaddy, 0755)
|
||||
|
||||
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
|
||||
router := gin.New()
|
||||
router.GET("/import/preview", handler.GetPreview)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/import/preview", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Response body: %s", w.Body.String())
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(w.Body.Bytes(), &result)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify transient session
|
||||
session, ok := result["session"].(map[string]interface{})
|
||||
assert.True(t, ok, "session should be present in response")
|
||||
assert.Equal(t, "transient", session["state"])
|
||||
assert.Equal(t, mountPath, session["source_file"])
|
||||
|
||||
// Verify preview contains hosts
|
||||
preview, ok := result["preview"].(map[string]interface{})
|
||||
assert.True(t, ok, "preview should be present in response")
|
||||
assert.NotNil(t, preview["hosts"])
|
||||
|
||||
// Verify content
|
||||
assert.Equal(t, content, result["caddyfile_content"])
|
||||
}
|
||||
|
||||
func TestImportHandler_Commit_TransientUpload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Use fake caddy script
|
||||
cwd, _ := os.Getwd()
|
||||
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
|
||||
os.Chmod(fakeCaddy, 0755)
|
||||
|
||||
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
|
||||
router := gin.New()
|
||||
router.POST("/import/upload", handler.Upload)
|
||||
router.POST("/import/commit", handler.Commit)
|
||||
|
||||
// First upload to create transient session
|
||||
uploadPayload := map[string]string{
|
||||
"content": "uploaded.com",
|
||||
"filename": "Caddyfile",
|
||||
}
|
||||
uploadBody, _ := json.Marshal(uploadPayload)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(uploadBody))
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Extract session ID
|
||||
var uploadResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &uploadResp)
|
||||
session := uploadResp["session"].(map[string]interface{})
|
||||
sessionID := session["id"].(string)
|
||||
|
||||
// Now commit the transient upload
|
||||
commitPayload := map[string]interface{}{
|
||||
"session_uuid": sessionID,
|
||||
"resolutions": map[string]string{
|
||||
"uploaded.com": "import",
|
||||
},
|
||||
}
|
||||
commitBody, _ := json.Marshal(commitPayload)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(commitBody))
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify host created
|
||||
var host models.ProxyHost
|
||||
err := db.Where("domain_names = ?", "uploaded.com").First(&host).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "uploaded.com", host.DomainNames)
|
||||
|
||||
// Verify session persisted
|
||||
var importSession models.ImportSession
|
||||
err = db.Where("uuid = ?", sessionID).First(&importSession).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "committed", importSession.Status)
|
||||
}
|
||||
|
||||
func TestImportHandler_Commit_TransientMount(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
mountPath := filepath.Join(tmpDir, "mounted.caddyfile")
|
||||
|
||||
// Create a mounted Caddyfile
|
||||
err := os.WriteFile(mountPath, []byte("mounted.com"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Use fake caddy script
|
||||
cwd, _ := os.Getwd()
|
||||
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
|
||||
os.Chmod(fakeCaddy, 0755)
|
||||
|
||||
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, mountPath)
|
||||
router := gin.New()
|
||||
router.POST("/import/commit", handler.Commit)
|
||||
|
||||
// Commit the mount with a random session ID (transient)
|
||||
sessionID := uuid.NewString()
|
||||
commitPayload := map[string]interface{}{
|
||||
"session_uuid": sessionID,
|
||||
"resolutions": map[string]string{
|
||||
"mounted.com": "import",
|
||||
},
|
||||
}
|
||||
commitBody, _ := json.Marshal(commitPayload)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/import/commit", bytes.NewBuffer(commitBody))
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify host created
|
||||
var host models.ProxyHost
|
||||
err = db.Where("domain_names = ?", "mounted.com").First(&host).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify session persisted
|
||||
var importSession models.ImportSession
|
||||
err = db.Where("uuid = ?", sessionID).First(&importSession).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "committed", importSession.Status)
|
||||
}
|
||||
|
||||
func TestImportHandler_Cancel_TransientUpload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Use fake caddy script
|
||||
cwd, _ := os.Getwd()
|
||||
fakeCaddy := filepath.Join(cwd, "testdata", "fake_caddy_hosts.sh")
|
||||
os.Chmod(fakeCaddy, 0755)
|
||||
|
||||
handler := handlers.NewImportHandler(db, fakeCaddy, tmpDir, "")
|
||||
router := gin.New()
|
||||
router.POST("/import/upload", handler.Upload)
|
||||
router.DELETE("/import/cancel", handler.Cancel)
|
||||
|
||||
// Upload to create transient file
|
||||
uploadPayload := map[string]string{
|
||||
"content": "test.com",
|
||||
"filename": "Caddyfile",
|
||||
}
|
||||
uploadBody, _ := json.Marshal(uploadPayload)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer(uploadBody))
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Extract session ID and file path
|
||||
var uploadResp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &uploadResp)
|
||||
session := uploadResp["session"].(map[string]interface{})
|
||||
sessionID := session["id"].(string)
|
||||
sourceFile := session["source_file"].(string)
|
||||
|
||||
// Verify file exists
|
||||
_, err := os.Stat(sourceFile)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Cancel should delete the file
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid="+sessionID, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify file deleted
|
||||
_, err = os.Stat(sourceFile)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestImportHandler_Errors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupImportTestDB(t)
|
||||
handler := handlers.NewImportHandler(db, "echo", "/tmp", "")
|
||||
router := gin.New()
|
||||
router.POST("/import/upload", handler.Upload)
|
||||
router.POST("/import/commit", handler.Commit)
|
||||
router.DELETE("/import/cancel", handler.Cancel)
|
||||
|
||||
// Upload - Invalid JSON
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/import/upload", bytes.NewBuffer([]byte("invalid")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// Commit - Invalid JSON
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer([]byte("invalid")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// Commit - Session Not Found
|
||||
body := map[string]interface{}{
|
||||
"session_uuid": "non-existent",
|
||||
"resolutions": map[string]string{},
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/import/commit", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Cancel - Session Not Found
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("DELETE", "/import/cancel?session_uuid=non-existent", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type LogsHandler struct {
|
||||
service *services.LogService
|
||||
}
|
||||
|
||||
func NewLogsHandler(service *services.LogService) *LogsHandler {
|
||||
return &LogsHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *LogsHandler) List(c *gin.Context) {
|
||||
logs, err := h.service.ListLogs()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list logs"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, logs)
|
||||
}
|
||||
|
||||
func (h *LogsHandler) Read(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
|
||||
// Parse query parameters
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
filter := models.LogFilter{
|
||||
Search: c.Query("search"),
|
||||
Host: c.Query("host"),
|
||||
Status: c.Query("status"),
|
||||
Level: c.Query("level"),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Sort: c.DefaultQuery("sort", "desc"),
|
||||
}
|
||||
|
||||
logs, total, err := h.service.QueryLogs(filename, filter)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read log"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"filename": filename,
|
||||
"logs": logs,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *LogsHandler) Download(c *gin.Context) {
|
||||
filename := c.Param("filename")
|
||||
path, err := h.service.GetLogPath(filename)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "invalid filename") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a temporary file to serve a consistent snapshot
|
||||
// This prevents Content-Length mismatches if the live log file grows during download
|
||||
tmpFile, err := os.CreateTemp("", "cpmp-log-*.log")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp file"})
|
||||
return
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
tmpFile.Close()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open log file"})
|
||||
return
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
if _, err := io.Copy(tmpFile, srcFile); err != nil {
|
||||
tmpFile.Close()
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to copy log file"})
|
||||
return
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.File(tmpFile.Name())
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupLogsTest(t *testing.T) (*gin.Engine, *services.LogService, string) {
|
||||
t.Helper()
|
||||
|
||||
// Create temp directories
|
||||
tmpDir, err := os.MkdirTemp("", "cpm-logs-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
// LogService expects LogDir to be .../data/logs
|
||||
// It derives it from cfg.DatabasePath
|
||||
|
||||
dataDir := filepath.Join(tmpDir, "data")
|
||||
err = os.MkdirAll(dataDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbPath := filepath.Join(dataDir, "cpm.db")
|
||||
|
||||
// Create logs dir
|
||||
logsDir := filepath.Join(dataDir, "logs")
|
||||
err = os.MkdirAll(logsDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create dummy log files with JSON content
|
||||
log1 := `{"level":"info","ts":1600000000,"msg":"request handled","request":{"method":"GET","host":"example.com","uri":"/","remote_ip":"1.2.3.4"},"status":200}`
|
||||
log2 := `{"level":"error","ts":1600000060,"msg":"error handled","request":{"method":"POST","host":"api.example.com","uri":"/submit","remote_ip":"5.6.7.8"},"status":500}`
|
||||
|
||||
err = os.WriteFile(filepath.Join(logsDir, "access.log"), []byte(log1+"\n"+log2+"\n"), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(logsDir, "cpmp.log"), []byte("app log line 1\napp log line 2"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
svc := services.NewLogService(cfg)
|
||||
h := NewLogsHandler(svc)
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
logs := api.Group("/logs")
|
||||
logs.GET("", h.List)
|
||||
logs.GET("/:filename", h.Read)
|
||||
logs.GET("/:filename/download", h.Download)
|
||||
|
||||
return r, svc, tmpDir
|
||||
}
|
||||
|
||||
func TestLogsLifecycle(t *testing.T) {
|
||||
router, _, tmpDir := setupLogsTest(t)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// 1. List logs
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/logs", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var logs []services.LogFile
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &logs)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, logs, 2) // access.log and cpmp.log
|
||||
|
||||
// Verify content of one log file
|
||||
found := false
|
||||
for _, l := range logs {
|
||||
if l.Name == "access.log" {
|
||||
found = true
|
||||
require.Greater(t, l.Size, int64(0))
|
||||
}
|
||||
}
|
||||
require.True(t, found)
|
||||
|
||||
// 2. Read log
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log?limit=2", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var content struct {
|
||||
Filename string `json:"filename"`
|
||||
Logs []interface{} `json:"logs"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
err = json.Unmarshal(resp.Body.Bytes(), &content)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, content.Logs, 2)
|
||||
|
||||
// 3. Download log
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/access.log/download", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
require.Contains(t, resp.Body.String(), "request handled")
|
||||
|
||||
// 4. Read non-existent log
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
|
||||
// 5. Download non-existent log
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs/missing.log/download", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
|
||||
// 6. List logs error (delete directory)
|
||||
os.RemoveAll(filepath.Join(tmpDir, "data", "logs"))
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/logs", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
// ListLogs returns empty list if dir doesn't exist, so it should be 200 OK with empty list
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
var emptyLogs []services.LogFile
|
||||
err = json.Unmarshal(resp.Body.Bytes(), &emptyLogs)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, emptyLogs)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NotificationHandler struct {
|
||||
service *services.NotificationService
|
||||
}
|
||||
|
||||
func NewNotificationHandler(service *services.NotificationService) *NotificationHandler {
|
||||
return &NotificationHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) List(c *gin.Context) {
|
||||
unreadOnly := c.Query("unread") == "true"
|
||||
notifications, err := h.service.List(unreadOnly)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, notifications)
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.service.MarkAsRead(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"})
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
|
||||
if err := h.service.MarkAllAsRead(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark all notifications as read"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"})
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupNotificationTestDB() *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect to test database")
|
||||
}
|
||||
db.AutoMigrate(&models.Notification{})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNotificationHandler_List(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationTestDB()
|
||||
|
||||
// Seed data
|
||||
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
|
||||
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: true})
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
router := gin.New()
|
||||
router.GET("/notifications", handler.List)
|
||||
|
||||
// Test List All
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/notifications", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var notifications []models.Notification
|
||||
err := json.Unmarshal(w.Body.Bytes(), ¬ifications)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, notifications, 2)
|
||||
|
||||
// Test List Unread
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("GET", "/notifications?unread=true", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
err = json.Unmarshal(w.Body.Bytes(), ¬ifications)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, notifications, 1)
|
||||
assert.False(t, notifications[0].Read)
|
||||
}
|
||||
|
||||
func TestNotificationHandler_MarkAsRead(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationTestDB()
|
||||
|
||||
// Seed data
|
||||
notif := &models.Notification{Title: "Test 1", Message: "Msg 1", Read: false}
|
||||
db.Create(notif)
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
router := gin.New()
|
||||
router.POST("/notifications/:id/read", handler.MarkAsRead)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/notifications/"+notif.ID+"/read", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var updated models.Notification
|
||||
db.First(&updated, "id = ?", notif.ID)
|
||||
assert.True(t, updated.Read)
|
||||
}
|
||||
|
||||
func TestNotificationHandler_MarkAllAsRead(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationTestDB()
|
||||
|
||||
// Seed data
|
||||
db.Create(&models.Notification{Title: "Test 1", Message: "Msg 1", Read: false})
|
||||
db.Create(&models.Notification{Title: "Test 2", Message: "Msg 2", Read: false})
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
router := gin.New()
|
||||
router.POST("/notifications/read-all", handler.MarkAllAsRead)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/notifications/read-all", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var count int64
|
||||
db.Model(&models.Notification{}).Where("read = ?", false).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestNotificationHandler_MarkAllAsRead_Error(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationTestDB()
|
||||
service := services.NewNotificationService(db)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/notifications/read-all", handler.MarkAllAsRead)
|
||||
|
||||
// Close DB to force error
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", "/notifications/read-all", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
|
||||
func TestNotificationHandler_DBError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupNotificationTestDB()
|
||||
service := services.NewNotificationService(db)
|
||||
handler := handlers.NewNotificationHandler(service)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/notifications/:id/read", handler.MarkAsRead)
|
||||
|
||||
// Close DB to force error
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", "/notifications/1/read", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type NotificationProviderHandler struct {
|
||||
service *services.NotificationService
|
||||
}
|
||||
|
||||
func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler {
|
||||
return &NotificationProviderHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *NotificationProviderHandler) List(c *gin.Context) {
|
||||
providers, err := h.service.ListProviders()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, providers)
|
||||
}
|
||||
|
||||
func (h *NotificationProviderHandler) Create(c *gin.Context) {
|
||||
var provider models.NotificationProvider
|
||||
if err := c.ShouldBindJSON(&provider); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.CreateProvider(&provider); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, provider)
|
||||
}
|
||||
|
||||
func (h *NotificationProviderHandler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var provider models.NotificationProvider
|
||||
if err := c.ShouldBindJSON(&provider); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
provider.ID = id
|
||||
|
||||
if err := h.service.UpdateProvider(&provider); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, provider)
|
||||
}
|
||||
|
||||
func (h *NotificationProviderHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.service.DeleteProvider(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Provider deleted"})
|
||||
}
|
||||
|
||||
func (h *NotificationProviderHandler) Test(c *gin.Context) {
|
||||
var provider models.NotificationProvider
|
||||
if err := c.ShouldBindJSON(&provider); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.TestProvider(provider); err != nil {
|
||||
// Create internal notification for the failure
|
||||
h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed: %v", provider.Name, err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupNotificationProviderTest(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
|
||||
|
||||
service := services.NewNotificationService(db)
|
||||
handler := handlers.NewNotificationProviderHandler(service)
|
||||
|
||||
r := gin.Default()
|
||||
api := r.Group("/api/v1")
|
||||
providers := api.Group("/notification-providers")
|
||||
providers.GET("", handler.List)
|
||||
providers.POST("", handler.Create)
|
||||
providers.PUT("/:id", handler.Update)
|
||||
providers.DELETE("/:id", handler.Delete)
|
||||
providers.POST("/test", handler.Test)
|
||||
|
||||
return r, db
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_CRUD(t *testing.T) {
|
||||
r, db := setupNotificationProviderTest(t)
|
||||
|
||||
// 1. Create
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Test Discord",
|
||||
Type: "discord",
|
||||
URL: "https://discord.com/api/webhooks/...",
|
||||
}
|
||||
body, _ := json.Marshal(provider)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notification-providers", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
var created models.NotificationProvider
|
||||
err := json.Unmarshal(w.Body.Bytes(), &created)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, provider.Name, created.Name)
|
||||
assert.NotEmpty(t, created.ID)
|
||||
|
||||
// 2. List
|
||||
req, _ = http.NewRequest("GET", "/api/v1/notification-providers", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var list []models.NotificationProvider
|
||||
err = json.Unmarshal(w.Body.Bytes(), &list)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 1)
|
||||
|
||||
// 3. Update
|
||||
created.Name = "Updated Discord"
|
||||
body, _ = json.Marshal(created)
|
||||
req, _ = http.NewRequest("PUT", "/api/v1/notification-providers/"+created.ID, bytes.NewBuffer(body))
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var updated models.NotificationProvider
|
||||
err = json.Unmarshal(w.Body.Bytes(), &updated)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Discord", updated.Name)
|
||||
|
||||
// Verify in DB
|
||||
var dbProvider models.NotificationProvider
|
||||
db.First(&dbProvider, "id = ?", created.ID)
|
||||
assert.Equal(t, "Updated Discord", dbProvider.Name)
|
||||
|
||||
// 4. Delete
|
||||
req, _ = http.NewRequest("DELETE", "/api/v1/notification-providers/"+created.ID, nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Verify Delete
|
||||
var count int64
|
||||
db.Model(&models.NotificationProvider{}).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Test(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
// Test with invalid provider (should fail validation or service check)
|
||||
// Since we don't have a real shoutrrr backend mocked easily here without more work,
|
||||
// we expect it might fail or pass depending on service implementation.
|
||||
// Looking at service code (not shown but assumed), TestProvider likely calls shoutrrr.Send.
|
||||
// If URL is invalid, it should error.
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Type: "discord",
|
||||
URL: "invalid-url",
|
||||
}
|
||||
body, _ := json.Marshal(provider)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notification-providers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// It should probably fail with 400
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestNotificationProviderHandler_Errors(t *testing.T) {
|
||||
r, _ := setupNotificationProviderTest(t)
|
||||
|
||||
// Create Invalid JSON
|
||||
req, _ := http.NewRequest("POST", "/api/v1/notification-providers", bytes.NewBuffer([]byte("invalid")))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// Update Invalid JSON
|
||||
req, _ = http.NewRequest("PUT", "/api/v1/notification-providers/123", bytes.NewBuffer([]byte("invalid")))
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// Test Invalid JSON
|
||||
req, _ = http.NewRequest("POST", "/api/v1/notification-providers/test", bytes.NewBuffer([]byte("invalid")))
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
// ProxyHostHandler handles CRUD operations for proxy hosts.
|
||||
type ProxyHostHandler struct {
|
||||
service *services.ProxyHostService
|
||||
caddyManager *caddy.Manager
|
||||
notificationService *services.NotificationService
|
||||
}
|
||||
|
||||
// NewProxyHostHandler creates a new proxy host handler.
|
||||
func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService) *ProxyHostHandler {
|
||||
return &ProxyHostHandler{
|
||||
service: services.NewProxyHostService(db),
|
||||
caddyManager: caddyManager,
|
||||
notificationService: ns,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers proxy host routes.
|
||||
func (h *ProxyHostHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/proxy-hosts", h.List)
|
||||
router.POST("/proxy-hosts", h.Create)
|
||||
router.GET("/proxy-hosts/:uuid", h.Get)
|
||||
router.PUT("/proxy-hosts/:uuid", h.Update)
|
||||
router.DELETE("/proxy-hosts/:uuid", h.Delete)
|
||||
router.POST("/proxy-hosts/test", h.TestConnection)
|
||||
}
|
||||
|
||||
// List retrieves all proxy hosts.
|
||||
func (h *ProxyHostHandler) List(c *gin.Context) {
|
||||
hosts, err := h.service.List()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, hosts)
|
||||
}
|
||||
|
||||
// Create creates a new proxy host.
|
||||
func (h *ProxyHostHandler) Create(c *gin.Context) {
|
||||
var host models.ProxyHost
|
||||
if err := c.ShouldBindJSON(&host); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
host.UUID = uuid.NewString()
|
||||
|
||||
// Assign UUIDs to locations
|
||||
for i := range host.Locations {
|
||||
host.Locations[i].UUID = uuid.NewString()
|
||||
}
|
||||
|
||||
if err := h.service.Create(&host); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if h.caddyManager != nil {
|
||||
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
|
||||
// Rollback: delete the created host if config application fails
|
||||
fmt.Printf("Error applying config: %v\n", err) // Log to stdout
|
||||
if deleteErr := h.service.Delete(host.ID); deleteErr != nil {
|
||||
fmt.Printf("Critical: Failed to rollback host %d: %v\n", host.ID, deleteErr)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send Notification
|
||||
if h.notificationService != nil {
|
||||
h.notificationService.SendExternal(
|
||||
"proxy_host",
|
||||
"Proxy Host Created",
|
||||
fmt.Sprintf("Proxy Host %s (%s) created", host.Name, host.DomainNames),
|
||||
map[string]interface{}{
|
||||
"Name": host.Name,
|
||||
"Domains": host.DomainNames,
|
||||
"Action": "created",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, host)
|
||||
}
|
||||
|
||||
// Get retrieves a proxy host by UUID.
|
||||
func (h *ProxyHostHandler) Get(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
host, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, host)
|
||||
}
|
||||
|
||||
// Update updates an existing proxy host.
|
||||
func (h *ProxyHostHandler) Update(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
host, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(host); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Update(host); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if h.caddyManager != nil {
|
||||
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, host)
|
||||
}
|
||||
|
||||
// Delete removes a proxy host.
|
||||
func (h *ProxyHostHandler) Delete(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
host, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(host.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if h.caddyManager != nil {
|
||||
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Send Notification
|
||||
if h.notificationService != nil {
|
||||
h.notificationService.SendExternal(
|
||||
"proxy_host",
|
||||
"Proxy Host Deleted",
|
||||
fmt.Sprintf("Proxy Host %s deleted", host.Name),
|
||||
map[string]interface{}{
|
||||
"Name": host.Name,
|
||||
"Action": "deleted",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "proxy host deleted"})
|
||||
}
|
||||
|
||||
// TestConnection checks if the proxy host is reachable.
|
||||
func (h *ProxyHostHandler) TestConnection(c *gin.Context) {
|
||||
var req struct {
|
||||
ForwardHost string `json:"forward_host" binding:"required"`
|
||||
ForwardPort int `json:"forward_port" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.TestConnection(req.ForwardHost, req.ForwardPort); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Connection successful"})
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
h := NewProxyHostHandler(db, nil, ns)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
return r, db
|
||||
}
|
||||
|
||||
func TestProxyHostLifecycle(t *testing.T) {
|
||||
router, _ := setupTestRouter(t)
|
||||
|
||||
body := `{"name":"Media","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":true}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusCreated, resp.Code)
|
||||
|
||||
var created models.ProxyHost
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
|
||||
require.Equal(t, "media.example.com", created.DomainNames)
|
||||
|
||||
listReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil)
|
||||
listResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(listResp, listReq)
|
||||
require.Equal(t, http.StatusOK, listResp.Code)
|
||||
|
||||
var hosts []models.ProxyHost
|
||||
require.NoError(t, json.Unmarshal(listResp.Body.Bytes(), &hosts))
|
||||
require.Len(t, hosts, 1)
|
||||
|
||||
// Get by ID
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil)
|
||||
getResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(getResp, getReq)
|
||||
require.Equal(t, http.StatusOK, getResp.Code)
|
||||
|
||||
var fetched models.ProxyHost
|
||||
require.NoError(t, json.Unmarshal(getResp.Body.Bytes(), &fetched))
|
||||
require.Equal(t, created.UUID, fetched.UUID)
|
||||
|
||||
// Update
|
||||
updateBody := `{"name":"Media Updated","domain_names":"media.example.com","forward_scheme":"http","forward_host":"media","forward_port":32400,"enabled":false}`
|
||||
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+created.UUID, strings.NewReader(updateBody))
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateResp, updateReq)
|
||||
require.Equal(t, http.StatusOK, updateResp.Code)
|
||||
|
||||
var updated models.ProxyHost
|
||||
require.NoError(t, json.Unmarshal(updateResp.Body.Bytes(), &updated))
|
||||
require.Equal(t, "Media Updated", updated.Name)
|
||||
require.False(t, updated.Enabled)
|
||||
|
||||
// Delete
|
||||
delReq := httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+created.UUID, nil)
|
||||
delResp := httptest.NewRecorder()
|
||||
router.ServeHTTP(delResp, delReq)
|
||||
require.Equal(t, http.StatusOK, delResp.Code)
|
||||
|
||||
// Verify Delete
|
||||
getReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/"+created.UUID, nil)
|
||||
getResp2 := httptest.NewRecorder()
|
||||
router.ServeHTTP(getResp2, getReq2)
|
||||
require.Equal(t, http.StatusNotFound, getResp2.Code)
|
||||
}
|
||||
|
||||
func TestProxyHostErrors(t *testing.T) {
|
||||
// Mock Caddy Admin API that fails
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}))
|
||||
|
||||
// Setup Caddy Manager
|
||||
tmpDir := t.TempDir()
|
||||
client := caddy.NewClient(caddyServer.URL)
|
||||
manager := caddy.NewManager(client, db, tmpDir, "")
|
||||
|
||||
// Setup Handler
|
||||
ns := services.NewNotificationService(db)
|
||||
h := NewProxyHostHandler(db, manager, ns)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
// Test Create - Bind Error
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`invalid json`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
// Test Create - Apply Config Error
|
||||
body := `{"name":"Fail Host","domain_names":"fail-unique-456.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}`
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusInternalServerError, resp.Code)
|
||||
|
||||
// Create a host for Update/Delete/Get tests (manually in DB to avoid handler error)
|
||||
host := models.ProxyHost{
|
||||
UUID: uuid.NewString(),
|
||||
Name: "Existing Host",
|
||||
DomainNames: "exist.local",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
}
|
||||
db.Create(&host)
|
||||
|
||||
// Test Get - Not Found
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts/non-existent-uuid", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
|
||||
// Test Update - Not Found
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/non-existent-uuid", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
|
||||
// Test Update - Bind Error
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, strings.NewReader(`invalid json`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
// Test Update - Apply Config Error
|
||||
updateBody := `{"name":"Fail Host Update","domain_names":"fail-unique-update.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}`
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+host.UUID, strings.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusInternalServerError, resp.Code)
|
||||
|
||||
// Test Delete - Not Found
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/non-existent-uuid", nil)
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusNotFound, resp.Code)
|
||||
|
||||
// Test Delete - Apply Config Error
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+host.UUID, nil)
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusInternalServerError, resp.Code)
|
||||
|
||||
// Test TestConnection - Bind Error
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(`invalid json`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
// Test TestConnection - Connection Failure
|
||||
testBody := `{"forward_host": "invalid.host.local", "forward_port": 12345}`
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(testBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadGateway, resp.Code)
|
||||
}
|
||||
|
||||
func TestProxyHostValidation(t *testing.T) {
|
||||
router, db := setupTestRouter(t)
|
||||
|
||||
// Invalid JSON
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(`{invalid json}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
// Create a host first
|
||||
host := &models.ProxyHost{
|
||||
UUID: "valid-uuid",
|
||||
DomainNames: "valid.com",
|
||||
}
|
||||
db.Create(host)
|
||||
|
||||
// Update with invalid JSON
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/valid-uuid", strings.NewReader(`{invalid json}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
|
||||
func TestProxyHostConnection(t *testing.T) {
|
||||
router, _ := setupTestRouter(t)
|
||||
|
||||
// 1. Test Invalid Input (Missing Host)
|
||||
body := `{"forward_port": 80}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
|
||||
// 2. Test Connection Failure (Unreachable Port)
|
||||
// Use a reserved port or localhost port that is likely closed
|
||||
body = `{"forward_host": "localhost", "forward_port": 54321}`
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
// It should return 502 Bad Gateway
|
||||
require.Equal(t, http.StatusBadGateway, resp.Code)
|
||||
|
||||
// 3. Test Connection Success
|
||||
// Start a local listener
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
|
||||
addr := l.Addr().(*net.TCPAddr)
|
||||
|
||||
body = fmt.Sprintf(`{"forward_host": "%s", "forward_port": %d}`, addr.IP.String(), addr.Port)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts/test", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp = httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
func TestProxyHostHandler_List_Error(t *testing.T) {
|
||||
router, db := setupTestRouter(t)
|
||||
|
||||
// Close DB to force error
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/proxy-hosts", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusInternalServerError, resp.Code)
|
||||
}
|
||||
|
||||
func TestProxyHostWithCaddyIntegration(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == "POST" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}))
|
||||
|
||||
// Setup Caddy Manager
|
||||
tmpDir := t.TempDir()
|
||||
client := caddy.NewClient(caddyServer.URL)
|
||||
manager := caddy.NewManager(client, db, tmpDir, "")
|
||||
|
||||
// Setup Handler
|
||||
ns := services.NewNotificationService(db)
|
||||
h := NewProxyHostHandler(db, manager, ns)
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
h.RegisterRoutes(api)
|
||||
|
||||
// Test Create with Caddy Sync
|
||||
body := `{"name":"Caddy Host","domain_names":"caddy.local","forward_scheme":"http","forward_host":"localhost","forward_port":8080,"enabled":true}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/proxy-hosts", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusCreated, resp.Code)
|
||||
|
||||
// Test Update with Caddy Sync
|
||||
var createdHost models.ProxyHost
|
||||
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &createdHost))
|
||||
|
||||
updateBody := `{"name":"Updated Caddy Host","domain_names":"caddy.local","forward_scheme":"http","forward_host":"localhost","forward_port":8081,"enabled":true}`
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/proxy-hosts/"+createdHost.UUID, strings.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
// Test Delete with Caddy Sync
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/proxy-hosts/"+createdHost.UUID, nil)
|
||||
resp = httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
// RemoteServerHandler handles HTTP requests for remote server management.
|
||||
type RemoteServerHandler struct {
|
||||
service *services.RemoteServerService
|
||||
notificationService *services.NotificationService
|
||||
}
|
||||
|
||||
// NewRemoteServerHandler creates a new remote server handler.
|
||||
func NewRemoteServerHandler(db *gorm.DB, ns *services.NotificationService) *RemoteServerHandler {
|
||||
return &RemoteServerHandler{
|
||||
service: services.NewRemoteServerService(db),
|
||||
notificationService: ns,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers remote server routes.
|
||||
func (h *RemoteServerHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
router.GET("/remote-servers", h.List)
|
||||
router.POST("/remote-servers", h.Create)
|
||||
router.GET("/remote-servers/:uuid", h.Get)
|
||||
router.PUT("/remote-servers/:uuid", h.Update)
|
||||
router.DELETE("/remote-servers/:uuid", h.Delete)
|
||||
router.POST("/remote-servers/test", h.TestConnectionCustom)
|
||||
router.POST("/remote-servers/:uuid/test", h.TestConnection)
|
||||
}
|
||||
|
||||
// List retrieves all remote servers.
|
||||
func (h *RemoteServerHandler) List(c *gin.Context) {
|
||||
enabledOnly := c.Query("enabled") == "true"
|
||||
|
||||
servers, err := h.service.List(enabledOnly)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, servers)
|
||||
}
|
||||
|
||||
// Create creates a new remote server.
|
||||
func (h *RemoteServerHandler) Create(c *gin.Context) {
|
||||
var server models.RemoteServer
|
||||
if err := c.ShouldBindJSON(&server); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
server.UUID = uuid.NewString()
|
||||
|
||||
if err := h.service.Create(&server); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Send Notification
|
||||
if h.notificationService != nil {
|
||||
h.notificationService.SendExternal(
|
||||
"remote_server",
|
||||
"Remote Server Added",
|
||||
fmt.Sprintf("Remote Server %s (%s:%d) added", server.Name, server.Host, server.Port),
|
||||
map[string]interface{}{
|
||||
"Name": server.Name,
|
||||
"Host": server.Host,
|
||||
"Port": server.Port,
|
||||
"Action": "created",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, server)
|
||||
}
|
||||
|
||||
// Get retrieves a remote server by UUID.
|
||||
func (h *RemoteServerHandler) Get(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
server, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, server)
|
||||
}
|
||||
|
||||
// Update updates an existing remote server.
|
||||
func (h *RemoteServerHandler) Update(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
server, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(server); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Update(server); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, server)
|
||||
}
|
||||
|
||||
// Delete removes a remote server.
|
||||
func (h *RemoteServerHandler) Delete(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
server, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Delete(server.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Send Notification
|
||||
if h.notificationService != nil {
|
||||
h.notificationService.SendExternal(
|
||||
"remote_server",
|
||||
"Remote Server Deleted",
|
||||
fmt.Sprintf("Remote Server %s deleted", server.Name),
|
||||
map[string]interface{}{
|
||||
"Name": server.Name,
|
||||
"Action": "deleted",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// TestConnection tests the TCP connection to a remote server.
|
||||
func (h *RemoteServerHandler) TestConnection(c *gin.Context) {
|
||||
uuid := c.Param("uuid")
|
||||
|
||||
server, err := h.service.GetByUUID(uuid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Test TCP connection with 5 second timeout
|
||||
address := net.JoinHostPort(server.Host, fmt.Sprintf("%d", server.Port))
|
||||
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
|
||||
|
||||
result := gin.H{
|
||||
"server_uuid": server.UUID,
|
||||
"address": address,
|
||||
"timestamp": time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result["reachable"] = false
|
||||
result["error"] = err.Error()
|
||||
|
||||
// Update server reachability status
|
||||
server.Reachable = false
|
||||
now := time.Now().UTC()
|
||||
server.LastChecked = &now
|
||||
h.service.Update(server)
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Connection successful
|
||||
result["reachable"] = true
|
||||
result["latency_ms"] = time.Since(time.Now()).Milliseconds()
|
||||
|
||||
// Update server reachability status
|
||||
server.Reachable = true
|
||||
now := time.Now().UTC()
|
||||
server.LastChecked = &now
|
||||
h.service.Update(server)
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// TestConnectionCustom tests connectivity to a host/port provided in the body
|
||||
func (h *RemoteServerHandler) TestConnectionCustom(c *gin.Context) {
|
||||
var req struct {
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port int `json:"port" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Test TCP connection with 5 second timeout
|
||||
address := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
|
||||
|
||||
result := gin.H{
|
||||
"address": address,
|
||||
"timestamp": time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result["reachable"] = false
|
||||
result["error"] = err.Error()
|
||||
c.JSON(http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Connection successful
|
||||
result["reachable"] = true
|
||||
result["latency_ms"] = time.Since(start).Milliseconds()
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupRemoteServerTest_New(t *testing.T) (*gin.Engine, *handlers.RemoteServerHandler) {
|
||||
t.Helper()
|
||||
db := setupTestDB()
|
||||
// Ensure RemoteServer table exists
|
||||
db.AutoMigrate(&models.RemoteServer{})
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
handler := handlers.NewRemoteServerHandler(db, ns)
|
||||
|
||||
r := gin.Default()
|
||||
api := r.Group("/api/v1")
|
||||
servers := api.Group("/remote-servers")
|
||||
servers.GET("", handler.List)
|
||||
servers.POST("", handler.Create)
|
||||
servers.GET("/:uuid", handler.Get)
|
||||
servers.PUT("/:uuid", handler.Update)
|
||||
servers.DELETE("/:uuid", handler.Delete)
|
||||
servers.POST("/test", handler.TestConnectionCustom)
|
||||
servers.POST("/:uuid/test", handler.TestConnection)
|
||||
|
||||
return r, handler
|
||||
}
|
||||
|
||||
func TestRemoteServerHandler_TestConnectionCustom(t *testing.T) {
|
||||
r, _ := setupRemoteServerTest_New(t)
|
||||
|
||||
// Test with a likely closed port
|
||||
payload := map[string]interface{}{
|
||||
"host": "127.0.0.1",
|
||||
"port": 54321,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/remote-servers/test", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, false, result["reachable"])
|
||||
assert.NotEmpty(t, result["error"])
|
||||
}
|
||||
|
||||
func TestRemoteServerHandler_FullCRUD(t *testing.T) {
|
||||
r, _ := setupRemoteServerTest_New(t)
|
||||
|
||||
// Create
|
||||
rs := models.RemoteServer{
|
||||
Name: "Test Server CRUD",
|
||||
Host: "192.168.1.100",
|
||||
Port: 22,
|
||||
Provider: "manual",
|
||||
}
|
||||
body, _ := json.Marshal(rs)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer(body))
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
var created models.RemoteServer
|
||||
err := json.Unmarshal(w.Body.Bytes(), &created)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, rs.Name, created.Name)
|
||||
assert.NotEmpty(t, created.UUID)
|
||||
|
||||
// List
|
||||
req, _ = http.NewRequest("GET", "/api/v1/remote-servers", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Get
|
||||
req, _ = http.NewRequest("GET", "/api/v1/remote-servers/"+created.UUID, nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Update
|
||||
created.Name = "Updated Server CRUD"
|
||||
body, _ = json.Marshal(created)
|
||||
req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/"+created.UUID, bytes.NewBuffer(body))
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// Delete
|
||||
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/"+created.UUID, nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
|
||||
// Create - Invalid JSON
|
||||
req, _ = http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer([]byte("invalid json")))
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// Update - Not Found
|
||||
req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/non-existent-uuid", bytes.NewBuffer(body))
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Delete - Not Found
|
||||
req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent-uuid", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
|
||||
return &SettingsHandler{DB: db}
|
||||
}
|
||||
|
||||
// GetSettings returns all settings.
|
||||
func (h *SettingsHandler) GetSettings(c *gin.Context) {
|
||||
var settings []models.Setting
|
||||
if err := h.DB.Find(&settings).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to map for easier frontend consumption
|
||||
settingsMap := make(map[string]string)
|
||||
for _, s := range settings {
|
||||
settingsMap[s.Key] = s.Value
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, settingsMap)
|
||||
}
|
||||
|
||||
type UpdateSettingRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// UpdateSetting updates or creates a setting.
|
||||
func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
|
||||
var req UpdateSettingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
setting := models.Setting{
|
||||
Key: req.Key,
|
||||
Value: req.Value,
|
||||
}
|
||||
|
||||
if req.Category != "" {
|
||||
setting.Category = req.Category
|
||||
}
|
||||
if req.Type != "" {
|
||||
setting.Type = req.Type
|
||||
}
|
||||
|
||||
// Upsert
|
||||
if err := h.DB.Where(models.Setting{Key: req.Key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save setting"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, setting)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func setupSettingsTestDB(t *testing.T) *gorm.DB {
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect to test database")
|
||||
}
|
||||
db.AutoMigrate(&models.Setting{})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSettingsHandler_GetSettings(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
// Seed data
|
||||
db.Create(&models.Setting{Key: "test_key", Value: "test_value", Category: "general", Type: "string"})
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := gin.New()
|
||||
router.GET("/settings", handler.GetSettings)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/settings", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]string
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test_value", response["test_key"])
|
||||
}
|
||||
|
||||
func TestSettingsHandler_UpdateSettings(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := gin.New()
|
||||
router.POST("/settings", handler.UpdateSetting)
|
||||
|
||||
// Test Create
|
||||
payload := map[string]string{
|
||||
"key": "new_key",
|
||||
"value": "new_value",
|
||||
"category": "system",
|
||||
"type": "string",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var setting models.Setting
|
||||
db.Where("key = ?", "new_key").First(&setting)
|
||||
assert.Equal(t, "new_value", setting.Value)
|
||||
|
||||
// Test Update
|
||||
payload["value"] = "updated_value"
|
||||
body, _ = json.Marshal(payload)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
db.Where("key = ?", "new_key").First(&setting)
|
||||
assert.Equal(t, "updated_value", setting.Value)
|
||||
}
|
||||
|
||||
func TestSettingsHandler_Errors(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupSettingsTestDB(t)
|
||||
|
||||
handler := handlers.NewSettingsHandler(db)
|
||||
router := gin.New()
|
||||
router.POST("/settings", handler.UpdateSetting)
|
||||
|
||||
// Invalid JSON
|
||||
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer([]byte("invalid")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// Missing Key/Value
|
||||
payload := map[string]string{
|
||||
"key": "some_key",
|
||||
// value missing
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ = http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/bin/sh
|
||||
echo '{"apps":{}}'
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
if [ "$1" = "version" ]; then
|
||||
echo "v2.0.0"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
if [ "$1" = "version" ]; then
|
||||
echo "v2.0.0"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "adapt" ]; then
|
||||
# Read the domain from the input Caddyfile (stdin or --config file)
|
||||
DOMAIN="example.com"
|
||||
if [ "$2" = "--config" ]; then
|
||||
DOMAIN=$(cat "$3" | head -1 | tr -d '\n')
|
||||
fi
|
||||
echo "{\"apps\":{\"http\":{\"servers\":{\"srv0\":{\"routes\":[{\"match\":[{\"host\":[\"$DOMAIN\"]}],\"handle\":[{\"handler\":\"reverse_proxy\",\"upstreams\":[{\"dial\":\"localhost:8080\"}]}]}]}}}}}"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
@@ -1,25 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UpdateHandler struct {
|
||||
service *services.UpdateService
|
||||
}
|
||||
|
||||
func NewUpdateHandler(service *services.UpdateService) *UpdateHandler {
|
||||
return &UpdateHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *UpdateHandler) Check(c *gin.Context) {
|
||||
info, err := h.service.CheckForUpdates()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check for updates"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func TestUpdateHandler_Check(t *testing.T) {
|
||||
// Mock GitHub API
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/releases/latest" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"tag_name":"v1.0.0","html_url":"https://github.com/example/repo/releases/tag/v1.0.0"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Setup Service
|
||||
svc := services.NewUpdateService()
|
||||
svc.SetAPIURL(server.URL + "/releases/latest")
|
||||
|
||||
// Setup Handler
|
||||
h := NewUpdateHandler(svc)
|
||||
|
||||
// Setup Router
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/api/v1/update", h.Check)
|
||||
|
||||
// Test Request
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/update", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
r.ServeHTTP(resp, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var info services.UpdateInfo
|
||||
err := json.Unmarshal(resp.Body.Bytes(), &info)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, info.Available) // Assuming current version is not v1.0.0
|
||||
assert.Equal(t, "v1.0.0", info.LatestVersion)
|
||||
|
||||
// Test Failure
|
||||
serverError := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer serverError.Close()
|
||||
|
||||
svcError := services.NewUpdateService()
|
||||
svcError.SetAPIURL(serverError.URL)
|
||||
hError := NewUpdateHandler(svcError)
|
||||
|
||||
rError := gin.New()
|
||||
rError.GET("/api/v1/update", hError.Check)
|
||||
|
||||
reqError := httptest.NewRequest(http.MethodGet, "/api/v1/update", nil)
|
||||
respError := httptest.NewRecorder()
|
||||
rError.ServeHTTP(respError, reqError)
|
||||
|
||||
assert.Equal(t, http.StatusOK, respError.Code)
|
||||
var infoError services.UpdateInfo
|
||||
err = json.Unmarshal(respError.Body.Bytes(), &infoError)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, infoError.Available)
|
||||
|
||||
// Test Client Error (Invalid URL)
|
||||
svcClientError := services.NewUpdateService()
|
||||
svcClientError.SetAPIURL("http://invalid-url-that-does-not-exist")
|
||||
hClientError := NewUpdateHandler(svcClientError)
|
||||
|
||||
rClientError := gin.New()
|
||||
rClientError.GET("/api/v1/update", hClientError.Check)
|
||||
|
||||
reqClientError := httptest.NewRequest(http.MethodGet, "/api/v1/update", nil)
|
||||
respClientError := httptest.NewRecorder()
|
||||
rClientError.ServeHTTP(respClientError, reqClientError)
|
||||
|
||||
// CheckForUpdates returns error on client failure
|
||||
// Handler returns 500 on error
|
||||
assert.Equal(t, http.StatusInternalServerError, respClientError.Code)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UptimeHandler struct {
|
||||
service *services.UptimeService
|
||||
}
|
||||
|
||||
func NewUptimeHandler(service *services.UptimeService) *UptimeHandler {
|
||||
return &UptimeHandler{service: service}
|
||||
}
|
||||
|
||||
func (h *UptimeHandler) List(c *gin.Context) {
|
||||
monitors, err := h.service.ListMonitors()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list monitors"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, monitors)
|
||||
}
|
||||
|
||||
func (h *UptimeHandler) GetHistory(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
|
||||
history, err := h.service.GetMonitorHistory(id, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, history)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
func setupUptimeHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.NotificationProvider{}, &models.Notification{}))
|
||||
|
||||
ns := services.NewNotificationService(db)
|
||||
service := services.NewUptimeService(db, ns)
|
||||
handler := handlers.NewUptimeHandler(service)
|
||||
|
||||
r := gin.Default()
|
||||
api := r.Group("/api/v1")
|
||||
uptime := api.Group("/uptime")
|
||||
uptime.GET("", handler.List)
|
||||
uptime.GET("/:id/history", handler.GetHistory)
|
||||
|
||||
return r, db
|
||||
}
|
||||
|
||||
func TestUptimeHandler_List(t *testing.T) {
|
||||
r, db := setupUptimeHandlerTest(t)
|
||||
|
||||
// Seed Monitor
|
||||
monitor := models.UptimeMonitor{
|
||||
ID: "monitor-1",
|
||||
Name: "Test Monitor",
|
||||
Type: "http",
|
||||
URL: "http://example.com",
|
||||
}
|
||||
db.Create(&monitor)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/uptime", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var list []models.UptimeMonitor
|
||||
err := json.Unmarshal(w.Body.Bytes(), &list)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 1)
|
||||
assert.Equal(t, "Test Monitor", list[0].Name)
|
||||
}
|
||||
|
||||
func TestUptimeHandler_GetHistory(t *testing.T) {
|
||||
r, db := setupUptimeHandlerTest(t)
|
||||
|
||||
// Seed Monitor and Heartbeats
|
||||
monitorID := "monitor-1"
|
||||
monitor := models.UptimeMonitor{
|
||||
ID: monitorID,
|
||||
Name: "Test Monitor",
|
||||
}
|
||||
db.Create(&monitor)
|
||||
|
||||
db.Create(&models.UptimeHeartbeat{
|
||||
MonitorID: monitorID,
|
||||
Status: "up",
|
||||
Latency: 10,
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
})
|
||||
db.Create(&models.UptimeHeartbeat{
|
||||
MonitorID: monitorID,
|
||||
Status: "down",
|
||||
Latency: 0,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/api/v1/uptime/"+monitorID+"/history", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var history []models.UptimeHeartbeat
|
||||
err := json.Unmarshal(w.Body.Bytes(), &history)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, history, 2)
|
||||
// Should be ordered by created_at desc
|
||||
assert.Equal(t, "down", history[0].Status)
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserHandler(db *gorm.DB) *UserHandler {
|
||||
return &UserHandler{DB: db}
|
||||
}
|
||||
|
||||
func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.GET("/setup", h.GetSetupStatus)
|
||||
r.POST("/setup", h.Setup)
|
||||
r.GET("/profile", h.GetProfile)
|
||||
r.POST("/regenerate-api-key", h.RegenerateAPIKey)
|
||||
r.PUT("/profile", h.UpdateProfile)
|
||||
}
|
||||
|
||||
// GetSetupStatus checks if the application needs initial setup (i.e., no users exist).
|
||||
func (h *UserHandler) GetSetupStatus(c *gin.Context) {
|
||||
var count int64
|
||||
if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"setupRequired": count == 0,
|
||||
})
|
||||
}
|
||||
|
||||
type SetupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
}
|
||||
|
||||
// Setup creates the initial admin user and configures the ACME email.
|
||||
func (h *UserHandler) Setup(c *gin.Context) {
|
||||
// 1. Check if setup is allowed
|
||||
var count int64
|
||||
if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Parse request
|
||||
var req SetupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Create User
|
||||
user := models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Name: req.Name,
|
||||
Email: strings.ToLower(req.Email),
|
||||
Role: "admin",
|
||||
Enabled: true,
|
||||
APIKey: uuid.New().String(),
|
||||
}
|
||||
|
||||
if err := user.SetPassword(req.Password); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Create Setting for ACME Email
|
||||
acmeEmailSetting := models.Setting{
|
||||
Key: "caddy.acme_email",
|
||||
Value: req.Email,
|
||||
Type: "string",
|
||||
Category: "caddy",
|
||||
}
|
||||
|
||||
// Transaction to ensure both succeed
|
||||
err := h.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Use Save to update if exists (though it shouldn't in fresh setup) or create
|
||||
if err := tx.Where(models.Setting{Key: "caddy.acme_email"}).Assign(models.Setting{Value: req.Email}).FirstOrCreate(&acmeEmailSetting).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Setup completed successfully",
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RegenerateAPIKey generates a new API key for the authenticated user.
|
||||
func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := uuid.New().String()
|
||||
|
||||
if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"api_key": apiKey})
|
||||
}
|
||||
|
||||
// GetProfile returns the current user's profile including API key.
|
||||
func (h *UserHandler) GetProfile(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"api_key": user.APIKey,
|
||||
})
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
CurrentPassword string `json:"current_password"`
|
||||
}
|
||||
|
||||
// UpdateProfile updates the authenticated user's profile.
|
||||
func (h *UserHandler) UpdateProfile(c *gin.Context) {
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Get current user
|
||||
var user models.User
|
||||
if err := h.DB.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email is already taken by another user
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
var count int64
|
||||
if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&count).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email availability"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
|
||||
return
|
||||
}
|
||||
|
||||
// If email is changing, verify password
|
||||
if req.Email != user.Email {
|
||||
if req.CurrentPassword == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is required to change email"})
|
||||
return
|
||||
}
|
||||
if !user.CheckPassword(req.CurrentPassword) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"name": req.Name,
|
||||
"email": req.Email,
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"})
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupUserHandler(t *testing.T) (*UserHandler, *gorm.DB) {
|
||||
// Use unique DB for each test to avoid pollution
|
||||
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.AutoMigrate(&models.User{}, &models.Setting{})
|
||||
return NewUserHandler(db), db
|
||||
}
|
||||
|
||||
func TestUserHandler_GetSetupStatus(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/setup", handler.GetSetupStatus)
|
||||
|
||||
// No users -> setup required
|
||||
req, _ := http.NewRequest("GET", "/setup", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "\"setupRequired\":true")
|
||||
|
||||
// Create user -> setup not required
|
||||
db.Create(&models.User{Email: "test@example.com"})
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "\"setupRequired\":false")
|
||||
}
|
||||
|
||||
func TestUserHandler_Setup(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/setup", handler.Setup)
|
||||
|
||||
// 1. Invalid JSON (Before setup is done)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/setup", bytes.NewBuffer([]byte("invalid json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// 2. Valid Setup
|
||||
body := map[string]string{
|
||||
"name": "Admin",
|
||||
"email": "admin@example.com",
|
||||
"password": "password123",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req, _ = http.NewRequest("POST", "/setup", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Setup completed successfully")
|
||||
|
||||
// 3. Try again -> should fail (already setup)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", "/setup", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
func TestUserHandler_Setup_DBError(t *testing.T) {
|
||||
// Can't easily mock DB error with sqlite memory unless we close it or something.
|
||||
// But we can try to insert duplicate email if we had a unique constraint and pre-seeded data,
|
||||
// but Setup checks if ANY user exists first.
|
||||
// So if we have a user, it returns Forbidden.
|
||||
// If we don't, it tries to create.
|
||||
// If we want Create to fail, maybe invalid data that passes binding but fails DB constraint?
|
||||
// User model has validation?
|
||||
// Let's try empty password if allowed by binding but rejected by DB?
|
||||
// Or very long string?
|
||||
}
|
||||
|
||||
func TestUserHandler_RegenerateAPIKey(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
user := &models.User{Email: "api@example.com"}
|
||||
db.Create(user)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", user.ID)
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/api-key", handler.RegenerateAPIKey)
|
||||
|
||||
req, _ := http.NewRequest("POST", "/api-key", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]string
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.NotEmpty(t, resp["api_key"])
|
||||
|
||||
// Verify DB
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.Equal(t, resp["api_key"], updatedUser.APIKey)
|
||||
}
|
||||
|
||||
func TestUserHandler_GetProfile(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
user := &models.User{
|
||||
Email: "profile@example.com",
|
||||
Name: "Profile User",
|
||||
APIKey: "existing-key",
|
||||
}
|
||||
db.Create(user)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", user.ID)
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/profile", handler.GetProfile)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/profile", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp models.User
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Equal(t, user.Email, resp.Email)
|
||||
assert.Equal(t, user.APIKey, resp.APIKey)
|
||||
}
|
||||
|
||||
func TestUserHandler_RegisterRoutes(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
api := r.Group("/api")
|
||||
handler.RegisterRoutes(api)
|
||||
|
||||
routes := r.Routes()
|
||||
expectedRoutes := map[string]string{
|
||||
"/api/setup": "GET,POST",
|
||||
"/api/profile": "GET",
|
||||
"/api/regenerate-api-key": "POST",
|
||||
}
|
||||
|
||||
for path := range expectedRoutes {
|
||||
found := false
|
||||
for _, route := range routes {
|
||||
if route.Path == path {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Route %s not found", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserHandler_Errors(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
// Middleware to simulate missing userID
|
||||
r.GET("/profile-no-auth", func(c *gin.Context) {
|
||||
// No userID set
|
||||
handler.GetProfile(c)
|
||||
})
|
||||
r.POST("/api-key-no-auth", func(c *gin.Context) {
|
||||
// No userID set
|
||||
handler.RegenerateAPIKey(c)
|
||||
})
|
||||
|
||||
// Middleware to simulate non-existent user
|
||||
r.GET("/profile-not-found", func(c *gin.Context) {
|
||||
c.Set("userID", uint(99999))
|
||||
handler.GetProfile(c)
|
||||
})
|
||||
r.POST("/api-key-not-found", func(c *gin.Context) {
|
||||
c.Set("userID", uint(99999))
|
||||
handler.RegenerateAPIKey(c)
|
||||
})
|
||||
|
||||
// Test Unauthorized
|
||||
req, _ := http.NewRequest("GET", "/profile-no-auth", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
req, _ = http.NewRequest("POST", "/api-key-no-auth", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
// Test Not Found (GetProfile)
|
||||
req, _ = http.NewRequest("GET", "/profile-not-found", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Test DB Error (RegenerateAPIKey) - Hard to mock DB error on update with sqlite memory,
|
||||
// but we can try to update a non-existent user which GORM Update might not treat as error unless we check RowsAffected.
|
||||
// The handler code: if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil
|
||||
// Update on non-existent record usually returns nil error in GORM unless configured otherwise.
|
||||
// However, let's see if we can force an error by closing DB? No, shared DB.
|
||||
// We can drop the table?
|
||||
db.Migrator().DropTable(&models.User{})
|
||||
req, _ = http.NewRequest("POST", "/api-key-not-found", nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
// If table missing, Update should fail
|
||||
assert.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateProfile(t *testing.T) {
|
||||
handler, db := setupUserHandler(t)
|
||||
|
||||
// Create user
|
||||
user := &models.User{
|
||||
UUID: uuid.NewString(),
|
||||
Email: "test@example.com",
|
||||
Name: "Test User",
|
||||
APIKey: uuid.NewString(),
|
||||
}
|
||||
user.SetPassword("password123")
|
||||
db.Create(user)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", user.ID)
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/profile", handler.UpdateProfile)
|
||||
|
||||
// 1. Success - Name only
|
||||
t.Run("Success Name Only", func(t *testing.T) {
|
||||
body := map[string]string{
|
||||
"name": "Updated Name",
|
||||
"email": "test@example.com",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.Equal(t, "Updated Name", updatedUser.Name)
|
||||
})
|
||||
|
||||
// 2. Success - Email change with password
|
||||
t.Run("Success Email Change", func(t *testing.T) {
|
||||
body := map[string]string{
|
||||
"name": "Updated Name",
|
||||
"email": "newemail@example.com",
|
||||
"current_password": "password123",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.Equal(t, "newemail@example.com", updatedUser.Email)
|
||||
})
|
||||
|
||||
// 3. Fail - Email change without password
|
||||
t.Run("Fail Email Change No Password", func(t *testing.T) {
|
||||
// Reset email
|
||||
db.Model(user).Update("email", "test@example.com")
|
||||
|
||||
body := map[string]string{
|
||||
"name": "Updated Name",
|
||||
"email": "another@example.com",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
|
||||
// 4. Fail - Email change wrong password
|
||||
t.Run("Fail Email Change Wrong Password", func(t *testing.T) {
|
||||
body := map[string]string{
|
||||
"name": "Updated Name",
|
||||
"email": "another@example.com",
|
||||
"current_password": "wrongpassword",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
|
||||
// 5. Fail - Email already in use
|
||||
t.Run("Fail Email In Use", func(t *testing.T) {
|
||||
// Create another user
|
||||
otherUser := &models.User{
|
||||
UUID: uuid.NewString(),
|
||||
Email: "other@example.com",
|
||||
Name: "Other User",
|
||||
APIKey: uuid.NewString(),
|
||||
}
|
||||
db.Create(otherUser)
|
||||
|
||||
body := map[string]string{
|
||||
"name": "Updated Name",
|
||||
"email": "other@example.com",
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusConflict, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserHandler_UpdateProfile_Errors(t *testing.T) {
|
||||
handler, _ := setupUserHandler(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
// 1. Unauthorized (no userID)
|
||||
r.PUT("/profile-no-auth", handler.UpdateProfile)
|
||||
req, _ := http.NewRequest("PUT", "/profile-no-auth", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
// Middleware for subsequent tests
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", uint(999)) // Non-existent ID
|
||||
c.Next()
|
||||
})
|
||||
r.PUT("/profile", handler.UpdateProfile)
|
||||
|
||||
// 2. BindJSON error
|
||||
req, _ = http.NewRequest("PUT", "/profile", bytes.NewBufferString("invalid"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// 3. User not found
|
||||
body := map[string]string{"name": "New Name", "email": "new@example.com"}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
req, _ = http.NewRequest("PUT", "/profile", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestUserLoginAfterEmailChange(t *testing.T) {
|
||||
// Setup DB
|
||||
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.AutoMigrate(&models.User{}, &models.Setting{})
|
||||
|
||||
// Setup Services and Handlers
|
||||
cfg := config.Config{}
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authHandler := NewAuthHandler(authService)
|
||||
userHandler := NewUserHandler(db)
|
||||
|
||||
// Setup Router
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
// Register Routes
|
||||
r.POST("/auth/login", authHandler.Login)
|
||||
|
||||
// Mock Auth Middleware for UpdateProfile
|
||||
r.POST("/user/profile", func(c *gin.Context) {
|
||||
// Simulate authenticated user
|
||||
var user models.User
|
||||
db.First(&user)
|
||||
c.Set("userID", user.ID)
|
||||
c.Set("role", user.Role)
|
||||
c.Next()
|
||||
}, userHandler.UpdateProfile)
|
||||
|
||||
// 1. Create Initial User
|
||||
initialEmail := "initial@example.com"
|
||||
password := "password123"
|
||||
user, err := authService.Register(initialEmail, password, "Test User")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
|
||||
// 2. Login with Initial Credentials (Verify it works)
|
||||
loginBody := map[string]string{
|
||||
"email": initialEmail,
|
||||
"password": password,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(loginBody)
|
||||
req, _ := http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Initial login should succeed")
|
||||
|
||||
// 3. Update Profile (Change Email)
|
||||
newEmail := "updated@example.com"
|
||||
updateBody := map[string]string{
|
||||
"name": "Test User Updated",
|
||||
"email": newEmail,
|
||||
"current_password": password,
|
||||
}
|
||||
jsonUpdate, _ := json.Marshal(updateBody)
|
||||
req, _ = http.NewRequest("POST", "/user/profile", bytes.NewBuffer(jsonUpdate))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Update profile should succeed")
|
||||
|
||||
// Verify DB update
|
||||
var updatedUser models.User
|
||||
db.First(&updatedUser, user.ID)
|
||||
assert.Equal(t, newEmail, updatedUser.Email, "Email should be updated in DB")
|
||||
|
||||
// 4. Login with New Email
|
||||
loginBodyNew := map[string]string{
|
||||
"email": newEmail,
|
||||
"password": password,
|
||||
}
|
||||
jsonBodyNew, _ := json.Marshal(loginBodyNew)
|
||||
req, _ = http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonBodyNew))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// This is where the user says it fails
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Login with new email should succeed")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Logf("Response Body: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// 5. Login with New Email (Different Case)
|
||||
loginBodyCase := map[string]string{
|
||||
"email": "Updated@Example.com", // Different case
|
||||
"password": password,
|
||||
}
|
||||
jsonBodyCase, _ := json.Marshal(loginBodyCase)
|
||||
req, _ = http.NewRequest("POST", "/auth/login", bytes.NewBuffer(jsonBodyCase))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// If this fails, it confirms case sensitivity issue
|
||||
assert.Equal(t, http.StatusOK, w.Code, "Login with mixed case email should succeed")
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
// Try cookie
|
||||
cookie, err := c.Cookie("auth_token")
|
||||
if err == nil {
|
||||
authHeader = "Bearer " + cookie
|
||||
}
|
||||
}
|
||||
|
||||
if authHeader == "" {
|
||||
// Try query param
|
||||
token := c.Query("token")
|
||||
if token != "" {
|
||||
authHeader = "Bearer " + token
|
||||
}
|
||||
}
|
||||
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
claims, err := authService.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("userID", claims.UserID)
|
||||
c.Set("role", claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireRole(role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userRole, exists := c.Get("role")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if userRole.(string) != role && userRole.(string) != "admin" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupAuthService(t *testing.T) *services.AuthService {
|
||||
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.AutoMigrate(&models.User{})
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
return services.NewAuthService(db, cfg)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_MissingHeader(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// We pass nil for authService because we expect it to fail before using it
|
||||
r.Use(AuthMiddleware(nil))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Authorization header required")
|
||||
}
|
||||
|
||||
func TestRequireRole_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "admin")
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireRole("admin"))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestRequireRole_Forbidden(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("role", "user")
|
||||
c.Next()
|
||||
})
|
||||
r.Use(RequireRole("admin"))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_Cookie(t *testing.T) {
|
||||
authService := setupAuthService(t)
|
||||
user, err := authService.Register("test@example.com", "password", "Test User")
|
||||
require.NoError(t, err)
|
||||
token, err := authService.GenerateToken(user)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(authService))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
assert.Equal(t, user.ID, userID)
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_ValidToken(t *testing.T) {
|
||||
authService := setupAuthService(t)
|
||||
user, err := authService.Register("test@example.com", "password", "Test User")
|
||||
require.NoError(t, err)
|
||||
token, err := authService.GenerateToken(user)
|
||||
require.NoError(t, err)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(authService))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
userID, _ := c.Get("userID")
|
||||
assert.Equal(t, user.ID, userID)
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_InvalidToken(t *testing.T) {
|
||||
authService := setupAuthService(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(AuthMiddleware(authService))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "Invalid token")
|
||||
}
|
||||
|
||||
func TestRequireRole_MissingRoleInContext(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// No role set in context
|
||||
r.Use(RequireRole("admin"))
|
||||
r.GET("/test", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req, _ := http.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/handlers"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/middleware"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/caddy"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
|
||||
)
|
||||
|
||||
// Register wires up API routes and performs automatic migrations.
|
||||
func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
// AutoMigrate all models for Issue #5 persistence layer
|
||||
if err := db.AutoMigrate(
|
||||
&models.ProxyHost{},
|
||||
&models.Location{},
|
||||
&models.CaddyConfig{},
|
||||
&models.RemoteServer{},
|
||||
&models.SSLCertificate{},
|
||||
&models.AccessList{},
|
||||
&models.User{},
|
||||
&models.Setting{},
|
||||
&models.ImportSession{},
|
||||
&models.Notification{},
|
||||
&models.NotificationProvider{},
|
||||
&models.UptimeMonitor{},
|
||||
&models.UptimeHeartbeat{},
|
||||
&models.Domain{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
|
||||
router.GET("/api/v1/health", handlers.HealthHandler)
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
|
||||
// Auth routes
|
||||
authService := services.NewAuthService(db, cfg)
|
||||
authHandler := handlers.NewAuthHandler(authService)
|
||||
authMiddleware := middleware.AuthMiddleware(authService)
|
||||
|
||||
// Backup routes
|
||||
backupService := services.NewBackupService(&cfg)
|
||||
backupHandler := handlers.NewBackupHandler(backupService)
|
||||
|
||||
// Log routes
|
||||
logService := services.NewLogService(&cfg)
|
||||
logsHandler := handlers.NewLogsHandler(logService)
|
||||
|
||||
// Notification Service (needed for multiple handlers)
|
||||
notificationService := services.NewNotificationService(db)
|
||||
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
api.POST("/auth/register", authHandler.Register)
|
||||
|
||||
protected := api.Group("/")
|
||||
protected.Use(authMiddleware)
|
||||
{
|
||||
protected.POST("/auth/logout", authHandler.Logout)
|
||||
protected.GET("/auth/me", authHandler.Me)
|
||||
protected.POST("/auth/change-password", authHandler.ChangePassword)
|
||||
|
||||
// Backups
|
||||
protected.GET("/backups", backupHandler.List)
|
||||
protected.POST("/backups", backupHandler.Create)
|
||||
protected.DELETE("/backups/:filename", backupHandler.Delete)
|
||||
protected.GET("/backups/:filename/download", backupHandler.Download)
|
||||
protected.POST("/backups/:filename/restore", backupHandler.Restore)
|
||||
|
||||
// Logs
|
||||
protected.GET("/logs", logsHandler.List)
|
||||
protected.GET("/logs/:filename", logsHandler.Read)
|
||||
protected.GET("/logs/:filename/download", logsHandler.Download)
|
||||
|
||||
// Settings
|
||||
settingsHandler := handlers.NewSettingsHandler(db)
|
||||
protected.GET("/settings", settingsHandler.GetSettings)
|
||||
protected.POST("/settings", settingsHandler.UpdateSetting)
|
||||
|
||||
// User Profile & API Key
|
||||
userHandler := handlers.NewUserHandler(db)
|
||||
protected.GET("/user/profile", userHandler.GetProfile)
|
||||
protected.POST("/user/profile", userHandler.UpdateProfile)
|
||||
protected.POST("/user/api-key", userHandler.RegenerateAPIKey)
|
||||
|
||||
// Updates
|
||||
updateService := services.NewUpdateService()
|
||||
updateHandler := handlers.NewUpdateHandler(updateService)
|
||||
protected.GET("/system/updates", updateHandler.Check)
|
||||
|
||||
// Notifications
|
||||
notificationHandler := handlers.NewNotificationHandler(notificationService)
|
||||
protected.GET("/notifications", notificationHandler.List)
|
||||
protected.POST("/notifications/:id/read", notificationHandler.MarkAsRead)
|
||||
protected.POST("/notifications/read-all", notificationHandler.MarkAllAsRead)
|
||||
|
||||
// Domains
|
||||
domainHandler := handlers.NewDomainHandler(db, notificationService)
|
||||
protected.GET("/domains", domainHandler.List)
|
||||
protected.POST("/domains", domainHandler.Create)
|
||||
protected.DELETE("/domains/:id", domainHandler.Delete)
|
||||
|
||||
// Docker
|
||||
dockerService, err := services.NewDockerService()
|
||||
if err == nil { // Only register if Docker is available
|
||||
dockerHandler := handlers.NewDockerHandler(dockerService)
|
||||
dockerHandler.RegisterRoutes(protected)
|
||||
} else {
|
||||
fmt.Printf("Warning: Docker service unavailable: %v\n", err)
|
||||
}
|
||||
|
||||
// Uptime Service
|
||||
uptimeService := services.NewUptimeService(db, notificationService)
|
||||
uptimeHandler := handlers.NewUptimeHandler(uptimeService)
|
||||
protected.GET("/uptime/monitors", uptimeHandler.List)
|
||||
protected.GET("/uptime/monitors/:id/history", uptimeHandler.GetHistory)
|
||||
|
||||
// Notification Providers
|
||||
notificationProviderHandler := handlers.NewNotificationProviderHandler(notificationService)
|
||||
protected.GET("/notifications/providers", notificationProviderHandler.List)
|
||||
protected.POST("/notifications/providers", notificationProviderHandler.Create)
|
||||
protected.PUT("/notifications/providers/:id", notificationProviderHandler.Update)
|
||||
protected.DELETE("/notifications/providers/:id", notificationProviderHandler.Delete)
|
||||
protected.POST("/notifications/providers/test", notificationProviderHandler.Test)
|
||||
|
||||
// Start background checker (every 1 minute)
|
||||
go func() {
|
||||
// Wait a bit for server to start
|
||||
time.Sleep(30 * time.Second)
|
||||
// Initial sync
|
||||
if err := uptimeService.SyncMonitors(); err != nil {
|
||||
fmt.Printf("Failed to sync monitors: %v\n", err)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
uptimeService.SyncMonitors()
|
||||
uptimeService.CheckAll()
|
||||
}
|
||||
}()
|
||||
|
||||
protected.POST("/system/uptime/check", func(c *gin.Context) {
|
||||
go uptimeService.CheckAll()
|
||||
c.JSON(200, gin.H{"message": "Uptime check started"})
|
||||
})
|
||||
}
|
||||
|
||||
// Caddy Manager
|
||||
caddyClient := caddy.NewClient(cfg.CaddyAdminAPI)
|
||||
caddyManager := caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir)
|
||||
|
||||
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService)
|
||||
proxyHostHandler.RegisterRoutes(api)
|
||||
|
||||
remoteServerHandler := handlers.NewRemoteServerHandler(db, notificationService)
|
||||
remoteServerHandler.RegisterRoutes(api)
|
||||
|
||||
userHandler := handlers.NewUserHandler(db)
|
||||
userHandler.RegisterRoutes(api)
|
||||
|
||||
// Certificate routes
|
||||
// Use cfg.CaddyConfigDir + "/data" for cert service so we scan the actual Caddy storage
|
||||
// where ACME and certificates are stored (e.g. <CaddyConfigDir>/data).
|
||||
caddyDataDir := cfg.CaddyConfigDir + "/data"
|
||||
fmt.Printf("Using Caddy data directory for certificates scan: %s\n", caddyDataDir)
|
||||
certService := services.NewCertificateService(caddyDataDir, db)
|
||||
certHandler := handlers.NewCertificateHandler(certService, notificationService)
|
||||
api.GET("/certificates", certHandler.List)
|
||||
api.POST("/certificates", certHandler.Upload)
|
||||
api.DELETE("/certificates/:id", certHandler.Delete)
|
||||
|
||||
// Initial Caddy Config Sync
|
||||
go func() {
|
||||
// Wait for Caddy to be ready (max 30 seconds)
|
||||
ctx := context.Background()
|
||||
timeout := time.After(30 * time.Second)
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
ready := false
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
fmt.Println("Timeout waiting for Caddy to be ready")
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := caddyManager.Ping(ctx); err == nil {
|
||||
ready = true
|
||||
goto Apply
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Apply:
|
||||
if ready {
|
||||
// Apply config
|
||||
if err := caddyManager.ApplyConfig(ctx); err != nil {
|
||||
fmt.Printf("Failed to apply initial Caddy config: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Successfully applied initial Caddy config\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterImportHandler wires up import routes with config dependencies.
|
||||
func RegisterImportHandler(router *gin.Engine, db *gorm.DB, caddyBinary, importDir, mountPath string) {
|
||||
importHandler := handlers.NewImportHandler(db, caddyBinary, importDir, mountPath)
|
||||
api := router.Group("/api/v1")
|
||||
importHandler.RegisterRoutes(api)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/api/routes"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func setupTestImportDB(t *testing.T) *gorm.DB {
|
||||
dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
db.AutoMigrate(&models.ImportSession{}, &models.ProxyHost{})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRegisterImportHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestImportDB(t)
|
||||
|
||||
router := gin.New()
|
||||
routes.RegisterImportHandler(router, db, "echo", "/tmp", "/import/Caddyfile")
|
||||
|
||||
// Verify routes are registered by checking the routes list
|
||||
routeInfo := router.Routes()
|
||||
|
||||
expectedRoutes := map[string]bool{
|
||||
"GET /api/v1/import/status": false,
|
||||
"GET /api/v1/import/preview": false,
|
||||
"POST /api/v1/import/upload": false,
|
||||
"POST /api/v1/import/commit": false,
|
||||
"DELETE /api/v1/import/cancel": false,
|
||||
}
|
||||
|
||||
for _, route := range routeInfo {
|
||||
key := route.Method + " " + route.Path
|
||||
if _, exists := expectedRoutes[key]; exists {
|
||||
expectedRoutes[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
for route, found := range expectedRoutes {
|
||||
assert.True(t, found, "route %s should be registered", route)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
// Use in-memory DB
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := config.Config{
|
||||
JWTSecret: "test-secret",
|
||||
}
|
||||
|
||||
err = Register(router, db, cfg)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify some routes are registered
|
||||
routes := router.Routes()
|
||||
assert.NotEmpty(t, routes)
|
||||
|
||||
foundHealth := false
|
||||
for _, r := range routes {
|
||||
if r.Path == "/api/v1/health" {
|
||||
foundHealth = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundHealth, "Health route should be registered")
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client wraps the Caddy admin API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a Caddy API client.
|
||||
func NewClient(adminAPIURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: adminAPIURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load atomically replaces Caddy's entire configuration.
|
||||
// This is the primary method for applying configuration changes.
|
||||
func (c *Client) Load(ctx context.Context, config *Config) error {
|
||||
body, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/load", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfig retrieves the current running configuration from Caddy.
|
||||
func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// Ping checks if Caddy admin API is reachable.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caddy unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("caddy returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestClient_Load_Success(t *testing.T) {
|
||||
// Mock Caddy admin API
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/load", r.URL.Path)
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
config, _ := GenerateConfig([]models.ProxyHost{
|
||||
{
|
||||
UUID: "test",
|
||||
DomainNames: "test.com",
|
||||
ForwardHost: "app",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
},
|
||||
}, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
|
||||
err := client.Load(context.Background(), config)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_Load_Failure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error": "invalid config"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
config := &Config{}
|
||||
|
||||
err := client.Load(context.Background(), config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "400")
|
||||
}
|
||||
|
||||
func TestClient_GetConfig_Success(t *testing.T) {
|
||||
testConfig := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"test": {Listen: []string{":80"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/config/", r.URL.Path)
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(testConfig)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
config, err := client.GetConfig(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
require.NotNil(t, config.Apps.HTTP)
|
||||
}
|
||||
|
||||
func TestClient_Ping_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
err := client.Ping(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_Ping_Unreachable(t *testing.T) {
|
||||
client := NewClient("http://localhost:9999")
|
||||
err := client.Ping(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// GenerateConfig creates a Caddy JSON configuration from proxy hosts.
|
||||
// This is the core transformation layer from our database model to Caddy config.
|
||||
func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string) (*Config, error) {
|
||||
// Define log file paths
|
||||
// We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs"
|
||||
// storageDir is .../data/caddy/data
|
||||
// Dir -> .../data/caddy
|
||||
// Dir -> .../data
|
||||
logDir := filepath.Join(filepath.Dir(filepath.Dir(storageDir)), "logs")
|
||||
logFile := filepath.Join(logDir, "access.log")
|
||||
|
||||
config := &Config{
|
||||
Logging: &LoggingConfig{
|
||||
Logs: map[string]*LogConfig{
|
||||
"access": {
|
||||
Level: "DEBUG",
|
||||
Writer: &WriterConfig{
|
||||
Output: "file",
|
||||
Filename: logFile,
|
||||
Roll: true,
|
||||
RollSize: 10, // 10 MB
|
||||
RollKeep: 5, // Keep 5 files
|
||||
RollKeepDays: 7, // Keep for 7 days
|
||||
},
|
||||
Encoder: &EncoderConfig{
|
||||
Format: "json",
|
||||
},
|
||||
Include: []string{"http.log.access.access_log"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{},
|
||||
},
|
||||
},
|
||||
Storage: Storage{
|
||||
System: "file_system",
|
||||
Root: storageDir,
|
||||
},
|
||||
}
|
||||
|
||||
if acmeEmail != "" {
|
||||
var issuers []interface{}
|
||||
|
||||
// Configure issuers based on provider preference
|
||||
switch sslProvider {
|
||||
case "letsencrypt":
|
||||
issuers = append(issuers, map[string]interface{}{
|
||||
"module": "acme",
|
||||
"email": acmeEmail,
|
||||
})
|
||||
case "zerossl":
|
||||
issuers = append(issuers, map[string]interface{}{
|
||||
"module": "zerossl",
|
||||
})
|
||||
default: // "both" or empty
|
||||
issuers = append(issuers, map[string]interface{}{
|
||||
"module": "acme",
|
||||
"email": acmeEmail,
|
||||
})
|
||||
issuers = append(issuers, map[string]interface{}{
|
||||
"module": "zerossl",
|
||||
})
|
||||
}
|
||||
|
||||
config.Apps.TLS = &TLSApp{
|
||||
Automation: &AutomationConfig{
|
||||
Policies: []*AutomationPolicy{
|
||||
{
|
||||
IssuersRaw: issuers,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Collect custom certificates
|
||||
customCerts := make(map[uint]models.SSLCertificate)
|
||||
for _, host := range hosts {
|
||||
if host.CertificateID != nil && host.Certificate != nil {
|
||||
customCerts[*host.CertificateID] = *host.Certificate
|
||||
}
|
||||
}
|
||||
|
||||
if len(customCerts) > 0 {
|
||||
var loadPEM []LoadPEMConfig
|
||||
for _, cert := range customCerts {
|
||||
loadPEM = append(loadPEM, LoadPEMConfig{
|
||||
Certificate: cert.Certificate,
|
||||
Key: cert.PrivateKey,
|
||||
Tags: []string{cert.UUID},
|
||||
})
|
||||
}
|
||||
|
||||
if config.Apps.TLS == nil {
|
||||
config.Apps.TLS = &TLSApp{}
|
||||
}
|
||||
config.Apps.TLS.Certificates = &CertificatesConfig{
|
||||
LoadPEM: loadPEM,
|
||||
}
|
||||
}
|
||||
|
||||
if len(hosts) == 0 && frontendDir == "" {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Initialize routes slice
|
||||
routes := make([]*Route, 0)
|
||||
|
||||
// Track processed domains to prevent duplicates (Ghost Host fix)
|
||||
processedDomains := make(map[string]bool)
|
||||
|
||||
// Sort hosts by UpdatedAt desc to prefer newer configs in case of duplicates
|
||||
// Note: This assumes the input slice is already sorted or we don't care about order beyond duplicates
|
||||
// The caller (ApplyConfig) fetches all hosts. We should probably sort them here or there.
|
||||
// For now, we'll just process them. If we encounter a duplicate domain, we skip it.
|
||||
// To ensure we keep the *latest* one, we should iterate in reverse or sort.
|
||||
// But ApplyConfig uses db.Find(&hosts), which usually returns by ID asc.
|
||||
// So later IDs (newer) come last.
|
||||
// We want to keep the NEWER one.
|
||||
// So we should iterate backwards? Or just overwrite?
|
||||
// Caddy config structure is a list of servers/routes.
|
||||
// If we have multiple routes matching the same host, Caddy uses the first one?
|
||||
// Actually, Caddy matches routes in order.
|
||||
// If we emit two routes for "example.com", the first one will catch it.
|
||||
// So we want the NEWEST one to be FIRST in the list?
|
||||
// Or we want to only emit ONE route for "example.com".
|
||||
// If we emit only one, it should be the newest one.
|
||||
// So we should process hosts from newest to oldest, and skip duplicates.
|
||||
|
||||
// Let's iterate in reverse order (assuming input is ID ASC)
|
||||
for i := len(hosts) - 1; i >= 0; i-- {
|
||||
host := hosts[i]
|
||||
|
||||
if !host.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
if host.DomainNames == "" {
|
||||
// Log warning?
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse comma-separated domains
|
||||
rawDomains := strings.Split(host.DomainNames, ",")
|
||||
var uniqueDomains []string
|
||||
|
||||
for _, d := range rawDomains {
|
||||
d = strings.TrimSpace(d)
|
||||
d = strings.ToLower(d) // Normalize to lowercase
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
if processedDomains[d] {
|
||||
fmt.Printf("Warning: Skipping duplicate domain %s for host %s (Ghost Host detection)\n", d, host.UUID)
|
||||
continue
|
||||
}
|
||||
processedDomains[d] = true
|
||||
uniqueDomains = append(uniqueDomains, d)
|
||||
}
|
||||
|
||||
if len(uniqueDomains) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build handlers for this host
|
||||
handlers := make([]Handler, 0)
|
||||
|
||||
// Add HSTS header if enabled
|
||||
if host.HSTSEnabled {
|
||||
hstsValue := "max-age=31536000"
|
||||
if host.HSTSSubdomains {
|
||||
hstsValue += "; includeSubDomains"
|
||||
}
|
||||
handlers = append(handlers, HeaderHandler(map[string][]string{
|
||||
"Strict-Transport-Security": {hstsValue},
|
||||
}))
|
||||
}
|
||||
|
||||
// Add exploit blocking if enabled
|
||||
if host.BlockExploits {
|
||||
handlers = append(handlers, BlockExploitsHandler())
|
||||
}
|
||||
|
||||
// Handle custom locations first (more specific routes)
|
||||
for _, loc := range host.Locations {
|
||||
dial := fmt.Sprintf("%s:%d", loc.ForwardHost, loc.ForwardPort)
|
||||
locRoute := &Route{
|
||||
Match: []Match{
|
||||
{
|
||||
Host: uniqueDomains,
|
||||
Path: []string{loc.Path, loc.Path + "/*"},
|
||||
},
|
||||
},
|
||||
Handle: []Handler{
|
||||
ReverseProxyHandler(dial, host.WebsocketSupport),
|
||||
},
|
||||
Terminal: true,
|
||||
}
|
||||
routes = append(routes, locRoute)
|
||||
}
|
||||
|
||||
// Main proxy handler
|
||||
dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort)
|
||||
mainHandlers := append(handlers, ReverseProxyHandler(dial, host.WebsocketSupport))
|
||||
|
||||
route := &Route{
|
||||
Match: []Match{
|
||||
{Host: uniqueDomains},
|
||||
},
|
||||
Handle: mainHandlers,
|
||||
Terminal: true,
|
||||
}
|
||||
|
||||
routes = append(routes, route)
|
||||
}
|
||||
|
||||
// Add catch-all 404 handler
|
||||
// This matches any request that wasn't handled by previous routes
|
||||
if frontendDir != "" {
|
||||
catchAllRoute := &Route{
|
||||
Handle: []Handler{
|
||||
RewriteHandler("/unknown.html"),
|
||||
FileServerHandler(frontendDir),
|
||||
},
|
||||
Terminal: true,
|
||||
}
|
||||
routes = append(routes, catchAllRoute)
|
||||
}
|
||||
|
||||
config.Apps.HTTP.Servers["cpm_server"] = &Server{
|
||||
Listen: []string{":80", ":443"},
|
||||
Routes: routes,
|
||||
AutoHTTPS: &AutoHTTPSConfig{
|
||||
Disable: false,
|
||||
DisableRedir: false,
|
||||
},
|
||||
Logs: &ServerLogs{
|
||||
DefaultLoggerName: "access_log",
|
||||
},
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestGenerateConfig_Empty(t *testing.T) {
|
||||
config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
require.NotNil(t, config.Apps.HTTP)
|
||||
require.Empty(t, config.Apps.HTTP.Servers)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_SingleHost(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "test-uuid",
|
||||
Name: "Media",
|
||||
DomainNames: "media.example.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "media",
|
||||
ForwardPort: 32400,
|
||||
SSLForced: true,
|
||||
WebsocketSupport: false,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
require.NotNil(t, config.Apps.HTTP)
|
||||
require.Len(t, config.Apps.HTTP.Servers, 1)
|
||||
|
||||
server := config.Apps.HTTP.Servers["cpm_server"]
|
||||
require.NotNil(t, server)
|
||||
require.Contains(t, server.Listen, ":80")
|
||||
require.Contains(t, server.Listen, ":443")
|
||||
require.Len(t, server.Routes, 1)
|
||||
|
||||
route := server.Routes[0]
|
||||
require.Len(t, route.Match, 1)
|
||||
require.Equal(t, []string{"media.example.com"}, route.Match[0].Host)
|
||||
require.Len(t, route.Handle, 1)
|
||||
require.True(t, route.Terminal)
|
||||
|
||||
handler := route.Handle[0]
|
||||
require.Equal(t, "reverse_proxy", handler["handler"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_MultipleHosts(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "uuid-1",
|
||||
DomainNames: "site1.example.com",
|
||||
ForwardHost: "app1",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
UUID: "uuid-2",
|
||||
DomainNames: "site2.example.com",
|
||||
ForwardHost: "app2",
|
||||
ForwardPort: 8081,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, config.Apps.HTTP.Servers["cpm_server"].Routes, 2)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "uuid-ws",
|
||||
DomainNames: "ws.example.com",
|
||||
ForwardHost: "wsapp",
|
||||
ForwardPort: 3000,
|
||||
WebsocketSupport: true,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
route := config.Apps.HTTP.Servers["cpm_server"].Routes[0]
|
||||
handler := route.Handle[0]
|
||||
|
||||
// Check WebSocket headers are present
|
||||
require.NotNil(t, handler["headers"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_EmptyDomain(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "bad-uuid",
|
||||
DomainNames: "",
|
||||
ForwardHost: "app",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
// Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here)
|
||||
require.Empty(t, config.Apps.HTTP.Servers["cpm_server"].Routes)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_Logging(t *testing.T) {
|
||||
hosts := []models.ProxyHost{}
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify logging configuration
|
||||
require.NotNil(t, config.Logging)
|
||||
require.NotNil(t, config.Logging.Logs)
|
||||
require.NotNil(t, config.Logging.Logs["access"])
|
||||
require.Equal(t, "DEBUG", config.Logging.Logs["access"].Level)
|
||||
require.Contains(t, config.Logging.Logs["access"].Writer.Filename, "access.log")
|
||||
require.Equal(t, 10, config.Logging.Logs["access"].Writer.RollSize)
|
||||
require.Equal(t, 5, config.Logging.Logs["access"].Writer.RollKeep)
|
||||
require.Equal(t, 7, config.Logging.Logs["access"].Writer.RollKeepDays)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_Advanced(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "advanced-uuid",
|
||||
Name: "Advanced",
|
||||
DomainNames: "advanced.example.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "advanced",
|
||||
ForwardPort: 8080,
|
||||
SSLForced: true,
|
||||
HSTSEnabled: true,
|
||||
HSTSSubdomains: true,
|
||||
BlockExploits: true,
|
||||
Enabled: true,
|
||||
Locations: []models.Location{
|
||||
{
|
||||
Path: "/api",
|
||||
ForwardHost: "api-service",
|
||||
ForwardPort: 9000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
|
||||
server := config.Apps.HTTP.Servers["cpm_server"]
|
||||
require.NotNil(t, server)
|
||||
// Should have 2 routes: 1 for location /api, 1 for main domain
|
||||
require.Len(t, server.Routes, 2)
|
||||
|
||||
// Check Location Route (should be first as it is more specific)
|
||||
locRoute := server.Routes[0]
|
||||
require.Equal(t, []string{"/api", "/api/*"}, locRoute.Match[0].Path)
|
||||
require.Equal(t, []string{"advanced.example.com"}, locRoute.Match[0].Host)
|
||||
|
||||
// Check Main Route
|
||||
mainRoute := server.Routes[1]
|
||||
require.Nil(t, mainRoute.Match[0].Path) // No path means all paths
|
||||
require.Equal(t, []string{"advanced.example.com"}, mainRoute.Match[0].Host)
|
||||
|
||||
// Check HSTS and BlockExploits handlers in main route
|
||||
// Handlers are: [HSTS, BlockExploits, ReverseProxy]
|
||||
// But wait, BlockExploitsHandler implementation details?
|
||||
// Let's just check count for now or inspect types if possible.
|
||||
// Based on code:
|
||||
// handlers = append(handlers, HeaderHandler(...)) // HSTS
|
||||
// handlers = append(handlers, BlockExploitsHandler()) // BlockExploits
|
||||
// mainHandlers = append(handlers, ReverseProxyHandler(...))
|
||||
|
||||
require.Len(t, mainRoute.Handle, 3)
|
||||
|
||||
// Check HSTS
|
||||
hstsHandler := mainRoute.Handle[0]
|
||||
require.Equal(t, "headers", hstsHandler["handler"])
|
||||
// We can't easily check the map content without casting, but we know it's there.
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// Executor defines an interface for executing shell commands.
|
||||
type Executor interface {
|
||||
Execute(name string, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
// DefaultExecutor implements Executor using os/exec.
|
||||
type DefaultExecutor struct{}
|
||||
|
||||
func (e *DefaultExecutor) Execute(name string, args ...string) ([]byte, error) {
|
||||
return exec.Command(name, args...).Output()
|
||||
}
|
||||
|
||||
// CaddyConfig represents the root structure of Caddy's JSON config.
|
||||
type CaddyConfig struct {
|
||||
Apps *CaddyApps `json:"apps,omitempty"`
|
||||
}
|
||||
|
||||
// CaddyApps contains application-specific configurations.
|
||||
type CaddyApps struct {
|
||||
HTTP *CaddyHTTP `json:"http,omitempty"`
|
||||
}
|
||||
|
||||
// CaddyHTTP represents the HTTP app configuration.
|
||||
type CaddyHTTP struct {
|
||||
Servers map[string]*CaddyServer `json:"servers,omitempty"`
|
||||
}
|
||||
|
||||
// CaddyServer represents a single server configuration.
|
||||
type CaddyServer struct {
|
||||
Routes []*CaddyRoute `json:"routes,omitempty"`
|
||||
TLSConnectionPolicies interface{} `json:"tls_connection_policies,omitempty"`
|
||||
}
|
||||
|
||||
// CaddyRoute represents a single route with matchers and handlers.
|
||||
type CaddyRoute struct {
|
||||
Match []*CaddyMatcher `json:"match,omitempty"`
|
||||
Handle []*CaddyHandler `json:"handle,omitempty"`
|
||||
}
|
||||
|
||||
// CaddyMatcher represents route matching criteria.
|
||||
type CaddyMatcher struct {
|
||||
Host []string `json:"host,omitempty"`
|
||||
}
|
||||
|
||||
// CaddyHandler represents a handler in the route.
|
||||
type CaddyHandler struct {
|
||||
Handler string `json:"handler"`
|
||||
Upstreams interface{} `json:"upstreams,omitempty"`
|
||||
Headers interface{} `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// ParsedHost represents a single host detected during Caddyfile import.
|
||||
type ParsedHost struct {
|
||||
DomainNames string `json:"domain_names"`
|
||||
ForwardScheme string `json:"forward_scheme"`
|
||||
ForwardHost string `json:"forward_host"`
|
||||
ForwardPort int `json:"forward_port"`
|
||||
SSLForced bool `json:"ssl_forced"`
|
||||
WebsocketSupport bool `json:"websocket_support"`
|
||||
RawJSON string `json:"raw_json"` // Original Caddy JSON for this route
|
||||
Warnings []string `json:"warnings"` // Unsupported features
|
||||
}
|
||||
|
||||
// ImportResult contains parsed hosts and detected conflicts.
|
||||
type ImportResult struct {
|
||||
Hosts []ParsedHost `json:"hosts"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// Importer handles Caddyfile parsing and conversion to CPM+ models.
|
||||
type Importer struct {
|
||||
caddyBinaryPath string
|
||||
executor Executor
|
||||
}
|
||||
|
||||
// NewImporter creates a new Caddyfile importer.
|
||||
func NewImporter(binaryPath string) *Importer {
|
||||
if binaryPath == "" {
|
||||
binaryPath = "caddy" // Default to PATH
|
||||
}
|
||||
return &Importer{
|
||||
caddyBinaryPath: binaryPath,
|
||||
executor: &DefaultExecutor{},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseCaddyfile reads a Caddyfile and converts it to Caddy JSON.
|
||||
func (i *Importer) ParseCaddyfile(caddyfilePath string) ([]byte, error) {
|
||||
if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("caddyfile not found: %s", caddyfilePath)
|
||||
}
|
||||
|
||||
output, err := i.executor.Execute(i.caddyBinaryPath, "adapt", "--config", caddyfilePath, "--adapter", "caddyfile")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("caddy adapt failed: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// ExtractHosts parses Caddy JSON and extracts proxy host information.
|
||||
func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
|
||||
var config CaddyConfig
|
||||
if err := json.Unmarshal(caddyJSON, &config); err != nil {
|
||||
return nil, fmt.Errorf("parsing caddy json: %w", err)
|
||||
}
|
||||
|
||||
result := &ImportResult{
|
||||
Hosts: []ParsedHost{},
|
||||
Conflicts: []string{},
|
||||
Errors: []string{},
|
||||
}
|
||||
|
||||
if config.Apps == nil || config.Apps.HTTP == nil || config.Apps.HTTP.Servers == nil {
|
||||
return result, nil // Empty config
|
||||
}
|
||||
|
||||
seenDomains := make(map[string]bool)
|
||||
|
||||
for serverName, server := range config.Apps.HTTP.Servers {
|
||||
for routeIdx, route := range server.Routes {
|
||||
for _, match := range route.Match {
|
||||
for _, hostMatcher := range match.Host {
|
||||
domain := hostMatcher
|
||||
|
||||
// Check for duplicate domains (report domain names only)
|
||||
if seenDomains[domain] {
|
||||
result.Conflicts = append(result.Conflicts, domain)
|
||||
continue
|
||||
}
|
||||
seenDomains[domain] = true
|
||||
|
||||
// Extract reverse proxy handler
|
||||
host := ParsedHost{
|
||||
DomainNames: domain,
|
||||
SSLForced: strings.HasPrefix(domain, "https") || server.TLSConnectionPolicies != nil,
|
||||
}
|
||||
|
||||
// Find reverse_proxy handler
|
||||
for _, handler := range route.Handle {
|
||||
if handler.Handler == "reverse_proxy" {
|
||||
upstreams, _ := handler.Upstreams.([]interface{})
|
||||
if len(upstreams) > 0 {
|
||||
if upstream, ok := upstreams[0].(map[string]interface{}); ok {
|
||||
dial, _ := upstream["dial"].(string)
|
||||
if dial != "" {
|
||||
hostStr, portStr, err := net.SplitHostPort(dial)
|
||||
if err == nil {
|
||||
host.ForwardHost = hostStr
|
||||
if _, err := fmt.Sscanf(portStr, "%d", &host.ForwardPort); err != nil {
|
||||
host.ForwardPort = 80
|
||||
}
|
||||
} else {
|
||||
// Fallback: assume dial is just the host or has some other format
|
||||
// Try to handle simple "host:port" manually if net.SplitHostPort failed for some reason
|
||||
// or assume it's just a host
|
||||
parts := strings.Split(dial, ":")
|
||||
if len(parts) == 2 {
|
||||
host.ForwardHost = parts[0]
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &host.ForwardPort); err != nil {
|
||||
host.ForwardPort = 80
|
||||
}
|
||||
} else {
|
||||
host.ForwardHost = dial
|
||||
host.ForwardPort = 80
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for websocket support
|
||||
if headers, ok := handler.Headers.(map[string]interface{}); ok {
|
||||
if upgrade, ok := headers["Upgrade"].([]interface{}); ok {
|
||||
for _, v := range upgrade {
|
||||
if v == "websocket" {
|
||||
host.WebsocketSupport = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default scheme
|
||||
host.ForwardScheme = "http"
|
||||
if host.SSLForced {
|
||||
host.ForwardScheme = "https"
|
||||
}
|
||||
}
|
||||
|
||||
// Detect unsupported features
|
||||
if handler.Handler == "rewrite" {
|
||||
host.Warnings = append(host.Warnings, "Rewrite rules not supported - manual configuration required")
|
||||
}
|
||||
if handler.Handler == "file_server" {
|
||||
host.Warnings = append(host.Warnings, "File server directives not supported")
|
||||
}
|
||||
}
|
||||
|
||||
// Store raw JSON for this route
|
||||
routeJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"server": serverName,
|
||||
"route": routeIdx,
|
||||
"data": route,
|
||||
})
|
||||
host.RawJSON = string(routeJSON)
|
||||
|
||||
result.Hosts = append(result.Hosts, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ImportFile performs complete import: parse Caddyfile and extract hosts.
|
||||
func (i *Importer) ImportFile(caddyfilePath string) (*ImportResult, error) {
|
||||
caddyJSON, err := i.ParseCaddyfile(caddyfilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return i.ExtractHosts(caddyJSON)
|
||||
}
|
||||
|
||||
// ConvertToProxyHosts converts parsed hosts to ProxyHost models.
|
||||
func ConvertToProxyHosts(parsedHosts []ParsedHost) []models.ProxyHost {
|
||||
hosts := make([]models.ProxyHost, 0, len(parsedHosts))
|
||||
|
||||
for _, parsed := range parsedHosts {
|
||||
if parsed.ForwardHost == "" || parsed.ForwardPort == 0 {
|
||||
continue // Skip invalid entries
|
||||
}
|
||||
|
||||
hosts = append(hosts, models.ProxyHost{
|
||||
Name: parsed.DomainNames, // Can be customized by user during review
|
||||
DomainNames: parsed.DomainNames,
|
||||
ForwardScheme: parsed.ForwardScheme,
|
||||
ForwardHost: parsed.ForwardHost,
|
||||
ForwardPort: parsed.ForwardPort,
|
||||
SSLForced: parsed.SSLForced,
|
||||
WebsocketSupport: parsed.WebsocketSupport,
|
||||
})
|
||||
}
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
// ValidateCaddyBinary checks if the Caddy binary is available.
|
||||
func (i *Importer) ValidateCaddyBinary() error {
|
||||
_, err := i.executor.Execute(i.caddyBinaryPath, "version")
|
||||
if err != nil {
|
||||
return errors.New("caddy binary not found or not executable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BackupCaddyfile creates a timestamped backup of the original Caddyfile.
|
||||
func BackupCaddyfile(originalPath, backupDir string) (string, error) {
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("creating backup directory: %w", err)
|
||||
}
|
||||
|
||||
timestamp := fmt.Sprintf("%d", os.Getpid()) // Simple timestamp placeholder
|
||||
backupPath := filepath.Join(backupDir, fmt.Sprintf("Caddyfile.%s.backup", timestamp))
|
||||
|
||||
input, err := os.ReadFile(originalPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading original file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(backupPath, input, 0644); err != nil {
|
||||
return "", fmt.Errorf("writing backup: %w", err)
|
||||
}
|
||||
|
||||
return backupPath, nil
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewImporter(t *testing.T) {
|
||||
importer := NewImporter("/usr/bin/caddy")
|
||||
assert.NotNil(t, importer)
|
||||
assert.Equal(t, "/usr/bin/caddy", importer.caddyBinaryPath)
|
||||
|
||||
importerDefault := NewImporter("")
|
||||
assert.NotNil(t, importerDefault)
|
||||
assert.Equal(t, "caddy", importerDefault.caddyBinaryPath)
|
||||
}
|
||||
|
||||
func TestImporter_ParseCaddyfile_NotFound(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
_, err := importer.ParseCaddyfile("non-existent-file")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "caddyfile not found")
|
||||
}
|
||||
|
||||
type MockExecutor struct {
|
||||
Output []byte
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *MockExecutor) Execute(name string, args ...string) ([]byte, error) {
|
||||
return m.Output, m.Err
|
||||
}
|
||||
|
||||
func TestImporter_ParseCaddyfile_Success(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
mockExecutor := &MockExecutor{
|
||||
Output: []byte(`{"apps": {"http": {"servers": {}}}}`),
|
||||
Err: nil,
|
||||
}
|
||||
importer.executor = mockExecutor
|
||||
|
||||
// Create a dummy file to bypass os.Stat check
|
||||
tmpFile := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
err := os.WriteFile(tmpFile, []byte("foo"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output, err := importer.ParseCaddyfile(tmpFile)
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"apps": {"http": {"servers": {}}}}`, string(output))
|
||||
}
|
||||
|
||||
func TestImporter_ParseCaddyfile_Failure(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
mockExecutor := &MockExecutor{
|
||||
Output: []byte("syntax error"),
|
||||
Err: assert.AnError,
|
||||
}
|
||||
importer.executor = mockExecutor
|
||||
|
||||
// Create a dummy file
|
||||
tmpFile := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
err := os.WriteFile(tmpFile, []byte("foo"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = importer.ParseCaddyfile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "caddy adapt failed")
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
|
||||
// Test Case 1: Empty Config
|
||||
emptyJSON := []byte(`{}`)
|
||||
result, err := importer.ExtractHosts(emptyJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, result.Hosts)
|
||||
|
||||
// Test Case 2: Invalid JSON
|
||||
invalidJSON := []byte(`{invalid`)
|
||||
_, err = importer.ExtractHosts(invalidJSON)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test Case 3: Valid Config with Reverse Proxy
|
||||
validJSON := []byte(`{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [{"host": ["example.com"]}],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": [{"dial": "127.0.0.1:8080"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
result, err = importer.ExtractHosts(validJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result.Hosts, 1)
|
||||
assert.Equal(t, "example.com", result.Hosts[0].DomainNames)
|
||||
assert.Equal(t, "127.0.0.1", result.Hosts[0].ForwardHost)
|
||||
assert.Equal(t, 8080, result.Hosts[0].ForwardPort)
|
||||
|
||||
// Test Case 4: Duplicate Domain
|
||||
duplicateJSON := []byte(`{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [{"host": ["example.com"]}],
|
||||
"handle": [{"handler": "reverse_proxy"}]
|
||||
},
|
||||
{
|
||||
"match": [{"host": ["example.com"]}],
|
||||
"handle": [{"handler": "reverse_proxy"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
result, err = importer.ExtractHosts(duplicateJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result.Hosts, 1)
|
||||
assert.Len(t, result.Conflicts, 1)
|
||||
assert.Equal(t, "example.com", result.Conflicts[0])
|
||||
|
||||
// Test Case 5: Unsupported Features
|
||||
unsupportedJSON := []byte(`{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [{"host": ["files.example.com"]}],
|
||||
"handle": [
|
||||
{"handler": "file_server"},
|
||||
{"handler": "rewrite"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
result, err = importer.ExtractHosts(unsupportedJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result.Hosts, 1)
|
||||
assert.Len(t, result.Hosts[0].Warnings, 2)
|
||||
assert.Contains(t, result.Hosts[0].Warnings, "File server directives not supported")
|
||||
assert.Contains(t, result.Hosts[0].Warnings, "Rewrite rules not supported - manual configuration required")
|
||||
}
|
||||
|
||||
func TestImporter_ImportFile(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
mockExecutor := &MockExecutor{
|
||||
Output: []byte(`{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [{"host": ["example.com"]}],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": [{"dial": "127.0.0.1:8080"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
Err: nil,
|
||||
}
|
||||
importer.executor = mockExecutor
|
||||
|
||||
// Create a dummy file
|
||||
tmpFile := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
err := os.WriteFile(tmpFile, []byte("foo"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result, err := importer.ImportFile(tmpFile)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result.Hosts, 1)
|
||||
assert.Equal(t, "example.com", result.Hosts[0].DomainNames)
|
||||
}
|
||||
|
||||
func TestConvertToProxyHosts(t *testing.T) {
|
||||
parsedHosts := []ParsedHost{
|
||||
{
|
||||
DomainNames: "example.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
SSLForced: true,
|
||||
WebsocketSupport: true,
|
||||
},
|
||||
{
|
||||
DomainNames: "invalid.com",
|
||||
ForwardHost: "", // Invalid
|
||||
},
|
||||
}
|
||||
|
||||
hosts := ConvertToProxyHosts(parsedHosts)
|
||||
assert.Len(t, hosts, 1)
|
||||
assert.Equal(t, "example.com", hosts[0].DomainNames)
|
||||
assert.Equal(t, "127.0.0.1", hosts[0].ForwardHost)
|
||||
assert.Equal(t, 8080, hosts[0].ForwardPort)
|
||||
assert.True(t, hosts[0].SSLForced)
|
||||
assert.True(t, hosts[0].WebsocketSupport)
|
||||
}
|
||||
|
||||
func TestImporter_ValidateCaddyBinary(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
|
||||
// Success
|
||||
importer.executor = &MockExecutor{Output: []byte("v2.0.0"), Err: nil}
|
||||
err := importer.ValidateCaddyBinary()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Failure
|
||||
importer.executor = &MockExecutor{Output: nil, Err: assert.AnError}
|
||||
err = importer.ValidateCaddyBinary()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "caddy binary not found or not executable", err.Error())
|
||||
}
|
||||
|
||||
func TestBackupCaddyfile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalFile := filepath.Join(tmpDir, "Caddyfile")
|
||||
err := os.WriteFile(originalFile, []byte("original content"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
|
||||
// Success
|
||||
backupPath, err := BackupCaddyfile(originalFile, backupDir)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, backupPath)
|
||||
|
||||
content, err := os.ReadFile(backupPath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original content", string(content))
|
||||
|
||||
// Failure - Source not found
|
||||
_, err = BackupCaddyfile("non-existent", backupDir)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDefaultExecutor_Execute(t *testing.T) {
|
||||
executor := &DefaultExecutor{}
|
||||
output, err := executor.Execute("echo", "hello")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello\n", string(output))
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback.
|
||||
type Manager struct {
|
||||
client *Client
|
||||
db *gorm.DB
|
||||
configDir string
|
||||
frontendDir string
|
||||
}
|
||||
|
||||
// NewManager creates a configuration manager.
|
||||
func NewManager(client *Client, db *gorm.DB, configDir string, frontendDir string) *Manager {
|
||||
return &Manager{
|
||||
client: client,
|
||||
db: db,
|
||||
configDir: configDir,
|
||||
frontendDir: frontendDir,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure.
|
||||
func (m *Manager) ApplyConfig(ctx context.Context) error {
|
||||
// Fetch all proxy hosts from database
|
||||
var hosts []models.ProxyHost
|
||||
if err := m.db.Preload("Locations").Preload("Certificate").Find(&hosts).Error; err != nil {
|
||||
return fmt.Errorf("fetch proxy hosts: %w", err)
|
||||
}
|
||||
|
||||
// Fetch ACME email setting
|
||||
var acmeEmailSetting models.Setting
|
||||
var acmeEmail string
|
||||
if err := m.db.Where("key = ?", "caddy.acme_email").First(&acmeEmailSetting).Error; err == nil {
|
||||
acmeEmail = acmeEmailSetting.Value
|
||||
}
|
||||
|
||||
// Fetch SSL Provider setting
|
||||
var sslProviderSetting models.Setting
|
||||
var sslProvider string
|
||||
if err := m.db.Where("key = ?", "caddy.ssl_provider").First(&sslProviderSetting).Error; err == nil {
|
||||
sslProvider = sslProviderSetting.Value
|
||||
}
|
||||
|
||||
// Generate Caddy config
|
||||
config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate config: %w", err)
|
||||
}
|
||||
|
||||
// Validate before applying
|
||||
if err := Validate(config); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Save snapshot for rollback
|
||||
snapshotPath, err := m.saveSnapshot(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Calculate config hash for audit trail
|
||||
configJSON, _ := json.Marshal(config)
|
||||
configHash := fmt.Sprintf("%x", sha256.Sum256(configJSON))
|
||||
|
||||
// Apply to Caddy
|
||||
if err := m.client.Load(ctx, config); err != nil {
|
||||
// Remove the failed snapshot so rollback uses the previous one
|
||||
os.Remove(snapshotPath)
|
||||
|
||||
// Rollback on failure
|
||||
if rollbackErr := m.rollback(ctx); rollbackErr != nil {
|
||||
// If rollback fails, we still want to record the failure
|
||||
m.recordConfigChange(configHash, false, err.Error())
|
||||
return fmt.Errorf("apply failed: %w, rollback also failed: %v", err, rollbackErr)
|
||||
}
|
||||
|
||||
// Record failed attempt
|
||||
m.recordConfigChange(configHash, false, err.Error())
|
||||
return fmt.Errorf("apply failed (rolled back): %w", err)
|
||||
}
|
||||
|
||||
// Record successful application
|
||||
m.recordConfigChange(configHash, true, "")
|
||||
|
||||
// Cleanup old snapshots (keep last 10)
|
||||
if err := m.rotateSnapshots(10); err != nil {
|
||||
// Non-fatal - log but don't fail
|
||||
fmt.Printf("warning: snapshot rotation failed: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveSnapshot stores the config to disk with timestamp.
|
||||
func (m *Manager) saveSnapshot(config *Config) (string, error) {
|
||||
timestamp := time.Now().Unix()
|
||||
filename := fmt.Sprintf("config-%d.json", timestamp)
|
||||
path := filepath.Join(m.configDir, filename)
|
||||
|
||||
configJSON, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, configJSON, 0644); err != nil {
|
||||
return "", fmt.Errorf("write snapshot: %w", err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// rollback loads the most recent snapshot from disk.
|
||||
func (m *Manager) rollback(ctx context.Context) error {
|
||||
snapshots, err := m.listSnapshots()
|
||||
if err != nil || len(snapshots) == 0 {
|
||||
return fmt.Errorf("no snapshots available for rollback")
|
||||
}
|
||||
|
||||
// Load most recent snapshot
|
||||
latestSnapshot := snapshots[len(snapshots)-1]
|
||||
configJSON, err := os.ReadFile(latestSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read snapshot: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.Unmarshal(configJSON, &config); err != nil {
|
||||
return fmt.Errorf("unmarshal snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Apply the snapshot
|
||||
if err := m.client.Load(ctx, &config); err != nil {
|
||||
return fmt.Errorf("load snapshot: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listSnapshots returns all snapshot file paths sorted by modification time.
|
||||
func (m *Manager) listSnapshots() ([]string, error) {
|
||||
entries, err := os.ReadDir(m.configDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config dir: %w", err)
|
||||
}
|
||||
|
||||
var snapshots []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
snapshots = append(snapshots, filepath.Join(m.configDir, entry.Name()))
|
||||
}
|
||||
|
||||
// Sort by modification time
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
infoI, _ := os.Stat(snapshots[i])
|
||||
infoJ, _ := os.Stat(snapshots[j])
|
||||
return infoI.ModTime().Before(infoJ.ModTime())
|
||||
})
|
||||
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// rotateSnapshots keeps only the N most recent snapshots.
|
||||
func (m *Manager) rotateSnapshots(keep int) error {
|
||||
snapshots, err := m.listSnapshots()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(snapshots) <= keep {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete oldest snapshots
|
||||
toDelete := snapshots[:len(snapshots)-keep]
|
||||
for _, path := range toDelete {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("delete snapshot %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordConfigChange stores an audit record in the database.
|
||||
func (m *Manager) recordConfigChange(configHash string, success bool, errorMsg string) {
|
||||
record := models.CaddyConfig{
|
||||
ConfigHash: configHash,
|
||||
AppliedAt: time.Now(),
|
||||
Success: success,
|
||||
ErrorMsg: errorMsg,
|
||||
}
|
||||
|
||||
// Best effort - don't fail if audit logging fails
|
||||
m.db.Create(&record)
|
||||
}
|
||||
|
||||
// Ping checks if Caddy is reachable.
|
||||
func (m *Manager) Ping(ctx context.Context) error {
|
||||
return m.client.Ping(ctx)
|
||||
}
|
||||
|
||||
// GetCurrentConfig retrieves the running config from Caddy.
|
||||
func (m *Manager) GetCurrentConfig(ctx context.Context) (*Config, error) {
|
||||
return m.client.GetConfig(ctx)
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestManager_ApplyConfig(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == "POST" {
|
||||
// Verify payload
|
||||
var config Config
|
||||
err := json.NewDecoder(r.Body).Decode(&config)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Setup Manager
|
||||
tmpDir := t.TempDir()
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmpDir, "")
|
||||
|
||||
// Create a host
|
||||
host := models.ProxyHost{
|
||||
DomainNames: "example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
db.Create(&host)
|
||||
|
||||
// Apply Config
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify config was saved to DB
|
||||
var caddyConfig models.CaddyConfig
|
||||
err = db.First(&caddyConfig).Error
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, caddyConfig.Success)
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_Failure(t *testing.T) {
|
||||
// Mock Caddy Admin API to fail
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Setup Manager
|
||||
tmpDir := t.TempDir()
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmpDir, "")
|
||||
|
||||
// Create a host
|
||||
host := models.ProxyHost{
|
||||
DomainNames: "example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
require.NoError(t, db.Create(&host).Error)
|
||||
|
||||
// Apply Config - should fail
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apply failed")
|
||||
|
||||
// Verify failure was recorded
|
||||
var caddyConfig models.CaddyConfig
|
||||
err = db.First(&caddyConfig).Error
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, caddyConfig.Success)
|
||||
assert.NotEmpty(t, caddyConfig.ErrorMsg)
|
||||
}
|
||||
|
||||
func TestManager_Ping(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/config/" && r.Method == "GET" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, nil, "", "")
|
||||
|
||||
err := manager.Ping(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestManager_GetCurrentConfig(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/config/" && r.Method == "GET" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"apps": {"http": {}}}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, nil, "", "")
|
||||
|
||||
config, err := manager.GetCurrentConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, config)
|
||||
assert.NotNil(t, config.Apps)
|
||||
assert.NotNil(t, config.Apps.HTTP)
|
||||
}
|
||||
|
||||
func TestManager_RotateSnapshots(t *testing.T) {
|
||||
// Setup Manager
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Mock Caddy Admin API (Success)
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmpDir, "")
|
||||
|
||||
// Create 15 dummy config files
|
||||
for i := 0; i < 15; i++ {
|
||||
// Use past timestamps
|
||||
ts := time.Now().Add(-time.Duration(i+1) * time.Minute).Unix()
|
||||
fname := fmt.Sprintf("config-%d.json", ts)
|
||||
f, _ := os.Create(filepath.Join(tmpDir, fname))
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// Call ApplyConfig once
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check number of files
|
||||
files, _ := os.ReadDir(tmpDir)
|
||||
|
||||
// Count files matching config-*.json
|
||||
count := 0
|
||||
for _, f := range files {
|
||||
if filepath.Ext(f.Name()) == ".json" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
// Should be 10 (kept)
|
||||
assert.Equal(t, 10, count)
|
||||
}
|
||||
|
||||
func TestManager_Rollback_Success(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
// First call succeeds (initial setup), second call fails (bad config), third call succeeds (rollback)
|
||||
callCount := 0
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if r.URL.Path == "/load" && r.Method == "POST" {
|
||||
if callCount == 2 {
|
||||
w.WriteHeader(http.StatusInternalServerError) // Fail the second apply
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Setup Manager
|
||||
tmpDir := t.TempDir()
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmpDir, "")
|
||||
|
||||
// 1. Apply valid config (creates snapshot)
|
||||
host1 := models.ProxyHost{
|
||||
UUID: "uuid-1",
|
||||
DomainNames: "example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
db.Create(&host1)
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify snapshot exists
|
||||
snapshots, _ := manager.listSnapshots()
|
||||
assert.Len(t, snapshots, 1)
|
||||
|
||||
// Sleep to ensure different timestamp for next snapshot
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
// 2. Apply another config (will fail at Caddy level)
|
||||
host2 := models.ProxyHost{
|
||||
UUID: "uuid-2",
|
||||
DomainNames: "fail.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8081,
|
||||
}
|
||||
db.Create(&host2)
|
||||
|
||||
// This should fail, trigger rollback, and succeed in rolling back
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apply failed (rolled back)")
|
||||
|
||||
// Verify we still have 1 snapshot (the failed one was removed)
|
||||
snapshots, _ = manager.listSnapshots()
|
||||
assert.Len(t, snapshots, 1)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package caddy
|
||||
|
||||
// Config represents Caddy's top-level JSON configuration structure.
|
||||
// Reference: https://caddyserver.com/docs/json/
|
||||
type Config struct {
|
||||
Apps Apps `json:"apps"`
|
||||
Logging *LoggingConfig `json:"logging,omitempty"`
|
||||
Storage Storage `json:"storage,omitempty"`
|
||||
}
|
||||
|
||||
// LoggingConfig configures Caddy's logging facility.
|
||||
type LoggingConfig struct {
|
||||
Logs map[string]*LogConfig `json:"logs,omitempty"`
|
||||
Sinks *SinkConfig `json:"sinks,omitempty"`
|
||||
}
|
||||
|
||||
// LogConfig configures a specific logger.
|
||||
type LogConfig struct {
|
||||
Writer *WriterConfig `json:"writer,omitempty"`
|
||||
Encoder *EncoderConfig `json:"encoder,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
}
|
||||
|
||||
// WriterConfig configures the log writer (output).
|
||||
type WriterConfig struct {
|
||||
Output string `json:"output"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Roll bool `json:"roll,omitempty"`
|
||||
RollSize int `json:"roll_size_mb,omitempty"`
|
||||
RollKeep int `json:"roll_keep,omitempty"`
|
||||
RollKeepDays int `json:"roll_keep_days,omitempty"`
|
||||
}
|
||||
|
||||
// EncoderConfig configures the log format.
|
||||
type EncoderConfig struct {
|
||||
Format string `json:"format"` // "json", "console", etc.
|
||||
}
|
||||
|
||||
// SinkConfig configures log sinks (e.g. stderr).
|
||||
type SinkConfig struct {
|
||||
Writer *WriterConfig `json:"writer,omitempty"`
|
||||
}
|
||||
|
||||
// Storage configures the storage module.
|
||||
type Storage struct {
|
||||
System string `json:"module"`
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// Apps contains all Caddy app modules.
|
||||
type Apps struct {
|
||||
HTTP *HTTPApp `json:"http,omitempty"`
|
||||
TLS *TLSApp `json:"tls,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPApp configures the HTTP app.
|
||||
type HTTPApp struct {
|
||||
Servers map[string]*Server `json:"servers"`
|
||||
}
|
||||
|
||||
// Server represents an HTTP server instance.
|
||||
type Server struct {
|
||||
Listen []string `json:"listen"`
|
||||
Routes []*Route `json:"routes"`
|
||||
AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"`
|
||||
Logs *ServerLogs `json:"logs,omitempty"`
|
||||
}
|
||||
|
||||
// AutoHTTPSConfig controls automatic HTTPS behavior.
|
||||
type AutoHTTPSConfig struct {
|
||||
Disable bool `json:"disable,omitempty"`
|
||||
DisableRedir bool `json:"disable_redirects,omitempty"`
|
||||
Skip []string `json:"skip,omitempty"`
|
||||
}
|
||||
|
||||
// ServerLogs configures access logging.
|
||||
type ServerLogs struct {
|
||||
DefaultLoggerName string `json:"default_logger_name,omitempty"`
|
||||
}
|
||||
|
||||
// Route represents an HTTP route (matcher + handlers).
|
||||
type Route struct {
|
||||
Match []Match `json:"match,omitempty"`
|
||||
Handle []Handler `json:"handle"`
|
||||
Terminal bool `json:"terminal,omitempty"`
|
||||
}
|
||||
|
||||
// Match represents a request matcher.
|
||||
type Match struct {
|
||||
Host []string `json:"host,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// Handler is the interface for all handler types.
|
||||
// Actual types will implement handler-specific fields.
|
||||
type Handler map[string]interface{}
|
||||
|
||||
// ReverseProxyHandler creates a reverse_proxy handler.
|
||||
func ReverseProxyHandler(dial string, enableWS bool) Handler {
|
||||
h := Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{
|
||||
{"dial": dial},
|
||||
},
|
||||
}
|
||||
|
||||
if enableWS {
|
||||
// Enable WebSocket support by preserving upgrade headers
|
||||
h["headers"] = map[string]interface{}{
|
||||
"request": map[string]interface{}{
|
||||
"set": map[string][]string{
|
||||
"Upgrade": {"{http.request.header.Upgrade}"},
|
||||
"Connection": {"{http.request.header.Connection}"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// HeaderHandler creates a handler that sets HTTP response headers.
|
||||
func HeaderHandler(headers map[string][]string) Handler {
|
||||
return Handler{
|
||||
"handler": "headers",
|
||||
"response": map[string]interface{}{
|
||||
"set": headers,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BlockExploitsHandler creates a handler that blocks common exploits.
|
||||
// This uses Caddy's request matchers to block malicious patterns.
|
||||
func BlockExploitsHandler() Handler {
|
||||
return Handler{
|
||||
"handler": "vars",
|
||||
// Placeholder for future exploit blocking logic
|
||||
// Can be extended with specific matchers for SQL injection, XSS, etc.
|
||||
}
|
||||
}
|
||||
|
||||
// RewriteHandler creates a rewrite handler.
|
||||
func RewriteHandler(uri string) Handler {
|
||||
return Handler{
|
||||
"handler": "rewrite",
|
||||
"uri": uri,
|
||||
}
|
||||
}
|
||||
|
||||
// FileServerHandler creates a file_server handler.
|
||||
func FileServerHandler(root string) Handler {
|
||||
return Handler{
|
||||
"handler": "file_server",
|
||||
"root": root,
|
||||
}
|
||||
}
|
||||
|
||||
// TLSApp configures the TLS app for certificate management.
|
||||
type TLSApp struct {
|
||||
Automation *AutomationConfig `json:"automation,omitempty"`
|
||||
Certificates *CertificatesConfig `json:"certificates,omitempty"`
|
||||
}
|
||||
|
||||
// CertificatesConfig configures manual certificate loading.
|
||||
type CertificatesConfig struct {
|
||||
LoadPEM []LoadPEMConfig `json:"load_pem,omitempty"`
|
||||
}
|
||||
|
||||
// LoadPEMConfig defines a PEM-loaded certificate.
|
||||
type LoadPEMConfig struct {
|
||||
Certificate string `json:"certificate"`
|
||||
Key string `json:"key"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// AutomationConfig controls certificate automation.
|
||||
type AutomationConfig struct {
|
||||
Policies []*AutomationPolicy `json:"policies,omitempty"`
|
||||
}
|
||||
|
||||
// AutomationPolicy defines certificate management for specific domains.
|
||||
type AutomationPolicy struct {
|
||||
Subjects []string `json:"subjects,omitempty"`
|
||||
IssuersRaw []interface{} `json:"issuers,omitempty"`
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHandlers(t *testing.T) {
|
||||
// Test RewriteHandler
|
||||
h := RewriteHandler("/new-uri")
|
||||
assert.Equal(t, "rewrite", h["handler"])
|
||||
assert.Equal(t, "/new-uri", h["uri"])
|
||||
|
||||
// Test FileServerHandler
|
||||
h = FileServerHandler("/var/www/html")
|
||||
assert.Equal(t, "file_server", h["handler"])
|
||||
assert.Equal(t, "/var/www/html", h["root"])
|
||||
|
||||
// Test ReverseProxyHandler
|
||||
h = ReverseProxyHandler("localhost:8080", true)
|
||||
assert.Equal(t, "reverse_proxy", h["handler"])
|
||||
|
||||
// Test HeaderHandler
|
||||
h = HeaderHandler(map[string][]string{"X-Test": {"Value"}})
|
||||
assert.Equal(t, "headers", h["handler"])
|
||||
|
||||
// Test BlockExploitsHandler
|
||||
h = BlockExploitsHandler()
|
||||
assert.Equal(t, "vars", h["handler"])
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate performs pre-flight validation on a Caddy config before applying it.
|
||||
func Validate(cfg *Config) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config cannot be nil")
|
||||
}
|
||||
|
||||
if cfg.Apps.HTTP == nil {
|
||||
return nil // Empty config is valid
|
||||
}
|
||||
|
||||
// Track seen hosts to detect duplicates
|
||||
seenHosts := make(map[string]bool)
|
||||
|
||||
for serverName, server := range cfg.Apps.HTTP.Servers {
|
||||
if len(server.Listen) == 0 {
|
||||
return fmt.Errorf("server %s has no listen addresses", serverName)
|
||||
}
|
||||
|
||||
// Validate listen addresses
|
||||
for _, addr := range server.Listen {
|
||||
if err := validateListenAddr(addr); err != nil {
|
||||
return fmt.Errorf("invalid listen address %s in server %s: %w", addr, serverName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate routes
|
||||
for i, route := range server.Routes {
|
||||
if err := validateRoute(route, seenHosts); err != nil {
|
||||
return fmt.Errorf("invalid route %d in server %s: %w", i, serverName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate JSON marshalling works
|
||||
if _, err := json.Marshal(cfg); err != nil {
|
||||
return fmt.Errorf("config cannot be marshalled to JSON: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListenAddr(addr string) error {
|
||||
// Strip network type prefix if present (tcp/, udp/)
|
||||
if idx := strings.Index(addr, "/"); idx != -1 {
|
||||
addr = addr[idx+1:]
|
||||
}
|
||||
|
||||
// Parse host:port
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid address format: %w", err)
|
||||
}
|
||||
|
||||
// Validate port
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port: %w", err)
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return fmt.Errorf("port %d out of range (1-65535)", port)
|
||||
}
|
||||
|
||||
// Validate host (allow empty for wildcard binding)
|
||||
if host != "" && net.ParseIP(host) == nil {
|
||||
return fmt.Errorf("invalid IP address: %s", host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRoute(route *Route, seenHosts map[string]bool) error {
|
||||
if len(route.Handle) == 0 {
|
||||
return fmt.Errorf("route has no handlers")
|
||||
}
|
||||
|
||||
// Check for duplicate host matchers
|
||||
for _, match := range route.Match {
|
||||
for _, host := range match.Host {
|
||||
if seenHosts[host] {
|
||||
return fmt.Errorf("duplicate host matcher: %s", host)
|
||||
}
|
||||
seenHosts[host] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate handlers
|
||||
for i, handler := range route.Handle {
|
||||
if err := validateHandler(handler); err != nil {
|
||||
return fmt.Errorf("invalid handler %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHandler(handler Handler) error {
|
||||
handlerType, ok := handler["handler"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("handler missing 'handler' field")
|
||||
}
|
||||
|
||||
switch handlerType {
|
||||
case "reverse_proxy":
|
||||
return validateReverseProxy(handler)
|
||||
case "file_server", "static_response":
|
||||
return nil // Accept other common handlers
|
||||
default:
|
||||
// Unknown handlers are allowed (Caddy is extensible)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateReverseProxy(handler Handler) error {
|
||||
upstreams, ok := handler["upstreams"].([]map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("reverse_proxy missing upstreams")
|
||||
}
|
||||
|
||||
if len(upstreams) == 0 {
|
||||
return fmt.Errorf("reverse_proxy has no upstreams")
|
||||
}
|
||||
|
||||
for i, upstream := range upstreams {
|
||||
dial, ok := upstream["dial"].(string)
|
||||
if !ok || dial == "" {
|
||||
return fmt.Errorf("upstream %d missing dial address", i)
|
||||
}
|
||||
|
||||
// Validate dial address format (host:port)
|
||||
if _, _, err := net.SplitHostPort(dial); err != nil {
|
||||
return fmt.Errorf("upstream %d has invalid dial address %s: %w", i, dial, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestValidate_EmptyConfig(t *testing.T) {
|
||||
config := &Config{}
|
||||
err := Validate(config)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidate_ValidConfig(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "test",
|
||||
DomainNames: "test.example.com",
|
||||
ForwardHost: "10.0.1.100",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
err := Validate(config)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidate_DuplicateHosts(t *testing.T) {
|
||||
config := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*Route{
|
||||
{
|
||||
Match: []Match{{Host: []string{"test.com"}}},
|
||||
Handle: []Handler{
|
||||
ReverseProxyHandler("app:8080", false),
|
||||
},
|
||||
},
|
||||
{
|
||||
Match: []Match{{Host: []string{"test.com"}}},
|
||||
Handle: []Handler{
|
||||
ReverseProxyHandler("app2:8080", false),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Validate(config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "duplicate host")
|
||||
}
|
||||
|
||||
func TestValidate_NoListenAddresses(t *testing.T) {
|
||||
config := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"srv": {
|
||||
Listen: []string{},
|
||||
Routes: []*Route{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Validate(config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no listen addresses")
|
||||
}
|
||||
|
||||
func TestValidate_InvalidPort(t *testing.T) {
|
||||
config := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"srv": {
|
||||
Listen: []string{":99999"},
|
||||
Routes: []*Route{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Validate(config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "out of range")
|
||||
}
|
||||
|
||||
func TestValidate_NoHandlers(t *testing.T) {
|
||||
config := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*Route{
|
||||
{
|
||||
Match: []Match{{Host: []string{"test.com"}}},
|
||||
Handle: []Handler{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Validate(config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no handlers")
|
||||
}
|
||||
|
||||
func TestValidateListenAddr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Valid", ":80", false},
|
||||
{"ValidIP", "127.0.0.1:80", false},
|
||||
{"ValidTCP", "tcp/127.0.0.1:80", false},
|
||||
{"ValidUDP", "udp/127.0.0.1:80", false},
|
||||
{"InvalidFormat", "invalid", true},
|
||||
{"InvalidPort", ":99999", true},
|
||||
{"InvalidPortNegative", ":-1", true},
|
||||
{"InvalidIP", "999.999.999.999:80", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateListenAddr(tt.addr)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReverseProxy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
handler Handler
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Valid",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{
|
||||
{"dial": "localhost:8080"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "MissingUpstreams",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "EmptyUpstreams",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "MissingDial",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{
|
||||
{"foo": "bar"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "InvalidDial",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{
|
||||
{"dial": "invalid"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateReverseProxy(tt.handler)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Config captures runtime configuration sourced from environment variables.
|
||||
type Config struct {
|
||||
Environment string
|
||||
HTTPPort string
|
||||
DatabasePath string
|
||||
FrontendDir string
|
||||
CaddyAdminAPI string
|
||||
CaddyConfigDir string
|
||||
CaddyBinary string
|
||||
ImportCaddyfile string
|
||||
ImportDir string
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
// Load reads env vars and falls back to defaults so the server can boot with zero configuration.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Environment: getEnv("CPM_ENV", "development"),
|
||||
HTTPPort: getEnv("CPM_HTTP_PORT", "8080"),
|
||||
DatabasePath: getEnv("CPM_DB_PATH", filepath.Join("data", "cpm.db")),
|
||||
FrontendDir: getEnv("CPM_FRONTEND_DIR", filepath.Clean(filepath.Join("..", "frontend", "dist"))),
|
||||
CaddyAdminAPI: getEnv("CPM_CADDY_ADMIN_API", "http://localhost:2019"),
|
||||
CaddyConfigDir: getEnv("CPM_CADDY_CONFIG_DIR", filepath.Join("data", "caddy")),
|
||||
CaddyBinary: getEnv("CPM_CADDY_BINARY", "caddy"),
|
||||
ImportCaddyfile: getEnv("CPM_IMPORT_CADDYFILE", "/import/Caddyfile"),
|
||||
ImportDir: getEnv("CPM_IMPORT_DIR", filepath.Join("data", "imports")),
|
||||
JWTSecret: getEnv("CPM_JWT_SECRET", "change-me-in-production"),
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o755); err != nil {
|
||||
return Config{}, fmt.Errorf("ensure data directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.CaddyConfigDir, 0o755); err != nil {
|
||||
return Config{}, fmt.Errorf("ensure caddy config directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.ImportDir, 0o755); err != nil {
|
||||
return Config{}, fmt.Errorf("ensure import directory: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
// Save original env vars
|
||||
originalEnv := os.Getenv("CPM_ENV")
|
||||
defer os.Setenv("CPM_ENV", originalEnv)
|
||||
|
||||
// Set test env vars
|
||||
os.Setenv("CPM_ENV", "test")
|
||||
tempDir := t.TempDir()
|
||||
os.Setenv("CPM_DB_PATH", filepath.Join(tempDir, "test.db"))
|
||||
os.Setenv("CPM_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy"))
|
||||
os.Setenv("CPM_IMPORT_DIR", filepath.Join(tempDir, "imports"))
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "test", cfg.Environment)
|
||||
assert.Equal(t, filepath.Join(tempDir, "test.db"), cfg.DatabasePath)
|
||||
assert.DirExists(t, filepath.Dir(cfg.DatabasePath))
|
||||
assert.DirExists(t, cfg.CaddyConfigDir)
|
||||
assert.DirExists(t, cfg.ImportDir)
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
// Clear env vars to test defaults
|
||||
os.Unsetenv("CPM_ENV")
|
||||
os.Unsetenv("CPM_HTTP_PORT")
|
||||
// We need to set paths to a temp dir to avoid creating real dirs in test
|
||||
tempDir := t.TempDir()
|
||||
os.Setenv("CPM_DB_PATH", filepath.Join(tempDir, "default.db"))
|
||||
os.Setenv("CPM_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy_default"))
|
||||
os.Setenv("CPM_IMPORT_DIR", filepath.Join(tempDir, "imports_default"))
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "development", cfg.Environment)
|
||||
assert.Equal(t, "8080", cfg.HTTPPort)
|
||||
}
|
||||
|
||||
func TestLoad_Error(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
filePath := filepath.Join(tempDir, "file")
|
||||
f, err := os.Create(filePath)
|
||||
require.NoError(t, err)
|
||||
f.Close()
|
||||
|
||||
// Case 1: CaddyConfigDir is a file
|
||||
os.Setenv("CPM_CADDY_CONFIG_DIR", filePath)
|
||||
// Set other paths to valid locations to isolate the error
|
||||
os.Setenv("CPM_DB_PATH", filepath.Join(tempDir, "db", "test.db"))
|
||||
os.Setenv("CPM_IMPORT_DIR", filepath.Join(tempDir, "imports"))
|
||||
|
||||
_, err = Load()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ensure caddy config directory")
|
||||
|
||||
// Case 2: ImportDir is a file
|
||||
os.Setenv("CPM_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy"))
|
||||
os.Setenv("CPM_IMPORT_DIR", filePath)
|
||||
|
||||
_, err = Load()
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "ensure import directory")
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Connect opens a SQLite database connection.
|
||||
func Connect(dbPath string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConnect(t *testing.T) {
|
||||
// Test with memory DB
|
||||
db, err := Connect("file::memory:?cache=shared")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, db)
|
||||
|
||||
// Test with file DB
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
db, err = Connect(dbPath)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, db)
|
||||
}
|
||||
|
||||
func TestConnect_Error(t *testing.T) {
|
||||
// Test with invalid path (directory)
|
||||
tempDir := t.TempDir()
|
||||
_, err := Connect(tempDir)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccessList defines IP-based or auth-based access control rules
|
||||
// that can be applied to proxy hosts.
|
||||
type AccessList struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"` // "allow", "deny", "basic_auth", "forward_auth"
|
||||
Rules string `json:"rules" gorm:"type:text"` // JSON array of rule definitions
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CaddyConfig stores an audit trail of Caddy configuration changes.
|
||||
type CaddyConfig struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
ConfigHash string `json:"config_hash" gorm:"index"`
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
Success bool `json:"success"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Domain struct {
|
||||
ID uint `json:"id" gorm:"primarykey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
}
|
||||
|
||||
func (d *Domain) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if d.UUID == "" {
|
||||
d.UUID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDomain_BeforeCreate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
db.AutoMigrate(&Domain{})
|
||||
|
||||
// Case 1: UUID is empty, should be generated
|
||||
d1 := &Domain{Name: "example.com"}
|
||||
err = db.Create(d1).Error
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, d1.UUID)
|
||||
|
||||
// Case 2: UUID is provided, should be kept
|
||||
uuid := "123e4567-e89b-12d3-a456-426614174000"
|
||||
d2 := &Domain{Name: "test.com", UUID: uuid}
|
||||
err = db.Create(d2).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uuid, d2.UUID)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ImportSession tracks Caddyfile import operations with pending state
|
||||
// until user reviews and confirms via UI.
|
||||
type ImportSession struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
SourceFile string `json:"source_file"` // Path to original Caddyfile
|
||||
Status string `json:"status" gorm:"default:'pending'"` // "pending", "reviewing", "committed", "rejected", "failed"
|
||||
ParsedData string `json:"parsed_data" gorm:"type:text"` // JSON representation of detected hosts
|
||||
ConflictReport string `json:"conflict_report" gorm:"type:text"` // JSON array of conflicts
|
||||
UserResolutions string `json:"user_resolutions" gorm:"type:text"` // JSON map of conflict resolutions
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CommittedAt *time.Time `json:"committed_at,omitempty"`
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Location represents a custom path-based proxy configuration within a ProxyHost.
|
||||
type Location struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
|
||||
ProxyHostID uint `json:"proxy_host_id" gorm:"not null;index"`
|
||||
Path string `json:"path" gorm:"not null"` // e.g., /api, /admin
|
||||
ForwardScheme string `json:"forward_scheme" gorm:"default:http"`
|
||||
ForwardHost string `json:"forward_host" gorm:"not null"`
|
||||
ForwardPort int `json:"forward_port" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package models
|
||||
|
||||
// CaddyAccessLog represents a structured log entry from Caddy's JSON access logs.
|
||||
type CaddyAccessLog struct {
|
||||
Level string `json:"level"`
|
||||
Ts float64 `json:"ts"`
|
||||
Logger string `json:"logger"`
|
||||
Msg string `json:"msg"`
|
||||
Request struct {
|
||||
RemoteIP string `json:"remote_ip"`
|
||||
RemotePort string `json:"remote_port"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Proto string `json:"proto"`
|
||||
Method string `json:"method"`
|
||||
Host string `json:"host"`
|
||||
URI string `json:"uri"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
TLS struct {
|
||||
Resumed bool `json:"resumed"`
|
||||
Version int `json:"version"`
|
||||
CipherSuite int `json:"cipher_suite"`
|
||||
Proto string `json:"proto"`
|
||||
ServerName string `json:"server_name"`
|
||||
} `json:"tls"`
|
||||
} `json:"request"`
|
||||
BytesRead int `json:"bytes_read"`
|
||||
UserID string `json:"user_id"`
|
||||
Duration float64 `json:"duration"`
|
||||
Size int `json:"size"`
|
||||
Status int `json:"status"`
|
||||
RespHeaders map[string][]string `json:"resp_headers"`
|
||||
}
|
||||
|
||||
// LogFilter defines criteria for filtering logs.
|
||||
type LogFilter struct {
|
||||
Search string `form:"search"`
|
||||
Host string `form:"host"`
|
||||
Status string `form:"status"` // e.g., "200", "4xx", "5xx"
|
||||
Level string `form:"level"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Sort string `form:"sort"`
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
NotificationTypeInfo NotificationType = "info"
|
||||
NotificationTypeSuccess NotificationType = "success"
|
||||
NotificationTypeWarning NotificationType = "warning"
|
||||
NotificationTypeError NotificationType = "error"
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Type NotificationType `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Read bool `json:"read"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (n *Notification) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if n.ID == "" {
|
||||
n.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NotificationProvider struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // discord, slack, gotify, telegram, generic, webhook
|
||||
URL string `json:"url"` // The shoutrrr URL or webhook URL
|
||||
Config string `json:"config"` // JSON payload template for custom webhooks
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// Notification Preferences
|
||||
NotifyProxyHosts bool `json:"notify_proxy_hosts" gorm:"default:true"`
|
||||
NotifyRemoteServers bool `json:"notify_remote_servers" gorm:"default:true"`
|
||||
NotifyDomains bool `json:"notify_domains" gorm:"default:true"`
|
||||
NotifyCerts bool `json:"notify_certs" gorm:"default:true"`
|
||||
NotifyUptime bool `json:"notify_uptime" gorm:"default:true"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (n *NotificationProvider) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if n.ID == "" {
|
||||
n.ID = uuid.New().String()
|
||||
}
|
||||
// Set defaults if not explicitly set (though gorm default tag handles DB side)
|
||||
// We can't easily distinguish between false and unset for bools here without pointers,
|
||||
// but for new creations via API, we can assume the frontend sends what it wants.
|
||||
// If we wanted to force defaults in Go:
|
||||
// n.NotifyProxyHosts = true ...
|
||||
return
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestNotificationProvider_BeforeCreate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Test",
|
||||
}
|
||||
err = db.Create(&provider).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, provider.ID)
|
||||
// Check defaults if any (currently none enforced in BeforeCreate other than ID)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestNotification_BeforeCreate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
db.AutoMigrate(&Notification{})
|
||||
|
||||
// Case 1: ID is empty, should be generated
|
||||
n1 := &Notification{Title: "Test", Message: "Test Message"}
|
||||
err = db.Create(n1).Error
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, n1.ID)
|
||||
|
||||
// Case 2: ID is provided, should be kept
|
||||
id := "123e4567-e89b-12d3-a456-426614174000"
|
||||
n2 := &Notification{ID: id, Title: "Test 2", Message: "Test Message 2"}
|
||||
err = db.Create(n2).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, id, n2.ID)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProxyHost represents a reverse proxy configuration.
|
||||
type ProxyHost struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name"`
|
||||
DomainNames string `json:"domain_names" gorm:"not null"` // Comma-separated list
|
||||
ForwardScheme string `json:"forward_scheme" gorm:"default:http"`
|
||||
ForwardHost string `json:"forward_host" gorm:"not null"`
|
||||
ForwardPort int `json:"forward_port" gorm:"not null"`
|
||||
SSLForced bool `json:"ssl_forced" gorm:"default:false"`
|
||||
HTTP2Support bool `json:"http2_support" gorm:"default:true"`
|
||||
HSTSEnabled bool `json:"hsts_enabled" gorm:"default:false"`
|
||||
HSTSSubdomains bool `json:"hsts_subdomains" gorm:"default:false"`
|
||||
BlockExploits bool `json:"block_exploits" gorm:"default:true"`
|
||||
WebsocketSupport bool `json:"websocket_support" gorm:"default:false"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
CertificateID *uint `json:"certificate_id"`
|
||||
Certificate *SSLCertificate `json:"certificate" gorm:"foreignKey:CertificateID"`
|
||||
Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RemoteServer represents a known backend server that can be selected
|
||||
// when creating proxy hosts, eliminating manual IP/port entry.
|
||||
type RemoteServer struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Provider string `json:"provider"` // e.g., "docker", "vm", "cloud", "manual"
|
||||
Host string `json:"host"` // IP address or hostname
|
||||
Port int `json:"port"`
|
||||
Scheme string `json:"scheme"` // http/https
|
||||
Tags string `json:"tags"` // comma-separated tags for filtering
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
LastChecked *time.Time `json:"last_checked,omitempty"`
|
||||
Reachable bool `json:"reachable" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Setting stores global application configuration as key-value pairs.
|
||||
// Used for system-wide preferences, feature flags, and runtime config.
|
||||
type Setting struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
Key string `json:"key" gorm:"uniqueIndex"`
|
||||
Value string `json:"value" gorm:"type:text"`
|
||||
Type string `json:"type"` // "string", "int", "bool", "json"
|
||||
Category string `json:"category"` // "general", "security", "caddy", "smtp", etc.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SSLCertificate represents TLS certificates managed by CPM+.
|
||||
// Can be Let's Encrypt auto-generated or custom uploaded certs.
|
||||
type SSLCertificate struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name"`
|
||||
Provider string `json:"provider"` // "letsencrypt", "custom", "self-signed"
|
||||
Domains string `json:"domains"` // comma-separated list of domains
|
||||
Certificate string `json:"certificate" gorm:"type:text"` // PEM-encoded certificate
|
||||
PrivateKey string `json:"private_key" gorm:"type:text"` // PEM-encoded private key
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
AutoRenew bool `json:"auto_renew" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UptimeMonitor struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
ProxyHostID *uint `json:"proxy_host_id"` // Optional link to proxy host
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // http, tcp, ping
|
||||
URL string `json:"url"`
|
||||
Interval int `json:"interval"` // seconds
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Current Status (Cached)
|
||||
Status string `json:"status"` // up, down, maintenance, pending
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
Latency int64 `json:"latency"` // ms
|
||||
}
|
||||
|
||||
type UptimeHeartbeat struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
MonitorID string `json:"monitor_id" gorm:"index"`
|
||||
Status string `json:"status"` // up, down
|
||||
Latency int64 `json:"latency"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"index"`
|
||||
}
|
||||
|
||||
func (m *UptimeMonitor) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if m.ID == "" {
|
||||
m.ID = uuid.New().String()
|
||||
}
|
||||
if m.Status == "" {
|
||||
m.Status = "pending"
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestUptimeMonitor_BeforeCreate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}))
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
Name: "Test",
|
||||
}
|
||||
err = db.Create(&monitor).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, monitor.ID)
|
||||
assert.Equal(t, "pending", monitor.Status)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// User represents authenticated users with role-based access control.
|
||||
// Supports local auth, SSO integration planned for later phases.
|
||||
type User struct {
|
||||
ID uint `json:"id" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Email string `json:"email" gorm:"uniqueIndex"`
|
||||
APIKey string `json:"api_key" gorm:"uniqueIndex"` // For external API access
|
||||
PasswordHash string `json:"-"` // Never serialize password hash
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role" gorm:"default:'user'"` // "admin", "user", "viewer"
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
FailedLoginAttempts int `json:"-" gorm:"default:0"`
|
||||
LockedUntil *time.Time `json:"-"`
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SetPassword hashes and sets the user's password.
|
||||
func (u *User) SetPassword(password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.PasswordHash = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword compares the provided password with the stored hash.
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUser_SetPassword(t *testing.T) {
|
||||
u := &User{}
|
||||
err := u.SetPassword("password123")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, u.PasswordHash)
|
||||
assert.NotEqual(t, "password123", u.PasswordHash)
|
||||
}
|
||||
|
||||
func TestUser_CheckPassword(t *testing.T) {
|
||||
u := &User{}
|
||||
_ = u.SetPassword("password123")
|
||||
|
||||
assert.True(t, u.CheckPassword("password123"))
|
||||
assert.False(t, u.CheckPassword("wrongpassword"))
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NewRouter creates a new Gin router with frontend static file serving.
|
||||
func NewRouter(frontendDir string) *gin.Engine {
|
||||
router := gin.Default()
|
||||
// Silence "trusted all proxies" warning by not trusting any by default.
|
||||
// If running behind a proxy, this should be configured to trust that proxy's IP.
|
||||
_ = router.SetTrustedProxies(nil)
|
||||
|
||||
// Serve frontend static files
|
||||
if frontendDir != "" {
|
||||
router.Static("/assets", frontendDir+"/assets")
|
||||
router.StaticFile("/", frontendDir+"/index.html")
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
c.File(frontendDir + "/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewRouter(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// Create a dummy frontend dir
|
||||
tempDir := t.TempDir()
|
||||
err := os.WriteFile(filepath.Join(tempDir, "index.html"), []byte("<html></html>"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
router := NewRouter(tempDir)
|
||||
assert.NotNil(t, router)
|
||||
|
||||
// Test static file serving
|
||||
req, _ := http.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "<html></html>")
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
config config.Config
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB, cfg config.Config) *AuthService {
|
||||
return &AuthService{db: db, config: cfg}
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(email, password, name string) (*models.User, error) {
|
||||
email = strings.ToLower(email)
|
||||
var count int64
|
||||
s.db.Model(&models.User{}).Count(&count)
|
||||
|
||||
role := "user"
|
||||
if count == 0 {
|
||||
role = "admin" // First user is admin
|
||||
}
|
||||
|
||||
user := &models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Email: email,
|
||||
Name: name,
|
||||
Role: role,
|
||||
APIKey: uuid.New().String(),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := user.SetPassword(password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.db.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(email, password string) (string, error) {
|
||||
email = strings.ToLower(email)
|
||||
var user models.User
|
||||
if err := s.db.Where("email = ?", email).First(&user).Error; err != nil {
|
||||
return "", errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
if !user.Enabled {
|
||||
return "", errors.New("account disabled")
|
||||
}
|
||||
|
||||
if user.LockedUntil != nil && user.LockedUntil.After(time.Now()) {
|
||||
return "", errors.New("account locked")
|
||||
}
|
||||
|
||||
if !user.CheckPassword(password) {
|
||||
user.FailedLoginAttempts++
|
||||
if user.FailedLoginAttempts >= 5 {
|
||||
lockTime := time.Now().Add(15 * time.Minute)
|
||||
user.LockedUntil = &lockTime
|
||||
}
|
||||
s.db.Save(&user)
|
||||
return "", errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Reset failed attempts
|
||||
user.FailedLoginAttempts = 0
|
||||
user.LockedUntil = nil
|
||||
now := time.Now()
|
||||
user.LastLogin = &now
|
||||
s.db.Save(&user)
|
||||
|
||||
return s.GenerateToken(&user)
|
||||
}
|
||||
|
||||
func (s *AuthService) GenerateToken(user *models.User) (string, error) {
|
||||
expirationTime := time.Now().Add(24 * time.Hour)
|
||||
claims := &Claims{
|
||||
UserID: user.ID,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
||||
Issuer: "cpmp",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(s.config.JWTSecret))
|
||||
}
|
||||
|
||||
func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword string) error {
|
||||
var user models.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
|
||||
if !user.CheckPassword(oldPassword) {
|
||||
return errors.New("invalid current password")
|
||||
}
|
||||
|
||||
if err := user.SetPassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Save(&user).Error
|
||||
}
|
||||
|
||||
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
|
||||
claims := &Claims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(s.config.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetUserByID(id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
if err := s.db.First(&user, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupAuthTestDB(t *testing.T) *gorm.DB {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.User{}))
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAuthService_Register(t *testing.T) {
|
||||
db := setupAuthTestDB(t)
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
service := NewAuthService(db, cfg)
|
||||
|
||||
// Test 1: First user should be admin
|
||||
admin, err := service.Register("admin@example.com", "password123", "Admin User")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "admin", admin.Role)
|
||||
assert.NotEmpty(t, admin.PasswordHash)
|
||||
assert.NotEqual(t, "password123", admin.PasswordHash)
|
||||
|
||||
// Test 2: Second user should be regular user
|
||||
user, err := service.Register("user@example.com", "password123", "Regular User")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user", user.Role)
|
||||
}
|
||||
|
||||
func TestAuthService_Login(t *testing.T) {
|
||||
db := setupAuthTestDB(t)
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
service := NewAuthService(db, cfg)
|
||||
|
||||
// Setup user
|
||||
_, err := service.Register("test@example.com", "password123", "Test User")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test 1: Successful login
|
||||
token, err := service.Login("test@example.com", "password123")
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
// Test 2: Invalid password
|
||||
token, err = service.Login("test@example.com", "wrongpassword")
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, token)
|
||||
assert.Equal(t, "invalid credentials", err.Error())
|
||||
|
||||
// Test 3: Account locking
|
||||
// Fail 4 more times (total 5)
|
||||
for i := 0; i < 4; i++ {
|
||||
_, err = service.Login("test@example.com", "wrongpassword")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// Check if locked
|
||||
var user models.User
|
||||
db.Where("email = ?", "test@example.com").First(&user)
|
||||
assert.Equal(t, 5, user.FailedLoginAttempts)
|
||||
assert.NotNil(t, user.LockedUntil)
|
||||
assert.True(t, user.LockedUntil.After(time.Now()))
|
||||
|
||||
// Try login with correct password while locked
|
||||
token, err = service.Login("test@example.com", "password123")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "account locked", err.Error())
|
||||
}
|
||||
|
||||
func TestAuthService_ChangePassword(t *testing.T) {
|
||||
db := setupAuthTestDB(t)
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
service := NewAuthService(db, cfg)
|
||||
|
||||
user, err := service.Register("test@example.com", "password123", "Test User")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Success
|
||||
err = service.ChangePassword(user.ID, "password123", "newpassword")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify login with new password
|
||||
_, err = service.Login("test@example.com", "newpassword")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Fail with old password
|
||||
_, err = service.Login("test@example.com", "password123")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Fail with wrong current password
|
||||
err = service.ChangePassword(user.ID, "wrong", "another")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "invalid current password", err.Error())
|
||||
|
||||
// Fail with non-existent user
|
||||
err = service.ChangePassword(999, "password", "new")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAuthService_ValidateToken(t *testing.T) {
|
||||
db := setupAuthTestDB(t)
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
service := NewAuthService(db, cfg)
|
||||
|
||||
user, err := service.Register("test@example.com", "password123", "Test User")
|
||||
require.NoError(t, err)
|
||||
|
||||
token, err := service.Login("test@example.com", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Valid token
|
||||
claims, err := service.ValidateToken(token)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, user.ID, claims.UserID)
|
||||
|
||||
// Invalid token
|
||||
_, err = service.ValidateToken("invalid.token.string")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAuthService_GetUserByID(t *testing.T) {
|
||||
db := setupAuthTestDB(t)
|
||||
cfg := config.Config{JWTSecret: "test-secret"}
|
||||
service := NewAuthService(db, cfg)
|
||||
|
||||
// Setup user
|
||||
user, err := service.Register("test@example.com", "password123", "Test User")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test 1: Get existing user
|
||||
foundUser, err := service.GetUserByID(user.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.ID, foundUser.ID)
|
||||
assert.Equal(t, user.Email, foundUser.Email)
|
||||
|
||||
// Test 2: Get non-existent user
|
||||
_, err = service.GetUserByID(999)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
type BackupService struct {
|
||||
DataDir string
|
||||
BackupDir string
|
||||
Cron *cron.Cron
|
||||
}
|
||||
|
||||
type BackupFile struct {
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
func NewBackupService(cfg *config.Config) *BackupService {
|
||||
// Ensure backup directory exists
|
||||
backupDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "backups")
|
||||
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||
fmt.Printf("Failed to create backup directory: %v\n", err)
|
||||
}
|
||||
|
||||
s := &BackupService{
|
||||
DataDir: filepath.Dir(cfg.DatabasePath), // e.g. /app/data
|
||||
BackupDir: backupDir,
|
||||
Cron: cron.New(),
|
||||
}
|
||||
|
||||
// Schedule daily backup at 3 AM
|
||||
_, err := s.Cron.AddFunc("0 3 * * *", s.RunScheduledBackup)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to schedule backup: %v\n", err)
|
||||
}
|
||||
s.Cron.Start()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *BackupService) RunScheduledBackup() {
|
||||
fmt.Println("Starting scheduled backup...")
|
||||
if name, err := s.CreateBackup(); err != nil {
|
||||
fmt.Printf("Scheduled backup failed: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("Scheduled backup created: %s\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ListBackups returns all backup files sorted by time (newest first)
|
||||
func (s *BackupService) ListBackups() ([]BackupFile, error) {
|
||||
entries, err := os.ReadDir(s.BackupDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var backups []BackupFile
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".zip") {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
backups = append(backups, BackupFile{
|
||||
Filename: entry.Name(),
|
||||
Size: info.Size(),
|
||||
Time: info.ModTime(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
return backups[i].Time.After(backups[j].Time)
|
||||
})
|
||||
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// CreateBackup creates a zip archive of the database and caddy data
|
||||
func (s *BackupService) CreateBackup() (string, error) {
|
||||
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
||||
filename := fmt.Sprintf("backup_%s.zip", timestamp)
|
||||
zipPath := filepath.Join(s.BackupDir, filename)
|
||||
|
||||
outFile, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
w := zip.NewWriter(outFile)
|
||||
defer w.Close()
|
||||
|
||||
// Files/Dirs to backup
|
||||
// 1. Database
|
||||
dbPath := filepath.Join(s.DataDir, "cpm.db")
|
||||
if err := s.addToZip(w, dbPath, "cpm.db"); err != nil {
|
||||
return "", fmt.Errorf("backup db: %w", err)
|
||||
}
|
||||
|
||||
// 2. Caddy Data (Certificates, etc)
|
||||
// We walk the 'caddy' subdirectory
|
||||
caddyDir := filepath.Join(s.DataDir, "caddy")
|
||||
if err := s.addDirToZip(w, caddyDir, "caddy"); err != nil {
|
||||
// It's possible caddy dir doesn't exist yet, which is fine
|
||||
fmt.Printf("Warning: could not backup caddy dir: %v\n", err)
|
||||
}
|
||||
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func (s *BackupService) addToZip(w *zip.Writer, srcPath, zipPath string) error {
|
||||
file, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
f, err := w.Create(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(f, file)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *BackupService) addDirToZip(w *zip.Writer, srcDir, zipBase string) error {
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
zipPath := filepath.Join(zipBase, relPath)
|
||||
return s.addToZip(w, path, zipPath)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBackup removes a backup file
|
||||
func (s *BackupService) DeleteBackup(filename string) error {
|
||||
cleanName := filepath.Base(filename)
|
||||
if filename != cleanName {
|
||||
return fmt.Errorf("invalid filename: path traversal attempt detected")
|
||||
}
|
||||
path := filepath.Join(s.BackupDir, cleanName)
|
||||
if !strings.HasPrefix(path, filepath.Clean(s.BackupDir)) {
|
||||
return fmt.Errorf("invalid filename: path traversal attempt detected")
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// GetBackupPath returns the full path to a backup file (for downloading)
|
||||
func (s *BackupService) GetBackupPath(filename string) (string, error) {
|
||||
cleanName := filepath.Base(filename)
|
||||
if filename != cleanName {
|
||||
return "", fmt.Errorf("invalid filename: path traversal attempt detected")
|
||||
}
|
||||
path := filepath.Join(s.BackupDir, cleanName)
|
||||
if !strings.HasPrefix(path, filepath.Clean(s.BackupDir)) {
|
||||
return "", fmt.Errorf("invalid filename: path traversal attempt detected")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// RestoreBackup restores the database and caddy data from a zip archive
|
||||
func (s *BackupService) RestoreBackup(filename string) error {
|
||||
cleanName := filepath.Base(filename)
|
||||
if filename != cleanName {
|
||||
return fmt.Errorf("invalid filename: path traversal attempt detected")
|
||||
}
|
||||
// 1. Verify backup exists
|
||||
srcPath := filepath.Join(s.BackupDir, cleanName)
|
||||
if !strings.HasPrefix(srcPath, filepath.Clean(s.BackupDir)) {
|
||||
return fmt.Errorf("invalid filename: path traversal attempt detected")
|
||||
}
|
||||
if _, err := os.Stat(srcPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Unzip to DataDir (overwriting)
|
||||
return s.unzip(srcPath, s.DataDir)
|
||||
}
|
||||
|
||||
func (s *BackupService) unzip(src, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
fpath := filepath.Join(dest, f.Name)
|
||||
|
||||
// Check for ZipSlip
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("illegal file path: %s", fpath)
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
_ = outFile.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
|
||||
// Check for close errors on writable file
|
||||
if closeErr := outFile.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBackupService_CreateAndList(t *testing.T) {
|
||||
// Setup temp dirs
|
||||
tmpDir, err := os.MkdirTemp("", "cpm-backup-service-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dataDir := filepath.Join(tmpDir, "data")
|
||||
err = os.MkdirAll(dataDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create dummy DB
|
||||
dbPath := filepath.Join(dataDir, "cpm.db")
|
||||
err = os.WriteFile(dbPath, []byte("dummy db"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create dummy caddy dir
|
||||
caddyDir := filepath.Join(dataDir, "caddy")
|
||||
err = os.MkdirAll(caddyDir, 0755)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(caddyDir, "caddy.json"), []byte("{}"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{DatabasePath: dbPath}
|
||||
service := NewBackupService(cfg)
|
||||
|
||||
// Test Create
|
||||
filename, err := service.CreateBackup()
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, filename)
|
||||
assert.FileExists(t, filepath.Join(service.BackupDir, filename))
|
||||
|
||||
// Test List
|
||||
backups, err := service.ListBackups()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, backups, 1)
|
||||
assert.Equal(t, filename, backups[0].Filename)
|
||||
assert.True(t, backups[0].Size > 0)
|
||||
|
||||
// Test GetBackupPath
|
||||
path, err := service.GetBackupPath(filename)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(service.BackupDir, filename), path)
|
||||
|
||||
// Test Restore
|
||||
// Modify DB to verify restore
|
||||
err = os.WriteFile(dbPath, []byte("modified db"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.RestoreBackup(filename)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify DB content restored
|
||||
content, err := os.ReadFile(dbPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dummy db", string(content))
|
||||
|
||||
// Test Delete
|
||||
err = service.DeleteBackup(filename)
|
||||
require.NoError(t, err)
|
||||
assert.NoFileExists(t, filepath.Join(service.BackupDir, filename))
|
||||
|
||||
// Test Delete Non-existent
|
||||
err = service.DeleteBackup("non-existent.zip")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBackupService_Restore_ZipSlip(t *testing.T) {
|
||||
// Setup temp dirs
|
||||
tmpDir := t.TempDir()
|
||||
service := &BackupService{
|
||||
DataDir: filepath.Join(tmpDir, "data"),
|
||||
BackupDir: filepath.Join(tmpDir, "backups"),
|
||||
}
|
||||
os.MkdirAll(service.BackupDir, 0755)
|
||||
|
||||
// Create malicious zip
|
||||
zipPath := filepath.Join(service.BackupDir, "malicious.zip")
|
||||
zipFile, err := os.Create(zipPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := zip.NewWriter(zipFile)
|
||||
f, err := w.Create("../../../evil.txt")
|
||||
require.NoError(t, err)
|
||||
_, err = f.Write([]byte("evil"))
|
||||
require.NoError(t, err)
|
||||
w.Close()
|
||||
zipFile.Close()
|
||||
|
||||
// Attempt restore
|
||||
err = service.RestoreBackup("malicious.zip")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "illegal file path")
|
||||
}
|
||||
|
||||
func TestBackupService_PathTraversal(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
service := &BackupService{
|
||||
DataDir: filepath.Join(tmpDir, "data"),
|
||||
BackupDir: filepath.Join(tmpDir, "backups"),
|
||||
}
|
||||
os.MkdirAll(service.BackupDir, 0755)
|
||||
|
||||
// Test GetBackupPath with traversal
|
||||
// Should return error
|
||||
_, err := service.GetBackupPath("../../etc/passwd")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid filename")
|
||||
|
||||
// Test DeleteBackup with traversal
|
||||
// Should return error
|
||||
err = service.DeleteBackup("../../etc/passwd")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid filename")
|
||||
}
|
||||
|
||||
func TestBackupService_RunScheduledBackup(t *testing.T) {
|
||||
// Setup temp dirs
|
||||
tmpDir := t.TempDir()
|
||||
dataDir := filepath.Join(tmpDir, "data")
|
||||
os.MkdirAll(dataDir, 0755)
|
||||
|
||||
// Create dummy DB
|
||||
dbPath := filepath.Join(dataDir, "cpm.db")
|
||||
os.WriteFile(dbPath, []byte("dummy db"), 0644)
|
||||
|
||||
cfg := &config.Config{DatabasePath: dbPath}
|
||||
service := NewBackupService(cfg)
|
||||
|
||||
// Run scheduled backup manually
|
||||
service.RunScheduledBackup()
|
||||
|
||||
// Verify backup created
|
||||
backups, err := service.ListBackups()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, backups, 1)
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// CertificateInfo represents parsed certificate details.
|
||||
type CertificateInfo struct {
|
||||
ID uint `json:"id,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Domain string `json:"domain"`
|
||||
Issuer string `json:"issuer"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Status string `json:"status"` // "valid", "expiring", "expired"
|
||||
Provider string `json:"provider"` // "letsencrypt", "custom"
|
||||
}
|
||||
|
||||
// CertificateService manages certificate retrieval and parsing.
|
||||
type CertificateService struct {
|
||||
dataDir string
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCertificateService creates a new certificate service.
|
||||
func NewCertificateService(dataDir string, db *gorm.DB) *CertificateService {
|
||||
return &CertificateService{
|
||||
dataDir: dataDir,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// ListCertificates returns both auto-generated and custom certificates.
|
||||
func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) {
|
||||
// First, scan Caddy data directory for auto-generated certificates and persist them.
|
||||
certRoot := filepath.Join(s.dataDir, "certificates")
|
||||
log.Printf("CertificateService: scanning cert directory: %s\n", certRoot)
|
||||
|
||||
foundDomains := map[string]struct{}{}
|
||||
|
||||
// If the cert root does not exist, skip scanning but still return DB entries below
|
||||
if _, err := os.Stat(certRoot); err == nil {
|
||||
_ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
log.Printf("CertificateService: walk error for %s: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") {
|
||||
log.Printf("CertificateService: found cert file: %s\n", path)
|
||||
certData, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("CertificateService: failed to read cert file %s: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certData)
|
||||
if block == nil {
|
||||
log.Printf("CertificateService: pem decode failed for %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
log.Printf("CertificateService: failed to parse cert %s: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
domain := cert.Subject.CommonName
|
||||
if domain == "" && len(cert.DNSNames) > 0 {
|
||||
domain = cert.DNSNames[0]
|
||||
}
|
||||
if domain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
foundDomains[domain] = struct{}{}
|
||||
|
||||
// Determine expiry
|
||||
expiresAt := cert.NotAfter
|
||||
|
||||
// Upsert into DB for provider 'letsencrypt'
|
||||
var existing models.SSLCertificate
|
||||
res := s.db.Where("provider = ? AND domains = ?", "letsencrypt", domain).First(&existing)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrRecordNotFound {
|
||||
// Create new record
|
||||
now := time.Now()
|
||||
newCert := models.SSLCertificate{
|
||||
UUID: uuid.New().String(),
|
||||
Name: domain,
|
||||
Provider: "letsencrypt",
|
||||
Domains: domain,
|
||||
Certificate: string(certData),
|
||||
PrivateKey: "",
|
||||
ExpiresAt: &expiresAt,
|
||||
AutoRenew: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.db.Create(&newCert).Error; err != nil {
|
||||
log.Printf("CertificateService: failed to create DB cert for %s: %v\n", domain, err)
|
||||
}
|
||||
} else {
|
||||
log.Printf("CertificateService: db error querying cert %s: %v\n", domain, res.Error)
|
||||
}
|
||||
} else {
|
||||
// Update expiry/certificate content if changed
|
||||
updated := false
|
||||
existing.ExpiresAt = &expiresAt
|
||||
if existing.Certificate != string(certData) {
|
||||
existing.Certificate = string(certData)
|
||||
updated = true
|
||||
}
|
||||
if updated {
|
||||
existing.UpdatedAt = time.Now()
|
||||
if err := s.db.Save(&existing).Error; err != nil {
|
||||
log.Printf("CertificateService: failed to update DB cert for %s: %v\n", domain, err)
|
||||
}
|
||||
} else {
|
||||
// still update ExpiresAt if needed
|
||||
if err := s.db.Model(&existing).Update("expires_at", &expiresAt).Error; err != nil {
|
||||
log.Printf("CertificateService: failed to update expiry for %s: %v\n", domain, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("CertificateService: cert directory does not exist: %s\n", certRoot)
|
||||
} else {
|
||||
log.Printf("CertificateService: failed to stat cert directory: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete stale DB entries for provider 'letsencrypt' not found on disk
|
||||
var acmeCerts []models.SSLCertificate
|
||||
if err := s.db.Where("provider = ?", "letsencrypt").Find(&acmeCerts).Error; err == nil {
|
||||
for _, c := range acmeCerts {
|
||||
if _, ok := foundDomains[c.Domains]; !ok {
|
||||
// remove stale record
|
||||
if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", c.ID).Error; err != nil {
|
||||
log.Printf("CertificateService: failed to delete stale cert %s: %v\n", c.Domains, err)
|
||||
} else {
|
||||
log.Printf("CertificateService: removed stale DB cert for %s\n", c.Domains)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, fetch all certificates from DB to build the response (includes custom and persisted ACME)
|
||||
certs := []CertificateInfo{}
|
||||
var dbCerts []models.SSLCertificate
|
||||
if err := s.db.Find(&dbCerts).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch certs from DB: %w", err)
|
||||
}
|
||||
|
||||
for _, c := range dbCerts {
|
||||
status := "valid"
|
||||
if c.ExpiresAt != nil {
|
||||
if time.Now().After(*c.ExpiresAt) {
|
||||
status = "expired"
|
||||
} else if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) {
|
||||
status = "expiring"
|
||||
}
|
||||
}
|
||||
|
||||
expires := time.Time{}
|
||||
if c.ExpiresAt != nil {
|
||||
expires = *c.ExpiresAt
|
||||
}
|
||||
|
||||
certs = append(certs, CertificateInfo{
|
||||
ID: c.ID,
|
||||
UUID: c.UUID,
|
||||
Name: c.Name,
|
||||
Domain: c.Domains,
|
||||
Issuer: c.Provider,
|
||||
ExpiresAt: expires,
|
||||
Status: status,
|
||||
Provider: c.Provider,
|
||||
})
|
||||
}
|
||||
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// UploadCertificate saves a new custom certificate.
|
||||
func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM string) (*models.SSLCertificate, error) {
|
||||
// Validate PEM
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("invalid certificate PEM")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
// Create DB entry
|
||||
sslCert := &models.SSLCertificate{
|
||||
UUID: uuid.New().String(),
|
||||
Name: name,
|
||||
Provider: "custom",
|
||||
Domains: cert.Subject.CommonName, // Or SANs
|
||||
Certificate: certPEM,
|
||||
PrivateKey: keyPEM,
|
||||
ExpiresAt: &cert.NotAfter,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Handle SANs if present
|
||||
if len(cert.DNSNames) > 0 {
|
||||
sslCert.Domains = strings.Join(cert.DNSNames, ",")
|
||||
}
|
||||
|
||||
if err := s.db.Create(sslCert).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sslCert, nil
|
||||
}
|
||||
|
||||
// DeleteCertificate removes a certificate.
|
||||
func (s *CertificateService) DeleteCertificate(id uint) error {
|
||||
var cert models.SSLCertificate
|
||||
if err := s.db.First(&cert, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cert.Provider == "letsencrypt" {
|
||||
// Best-effort file deletion
|
||||
certRoot := filepath.Join(s.dataDir, "certificates")
|
||||
_ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") {
|
||||
if info.Name() == cert.Domains+".crt" {
|
||||
// Found it
|
||||
log.Printf("CertificateService: deleting ACME cert file %s", path)
|
||||
if err := os.Remove(path); err != nil {
|
||||
log.Printf("CertificateService: failed to delete cert file: %v", err)
|
||||
}
|
||||
// Try to delete key as well
|
||||
keyPath := strings.TrimSuffix(path, ".crt") + ".key"
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
os.Remove(keyPath)
|
||||
}
|
||||
// Also try to delete the json meta file
|
||||
jsonPath := strings.TrimSuffix(path, ".crt") + ".json"
|
||||
if _, err := os.Stat(jsonPath); err == nil {
|
||||
os.Remove(jsonPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return s.db.Delete(&models.SSLCertificate{}, "id = ?", id).Error
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate private key: %v", err)
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: domain,
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: expiry,
|
||||
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
}
|
||||
|
||||
func TestCertificateService_GetCertificateInfo(t *testing.T) {
|
||||
// Create temp dir
|
||||
tmpDir, err := os.MkdirTemp("", "cert-test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Setup in-memory DB
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.SSLCertificate{}); err != nil {
|
||||
t.Fatalf("Failed to migrate database: %v", err)
|
||||
}
|
||||
|
||||
cs := NewCertificateService(tmpDir, db)
|
||||
|
||||
// Case 1: Valid Certificate
|
||||
domain := "example.com"
|
||||
expiry := time.Now().Add(24 * time.Hour * 60) // 60 days
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
|
||||
// Create cert directory
|
||||
certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain)
|
||||
err = os.MkdirAll(certDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cert dir: %v", err)
|
||||
}
|
||||
|
||||
certPath := filepath.Join(certDir, domain+".crt")
|
||||
err = os.WriteFile(certPath, certPEM, 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write cert file: %v", err)
|
||||
}
|
||||
|
||||
// List Certificates
|
||||
certs, err := cs.ListCertificates()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, certs, 1)
|
||||
if len(certs) > 0 {
|
||||
assert.Equal(t, domain, certs[0].Domain)
|
||||
assert.Equal(t, "valid", certs[0].Status)
|
||||
// Check expiry within a margin
|
||||
assert.WithinDuration(t, expiry, certs[0].ExpiresAt, time.Second)
|
||||
}
|
||||
|
||||
// Case 2: Expired Certificate
|
||||
expiredDomain := "expired.com"
|
||||
expiredExpiry := time.Now().Add(-24 * time.Hour) // Yesterday
|
||||
expiredCertPEM := generateTestCert(t, expiredDomain, expiredExpiry)
|
||||
|
||||
expiredCertDir := filepath.Join(tmpDir, "certificates", "other", expiredDomain)
|
||||
err = os.MkdirAll(expiredCertDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expiredCertPath := filepath.Join(expiredCertDir, expiredDomain+".crt")
|
||||
err = os.WriteFile(expiredCertPath, expiredCertPEM, 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
certs, err = cs.ListCertificates()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, certs, 2)
|
||||
|
||||
// Find the expired one
|
||||
var foundExpired bool
|
||||
for _, c := range certs {
|
||||
if c.Domain == expiredDomain {
|
||||
assert.Equal(t, "expired", c.Status)
|
||||
foundExpired = true
|
||||
}
|
||||
}
|
||||
assert.True(t, foundExpired, "Should find expired certificate")
|
||||
}
|
||||
|
||||
func TestCertificateService_UploadAndDelete(t *testing.T) {
|
||||
// Setup
|
||||
tmpDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
|
||||
|
||||
cs := NewCertificateService(tmpDir, db)
|
||||
|
||||
// Generate Cert
|
||||
domain := "custom.example.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
keyPEM := []byte("FAKE PRIVATE KEY")
|
||||
|
||||
// Test Upload
|
||||
cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM))
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, cert)
|
||||
assert.Equal(t, "My Custom Cert", cert.Name)
|
||||
assert.Equal(t, "custom", cert.Provider)
|
||||
assert.Equal(t, domain, cert.Domains)
|
||||
|
||||
// Verify it's in List
|
||||
certs, err := cs.ListCertificates()
|
||||
require.NoError(t, err)
|
||||
var found bool
|
||||
for _, c := range certs {
|
||||
if c.ID == cert.ID {
|
||||
found = true
|
||||
assert.Equal(t, "custom", c.Provider)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
|
||||
// Test Delete
|
||||
err = cs.DeleteCertificate(cert.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's gone
|
||||
certs, err = cs.ListCertificates()
|
||||
require.NoError(t, err)
|
||||
found = false
|
||||
for _, c := range certs {
|
||||
if c.ID == cert.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, found)
|
||||
}
|
||||
|
||||
func TestCertificateService_Persistence(t *testing.T) {
|
||||
// Setup
|
||||
tmpDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}))
|
||||
|
||||
cs := NewCertificateService(tmpDir, db)
|
||||
|
||||
// 1. Create a fake ACME cert file
|
||||
domain := "persist.example.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
|
||||
certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain)
|
||||
err = os.MkdirAll(certDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPath := filepath.Join(certDir, domain+".crt")
|
||||
err = os.WriteFile(certPath, certPEM, 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 2. Call ListCertificates to trigger scan and persistence
|
||||
certs, err := cs.ListCertificates()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's in the returned list
|
||||
var foundInList bool
|
||||
for _, c := range certs {
|
||||
if c.Domain == domain {
|
||||
foundInList = true
|
||||
assert.Equal(t, "letsencrypt", c.Provider)
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundInList, "Certificate should be in the returned list")
|
||||
|
||||
// 3. Verify it's in the DB
|
||||
var dbCert models.SSLCertificate
|
||||
err = db.Where("domains = ? AND provider = ?", domain, "letsencrypt").First(&dbCert).Error
|
||||
assert.NoError(t, err, "Certificate should be persisted to DB")
|
||||
assert.Equal(t, domain, dbCert.Name)
|
||||
assert.Equal(t, string(certPEM), dbCert.Certificate)
|
||||
|
||||
// 4. Delete the certificate via Service (which should delete the file)
|
||||
err = cs.DeleteCertificate(dbCert.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify file is gone
|
||||
_, err = os.Stat(certPath)
|
||||
assert.True(t, os.IsNotExist(err), "Cert file should be deleted")
|
||||
|
||||
// 5. Call ListCertificates again to trigger cleanup (though DB row is already gone)
|
||||
certs, err = cs.ListCertificates()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's NOT in the returned list
|
||||
foundInList = false
|
||||
for _, c := range certs {
|
||||
if c.Domain == domain {
|
||||
foundInList = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.False(t, foundInList, "Certificate should NOT be in the returned list after deletion")
|
||||
|
||||
// 6. Verify it's gone from the DB
|
||||
err = db.Where("domains = ? AND provider = ?", domain, "letsencrypt").First(&dbCert).Error
|
||||
assert.Error(t, err, "Certificate should be removed from DB")
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
type DockerPort struct {
|
||||
PrivatePort uint16 `json:"private_port"`
|
||||
PublicPort uint16 `json:"public_port"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type DockerContainer struct {
|
||||
ID string `json:"id"`
|
||||
Names []string `json:"names"`
|
||||
Image string `json:"image"`
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Network string `json:"network"`
|
||||
IP string `json:"ip"`
|
||||
Ports []DockerPort `json:"ports"`
|
||||
}
|
||||
|
||||
type DockerService struct {
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
func NewDockerService() (*DockerService, error) {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create docker client: %w", err)
|
||||
}
|
||||
return &DockerService{client: cli}, nil
|
||||
}
|
||||
|
||||
func (s *DockerService) ListContainers(ctx context.Context, host string) ([]DockerContainer, error) {
|
||||
var cli *client.Client
|
||||
var err error
|
||||
|
||||
if host == "" || host == "local" {
|
||||
cli = s.client
|
||||
} else {
|
||||
cli, err = client.NewClientWithOpts(client.WithHost(host), client.WithAPIVersionNegotiation())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create remote client: %w", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
}
|
||||
|
||||
containers, err := cli.ContainerList(ctx, container.ListOptions{All: false})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list containers: %w", err)
|
||||
}
|
||||
|
||||
var result []DockerContainer
|
||||
for _, c := range containers {
|
||||
// Get the first network's IP address if available
|
||||
networkName := ""
|
||||
ipAddress := ""
|
||||
if c.NetworkSettings != nil && len(c.NetworkSettings.Networks) > 0 {
|
||||
for name, net := range c.NetworkSettings.Networks {
|
||||
networkName = name
|
||||
ipAddress = net.IPAddress
|
||||
break // Just take the first one for now
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up names (remove leading slash)
|
||||
names := make([]string, len(c.Names))
|
||||
for i, name := range c.Names {
|
||||
names[i] = strings.TrimPrefix(name, "/")
|
||||
}
|
||||
|
||||
// Map ports
|
||||
var ports []DockerPort
|
||||
for _, p := range c.Ports {
|
||||
ports = append(ports, DockerPort{
|
||||
PrivatePort: p.PrivatePort,
|
||||
PublicPort: p.PublicPort,
|
||||
Type: p.Type,
|
||||
})
|
||||
}
|
||||
|
||||
result = append(result, DockerContainer{
|
||||
ID: c.ID[:12], // Short ID
|
||||
Names: names,
|
||||
Image: c.Image,
|
||||
State: c.State,
|
||||
Status: c.Status,
|
||||
Network: networkName,
|
||||
IP: ipAddress,
|
||||
Ports: ports,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDockerService_New(t *testing.T) {
|
||||
// This test might fail if docker socket is not available in the build environment
|
||||
// So we just check if it returns error or not, but don't fail the test if it's just "socket not found"
|
||||
// In a real CI environment with Docker-in-Docker, this would work.
|
||||
svc, err := NewDockerService()
|
||||
if err != nil {
|
||||
t.Logf("Skipping DockerService test: %v", err)
|
||||
return
|
||||
}
|
||||
assert.NotNil(t, svc)
|
||||
}
|
||||
|
||||
func TestDockerService_ListContainers(t *testing.T) {
|
||||
svc, err := NewDockerService()
|
||||
if err != nil {
|
||||
t.Logf("Skipping DockerService test: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Test local listing
|
||||
containers, err := svc.ListContainers(context.Background(), "")
|
||||
// If we can't connect to docker daemon, this will fail.
|
||||
// We should probably mock the client, but the docker client is an interface?
|
||||
// The official client struct is concrete.
|
||||
// For now, we just assert that if err is nil, containers is a slice.
|
||||
if err == nil {
|
||||
assert.IsType(t, []DockerContainer{}, containers)
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
type LogService struct {
|
||||
LogDir string
|
||||
}
|
||||
|
||||
func NewLogService(cfg *config.Config) *LogService {
|
||||
// Assuming logs are in data/logs relative to app root
|
||||
logDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "logs")
|
||||
return &LogService{LogDir: logDir}
|
||||
}
|
||||
|
||||
type LogFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"mod_time"`
|
||||
}
|
||||
|
||||
func (s *LogService) ListLogs() ([]LogFile, error) {
|
||||
entries, err := os.ReadDir(s.LogDir)
|
||||
if err != nil {
|
||||
// If directory doesn't exist, return empty list instead of error
|
||||
if os.IsNotExist(err) {
|
||||
return []LogFile{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var logs []LogFile
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".log") || strings.Contains(entry.Name(), ".log.")) {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
logs = append(logs, LogFile{
|
||||
Name: entry.Name(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
// GetLogPath returns the absolute path to a log file if it exists and is valid
|
||||
func (s *LogService) GetLogPath(filename string) (string, error) {
|
||||
cleanName := filepath.Base(filename)
|
||||
if filename != cleanName {
|
||||
return "", fmt.Errorf("invalid filename: path traversal attempt detected")
|
||||
}
|
||||
path := filepath.Join(s.LogDir, cleanName)
|
||||
if !strings.HasPrefix(path, filepath.Clean(s.LogDir)) {
|
||||
return "", fmt.Errorf("invalid filename: path traversal attempt detected")
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// QueryLogs parses and filters logs from a specific file
|
||||
func (s *LogService) QueryLogs(filename string, filter models.LogFilter) ([]models.CaddyAccessLog, int64, error) {
|
||||
path, err := s.GetLogPath(filename)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var logs []models.CaddyAccessLog
|
||||
var totalMatches int64 = 0
|
||||
|
||||
// Read file line by line
|
||||
// TODO: For large files, reading from end or indexing would be better
|
||||
// Current implementation reads all lines, filters, then paginates
|
||||
// This is acceptable for rotated logs (max 10MB)
|
||||
scanner := bufio.NewScanner(file)
|
||||
|
||||
// We'll store all matching logs first, then slice for pagination
|
||||
// This is memory intensive for very large matches but ensures correct sorting/filtering
|
||||
// Since we want latest first, we'll prepend or reverse later.
|
||||
// Actually, appending and then reversing is better.
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var entry models.CaddyAccessLog
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
// Handle non-JSON logs (like cpmp.log)
|
||||
// Try to parse standard Go log format: "2006/01/02 15:04:05 msg"
|
||||
parts := strings.SplitN(line, " ", 3)
|
||||
if len(parts) >= 3 {
|
||||
// Try parsing date/time
|
||||
ts, err := time.Parse("2006/01/02 15:04:05", parts[0]+" "+parts[1])
|
||||
if err == nil {
|
||||
entry.Ts = float64(ts.Unix())
|
||||
entry.Msg = parts[2]
|
||||
} else {
|
||||
entry.Msg = line
|
||||
}
|
||||
} else {
|
||||
entry.Msg = line
|
||||
}
|
||||
entry.Level = "INFO" // Default level for plain logs
|
||||
}
|
||||
|
||||
if s.matchesFilter(entry, filter) {
|
||||
logs = append(logs, entry)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Reverse logs to show newest first (default) unless sort is asc
|
||||
if filter.Sort != "asc" {
|
||||
for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 {
|
||||
logs[i], logs[j] = logs[j], logs[i]
|
||||
}
|
||||
}
|
||||
|
||||
totalMatches = int64(len(logs))
|
||||
|
||||
// Apply pagination
|
||||
start := filter.Offset
|
||||
end := start + filter.Limit
|
||||
|
||||
if start >= len(logs) {
|
||||
return []models.CaddyAccessLog{}, totalMatches, nil
|
||||
}
|
||||
if end > len(logs) {
|
||||
end = len(logs)
|
||||
}
|
||||
|
||||
return logs[start:end], totalMatches, nil
|
||||
}
|
||||
|
||||
func (s *LogService) matchesFilter(entry models.CaddyAccessLog, filter models.LogFilter) bool {
|
||||
// Status Filter
|
||||
if filter.Status != "" {
|
||||
statusStr := strconv.Itoa(entry.Status)
|
||||
if strings.HasSuffix(filter.Status, "xx") {
|
||||
// Handle 2xx, 4xx, 5xx
|
||||
prefix := filter.Status[:1]
|
||||
if !strings.HasPrefix(statusStr, prefix) {
|
||||
return false
|
||||
}
|
||||
} else if statusStr != filter.Status {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Level Filter
|
||||
if filter.Level != "" {
|
||||
if !strings.EqualFold(entry.Level, filter.Level) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Host Filter
|
||||
if filter.Host != "" {
|
||||
if !strings.Contains(strings.ToLower(entry.Request.Host), strings.ToLower(filter.Host)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Search Filter (generic text search)
|
||||
if filter.Search != "" {
|
||||
term := strings.ToLower(filter.Search)
|
||||
// Search in common fields
|
||||
if !strings.Contains(strings.ToLower(entry.Request.URI), term) &&
|
||||
!strings.Contains(strings.ToLower(entry.Request.Method), term) &&
|
||||
!strings.Contains(strings.ToLower(entry.Request.RemoteIP), term) &&
|
||||
!strings.Contains(strings.ToLower(entry.Msg), term) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/config"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLogService(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "cpm-log-service-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dataDir := filepath.Join(tmpDir, "data")
|
||||
logsDir := filepath.Join(dataDir, "logs")
|
||||
err = os.MkdirAll(logsDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create sample JSON logs
|
||||
logEntry1 := models.CaddyAccessLog{
|
||||
Level: "info",
|
||||
Ts: 1600000000,
|
||||
Msg: "request handled",
|
||||
Status: 200,
|
||||
}
|
||||
logEntry1.Request.Method = "GET"
|
||||
logEntry1.Request.Host = "example.com"
|
||||
logEntry1.Request.URI = "/"
|
||||
logEntry1.Request.RemoteIP = "1.2.3.4"
|
||||
|
||||
logEntry2 := models.CaddyAccessLog{
|
||||
Level: "error",
|
||||
Ts: 1600000060,
|
||||
Msg: "error handled",
|
||||
Status: 500,
|
||||
}
|
||||
logEntry2.Request.Method = "POST"
|
||||
logEntry2.Request.Host = "api.example.com"
|
||||
logEntry2.Request.URI = "/submit"
|
||||
logEntry2.Request.RemoteIP = "5.6.7.8"
|
||||
|
||||
line1, _ := json.Marshal(logEntry1)
|
||||
line2, _ := json.Marshal(logEntry2)
|
||||
|
||||
content := string(line1) + "\n" + string(line2) + "\n"
|
||||
|
||||
err = os.WriteFile(filepath.Join(logsDir, "access.log"), []byte(content), 0644)
|
||||
require.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(logsDir, "other.txt"), []byte("ignore me"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &config.Config{DatabasePath: filepath.Join(dataDir, "cpm.db")}
|
||||
service := NewLogService(cfg)
|
||||
|
||||
// Test List
|
||||
logs, err := service.ListLogs()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, logs, 1)
|
||||
assert.Equal(t, "access.log", logs[0].Name)
|
||||
|
||||
// Test QueryLogs - All
|
||||
results, total, err := service.QueryLogs("access.log", models.LogFilter{Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
assert.Len(t, results, 2)
|
||||
// Should be reversed (newest first)
|
||||
assert.Equal(t, 500, results[0].Status)
|
||||
assert.Equal(t, 200, results[1].Status)
|
||||
|
||||
// Test QueryLogs - Filter Status
|
||||
results, total, err = service.QueryLogs("access.log", models.LogFilter{Status: "5xx", Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Len(t, results, 1)
|
||||
assert.Equal(t, 500, results[0].Status)
|
||||
|
||||
// Test QueryLogs - Filter Host
|
||||
results, total, err = service.QueryLogs("access.log", models.LogFilter{Host: "api.example.com", Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Len(t, results, 1)
|
||||
assert.Equal(t, "api.example.com", results[0].Request.Host)
|
||||
|
||||
// Test QueryLogs - Search
|
||||
results, total, err = service.QueryLogs("access.log", models.LogFilter{Search: "submit", Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Len(t, results, 1)
|
||||
assert.Equal(t, "/submit", results[0].Request.URI)
|
||||
|
||||
// Test GetLogPath
|
||||
path, err := service.GetLogPath("access.log")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(logsDir, "access.log"), path)
|
||||
|
||||
// Test GetLogPath non-existent
|
||||
_, err = service.GetLogPath("missing.log")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test GetLogPath - Invalid
|
||||
_, err = service.GetLogPath("nonexistent.log")
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test GetLogPath - Traversal
|
||||
_, err = service.GetLogPath("../../etc/passwd")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid filename")
|
||||
|
||||
// Test ListLogs - Directory Not Exist
|
||||
nonExistService := NewLogService(&config.Config{DatabasePath: filepath.Join(t.TempDir(), "missing", "cpm.db")})
|
||||
logs, err = nonExistService.ListLogs()
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, logs)
|
||||
|
||||
// Test QueryLogs - Non-JSON Logs
|
||||
plainContent := "2023/10/27 10:00:00 Application started\nJust a plain line\n"
|
||||
err = os.WriteFile(filepath.Join(logsDir, "app.log"), []byte(plainContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
results, total, err = service.QueryLogs("app.log", models.LogFilter{Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), total)
|
||||
// Reverse order check
|
||||
assert.Equal(t, "Just a plain line", results[0].Msg)
|
||||
assert.Equal(t, "Application started", results[1].Msg)
|
||||
assert.Equal(t, "INFO", results[1].Level)
|
||||
|
||||
// Test QueryLogs - Pagination
|
||||
// We have 2 logs in access.log
|
||||
results, total, err = service.QueryLogs("access.log", models.LogFilter{Limit: 1, Offset: 0})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 1)
|
||||
assert.Equal(t, 500, results[0].Status) // Newest first
|
||||
|
||||
results, total, err = service.QueryLogs("access.log", models.LogFilter{Limit: 1, Offset: 1})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, results, 1)
|
||||
assert.Equal(t, 200, results[0].Status) // Second newest
|
||||
|
||||
results, total, err = service.QueryLogs("access.log", models.LogFilter{Limit: 10, Offset: 5})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, results)
|
||||
|
||||
// Test QueryLogs - Exact Status Match
|
||||
results, total, err = service.QueryLogs("access.log", models.LogFilter{Status: "200", Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, 200, results[0].Status)
|
||||
|
||||
// Test QueryLogs - Search Fields
|
||||
// Search Method
|
||||
results, total, err = service.QueryLogs("access.log", models.LogFilter{Search: "POST", Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, "POST", results[0].Request.Method)
|
||||
|
||||
// Search RemoteIP
|
||||
results, total, err = service.QueryLogs("access.log", models.LogFilter{Search: "5.6.7.8", Limit: 10})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, "5.6.7.8", results[0].Request.RemoteIP)
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/containrrr/shoutrrr"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NotificationService struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewNotificationService(db *gorm.DB) *NotificationService {
|
||||
return &NotificationService{DB: db}
|
||||
}
|
||||
|
||||
var discordWebhookRegex = regexp.MustCompile(`^https://discord(?:app)?\.com/api/webhooks/(\d+)/([a-zA-Z0-9_-]+)`)
|
||||
|
||||
func normalizeURL(serviceType, rawURL string) string {
|
||||
if serviceType == "discord" {
|
||||
matches := discordWebhookRegex.FindStringSubmatch(rawURL)
|
||||
if len(matches) == 3 {
|
||||
id := matches[1]
|
||||
token := matches[2]
|
||||
return fmt.Sprintf("discord://%s@%s", token, id)
|
||||
}
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
// Internal Notifications (DB)
|
||||
|
||||
func (s *NotificationService) Create(nType models.NotificationType, title, message string) (*models.Notification, error) {
|
||||
notification := &models.Notification{
|
||||
Type: nType,
|
||||
Title: title,
|
||||
Message: message,
|
||||
Read: false,
|
||||
}
|
||||
result := s.DB.Create(notification)
|
||||
return notification, result.Error
|
||||
}
|
||||
|
||||
func (s *NotificationService) List(unreadOnly bool) ([]models.Notification, error) {
|
||||
var notifications []models.Notification
|
||||
query := s.DB.Order("created_at desc")
|
||||
if unreadOnly {
|
||||
query = query.Where("read = ?", false)
|
||||
}
|
||||
result := query.Find(¬ifications)
|
||||
return notifications, result.Error
|
||||
}
|
||||
|
||||
func (s *NotificationService) MarkAsRead(id string) error {
|
||||
return s.DB.Model(&models.Notification{}).Where("id = ?", id).Update("read", true).Error
|
||||
}
|
||||
|
||||
func (s *NotificationService) MarkAllAsRead() error {
|
||||
return s.DB.Model(&models.Notification{}).Where("read = ?", false).Update("read", true).Error
|
||||
}
|
||||
|
||||
// External Notifications (Shoutrrr & Custom Webhooks)
|
||||
|
||||
func (s *NotificationService) SendExternal(eventType, title, message string, data map[string]interface{}) {
|
||||
var providers []models.NotificationProvider
|
||||
if err := s.DB.Where("enabled = ?", true).Find(&providers).Error; err != nil {
|
||||
log.Printf("Failed to fetch notification providers: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare data for templates
|
||||
if data == nil {
|
||||
data = make(map[string]interface{})
|
||||
}
|
||||
data["Title"] = title
|
||||
data["Message"] = message
|
||||
data["Time"] = time.Now().Format(time.RFC3339)
|
||||
data["EventType"] = eventType
|
||||
|
||||
for _, provider := range providers {
|
||||
// Filter based on preferences
|
||||
shouldSend := false
|
||||
switch eventType {
|
||||
case "proxy_host":
|
||||
shouldSend = provider.NotifyProxyHosts
|
||||
case "remote_server":
|
||||
shouldSend = provider.NotifyRemoteServers
|
||||
case "domain":
|
||||
shouldSend = provider.NotifyDomains
|
||||
case "cert":
|
||||
shouldSend = provider.NotifyCerts
|
||||
case "uptime":
|
||||
shouldSend = provider.NotifyUptime
|
||||
case "test":
|
||||
shouldSend = true
|
||||
default:
|
||||
// Default to true for unknown types or generic messages?
|
||||
// Or false to be safe? Let's say true for now to avoid missing things,
|
||||
// or maybe we should enforce types.
|
||||
shouldSend = true
|
||||
}
|
||||
|
||||
if !shouldSend {
|
||||
continue
|
||||
}
|
||||
|
||||
go func(p models.NotificationProvider) {
|
||||
if p.Type == "webhook" {
|
||||
s.sendCustomWebhook(p, data)
|
||||
} else {
|
||||
url := normalizeURL(p.Type, p.URL)
|
||||
if err := shoutrrr.Send(url, fmt.Sprintf("%s: %s", title, message)); err != nil {
|
||||
log.Printf("Failed to send notification to %s: %v", p.Name, err)
|
||||
}
|
||||
}
|
||||
}(provider)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NotificationService) sendCustomWebhook(p models.NotificationProvider, data map[string]interface{}) {
|
||||
// Default template if empty
|
||||
tmplStr := p.Config
|
||||
if tmplStr == "" {
|
||||
tmplStr = `{"content": "{{.Title}}: {{.Message}}"}`
|
||||
}
|
||||
|
||||
// Parse template
|
||||
tmpl, err := template.New("webhook").Parse(tmplStr)
|
||||
if err != nil {
|
||||
log.Printf("Failed to parse webhook template for %s: %v", p.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
if err := tmpl.Execute(&body, data); err != nil {
|
||||
log.Printf("Failed to execute webhook template for %s: %v", p.Name, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send Request
|
||||
resp, err := http.Post(p.URL, "application/json", &body)
|
||||
if err != nil {
|
||||
log.Printf("Failed to send webhook to %s: %v", p.Name, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
log.Printf("Webhook %s returned status: %d", p.Name, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NotificationService) TestProvider(provider models.NotificationProvider) error {
|
||||
if provider.Type == "webhook" {
|
||||
data := map[string]interface{}{
|
||||
"Title": "Test Notification",
|
||||
"Message": "This is a test notification from CaddyProxyManager+",
|
||||
"Status": "TEST",
|
||||
"Name": "Test Monitor",
|
||||
"Latency": 123,
|
||||
"Time": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
s.sendCustomWebhook(provider, data)
|
||||
return nil
|
||||
}
|
||||
url := normalizeURL(provider.Type, provider.URL)
|
||||
return shoutrrr.Send(url, "Test notification from CaddyProxyManager+")
|
||||
}
|
||||
|
||||
// Provider Management
|
||||
|
||||
func (s *NotificationService) ListProviders() ([]models.NotificationProvider, error) {
|
||||
var providers []models.NotificationProvider
|
||||
result := s.DB.Find(&providers)
|
||||
return providers, result.Error
|
||||
}
|
||||
|
||||
func (s *NotificationService) CreateProvider(provider *models.NotificationProvider) error {
|
||||
return s.DB.Create(provider).Error
|
||||
}
|
||||
|
||||
func (s *NotificationService) UpdateProvider(provider *models.NotificationProvider) error {
|
||||
return s.DB.Save(provider).Error
|
||||
}
|
||||
|
||||
func (s *NotificationService) DeleteProvider(id string) error {
|
||||
return s.DB.Delete(&models.NotificationProvider{}, "id = ?", id).Error
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupNotificationTestDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.AutoMigrate(&models.Notification{}, &models.NotificationProvider{})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNotificationService_Create(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
notif, err := svc.Create(models.NotificationTypeInfo, "Test", "Message")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Test", notif.Title)
|
||||
assert.Equal(t, "Message", notif.Message)
|
||||
assert.False(t, notif.Read)
|
||||
}
|
||||
|
||||
func TestNotificationService_List(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
svc.Create(models.NotificationTypeInfo, "N1", "M1")
|
||||
svc.Create(models.NotificationTypeInfo, "N2", "M2")
|
||||
|
||||
list, err := svc.List(false)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 2)
|
||||
|
||||
// Mark one as read
|
||||
db.Model(&models.Notification{}).Where("title = ?", "N1").Update("read", true)
|
||||
|
||||
listUnread, err := svc.List(true)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, listUnread, 1)
|
||||
assert.Equal(t, "N2", listUnread[0].Title)
|
||||
}
|
||||
|
||||
func TestNotificationService_MarkAsRead(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
notif, _ := svc.Create(models.NotificationTypeInfo, "N1", "M1")
|
||||
|
||||
err := svc.MarkAsRead(fmt.Sprintf("%s", notif.ID))
|
||||
require.NoError(t, err)
|
||||
|
||||
var updated models.Notification
|
||||
db.First(&updated, "id = ?", notif.ID)
|
||||
assert.True(t, updated.Read)
|
||||
}
|
||||
|
||||
func TestNotificationService_MarkAllAsRead(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
svc.Create(models.NotificationTypeInfo, "N1", "M1")
|
||||
svc.Create(models.NotificationTypeInfo, "N2", "M2")
|
||||
|
||||
err := svc.MarkAllAsRead()
|
||||
require.NoError(t, err)
|
||||
|
||||
var count int64
|
||||
db.Model(&models.Notification{}).Where("read = ?", false).Count(&count)
|
||||
assert.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestNotificationService_Providers(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
// Create
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Discord",
|
||||
Type: "discord",
|
||||
URL: "http://example.com",
|
||||
}
|
||||
err := svc.CreateProvider(&provider)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, provider.ID)
|
||||
assert.Equal(t, "Discord", provider.Name)
|
||||
|
||||
// List
|
||||
list, err := svc.ListProviders()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 1)
|
||||
|
||||
// Update
|
||||
provider.Name = "Discord Updated"
|
||||
err = svc.UpdateProvider(&provider)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Discord Updated", provider.Name)
|
||||
|
||||
// Delete
|
||||
err = svc.DeleteProvider(provider.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
list, err = svc.ListProviders()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 0)
|
||||
}
|
||||
|
||||
func TestNotificationService_TestProvider_Webhook(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
// Start a test server
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
assert.Equal(t, "Test Notification", body["Title"])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Test Webhook",
|
||||
Type: "webhook",
|
||||
URL: ts.URL,
|
||||
Config: `{"Title": "{{.Title}}"}`,
|
||||
}
|
||||
|
||||
err := svc.TestProvider(provider)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestNotificationService_SendExternal(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
received := make(chan struct{})
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(received)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Test Webhook",
|
||||
Type: "webhook",
|
||||
URL: ts.URL,
|
||||
Enabled: true,
|
||||
NotifyProxyHosts: true,
|
||||
}
|
||||
svc.CreateProvider(&provider)
|
||||
|
||||
svc.SendExternal("proxy_host", "Title", "Message", nil)
|
||||
|
||||
select {
|
||||
case <-received:
|
||||
// Success
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("Timed out waiting for webhook")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationService_SendExternal_Filtered(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
received := make(chan struct{})
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(received)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Test Webhook",
|
||||
Type: "webhook",
|
||||
URL: ts.URL,
|
||||
Enabled: true,
|
||||
NotifyProxyHosts: false, // Disabled
|
||||
}
|
||||
svc.CreateProvider(&provider)
|
||||
// Force update to false because GORM default tag might override zero value (false) on Create
|
||||
db.Model(&provider).Update("notify_proxy_hosts", false)
|
||||
|
||||
svc.SendExternal("proxy_host", "Title", "Message", nil)
|
||||
|
||||
select {
|
||||
case <-received:
|
||||
t.Fatal("Should not have received webhook")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
// Success (timeout expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationService_SendExternal_Shoutrrr(t *testing.T) {
|
||||
db := setupNotificationTestDB(t)
|
||||
svc := NewNotificationService(db)
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Test Discord",
|
||||
Type: "discord",
|
||||
URL: "discord://token@id",
|
||||
Enabled: true,
|
||||
NotifyProxyHosts: true,
|
||||
}
|
||||
svc.CreateProvider(&provider)
|
||||
|
||||
// This will log an error but should cover the code path
|
||||
svc.SendExternal("proxy_host", "Title", "Message", nil)
|
||||
|
||||
// Give it a moment to run goroutine
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestNormalizeURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serviceType string
|
||||
rawURL string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Discord HTTPS",
|
||||
serviceType: "discord",
|
||||
rawURL: "https://discord.com/api/webhooks/123456789/abcdefg",
|
||||
expected: "discord://abcdefg@123456789",
|
||||
},
|
||||
{
|
||||
name: "Discord HTTPS with app",
|
||||
serviceType: "discord",
|
||||
rawURL: "https://discordapp.com/api/webhooks/123456789/abcdefg",
|
||||
expected: "discord://abcdefg@123456789",
|
||||
},
|
||||
{
|
||||
name: "Discord Shoutrrr",
|
||||
serviceType: "discord",
|
||||
rawURL: "discord://token@id",
|
||||
expected: "discord://token@id",
|
||||
},
|
||||
{
|
||||
name: "Other Service",
|
||||
serviceType: "slack",
|
||||
rawURL: "https://hooks.slack.com/services/...",
|
||||
expected: "https://hooks.slack.com/services/...",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := normalizeURL(tt.serviceType, tt.rawURL)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// ProxyHostService encapsulates business logic for proxy host management.
|
||||
type ProxyHostService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewProxyHostService creates a new proxy host service.
|
||||
func NewProxyHostService(db *gorm.DB) *ProxyHostService {
|
||||
return &ProxyHostService{db: db}
|
||||
}
|
||||
|
||||
// ValidateUniqueDomain ensures no duplicate domains exist before creation/update.
|
||||
func (s *ProxyHostService) ValidateUniqueDomain(domainNames string, excludeID uint) error {
|
||||
var count int64
|
||||
query := s.db.Model(&models.ProxyHost{}).Where("domain_names = ?", domainNames)
|
||||
|
||||
if excludeID > 0 {
|
||||
query = query.Where("id != ?", excludeID)
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("checking domain uniqueness: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return errors.New("domain already exists")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create validates and creates a new proxy host.
|
||||
func (s *ProxyHostService) Create(host *models.ProxyHost) error {
|
||||
if err := s.ValidateUniqueDomain(host.DomainNames, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Create(host).Error
|
||||
}
|
||||
|
||||
// Update validates and updates an existing proxy host.
|
||||
func (s *ProxyHostService) Update(host *models.ProxyHost) error {
|
||||
if err := s.ValidateUniqueDomain(host.DomainNames, host.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Save(host).Error
|
||||
}
|
||||
|
||||
// Delete removes a proxy host.
|
||||
func (s *ProxyHostService) Delete(id uint) error {
|
||||
return s.db.Delete(&models.ProxyHost{}, id).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves a proxy host by ID.
|
||||
func (s *ProxyHostService) GetByID(id uint) (*models.ProxyHost, error) {
|
||||
var host models.ProxyHost
|
||||
if err := s.db.First(&host, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &host, nil
|
||||
}
|
||||
|
||||
// GetByUUID finds a proxy host by UUID.
|
||||
func (s *ProxyHostService) GetByUUID(uuid string) (*models.ProxyHost, error) {
|
||||
var host models.ProxyHost
|
||||
if err := s.db.Preload("Locations").Preload("Certificate").Where("uuid = ?", uuid).First(&host).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &host, nil
|
||||
}
|
||||
|
||||
// List returns all proxy hosts.
|
||||
func (s *ProxyHostService) List() ([]models.ProxyHost, error) {
|
||||
var hosts []models.ProxyHost
|
||||
if err := s.db.Preload("Locations").Preload("Certificate").Order("updated_at desc").Find(&hosts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
// TestConnection attempts to connect to the target host and port.
|
||||
func (s *ProxyHostService) TestConnection(host string, port int) error {
|
||||
if host == "" || port <= 0 {
|
||||
return errors.New("invalid host or port")
|
||||
}
|
||||
|
||||
target := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
conn, err := net.DialTimeout("tcp", target, 3*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connection failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupProxyHostTestDB(t *testing.T) *gorm.DB {
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}))
|
||||
return db
|
||||
}
|
||||
|
||||
func TestProxyHostService_ValidateUniqueDomain(t *testing.T) {
|
||||
db := setupProxyHostTestDB(t)
|
||||
service := NewProxyHostService(db)
|
||||
|
||||
// Create existing host
|
||||
existing := &models.ProxyHost{
|
||||
DomainNames: "example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
require.NoError(t, db.Create(existing).Error)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
domainNames string
|
||||
excludeID uint
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "New unique domain",
|
||||
domainNames: "new.example.com",
|
||||
excludeID: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Duplicate domain",
|
||||
domainNames: "example.com",
|
||||
excludeID: 0,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Same domain but excluded ID (update self)",
|
||||
domainNames: "example.com",
|
||||
excludeID: existing.ID,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := service.ValidateUniqueDomain(tt.domainNames, tt.excludeID)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyHostService_CRUD(t *testing.T) {
|
||||
db := setupProxyHostTestDB(t)
|
||||
service := NewProxyHostService(db)
|
||||
|
||||
// Create
|
||||
host := &models.ProxyHost{
|
||||
UUID: "uuid-1",
|
||||
DomainNames: "test.example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
err := service.Create(host)
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, host.ID)
|
||||
|
||||
// Create Duplicate
|
||||
dup := &models.ProxyHost{
|
||||
UUID: "uuid-2",
|
||||
DomainNames: "test.example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8081,
|
||||
}
|
||||
err = service.Create(dup)
|
||||
assert.Error(t, err)
|
||||
|
||||
// GetByID
|
||||
fetched, err := service.GetByID(host.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, host.DomainNames, fetched.DomainNames)
|
||||
|
||||
// GetByUUID
|
||||
fetchedUUID, err := service.GetByUUID(host.UUID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, host.ID, fetchedUUID.ID)
|
||||
|
||||
// Update
|
||||
host.ForwardPort = 9090
|
||||
err = service.Update(host)
|
||||
assert.NoError(t, err)
|
||||
|
||||
fetched, err = service.GetByID(host.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 9090, fetched.ForwardPort)
|
||||
|
||||
// Update Duplicate
|
||||
host2 := &models.ProxyHost{
|
||||
UUID: "uuid-3",
|
||||
DomainNames: "other.example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
service.Create(host2)
|
||||
|
||||
host.DomainNames = "other.example.com" // Conflict with host2
|
||||
err = service.Update(host)
|
||||
assert.Error(t, err)
|
||||
|
||||
// List
|
||||
hosts, err := service.List()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, hosts, 2)
|
||||
|
||||
// Delete
|
||||
err = service.Delete(host.ID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = service.GetByID(host.ID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestProxyHostService_TestConnection(t *testing.T) {
|
||||
db := setupProxyHostTestDB(t)
|
||||
service := NewProxyHostService(db)
|
||||
|
||||
// 1. Invalid Input
|
||||
err := service.TestConnection("", 80)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid host or port")
|
||||
|
||||
err = service.TestConnection("example.com", 0)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid host or port")
|
||||
|
||||
// 2. Connection Failure (Unreachable)
|
||||
err = service.TestConnection("localhost", 54321)
|
||||
assert.Error(t, err)
|
||||
|
||||
// 3. Connection Success
|
||||
// Start a local listener
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
addr := l.Addr().(*net.TCPAddr)
|
||||
|
||||
err = service.TestConnection(addr.IP.String(), addr.Port)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// RemoteServerService encapsulates business logic for remote server management.
|
||||
type RemoteServerService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewRemoteServerService creates a new remote server service.
|
||||
func NewRemoteServerService(db *gorm.DB) *RemoteServerService {
|
||||
return &RemoteServerService{db: db}
|
||||
}
|
||||
|
||||
// ValidateUniqueServer ensures no duplicate name+host+port combinations.
|
||||
func (s *RemoteServerService) ValidateUniqueServer(name, host string, port int, excludeID uint) error {
|
||||
var count int64
|
||||
query := s.db.Model(&models.RemoteServer{}).Where("name = ? OR (host = ? AND port = ?)", name, host, port)
|
||||
|
||||
if excludeID > 0 {
|
||||
query = query.Where("id != ?", excludeID)
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("checking server uniqueness: %w", err)
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return errors.New("server with same name or host:port already exists")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create validates and creates a new remote server.
|
||||
func (s *RemoteServerService) Create(server *models.RemoteServer) error {
|
||||
if err := s.ValidateUniqueServer(server.Name, server.Host, server.Port, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Create(server).Error
|
||||
}
|
||||
|
||||
// Update validates and updates an existing remote server.
|
||||
func (s *RemoteServerService) Update(server *models.RemoteServer) error {
|
||||
if err := s.ValidateUniqueServer(server.Name, server.Host, server.Port, server.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.db.Save(server).Error
|
||||
}
|
||||
|
||||
// Delete removes a remote server.
|
||||
func (s *RemoteServerService) Delete(id uint) error {
|
||||
return s.db.Delete(&models.RemoteServer{}, id).Error
|
||||
}
|
||||
|
||||
// GetByID retrieves a remote server by ID.
|
||||
func (s *RemoteServerService) GetByID(id uint) (*models.RemoteServer, error) {
|
||||
var server models.RemoteServer
|
||||
if err := s.db.First(&server, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
// GetByUUID retrieves a remote server by UUID.
|
||||
func (s *RemoteServerService) GetByUUID(uuid string) (*models.RemoteServer, error) {
|
||||
var server models.RemoteServer
|
||||
if err := s.db.Where("uuid = ?", uuid).First(&server).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
// List retrieves all remote servers, optionally filtering by enabled status.
|
||||
func (s *RemoteServerService) List(enabledOnly bool) ([]models.RemoteServer, error) {
|
||||
var servers []models.RemoteServer
|
||||
query := s.db
|
||||
|
||||
if enabledOnly {
|
||||
query = query.Where("enabled = ?", true)
|
||||
}
|
||||
|
||||
if err := query.Order("name ASC").Find(&servers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user