Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,10 +362,27 @@ Log verbosity: `none`, `errors` (default), `info`, `debug`.
CA certificate configuration for MITM TLS termination:

- `path`: Directory for CA cert/key storage (default: `~/.claw-wrap/ca` on macOS, `/etc/openclaw/ca` on Linux)
- `validity_days`: Certificate validity period (default: 365)
- `organization`: CA organization name in certificate
- `cert_file`: Certificate filename (default: `ca.crt`). Use `tls.crt` for cert-manager compatibility.
- `key_file`: Key filename (default: `ca.key`). Use `tls.key` for cert-manager compatibility.
- `external`: Enable external CA mode (default: `false`). When `true`:
- Fails fast if CA files are missing (never auto-generates)
- Relaxes key permission check for k8s secret mounts (allows 0644)
- Watches files for changes and hot-reloads on rotation
- `validity_days`: Certificate validity period (default: 365, ignored in external mode)
- `organization`: CA organization name in certificate (ignored in external mode)

The CA cert is auto-generated on first start and auto-rotated 30 days before expiry.
**Self-managed mode** (default): The CA cert is auto-generated on first start and auto-rotated 30 days before expiry.

**External mode** (`external: true`): Use with cert-manager or k8s secrets:

```yaml
http_proxy:
ca:
path: /etc/claw/ca
cert_file: tls.crt
key_file: tls.key
external: true
```

