From ffa7ceaed783bb7f1a7e70a6806c9271704da3bd Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Tue, 23 Jun 2026 20:29:57 +0800 Subject: [PATCH] feat(tls): persist auto-generated CA certificate to $HOME/.gost/ Previously BuildDefaultTLSConfig generated a new random self-signed certificate in memory on every run, making it impossible for users to extract and trust the certificate in their system trust store. Now when no explicit cert files are configured, GOST: 1. First checks cert.pem / key.pem in CWD (backward compatible) 2. Then checks auto-ca-cert.pem / auto-ca-key.pem under $HOME/.gost/ 3. If none found, generates a new ECDSA P-256 CA certificate, persists it to $HOME/.gost/, and uses it The persisted cert has IsCA=true so tools recognize it as a root CA. Falls back to in-memory-only when disk writes fail. Closes go-gost/gost#876 --- config/parsing/tls.go | 120 ++++++++++++++++++----- config/parsing/tls_test.go | 190 +++++++++++++++++++++++++++++++++++-- 2 files changed, 279 insertions(+), 31 deletions(-) diff --git a/config/parsing/tls.go b/config/parsing/tls.go index 84f81556..b73c8618 100644 --- a/config/parsing/tls.go +++ b/config/parsing/tls.go @@ -12,6 +12,8 @@ import ( "crypto/x509/pkix" "encoding/pem" "math/big" + "os" + "path/filepath" "sync/atomic" "time" @@ -24,6 +26,24 @@ var ( defaultTLSConfig atomic.Value ) +// testDefaultCertDir overrides the default certificate directory in tests. +// It is set only from same-package test code and left empty in production. +var testDefaultCertDir string + +// defaultCertDir returns the directory used for persisting auto-generated +// certificates. It uses testDefaultCertDir when set (for testing), otherwise +// returns $HOME/.gost/. +func defaultCertDir() string { + if testDefaultCertDir != "" { + return testDefaultCertDir + } + dir, err := os.UserHomeDir() + if err != nil { + return ".gost" + } + return filepath.Join(dir, ".gost") +} + // DefaultTLSConfig returns the global default TLS configuration used as a // fallback when a listener or handler does not specify its own TLS settings. // The returned config is a shared instance; callers that need to mutate it @@ -40,44 +60,99 @@ func SetDefaultTLSConfig(cfg *tls.Config) { } // BuildDefaultTLSConfig loads or generates the default TLS certificate and key -// from the given config. If cfg is nil it attempts to load well-known files -// ("cert.pem", "key.pem", "ca.pem"). On failure it generates a self-signed -// ECDSA P-256 certificate valid for one year. +// from the given config. +// +// When explicit certificate files are configured (CertFile, KeyFile, or CAFile +// is non-empty) it loads them via tls_util.LoadDefaultConfig — the original +// behaviour preserved for backward compatibility. +// +// When no certificate files are configured (all three empty), it tries sources +// in this order: +// 1. cert.pem / key.pem in the current working directory (backward compatible) +// 2. auto-ca-cert.pem / auto-ca-key.pem under $HOME/.gost/ (persisted) +// 3. If none found, generates a new ECDSA P-256 CA certificate, persists it +// to $HOME/.gost/, and uses it. +// +// If cfg is nil an empty TLSConfig is used (equivalent to no explicit files), +// so the CWD → persisted → generate path is taken. func BuildDefaultTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) { log := logger.Default() if cfg == nil { - cfg = &config.TLSConfig{ - CertFile: "cert.pem", - KeyFile: "key.pem", - CAFile: "ca.pem", - } + cfg = &config.TLSConfig{} } - tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile) - if err != nil { - // generate random self-signed certificate. - cert, err := genCertificate(cfg.Validity, cfg.Organization, cfg.CommonName) + if cfg.CertFile != "" || cfg.KeyFile != "" || cfg.CAFile != "" { + tlsConfig, err := tls_util.LoadDefaultConfig(cfg.CertFile, cfg.KeyFile, cfg.CAFile) if err != nil { return nil, err } - tlsConfig = &tls.Config{ - Certificates: []tls.Certificate{cert}, - } - log.Debug("load global TLS certificate files failed, use random generated certificate") - } else { log.Debug("load global TLS certificate files OK") + return tlsConfig, nil } + tlsConfig, err := loadOrGeneratePersistentTLSConfig(cfg) + if err != nil { + return nil, err + } return tlsConfig, nil } -func genCertificate(validity time.Duration, org string, cn string) (cert tls.Certificate, err error) { - rawCert, rawKey, err := generateKeyPair(validity, org, cn) - if err != nil { - return +// loadOrGeneratePersistentTLSConfig tries loading an auto-generated CA +// certificate from these locations (in order): +// 1. cert.pem / key.pem in the current working directory (backward compatible) +// 2. auto-ca-cert.pem / auto-ca-key.pem under defaultCertDir() (persisted) +// +// If none is found it generates a new one, persists it to defaultCertDir() +// (best-effort), and returns it. +func loadOrGeneratePersistentTLSConfig(cfg *config.TLSConfig) (*tls.Config, error) { + log := logger.Default() + + // 1. Try CWD cert.pem / key.pem first (backward compatible). + cwdCert, cwdErr := tls.LoadX509KeyPair("cert.pem", "key.pem") + if cwdErr == nil { + log.Debug("loaded default certificate from current working directory (cert.pem / key.pem)") + return &tls.Config{Certificates: []tls.Certificate{cwdCert}}, nil } - return tls.X509KeyPair(rawCert, rawKey) + + // 2. Try persisted certificate in defaultCertDir(). + dir := defaultCertDir() + certFile := filepath.Join(dir, "auto-ca-cert.pem") + keyFile := filepath.Join(dir, "auto-ca-key.pem") + + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err == nil { + log.Debugf("loaded persisted default certificate from %s", dir) + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil + } + + log.Debug("generating new default certificate (no persisted certificate found)") + + // 3. Generate a new certificate. + rawCert, rawKey, err := generateKeyPair(cfg.Validity, cfg.Organization, cfg.CommonName) + if err != nil { + return nil, err + } + + // Best-effort persist to disk. If it fails the in-memory certificate is + // still usable. + if err := os.MkdirAll(dir, 0700); err != nil { + log.Warnf("failed to create certificate directory %s: %v", dir, err) + } else { + if err := os.WriteFile(certFile, rawCert, 0644); err != nil { + log.Warnf("failed to persist certificate: %v", err) + } else if err := os.WriteFile(keyFile, rawKey, 0600); err != nil { + log.Warnf("failed to persist private key: %v", err) + } else { + log.Debugf("persisted default certificate to %s", dir) + } + } + + cert, err = tls.X509KeyPair(rawCert, rawKey) + if err != nil { + return nil, err + } + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil } func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, rawKey []byte, err error) { @@ -124,6 +199,7 @@ func generateKeyPair(validity time.Duration, org string, cn string) (rawCert, ra x509.ExtKeyUsageServerAuth, }, BasicConstraintsValid: true, + IsCA: true, } if _, isRSA := priv.(*rsa.PrivateKey); isRSA { template.KeyUsage |= x509.KeyUsageKeyEncipherment diff --git a/config/parsing/tls_test.go b/config/parsing/tls_test.go index e783d9d8..38aeae42 100644 --- a/config/parsing/tls_test.go +++ b/config/parsing/tls_test.go @@ -2,15 +2,58 @@ package parsing import ( "bytes" + "crypto/rand" + "crypto/rsa" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "io" + "math/big" + "os" + "path/filepath" "testing" + "time" "github.com/go-gost/core/logger" "github.com/go-gost/x/config" xlogger "github.com/go-gost/x/logger" ) +// writeTestCertKey writes a self-signed cert/key pair to the given file paths. +// The cert is NOT persisted via the defaultCertDir mechanism — it simulates +// user-provided cert.pem / key.pem in a working directory. +func writeTestCertKey(t *testing.T, certFile, keyFile string) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "cwd-test.local"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + if err := os.WriteFile(certFile, certPEM, 0644); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil { + t.Fatalf("write key: %v", err) + } +} + func TestMain(m *testing.M) { logger.SetDefault(xlogger.NewLogger(xlogger.OutputOption(io.Discard))) m.Run() @@ -47,6 +90,10 @@ func TestDefaultTLSConfig_InitiallyNil(t *testing.T) { } func TestBuildDefaultTLSConfig_Nil(t *testing.T) { + // Use a temp dir so the test is isolated and doesn't touch $HOME/.gost + testDefaultCertDir = t.TempDir() + t.Cleanup(func() { testDefaultCertDir = "" }) + // BuildDefaultTLSConfig with nil should generate a self-signed cert tlsCfg, err := BuildDefaultTLSConfig(nil) if err != nil { @@ -58,9 +105,22 @@ func TestBuildDefaultTLSConfig_Nil(t *testing.T) { if len(tlsCfg.Certificates) == 0 { t.Fatal("expected at least one certificate") } + + // Verify that the certificate and key files were persisted. + certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem") + keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem") + if _, err := os.Stat(certFile); os.IsNotExist(err) { + t.Fatal("cert file was not persisted") + } + if _, err := os.Stat(keyFile); os.IsNotExist(err) { + t.Fatal("key file was not persisted") + } } func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) { + testDefaultCertDir = t.TempDir() + t.Cleanup(func() { testDefaultCertDir = "" }) + cfg := &config.TLSConfig{ Validity: 0, // uses default Organization: "TestOrg", @@ -78,26 +138,138 @@ func TestBuildDefaultTLSConfig_WithOptions(t *testing.T) { } } -func TestBuildDefaultTLSConfig_TwoCallsDifferentCerts(t *testing.T) { +func TestBuildDefaultTLSConfig_TwoCallsSameCerts(t *testing.T) { + testDefaultCertDir = t.TempDir() + t.Cleanup(func() { testDefaultCertDir = "" }) + cfg1, err := BuildDefaultTLSConfig(nil) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf("unexpected error on first call: %v", err) } cfg2, err := BuildDefaultTLSConfig(nil) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf("unexpected error on second call: %v", err) } if len(cfg1.Certificates) == 0 || len(cfg2.Certificates) == 0 { t.Fatal("expected certificates in both configs") } - // Each call generates a random cert; they should have different raw bytes. - if len(cfg1.Certificates[0].Certificate) == 0 || len(cfg2.Certificates[0].Certificate) == 0 { - t.Fatal("expected non-empty certificate raw bytes") - } + // Both calls should return the SAME persisted certificate. c1 := cfg1.Certificates[0].Certificate[0] c2 := cfg2.Certificates[0].Certificate[0] - if bytes.Equal(c1, c2) { - t.Fatal("expected different certificates from two calls") + if !bytes.Equal(c1, c2) { + t.Fatal("expected same certificate from two calls (persisted)") + } +} + +func TestBuildDefaultTLSConfig_PersistsAndReloads(t *testing.T) { + testDefaultCertDir = t.TempDir() + defer func() { testDefaultCertDir = "" }() + + // First call generates and persists. + cfg1, err := BuildDefaultTLSConfig(nil) + if err != nil { + t.Fatalf("unexpected error on first call: %v", err) + } + + // Verify files exist on disk. + certFile := filepath.Join(testDefaultCertDir, "auto-ca-cert.pem") + keyFile := filepath.Join(testDefaultCertDir, "auto-ca-key.pem") + if _, err := os.Stat(certFile); os.IsNotExist(err) { + t.Fatal("cert file was not persisted after first call") + } + if _, err := os.Stat(keyFile); os.IsNotExist(err) { + t.Fatal("key file was not persisted after first call") + } + + // Second call loads the persisted cert. + cfg2, err := BuildDefaultTLSConfig(nil) + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + + c1 := cfg1.Certificates[0].Certificate[0] + c2 := cfg2.Certificates[0].Certificate[0] + if !bytes.Equal(c1, c2) { + t.Fatal("expected same certificate on reload (persisted)") + } +} + +func TestBuildDefaultTLSConfig_FallsBackWhenWriteBlocked(t *testing.T) { + // Point to a directory that can't be created (e.g. a file path). + // We use a path under /proc on Linux which is a read-only filesystem. + testDefaultCertDir = "/proc/doesnotexist/certdir" + defer func() { testDefaultCertDir = "" }() + + tlsCfg, err := BuildDefaultTLSConfig(nil) + if err != nil { + t.Fatalf("unexpected error (should fall back to in-memory): %v", err) + } + if tlsCfg == nil { + t.Fatal("expected non-nil config even when write fails") + } + if len(tlsCfg.Certificates) == 0 { + t.Fatal("expected at least one certificate from fallback") + } +} + +// TestBuildDefaultTLSConfig_CWDTakesPriority verifies that when cert.pem and +// key.pem exist in the current working directory, they take precedence over +// both the persisted certificate and auto-generation. +func TestBuildDefaultTLSConfig_CWDTakesPriority(t *testing.T) { + // Create a temp dir with cert.pem / key.pem. + cwd := t.TempDir() + writeTestCertKey(t, filepath.Join(cwd, "cert.pem"), filepath.Join(cwd, "key.pem")) + + // Also set up a persisted cert directory. + testDefaultCertDir = t.TempDir() + defer func() { testDefaultCertDir = "" }() + + // Pre-populate persisted cert so we can prove CWD wins. + _, err := BuildDefaultTLSConfig(nil) + if err != nil { + t.Fatalf("pre-populate persisted cert: %v", err) + } + + // Change to the CWD that has cert.pem / key.pem. + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(cwd); err != nil { + t.Fatalf("chdir: %v", err) + } + defer os.Chdir(origDir) + + // BuildDefaultTLSConfig should load CWD cert, not the persisted one. + tlsCfg, err := BuildDefaultTLSConfig(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(tlsCfg.Certificates) == 0 { + t.Fatal("expected at least one certificate") + } + + // Load CWD cert directly and compare. + cwdCert, err := tls.LoadX509KeyPair("cert.pem", "key.pem") + if err != nil { + t.Fatalf("load CWD cert: %v", err) + } + got := tlsCfg.Certificates[0].Certificate[0] + want := cwdCert.Certificate[0] + if !bytes.Equal(got, want) { + t.Fatal("CWD certificate was not used (persisted or auto-generated cert took priority)") + } + + // Load persisted cert to prove it's different from CWD cert. + persistedCert, err := tls.LoadX509KeyPair( + filepath.Join(testDefaultCertDir, "auto-ca-cert.pem"), + filepath.Join(testDefaultCertDir, "auto-ca-key.pem"), + ) + if err != nil { + t.Fatalf("load persisted cert: %v", err) + } + if bytes.Equal(got, persistedCert.Certificate[0]) { + t.Fatal("CWD cert should differ from persisted cert; test setup issue") } }