diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go index d46ad2d4..fd3cd257 100644 --- a/backend/internal/api/handlers/certificate_handler.go +++ b/backend/internal/api/handlers/certificate_handler.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "strconv" "github.com/gin-gonic/gin" @@ -25,3 +26,80 @@ func (h *CertificateHandler) List(c *gin.Context) { 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 + } + + 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 + } + + c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"}) +} diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go index 116e547e..195ceef6 100644 --- a/backend/internal/api/handlers/certificate_handler_test.go +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -1,19 +1,59 @@ 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() @@ -21,7 +61,12 @@ func TestCertificateHandler_List(t *testing.T) { err := os.MkdirAll(caddyDir, 0755) require.NoError(t, err) - service := services.NewCertificateService(tmpDir) + // 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) handler := NewCertificateHandler(service) gin.SetMode(gin.TestMode) @@ -38,3 +83,83 @@ func TestCertificateHandler_List(t *testing.T) { 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) + handler := NewCertificateHandler(service) + + 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) + handler := NewCertificateHandler(service) + + 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) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 38664818..d00396cb 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -146,9 +146,11 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { // Certificate routes // Use cfg.CaddyConfigDir + "/data" for cert service caddyDataDir := cfg.CaddyConfigDir + "/data" - certService := services.NewCertificateService(caddyDataDir) + certService := services.NewCertificateService(caddyDataDir, db) certHandler := handlers.NewCertificateHandler(certService) api.GET("/certificates", certHandler.List) + api.POST("/certificates", certHandler.Upload) + api.DELETE("/certificates/:id", certHandler.Delete) return nil } diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 6402d82e..eadf6352 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -85,6 +85,32 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail strin } } + // 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 } diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go index 70b0bf33..e3584f96 100644 --- a/backend/internal/caddy/types.go +++ b/backend/internal/caddy/types.go @@ -159,7 +159,20 @@ func FileServerHandler(root string) Handler { // TLSApp configures the TLS app for certificate management. type TLSApp struct { - Automation *AutomationConfig `json:"automation,omitempty"` + 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. diff --git a/backend/internal/models/proxy_host.go b/backend/internal/models/proxy_host.go index 268e1e37..2f1dbdb6 100644 --- a/backend/internal/models/proxy_host.go +++ b/backend/internal/models/proxy_host.go @@ -6,21 +6,23 @@ import ( // 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"` - Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + 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"` } diff --git a/backend/internal/services/certificate_service.go b/backend/internal/services/certificate_service.go index bee1efb6..e678510a 100644 --- a/backend/internal/services/certificate_service.go +++ b/backend/internal/services/certificate_service.go @@ -8,98 +8,165 @@ import ( "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" + 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) *CertificateService { +func NewCertificateService(dataDir string, db *gorm.DB) *CertificateService { return &CertificateService{ dataDir: dataDir, + db: db, } } -// ListCertificates scans the Caddy data directory for certificates. -// It looks in certificates/acme-v02.api.letsencrypt.org-directory/ and others. +// ListCertificates returns both auto-generated and custom certificates. func (s *CertificateService) ListCertificates() ([]CertificateInfo, error) { - certs := []CertificateInfo{} - certRoot := filepath.Join(s.dataDir, "certificates") + var certs []CertificateInfo - // Walk through the certificate directory + // 1. Get Custom Certificates from DB + var dbCerts []models.SSLCertificate + if err := s.db.Find(&dbCerts).Error; err != nil { + return nil, fmt.Errorf("failed to fetch custom certs: %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" + } + } + + certs = append(certs, CertificateInfo{ + ID: c.ID, + UUID: c.UUID, + Name: c.Name, + Domain: c.Domains, + Issuer: c.Provider, // "custom" or "self-signed" + ExpiresAt: *c.ExpiresAt, + Status: status, + Provider: c.Provider, + }) + } + + // 2. Scan Caddy data directory for auto-generated certificates + certRoot := filepath.Join(s.dataDir, "certificates") 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) + // Parse the certificate + certData, err := os.ReadFile(path) if err != nil { - // Log error but continue scanning other certs - fmt.Printf("failed to parse cert %s: %v\n", path, err) + return nil // Skip unreadable + } + + block, _ := pem.Decode(certData) + if block == nil { return nil } - certs = append(certs, *cert) + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil + } + + // Determine status + status := "valid" + if time.Now().After(cert.NotAfter) { + status = "expired" + } else if time.Now().AddDate(0, 0, 30).After(cert.NotAfter) { + status = "expiring" + } + + // Avoid duplicates if we somehow have them (though DB ones are custom) + certs = append(certs, CertificateInfo{ + Domain: cert.Subject.CommonName, + Issuer: cert.Issuer.CommonName, + ExpiresAt: cert.NotAfter, + Status: status, + Provider: "letsencrypt", // Assuming auto-generated are mostly LE/ZeroSSL + }) } return nil }) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("walk certificates: %w", err) + if err != nil { + // Log error but return what we have? + fmt.Printf("Error walking cert dir: %v\n", 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) +// 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("failed to decode PEM block") + return nil, fmt.Errorf("invalid certificate PEM") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { - return nil, fmt.Errorf("parse certificate: %w", err) + return nil, fmt.Errorf("failed to 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" + // 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(), } - // Domain is usually the CommonName or the first SAN - domain := cert.Subject.CommonName - if domain == "" && len(cert.DNSNames) > 0 { - domain = cert.DNSNames[0] + // Handle SANs if present + if len(cert.DNSNames) > 0 { + sslCert.Domains = strings.Join(cert.DNSNames, ",") } - return &CertificateInfo{ - Domain: domain, - Issuer: cert.Issuer.CommonName, - ExpiresAt: cert.NotAfter, - Status: status, - }, nil + if err := s.db.Create(sslCert).Error; err != nil { + return nil, err + } + + return sslCert, nil +} + +// DeleteCertificate removes a custom certificate. +func (s *CertificateService) DeleteCertificate(id uint) error { + return s.db.Delete(&models.SSLCertificate{}, "id = ?", id).Error } diff --git a/backend/internal/services/certificate_service_test.go b/backend/internal/services/certificate_service_test.go index 7d18bc11..f31a4b6c 100644 --- a/backend/internal/services/certificate_service_test.go +++ b/backend/internal/services/certificate_service_test.go @@ -13,6 +13,11 @@ import ( "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 { @@ -50,7 +55,16 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) { } defer os.RemoveAll(tmpDir) - cs := NewCertificateService(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" @@ -108,3 +122,56 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) { } 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) +} diff --git a/backend/internal/services/proxyhost_service.go b/backend/internal/services/proxyhost_service.go index 04bc1069..76443728 100644 --- a/backend/internal/services/proxyhost_service.go +++ b/backend/internal/services/proxyhost_service.go @@ -77,7 +77,7 @@ func (s *ProxyHostService) GetByID(id uint) (*models.ProxyHost, error) { // 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").Where("uuid = ?", uuid).First(&host).Error; err != nil { + if err := s.db.Preload("Locations").Preload("Certificate").Where("uuid = ?", uuid).First(&host).Error; err != nil { return nil, err } return &host, nil @@ -86,7 +86,7 @@ func (s *ProxyHostService) GetByUUID(uuid string) (*models.ProxyHost, error) { // List returns all proxy hosts. func (s *ProxyHostService) List() ([]models.ProxyHost, error) { var hosts []models.ProxyHost - if err := s.db.Preload("Locations").Order("updated_at desc").Find(&hosts).Error; err != nil { + if err := s.db.Preload("Locations").Preload("Certificate").Order("updated_at desc").Find(&hosts).Error; err != nil { return nil, err } return hosts, nil diff --git a/frontend/src/api/certificates.ts b/frontend/src/api/certificates.ts index cd51ddd9..ec58d29a 100644 --- a/frontend/src/api/certificates.ts +++ b/frontend/src/api/certificates.ts @@ -1,13 +1,34 @@ import client from './client' export interface Certificate { + id?: number + name?: string domain: string issuer: string expires_at: string status: 'valid' | 'expiring' | 'expired' + provider: string } export async function getCertificates(): Promise { const response = await client.get('/certificates') return response.data } + +export async function uploadCertificate(name: string, certFile: File, keyFile: File): Promise { + const formData = new FormData() + formData.append('name', name) + formData.append('certificate_file', certFile) + formData.append('key_file', keyFile) + + const response = await client.post('/certificates', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return response.data +} + +export async function deleteCertificate(id: number): Promise { + await client.delete(`/certificates/${id}`) +} diff --git a/frontend/src/api/proxyHosts.ts b/frontend/src/api/proxyHosts.ts index 40e07d9c..353cb01e 100644 --- a/frontend/src/api/proxyHosts.ts +++ b/frontend/src/api/proxyHosts.ts @@ -23,6 +23,7 @@ export interface ProxyHost { locations: Location[]; advanced_config?: string; enabled: boolean; + certificate_id?: number | null; created_at: string; updated_at: string; } diff --git a/frontend/src/components/CertificateList.tsx b/frontend/src/components/CertificateList.tsx index 529d5c25..2936037c 100644 --- a/frontend/src/components/CertificateList.tsx +++ b/frontend/src/components/CertificateList.tsx @@ -1,8 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Trash2 } from 'lucide-react' import { useCertificates } from '../hooks/useCertificates' +import { deleteCertificate } from '../api/certificates' import { LoadingSpinner } from './LoadingStates' +import { toast } from '../utils/toast' export default function CertificateList() { const { certificates, isLoading, error } = useCertificates() + const queryClient = useQueryClient() + + const deleteMutation = useMutation({ + mutationFn: deleteCertificate, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['certificates'] }) + toast.success('Certificate deleted') + }, + onError: (error: any) => { + toast.error(`Failed to delete certificate: ${error.message}`) + }, + }) if (isLoading) return if (error) return
Failed to load certificates
@@ -13,22 +29,25 @@ export default function CertificateList() { + + {certificates.length === 0 ? ( - ) : ( certificates.map((cert) => ( - + + + )) )} diff --git a/frontend/src/components/ProxyHostForm.tsx b/frontend/src/components/ProxyHostForm.tsx index fee8ba10..0b34b413 100644 --- a/frontend/src/components/ProxyHostForm.tsx +++ b/frontend/src/components/ProxyHostForm.tsx @@ -4,6 +4,7 @@ import type { ProxyHost } from '../api/proxyHosts' import { testProxyHostConnection } from '../api/proxyHosts' import { useRemoteServers } from '../hooks/useRemoteServers' import { useDomains } from '../hooks/useDomains' +import { useCertificates } from '../hooks/useCertificates' import { useDocker } from '../hooks/useDocker' import { parse } from 'tldts' @@ -27,10 +28,12 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor websocket_support: host?.websocket_support ?? true, advanced_config: host?.advanced_config || '', enabled: host?.enabled ?? true, + certificate_id: host?.certificate_id, }) const { servers: remoteServers } = useRemoteServers() const { domains, createDomain } = useDomains() + const { certificates } = useCertificates() const [connectionSource, setConnectionSource] = useState<'local' | 'custom' | string>('custom') const [selectedDomain, setSelectedDomain] = useState('') const [selectedContainerId, setSelectedContainerId] = useState('') @@ -355,6 +358,25 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor + {/* SSL Certificate Selection */} +
+ + +
+ {/* SSL & Security Options */}
Name Domain Issuer Expires StatusActions
+ No certificates found.
{cert.name || '-'} {cert.domain} {cert.issuer} @@ -37,6 +56,21 @@ export default function CertificateList() { + {cert.provider === 'custom' && cert.id && ( + + )} +