### `strip_response_headers`

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (

require (
filippo.io/hpke v0.4.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.8.1 h1:/qGpPJGgIPOTZ7IoIQvjavocp//qYSe9LQnIGCgRY5k=
github.com/elazarl/goproxy v1.8.1/go.mod h1:b5xm6W48AUHNpRTCvlnd0YVh+JafCCtsLsJZvvNTz+E=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
Expand Down
66 changes: 63 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ func (c *HTTPProxyConfig) GetRequireAuth() bool {
// CAConfig holds CA certificate configuration for MITM proxy.
type CAConfig struct {
Path string `yaml:"path"`
CertFile string `yaml:"cert_file"` // default "ca.crt"
KeyFile string `yaml:"key_file"` // default "ca.key"
External bool `yaml:"external"` // external management mode (cert-manager, etc.)
ValidityDays int `yaml:"validity_days"`
Comment on lines 90 to 96
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new external CA functionality (cert_file, key_file, external fields) is not documented in docs/CONFIG.md. Users need documentation to understand:

  1. What the external mode is for (cert-manager, k8s secrets integration)
  2. How to configure custom filenames
  3. How hot-reload works
  4. The differences in behavior (permission checks relaxed, fail-fast on missing files)

Consider adding a section in CONFIG.md explaining these new options with examples, especially the cert-manager use case mentioned in the PR description.

Suggested change
// CAConfig holds CA certificate configuration for MITM proxy.
type CAConfig struct {
Path string `yaml:"path"`
CertFile string `yaml:"cert_file"` // default "ca.crt"
KeyFile string `yaml:"key_file"` // default "ca.key"
External bool `yaml:"external"` // external management mode (cert-manager, etc.)
ValidityDays int `yaml:"validity_days"`
// CAConfig holds CA certificate configuration for the HTTP MITM proxy.
//
// The proxy supports two modes of operation:
//
// 1. Managed / internal CA (External == false, default):
// - The daemon owns the CA private key and certificate.
// - If the CA files are missing, it will generate a new CA and write
// them to disk under Path/CertFile and Path/KeyFile.
// - Strict file-permission checks are enforced to avoid leaking the
// private key (e.g. mode 0600 on the key file).
// - ValidityDays and Organization are used when creating a new CA.
//
// 2. External CA (External == true):
// - CA material is managed by an external system such as cert-manager
// or a Kubernetes Secret/ConfigMap mounted into the container.
// - The daemon does NOT create, rotate, or modify the CA files; it only
// reads them from disk.
// - CertFile and KeyFile can be customized to match the filenames used
// by the external system (for example, cert-manager's secret keys).
// - On startup, the daemon fails fast if the configured files are
// missing or unreadable, rather than silently generating a new CA.
// - Permission checks on the key file are relaxed to accommodate
// typical Kubernetes Secret mounts.
// - Changes to the CA files on disk are picked up via hot-reload, so
// external rotation (e.g. by cert-manager) does not require a restart.
//
// Path is the directory containing the CA certificate and key. CertFile and
// KeyFile are the filenames within that directory. If CertFile or KeyFile
// are empty in the configuration, they default to "ca.crt" and "ca.key",
// respectively.
type CAConfig struct {
// Path is the directory where the CA certificate and key are stored.
// In a Kubernetes deployment, this is typically the mountPath of a
// Secret or ConfigMap containing the CA material.
Path string `yaml:"path"`
// CertFile is the filename (relative to Path) of the CA certificate.
// Default: "ca.crt".
CertFile string `yaml:"cert_file"`
// KeyFile is the filename (relative to Path) of the CA private key.
// Default: "ca.key".
KeyFile string `yaml:"key_file"`
// External enables external management mode for the CA.
// When true, the daemon will not create or rotate the CA; instead, it
// expects the certificate and key to be written by an external system
// (such as cert-manager) and will hot-reload changes on disk.
External bool `yaml:"external"`
// ValidityDays controls the lifetime of a newly generated CA certificate
// when running in managed / internal CA mode. It is ignored when External
// is true and the CA is provided by an external system.
ValidityDays int `yaml:"validity_days"`
// Organization is the subject organization used when creating a new CA
// in managed / internal mode. It is not modified in external mode.

Copilot uses AI. Check for mistakes.
Organization string `yaml:"organization"`
}
Expand Down Expand Up @@ -171,9 +174,9 @@ type CredentialDef struct {

// ToolDef defines a wrapped tool.
type ToolDef struct {
Binary string `yaml:"binary"`
Timeout string `yaml:"timeout,omitempty"`
Env map[string]string `yaml:"env,omitempty"` // Unified env: credential refs, {{ interpolation }}, or literals
Binary string `yaml:"binary"`
Timeout string `yaml:"timeout,omitempty"`
Env map[string]string `yaml:"env,omitempty"` // Unified env: credential refs, {{ interpolation }}, or literals
// Deprecated: Use Env instead. ForcedEnv values are always treated as literals.
// Will be removed in a future version.
ForcedEnv map[string]string `yaml:"forced_env,omitempty"`
Expand Down Expand Up @@ -457,6 +460,14 @@ func (c *Config) validateHTTPProxy() error {
return fmt.Errorf("ca.validity_days must be non-negative")
}

// Validate CA filenames (prevent path traversal)
if err := validateCAFilename(cfg.CA.CertFile, "cert_file"); err != nil {
return err
}
if err := validateCAFilename(cfg.CA.KeyFile, "key_file"); err != nil {
return err
}

// Validate and compile routes
for i := range cfg.Routes {
route := &cfg.Routes[i]
Expand Down Expand Up @@ -656,6 +667,31 @@ func validateSafeRelativePath(value string, allowNested bool) error {
return nil
}

// validateCAFilename ensures a CA filename is a simple basename without path traversal.
// Empty values are allowed (defaults apply).
func validateCAFilename(filename, field string) error {
if filename == "" {
return nil // Defaults apply
}
// Must be a simple basename (no directory components)
if strings.ContainsAny(filename, "/\\") {
return fmt.Errorf("ca.%s: must be a filename, not a path", field)
}
if filename == "." || filename == ".." {
return fmt.Errorf("ca.%s: invalid filename %q", field, filename)
}
if strings.ContainsRune(filename, '\x00') {
return fmt.Errorf("ca.%s: contains NUL byte", field)
}
// Reject control characters (log injection prevention)
for _, r := range filename {
if r < 32 || r == 127 {
return fmt.Errorf("ca.%s: contains control character", field)
}
}
return nil
}

// LoadDefault loads the configuration from the default path.
func LoadDefault() (*Config, error) {
return Load(DefaultConfigPath)
Expand Down Expand Up @@ -969,6 +1005,30 @@ func (c *Config) GetHTTPProxyRequireAuth() bool {
return c.HTTPProxy.GetRequireAuth()
}

// GetHTTPProxyCACertFile returns the CA certificate filename (default "ca.crt").
func (c *Config) GetHTTPProxyCACertFile() string {
if c.HTTPProxy == nil || c.HTTPProxy.CA.CertFile == "" {
return "ca.crt"
}
return c.HTTPProxy.CA.CertFile
}

// GetHTTPProxyCAKeyFile returns the CA key filename (default "ca.key").
func (c *Config) GetHTTPProxyCAKeyFile() string {
if c.HTTPProxy == nil || c.HTTPProxy.CA.KeyFile == "" {
return "ca.key"
}
return c.HTTPProxy.CA.KeyFile
}

// GetHTTPProxyCAExternal returns whether the CA is externally managed.
func (c *Config) GetHTTPProxyCAExternal() bool {
if c.HTTPProxy == nil {
return false
}
return c.HTTPProxy.CA.External
}

// GetTimeout returns the tool-specific timeout or falls back to the global default.
func (t *ToolDef) GetTimeout(globalDefault time.Duration) time.Duration {
if t.Timeout == "" {
Expand Down
133 changes: 133 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1706,3 +1706,136 @@ func TestAuditConfig_BoolDefaults(t *testing.T) {
t.Error("GetIncludeDuration() should be false when explicitly set")
}
}

// --- CAConfig tests ---

func TestCAConfig_DefaultFilenames(t *testing.T) {
cfg := &Config{
HTTPProxy: &HTTPProxyConfig{
CA: CAConfig{
Path: "/etc/claw/ca",
// No CertFile or KeyFile specified
},
},
}

if got := cfg.GetHTTPProxyCACertFile(); got != "ca.crt" {
t.Errorf("GetHTTPProxyCACertFile() = %q, want ca.crt", got)
}
if got := cfg.GetHTTPProxyCAKeyFile(); got != "ca.key" {
t.Errorf("GetHTTPProxyCAKeyFile() = %q, want ca.key", got)
}
}

func TestCAConfig_CustomFilenames(t *testing.T) {
cfg := &Config{
HTTPProxy: &HTTPProxyConfig{
CA: CAConfig{
Path: "/etc/claw/ca",
CertFile: "tls.crt",
KeyFile: "tls.key",
},
},
}

if got := cfg.GetHTTPProxyCACertFile(); got != "tls.crt" {
t.Errorf("GetHTTPProxyCACertFile() = %q, want tls.crt", got)
}
if got := cfg.GetHTTPProxyCAKeyFile(); got != "tls.key" {
t.Errorf("GetHTTPProxyCAKeyFile() = %q, want tls.key", got)
}
}

func TestCAConfig_ExternalMode(t *testing.T) {
cfg := &Config{
HTTPProxy: &HTTPProxyConfig{
CA: CAConfig{
Path: "/etc/claw/ca",
External: true,
},
},
}

if !cfg.GetHTTPProxyCAExternal() {
t.Error("GetHTTPProxyCAExternal() = false, want true")
}

// Default is false
cfg2 := &Config{
HTTPProxy: &HTTPProxyConfig{
CA: CAConfig{
Path: "/etc/claw/ca",
},
},
}
if cfg2.GetHTTPProxyCAExternal() {
t.Error("GetHTTPProxyCAExternal() default = true, want false")
}
}

func TestCAConfig_NilHTTPProxy(t *testing.T) {
cfg := &Config{}

if got := cfg.GetHTTPProxyCACertFile(); got != "ca.crt" {
t.Errorf("GetHTTPProxyCACertFile() with nil HTTPProxy = %q, want ca.crt", got)
}
if got := cfg.GetHTTPProxyCAKeyFile(); got != "ca.key" {
t.Errorf("GetHTTPProxyCAKeyFile() with nil HTTPProxy = %q, want ca.key", got)
}
if cfg.GetHTTPProxyCAExternal() {
t.Error("GetHTTPProxyCAExternal() with nil HTTPProxy = true, want false")
}
}

// --- CA filename validation (path traversal prevention) ---

func TestCAConfig_FilenameValidation_PathTraversal(t *testing.T) {
tests := []struct {
name string
certFile string
keyFile string
wantErr bool
}{
{"valid defaults", "", "", false},
{"valid custom", "tls.crt", "tls.key", false},
{"path traversal cert", "../../../etc/passwd", "key.pem", true},
{"path traversal key", "cert.pem", "../secret.key", true},
{"absolute path cert", "/etc/ssl/ca.crt", "ca.key", true},
{"directory in cert", "foo/bar.crt", "ca.key", true},
{"dot filename", ".", "ca.key", true},
{"dotdot filename", "..", "ca.key", true},
{"backslash path", "foo\\bar.crt", "ca.key", true},
{"nul byte cert", "ca\x00.crt", "ca.key", true},
{"control char cert", "ca\n.crt", "ca.key", true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := Config{
Credentials: map[string]CredentialDef{
"test": {Source: "env:TEST"},
},
HTTPProxy: &HTTPProxyConfig{
Enabled: true,
CA: CAConfig{
Path: "/tmp/ca",
CertFile: tt.certFile,
KeyFile: tt.keyFile,
},
Routes: []ProxyRoute{
{Host: "api.example.com", Inject: InjectSpec{Header: "X-Api-Key", Value: "{{test}}"}},
},
},
}
err := cfg.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && err != nil {
if !strings.Contains(err.Error(), "ca.cert_file") && !strings.Contains(err.Error(), "ca.key_file") {
t.Errorf("error should mention ca.cert_file or ca.key_file, got: %v", err)
}
}
})
}
}
1 change: 0 additions & 1 deletion internal/config/interpolate.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,3 @@ func CredentialNamesSet(credentials map[string]CredentialDef) map[string]struct{
}
return names
}

8 changes: 4 additions & 4 deletions internal/credentials/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import (
type Backend string

const (
BackendPass Backend = "pass"
BackendEnv Backend = "env"
BackendPass Backend = "pass"
BackendEnv Backend = "env"
Backend1Password Backend = "op"
BackendAge Backend = "age"
BackendKeychain Backend = "keychain"
BackendAge Backend = "age"
BackendKeychain Backend = "keychain"
BackendBitwarden Backend = "bw"
)

Expand Down
Loading