Merge pull request #120 from Wikid82/feature/Automatic-HTTPS-&-Certificate-Management

feat: Implement User Authentication & Fix Frontend Startup
This commit is contained in:
Jeremy
2025-11-19 19:47:13 -05:00
committed by GitHub
38 changed files with 1198 additions and 67 deletions

View File

@@ -27,7 +27,8 @@ func main() {
router := server.NewRouter(cfg.FrontendDir)
if err := routes.Register(router, db); err != nil {
// 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)
}

View File

@@ -22,6 +22,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.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

View File

@@ -25,6 +25,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
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/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.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=

View File

@@ -0,0 +1,73 @@
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")
c.JSON(http.StatusOK, gin.H{"user_id": userID, "role": role})
}

View File

@@ -0,0 +1,27 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/services"
)
type CertificateHandler struct {
service *services.CertificateService
}
func NewCertificateHandler(service *services.CertificateService) *CertificateHandler {
return &CertificateHandler{service: service}
}
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)
}

View File

@@ -0,0 +1,113 @@
package handlers
import (
"net/http"
"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)
}
// 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: req.Email,
Role: "admin",
Enabled: true,
}
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,
},
})
}

View File

@@ -0,0 +1,55 @@
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 == "" {
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()
}
}

View File

@@ -7,11 +7,14 @@ import (
"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/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) error {
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{},
@@ -31,12 +34,37 @@ func Register(router *gin.Engine, db *gorm.DB) error {
api := router.Group("/api/v1")
// Auth routes
authService := services.NewAuthService(db, cfg)
authHandler := handlers.NewAuthHandler(authService)
authMiddleware := middleware.AuthMiddleware(authService)
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)
}
proxyHostHandler := handlers.NewProxyHostHandler(db)
proxyHostHandler.RegisterRoutes(api)
remoteServerHandler := handlers.NewRemoteServerHandler(db)
remoteServerHandler.RegisterRoutes(api)
userHandler := handlers.NewUserHandler(db)
userHandler.RegisterRoutes(api)
// Certificate routes
// Use cfg.CaddyConfigDir + "/data" for cert service
caddyDataDir := cfg.CaddyConfigDir + "/data"
certService := services.NewCertificateService(caddyDataDir)
certHandler := handlers.NewCertificateHandler(certService)
api.GET("/certificates", certHandler.List)
return nil
}

View File

@@ -30,7 +30,7 @@ func TestClient_Load_Success(t *testing.T) {
ForwardPort: 8080,
Enabled: true,
},
})
}, "/tmp/caddy-data", "admin@example.com")
err := client.Load(context.Background(), config)
require.NoError(t, err)

View File

@@ -9,15 +9,42 @@ import (
// 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) (*Config, error) {
if len(hosts) == 0 {
return &Config{
Apps: Apps{
HTTP: &HTTPApp{
Servers: map[string]*Server{},
func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string) (*Config, error) {
config := &Config{
Apps: Apps{
HTTP: &HTTPApp{
Servers: map[string]*Server{},
},
},
Storage: Storage{
System: "file_system",
Root: storageDir,
},
}
if acmeEmail != "" {
config.Apps.TLS = &TLSApp{
Automation: &AutomationConfig{
Policies: []*AutomationPolicy{
{
IssuersRaw: []interface{}{
map[string]interface{}{
"module": "acme",
"email": acmeEmail,
},
map[string]interface{}{
"module": "zerossl",
"email": acmeEmail,
},
},
},
},
},
}, nil
}
}
if len(hosts) == 0 {
return config, nil
}
routes := make([]*Route, 0)
@@ -89,19 +116,12 @@ func GenerateConfig(hosts []models.ProxyHost) (*Config, error) {
routes = append(routes, route)
}
config := &Config{
Apps: Apps{
HTTP: &HTTPApp{
Servers: map[string]*Server{
"cpm_server": {
Listen: []string{":80", ":443"},
Routes: routes,
AutoHTTPS: &AutoHTTPSConfig{
Disable: false,
},
},
},
},
config.Apps.HTTP.Servers["cpm_server"] = &Server{
Listen: []string{":80", ":443"},
Routes: routes,
AutoHTTPS: &AutoHTTPSConfig{
Disable: false,
DisableRedir: false,
},
}

View File

@@ -9,7 +9,7 @@ import (
)
func TestGenerateConfig_Empty(t *testing.T) {
config, err := GenerateConfig([]models.ProxyHost{})
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)
@@ -31,7 +31,7 @@ func TestGenerateConfig_SingleHost(t *testing.T) {
},
}
config, err := GenerateConfig(hosts)
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com")
require.NoError(t, err)
require.NotNil(t, config)
require.NotNil(t, config.Apps.HTTP)
@@ -71,7 +71,7 @@ func TestGenerateConfig_MultipleHosts(t *testing.T) {
},
}
config, err := GenerateConfig(hosts)
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)
}
@@ -88,7 +88,7 @@ func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
},
}
config, err := GenerateConfig(hosts)
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com")
require.NoError(t, err)
route := config.Apps.HTTP.Servers["cpm_server"].Routes[0]
@@ -109,7 +109,7 @@ func TestGenerateConfig_EmptyDomain(t *testing.T) {
},
}
_, err := GenerateConfig(hosts)
_, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com")
require.Error(t, err)
require.Contains(t, err.Error(), "empty domain")
}

