diff --git a/deploy.sh b/deploy.sh index d9872af..9ea1475 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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 diff --git a/go.mod b/go.mod index 3feb2e4..71a0d05 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 24c9f07..b5b1fd6 100644 --- a/go.sum +++ b/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= diff --git a/tools/nextwks-tool/main.go b/tools/nextwks-tool/main.go index 3a24a0e..aace72a 100644 --- a/tools/nextwks-tool/main.go +++ b/tools/nextwks-tool/main.go @@ -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 [flags]", os.Args[0]) + log.Fatalf("Usage: %s [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) - 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) + // 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") + 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)) - } - } - - // 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) - } + log.Printf(" %s — expires %s", domain, expiry.Format(time.RFC3339)) } } } -func obtainCerts(domains []string, email, backupDir string) error { - m := &autocert.Manager{ - Prompt: autocert.AcceptTOS, - Email: email, - Cache: autocert.DirCache(backupDir), +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) } - // 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) + // Build lego args + args := []string{ + "--email", email, + "--http", + "--http.port", ":80", + "--path", legoDir, + "--accept-tos", } - defer ln.Close() - - // Serve HTTP-01 challenge handler - srv := &http.Server{ - Handler: m.HTTPHandler(nil), - Addr: ":80", + for _, d := range domains { + args = append(args, "--domains", d) } - go srv.Serve(ln) + args = append(args, "run") - // Give LE a moment to validate - time.Sleep(2 * time.Second) + 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 { - 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() + 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) } - - // 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 } @@ -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 +} diff --git a/tools/nextwks-tool/nextwks-tool b/tools/nextwks-tool/nextwks-tool new file mode 100755 index 0000000..10fac66 Binary files /dev/null and b/tools/nextwks-tool/nextwks-tool differ