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() } }