9e71af104f
- Dark Mode: ThemeProvider (next-themes), ThemeToggle in Navbar (Hell/Dunkel/System) - Zertifikat-Tab (superadmin only): aktuelles Zertifikat anzeigen, Upload (cert+key), Self-Signed ausstellen, Let's Encrypt/ACME - Backend: /api/admin/cert/* Endpunkte (info, upload, self-signed, acme), nginx reload - HTTPS bereits live auf Server (self-signed RSA-4096, Port 443) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
366 lines
11 KiB
Go
366 lines
11 KiB
Go
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/<domain>/ 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)
|
|
}
|