From b9a3549cbcbe6620ea10ce79d88927b862f51fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fan=20Can=20Bak=C4=B1r?= Date: Tue, 24 Feb 2026 14:44:14 +0300 Subject: [PATCH 1/4] fix: auto-renew TLS certificates without restart --- cmd/interactsh-server/main.go | 33 +-- pkg/server/acme/acme_certbot.go | 37 +-- pkg/server/acme/cert_reloader.go | 115 ++++++++++ pkg/server/acme/cert_reloader_test.go | 314 ++++++++++++++++++++++++++ 4 files changed, 453 insertions(+), 46 deletions(-) create mode 100644 pkg/server/acme/cert_reloader.go create mode 100644 pkg/server/acme/cert_reloader_test.go diff --git a/cmd/interactsh-server/main.go b/cmd/interactsh-server/main.go index 09bf0b51..83203be7 100644 --- a/cmd/interactsh-server/main.go +++ b/cmd/interactsh-server/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "crypto/rand" "crypto/tls" "encoding/hex" @@ -17,6 +18,7 @@ import ( _ "net/http/pprof" + "github.com/caddyserver/certmagic" "github.com/projectdiscovery/goflags" "github.com/projectdiscovery/gologger" "github.com/projectdiscovery/gologger/levels" @@ -293,34 +295,33 @@ func main() { ) switch { case cliOptions.CertificatePath != "" && cliOptions.PrivateKeyPath != "": - var domain string - if len(cliOptions.Domains) > 0 { - domain = cliOptions.Domains[0] - } - acmeManagerTLS, acmeErr := acme.BuildTlsConfigWithCertAndKeyPaths(cliOptions.CertificatePath, cliOptions.PrivateKeyPath, domain) - if acmeErr != nil { - gologger.Error().Msgf("https will be disabled: %s", acmeErr) + reloader, reloaderErr := acme.NewCertReloader(cliOptions.CertificatePath, cliOptions.PrivateKeyPath) + if reloaderErr != nil { + gologger.Error().Msgf("https will be disabled: %s", reloaderErr) } else { - tlsConfig = acmeManagerTLS + go reloader.Start(context.Background()) + tlsConfig = &tls.Config{ + GetCertificate: reloader.GetCertificate, + NextProtos: []string{"h2", "http/1.1"}, + } } case !cliOptions.SkipAcme && len(cliOptions.Domains) > 0: - var certs []tls.Certificate + var cmCfg *certmagic.Config for idx, domain := range cliOptions.Domains { trimmedDomain := strings.TrimSuffix(domain, ".") hostmaster := serverOptions.Hostmasters[idx] var acmeErr error - domainCerts, certFiles, acmeErr = acme.HandleWildcardCertificates(fmt.Sprintf("*.%s", trimmedDomain), hostmaster, acmeStore, cliOptions.Debug, cliOptions.Resolvers) + domainCerts, certFiles, cmCfg, acmeErr = acme.HandleWildcardCertificates(fmt.Sprintf("*.%s", trimmedDomain), hostmaster, acmeStore, cliOptions.Debug, cliOptions.Resolvers) if acmeErr != nil { gologger.Error().Msgf("An error occurred while applying for a certificate, error: %v", acmeErr) gologger.Error().Msgf("Could not generate certs for auto TLS, https will be disabled") - } else { - certs = append(certs, domainCerts...) } } - var tlsErr error - tlsConfig, tlsErr = acme.BuildTlsConfigWithCerts("", certs...) - if tlsErr != nil { - gologger.Error().Msgf("An error occurred while preparing tls configuration, error: %v", tlsErr) + if cmCfg != nil { + tlsConfig = &tls.Config{ + GetCertificate: cmCfg.GetCertificate, + NextProtos: []string{"h2", "http/1.1"}, + } } } diff --git a/pkg/server/acme/acme_certbot.go b/pkg/server/acme/acme_certbot.go index ac45a649..fc98f935 100644 --- a/pkg/server/acme/acme_certbot.go +++ b/pkg/server/acme/acme_certbot.go @@ -36,10 +36,11 @@ type CertificateFiles struct { // HandleWildcardCertificates handles ACME wildcard cert generation with DNS // challenge using certmagic library from caddyserver. -func HandleWildcardCertificates(domain, email string, store *Provider, debug bool, customResolvers []string) ([]tls.Certificate, []CertificateFiles, error) { +// The returned *certmagic.Config can be used with GetCertificate for dynamic TLS cert serving. +func HandleWildcardCertificates(domain, email string, store *Provider, debug bool, customResolvers []string) ([]tls.Certificate, []CertificateFiles, *certmagic.Config, error) { logger, err := zap.NewProduction() if err != nil { - return nil, nil, err + return nil, nil, nil, err } certmagic.DefaultACME.Agreed = true certmagic.DefaultACME.Email = email @@ -78,7 +79,7 @@ func HandleWildcardCertificates(domain, email string, store *Provider, debug boo // this obtains certificates or renews them if necessary if syncErr := cfg.ObtainCertSync(context.Background(), domain); syncErr != nil { - return nil, nil, syncErr + return nil, nil, nil, syncErr } domains := []string{domain, originalDomain} @@ -101,7 +102,7 @@ func HandleWildcardCertificates(domain, email string, store *Provider, debug boo retry_cert: certPath, privKeyPath, err := ExtractCaddyPaths(cfg, &certmagic.DefaultACME, domain) if err != nil { - return nil, nil, err + return nil, nil, nil, err } certFiles = append(certFiles, CertificateFiles{CertPath: certPath, PrivKeyPath: privKeyPath}) cert, err := tls.LoadX509KeyPair(certPath, privKeyPath) @@ -122,12 +123,12 @@ func HandleWildcardCertificates(domain, email string, store *Provider, debug boo } } if err != nil { - return nil, nil, err + return nil, nil, nil, err } certs = append(certs, cert) } - return certs, certFiles, nil + return certs, certFiles, cfg, nil } // certAlreadyExists returns true if a cert already exists @@ -158,27 +159,3 @@ func ExtractCaddyPaths(cfg *certmagic.Config, issuer certmagic.Issuer, domain st return } -// BuildTlsConfigWithCertAndKeyPaths Build TlsConfig with certificates -func BuildTlsConfigWithCertAndKeyPaths(certPath, privKeyPath, domain string) (*tls.Config, error) { - cert, err := tls.LoadX509KeyPair(certPath, privKeyPath) - if err != nil { - return nil, errors.New("Could not load certs and private key") - } - return BuildTlsConfigWithCerts(domain, cert) -} - -// BuildTlsConfigWithCerts Build TlsConfig with existing certificates -func BuildTlsConfigWithCerts(domain string, certs ...tls.Certificate) (*tls.Config, error) { - if certs == nil { - return nil, errors.New("no certificates provided") - } - tlsConfig := &tls.Config{ - InsecureSkipVerify: true, - Certificates: certs, - } - if domain != "" { - tlsConfig.ServerName = domain - } - tlsConfig.NextProtos = []string{"h2", "http/1.1"} - return tlsConfig, nil -} diff --git a/pkg/server/acme/cert_reloader.go b/pkg/server/acme/cert_reloader.go new file mode 100644 index 00000000..dc3d31a4 --- /dev/null +++ b/pkg/server/acme/cert_reloader.go @@ -0,0 +1,115 @@ +package acme + +import ( + "context" + "crypto/tls" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/projectdiscovery/gologger" +) + +const defaultCertCheckInterval = 1 * time.Hour + +func certCheckInterval() time.Duration { + if v := os.Getenv("CERT_CHECK_INTERVAL"); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + return d + } + gologger.Warning().Msgf("Invalid CERT_CHECK_INTERVAL %q, using default %s", v, defaultCertCheckInterval) + } + return defaultCertCheckInterval +} + +// CertReloader watches a certificate/key file pair and reloads when files change on disk. +type CertReloader struct { + certPath string + keyPath string + cert atomic.Pointer[tls.Certificate] + modTime time.Time + mu sync.Mutex // protects reload; reads are lock-free via atomic +} + +// NewCertReloader loads the initial certificate and returns a reloader. +func NewCertReloader(certPath, keyPath string) (*CertReloader, error) { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, err + } + + modTime, _ := latestModTime(certPath, keyPath) + + r := &CertReloader{ + certPath: certPath, + keyPath: keyPath, + modTime: modTime, + } + r.cert.Store(&cert) + return r, nil +} + +// GetCertificate returns the current certificate. Safe for concurrent use. +func (r *CertReloader) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { + return r.cert.Load(), nil +} + +// Start polls for certificate file changes and reloads when detected. +// Blocks until ctx is cancelled. +// The check interval is configurable via the CERT_CHECK_INTERVAL env variable (e.g. "30m", "2h"). +func (r *CertReloader) Start(ctx context.Context) { + interval := certCheckInterval() + gologger.Info().Msgf("Certificate reload check interval: %s", interval) + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + r.tryReload() + } + } +} + +func (r *CertReloader) tryReload() { + r.mu.Lock() + defer r.mu.Unlock() + + mt, err := latestModTime(r.certPath, r.keyPath) + if err != nil { + gologger.Warning().Msgf("Could not stat certificate files: %s", err) + return + } + if !mt.After(r.modTime) { + return + } + + cert, err := tls.LoadX509KeyPair(r.certPath, r.keyPath) + if err != nil { + gologger.Warning().Msgf("Could not reload certificate: %s", err) + return + } + + r.cert.Store(&cert) + r.modTime = mt + gologger.Info().Msgf("Reloaded TLS certificate from %s", r.certPath) +} + +// latestModTime returns the most recent modification time of the two files. +func latestModTime(certPath, keyPath string) (time.Time, error) { + certInfo, err := os.Stat(certPath) + if err != nil { + return time.Time{}, err + } + keyInfo, err := os.Stat(keyPath) + if err != nil { + return time.Time{}, err + } + if keyInfo.ModTime().After(certInfo.ModTime()) { + return keyInfo.ModTime(), nil + } + return certInfo.ModTime(), nil +} diff --git a/pkg/server/acme/cert_reloader_test.go b/pkg/server/acme/cert_reloader_test.go new file mode 100644 index 00000000..8dc2a4b4 --- /dev/null +++ b/pkg/server/acme/cert_reloader_test.go @@ -0,0 +1,314 @@ +package acme + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "os" + "path/filepath" + "testing" + "time" +) + +func generateSelfSignedCert(t *testing.T, cn string) (certPEM, keyPEM []byte) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return +} + +func writeCertFiles(t *testing.T, dir, certName, keyName string, certPEM, keyPEM []byte) (string, string) { + t.Helper() + certPath := filepath.Join(dir, certName) + keyPath := filepath.Join(dir, keyName) + if err := os.WriteFile(certPath, certPEM, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + t.Fatal(err) + } + return certPath, keyPath +} + +func TestNewCertReloader(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, "initial.example.com") + certPath, keyPath := writeCertFiles(t, dir, "cert.pem", "key.pem", certPEM, keyPEM) + + reloader, err := NewCertReloader(certPath, keyPath) + if err != nil { + t.Fatalf("NewCertReloader: %v", err) + } + + cert, err := reloader.GetCertificate(nil) + if err != nil { + t.Fatalf("GetCertificate: %v", err) + } + if cert == nil { + t.Fatal("expected non-nil certificate") + } + + parsed, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatal(err) + } + if parsed.Subject.CommonName != "initial.example.com" { + t.Fatalf("expected CN=initial.example.com, got %s", parsed.Subject.CommonName) + } +} + +func TestNewCertReloaderInvalidFiles(t *testing.T) { + _, err := NewCertReloader("/nonexistent/cert.pem", "/nonexistent/key.pem") + if err == nil { + t.Fatal("expected error for nonexistent files") + } +} + +func TestCertReloaderReloadsOnChange(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, "original.example.com") + certPath, keyPath := writeCertFiles(t, dir, "cert.pem", "key.pem", certPEM, keyPEM) + + reloader, err := NewCertReloader(certPath, keyPath) + if err != nil { + t.Fatalf("NewCertReloader: %v", err) + } + + // Advance file mod time so the reloader detects a change + newCertPEM, newKeyPEM := generateSelfSignedCert(t, "renewed.example.com") + if err := os.WriteFile(certPath, newCertPEM, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, newKeyPEM, 0600); err != nil { + t.Fatal(err) + } + future := time.Now().Add(time.Hour) + _ = os.Chtimes(certPath, future, future) + _ = os.Chtimes(keyPath, future, future) + + reloader.tryReload() + + cert, err := reloader.GetCertificate(nil) + if err != nil { + t.Fatalf("GetCertificate: %v", err) + } + parsed, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatal(err) + } + if parsed.Subject.CommonName != "renewed.example.com" { + t.Fatalf("expected CN=renewed.example.com after reload, got %s", parsed.Subject.CommonName) + } +} + +func TestCertReloaderNoReloadWhenUnchanged(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, "stable.example.com") + certPath, keyPath := writeCertFiles(t, dir, "cert.pem", "key.pem", certPEM, keyPEM) + + reloader, err := NewCertReloader(certPath, keyPath) + if err != nil { + t.Fatalf("NewCertReloader: %v", err) + } + + certBefore, _ := reloader.GetCertificate(nil) + reloader.tryReload() + certAfter, _ := reloader.GetCertificate(nil) + + // Same pointer means no reload happened + if certBefore != certAfter { + t.Fatal("expected same certificate pointer when files are unchanged") + } +} + +func TestCertReloaderStartRespectsContext(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, "ctx.example.com") + certPath, keyPath := writeCertFiles(t, dir, "cert.pem", "key.pem", certPEM, keyPEM) + + reloader, err := NewCertReloader(certPath, keyPath) + if err != nil { + t.Fatalf("NewCertReloader: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + reloader.Start(ctx) + close(done) + }() + + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Start did not return after context cancellation") + } +} + +func TestGetCertificateIsConcurrentSafe(t *testing.T) { + dir := t.TempDir() + certPEM, keyPEM := generateSelfSignedCert(t, "concurrent.example.com") + certPath, keyPath := writeCertFiles(t, dir, "cert.pem", "key.pem", certPEM, keyPEM) + + reloader, err := NewCertReloader(certPath, keyPath) + if err != nil { + t.Fatalf("NewCertReloader: %v", err) + } + + // Concurrently read and reload + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < 100; i++ { + reloader.tryReload() + } + }() + + for i := 0; i < 100; i++ { + cert, err := reloader.GetCertificate(&tls.ClientHelloInfo{}) + if err != nil { + t.Fatalf("concurrent GetCertificate: %v", err) + } + if cert == nil { + t.Fatal("concurrent GetCertificate returned nil") + } + } + <-done +} + +func TestCertReloaderTLSHandshake(t *testing.T) { + dir := t.TempDir() + + // Start with original cert + certPEM, keyPEM := generateSelfSignedCert(t, "original.example.com") + certPath, keyPath := writeCertFiles(t, dir, "cert.pem", "key.pem", certPEM, keyPEM) + + reloader, err := NewCertReloader(certPath, keyPath) + if err != nil { + t.Fatalf("NewCertReloader: %v", err) + } + + // Start a real TLS listener using the reloader + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("net.Listen: %v", err) + } + defer ln.Close() + + tlsLn := tls.NewListener(ln, &tls.Config{ + GetCertificate: reloader.GetCertificate, + }) + defer tlsLn.Close() + + go func() { + for { + conn, err := tlsLn.Accept() + if err != nil { + return + } + // Keep connection alive long enough for client to read peer certs + go func(c net.Conn) { + buf := make([]byte, 1) + _, _ = c.Read(buf) + c.Close() + }(conn) + } + }() + + addr := ln.Addr().String() + + // Verify original cert is served + cn := tlsHandshakeCN(t, addr) + if cn != "original.example.com" { + t.Fatalf("expected CN=original.example.com, got %s", cn) + } + + // Swap cert files on disk and trigger reload + newCertPEM, newKeyPEM := generateSelfSignedCert(t, "renewed.example.com") + if err := os.WriteFile(certPath, newCertPEM, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, newKeyPEM, 0600); err != nil { + t.Fatal(err) + } + future := time.Now().Add(time.Hour) + _ = os.Chtimes(certPath, future, future) + _ = os.Chtimes(keyPath, future, future) + + reloader.tryReload() + + // Verify renewed cert is now served + cn = tlsHandshakeCN(t, addr) + if cn != "renewed.example.com" { + t.Fatalf("expected CN=renewed.example.com after reload, got %s", cn) + } +} + +// tlsHandshakeCN performs a real TLS handshake and returns the peer certificate's CN. +func tlsHandshakeCN(t *testing.T, addr string) string { + t.Helper() + conn, err := tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Fatalf("tls.Dial: %v", err) + } + defer conn.Close() + + certs := conn.ConnectionState().PeerCertificates + if len(certs) == 0 { + t.Fatal("no peer certificates") + } + return certs[0].Subject.CommonName +} + +func TestCertCheckIntervalEnv(t *testing.T) { + // Default + interval := certCheckInterval() + if interval != defaultCertCheckInterval { + t.Fatalf("expected default %s, got %s", defaultCertCheckInterval, interval) + } + + // Valid override + t.Setenv("CERT_CHECK_INTERVAL", "30m") + interval = certCheckInterval() + if interval != 30*time.Minute { + t.Fatalf("expected 30m, got %s", interval) + } + + // Invalid falls back to default + t.Setenv("CERT_CHECK_INTERVAL", "notaduration") + interval = certCheckInterval() + if interval != defaultCertCheckInterval { + t.Fatalf("expected default %s for invalid env, got %s", defaultCertCheckInterval, interval) + } +} From f440273269c2b0c7a8346c6493f699df72557bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fan=20Can=20Bak=C4=B1r?= Date: Tue, 24 Feb 2026 15:22:27 +0300 Subject: [PATCH 2/4] fix: accumulate certs across domains instead of overwriting Split HandleWildcardCertificates into NewCertmagicConfig + per-domain cert obtaining so a single certmagic.Config is shared across all domains. The ACME loop now appends to domainCerts/certFiles instead of replacing them on each iteration, fixing multi-domain certificate handling. --- cmd/interactsh-server/main.go | 28 +++++++++++++++------------- pkg/server/acme/acme_certbot.go | 26 +++++++++++++++----------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/cmd/interactsh-server/main.go b/cmd/interactsh-server/main.go index 83203be7..b0f0fbe8 100644 --- a/cmd/interactsh-server/main.go +++ b/cmd/interactsh-server/main.go @@ -18,7 +18,6 @@ import ( _ "net/http/pprof" - "github.com/caddyserver/certmagic" "github.com/projectdiscovery/goflags" "github.com/projectdiscovery/gologger" "github.com/projectdiscovery/gologger/levels" @@ -306,20 +305,23 @@ func main() { } } case !cliOptions.SkipAcme && len(cliOptions.Domains) > 0: - var cmCfg *certmagic.Config - for idx, domain := range cliOptions.Domains { - trimmedDomain := strings.TrimSuffix(domain, ".") - hostmaster := serverOptions.Hostmasters[idx] - var acmeErr error - domainCerts, certFiles, cmCfg, acmeErr = acme.HandleWildcardCertificates(fmt.Sprintf("*.%s", trimmedDomain), hostmaster, acmeStore, cliOptions.Debug, cliOptions.Resolvers) - if acmeErr != nil { - gologger.Error().Msgf("An error occurred while applying for a certificate, error: %v", acmeErr) - gologger.Error().Msgf("Could not generate certs for auto TLS, https will be disabled") + cfg, cfgErr := acme.NewCertmagicConfig(serverOptions.Hostmasters[0], acmeStore, cliOptions.Debug, cliOptions.Resolvers) + if cfgErr != nil { + gologger.Error().Msgf("Could not configure ACME: %s", cfgErr) + } else { + for _, domain := range cliOptions.Domains { + trimmedDomain := strings.TrimSuffix(domain, ".") + certs, files, acmeErr := acme.HandleWildcardCertificates(cfg, fmt.Sprintf("*.%s", trimmedDomain)) + if acmeErr != nil { + gologger.Error().Msgf("An error occurred while applying for a certificate for %s: %v", domain, acmeErr) + gologger.Error().Msgf("Could not generate certs for auto TLS, https will be disabled") + } else { + domainCerts = append(domainCerts, certs...) + certFiles = append(certFiles, files...) + } } - } - if cmCfg != nil { tlsConfig = &tls.Config{ - GetCertificate: cmCfg.GetCertificate, + GetCertificate: cfg.GetCertificate, NextProtos: []string{"h2", "http/1.1"}, } } diff --git a/pkg/server/acme/acme_certbot.go b/pkg/server/acme/acme_certbot.go index fc98f935..1634792c 100644 --- a/pkg/server/acme/acme_certbot.go +++ b/pkg/server/acme/acme_certbot.go @@ -34,13 +34,12 @@ type CertificateFiles struct { PrivKeyPath string } -// HandleWildcardCertificates handles ACME wildcard cert generation with DNS -// challenge using certmagic library from caddyserver. -// The returned *certmagic.Config can be used with GetCertificate for dynamic TLS cert serving. -func HandleWildcardCertificates(domain, email string, store *Provider, debug bool, customResolvers []string) ([]tls.Certificate, []CertificateFiles, *certmagic.Config, error) { +// NewCertmagicConfig creates and configures a *certmagic.Config for ACME DNS-01 challenge. +// The returned config can be reused across multiple HandleWildcardCertificates calls. +func NewCertmagicConfig(email string, store *Provider, debug bool, customResolvers []string) (*certmagic.Config, error) { logger, err := zap.NewProduction() if err != nil { - return nil, nil, nil, err + return nil, err } certmagic.DefaultACME.Agreed = true certmagic.DefaultACME.Email = email @@ -55,8 +54,6 @@ func HandleWildcardCertificates(domain, email string, store *Provider, debug boo }(), }, } - originalDomain := strings.TrimPrefix(domain, "*.") - certmagic.DefaultACME.CA = certmagic.LetsEncryptProductionCA if debug { certmagic.DefaultACME.Logger = logger @@ -68,6 +65,13 @@ func HandleWildcardCertificates(domain, email string, store *Provider, debug boo if debug { cfg.Logger = logger } + return cfg, nil +} + +// HandleWildcardCertificates handles ACME wildcard cert generation with DNS +// challenge using certmagic library from caddyserver. +func HandleWildcardCertificates(cfg *certmagic.Config, domain string) ([]tls.Certificate, []CertificateFiles, error) { + originalDomain := strings.TrimPrefix(domain, "*.") var creating bool if !certAlreadyExists(cfg, &certmagic.DefaultACME, domain) { @@ -79,7 +83,7 @@ func HandleWildcardCertificates(domain, email string, store *Provider, debug boo // this obtains certificates or renews them if necessary if syncErr := cfg.ObtainCertSync(context.Background(), domain); syncErr != nil { - return nil, nil, nil, syncErr + return nil, nil, syncErr } domains := []string{domain, originalDomain} @@ -102,7 +106,7 @@ func HandleWildcardCertificates(domain, email string, store *Provider, debug boo retry_cert: certPath, privKeyPath, err := ExtractCaddyPaths(cfg, &certmagic.DefaultACME, domain) if err != nil { - return nil, nil, nil, err + return nil, nil, err } certFiles = append(certFiles, CertificateFiles{CertPath: certPath, PrivKeyPath: privKeyPath}) cert, err := tls.LoadX509KeyPair(certPath, privKeyPath) @@ -123,12 +127,12 @@ func HandleWildcardCertificates(domain, email string, store *Provider, debug boo } } if err != nil { - return nil, nil, nil, err + return nil, nil, err } certs = append(certs, cert) } - return certs, certFiles, cfg, nil + return certs, certFiles, nil } // certAlreadyExists returns true if a cert already exists From 5ad1e0955a9064595c34f6003aaab3c2c29d1168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Do=C4=9Fan=20Can=20Bak=C4=B1r?= Date: Tue, 10 Mar 2026 00:52:45 +0300 Subject: [PATCH 3/4] remove unnecessary mutex from CertReloader --- pkg/server/acme/cert_reloader.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pkg/server/acme/cert_reloader.go b/pkg/server/acme/cert_reloader.go index dc3d31a4..ea224465 100644 --- a/pkg/server/acme/cert_reloader.go +++ b/pkg/server/acme/cert_reloader.go @@ -4,7 +4,6 @@ import ( "context" "crypto/tls" "os" - "sync" "sync/atomic" "time" @@ -28,8 +27,7 @@ type CertReloader struct { certPath string keyPath string cert atomic.Pointer[tls.Certificate] - modTime time.Time - mu sync.Mutex // protects reload; reads are lock-free via atomic + modTime time.Time } // NewCertReloader loads the initial certificate and returns a reloader. @@ -75,9 +73,6 @@ func (r *CertReloader) Start(ctx context.Context) { } func (r *CertReloader) tryReload() { - r.mu.Lock() - defer r.mu.Unlock() - mt, err := latestModTime(r.certPath, r.keyPath) if err != nil { gologger.Warning().Msgf("Could not stat certificate files: %s", err) From f94dfd91ef595e8f23a8250d1a2d281990b19fe7 Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Wed, 25 Mar 2026 17:27:54 +0100 Subject: [PATCH 4/4] minor changes --- cmd/interactsh-server/main.go | 10 ++++++---- pkg/server/acme/cert_reloader.go | 14 +++++++------- pkg/server/acme/cert_reloader_test.go | 8 +++++++- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/cmd/interactsh-server/main.go b/cmd/interactsh-server/main.go index b0f0fbe8..bf14c11a 100644 --- a/cmd/interactsh-server/main.go +++ b/cmd/interactsh-server/main.go @@ -304,7 +304,7 @@ func main() { NextProtos: []string{"h2", "http/1.1"}, } } - case !cliOptions.SkipAcme && len(cliOptions.Domains) > 0: + case !cliOptions.SkipAcme && len(cliOptions.Domains) > 0 && len(serverOptions.Hostmasters) > 0: cfg, cfgErr := acme.NewCertmagicConfig(serverOptions.Hostmasters[0], acmeStore, cliOptions.Debug, cliOptions.Resolvers) if cfgErr != nil { gologger.Error().Msgf("Could not configure ACME: %s", cfgErr) @@ -320,9 +320,11 @@ func main() { certFiles = append(certFiles, files...) } } - tlsConfig = &tls.Config{ - GetCertificate: cfg.GetCertificate, - NextProtos: []string{"h2", "http/1.1"}, + if len(domainCerts) > 0 { + tlsConfig = &tls.Config{ + GetCertificate: cfg.GetCertificate, + NextProtos: []string{"h2", "http/1.1"}, + } } } } diff --git a/pkg/server/acme/cert_reloader.go b/pkg/server/acme/cert_reloader.go index ea224465..c3c8c67a 100644 --- a/pkg/server/acme/cert_reloader.go +++ b/pkg/server/acme/cert_reloader.go @@ -24,10 +24,10 @@ func certCheckInterval() time.Duration { // CertReloader watches a certificate/key file pair and reloads when files change on disk. type CertReloader struct { - certPath string - keyPath string - cert atomic.Pointer[tls.Certificate] - modTime time.Time + certPath string + keyPath string + cert atomic.Pointer[tls.Certificate] + modTimeNs atomic.Int64 } // NewCertReloader loads the initial certificate and returns a reloader. @@ -42,9 +42,9 @@ func NewCertReloader(certPath, keyPath string) (*CertReloader, error) { r := &CertReloader{ certPath: certPath, keyPath: keyPath, - modTime: modTime, } r.cert.Store(&cert) + r.modTimeNs.Store(modTime.UnixNano()) return r, nil } @@ -78,7 +78,7 @@ func (r *CertReloader) tryReload() { gologger.Warning().Msgf("Could not stat certificate files: %s", err) return } - if !mt.After(r.modTime) { + if mt.UnixNano() <= r.modTimeNs.Load() { return } @@ -89,7 +89,7 @@ func (r *CertReloader) tryReload() { } r.cert.Store(&cert) - r.modTime = mt + r.modTimeNs.Store(mt.UnixNano()) gologger.Info().Msgf("Reloaded TLS certificate from %s", r.certPath) } diff --git a/pkg/server/acme/cert_reloader_test.go b/pkg/server/acme/cert_reloader_test.go index 8dc2a4b4..411272f9 100644 --- a/pkg/server/acme/cert_reloader_test.go +++ b/pkg/server/acme/cert_reloader_test.go @@ -186,11 +186,17 @@ func TestGetCertificateIsConcurrentSafe(t *testing.T) { t.Fatalf("NewCertReloader: %v", err) } - // Concurrently read and reload done := make(chan struct{}) go func() { defer close(done) for i := 0; i < 100; i++ { + // Swap files with advancing timestamps to exercise the atomic modTime path + newCert, newKey := generateSelfSignedCert(t, "concurrent.example.com") + _ = os.WriteFile(certPath, newCert, 0600) + _ = os.WriteFile(keyPath, newKey, 0600) + future := time.Now().Add(time.Duration(i+1) * time.Hour) + _ = os.Chtimes(certPath, future, future) + _ = os.Chtimes(keyPath, future, future) reloader.tryReload() } }()