feat: replace autocert with lego CLI for reliable LE cert issuance

This commit is contained in:
Claus Lohmar 2026-07-07 15:05:41 +01:00
parent 02bab22a5d
commit 5f168d21f1
5 changed files with 154 additions and 144 deletions

View file

@ -55,6 +55,12 @@ if [ -d "$TOOL_SRC" ]; then
cd "$REPO_DIR" cd "$REPO_DIR"
fi fi
# Install lego if not present (for LE certificate management)
if ! command -v lego &>/dev/null && [ -f "$TOOL_BIN" ]; then
echo "[*] Installing lego ACME client..."
"$TOOL_BIN" install-lego 2>&1 || echo "[WARN] Lego install failed"
fi
# --- Greenfield path --- # --- Greenfield path ---
if [ "$GREENFIELD" = true ]; then if [ "$GREENFIELD" = true ]; then

4
go.mod
View file

@ -4,12 +4,10 @@ go 1.25.0
require ( require (
go.etcd.io/bbolt v1.5.0 go.etcd.io/bbolt v1.5.0
golang.org/x/crypto v0.53.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
) )

6
go.sum
View file

@ -6,16 +6,10 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -3,7 +3,6 @@ package main
import ( import (
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/pem" "encoding/pem"
@ -11,20 +10,18 @@ import (
"fmt" "fmt"
"log" "log"
"math/big" "math/big"
"net"
"net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
"golang.org/x/crypto/acme/autocert"
) )
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
log.Fatalf("Usage: %s <cert|db> [flags]", os.Args[0]) log.Fatalf("Usage: %s <cert|db|install-lego> [flags]", os.Args[0])
} }
switch os.Args[1] { switch os.Args[1] {
@ -32,11 +29,67 @@ func main() {
runCert(os.Args[2:]) runCert(os.Args[2:])
case "db": case "db":
runDB(os.Args[2:]) runDB(os.Args[2:])
case "install-lego":
runInstallLego()
default: default:
log.Fatalf("Unknown command: %s (use cert or db)", os.Args[1]) log.Fatalf("Unknown command: %s (use cert, db, or install-lego)", os.Args[1])
} }
} }
// --- Lego install ---
func runInstallLego() {
arch, err := exec.Command("uname", "-m").Output()
if err != nil {
log.Fatalf("Failed to detect architecture: %v", err)
}
archStr := strings.TrimSpace(string(arch))
archStr = strings.ReplaceAll(archStr, "x86_64", "amd64")
archStr = strings.ReplaceAll(archStr, "aarch64", "arm64")
// Get latest version
out, err := exec.Command("curl", "-fsSL",
"https://api.github.com/repos/go-acme/lego/releases/latest").Output()
if err != nil {
log.Fatalf("Failed to fetch latest lego version: %v", err)
}
version := extractLegoVersion(string(out))
if version == "" {
log.Fatalf("Could not parse latest lego version")
}
tarball := fmt.Sprintf("lego_%s_linux_%s.tar.gz", version, archStr)
url := fmt.Sprintf("https://github.com/go-acme/lego/releases/download/%s/%s", version, tarball)
log.Printf("Downloading lego %s for %s...", version, archStr)
if err := exec.Command("curl", "-fLO", url).Run(); err != nil {
log.Fatalf("Failed to download lego: %v", err)
}
log.Printf("Installing to /usr/local/bin/...")
if err := exec.Command("sudo", "tar", "-xzf", tarball, "-C", "/usr/local/bin", "lego").Run(); err != nil {
log.Fatalf("Failed to extract lego: %v", err)
}
os.Remove(tarball)
log.Printf("[OK] lego %s installed at /usr/local/bin/lego", version)
}
func extractLegoVersion(body string) string {
// Simple parser for the GitHub API response
marker := `"tag_name": "`
idx := strings.Index(body, marker)
if idx < 0 {
return ""
}
rest := body[idx+len(marker):]
end := strings.Index(rest, `"`)
if end < 0 {
return ""
}
return rest[:end]
}
// --- Cert command --- // --- Cert command ---
func runCert(args []string) { func runCert(args []string) {
@ -44,7 +97,7 @@ func runCert(args []string) {
domainsStr := fs.String("domains", "", "Comma-separated domain list") domainsStr := fs.String("domains", "", "Comma-separated domain list")
email := fs.String("email", "", "ACME email") email := fs.String("email", "", "ACME email")
backupDir := fs.String("backup-dir", "/opt/backup/certs", "Backup directory for certs") backupDir := fs.String("backup-dir", "/opt/backup/certs", "Backup directory for certs")
deployDir := fs.String("deploy-dir", "", "Optional deploy directory to copy certs to") legoDir := fs.String("lego-dir", "/opt/backup/lego", "Lego working directory")
fs.Parse(args) fs.Parse(args)
if *domainsStr == "" || *email == "" { if *domainsStr == "" || *email == "" {
@ -69,167 +122,85 @@ func runCert(args []string) {
needIssue = true needIssue = true
break break
} }
// Check if cert is from LE (not self-signed fallback)
if !isCertFromLE(certFile) { if !isCertFromLE(certFile) {
log.Printf("[INFO] Cert for %s is not from Let's Encrypt (self-signed fallback), reissuing", domain) log.Printf("[INFO] %s: cert is self-signed, reissuing", domain)
needIssue = true needIssue = true
break break
} }
// Check expiry (renew if within 30 days)
if isCertExpired(certFile, 30*24*time.Hour) { if isCertExpired(certFile, 30*24*time.Hour) {
log.Printf("[INFO] Cert for %s expires soon, reissuing", domain) log.Printf("[INFO] %s: cert expires soon, reissuing", domain)
needIssue = true needIssue = true
break break
} }
} }
if needIssue { if needIssue {
log.Printf("[INFO] Requesting LE certificates for %v...", domains) // Try lego first
if err := obtainCerts(domains, *email, *backupDir); err != nil { legoPath, legoErr := exec.LookPath("lego")
log.Printf("[WARN] LE cert issuance failed: %v", err) if legoErr == nil {
log.Printf("[INFO] Generating self-signed fallback certs") log.Printf("[INFO] Requesting LE certificates via lego...")
if err := generateSelfSigned(domains, *backupDir); err != nil { if err := obtainCertsLego(domains, *email, *backupDir, *legoDir, legoPath); err != nil {
log.Printf("[WARN] Self-signed fallback also failed: %v", err) log.Printf("[WARN] lego failed: %v", err)
log.Printf("[INFO] Generating self-signed fallback certs")
generateSelfSigned(domains, *backupDir)
} }
} else {
log.Printf("[WARN] lego not found (install with 'nextwks-tool install-lego')")
log.Printf("[INFO] Generating self-signed fallback certs")
generateSelfSigned(domains, *backupDir)
} }
} else { } else {
log.Printf("[OK] All certificates found in backup (dry-run)") log.Printf("[OK] All certificates found in backup (dry-run)")
for _, domain := range domains { for _, domain := range domains {
certFile := filepath.Join(*backupDir, domain, "fullchain.pem") certFile := filepath.Join(*backupDir, domain, "fullchain.pem")
expiry := getCertExpiry(certFile) expiry := getCertExpiry(certFile)
log.Printf(" %s — expires %s (dry-run, deploy.sh copies from backup)", domain, expiry.Format(time.RFC3339)) log.Printf(" %s — expires %s", domain, expiry.Format(time.RFC3339))
}
}
// Copy to deploy-dir if specified (legacy, not used by deploy.sh)
if *deployDir != "" {
if err := os.MkdirAll(*deployDir, 0755); err != nil {
log.Printf("[WARN] Failed to create deploy dir: %v", err)
return
}
for _, domain := range domains {
srcCert := filepath.Join(*backupDir, domain, "fullchain.pem")
srcKey := filepath.Join(*backupDir, domain, "privkey.pem")
dstCert := filepath.Join(*deployDir, domain+".crt")
dstKey := filepath.Join(*deployDir, domain+".key")
if fileExists(srcCert) && fileExists(srcKey) {
copyFile(srcCert, dstCert)
copyFile(srcKey, dstKey)
log.Printf("[OK] Deployed cert for %s", domain)
}
} }
} }
} }
func obtainCerts(domains []string, email, backupDir string) error { func obtainCertsLego(domains []string, email, backupDir, legoDir, legoPath string) error {
m := &autocert.Manager{ if err := os.MkdirAll(legoDir, 0755); err != nil {
Prompt: autocert.AcceptTOS, return fmt.Errorf("creating lego dir: %w", err)
Email: email,
Cache: autocert.DirCache(backupDir),
} }
// Try to obtain certs by starting a temporary HTTP server for challenge // Build lego args
ln, err := net.Listen("tcp", ":80") args := []string{
if err != nil { "--email", email,
// Port 80 busy — try to use the autocert client directly without HTTP server "--http",
log.Printf("[WARN] Port 80 not available (%v), trying direct ACME...", err) "--http.port", ":80",
return obtainCertsDirect(domains, email, backupDir) "--path", legoDir,
"--accept-tos",
} }
defer ln.Close() for _, d := range domains {
args = append(args, "--domains", d)
// Serve HTTP-01 challenge handler
srv := &http.Server{
Handler: m.HTTPHandler(nil),
Addr: ":80",
} }
go srv.Serve(ln) args = append(args, "run")
// Give LE a moment to validate cmd := exec.Command(legoPath, args...)
time.Sleep(2 * time.Second) cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Printf(" Running: %s %s", legoPath, strings.Join(args, " "))
if err := cmd.Run(); err != nil {
return fmt.Errorf("lego run failed: %w", err)
}
// Copy certificates from lego output to backup
certDir := filepath.Join(legoDir, "certificates")
for _, domain := range domains { for _, domain := range domains {
hello := &tls.ClientHelloInfo{ crtSrc := filepath.Join(certDir, domain+".crt")
ServerName: domain, keySrc := filepath.Join(certDir, domain+".key")
}
cert, err := m.GetCertificate(hello)
if err != nil {
log.Printf("[WARN] Failed to get cert for %s: %v", domain, err)
continue
}
// Store the cert to backup
domainDir := filepath.Join(backupDir, domain) domainDir := filepath.Join(backupDir, domain)
os.MkdirAll(domainDir, 0755) os.MkdirAll(domainDir, 0755)
for _, c := range cert.Certificate { if fileExists(crtSrc) && fileExists(keySrc) {
block := &pem.Block{Type: "CERTIFICATE", Bytes: c} copyFile(crtSrc, filepath.Join(domainDir, "fullchain.pem"))
f, err := os.OpenFile(filepath.Join(domainDir, "fullchain.pem"), copyFile(keySrc, filepath.Join(domainDir, "privkey.pem"))
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) log.Printf("[OK] Certificate obtained for %s", domain)
if err != nil { } else {
return fmt.Errorf("writing cert: %w", err) log.Printf("[WARN] Certificate files not found for %s in %s", domain, certDir)
}
pem.Encode(f, block)
f.Close()
} }
// Extract and save private key
if key, ok := cert.PrivateKey.(*rsa.PrivateKey); ok {
keyBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
os.WriteFile(filepath.Join(domainDir, "privkey.pem"),
pem.EncodeToMemory(keyBlock), 0600)
}
log.Printf("[OK] Certificate obtained for %s", domain)
}
return nil
}
func obtainCertsDirect(domains []string, email, backupDir string) error {
// Direct ACME without port 80 — will likely fail but try anyway
// This is a simplified fallback
return fmt.Errorf("port 80 required for HTTP-01 challenge")
}
func generateSelfSigned(domains []string, backupDir string) error {
for _, domain := range domains {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: domain},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
if len(domains) > 1 {
tmpl.DNSNames = domains
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return err
}
domainDir := filepath.Join(backupDir, domain)
os.MkdirAll(domainDir, 0755)
certFile := filepath.Join(domainDir, "fullchain.pem")
keyFile := filepath.Join(domainDir, "privkey.pem")
f, _ := os.Create(certFile)
pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
f.Close()
f, _ = os.Create(keyFile)
pem.Encode(f, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
f.Close()
log.Printf("[INFO] Self-signed cert generated for %s", domain)
} }
return nil return nil
} }
@ -338,3 +309,44 @@ func copyFile(src, dst string) error {
} }
return os.WriteFile(dst, data, 0644) return os.WriteFile(dst, data, 0644)
} }
func generateSelfSigned(domains []string, backupDir string) error {
for _, domain := range domains {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: domain},
NotBefore: time.Now(),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return err
}
domainDir := filepath.Join(backupDir, domain)
os.MkdirAll(domainDir, 0755)
certFile := filepath.Join(domainDir, "fullchain.pem")
keyFile := filepath.Join(domainDir, "privkey.pem")
f, _ := os.Create(certFile)
pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
f.Close()
f, _ = os.Create(keyFile)
pem.Encode(f, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
f.Close()
log.Printf("[INFO] Self-signed cert generated for %s", domain)
}
return nil
}

BIN
tools/nextwks-tool/nextwks-tool Executable file

Binary file not shown.