fix(cli): enable macOS log-send socket with a Control D custom config

This commit is contained in:
Dev Scribe authored and Cuong Manh Le committed 2026-10-20 17:23:47 +07:00
1 parent 1da3fcf269
commit 316d5eef7e
5 files changed
+137 -19

No files matched your search

+55 -15
View File
@@ -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.
+28
View File
@@ -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
+1 -1
View File
@@ -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
+45
View File
@@ -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)
}
})
}
}
+8 -3
View File
@@ -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