View File

@@ -39,8 +39,15 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
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
}
// Generate Caddy config
config, err := GenerateConfig(hosts)
config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail)
if err != nil {
return fmt.Errorf("generate config: %w", err)
}

View File

@@ -3,7 +3,14 @@ package caddy
// Config represents Caddy's top-level JSON configuration structure.
// Reference: https://caddyserver.com/docs/json/
type Config struct {
Apps Apps `json:"apps"`
Apps Apps `json:"apps"`
Storage Storage `json:"storage,omitempty"`
}
// Storage configures the storage module.
type Storage struct {
System string `json:"module"`
Root string `json:"root,omitempty"`
}
// Apps contains all Caddy app modules.

View File

@@ -25,7 +25,7 @@ func TestValidate_ValidConfig(t *testing.T) {
},
}
config, _ := GenerateConfig(hosts)
config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com")
err := Validate(config)
require.NoError(t, err)
}

View File

@@ -17,6 +17,7 @@ type Config struct {
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.
@@ -31,6 +32,7 @@ func Load() (Config, error) {
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 {

View File

@@ -2,19 +2,39 @@ 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"`
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"`
LastLogin *time.Time `json:"last_login,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `json:"id" gorm:"primaryKey"`
UUID string `json:"uuid" gorm:"uniqueIndex"`
Email string `json:"email" gorm:"uniqueIndex"`
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
}

View File

@@ -0,0 +1,122 @@
package services
import (
"errors"
"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) {
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,
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) {
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) 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
}

View File

@@ -0,0 +1,105 @@
package services
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// CertificateInfo represents parsed certificate details.
type CertificateInfo struct {
Domain string `json:"domain"`
Issuer string `json:"issuer"`
ExpiresAt time.Time `json:"expires_at"`
Status string `json:"status"` // "valid", "expiring", "expired"
}
// CertificateService manages certificate retrieval and parsing.
type CertificateService struct {
dataDir string
}
// NewCertificateService creates a new certificate service.
func NewCertificateService(dataDir string) *CertificateService {
return &CertificateService{
dataDir: dataDir,
}
}
// ListCertificates scans the Caddy data directory for certificates.
// It looks in certificates/acme-v02.api.letsencrypt.org-directory/ and others.
func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) {
certs := []CertificateInfo{}
certRoot := filepath.Join(s.dataDir, "certificates")
// Walk through the certificate directory
err := filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
// If directory doesn't exist yet (fresh install), just return empty
if os.IsNotExist(err) {
return nil
}
return err
}
// We only care about .crt files
if !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") {
cert, err := s.parseCertificate(path)
if err != nil {
// Log error but continue scanning other certs
fmt.Printf("failed to parse cert %s: %v\n", path, err)
return nil
}
certs = append(certs, *cert)
}
return nil
})
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("walk certificates: %w", err)
}
return certs, nil
}
func (s *CertificateService) parseCertificate(path string) (*CertificateInfo, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
status := "valid"
now := time.Now()
if now.After(cert.NotAfter) {
status = "expired"
} else if now.Add(30 * 24 * time.Hour).After(cert.NotAfter) {
status = "expiring"
}
// Domain is usually the CommonName or the first SAN
domain := cert.Subject.CommonName
if domain == "" && len(cert.DNSNames) > 0 {
domain = cert.DNSNames[0]
}
return &CertificateInfo{
Domain: domain,
Issuer: cert.Issuer.CommonName,
ExpiresAt: cert.NotAfter,
Status: status,
}, nil
}

40
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,40 @@
version: '3.9'
services:
app:
image: cpmp:local
container_name: caddyproxymanagerplus-local
restart: unless-stopped
ports:
- "80:80" # HTTP (Caddy proxy)
- "443:443" # HTTPS (Caddy proxy)
- "443:443/udp" # HTTP/3 (Caddy proxy)
- "8080:8080" # Management UI (CPM+)
environment:
- CPM_ENV=production
- CPM_HTTP_PORT=8080
- CPM_DB_PATH=/app/data/cpm.db
- CPM_FRONTEND_DIR=/app/frontend/dist
- CPM_CADDY_ADMIN_API=http://localhost:2019
- CPM_CADDY_CONFIG_DIR=/app/data/caddy
- CPM_CADDY_BINARY=caddy
- CPM_IMPORT_CADDYFILE=/import/Caddyfile
- CPM_IMPORT_DIR=/app/data/imports
volumes:
- cpm_data_local:/app/data
- caddy_data_local:/data
- caddy_config_local:/config
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
cpm_data_local:
driver: local
caddy_data_local:
driver: local
caddy_config_local:
driver: local

View File

@@ -17,7 +17,7 @@ echo "Caddy started (PID: $CADDY_PID)"
echo "Waiting for Caddy admin API..."
i=1
while [ "$i" -le 30 ]; do
if wget -q -O- http://localhost:2019/config/ > /dev/null 2>&1; then
if wget -q -O- http://127.0.0.1:2019/config/ > /dev/null 2>&1; then
echo "Caddy is ready!"
break
fi

View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Caddy Proxy Manager+</title>
<script type="module" crossorigin src="/assets/index-BQ-TMhGu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChYJmfs0.css">
<script type="module" crossorigin src="/assets/index-8SDdHIgk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-uCmiid6Y.css">
</head>
<body>
<div id="root"></div>

View File

@@ -11,6 +11,7 @@
"@tanstack/react-query": "^5.62.8",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"lucide-react": "^0.554.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.2"
@@ -4042,6 +4043,14 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.554.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz",
"integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",

View File

@@ -16,6 +16,7 @@
"@tanstack/react-query": "^5.62.8",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"lucide-react": "^0.554.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.2"

View File

@@ -1,27 +1,44 @@
import { BrowserRouter as Router, Routes, Route, Outlet } from 'react-router-dom'
import Layout from './components/Layout'
import { ToastContainer } from './components/Toast'
import { SetupGuard } from './components/SetupGuard'
import RequireAuth from './components/RequireAuth'
import { AuthProvider } from './context/AuthContext'
import Dashboard from './pages/Dashboard'
import ProxyHosts from './pages/ProxyHosts'
import RemoteServers from './pages/RemoteServers'
import ImportCaddy from './pages/ImportCaddy'
import Certificates from './pages/Certificates'
import Settings from './pages/Settings'
import Login from './pages/Login'
import Setup from './pages/Setup'
export default function App() {
return (
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Layout><Outlet /></Layout>}>
<Route index element={<Dashboard />} />
<Route path="proxy-hosts" element={<ProxyHosts />} />
<Route path="remote-servers" element={<RemoteServers />} />
<Route path="import" element={<ImportCaddy />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
<ToastContainer />
</Router>
<AuthProvider>
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/setup" element={<Setup />} />
<Route path="/" element={
<SetupGuard>
<RequireAuth>
<Layout>
<Outlet />
</Layout>
</RequireAuth>
</SetupGuard>
}>
<Route index element={<Dashboard />} />
<Route path="proxy-hosts" element={<ProxyHosts />} />
<Route path="remote-servers" element={<RemoteServers />} />
<Route path="certificates" element={<Certificates />} />
<Route path="import" element={<ImportCaddy />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
<ToastContainer />
</Router>
</AuthProvider>
)
}

View File

@@ -0,0 +1,13 @@
import client from './client'
export interface Certificate {
domain: string
issuer: string
expires_at: string
status: 'valid' | 'expiring' | 'expired'
}
export async function getCertificates(): Promise<Certificate[]> {
const response = await client.get<Certificate[]>('/certificates')
return response.data
}

20
frontend/src/api/setup.ts Normal file
View File

@@ -0,0 +1,20 @@
import client from './client';
export interface SetupStatus {
setupRequired: boolean;
}
export interface SetupRequest {
name: string;
email: string;
password: string;
}
export const getSetupStatus = async (): Promise<SetupStatus> => {
const response = await client.get<SetupStatus>('/setup');
return response.data;
};
export const performSetup = async (data: SetupRequest): Promise<void> => {
await client.post('/setup', data);
};

View File

@@ -0,0 +1,71 @@
import { useCertificates } from '../hooks/useCertificates'
import { LoadingSpinner } from './LoadingStates'
export default function CertificateList() {
const { certificates, isLoading, error } = useCertificates()
if (isLoading) return <LoadingSpinner />
if (error) return <div className="text-red-500">Failed to load certificates</div>
return (
<div className="bg-dark-card rounded-lg border border-gray-800 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-gray-400">
<thead className="bg-gray-900 text-gray-200 uppercase font-medium">
<tr>
<th className="px-6 py-3">Domain</th>
<th className="px-6 py-3">Issuer</th>
<th className="px-6 py-3">Expires</th>
<th className="px-6 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800">
{certificates.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
No certificates found.
</td>
</tr>
) : (
certificates.map((cert) => (
<tr key={cert.domain} className="hover:bg-gray-800/50 transition-colors">
<td className="px-6 py-4 font-medium text-white">{cert.domain}</td>
<td className="px-6 py-4">{cert.issuer}</td>
<td className="px-6 py-4">
{new Date(cert.expires_at).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<StatusBadge status={cert.status} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const styles = {
valid: 'bg-green-900/30 text-green-400 border-green-800',
expiring: 'bg-yellow-900/30 text-yellow-400 border-yellow-800',
expired: 'bg-red-900/30 text-red-400 border-red-800',
}
const labels = {
valid: 'Valid',
expiring: 'Expiring Soon',
expired: 'Expired',
}
const style = styles[status as keyof typeof styles] || styles.valid
const label = labels[status as keyof typeof labels] || status
return (
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${style}`}>
{label}
</span>
)
}

