pkg: apply managed DNS mode in the macOS package

This commit is contained in:
Dev Scribe
2026-08-24 15:17:03 +07:00
committed by Cuong Manh Le
parent a40eb70ce8
commit 5eb2db549b
13 changed files with 432 additions and 64 deletions
+25 -8
View File
@@ -151,6 +151,25 @@ func isMobile() bool {
return runtime.GOOS == "android" || runtime.GOOS == "ios"
}
func updateConfigInterceptMode(cfg *ctrld.Config, mode string) bool {
desired := ""
switch mode {
case "dns", "hard":
desired = mode
case "off":
desired = ""
case "":
return false
default:
return false
}
if cfg.Service.InterceptMode == desired {
return false
}
cfg.Service.InterceptMode = desired
return true
}
// isAndroid reports whether the current OS is Android.
func isAndroid() bool {
return runtime.GOOS == "android"
@@ -367,14 +386,12 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
processLogAndCacheFlags(v, &cfg)
}
// Persist intercept_mode to config when provided via CLI flag on full install.
// This ensures the config file reflects the actual running mode for RMM/MDM visibility.
if interceptMode == "dns" || interceptMode == "hard" {
if cfg.Service.InterceptMode != interceptMode {
cfg.Service.InterceptMode = interceptMode
updated = true
p.Info().Msgf("writing intercept_mode = %q to config", interceptMode)
}
// Keep config and the explicit CLI/service mode in sync. In particular, "off"
// must clear a previously persisted dns/hard value or the next service start
// would silently re-enable interception from config.
if updateConfigInterceptMode(&cfg, interceptMode) {
updated = true
p.Info().Msgf("writing intercept_mode = %q to config", cfg.Service.InterceptMode)
}
// Persist firewall_mode to config only when provided via CLI flag.
+13 -11
View File
@@ -118,21 +118,23 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
svcExists := serviceConfigFileExists()
logger.Debug().Msgf("intercept upgrade check: args=%v interceptOnly=%v svcConfigExists=%v interceptMode=%q", osArgsEarly, interceptOnly, svcExists, interceptMode)
if interceptOnly && svcExists {
// Remove any existing intercept flags before applying the new value.
_ = removeServiceFlag("--intercept-mode")
// Replace any existing split or --intercept-mode=<value> form. Keep an
// explicit "off" argument so it overrides a previously persisted config
// value while the service clears that value on startup.
if err := removeServiceFlag("--intercept-mode"); err != nil {
logger.Fatal().Err(err).Msg("failed to remove existing intercept mode from service arguments")
}
if interceptMode == "off" {
// "off" = remove intercept mode entirely (just the removal above).
logger.Notice().Msg("Existing service detected — removing --intercept-mode from service arguments")
logger.Notice().Msg("Existing service detected — disabling intercept mode")
} else {
// Add the new mode value.
logger.Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
if err := appendServiceFlag("--intercept-mode"); err != nil {
logger.Fatal().Err(err).Msg("failed to append intercept flag to service arguments")
}
if err := appendServiceFlag(interceptMode); err != nil {
logger.Fatal().Err(err).Msg("failed to append intercept mode value to service arguments")
}
}
if err := appendServiceFlag("--intercept-mode"); err != nil {
logger.Fatal().Err(err).Msg("failed to append intercept flag to service arguments")
}
if err := appendServiceFlag(interceptMode); err != nil {
logger.Fatal().Err(err).Msg("failed to append intercept mode value to service arguments")
}
// Stop the service if running (bypasses ctrld pin — this is an
+38
View File
@@ -0,0 +1,38 @@
package cli
import (
"testing"
"github.com/Control-D-Inc/ctrld"
)
func TestUpdateConfigInterceptMode(t *testing.T) {
tests := []struct {
name string
current string
mode string
want string
wantUpdated bool
}{
{name: "empty flag preserves config", current: "dns", mode: "", want: "dns"},
{name: "dns is persisted", mode: "dns", want: "dns", wantUpdated: true},
{name: "hard is persisted", current: "dns", mode: "hard", want: "hard", wantUpdated: true},
{name: "off clears persisted mode", current: "dns", mode: "off", want: "", wantUpdated: true},
{name: "off is idempotent", mode: "off", want: ""},
{name: "invalid flag preserves config", current: "hard", mode: "invalid", want: "hard"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := &ctrld.Config{}
cfg.Service.InterceptMode = tc.current
updated := updateConfigInterceptMode(cfg, tc.mode)
if updated != tc.wantUpdated {
t.Fatalf("updateConfigInterceptMode() updated = %v, want %v", updated, tc.wantUpdated)
}
if cfg.Service.InterceptMode != tc.want {
t.Fatalf("service.intercept_mode = %q, want %q", cfg.Service.InterceptMode, tc.want)
}
})
}
}
+23 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"strings"
"sync"
"testing"
"go.uber.org/zap"
@@ -13,7 +14,28 @@ import (
"github.com/Control-D-Inc/ctrld"
)
var logOutput strings.Builder
// logOutput is the log sink for the whole test binary. Tests share it with any
// background goroutine the code under test starts (watchdogs, timers), so it
// must tolerate concurrent writes.
var logOutput syncBuffer
// syncBuffer is a strings.Builder guarded by a mutex.
type syncBuffer struct {
mu sync.Mutex
sb strings.Builder
}
func (b *syncBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.sb.Write(p)
}
func (b *syncBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.sb.String()
}
// envFakeVersionOutput makes this test binary impersonate a ctrld executable: when
// set, the process writes the value to stdout and exits without running any test, so
+3 -2
View File
@@ -923,11 +923,12 @@ func (p *prog) setDNS() {
// Validate and resolve intercept mode.
// CLI flag (--intercept-mode) takes priority over config file.
// Valid values: "" (off), "dns" (with VPN split routing), "hard" (all DNS through ctrld).
// Valid values: "" (use config), "off" (explicitly disable), "dns" (with VPN
// split routing), and "hard" (all DNS through ctrld).
if interceptMode != "" && !validInterceptMode(interceptMode) {
p.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", interceptMode)
}
if interceptMode == "" || interceptMode == "off" {
if interceptMode == "" {
interceptMode = p.configuredInterceptMode()
if interceptMode != "" && interceptMode != "off" {
p.Info().Msgf("Intercept mode enabled via config (intercept_mode = %q)", interceptMode)
+16
View File
@@ -132,6 +132,22 @@ func (h *interceptFallbackHarness) run(t *testing.T) {
p.setDNS()
}
func TestSetDNSExplicitOffOverridesConfig(t *testing.T) {
h := newInterceptFallbackHarness(t, &ctrld.ListenerConfig{IP: "127.0.0.1", Port: 53})
interceptMode = "off"
dnsIntercept = false
hardIntercept = false
h.run(t)
if h.interceptCalls != 0 {
t.Fatalf("intercept start called %d time(s), want 0: explicit off must override service.intercept_mode", h.interceptCalls)
}
if h.installCalls != 1 {
t.Fatalf("interface DNS installed %d time(s), want 1", h.installCalls)
}
}
// TestSetDNSRefusesUnreachableFallback is the behaviour test for the reported outage: it
// drives the real setDNS() lifecycle rather than the classification helper alone.
//
+5
View File
@@ -124,6 +124,11 @@ func (s *systemd) Start() error {
// This is necessary for running self-upgrade flow.
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
opts, err := unit.DeserializeOptions(r)
// staticcheck sees only the explicit non-nil sends on the lexer's error
// channel, so it reports this comparison as always true. On success the
// lexer sends nothing and closes the channel, so the receive yields a nil
// error and this branch is not taken.
//lint:ignore SA4023 upstream delivers a nil error by closing the channel
if err != nil {
mainLog.Load().Error().Err(err).Msg("Failed to deserialize options")
return
+30 -18
View File
@@ -24,19 +24,19 @@ func serviceConfigFileExists() bool {
// to intercept mode without losing the existing --cd flag and other arguments.
//
// On macOS, this modifies the launchd plist at /Library/LaunchDaemons/ctrld.plist
// using the "defaults" command, which is the standard way to edit plists.
// using PlistBuddy for exact array reads and writes.
//
// The function is idempotent: if the flag already exists, it's a no-op.
func appendServiceFlag(flag string) error {
// Read current ProgramArguments from plist.
out, err := exec.Command("defaults", "read", launchdPlistPath, "ProgramArguments").CombinedOutput()
out, err := exec.Command("/usr/libexec/PlistBuddy", "-c", "Print :ProgramArguments", launchdPlistPath).CombinedOutput()
if err != nil {
return fmt.Errorf("failed to read plist ProgramArguments: %w (output: %s)", err, strings.TrimSpace(string(out)))
}
// Check if the flag is already present (idempotent).
args := string(out)
if strings.Contains(args, flag) {
// Check exact array entries. A substring match can confuse a mode such as "off"
// with an unrelated path or argument and leave the flag without its value.
if serviceArgumentPresent(out, flag) {
mainLog.Load().Debug().Msgf("Service flag %q already present in plist, skipping", flag)
return nil
}
@@ -61,9 +61,8 @@ func verifyServiceRegistration() error {
return nil
}
// removeServiceFlag removes a CLI flag (and its value, if the next argument is not
// a flag) from the installed service's launch arguments. For example, removing
// "--intercept-mode" also removes the following "dns" or "hard" value argument.
// removeServiceFlag removes both "--flag value" and "--flag=value" forms from the
// installed service's launch arguments.
//
// The function is idempotent: if the flag doesn't exist, it's a no-op.
func removeServiceFlag(flag string) error {
@@ -92,22 +91,14 @@ func removeServiceFlag(flag string) error {
entries = append(entries, trimmed)
}
index := -1
for i, entry := range entries {
if entry == flag {
index = i
break
}
}
index, hasValue := serviceFlagPosition(entries, flag)
if index < 0 {
mainLog.Load().Debug().Msgf("Service flag %q not present in plist, skipping removal", flag)
return nil
}
// Check if the next entry is a value (not a flag). If so, delete it first
// (deleting by index shifts subsequent entries down, so delete value before flag).
hasValue := index+1 < len(entries) && !strings.HasPrefix(entries[index+1], "-")
// Delete a separate value first. An inline --flag=value entry is one array item.
if hasValue {
delVal := exec.Command(
"/usr/libexec/PlistBuddy",
@@ -132,3 +123,24 @@ func removeServiceFlag(flag string) error {
mainLog.Load().Info().Msgf("Removed %q from service launch arguments", flag)
return nil
}
func serviceArgumentPresent(out []byte, argument string) bool {
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) == argument {
return true
}
}
return false
}
func serviceFlagPosition(entries []string, flag string) (index int, hasValue bool) {
for i, entry := range entries {
switch {
case entry == flag:
return i, i+1 < len(entries) && !strings.HasPrefix(entries[i+1], "-")
case strings.HasPrefix(entry, flag+"="):
return i, false
}
}
return -1, false
}
+58
View File
@@ -0,0 +1,58 @@
//go:build darwin
package cli
import "testing"
func TestServiceArgumentPresent(t *testing.T) {
out := []byte("Array {\n /usr/local/bin/ctrld\n run\n --config=/Users/officer/ctrld.toml\n --intercept-mode=dns\n}\n")
if !serviceArgumentPresent(out, "--intercept-mode=dns") {
t.Fatal("exact inline argument was not found")
}
if serviceArgumentPresent(out, "--intercept-mode") {
t.Fatal("inline flag was mistaken for a separate flag argument")
}
if serviceArgumentPresent(out, "off") {
t.Fatal("substring in an unrelated path was mistaken for the off argument")
}
}
func TestServiceFlagPosition(t *testing.T) {
tests := []struct {
name string
entries []string
wantIndex int
wantHasValue bool
}{
{
name: "split form",
entries: []string{"run", "--cd=uid", "--intercept-mode", "dns"},
wantIndex: 2,
wantHasValue: true,
},
{
name: "inline form",
entries: []string{"run", "--cd=uid", "--intercept-mode=dns"},
wantIndex: 2,
},
{
name: "flag followed by another flag",
entries: []string{"run", "--intercept-mode", "--config=/etc/ctrld.toml"},
wantIndex: 1,
},
{
name: "absent",
entries: []string{"run", "--cd=uid"},
wantIndex: -1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
index, hasValue := serviceFlagPosition(tc.entries, "--intercept-mode")
if index != tc.wantIndex || hasValue != tc.wantHasValue {
t.Fatalf("serviceFlagPosition() = (%d, %v), want (%d, %v)", index, hasValue, tc.wantIndex, tc.wantHasValue)
}
})
}
}
+7 -3
View File
@@ -3,10 +3,14 @@
package cli
import (
"fmt"
"errors"
"os"
)
// errServiceFlagsUnsupported is returned by the service-argument helpers on
// platforms that do not store service arguments in a file ctrld can rewrite.
var errServiceFlagsUnsupported = errors.New("modifying service flags is not supported on this platform; use intercept_mode in config instead")
// serviceConfigFileExists checks common service config file locations on Linux.
func serviceConfigFileExists() bool {
// systemd unit file
@@ -24,7 +28,7 @@ func serviceConfigFileExists() bool {
// Linux services (systemd) store args in unit files; intercept mode
// should be set via the config file (intercept_mode) on these platforms.
func appendServiceFlag(flag string) error {
return fmt.Errorf("appending service flags is not supported on this platform; use intercept_mode in config instead")
return errServiceFlagsUnsupported
}
// verifyServiceRegistration is a no-op on this platform.
@@ -34,5 +38,5 @@ func verifyServiceRegistration() error {
// removeServiceFlag is not yet implemented on this platform.
func removeServiceFlag(flag string) error {
return fmt.Errorf("removing service flags is not supported on this platform; use intercept_mode in config instead")
return errServiceFlagsUnsupported
}
+37 -21
View File
@@ -47,8 +47,9 @@ func appendServiceFlag(flag string) error {
return fmt.Errorf("failed to read service config: %w", err)
}
// Check if flag already present (idempotent).
if strings.Contains(config.BinaryPathName, flag) {
// Check exact arguments so a short mode such as "off" is not confused with
// an unrelated path or value.
if binaryPathArgumentPresent(config.BinaryPathName, flag) {
mainLog.Load().Debug().Msgf("Service flag %q already present in BinPath, skipping", flag)
return nil
}
@@ -103,9 +104,8 @@ func verifyServiceRegistration() error {
return nil
}
// removeServiceFlag removes a CLI flag (and its value, if present) from the installed
// Windows service's BinPath. For example, removing "--intercept-mode" also removes
// the following "dns" or "hard" value. The function is idempotent.
// removeServiceFlag removes both "--flag value" and "--flag=value" forms from the
// installed Windows service's BinPath. The function is idempotent.
func removeServiceFlag(flag string) error {
m, err := mgr.Connect()
if err != nil {
@@ -124,25 +124,12 @@ func removeServiceFlag(flag string) error {
return fmt.Errorf("failed to read service config: %w", err)
}
if !strings.Contains(config.BinaryPathName, flag) {
updatedPath, removed := removeBinaryPathFlag(config.BinaryPathName, flag)
if !removed {
mainLog.Load().Debug().Msgf("Service flag %q not present in BinPath, skipping removal", flag)
return nil
}
// Split BinPath into parts, find and remove the flag + its value (if any).
parts := strings.Fields(config.BinaryPathName)
var newParts []string
for i := 0; i < len(parts); i++ {
if parts[i] == flag {
// Skip the flag. Also skip the next part if it's a value (not a flag).
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
i++ // skip value too
}
continue
}
newParts = append(newParts, parts[i])
}
config.BinaryPathName = strings.Join(newParts, " ")
config.BinaryPathName = updatedPath
if err := s.UpdateConfig(config); err != nil {
return fmt.Errorf("failed to update service config: %w", err)
@@ -151,3 +138,32 @@ func removeServiceFlag(flag string) error {
mainLog.Load().Info().Msgf("Removed %q from service BinPath", flag)
return nil
}
func binaryPathArgumentPresent(binaryPath, argument string) bool {
for _, part := range strings.Fields(binaryPath) {
if part == argument {
return true
}
}
return false
}
func removeBinaryPathFlag(binaryPath, flag string) (string, bool) {
parts := strings.Fields(binaryPath)
newParts := make([]string, 0, len(parts))
removed := false
for i := 0; i < len(parts); i++ {
switch {
case parts[i] == flag:
removed = true
if i+1 < len(parts) && !strings.HasPrefix(parts[i+1], "-") {
i++
}
case strings.HasPrefix(parts[i], flag+"="):
removed = true
default:
newParts = append(newParts, parts[i])
}
}
return strings.Join(newParts, " "), removed
}
+54
View File
@@ -0,0 +1,54 @@
//go:build windows
package cli
import "testing"
func TestBinaryPathArgumentPresent(t *testing.T) {
path := `C:\ControlD\ctrld.exe run --config=C:\Users\officer\ctrld.toml --intercept-mode=dns`
if !binaryPathArgumentPresent(path, "--intercept-mode=dns") {
t.Fatal("exact inline argument was not found")
}
if binaryPathArgumentPresent(path, "--intercept-mode") {
t.Fatal("inline flag was mistaken for a separate flag argument")
}
if binaryPathArgumentPresent(path, "off") {
t.Fatal("substring in an unrelated path was mistaken for the off argument")
}
}
func TestRemoveBinaryPathFlag(t *testing.T) {
tests := []struct {
name string
binaryPath string
wantPath string
wantRemoved bool
}{
{
name: "split form",
binaryPath: `ctrld.exe run --cd=uid --intercept-mode dns --config=ctrld.toml`,
wantPath: `ctrld.exe run --cd=uid --config=ctrld.toml`,
wantRemoved: true,
},
{
name: "inline form",
binaryPath: `ctrld.exe run --cd=uid --intercept-mode=dns --config=ctrld.toml`,
wantPath: `ctrld.exe run --cd=uid --config=ctrld.toml`,
wantRemoved: true,
},
{
name: "absent",
binaryPath: `ctrld.exe run --cd=uid`,
wantPath: `ctrld.exe run --cd=uid`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
path, removed := removeBinaryPathFlag(tc.binaryPath, "--intercept-mode")
if path != tc.wantPath || removed != tc.wantRemoved {
t.Fatalf("removeBinaryPathFlag() = (%q, %v), want (%q, %v)", path, removed, tc.wantPath, tc.wantRemoved)
}
})
}
}
+123
View File
@@ -0,0 +1,123 @@
#!/bin/sh
set -eu
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
postinstall="$repo_root/scripts/pkg/postinstall"
fixture=$(mktemp -d "${TMPDIR:-/tmp}/ctrld-pkg-intercept.XXXXXX")
trap 'rm -rf "$fixture"' EXIT HUP INT TERM
bin="$fixture/bin"
mkdir -p "$bin"
cat >"$bin/defaults" <<'EOF'
#!/bin/sh
key=${3:-}
case "$key" in
ProvisionToken)
[ "${FAKE_TOKEN_PRESENT:-0}" = "1" ] || exit 1
printf '%s\n' "${FAKE_TOKEN:-test-token}"
;;
InterceptMode)
[ "${FAKE_MODE_PRESENT:-0}" = "1" ] || exit 1
printf '%s\n' "${FAKE_MODE:-}"
;;
CustomHostname|UseDevEnvironment)
exit 1
;;
*)
exit 1
;;
esac
EOF
cat >"$bin/launchctl" <<'EOF'
#!/bin/sh
printf 'launchctl %s\n' "$*" >>"$CALLS"
exit 0
EOF
cat >"$bin/ctrld" <<'EOF'
#!/bin/sh
printf 'ctrld %s\n' "$*" >>"$CALLS"
case " $* " in
*" --cd-org="*) : >"$CTRLD_POSTINSTALL_PLIST" ;;
esac
exit 0
EOF
chmod +x "$bin/defaults" "$bin/launchctl" "$bin/ctrld"
assert_contains() {
expected=$1
file=$2
if ! grep -Fq -- "$expected" "$file"; then
printf 'FAIL: expected %s in %s\n' "$expected" "$file" >&2
sed -n '1,120p' "$file" >&2
exit 1
fi
}
assert_not_contains() {
unexpected=$1
file=$2
if grep -Fq -- "$unexpected" "$file"; then
printf 'FAIL: did not expect %s in %s\n' "$unexpected" "$file" >&2
sed -n '1,120p' "$file" >&2
exit 1
fi
}
run_case() {
name=$1
existing=$2
mode_present=$3
mode=$4
case_dir="$fixture/$name"
mkdir -p "$case_dir"
plist="$case_dir/ctrld.plist"
prefs="$case_dir/preferences"
calls="$case_dir/calls"
output="$case_dir/output"
: >"$calls"
if [ "$existing" = "1" ]; then
: >"$plist"
fi
PATH="$bin:$PATH" \
CALLS="$calls" \
FAKE_TOKEN_PRESENT=1 \
FAKE_TOKEN=test-token \
FAKE_MODE_PRESENT="$mode_present" \
FAKE_MODE="$mode" \
CTRLD_POSTINSTALL_PLIST="$plist" \
CTRLD_POSTINSTALL_CTRLD="$bin/ctrld" \
CTRLD_POSTINSTALL_PREFS="$prefs" \
"$postinstall" >"$output" 2>&1
printf '%s\n' "$case_dir"
}
case_dir=$(run_case fresh-legacy 0 0 '')
assert_contains 'ctrld start --cd-org=test-token' "$case_dir/calls"
assert_not_contains '--intercept-mode' "$case_dir/calls"
case_dir=$(run_case fresh-standard 0 1 standard)
assert_contains 'ctrld start --cd-org=test-token' "$case_dir/calls"
assert_not_contains '--intercept-mode' "$case_dir/calls"
case_dir=$(run_case fresh-intercept 0 1 intercept-dns)
assert_contains 'ctrld start --cd-org=test-token --intercept-mode dns' "$case_dir/calls"
case_dir=$(run_case upgrade-legacy 1 0 '')
assert_contains 'launchctl load' "$case_dir/calls"
assert_not_contains 'ctrld start' "$case_dir/calls"
case_dir=$(run_case upgrade-standard 1 1 standard)
assert_contains 'ctrld start --intercept-mode off' "$case_dir/calls"
assert_not_contains 'launchctl load' "$case_dir/calls"
case_dir=$(run_case upgrade-intercept 1 1 intercept-dns)
assert_contains 'ctrld start --intercept-mode dns' "$case_dir/calls"
assert_not_contains 'launchctl load' "$case_dir/calls"
printf 'PASS: pkg postinstall preserves legacy mode and applies standard/intercept-dns policy\n'