package api import ( "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/json" "encoding/pem" "fmt" "io" "math/big" "net" "net/http" "os" "os/exec" "path/filepath" "strings" "time" ) const ( certDir = "/etc/ssl/archivmail" certPath = "/etc/ssl/archivmail/archivmail.crt" keyPath = "/etc/ssl/archivmail/archivmail.key" ) // certInfoResponse holds the parsed certificate metadata returned by GET /api/admin/cert/info. type certInfoResponse struct { Exists bool `json:"exists"` Subject string `json:"subject,omitempty"` Issuer string `json:"issuer,omitempty"` NotBefore time.Time `json:"not_before,omitempty"` NotAfter time.Time `json:"not_after,omitempty"` DNSNames []string `json:"dns_names,omitempty"` IPAddresses []string `json:"ip_addresses,omitempty"` FingerprintSHA256 string `json:"fingerprint_sha256,omitempty"` IsSelfSigned bool `json:"is_self_signed,omitempty"` DaysRemaining int `json:"days_remaining,omitempty"` } // handleCertInfo returns metadata about the currently installed TLS certificate. // GET /api/admin/cert/info func (s *Server) handleCertInfo(w http.ResponseWriter, r *http.Request) { info, err := readCertInfo() if err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read certificate: %v", err)) return } writeJSON(w, http.StatusOK, info) } // readCertInfo reads and parses the certificate file at certPath. // Returns {"exists": false} when the file is absent; an error only on read/parse failures. func readCertInfo() (*certInfoResponse, error) { raw, err := os.ReadFile(certPath) if os.IsNotExist(err) { return &certInfoResponse{Exists: false}, nil } if err != nil { return nil, fmt.Errorf("read cert file: %w", err) } block, _ := pem.Decode(raw) if block == nil { return nil, fmt.Errorf("no PEM block found in certificate file") } cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, fmt.Errorf("parse certificate: %w", err) } ips := make([]string, 0, len(cert.IPAddresses)) for _, ip := range cert.IPAddresses { ips = append(ips, ip.String()) } sum := sha256.Sum256(cert.Raw) parts := make([]string, 32) for i, b := range sum { parts[i] = fmt.Sprintf("%02X", b) } fingerprint := strings.Join(parts, ":") isSelfSigned := cert.Subject.String() == cert.Issuer.String() daysRemaining := int(time.Until(cert.NotAfter).Hours() / 24) dnsNames := cert.DNSNames if dnsNames == nil { dnsNames = []string{} } if ips == nil { ips = []string{} } return &certInfoResponse{ Exists: true, Subject: cert.Subject.String(), Issuer: cert.Issuer.String(), NotBefore: cert.NotBefore, NotAfter: cert.NotAfter, DNSNames: dnsNames, IPAddresses: ips, FingerprintSHA256: fingerprint, IsSelfSigned: isSelfSigned, DaysRemaining: daysRemaining, }, nil } // handleCertUpload validates and installs an uploaded PEM certificate + key pair. // POST /api/admin/cert/upload (multipart/form-data fields: cert, key) func (s *Server) handleCertUpload(w http.ResponseWriter, r *http.Request) { // Limit to 4 MB to prevent abuse; real PEM files are a few KB at most. if err := r.ParseMultipartForm(4 << 20); err != nil { writeError(w, http.StatusBadRequest, "failed to parse multipart form") return } certPEM, err := readMultipartField(r, "cert") if err != nil { writeError(w, http.StatusBadRequest, "missing or unreadable 'cert' field") return } keyPEM, err := readMultipartField(r, "key") if err != nil { writeError(w, http.StatusBadRequest, "missing or unreadable 'key' field") return } // Validate that cert and key form a valid pair before touching disk. if _, err := tls.X509KeyPair(certPEM, keyPEM); err != nil { writeError(w, http.StatusBadRequest, fmt.Sprintf("certificate and key do not match: %v", err)) return } if err := writeCertFiles(certPEM, keyPEM); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write certificate files: %v", err)) return } if out, err := reloadNginx(); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("nginx reload failed: %v — output: %s", err, out)) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "message": "Zertifikat gespeichert und nginx neu geladen", }) } // handleCertSelfSigned generates a new self-signed RSA-4096 certificate using Go's crypto/x509. // POST /api/admin/cert/self-signed func (s *Server) handleCertSelfSigned(w http.ResponseWriter, r *http.Request) { var req struct { CommonName string `json:"common_name"` DNSNames []string `json:"dns_names"` IPAddresses []string `json:"ip_addresses"` ValidityYears int `json:"validity_years"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.CommonName == "" { writeError(w, http.StatusBadRequest, "common_name is required") return } if req.ValidityYears <= 0 || req.ValidityYears > 30 { req.ValidityYears = 10 } // Parse and validate IP addresses. ips := make([]net.IP, 0, len(req.IPAddresses)) for _, addr := range req.IPAddresses { ip := net.ParseIP(addr) if ip == nil { writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid IP address: %s", addr)) return } ips = append(ips, ip) } // Generate RSA-4096 private key. privKey, err := rsa.GenerateKey(rand.Reader, 4096) if err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("key generation failed: %v", err)) return } serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { writeError(w, http.StatusInternalServerError, "serial number generation failed") return } notBefore := time.Now().UTC() notAfter := notBefore.AddDate(req.ValidityYears, 0, 0) dnsNames := req.DNSNames if dnsNames == nil { dnsNames = []string{} } tmpl := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: req.CommonName, }, DNSNames: dnsNames, IPAddresses: ips, NotBefore: notBefore, NotAfter: notAfter, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, } derBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &privKey.PublicKey, privKey) if err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("certificate creation failed: %v", err)) return } certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privKey)}) if err := writeCertFiles(certPEM, keyPEM); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to write certificate files: %v", err)) return } if out, err := reloadNginx(); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("nginx reload failed: %v — output: %s", err, out)) return } info, err := readCertInfo() if err != nil { writeError(w, http.StatusInternalServerError, "certificate written but could not read back info") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "cert": info, }) } // handleCertACME runs certbot to obtain a Let's Encrypt certificate via the NGINX plugin. // POST /api/admin/cert/acme func (s *Server) handleCertACME(w http.ResponseWriter, r *http.Request) { var req struct { Domain string `json:"domain"` Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if req.Domain == "" { writeError(w, http.StatusBadRequest, "domain is required") return } if req.Email == "" { writeError(w, http.StatusBadRequest, "email is required") return } // Verify certbot is available before attempting anything. certbotPath, err := exec.LookPath("certbot") if err != nil { writeError(w, http.StatusInternalServerError, "certbot is not installed on this server") return } cmd := exec.Command(certbotPath, "certonly", "--nginx", "-d", req.Domain, "--non-interactive", "--agree-tos", "-m", req.Email, ) out, err := cmd.CombinedOutput() if err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot failed: %v\n%s", err, string(out))) return } // Copy the issued certificates from /etc/letsencrypt/live// to certDir. lePath := filepath.Join("/etc/letsencrypt/live", req.Domain) certPEM, err := os.ReadFile(filepath.Join(lePath, "fullchain.pem")) if err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot succeeded but could not read issued cert: %v", err)) return } keyPEM, err := os.ReadFile(filepath.Join(lePath, "privkey.pem")) if err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("certbot succeeded but could not read issued key: %v", err)) return } if err := writeCertFiles(certPEM, keyPEM); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to copy certificates: %v", err)) return } if nginxOut, err := reloadNginx(); err != nil { writeError(w, http.StatusInternalServerError, fmt.Sprintf("nginx reload failed: %v — output: %s", err, nginxOut)) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "ok": true, "output": string(out), }) } // --- internal helpers --- // writeCertFiles creates certDir if necessary and writes cert (0644) + key (0600). func writeCertFiles(certPEM, keyPEM []byte) error { if err := os.MkdirAll(certDir, 0755); err != nil { return fmt.Errorf("create cert dir: %w", err) } if err := os.WriteFile(certPath, certPEM, 0644); err != nil { return fmt.Errorf("write cert: %w", err) } if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { return fmt.Errorf("write key: %w", err) } return nil } // reloadNginx tests nginx config validity, then reloads the service. // Returns combined stdout+stderr output and any error. func reloadNginx() (string, error) { testCmd := exec.Command("nginx", "-t") testOut, err := testCmd.CombinedOutput() if err != nil { return string(testOut), fmt.Errorf("nginx config test failed: %w", err) } reloadCmd := exec.Command("systemctl", "reload", "nginx") reloadOut, err := reloadCmd.CombinedOutput() if err != nil { return string(reloadOut), fmt.Errorf("systemctl reload nginx: %w", err) } return string(testOut) + string(reloadOut), nil } // readMultipartField reads all bytes from a named file field in a parsed multipart form. func readMultipartField(r *http.Request, field string) ([]byte, error) { f, _, err := r.FormFile(field) if err != nil { return nil, err } defer f.Close() return io.ReadAll(f) }