diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index e3dded9..fea6155 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -434,6 +434,15 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { mainLog.Load().Info().Msgf("writing intercept_mode = %q to config (requested %q)", cfg.Service.InterceptMode, interceptMode) } + // The API answer is the source of truth for a managed run. Without this, + // a custom config added (or changed) after install is applied in memory + // but never reaches the file, which file readers such as the macOS + // delegated log-send gate still use. + if managedConfigFetched && !updated && configFileDiffers(&cfg) { + updated = true + mainLog.Load().Info().Msg("config from Control D API differs from the config file") + } + if updated { if err := writeConfigFile(&cfg); err != nil { failRunUnclassified(mainLog.Load().Error().Err(err), fmt.Sprintf("failed to write config file: %v", err), p.notifyExitToLogServer) @@ -527,30 +536,61 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { } } -func writeConfigFile(cfg *ctrld.Config) error { +// configFileForWrite returns the config file that writeConfigFile writes: +// viper's file when it read one, then --config, then defaultConfigFile. A +// Control D custom config is read from memory, so viper names no file then. +func configFileForWrite() string { if cfu := v.ConfigFileUsed(); cfu != "" { - defaultConfigFile = cfu - } else if configPath != "" { - defaultConfigFile = configPath + return cfu + } + if configPath != "" { + return configPath + } + return defaultConfigFile +} + +// renderConfigFile returns the exact bytes writeConfigFile writes for cfg. +func renderConfigFile(cfg *ctrld.Config) ([]byte, error) { + var buf bytes.Buffer + if cdUID != "" { + buf.WriteString("# AUTO-GENERATED VIA CD FLAG - DO NOT MODIFY\n\n") + } + enc := toml.NewEncoder(&buf).SetIndentTables(true) + if err := enc.Encode(&cfg); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// configFileDiffers reports whether the config file on disk is not what +// writeConfigFile would write for cfg. A missing or unreadable file differs. +func configFileDiffers(cfg *ctrld.Config) bool { + want, err := renderConfigFile(cfg) + if err != nil { + return true + } + have, err := os.ReadFile(configFileForWrite()) + if err != nil { + return true + } + return !bytes.Equal(have, want) +} + +func writeConfigFile(cfg *ctrld.Config) error { + defaultConfigFile = configFileForWrite() + data, err := renderConfigFile(cfg) + if err != nil { + return err } f, err := os.OpenFile(defaultConfigFile, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(0o644)) if err != nil { return err } defer f.Close() - if cdUID != "" { - if _, err := f.WriteString("# AUTO-GENERATED VIA CD FLAG - DO NOT MODIFY\n\n"); err != nil { - return err - } - } - enc := toml.NewEncoder(f).SetIndentTables(true) - if err := enc.Encode(&cfg); err != nil { + if _, err := f.Write(data); err != nil { return err } - if err := f.Close(); err != nil { - return err - } - return nil + return f.Close() } // readConfigFile reads in config file. diff --git a/cmd/cli/cli_test.go b/cmd/cli/cli_test.go index eae2673..20c5f4f 100644 --- a/cmd/cli/cli_test.go +++ b/cmd/cli/cli_test.go @@ -5,8 +5,11 @@ import ( "path/filepath" "testing" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/Control-D-Inc/ctrld" ) func Test_writeConfigFile(t *testing.T) { @@ -22,6 +25,31 @@ func Test_writeConfigFile(t *testing.T) { require.NoError(t, err) } +// A managed start rewrites the config file only when the API answer differs +// from it, e.g. a custom config added after install. +func Test_configFileDiffers(t *testing.T) { + oldV, oldFile, oldPath, oldUID := v, defaultConfigFile, configPath, cdUID + t.Cleanup(func() { v, defaultConfigFile, configPath, cdUID = oldV, oldFile, oldPath, oldUID }) + v = viper.NewWithOptions(viper.KeyDelimiter("::")) + configPath = filepath.Join(t.TempDir(), "ctrld.toml") + cdUID = "test-uid" + + generated := &ctrld.Config{Upstream: map[string]*ctrld.UpstreamConfig{"0": {Type: "doh", Endpoint: "https://dns.controld.com/test-uid"}}} + assert.True(t, configFileDiffers(generated), "missing file must differ") + require.NoError(t, writeConfigFile(generated)) + assert.False(t, configFileDiffers(generated), "file written from the same config must not differ") + + custom := *generated + custom.Service.AllowUnprivilegedLogSend = true + assert.True(t, configFileDiffers(&custom), "custom config added after install must differ") + require.NoError(t, writeConfigFile(&custom)) + assert.False(t, configFileDiffers(&custom)) + data, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(data), "allow_unprivileged_log_send = true") + assert.True(t, configFileDiffers(generated), "custom config removed must differ") +} + func Test_isStableVersion(t *testing.T) { tests := []struct { name string diff --git a/cmd/cli/log_send_darwin.go b/cmd/cli/log_send_darwin.go index 764d625..ee8f3c2 100644 --- a/cmd/cli/log_send_darwin.go +++ b/cmd/cli/log_send_darwin.go @@ -90,7 +90,7 @@ func (p *prog) newDelegatedLogSendServer() (*logSendServer, error) { return nil, errLogSendTrust } tree := trustedLogTree{root: "/", uid: 0} - collector := delegatedLogCollector{tree: tree, config: canonicalLogSendPath(v.ConfigFileUsed())} + collector := delegatedLogCollector{tree: tree, config: canonicalLogSendPath(configFileForWrite())} // Capture source paths and budgets once. A reload cannot redirect this socket // to new files, identities, or destinations, nor enable an initially off socket. var files []*rotatingFile diff --git a/cmd/cli/log_send_darwin_test.go b/cmd/cli/log_send_darwin_test.go index 25ecc92..4f3c238 100644 --- a/cmd/cli/log_send_darwin_test.go +++ b/cmd/cli/log_send_darwin_test.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/base64" "errors" "io" "net" @@ -10,6 +11,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/spf13/viper" ) // These tests touch only owned temporary files and sockets: no service, PF, @@ -141,3 +144,45 @@ func TestDelegatedLogSendDarwinListenerLifecycle(t *testing.T) { t.Fatal("symlink parent accepted") } } + +func TestDelegatedLogSendConfigPathAfterCustomConfig(t *testing.T) { + oldV, oldFile, oldPath := v, defaultConfigFile, configPath + t.Cleanup(func() { v, defaultConfigFile, configPath = oldV, oldFile, oldPath }) + + dir := t.TempDir() + onDisk := filepath.Join(dir, "ctrld.toml") + if err := os.WriteFile(onDisk, []byte("[service]\nallow_unprivileged_log_send = true\n"), 0644); err != nil { + t.Fatal(err) + } + // Service start: --config names the file and readConfigFile reads it. + v = viper.NewWithOptions(viper.KeyDelimiter("::")) + configPath, defaultConfigFile = onDisk, onDisk + v.SetConfigFile(onDisk) + if err := v.ReadInConfig(); err != nil { + t.Fatal(err) + } + if got := configFileForWrite(); got != onDisk { + t.Fatalf("file config: got %q, want %q", got, onDisk) + } + + // --cd with a custom config: the production loader replaces viper with an + // in-memory config, so viper no longer names any file. + custom := base64.StdEncoding.EncodeToString([]byte("[service]\nallow_unprivileged_log_send = true\n")) + if err := readBase64Config(custom); err != nil { + t.Fatal(err) + } + if v.ConfigFileUsed() != "" { + t.Fatalf("precondition: viper still names %q", v.ConfigFileUsed()) + } + for _, tc := range []struct{ name, configPath, defaultFile string }{ + {"service --config", onDisk, "ctrld.toml"}, + {"default config file", "", onDisk}, + } { + t.Run(tc.name, func(t *testing.T) { + configPath, defaultConfigFile = tc.configPath, tc.defaultFile + if got := configFileForWrite(); got != onDisk { + t.Fatalf("custom config: got %q, want %q", got, onDisk) + } + }) + } +} diff --git a/docs/config.md b/docs/config.md index 08eb9a2..c47af74 100644 --- a/docs/config.md +++ b/docs/config.md @@ -111,7 +111,9 @@ The `[service]` section controls general behaviors. ### allow_unprivileged_log_send -macOS only. Default: `false`. An administrator can opt in using the service's +macOS only. Default: `false`. The configuration that ctrld generates from the +Control D API never enables it. An administrator opts in through the device's +custom ctrld configuration in the Control D dashboard, which ctrld writes to its root-owned configuration file: ```toml @@ -119,14 +121,17 @@ root-owned configuration file: allow_unprivileged_log_send = true ``` -Restart the service as administrator after enabling. Standard users can then run +Restart the service as administrator after enabling, on a new or an existing +install. At startup ctrld rewrites the configuration file whenever the API answer +differs from it, so a custom config added, changed or removed after installation +reaches the file. Standard users can then run `ctrld log send` without `sudo`. **Every local-user process can trigger a machine-wide diagnostic upload to Control D**, not just the interactive user. This grants upload permission, not permission to read logs or administer ctrld. `--full`, `log view`, `log tail`, and other privileged commands remain restricted. Other platforms retain their existing behavior. -This requires Control D mode, an on-disk TOML configuration, and file-backed logs. +This requires Control D mode (`--cd`) with a custom configuration, and file-backed logs. The config, log files, rotated logs, and every parent directory must be root-owned, not group/world writable, and free of extended ACLs. Symlinks and hard-linked log files are refused; only the standard macOS `/var`, `/etc`, and `/tmp` aliases are