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") } }