View File

@@ -15,6 +15,7 @@ export default function Layout({ children }: LayoutProps) {
{ name: 'Dashboard', path: '/', icon: '📊' },
{ name: 'Proxy Hosts', path: '/proxy-hosts', icon: '🌐' },
{ name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' },
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
{ name: 'Import Caddyfile', path: '/import', icon: '📥' },
{ name: 'Settings', path: '/settings', icon: '⚙️' },
]

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const RequireAuth: React.FC<{ children: JSX.Element }> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <div>Loading...</div>; // Or a spinner
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
};
export default RequireAuth;

View File

@@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getSetupStatus } from '../api/setup';
interface SetupGuardProps {
children: React.ReactNode;
}
export const SetupGuard: React.FC<SetupGuardProps> = ({ children }) => {
const navigate = useNavigate();
const { data: status, isLoading } = useQuery({
queryKey: ['setupStatus'],
queryFn: getSetupStatus,
retry: false,
});
useEffect(() => {
if (status?.setupRequired) {
navigate('/setup');
}
}, [status, navigate]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="text-blue-500">Loading...</div>
</div>
);
}
if (status?.setupRequired) {
return null; // Will redirect
}
return <>{children}</>;
};

View File

@@ -36,6 +36,7 @@ describe('Layout', () => {
expect(screen.getByText('Dashboard')).toBeInTheDocument()
expect(screen.getByText('Proxy Hosts')).toBeInTheDocument()
expect(screen.getByText('Remote Servers')).toBeInTheDocument()
expect(screen.getByText('Certificates')).toBeInTheDocument()
expect(screen.getByText('Import Caddyfile')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
})

View File

@@ -0,0 +1,71 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import client from '../api/client';
import { AxiosResponse } from 'axios';
interface User {
user_id: number;
role: string;
}
interface AuthContextType {
user: User | null;
login: () => void;
logout: () => void;
isAuthenticated: boolean;
isLoading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const checkAuth = async () => {
try {
const response = await client.get('/auth/me');
setUser(response.data);
} catch (error) {
setUser(null);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = () => {
// Token is stored in cookie by backend, but we might want to store it in memory or trigger a re-fetch
// Actually, if backend sets cookie, we just need to fetch /auth/me
client.get('/auth/me').then((response: AxiosResponse<User>) => {
setUser(response.data);
}).catch(() => {
setUser(null);
});
};
const logout = async () => {
try {
await client.post('/auth/logout');
} catch (error) {
console.error("Logout failed", error);
}
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: !!user, isLoading }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { getCertificates } from '../api/certificates'
export function useCertificates() {
const { data: certificates = [], isLoading, error } = useQuery({
queryKey: ['certificates'],
queryFn: getCertificates,
})
return {
certificates,
isLoading,
error,
}
}

View File

@@ -1,13 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.tsx'
import { ThemeProvider } from './context/ThemeContext'
import './index.css'
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<App />
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,18 @@
import CertificateList from '../components/CertificateList'
export default function Certificates() {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-white mb-2">Certificates</h1>
<p className="text-gray-400">
View and manage SSL certificates automatically acquired by Caddy.
</p>
</div>
</div>
<CertificateList />
</div>
)
}

View File

@@ -4,27 +4,30 @@ import { Card } from '../components/ui/Card'
import { Input } from '../components/ui/Input'
import { Button } from '../components/ui/Button'
import { toast } from '../components/Toast'
import client from '../api/client'
import { useAuth } from '../context/AuthContext'
export default function Login() {
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
// Mock login delay
await new Promise(resolve => setTimeout(resolve, 1000))
if (email === 'admin@example.com' && password === 'changeme') {
try {
await client.post('/auth/login', { email, password })
login()
toast.success('Logged in successfully')
navigate('/')
} else {
toast.error('Invalid credentials')
} catch (err: any) {
toast.error(err.response?.data?.error || 'Login failed')
} finally {
setLoading(false)
}
setLoading(false)
}
return (

View File

@@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery } from '@tanstack/react-query';
import { getSetupStatus, performSetup, SetupRequest } from '../api/setup';
const Setup: React.FC = () => {
const navigate = useNavigate();
const [formData, setFormData] = useState<SetupRequest>({
name: '',
email: '',
password: '',
});
const [error, setError] = useState<string | null>(null);
const { data: status, isLoading: statusLoading } = useQuery({
queryKey: ['setupStatus'],
queryFn: getSetupStatus,
retry: false,
});
useEffect(() => {
if (status && !status.setupRequired) {
navigate('/login');
}
}, [status, navigate]);
const mutation = useMutation({
mutationFn: performSetup,
onSuccess: () => {
navigate('/login');
},
onError: (err: any) => {
setError(err.response?.data?.error || 'Setup failed');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
mutation.mutate(formData);
};
if (statusLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900">
<div className="text-blue-500">Loading...</div>
</div>
);
}
if (status && !status.setupRequired) {
return null; // Will redirect in useEffect
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 bg-white dark:bg-gray-800 p-8 rounded-lg shadow-md">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
Welcome to CPM+
</h2>
<p className="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Create your administrator account to get started.
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div className="mb-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Admin User"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="mb-4">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email Address
</label>
<input
id="email"
name="email"
type="email"
required
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="admin@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
This email will be used for Let's Encrypt certificate notifications and recovery.
</p>
</div>
<div className="mb-4">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 placeholder-gray-500 text-gray-900 dark:text-white dark:bg-gray-700 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="********"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
</div>
</div>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div>
<button
type="submit"
disabled={mutation.isPending}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{mutation.isPending ? (
'Loading...'
) : (
'Create Admin Account'
)}
</button>
</div>
</form>
</div>
</div>
);
};
export default Setup;

View File

@@ -0,0 +1,61 @@
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import Setup from '../Setup';
import * as setupApi from '../../api/setup';
// Mock the API module
vi.mock('../../api/setup', () => ({
getSetupStatus: vi.fn(),
performSetup: vi.fn(),
}));
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const renderWithProviders = (ui: React.ReactNode) => {
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
{ui}
</MemoryRouter>
</QueryClientProvider>
);
};
describe('Setup Page', () => {
beforeEach(() => {
vi.clearAllMocks();
queryClient.clear();
});
it('renders setup form when setup is required', async () => {
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: true });
renderWithProviders(<Setup />);
await waitFor(() => {
expect(screen.getByText('Welcome to CPM+')).toBeTruthy();
});
expect(screen.getByLabelText('Name')).toBeTruthy();
expect(screen.getByLabelText('Email Address')).toBeTruthy();
expect(screen.getByLabelText('Password')).toBeTruthy();
});
it('does not render form when setup is not required', async () => {
vi.mocked(setupApi.getSetupStatus).mockResolvedValue({ setupRequired: false });
renderWithProviders(<Setup />);
await waitFor(() => {
expect(screen.queryByText('Welcome to CPM+')).toBeNull();
});
});
});