diff --git a/backend/internal/api/ssl.go b/backend/internal/api/ssl.go new file mode 100644 index 0000000..47d6169 --- /dev/null +++ b/backend/internal/api/ssl.go @@ -0,0 +1,580 @@ +package api + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "clicd/internal/config" +) + +type sslSettingsRequest struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + Target string `json:"target"` + Email string `json:"email"` + CertPEM string `json:"cert_pem"` + KeyPEM string `json:"key_pem"` + ApplyNow bool `json:"apply_now"` +} + +type sslCertificateInfo struct { + Subject string `json:"subject"` + Issuer string `json:"issuer"` + DNSNames []string `json:"dns_names"` + IPNames []string `json:"ip_names"` + NotBefore string `json:"not_before"` + NotAfter string `json:"not_after"` + Valid bool `json:"valid"` +} + +type sslSavedCertificateStatus struct { + config.SSLConfig + Certificate *sslCertificateInfo `json:"certificate,omitempty"` +} + +type sslSettingsResponse struct { + config.SSLConfig + DetectedHost string `json:"detected_host"` + Certificate *sslCertificateInfo `json:"certificate,omitempty"` + ModeCertificates map[string]sslSavedCertificateStatus `json:"mode_certificates"` + NeedsRestart bool `json:"needs_restart,omitempty"` +} + +func HandleSSLSettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: sslSettingsStatus(r, false)}) + case http.MethodPut: + updateSSLSettings(w, r) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + +func updateSSLSettings(w http.ResponseWriter, r *http.Request) { + var req sslSettingsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + + mode := config.NormalizeSSLMode(req.Mode) + if !req.Enabled || mode == config.SSLModeDisabled { + saveCurrentSSLSlot() + config.AppConfig.SSL = config.SSLConfig{Enabled: false, Mode: config.SSLModeDisabled} + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"}) + return + } + restartIfRequested(req.ApplyNow) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL disabled", Data: sslSettingsStatus(r, true)}) + return + } + + target := strings.TrimSpace(req.Target) + if target == "" { + target = detectedRequestHost(r) + } + if target == "" { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "SSL target is required"}) + return + } + + next, err := resolveSSLModeCertificate(mode, target, strings.TrimSpace(req.Email), req.CertPEM, req.KeyPEM) + if err != nil { + _ = config.SaveConfig() + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error(), Data: sslSettingsStatus(r, false)}) + return + } + + if err := validateCertificatePair(next.CertPath, next.KeyPath); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: err.Error()}) + return + } + next.LastIssuedAt = time.Now().Format(time.RFC3339) + next.Enabled = true + config.AppConfig.SSL = next + saveSSLSlot(next) + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Save SSL settings failed"}) + return + } + + restartIfRequested(req.ApplyNow) + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Message: "SSL settings saved", Data: sslSettingsStatus(r, true)}) +} + +func sslSettingsStatus(r *http.Request, needsRestart bool) sslSettingsResponse { + cfg := config.AppConfig.SSL + cfg.KeyPath = maskExistingPath(cfg.KeyPath) + resp := sslSettingsResponse{ + SSLConfig: cfg, + DetectedHost: detectedRequestHost(r), + ModeCertificates: sslModeCertificatesStatus(), + NeedsRestart: needsRestart, + } + if cert, err := readCertificateInfo(config.AppConfig.SSL.CertPath); err == nil { + resp.Certificate = cert + } + return resp +} + +func resolveSSLModeCertificate(mode, target, email, certPEM, keyPEM string) (config.SSLConfig, error) { + if config.AppConfig.SSLCertificates == nil { + config.AppConfig.SSLCertificates = map[string]config.SSLConfig{} + } + next := config.AppConfig.SSLCertificates[mode] + next.Mode = mode + next.Target = target + if email != "" || next.Email == "" { + next.Email = email + } + + var err error + switch mode { + case config.SSLModeUploaded: + if strings.TrimSpace(certPEM) != "" || strings.TrimSpace(keyPEM) != "" { + next.CertPath, next.KeyPath, err = saveUploadedCertificate(certPEM, keyPEM) + } else if next.CertPath == "" || next.KeyPath == "" { + err = fmt.Errorf("certificate and private key are required") + } else if !certificateUsable(next.CertPath, next.KeyPath, target) { + err = fmt.Errorf("uploaded certificate is expired, invalid, or does not match the target") + } + case config.SSLModeSelfSigned: + if !certificateUsable(next.CertPath, next.KeyPath, target) { + next.CertPath, next.KeyPath, err = generateSelfSignedCertificate(target) + } + case config.SSLModeLetsEncrypt: + if !certificateUsable(next.CertPath, next.KeyPath, target) { + next.CertPath, next.KeyPath, err = requestLetsEncryptCertificate(target, next.Email) + } + default: + err = fmt.Errorf("unsupported SSL mode") + } + if err != nil { + next.LastError = err.Error() + saveSSLSlot(next) + return next, err + } + next.LastError = "" + return next, nil +} + +func sslModeCertificatesStatus() map[string]sslSavedCertificateStatus { + result := map[string]sslSavedCertificateStatus{} + for _, mode := range []string{config.SSLModeLetsEncrypt, config.SSLModeSelfSigned, config.SSLModeUploaded} { + cfg := config.AppConfig.SSLCertificates[mode] + cfg.KeyPath = maskExistingPath(cfg.KeyPath) + status := sslSavedCertificateStatus{SSLConfig: cfg} + if cert, err := readCertificateInfo(config.AppConfig.SSLCertificates[mode].CertPath); err == nil { + status.Certificate = cert + } + result[mode] = status + } + return result +} + +func saveCurrentSSLSlot() { + if config.AppConfig.SSL.Mode == config.SSLModeDisabled || config.AppConfig.SSL.CertPath == "" { + return + } + saveSSLSlot(config.AppConfig.SSL) +} + +func saveSSLSlot(ssl config.SSLConfig) { + mode := config.NormalizeSSLMode(ssl.Mode) + if mode == config.SSLModeDisabled { + return + } + if config.AppConfig.SSLCertificates == nil { + config.AppConfig.SSLCertificates = map[string]config.SSLConfig{} + } + ssl.Mode = mode + ssl.Enabled = false + config.AppConfig.SSLCertificates[mode] = ssl +} + +func saveUploadedCertificate(certPEM, keyPEM string) (string, string, error) { + certPEM = strings.TrimSpace(certPEM) + keyPEM = strings.TrimSpace(keyPEM) + if certPEM == "" || keyPEM == "" { + return "", "", fmt.Errorf("certificate and private key are required") + } + if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil { + return "", "", fmt.Errorf("certificate/private key mismatch: %v", err) + } + dir := sslStorageDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return "", "", err + } + certPath := filepath.Join(dir, "uploaded-fullchain.pem") + keyPath := filepath.Join(dir, "uploaded-privkey.pem") + if err := os.WriteFile(certPath, []byte(certPEM+"\n"), 0600); err != nil { + return "", "", err + } + if err := os.WriteFile(keyPath, []byte(keyPEM+"\n"), 0600); err != nil { + return "", "", err + } + return certPath, keyPath, nil +} + +func generateSelfSignedCertificate(target string) (string, string, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", "", fmt.Errorf("self-signed certificate target is required") + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return "", "", err + } + now := time.Now() + tpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: target, + }, + NotBefore: now.Add(-time.Hour), + NotAfter: now.AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + if ip := net.ParseIP(target); ip != nil { + tpl.IPAddresses = []net.IP{ip} + } else { + tpl.DNSNames = []string{target} + } + der, err := x509.CreateCertificate(rand.Reader, &tpl, &tpl, &key.PublicKey, key) + if err != nil { + return "", "", err + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return "", "", err + } + dir := sslStorageDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return "", "", err + } + certPath := filepath.Join(dir, "self-signed-fullchain.pem") + keyPath := filepath.Join(dir, "self-signed-privkey.pem") + certOut := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyOut := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(certPath, certOut, 0600); err != nil { + return "", "", err + } + if err := os.WriteFile(keyPath, keyOut, 0600); err != nil { + return "", "", err + } + return certPath, keyPath, nil +} + +func requestLetsEncryptCertificate(target, email string) (string, string, error) { + if _, err := exec.LookPath("certbot"); err != nil { + return "", "", fmt.Errorf("certbot is not installed on this server") + } + target = strings.TrimSpace(target) + if target == "" { + return "", "", fmt.Errorf("Let's Encrypt target is required") + } + args := []string{"certonly", "--non-interactive", "--agree-tos", "--standalone"} + if email != "" { + args = append(args, "--email", email) + } else { + args = append(args, "--register-unsafely-without-email") + } + if net.ParseIP(target) != nil { + if err := ensureCertbotSupportsIPCertificates(); err != nil { + return "", "", err + } + args = append(args, "--preferred-profile", "shortlived", "--ip-address", target) + } else { + args = append(args, "-d", target) + } + cmd := exec.Command("certbot", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return "", "", fmt.Errorf("Let's Encrypt request failed: %s", strings.TrimSpace(string(output))) + } + certPath := filepath.Join("/etc/letsencrypt/live", target, "fullchain.pem") + keyPath := filepath.Join("/etc/letsencrypt/live", target, "privkey.pem") + if _, err := os.Stat(certPath); err != nil { + return "", "", fmt.Errorf("Let's Encrypt certificate file not found after issuance: %s", certPath) + } + if _, err := os.Stat(keyPath); err != nil { + return "", "", fmt.Errorf("Let's Encrypt private key file not found after issuance: %s", keyPath) + } + return certPath, keyPath, nil +} + +func ensureCertbotSupportsIPCertificates() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "certbot", "--help", "all") + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("certbot check timed out") + } + if err != nil { + return fmt.Errorf("certbot capability check failed: %s", strings.TrimSpace(string(output))) + } + help := string(output) + if !strings.Contains(help, "--ip-address") || !strings.Contains(help, "--preferred-profile") { + return fmt.Errorf("current certbot does not support IP certificates; install Certbot 5.4+ from snap or another current source") + } + return nil +} + +func validateCertificatePair(certPath, keyPath string) error { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return err + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return err + } + if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil { + return fmt.Errorf("certificate/private key mismatch: %v", err) + } + return nil +} + +func certificateUsable(certPath, keyPath, target string) bool { + if certPath == "" || keyPath == "" { + return false + } + if err := validateCertificatePair(certPath, keyPath); err != nil { + return false + } + cert, err := readLeafCertificate(certPath) + if err != nil { + return false + } + now := time.Now() + if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) { + return false + } + return certificateMatchesTarget(cert, target) +} + +func certificateNeedsRenewal(certPath, keyPath, target string, renewBefore time.Duration) bool { + if certPath == "" || keyPath == "" { + return true + } + if err := validateCertificatePair(certPath, keyPath); err != nil { + return true + } + cert, err := readLeafCertificate(certPath) + if err != nil { + return true + } + now := time.Now() + if now.Before(cert.NotBefore) || !now.Before(cert.NotAfter) { + return true + } + if !certificateMatchesTarget(cert, target) { + return true + } + return cert.NotAfter.Sub(now) <= renewBefore +} + +func certificateMatchesTarget(cert *x509.Certificate, target string) bool { + target = strings.TrimSpace(strings.Trim(target, "[]")) + if target == "" { + return true + } + if ip := net.ParseIP(target); ip != nil { + for _, certIP := range cert.IPAddresses { + if certIP.Equal(ip) { + return true + } + } + return false + } + if err := cert.VerifyHostname(target); err != nil { + return false + } + return true +} + +func readCertificateInfo(certPath string) (*sslCertificateInfo, error) { + cert, err := readLeafCertificate(certPath) + if err != nil { + return nil, err + } + ipNames := make([]string, 0, len(cert.IPAddresses)) + for _, ip := range cert.IPAddresses { + ipNames = append(ipNames, ip.String()) + } + return &sslCertificateInfo{ + Subject: cert.Subject.String(), + Issuer: cert.Issuer.String(), + DNSNames: cert.DNSNames, + IPNames: ipNames, + NotBefore: cert.NotBefore.Format(time.RFC3339), + NotAfter: cert.NotAfter.Format(time.RFC3339), + Valid: time.Now().After(cert.NotBefore) && time.Now().Before(cert.NotAfter), + }, nil +} + +func readLeafCertificate(certPath string) (*x509.Certificate, error) { + if certPath == "" { + return nil, errors.New("certificate path is empty") + } + data, err := os.ReadFile(certPath) + if err != nil { + return nil, err + } + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("certificate PEM is invalid") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + return cert, nil +} + +func detectedRequestHost(r *http.Request) string { + host := strings.TrimSpace(r.Host) + if host == "" { + return firstPublicInterfaceIP() + } + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + host = strings.Trim(host, "[]") + if host == "localhost" || net.ParseIP(host).IsLoopback() { + if ip := firstPublicInterfaceIP(); ip != "" { + return ip + } + } + return host +} + +func firstPublicInterfaceIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + ip := ipNet.IP.To4() + if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() { + continue + } + return ip.String() + } + return "" +} + +func sslStorageDir() string { + dataDir := config.AppConfig.DataDir + if dataDir == "" { + dataDir = "/root/.clicd" + } + return filepath.Join(dataDir, "ssl") +} + +func maskExistingPath(path string) string { + if path == "" { + return "" + } + return path +} + +func restartIfRequested(applyNow bool) { + if !applyNow { + return + } + go func() { + time.Sleep(500 * time.Millisecond) + _ = exec.Command("systemctl", "restart", "clicd").Start() + }() +} + +func StartSSLRenewalMonitor() { + go func() { + time.Sleep(30 * time.Second) + renewSavedSSLCertificates() + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for range ticker.C { + renewSavedSSLCertificates() + } + }() +} + +func renewSavedSSLCertificates() { + if config.AppConfig == nil || len(config.AppConfig.SSLCertificates) == 0 { + return + } + changed := false + for mode, cert := range config.AppConfig.SSLCertificates { + mode = config.NormalizeSSLMode(mode) + if cert.Target == "" || mode == config.SSLModeDisabled || mode == config.SSLModeUploaded { + continue + } + + var certPath, keyPath string + var err error + switch mode { + case config.SSLModeLetsEncrypt: + if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 48*time.Hour) { + continue + } + certPath, keyPath, err = requestLetsEncryptCertificate(cert.Target, cert.Email) + case config.SSLModeSelfSigned: + if !certificateNeedsRenewal(cert.CertPath, cert.KeyPath, cert.Target, 30*24*time.Hour) { + continue + } + certPath, keyPath, err = generateSelfSignedCertificate(cert.Target) + } + if err != nil { + cert.LastError = err.Error() + config.AppConfig.SSLCertificates[mode] = cert + changed = true + continue + } + cert.CertPath = certPath + cert.KeyPath = keyPath + cert.LastIssuedAt = time.Now().Format(time.RFC3339) + cert.LastError = "" + config.AppConfig.SSLCertificates[mode] = cert + if config.AppConfig.SSL.Enabled && config.AppConfig.SSL.Mode == mode { + active := cert + active.Enabled = true + config.AppConfig.SSL = active + } + changed = true + } + if changed { + _ = config.SaveConfig() + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 61597d5..1560de8 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -205,26 +205,46 @@ type Snapshot struct { SizeBytes int64 `json:"size_bytes"` } +const ( + SSLModeDisabled = "disabled" + SSLModeLetsEncrypt = "letsencrypt" + SSLModeSelfSigned = "self_signed" + SSLModeUploaded = "uploaded" +) + +type SSLConfig struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + Target string `json:"target"` + Email string `json:"email,omitempty"` + CertPath string `json:"cert_path,omitempty"` + KeyPath string `json:"key_path,omitempty"` + LastIssuedAt string `json:"last_issued_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + // ClicdConfig is the main configuration structure type ClicdConfig struct { - AdminUser string `json:"admin_user"` - AdminPassHash string `json:"admin_pass_hash"` - JWTSecret string `json:"jwt_secret"` - Port int `json:"port"` - DataDir string `json:"data_dir"` - Containers []Container `json:"containers"` - NextContainerID int `json:"next_container_id"` - NextVNCPort int `json:"next_vnc_port"` - NextSSHPort int `json:"next_ssh_port"` - SetupComplete bool `json:"setup_complete"` - SubUsers []SubUser `json:"sub_users"` - ApiKeys []ApiKeyConfig `json:"api_keys"` - AuditLogs []AuditLog `json:"audit_logs"` - Tasks []SavedTask `json:"tasks"` - LoginLogs []SavedLoginLog `json:"login_logs"` - EnabledImages []string `json:"enabled_images"` - Snapshots []Snapshot `json:"snapshots"` - SecurityAutoShutdown bool `json:"security_auto_shutdown"` + AdminUser string `json:"admin_user"` + AdminPassHash string `json:"admin_pass_hash"` + JWTSecret string `json:"jwt_secret"` + Port int `json:"port"` + DataDir string `json:"data_dir"` + Containers []Container `json:"containers"` + NextContainerID int `json:"next_container_id"` + NextVNCPort int `json:"next_vnc_port"` + NextSSHPort int `json:"next_ssh_port"` + SetupComplete bool `json:"setup_complete"` + SubUsers []SubUser `json:"sub_users"` + ApiKeys []ApiKeyConfig `json:"api_keys"` + AuditLogs []AuditLog `json:"audit_logs"` + Tasks []SavedTask `json:"tasks"` + LoginLogs []SavedLoginLog `json:"login_logs"` + EnabledImages []string `json:"enabled_images"` + Snapshots []Snapshot `json:"snapshots"` + SecurityAutoShutdown bool `json:"security_auto_shutdown"` + SSL SSLConfig `json:"ssl"` + SSLCertificates map[string]SSLConfig `json:"ssl_certificates"` } var configPath string @@ -302,8 +322,11 @@ func InitConfig() (*ClicdConfig, error) { } if ok { AppConfig = cfg - normalizeConfigDefaults(dataDir) + changed := normalizeConfigDefaults(dataDir) if migrateLoadedConfig() { + changed = true + } + if changed { if err := SaveConfig(); err != nil { return nil, err } @@ -318,9 +341,8 @@ func InitConfig() (*ClicdConfig, error) { if ok { AppConfig = legacy normalizeConfigDefaults(dataDir) - if migrateLoadedConfig() { - // Save below persists normalized legacy data into SQLite. - } + migrateLoadedConfig() + // Always save legacy JSON data into SQLite. if err := SaveConfig(); err != nil { return nil, err } @@ -371,51 +393,127 @@ func InitConfig() (*ClicdConfig, error) { return AppConfig, nil } -func normalizeConfigDefaults(dataDir string) { +func normalizeConfigDefaults(dataDir string) bool { + changed := false if AppConfig.Port == 0 { AppConfig.Port = 8999 + changed = true } if AppConfig.NextVNCPort == 0 { AppConfig.NextVNCPort = 5900 + changed = true } if AppConfig.NextSSHPort == 0 { AppConfig.NextSSHPort = 22000 + changed = true } if AppConfig.NextContainerID == 0 { AppConfig.NextContainerID = 1 + changed = true } if AppConfig.DataDir == "" { AppConfig.DataDir = dataDir + changed = true } if AppConfig.Containers == nil { AppConfig.Containers = make([]Container, 0) + changed = true } if AppConfig.Snapshots == nil { AppConfig.Snapshots = make([]Snapshot, 0) + changed = true } if AppConfig.SubUsers == nil { AppConfig.SubUsers = make([]SubUser, 0) + changed = true } if AppConfig.ApiKeys == nil { AppConfig.ApiKeys = make([]ApiKeyConfig, 0) + changed = true } else { for i := range AppConfig.ApiKeys { if len(AppConfig.ApiKeys[i].Scopes) == 0 { AppConfig.ApiKeys[i].Scopes = []string{"*"} + changed = true } } } if AppConfig.AuditLogs == nil { AppConfig.AuditLogs = make([]AuditLog, 0) + changed = true } if AppConfig.Tasks == nil { AppConfig.Tasks = make([]SavedTask, 0) + changed = true } if AppConfig.LoginLogs == nil { AppConfig.LoginLogs = make([]SavedLoginLog, 0) + changed = true } if AppConfig.EnabledImages == nil { AppConfig.EnabledImages = make([]string, 0) + changed = true + } + if normalizeSSLDefaults() { + changed = true + } + return changed +} + +func normalizeSSLDefaults() bool { + changed := false + previousMode := AppConfig.SSL.Mode + AppConfig.SSL.Mode = NormalizeSSLMode(AppConfig.SSL.Mode) + if AppConfig.SSL.Mode != previousMode { + changed = true + } + if AppConfig.SSL.Mode == SSLModeDisabled { + if AppConfig.SSL.Enabled { + changed = true + } + AppConfig.SSL.Enabled = false + } + if AppConfig.SSLCertificates == nil { + AppConfig.SSLCertificates = map[string]SSLConfig{} + changed = true + } + for mode, cert := range AppConfig.SSLCertificates { + cert.Mode = NormalizeSSLMode(cert.Mode) + if cert.Mode == SSLModeDisabled { + delete(AppConfig.SSLCertificates, mode) + changed = true + continue + } + if AppConfig.SSLCertificates[cert.Mode] != cert { + changed = true + } + AppConfig.SSLCertificates[cert.Mode] = cert + if mode != cert.Mode { + delete(AppConfig.SSLCertificates, mode) + changed = true + } + } + if AppConfig.SSL.Mode != SSLModeDisabled && AppConfig.SSL.CertPath != "" && AppConfig.SSL.KeyPath != "" { + cert := AppConfig.SSL + cert.Enabled = false + if AppConfig.SSLCertificates[cert.Mode] != cert { + changed = true + } + AppConfig.SSLCertificates[cert.Mode] = cert + } + return changed +} + +func NormalizeSSLMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case SSLModeLetsEncrypt: + return SSLModeLetsEncrypt + case SSLModeSelfSigned: + return SSLModeSelfSigned + case SSLModeUploaded: + return SSLModeUploaded + default: + return SSLModeDisabled } } diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index 0721f18..ae01ae8 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -374,6 +374,12 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) { SetupComplete: atob(meta["setup_complete"]), SecurityAutoShutdown: atob(meta["security_auto_shutdown"]), } + if raw := strings.TrimSpace(meta["ssl"]); raw != "" { + _ = json.Unmarshal([]byte(raw), &cfg.SSL) + } + if raw := strings.TrimSpace(meta["ssl_certificates"]); raw != "" { + _ = json.Unmarshal([]byte(raw), &cfg.SSLCertificates) + } if cfg.Containers, err = loadContainers(); err != nil { return nil, false, err @@ -466,6 +472,8 @@ func saveConfigToDB() error { } func saveMeta(tx *sql.Tx) error { + sslJSON, _ := json.Marshal(AppConfig.SSL) + sslCertificatesJSON, _ := json.Marshal(AppConfig.SSLCertificates) values := map[string]string{ "admin_user": AppConfig.AdminUser, "admin_pass_hash": AppConfig.AdminPassHash, @@ -477,6 +485,8 @@ func saveMeta(tx *sql.Tx) error { "next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort), "setup_complete": btoa(AppConfig.SetupComplete), "security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown), + "ssl": string(sslJSON), + "ssl_certificates": string(sslCertificatesJSON), "schema_version": "1", "updated_at": time.Now().Format("2006-01-02 15:04:05"), } diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 1f6afa0..2e0bfba 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -1,11 +1,13 @@ package server import ( + "crypto/tls" "fmt" "log" "net" "net/http" "net/url" + "os" "strings" "clicd/internal/api" @@ -75,6 +77,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange))) mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange))) mux.HandleFunc("/api/login-logs", corsMiddleware(api.AdminMiddleware(api.HandleLoginLogs))) + mux.HandleFunc("/api/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings))) mux.HandleFunc("/api/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers)))) mux.HandleFunc("/api/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias)))) mux.HandleFunc("/api/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer)))) @@ -141,6 +144,7 @@ func setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/v1/sub-users/", corsMiddleware(api.AuthMiddleware(api.HandleSubUserAction))) mux.HandleFunc("/api/v1/audit-logs", corsMiddleware(api.AuthMiddleware(api.HandleAuditLogs))) mux.HandleFunc("/api/v1/login-logs", corsMiddleware(api.AuthMiddleware(api.HandleLoginLogs))) + mux.HandleFunc("/api/v1/ssl", corsMiddleware(api.AdminMiddleware(api.HandleSSLSettings))) mux.HandleFunc("/api/v1/security/alerts", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityAlerts)))) mux.HandleFunc("/api/v1/security/check", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:check", api.HandleSecurityCheck)))) mux.HandleFunc("/api/v1/security/logs", corsMiddleware(api.AuthMiddleware(api.ScopeMiddleware("security:read", api.HandleSecurityLogs)))) @@ -208,5 +212,33 @@ func Run() error { Handler: mux, } + if sslEnabled() { + server.TLSConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + cert, err := tls.LoadX509KeyPair(config.AppConfig.SSL.CertPath, config.AppConfig.SSL.KeyPath) + return &cert, err + }, + } + log.Printf("CLICD Web Server SSL enabled on https://0.0.0.0:%d", config.AppConfig.Port) + return server.ListenAndServeTLS("", "") + } + return server.ListenAndServe() } + +func sslEnabled() bool { + ssl := config.AppConfig.SSL + if !ssl.Enabled || ssl.CertPath == "" || ssl.KeyPath == "" { + return false + } + if _, err := os.Stat(ssl.CertPath); err != nil { + log.Printf("SSL certificate is not readable, falling back to HTTP: %v", err) + return false + } + if _, err := os.Stat(ssl.KeyPath); err != nil { + log.Printf("SSL private key is not readable, falling back to HTTP: %v", err) + return false + } + return true +} diff --git a/backend/main.go b/backend/main.go index dcef40f..5bebfb3 100644 --- a/backend/main.go +++ b/backend/main.go @@ -50,6 +50,7 @@ func main() { // Start security scanner api.InitScanner() + api.StartSSLRenewalMonitor() // Ensure iptables FORWARD rules allow managed bridge traffic. lxc.EnsureForwardRules("lxcbr0") diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 5f295ff..6c059b5 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,10 +1,13 @@ -import { useCallback, useEffect, useState } from 'react' -import { Clock, Globe, LogIn, Monitor, UserCog } from 'lucide-react' +import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react' +import { Clock, Globe, Lock, LogIn, Monitor, RefreshCw, ShieldCheck, Upload, UserCog } from 'lucide-react' import { changePassword, changeUsername, getLoginLogs, + getSSLSettings, LoginLog, + SSLSettings, + updateSSLSettings, } from '../services/api' import { useDialog } from '../components/Dialog' import { useAuth } from '../contexts/AuthContext' @@ -21,6 +24,16 @@ export default function Settings() { const [newPwd, setNewPwd] = useState('') const [newUsername, setNewUsername] = useState('') + const [ssl, setSSL] = useState(null) + const [sslEnabled, setSSLEnabled] = useState(false) + const [sslMode, setSSLMode] = useState('disabled') + const [sslTarget, setSSLTarget] = useState('') + const [sslEmail, setSSLEmail] = useState('') + const [certPEM, setCertPEM] = useState('') + const [keyPEM, setKeyPEM] = useState('') + const [applyNow, setApplyNow] = useState(true) + const [savingSSL, setSavingSSL] = useState(false) + const fetchLogs = useCallback(async () => { try { const res = await getLoginLogs() @@ -32,11 +45,61 @@ export default function Settings() { } }, []) + const fetchSSL = useCallback(async () => { + try { + const res = await getSSLSettings() + const data = res.data.data + if (!data) return + setSSL(data) + setSSLEnabled(data.enabled) + setSSLMode(data.mode || 'disabled') + setSSLTarget(data.target || data.detected_host || '') + setSSLEmail(data.email || '') + } catch (err) { + console.error(err) + } + }, []) + useEffect(() => { fetchLogs() + fetchSSL() const timer = setInterval(fetchLogs, 15000) return () => clearInterval(timer) - }, [fetchLogs]) + }, [fetchLogs, fetchSSL]) + + const handleSSLModeChange = (mode: SSLSettings['mode']) => { + setSSLMode(mode) + const saved = ssl?.mode_certificates?.[mode] + setSSLTarget(saved?.target || ssl?.detected_host || sslTarget) + setSSLEmail(saved?.email || '') + } + + const handleSaveSSL = async () => { + setSavingSSL(true) + try { + const enabled = sslEnabled && sslMode !== 'disabled' + const res = await updateSSLSettings({ + enabled, + mode: enabled ? sslMode : 'disabled', + target: sslTarget, + email: sslEmail, + cert_pem: certPEM, + key_pem: keyPEM, + apply_now: applyNow, + }) + if (res.data.data) { + setSSL(res.data.data) + setCertPEM('') + setKeyPEM('') + } + dialog.alert('完成', applyNow ? 'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。' : 'SSL 设置已保存,重启 clicd 服务后生效。') + } catch (err: unknown) { + const e = err as { response?: { data?: { message?: string } } } + dialog.alert('失败', e.response?.data?.message || 'SSL 设置保存失败') + } finally { + setSavingSSL(false) + } + } const handleSaveAccount = async () => { if (!oldPwd) { @@ -92,93 +155,252 @@ export default function Settings() {

面板设置

-

账号管理与登录日志

+

账号、安全证书与登录日志

-
-

- 账号设置 -

-
-
- - +
+ + +
+

+ 账号设置 +

+
+
+ + +
+
+ + setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" /> +
+
+ + setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" /> +
+
+ + setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" /> +
+
-
- - setNewUsername(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 3 位" /> -
-
- - setNewPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="至少 6 位" /> -
-
- - setOldPwd(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="输入当前密码以确认修改" /> -
-
-
-

- 登录日志 + +

+ ) +} + +interface SSLCardProps { + ssl: SSLSettings | null + sslEnabled: boolean + sslMode: SSLSettings['mode'] + sslTarget: string + sslEmail: string + certPEM: string + keyPEM: string + applyNow: boolean + savingSSL: boolean + onRefresh: () => void + onEnabledChange: (enabled: boolean) => void + onModeChange: (mode: SSLSettings['mode']) => void + onTargetChange: (target: string) => void + onEmailChange: (email: string) => void + onCertChange: (cert: string) => void + onKeyChange: (key: string) => void + onApplyNowChange: (apply: boolean) => void + onSave: () => void +} + +function SSLCard(props: SSLCardProps) { + const selectedSSL = props.ssl?.mode_certificates?.[props.sslMode] + const modeOptions: Array<{ value: SSLSettings['mode']; label: string }> = [ + { value: 'letsencrypt', label: 'Let’s Encrypt' }, + { value: 'self_signed', label: '自签证书' }, + { value: 'uploaded', label: '上传证书' }, + ] + + return ( +
+
+

+ SSL 证书

- {logs.length === 0 ? ( -

暂无登录记录

- ) : ( - <> -
- - - - - - - - - - - - {logs.slice((logPage - 1) * pageSize, logPage * pageSize).map((log, index) => ( - - - - - - - - ))} - -
时间用户名IP设备结果
{log.time}{log.username}{log.ip}{formatUA(log.user_agent)} - - {log.success ? '成功' : '失败'} - -
-
- {logs.length > pageSize && ( -
- 共 {logs.length} 条,第 {logPage}/{totalPages} 页 -
- - - {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - let start = Math.max(1, logPage - 2) - if (start + 4 > totalPages) start = Math.max(1, totalPages - 4) - const page = start + i - if (page > totalPages) return null - return ( - - ) - })} - - -
-
- )} - - )} +
+
+ + +
+ {modeOptions.map((option) => ( + + ))} +
+ +
+
+ + props.onTargetChange(e.target.value)} + className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" + placeholder={props.ssl?.detected_host || '服务器公网 IP 或域名'} + /> +
+ {props.sslMode === 'letsencrypt' && ( +
+ + props.onEmailChange(e.target.value)} className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-black" placeholder="admin@example.com" /> +
+ )} +
+ + {props.sslMode === 'letsencrypt' && ( +
+ 纯 IP 证书需要服务器安装 Certbot 5.4+,且验证时 80 端口必须能被 Let’s Encrypt 访问。IP 证书是短有效期证书,certbot 需要保持自动续签。 +
+ )} + + {props.sslMode === 'self_signed' && ( +
+ 自签证书可以加密面板和 VNC,但浏览器会提示证书不受信任;证书快到期时系统会自动重新签发。 +
+ )} + + {props.sslMode === 'uploaded' && ( +
+
+ +