mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
pkg: apply managed DNS mode in the macOS package
This commit is contained in:
+25
-8
@@ -151,6 +151,25 @@ func isMobile() bool {
|
|||||||
return runtime.GOOS == "android" || runtime.GOOS == "ios"
|
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.
|
// isAndroid reports whether the current OS is Android.
|
||||||
func isAndroid() bool {
|
func isAndroid() bool {
|
||||||
return runtime.GOOS == "android"
|
return runtime.GOOS == "android"
|
||||||
@@ -367,14 +386,12 @@ func run(appCallback *AppCallback, stopCh chan struct{}) {
|
|||||||
processLogAndCacheFlags(v, &cfg)
|
processLogAndCacheFlags(v, &cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist intercept_mode to config when provided via CLI flag on full install.
|
// Keep config and the explicit CLI/service mode in sync. In particular, "off"
|
||||||
// This ensures the config file reflects the actual running mode for RMM/MDM visibility.
|
// must clear a previously persisted dns/hard value or the next service start
|
||||||
if interceptMode == "dns" || interceptMode == "hard" {
|
// would silently re-enable interception from config.
|
||||||
if cfg.Service.InterceptMode != interceptMode {
|
if updateConfigInterceptMode(&cfg, interceptMode) {
|
||||||
cfg.Service.InterceptMode = interceptMode
|
updated = true
|
||||||
updated = true
|
p.Info().Msgf("writing intercept_mode = %q to config", cfg.Service.InterceptMode)
|
||||||
p.Info().Msgf("writing intercept_mode = %q to config", interceptMode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist firewall_mode to config only when provided via CLI flag.
|
// Persist firewall_mode to config only when provided via CLI flag.
|
||||||
|
|||||||
@@ -118,21 +118,23 @@ func (sc *ServiceCommand) Start(cmd *cobra.Command, args []string) error {
|
|||||||
svcExists := serviceConfigFileExists()
|
svcExists := serviceConfigFileExists()
|
||||||
logger.Debug().Msgf("intercept upgrade check: args=%v interceptOnly=%v svcConfigExists=%v interceptMode=%q", osArgsEarly, interceptOnly, svcExists, interceptMode)
|
logger.Debug().Msgf("intercept upgrade check: args=%v interceptOnly=%v svcConfigExists=%v interceptMode=%q", osArgsEarly, interceptOnly, svcExists, interceptMode)
|
||||||
if interceptOnly && svcExists {
|
if interceptOnly && svcExists {
|
||||||
// Remove any existing intercept flags before applying the new value.
|
// Replace any existing split or --intercept-mode=<value> form. Keep an
|
||||||
_ = removeServiceFlag("--intercept-mode")
|
// 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" {
|
if interceptMode == "off" {
|
||||||
// "off" = remove intercept mode entirely (just the removal above).
|
logger.Notice().Msg("Existing service detected — disabling intercept mode")
|
||||||
logger.Notice().Msg("Existing service detected — removing --intercept-mode from service arguments")
|
|
||||||
} else {
|
} else {
|
||||||
// Add the new mode value.
|
|
||||||
logger.Notice().Msgf("Existing service detected — appending --intercept-mode %s to service arguments", interceptMode)
|
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("--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(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
|
// Stop the service if running (bypasses ctrld pin — this is an
|
||||||
|
|||||||
@@ -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
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -13,7 +14,28 @@ import (
|
|||||||
"github.com/Control-D-Inc/ctrld"
|
"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
|
// 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
|
// set, the process writes the value to stdout and exits without running any test, so
|
||||||
|
|||||||
+3
-2
@@ -923,11 +923,12 @@ func (p *prog) setDNS() {
|
|||||||
|
|
||||||
// Validate and resolve intercept mode.
|
// Validate and resolve intercept mode.
|
||||||
// CLI flag (--intercept-mode) takes priority over config file.
|
// 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) {
|
if interceptMode != "" && !validInterceptMode(interceptMode) {
|
||||||
p.Fatal().Msgf("invalid --intercept-mode value %q: must be 'off', 'dns', or 'hard'", 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()
|
interceptMode = p.configuredInterceptMode()
|
||||||
if interceptMode != "" && interceptMode != "off" {
|
if interceptMode != "" && interceptMode != "off" {
|
||||||
p.Info().Msgf("Intercept mode enabled via config (intercept_mode = %q)", interceptMode)
|
p.Info().Msgf("Intercept mode enabled via config (intercept_mode = %q)", interceptMode)
|
||||||
|
|||||||
@@ -132,6 +132,22 @@ func (h *interceptFallbackHarness) run(t *testing.T) {
|
|||||||
p.setDNS()
|
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
|
// TestSetDNSRefusesUnreachableFallback is the behaviour test for the reported outage: it
|
||||||
// drives the real setDNS() lifecycle rather than the classification helper alone.
|
// drives the real setDNS() lifecycle rather than the classification helper alone.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ func (s *systemd) Start() error {
|
|||||||
// This is necessary for running self-upgrade flow.
|
// This is necessary for running self-upgrade flow.
|
||||||
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
|
func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) {
|
||||||
opts, err := unit.DeserializeOptions(r)
|
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 {
|
if err != nil {
|
||||||
mainLog.Load().Error().Err(err).Msg("Failed to deserialize options")
|
mainLog.Load().Error().Err(err).Msg("Failed to deserialize options")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -24,19 +24,19 @@ func serviceConfigFileExists() bool {
|
|||||||
// to intercept mode without losing the existing --cd flag and other arguments.
|
// to intercept mode without losing the existing --cd flag and other arguments.
|
||||||
//
|
//
|
||||||
// On macOS, this modifies the launchd plist at /Library/LaunchDaemons/ctrld.plist
|
// 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.
|
// The function is idempotent: if the flag already exists, it's a no-op.
|
||||||
func appendServiceFlag(flag string) error {
|
func appendServiceFlag(flag string) error {
|
||||||
// Read current ProgramArguments from plist.
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read plist ProgramArguments: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
return fmt.Errorf("failed to read plist ProgramArguments: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the flag is already present (idempotent).
|
// Check exact array entries. A substring match can confuse a mode such as "off"
|
||||||
args := string(out)
|
// with an unrelated path or argument and leave the flag without its value.
|
||||||
if strings.Contains(args, flag) {
|
if serviceArgumentPresent(out, flag) {
|
||||||
mainLog.Load().Debug().Msgf("Service flag %q already present in plist, skipping", flag)
|
mainLog.Load().Debug().Msgf("Service flag %q already present in plist, skipping", flag)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -61,9 +61,8 @@ func verifyServiceRegistration() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeServiceFlag removes a CLI flag (and its value, if the next argument is not
|
// removeServiceFlag removes both "--flag value" and "--flag=value" forms from the
|
||||||
// a flag) from the installed service's launch arguments. For example, removing
|
// installed service's launch arguments.
|
||||||
// "--intercept-mode" also removes the following "dns" or "hard" value argument.
|
|
||||||
//
|
//
|
||||||
// The function is idempotent: if the flag doesn't exist, it's a no-op.
|
// The function is idempotent: if the flag doesn't exist, it's a no-op.
|
||||||
func removeServiceFlag(flag string) error {
|
func removeServiceFlag(flag string) error {
|
||||||
@@ -92,22 +91,14 @@ func removeServiceFlag(flag string) error {
|
|||||||
entries = append(entries, trimmed)
|
entries = append(entries, trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
index := -1
|
index, hasValue := serviceFlagPosition(entries, flag)
|
||||||
for i, entry := range entries {
|
|
||||||
if entry == flag {
|
|
||||||
index = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if index < 0 {
|
if index < 0 {
|
||||||
mainLog.Load().Debug().Msgf("Service flag %q not present in plist, skipping removal", flag)
|
mainLog.Load().Debug().Msgf("Service flag %q not present in plist, skipping removal", flag)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the next entry is a value (not a flag). If so, delete it first
|
// Delete a separate value first. An inline --flag=value entry is one array item.
|
||||||
// (deleting by index shifts subsequent entries down, so delete value before flag).
|
|
||||||
hasValue := index+1 < len(entries) && !strings.HasPrefix(entries[index+1], "-")
|
|
||||||
if hasValue {
|
if hasValue {
|
||||||
delVal := exec.Command(
|
delVal := exec.Command(
|
||||||
"/usr/libexec/PlistBuddy",
|
"/usr/libexec/PlistBuddy",
|
||||||
@@ -132,3 +123,24 @@ func removeServiceFlag(flag string) error {
|
|||||||
mainLog.Load().Info().Msgf("Removed %q from service launch arguments", flag)
|
mainLog.Load().Info().Msgf("Removed %q from service launch arguments", flag)
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
"os"
|
"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.
|
// serviceConfigFileExists checks common service config file locations on Linux.
|
||||||
func serviceConfigFileExists() bool {
|
func serviceConfigFileExists() bool {
|
||||||
// systemd unit file
|
// systemd unit file
|
||||||
@@ -24,7 +28,7 @@ func serviceConfigFileExists() bool {
|
|||||||
// Linux services (systemd) store args in unit files; intercept mode
|
// Linux services (systemd) store args in unit files; intercept mode
|
||||||
// should be set via the config file (intercept_mode) on these platforms.
|
// should be set via the config file (intercept_mode) on these platforms.
|
||||||
func appendServiceFlag(flag string) error {
|
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.
|
// verifyServiceRegistration is a no-op on this platform.
|
||||||
@@ -34,5 +38,5 @@ func verifyServiceRegistration() error {
|
|||||||
|
|
||||||
// removeServiceFlag is not yet implemented on this platform.
|
// removeServiceFlag is not yet implemented on this platform.
|
||||||
func removeServiceFlag(flag string) error {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ func appendServiceFlag(flag string) error {
|
|||||||
return fmt.Errorf("failed to read service config: %w", err)
|
return fmt.Errorf("failed to read service config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if flag already present (idempotent).
|
// Check exact arguments so a short mode such as "off" is not confused with
|
||||||
if strings.Contains(config.BinaryPathName, flag) {
|
// an unrelated path or value.
|
||||||
|
if binaryPathArgumentPresent(config.BinaryPathName, flag) {
|
||||||
mainLog.Load().Debug().Msgf("Service flag %q already present in BinPath, skipping", flag)
|
mainLog.Load().Debug().Msgf("Service flag %q already present in BinPath, skipping", flag)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -103,9 +104,8 @@ func verifyServiceRegistration() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeServiceFlag removes a CLI flag (and its value, if present) from the installed
|
// removeServiceFlag removes both "--flag value" and "--flag=value" forms from the
|
||||||
// Windows service's BinPath. For example, removing "--intercept-mode" also removes
|
// installed Windows service's BinPath. The function is idempotent.
|
||||||
// the following "dns" or "hard" value. The function is idempotent.
|
|
||||||
func removeServiceFlag(flag string) error {
|
func removeServiceFlag(flag string) error {
|
||||||
m, err := mgr.Connect()
|
m, err := mgr.Connect()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,25 +124,12 @@ func removeServiceFlag(flag string) error {
|
|||||||
return fmt.Errorf("failed to read service config: %w", err)
|
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)
|
mainLog.Load().Debug().Msgf("Service flag %q not present in BinPath, skipping removal", flag)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
config.BinaryPathName = updatedPath
|
||||||
// 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, " ")
|
|
||||||
|
|
||||||
if err := s.UpdateConfig(config); err != nil {
|
if err := s.UpdateConfig(config); err != nil {
|
||||||
return fmt.Errorf("failed to update service config: %w", err)
|
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)
|
mainLog.Load().Info().Msgf("Removed %q from service BinPath", flag)
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
Reference in New Issue
Block a user