feat: replace autocert with lego CLI for reliable LE cert issuance
This commit is contained in:
parent
02bab22a5d
commit
5f168d21f1
5 changed files with 154 additions and 144 deletions
|
|
@ -55,6 +55,12 @@ if [ -d "$TOOL_SRC" ]; then
|
|||
cd "$REPO_DIR"
|
||||
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 ---
|
||||
if [ "$GREENFIELD" = true ]; then
|
||||
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -4,12 +4,10 @@ go 1.25.0
|
|||
|
||||
require (
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
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/text v0.38.0 // indirect
|
||||
)
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -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=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
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/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package main
|
|||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
|
|
@ -11,20 +10,18 @@ import (
|
|||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.etcd.io/bbolt"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
)
|
||||
|
||||
func main() {
|
||||
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] {
|
||||
|
|
@ -32,11 +29,67 @@ func main() {
|
|||
runCert(os.Args[2:])
|
||||
case "db":
|
||||
runDB(os.Args[2:])
|
||||
case "install-lego":
|
||||
runInstallLego()
|
||||
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 ---
|
||||
|
||||
func runCert(args []string) {
|
||||
|
|
@ -44,7 +97,7 @@ func runCert(args []string) {
|
|||
domainsStr := fs.String("domains", "", "Comma-separated domain list")
|
||||
email := fs.String("email", "", "ACME email")
|
||||
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)
|
||||
|
||||
if *domainsStr == "" || *email == "" {
|
||||
|
|
@ -69,167 +122,85 @@ func runCert(args []string) {
|
|||
needIssue = true
|
||||
break
|
||||
}
|
||||
// Check if cert is from LE (not self-signed fallback)
|
||||
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
|
||||
break
|
||||
}
|
||||
// Check expiry (renew if within 30 days)
|
||||
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
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if needIssue {
|
||||
log.Printf("[INFO] Requesting LE certificates for %v...", domains)
|
||||
if err := obtainCerts(domains, *email, *backupDir); err != nil {
|
||||
log.Printf("[WARN] LE cert issuance failed: %v", err)
|
||||
// Try lego first
|
||||
legoPath, legoErr := exec.LookPath("lego")
|
||||
if legoErr == nil {
|
||||
log.Printf("[INFO] Requesting LE certificates via lego...")
|
||||
if err := obtainCertsLego(domains, *email, *backupDir, *legoDir, legoPath); err != nil {
|
||||
log.Printf("[WARN] lego failed: %v", err)
|
||||
log.Printf("[INFO] Generating self-signed fallback certs")
|
||||
if err := generateSelfSigned(domains, *backupDir); err != nil {
|
||||
log.Printf("[WARN] Self-signed fallback also failed: %v", err)
|
||||
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 {
|
||||
log.Printf("[OK] All certificates found in backup (dry-run)")
|
||||
for _, domain := range domains {
|
||||
certFile := filepath.Join(*backupDir, domain, "fullchain.pem")
|
||||
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
|
||||
func obtainCertsLego(domains []string, email, backupDir, legoDir, legoPath string) error {
|
||||
if err := os.MkdirAll(legoDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating lego dir: %w", err)
|
||||
}
|
||||
|
||||
// Build lego args
|
||||
args := []string{
|
||||
"--email", email,
|
||||
"--http",
|
||||
"--http.port", ":80",
|
||||
"--path", legoDir,
|
||||
"--accept-tos",
|
||||
}
|
||||
for _, d := range domains {
|
||||
args = append(args, "--domains", d)
|
||||
}
|
||||
args = append(args, "run")
|
||||
|
||||
cmd := exec.Command(legoPath, args...)
|
||||
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 {
|
||||
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 {
|
||||
m := &autocert.Manager{
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Email: email,
|
||||
Cache: autocert.DirCache(backupDir),
|
||||
}
|
||||
|
||||
// Try to obtain certs by starting a temporary HTTP server for challenge
|
||||
ln, err := net.Listen("tcp", ":80")
|
||||
if err != nil {
|
||||
// Port 80 busy — try to use the autocert client directly without HTTP server
|
||||
log.Printf("[WARN] Port 80 not available (%v), trying direct ACME...", err)
|
||||
return obtainCertsDirect(domains, email, backupDir)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
// Serve HTTP-01 challenge handler
|
||||
srv := &http.Server{
|
||||
Handler: m.HTTPHandler(nil),
|
||||
Addr: ":80",
|
||||
}
|
||||
go srv.Serve(ln)
|
||||
|
||||
// Give LE a moment to validate
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
for _, domain := range domains {
|
||||
hello := &tls.ClientHelloInfo{
|
||||
ServerName: domain,
|
||||
}
|
||||
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
|
||||
crtSrc := filepath.Join(certDir, domain+".crt")
|
||||
keySrc := filepath.Join(certDir, domain+".key")
|
||||
domainDir := filepath.Join(backupDir, domain)
|
||||
os.MkdirAll(domainDir, 0755)
|
||||
|
||||
for _, c := range cert.Certificate {
|
||||
block := &pem.Block{Type: "CERTIFICATE", Bytes: c}
|
||||
f, err := os.OpenFile(filepath.Join(domainDir, "fullchain.pem"),
|
||||
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing cert: %w", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if fileExists(crtSrc) && fileExists(keySrc) {
|
||||
copyFile(crtSrc, filepath.Join(domainDir, "fullchain.pem"))
|
||||
copyFile(keySrc, filepath.Join(domainDir, "privkey.pem"))
|
||||
log.Printf("[OK] Certificate obtained for %s", domain)
|
||||
} else {
|
||||
log.Printf("[WARN] Certificate files not found for %s in %s", domain, certDir)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -338,3 +309,44 @@ func copyFile(src, dst string) error {
|
|||
}
|
||||
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
BIN
tools/nextwks-tool/nextwks-tool
Executable file
Binary file not shown.
Loading…
Reference in a new issue