From d8994cc7d8b0ccda4c834f798fe89e582ea8bb14 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 27 Aug 2026 17:09:04 +0700 Subject: [PATCH] Rename ctrld v2.0.0 to ctrld-client Updates #565 --- README.md | 42 +++++----- cmd/cli/cli.go | 48 ++++++++++-- cmd/cli/cli_test.go | 52 +++++++++++++ cmd/cli/commands_service.go | 2 +- cmd/cli/commands_service_manager.go | 2 +- cmd/cli/commands_test.go | 11 ++- cmd/cli/commands_upgrade.go | 27 ++++++- cmd/cli/commands_upgrade_test.go | 78 ++++++++++++++++++- cmd/cli/library_retry_test.go | 4 +- cmd/cli/prog.go | 18 ++++- cmd/cli/service.go | 11 ++- cmd/cli/service_args_darwin.go | 4 +- cmd/cli/service_args_others.go | 4 +- cmd/cli/service_image_path.go | 2 +- cmd/cli/service_image_path_windows.go | 2 +- docker/Dockerfile | 17 +++- docker/Dockerfile.debug | 17 +++- docs/config.md | 2 +- docs/dns-intercept-mode.md | 10 +-- docs/ephemeral_mode.md | 8 +- docs/firewall-mode.md | 2 +- docs/known-issues.md | 2 +- docs/pf-dns-intercept.md | 2 +- docs/provisioning-failure-codes.md | 8 +- docs/username-detection.md | 4 +- docs/v2.0.0-breaking-changes.md | 28 +++---- docs/wfp-dns-intercept.md | 6 +- scripts/build.sh | 2 +- scripts/docker-smoke.sh | 53 +++++++++++++ .../darwin/test-pkg-intercept-mode.sh | 46 +++++++---- 30 files changed, 403 insertions(+), 111 deletions(-) create mode 100755 scripts/docker-smoke.sh diff --git a/README.md b/README.md index e7a7223..7b7177b 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,12 @@ There are several ways to download and install `ctrld`. The simplest way to download and install `ctrld` is to use the following installer command on any UNIX-like platform: ```shell -sh -c 'sh -c "$(curl -sL https://api.controld.com/dl?version=2)"' +sh -c 'sh -c "$(curl -sL https://api.controld.com/dl?version=client)"' ``` Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell: ```shell -(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1?version=2' -UseBasicParsing).Content | Set-Content "$env:TEMP\ctrld_install.ps1"; Invoke-Expression "& '$env:TEMP\ctrld_install.ps1'" +(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1?version=client' -UseBasicParsing).Content | Set-Content "$env:TEMP\ctrld_install.ps1"; Invoke-Expression "& '$env:TEMP\ctrld_install.ps1'" ``` Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld) @@ -93,7 +93,7 @@ _/ ___\ __\_ __ \ | / __ | \/ dns forwarding proxy \/ Usage: - ctrld [command] + ctrld-client [command] Available Commands: run Run the DNS proxy server @@ -109,12 +109,12 @@ Available Commands: log Manage runtime debug logs Flags: - -h, --help help for ctrld + -h, --help help for ctrld-client -s, --silent do not write any log output -v, --verbose count verbose log output, "-v" basic logging, "-vv" debug logging - --version version for ctrld + --version version for ctrld-client -Use "ctrld [command] --help" for more information about a command. +Use "ctrld-client [command] --help" for more information about a command. ``` ## Basic Run Mode @@ -124,12 +124,12 @@ This is the most basic way to run `ctrld`, in foreground mode. Unless you alread Windows (Admin Shell) ```shell - ctrld.exe run + ctrld-client.exe run ``` Linux or Macos ```shell - sudo ctrld run + sudo ctrld-client run ``` You can then run a test query using a DNS client, for example, `dig`: @@ -148,17 +148,17 @@ This mode will run the application as a background system service on any Windows Windows (Admin Shell) ```shell - ctrld.exe start + ctrld-client.exe start ``` Linux or Macos ``` - sudo ctrld start + sudo ctrld-client start ``` -If `ctrld` is not in your system path (you installed it manually), you will need to run the above commands from the directory where you installed `ctrld`. +If `ctrld-client` is not in your system path (you installed it manually), you will need to run the above commands from the directory where you installed it. -In order to stop the service, and restore your DNS to original state, simply run `ctrld stop`. If you wish to stop and uninstall the service permanently, run `ctrld uninstall`. +In order to stop the service, and restore your DNS to original state, simply run `ctrld-client stop`. If you wish to stop and uninstall the service permanently, run `ctrld-client uninstall`. ## Unmanaged Service Mode This mode functions similarly to the "Service Mode" above except it will simply start a system service and the config defined listeners, but **will not make any changes to any network interfaces**. You can then set the `ctrld` listener(s) IP on the desired network interfaces manually. @@ -167,12 +167,12 @@ This mode functions similarly to the "Service Mode" above except it will simply Windows (Admin Shell) ```shell - ctrld.exe service start + ctrld-client.exe service start ``` Linux or Macos ```shell - sudo ctrld service start + sudo ctrld-client service start ``` ## Configuration @@ -185,12 +185,12 @@ The following command will use your own personal Control D Device resolver, and Windows (Admin Shell) ```shell -ctrld.exe start --cd abcd1234 +ctrld-client.exe start --cd abcd1234 ``` Linux or Macos ```shell -sudo ctrld start --cd abcd1234 +sudo ctrld-client start --cd abcd1234 ``` Once you run the above command, the following things will happen: @@ -236,7 +236,7 @@ If you're unable to use a config file, `ctrld` can be be supplied with basic con ### Example ``` -ctrld run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=10.0.10.1:53 --domains=*.company.int,very-secure.local --log /path/to/log.log +ctrld-client run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=10.0.10.1:53 --domains=*.company.int,very-secure.local --log /path/to/log.log ``` The above will start a foreground process and: @@ -260,12 +260,12 @@ Enable DNS Intercept Mode if you: Windows (Admin Shell) ```shell -ctrld.exe start --intercept-mode dns --cd RESOLVER_ID_HERE +ctrld-client.exe start --intercept-mode dns --cd RESOLVER_ID_HERE ``` macOS ```shell -sudo ctrld start --intercept-mode dns --cd RESOLVER_ID_HERE +sudo ctrld-client start --intercept-mode dns --cd RESOLVER_ID_HERE ``` `--intercept-mode dns` automatically detects VPN internal domains and routes them to the VPN's DNS server, while Control D handles everything else. @@ -274,12 +274,12 @@ To disable intercept mode on a service that already has it enabled: Windows (Admin Shell) ```shell -ctrld.exe start --intercept-mode off +ctrld-client.exe start --intercept-mode off ``` macOS ```shell -sudo ctrld start --intercept-mode off +sudo ctrld-client start --intercept-mode off ``` This removes the intercept rules and reverts to standard interface-based DNS configuration. diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 304156c..f137737 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -77,6 +77,16 @@ func isNoConfigStart(cmd *cobra.Command) bool { return false } +// cliName is this client's identity: the command name Cobra prints in help and +// in "--version" output, the file the release pipeline publishes +// (scripts/build.sh executable_name), and therefore the file the self-upgrader +// downloads. +// +// Referenced rather than repeated because "--version" output is parsed as well as +// printed: binaryVersion builds its expected prefix from this, and a literal on +// either side would let a rename break rollback silently. +const cliName = "ctrld-client" + const rootShortDesc = ` __ .__ .___ _____/ |________| | __| _/ @@ -96,7 +106,7 @@ func curVersion() string { // Return version directly if it's not empty and not a dev build // This avoids unnecessary commit hash concatenation for release versions if version != "" && version != "dev" { - return version + return displayVersion(version) } // Truncate commit hash to 7 characters for readability // Git commit hashes are typically 40 characters, but 7 is sufficient for identification @@ -106,6 +116,28 @@ func curVersion() string { return fmt.Sprintf("%s-%s", version, commit) } +// displayVersion converts the build tag into the version the client reports. +// +// master carries v2.x.x tags so its releases can be tracked alongside the v1.x.x +// line still cut from the v1.0 branch, but the client presents itself as v1.x.x: +// a v2.0.0 tag reads as v1.0.0, v2.3.1 as v1.3.1. Only master ever carries a +// major >= 2, so the split tag spaces scope this to master by themselves and the +// binary needs no build-time signal for which branch produced it - the v1.0 +// branch's tags have major 1 and pass through untouched, as does anything that is +// not a semantic version ("dev", commit-suffixed builds). +// +// Minor, patch, prerelease and build metadata are preserved, so a v2.1.0-rc1 tag +// stays a release candidate at v1.1.0-rc1 and isStableVersion still classifies it +// the same way. +func displayVersion(v string) string { + sv, err := semver.NewVersion(v) + if err != nil || sv.Major() < 2 { + return v + } + shifted := semver.New(sv.Major()-1, sv.Minor(), sv.Patch(), sv.Prerelease(), sv.Metadata()) + return "v" + shifted.String() +} + func initCLI() *cobra.Command { // Enable opening via explorer.exe on Windows. // See: https://github.com/spf13/cobra/issues/844. @@ -113,7 +145,7 @@ func initCLI() *cobra.Command { cobra.EnableCommandSorting = false rootCmd := &cobra.Command{ - Use: "ctrld", + Use: cliName, Short: strings.TrimLeft(rootShortDesc, "\n"), Version: appVersion, PersistentPreRun: func(cmd *cobra.Command, args []string) { @@ -2233,16 +2265,20 @@ func goArm() string { return "5" } -// upgradeUrl returns the url for downloading new ctrld binary. +// upgradeUrl builds the download URL for this platform's binary. +// +// The path carries no version segment: the v1 line publishes as "ctrld" and this +// one as "ctrld-client", so the file name distinguishes them and they can share +// one path. func upgradeUrl(baseUrl string) string { - dlPath := fmt.Sprintf("v2/%s-%s/ctrld", runtime.GOOS, runtime.GOARCH) + dlPath := fmt.Sprintf("%s-%s/%s", runtime.GOOS, runtime.GOARCH, cliName) // Use arm version set during build time, v5 binary can be run on higher arm version system. if armVersion := goArm(); armVersion != "" { - dlPath = fmt.Sprintf("%s-%sv%s/ctrld", runtime.GOOS, runtime.GOARCH, armVersion) + dlPath = fmt.Sprintf("%s-%sv%s/%s", runtime.GOOS, runtime.GOARCH, armVersion, cliName) } // linux/amd64 has nocgo version, to support systems that missing some libc (like openwrt). if !cgoEnabled && runtime.GOOS == "linux" && runtime.GOARCH == "amd64" { - dlPath = fmt.Sprintf("%s-%s-nocgo/ctrld", runtime.GOOS, runtime.GOARCH) + dlPath = fmt.Sprintf("%s-%s-nocgo/%s", runtime.GOOS, runtime.GOARCH, cliName) } dlUrl := fmt.Sprintf("%s/%s", baseUrl, dlPath) if runtime.GOOS == "windows" { diff --git a/cmd/cli/cli_test.go b/cmd/cli/cli_test.go index eae2673..94f5cc9 100644 --- a/cmd/cli/cli_test.go +++ b/cmd/cli/cli_test.go @@ -44,3 +44,55 @@ func Test_isStableVersion(t *testing.T) { }) } } + +// Test_displayVersion pins the tag-to-display transform. master carries v2.x.x +// tags so its releases can be tracked next to the v1.x.x line still cut from the +// v1.0 branch, and the client reports the tag with its major decremented. +// +// The v1.0-branch cases are the ones that make the split safe without a +// build-time branch signal: a major of 1 has to pass through untouched, or the +// v1.0 line would start reporting v0.x.x. +func Test_displayVersion(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"master tag", "v2.0.0", "v1.0.0"}, + {"master tag with minor and patch", "v2.3.1", "v1.3.1"}, + {"master prerelease keeps its suffix", "v2.1.0-rc1", "v1.1.0-rc1"}, + {"master tag with build metadata", "v2.1.0+build.5", "v1.1.0+build.5"}, + {"a later major still decrements by one", "v3.2.1", "v2.2.1"}, + // v1.0 branch: untouched, which is what scopes the transform to master. + {"v1.0 branch tag", "v1.3.5", "v1.3.5"}, + {"v1.0 branch prerelease", "v1.3.5-next", "v1.3.5-next"}, + // Not semantic versions: dev and commit-suffixed builds pass through. + {"dev", "dev", "dev"}, + {"dev with commit", "dev-abc1234", "dev-abc1234"}, + {"empty", "", ""}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := displayVersion(tc.in); got != tc.want { + t.Errorf("displayVersion(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// Test_displayVersionKeepsStabilityClassification guards the coupling between the +// transform and isStableVersion, which selects the self-upgrade channel: a tag +// must not change from prerelease to stable (or back) by being renumbered. +func Test_displayVersionKeepsStabilityClassification(t *testing.T) { + for _, ver := range []string{"v2.0.0", "v2.1.0-rc1", "v1.3.5", "v1.3.5-next", "dev"} { + ver := ver + t.Run(ver, func(t *testing.T) { + t.Parallel() + if got, want := isStableVersion(displayVersion(ver)), isStableVersion(ver); got != want { + t.Errorf("isStableVersion(displayVersion(%q)) = %v, want %v", ver, got, want) + } + }) + } +} diff --git a/cmd/cli/commands_service.go b/cmd/cli/commands_service.go index 212961e..deabc66 100644 --- a/cmd/cli/commands_service.go +++ b/cmd/cli/commands_service.go @@ -70,7 +70,7 @@ func NewServiceCommand() *ServiceCommand { func (sc *ServiceCommand) createServiceConfig() *service.Config { return &service.Config{ Name: ctrldServiceName, - DisplayName: "Control-D Helper Service", + DisplayName: ctrldServiceDisplayName, Description: "A highly configurable, multi-protocol DNS forwarding proxy", Option: service.KeyValue{}, } diff --git a/cmd/cli/commands_service_manager.go b/cmd/cli/commands_service_manager.go index 2b35e8e..ffdbca3 100644 --- a/cmd/cli/commands_service_manager.go +++ b/cmd/cli/commands_service_manager.go @@ -23,7 +23,7 @@ func NewServiceManager() (*ServiceManager, error) { // Create a proper service configuration svcConfig := &service.Config{ Name: ctrldServiceName, - DisplayName: "Control-D Helper Service", + DisplayName: ctrldServiceDisplayName, Description: "A highly configurable, multi-protocol DNS forwarding proxy", Option: service.KeyValue{}, } diff --git a/cmd/cli/commands_test.go b/cmd/cli/commands_test.go index 98ac760..91a6c1c 100644 --- a/cmd/cli/commands_test.go +++ b/cmd/cli/commands_test.go @@ -15,7 +15,7 @@ func TestBasicCommandStructure(t *testing.T) { rootCmd := initCLI() // Test that root command has basic properties - assert.Equal(t, "ctrld", rootCmd.Use) + assert.Equal(t, "ctrld-client", rootCmd.Use) assert.NotEmpty(t, rootCmd.Short, "Root command should have a short description") // Test that root command has subcommands @@ -46,14 +46,19 @@ func TestServiceCommandCreation(t *testing.T) { config := sc.createServiceConfig() require.NotNil(t, config, "Service config should be created") assert.Equal(t, ctrldServiceName, config.Name) - assert.Equal(t, "Control-D Helper Service", config.DisplayName) + assert.Equal(t, ctrldServiceDisplayName, config.DisplayName) + // Windows requires service display names to be unique and rejects a second + // registration with ERROR_DUPLICATE_SERVICE_NAME. Reusing the v1 service's + // display name would make "ctrld-client start" unable to install on hosts + // that still have v1 installed, so pin that they stay distinct (#565). + assert.NotEqual(t, "Control-D Helper Service", config.DisplayName) assert.Equal(t, "A highly configurable, multi-protocol DNS forwarding proxy", config.Description) } // TestServiceCommandSubCommands tests service command sub commands func TestServiceCommandSubCommands(t *testing.T) { rootCmd := &cobra.Command{ - Use: "ctrld", + Use: "ctrld-client", Short: "DNS forwarding proxy", } diff --git a/cmd/cli/commands_upgrade.go b/cmd/cli/commands_upgrade.go index 09ff39a..19171df 100644 --- a/cmd/cli/commands_upgrade.go +++ b/cmd/cli/commands_upgrade.go @@ -329,11 +329,11 @@ var binaryVersionFn = binaryVersion // answers "can this binary actually run on this host", which is what rollback needs // to know before making a file the installed ctrld. // -// On Windows path is ctrld.exe_previous, whose extension is not in PATHEXT. That +// On Windows path is ctrld-client.exe_previous, whose extension is not in PATHEXT. That // resolves because os/exec only falls back to appending PATHEXT entries when the path // has no extension at all (lp_windows.go findExecutable): with one present and the // file on disk, it is used as-is. A suffix that left no extension - renaming -// oldBinSuffix such that the result is "ctrld_previous" - would break this probe with +// oldBinSuffix such that the result is "ctrld-client_previous" - would break this probe with // "executable file not found in %PATH%", and rollback would then refuse to restore a // perfectly good binary. func binaryVersion(path string) (string, error) { @@ -343,13 +343,32 @@ func binaryVersion(path string) (string, error) { if err != nil { return "", fmt.Errorf("running %s --version: %w", path, err) } - ver, found := strings.CutPrefix(strings.TrimSpace(string(out)), "ctrld version ") - if !found { + ver, ok := parseVersionOutput(string(out)) + if !ok { return "", fmt.Errorf("unexpected --version output from %s: %q", path, strings.TrimSpace(string(out))) } return ver, nil } +// parseVersionOutput extracts the version from a binary's "--version" output. +// +// The expected prefix is built from cliName, which is also what the root command +// is named, because Cobra renders "--version" as " version " from +// that same name. Spelling the prefix out here instead would make a rename of the +// client silently break this parser - and with it rollback, which refuses to +// restore a previous binary whose version it cannot read. That failure mode is +// the worst one this code has: the service is already stopped, so a wrongly +// rejected previous binary leaves the host with no ctrld enforcement at all. +func parseVersionOutput(out string) (string, bool) { + // Not CutPrefix's own return: on a miss it hands back the whole input, which a + // caller that forgot to check the bool would store as if it were a version. + ver, ok := strings.CutPrefix(strings.TrimSpace(out), cliName+" version ") + if !ok { + return "", false + } + return ver, true +} + // InitUpgradeCmd creates the upgrade command with proper logic func InitUpgradeCmd(rootCmd *cobra.Command) *cobra.Command { upgradeCmd := &cobra.Command{ diff --git a/cmd/cli/commands_upgrade_test.go b/cmd/cli/commands_upgrade_test.go index b94ef78..3b46db6 100644 --- a/cmd/cli/commands_upgrade_test.go +++ b/cmd/cli/commands_upgrade_test.go @@ -1,9 +1,11 @@ package cli import ( + "bytes" "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -137,7 +139,7 @@ func TestRemoveBinaryWithRetry(t *testing.T) { func TestBinaryVersion(t *testing.T) { t.Run("reports the version", func(t *testing.T) { - t.Setenv(envFakeVersionOutput, "ctrld version dev-94fbd3f") + t.Setenv(envFakeVersionOutput, cliName+" version dev-94fbd3f") got, err := binaryVersion(os.Args[0]) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -287,3 +289,77 @@ func TestRollbackToPreviousBinaryAbortsWhenStopFails(t *testing.T) { t.Errorf("binary was modified even though the stop failed: %v", err) } } + +// TestVersionOutputParsesThroughRollbackProbe ties the "--version" output the root +// command actually produces to the parser rollback reads it with. +// +// These are two halves of one contract that live in different files: Cobra renders +// " version ", and binaryVersion cuts a prefix off it. Renaming the +// client moved the first half; if the second half had kept its literal, every +// upgrade would have logged "unknown version" and - the part that matters - +// rollbackToPreviousBinary would have judged a perfectly good previous binary +// "not usable" and left the host stopped with the broken one installed. +// +// The version output is taken from the real root command rather than assembled +// here, so a future change to the name, the template, or the parser has to keep +// them agreeing. +func TestVersionOutputParsesThroughRollbackProbe(t *testing.T) { + rootCmd := initCLI() + rootCmd.SetVersionTemplate(rootCmd.VersionTemplate()) + + var out bytes.Buffer + rootCmd.SetOut(&out) + rootCmd.SetErr(&out) + rootCmd.SetArgs([]string{"--version"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("running --version: %v", err) + } + + got := out.String() + if strings.TrimSpace(got) == "" { + t.Fatal("--version produced no output") + } + ver, ok := parseVersionOutput(got) + if !ok { + t.Fatalf("the version probe cannot parse the root command's own --version output %q; "+ + "rollback would reject a working previous binary as unusable", strings.TrimSpace(got)) + } + if ver != appVersion { + t.Errorf("parsed version = %q, want %q", ver, appVersion) + } +} + +// TestParseVersionOutput covers the shapes the probe must accept and reject. The +// rejected ones are what a genuinely broken previous binary produces - the +// incident's ctrld.exe_previous printed nothing at all - and rollback depends on +// telling those apart from a healthy binary under a new name. +func TestParseVersionOutput(t *testing.T) { + tests := []struct { + name string + out string + want string + ok bool + }{ + {"current identity", cliName + " version v1.0.0", "v1.0.0", true}, + {"trailing newline", cliName + " version v1.0.0\n", "v1.0.0", true}, + {"dev build", cliName + " version dev-94fbd3f", "dev-94fbd3f", true}, + // The pre-rename identity: a v1-line binary is not a valid rollback target + // for this client, and must not be read as one. + {"previous identity", "ctrld version v1.3.5", "", false}, + {"no output", "", "", false}, + {"unrelated output", "some other program", "", false}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := parseVersionOutput(tc.out) + if ok != tc.ok { + t.Fatalf("parseVersionOutput(%q) ok = %v, want %v", tc.out, ok, tc.ok) + } + if got != tc.want { + t.Errorf("parseVersionOutput(%q) = %q, want %q", tc.out, got, tc.want) + } + }) + } +} diff --git a/cmd/cli/library_retry_test.go b/cmd/cli/library_retry_test.go index 9252944..e2f0678 100644 --- a/cmd/cli/library_retry_test.go +++ b/cmd/cli/library_retry_test.go @@ -37,7 +37,7 @@ func (rt *denyingRoundTripper) RoundTrip(req *http.Request) (*http.Response, err func TestDoWithRetryPreservesHostnameError(t *testing.T) { const hostname = "dl.controld.dev" - req, err := http.NewRequest(http.MethodGet, "https://"+hostname+"/v2/windows-amd64/ctrld.exe", nil) + req, err := http.NewRequest(http.MethodGet, "https://"+hostname+"/windows-amd64/ctrld-client.exe", nil) if err != nil { t.Fatal(err) } @@ -219,7 +219,7 @@ func TestDoWithFallbackClassificationEndToEnd(t *testing.T) { // and not only in the hand-built shape. func TestDoWithRetryComposesHostnameAttemptFirst(t *testing.T) { const hostname = "dl.controld.dev" - req, err := http.NewRequest(http.MethodGet, "https://"+hostname+"/v2/windows-amd64/ctrld.exe", nil) + req, err := http.NewRequest(http.MethodGet, "https://"+hostname+"/windows-amd64/ctrld-client.exe", nil) if err != nil { t.Fatal(err) } diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index 63a5a7f..cd589f0 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -48,7 +48,23 @@ const ( upstreamOS = upstreamPrefix + "os" upstreamOSLocal = upstreamOS + ".local" dnsWatchdogDefaultInterval = 20 * time.Second - ctrldServiceName = "ctrld" + ctrldServiceName = "ctrld-client" + // ctrldServiceDisplayName must differ from the v1 service's display name + // ("Control-D Helper Service"). Windows requires display names to be unique + // across all installed services and fails registration with + // ERROR_DUPLICATE_SERVICE_NAME otherwise, so reusing v1's name would make + // "ctrld-client start" unable to install on any host that still has the v1 + // service. It moves with ctrldServiceName: both identify this service. + ctrldServiceDisplayName = "Control-D Client Service" +) + +// Service-manager paths derived from ctrldServiceName. Every init system names +// its unit after the service identifier, so these must move with it: a rename +// that missed one would leave ctrld managing a unit it no longer installs. +const ( + systemdUnitFile = "/etc/systemd/system/" + ctrldServiceName + ".service" + sysVInitScript = "/etc/init.d/" + ctrldServiceName + launchdPlistFile = "/Library/LaunchDaemons/" + ctrldServiceName + ".plist" ) // RecoveryReason provides context for why we are waiting for recovery. diff --git a/cmd/cli/service.go b/cmd/cli/service.go index 46353e9..7fd47e6 100644 --- a/cmd/cli/service.go +++ b/cmd/cli/service.go @@ -41,7 +41,7 @@ type sysV struct { } func (s *sysV) installed() bool { - fi, err := os.Stat("/etc/init.d/ctrld") + fi, err := os.Stat(sysVInitScript) if err != nil { return false } @@ -53,7 +53,7 @@ func (s *sysV) Start() error { if !s.installed() { return service.ErrNotInstalled } - _, err := exec.Command("/etc/init.d/ctrld", "start").CombinedOutput() + _, err := exec.Command(sysVInitScript, "start").CombinedOutput() return err } @@ -61,7 +61,7 @@ func (s *sysV) Stop() error { if !s.installed() { return service.ErrNotInstalled } - _, err := exec.Command("/etc/init.d/ctrld", "stop").CombinedOutput() + _, err := exec.Command(sysVInitScript, "stop").CombinedOutput() return err } @@ -89,7 +89,7 @@ type systemd struct { } func (s *systemd) Status() (service.Status, error) { - out, _ := exec.Command("systemctl", "status", "ctrld").CombinedOutput() + out, _ := exec.Command("systemctl", "status", ctrldServiceName).CombinedOutput() if bytes.Contains(out, []byte("/FAILURE)")) { return service.StatusStopped, nil } @@ -97,7 +97,6 @@ func (s *systemd) Status() (service.Status, error) { } func (s *systemd) Start() error { - const systemdUnitFile = "/etc/systemd/system/ctrld.service" f, err := os.Open(systemdUnitFile) if err != nil { return err @@ -223,7 +222,7 @@ func checkHasElevatedPrivilege() { // unixSystemVServiceStatus checks the status of a Unix System V service func unixSystemVServiceStatus() (service.Status, error) { - out, err := exec.Command("/etc/init.d/ctrld", "status").CombinedOutput() + out, err := exec.Command(sysVInitScript, "status").CombinedOutput() if err != nil { return service.StatusUnknown, nil } diff --git a/cmd/cli/service_args_darwin.go b/cmd/cli/service_args_darwin.go index 5bc1823..fd3095c 100644 --- a/cmd/cli/service_args_darwin.go +++ b/cmd/cli/service_args_darwin.go @@ -9,7 +9,7 @@ import ( "strings" ) -const launchdPlistPath = "/Library/LaunchDaemons/ctrld.plist" +const launchdPlistPath = launchdPlistFile // serviceConfigFileExists returns true if the launchd plist for ctrld exists on disk. // This is more reliable than checking launchctl status, which may report "not found" @@ -23,7 +23,7 @@ func serviceConfigFileExists() bool { // service's launch arguments. This is used when upgrading an existing installation // 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 named after ctrldServiceName // using PlistBuddy for exact array reads and writes. // // The function is idempotent: if the flag already exists, it's a no-op. diff --git a/cmd/cli/service_args_others.go b/cmd/cli/service_args_others.go index b6eb688..e4a35f7 100644 --- a/cmd/cli/service_args_others.go +++ b/cmd/cli/service_args_others.go @@ -14,11 +14,11 @@ var errServiceFlagsUnsupported = errors.New("modifying service flags is not supp // serviceConfigFileExists checks common service config file locations on Linux. func serviceConfigFileExists() bool { // systemd unit file - if _, err := os.Stat("/etc/systemd/system/ctrld.service"); err == nil { + if _, err := os.Stat(systemdUnitFile); err == nil { return true } // SysV init script - if _, err := os.Stat("/etc/init.d/ctrld"); err == nil { + if _, err := os.Stat(sysVInitScript); err == nil { return true } return false diff --git a/cmd/cli/service_image_path.go b/cmd/cli/service_image_path.go index 6b2fc8c..406ce57 100644 --- a/cmd/cli/service_image_path.go +++ b/cmd/cli/service_image_path.go @@ -6,7 +6,7 @@ import "strings" // ImagePath value, which carries the command line rather than a bare path: it may be // quoted and is usually followed by arguments, e.g. // -// "C:\Program Files\Control D\ctrld.exe" run --config C:\...\ctrld.toml +// "C:\Program Files\Control D\ctrld-client.exe" run --config C:\...\ctrld.toml // // It returns "" when no path can be read, which callers must treat as "cannot tell" // rather than "does not match". diff --git a/cmd/cli/service_image_path_windows.go b/cmd/cli/service_image_path_windows.go index f7c6ef8..a03456c 100644 --- a/cmd/cli/service_image_path_windows.go +++ b/cmd/cli/service_image_path_windows.go @@ -11,7 +11,7 @@ import ( // installedServiceDirMatches reports whether this executable is the installed service // binary, by comparing its directory with the one in the service's registered ImagePath. // -// socketDir() on Windows is relative to the running executable, so a ctrld.exe run from +// socketDir() on Windows is relative to the running executable, so a ctrld-client.exe run from // somewhere else - a download directory, a build tree - looks for the control socket in // its own directory and never finds the installed daemon's. A failed probe from there // says nothing about the service's health, and reporting "not ready" for it would tell diff --git a/docker/Dockerfile b/docker/Dockerfile index dd25aeb..4535e8d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -5,9 +5,9 @@ # - Timezone info file. # - CA certs file. # - /etc/{passwd,group} file. -# - Non-cgo ctrld binary. +# - Non-cgo ctrld-client binary. # -# CI_COMMIT_TAG is used to set the version of ctrld binary. +# CI_COMMIT_TAG is used to set the version of the ctrld-client binary. FROM golang:1.25-bookworm AS base WORKDIR /app @@ -21,6 +21,15 @@ ARG tag=master ENV CI_COMMIT_TAG=$tag RUN CTRLD_NO_QF=yes CGO_ENABLED=0 ./scripts/build.sh +# Resolve the arch-suffixed build artifact to one fixed path, and fail the build +# if it is not there. A COPY glob that matches nothing does not reliably fail, +# which is how a rename can produce an image whose ENTRYPOINT names a binary the +# image does not contain. +RUN set -eu; \ + set -- ctrld-client-linux-*-nocgo; \ + [ "$#" -eq 1 ] && [ -x "$1" ] || { echo >&2 "no single executable build artifact: $*"; exit 1; }; \ + mv -- "$1" /ctrld-client + FROM scratch COPY --from=base /usr/share/zoneinfo /usr/share/zoneinfo @@ -28,6 +37,6 @@ COPY --from=base /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=base /etc/passwd /etc/passwd COPY --from=base /etc/group /etc/group -COPY --from=base /app/ctrld-linux-*-nocgo ctrld +COPY --from=base /ctrld-client ctrld-client -ENTRYPOINT ["./ctrld", "run"] +ENTRYPOINT ["./ctrld-client", "run"] diff --git a/docker/Dockerfile.debug b/docker/Dockerfile.debug index d5f053e..4369c78 100644 --- a/docker/Dockerfile.debug +++ b/docker/Dockerfile.debug @@ -5,9 +5,9 @@ # - Timezone info file. # - CA certs file. # - /etc/{passwd,group} file. -# - Non-cgo ctrld binary. +# - Non-cgo ctrld-client binary. # -# CI_COMMIT_TAG is used to set the version of ctrld binary. +# CI_COMMIT_TAG is used to set the version of the ctrld-client binary. FROM golang:1.25-bookworm AS base WORKDIR /app @@ -21,6 +21,15 @@ ARG tag=master ENV CI_COMMIT_TAG=$tag RUN CTRLD_NO_QF=yes CGO_ENABLED=0 ./scripts/build.sh +# Resolve the arch-suffixed build artifact to one fixed path, and fail the build +# if it is not there. A COPY glob that matches nothing does not reliably fail, +# which is how a rename can produce an image whose ENTRYPOINT names a binary the +# image does not contain. +RUN set -eu; \ + set -- ctrld-client-linux-*-nocgo; \ + [ "$#" -eq 1 ] && [ -x "$1" ] || { echo >&2 "no single executable build artifact: $*"; exit 1; }; \ + mv -- "$1" /ctrld-client + FROM alpine COPY --from=base /usr/share/zoneinfo /usr/share/zoneinfo @@ -28,6 +37,6 @@ COPY --from=base /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=base /etc/passwd /etc/passwd COPY --from=base /etc/group /etc/group -COPY --from=base /app/ctrld-linux-*-nocgo ctrld +COPY --from=base /ctrld-client ctrld-client -ENTRYPOINT ["./ctrld", "run"] +ENTRYPOINT ["./ctrld-client", "run"] diff --git a/docs/config.md b/docs/config.md index a8d976e..c196a73 100644 --- a/docs/config.md +++ b/docs/config.md @@ -23,7 +23,7 @@ The config file allows for advanced configuration of the `ctrld` utility to cove The user can choose to override default value using command line `--config` or `-c`: ```shell -ctrld run --config /path/to/myconfig.toml +ctrld-client run --config /path/to/myconfig.toml ``` If no configuration files found, a default `ctrld.toml` file will be created in the current directory. diff --git a/docs/dns-intercept-mode.md b/docs/dns-intercept-mode.md index acc8055..0545b85 100644 --- a/docs/dns-intercept-mode.md +++ b/docs/dns-intercept-mode.md @@ -26,17 +26,17 @@ DNS intercept mode works at a lower level than interface settings: ```bash # Start ctrld with DNS intercept mode (auto-detects VPN search domains) -ctrld start --intercept-mode dns --cd +ctrld-client start --intercept-mode dns --cd # Hard intercept: all DNS through ctrld, no VPN split routing -ctrld start --intercept-mode hard --cd +ctrld-client start --intercept-mode hard --cd # Or with a config file -ctrld start --intercept-mode dns -c /path/to/ctrld.toml +ctrld-client start --intercept-mode dns -c /path/to/ctrld.toml # Run in foreground (debug) -ctrld run --intercept-mode dns --cd -ctrld run --intercept-mode hard --cd +ctrld-client run --intercept-mode dns --cd +ctrld-client run --intercept-mode hard --cd ``` ### Intercept Modes diff --git a/docs/ephemeral_mode.md b/docs/ephemeral_mode.md index b0af572..154b9f9 100644 --- a/docs/ephemeral_mode.md +++ b/docs/ephemeral_mode.md @@ -3,11 +3,11 @@ ## Launch arguments ```shell -$ ctrld run --help +$ ctrld-client run --help Run the DNS proxy server Usage: - ctrld run [flags] + ctrld-client run [flags] Flags: --base64_config string base64 encoded config @@ -29,7 +29,7 @@ Global Flags: For example: ```shell -ctrld run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=8.8.8.8:53 --domains=*.company.int,*.net --log /path/to/log.log +ctrld-client run --listen=127.0.0.1:53 --primary_upstream=https://freedns.controld.com/p2 --secondary_upstream=8.8.8.8:53 --domains=*.company.int,*.net --log /path/to/log.log ``` Above command will be translated roughly to this config: @@ -70,5 +70,5 @@ Only `listen` and `primary_upstream` flags are required. `ctrld` can read a complete base64 encoded config via command line flag. This allows you to supply complex configurations. ```shell -ctrld run --base64_config="CltsaXN0ZW5lcl0KCiAgW2xpc3RlbmVyLjBdCiAgICBpcCA9ICIxMjcuMC4wLjEiCiAgICBwb3J0ID0gNTMKICAgIHJlc3RyaWN0ZWQgPSBmYWxzZQoKW25ldHdvcmtdCgogIFtuZXR3b3JrLjBdCiAgICBjaWRycyA9IFsiMC4wLjAuMC8wIl0KICAgIG5hbWUgPSAiTmV0d29yayAwIgoKW3Vwc3RyZWFtXQoKICBbdXBzdHJlYW0uMF0KICAgIGJvb3RzdHJhcF9pcCA9ICI3Ni43Ni4yLjExIgogICAgZW5kcG9pbnQgPSAiaHR0cHM6Ly9mcmVlZG5zLmNvbnRyb2xkLmNvbS9wMSIKICAgIG5hbWUgPSAiQ29udHJvbCBEIC0gQW50aS1NYWx3YXJlIgogICAgdGltZW91dCA9IDUwMDAKICAgIHR5cGUgPSAiZG9oIgoKICBbdXBzdHJlYW0uMV0KICAgIGJvb3RzdHJhcF9pcCA9ICI3Ni43Ni4yLjExIgogICAgZW5kcG9pbnQgPSAicDIuZnJlZWRucy5jb250cm9sZC5jb20iCiAgICBuYW1lID0gIkNvbnRyb2wgRCAtIE5vIEFkcyIKICAgIHRpbWVvdXQgPSAzMDAwCiAgICB0eXBlID0gImRvcSIK" +ctrld-client run --base64_config="CltsaXN0ZW5lcl0KCiAgW2xpc3RlbmVyLjBdCiAgICBpcCA9ICIxMjcuMC4wLjEiCiAgICBwb3J0ID0gNTMKICAgIHJlc3RyaWN0ZWQgPSBmYWxzZQoKW25ldHdvcmtdCgogIFtuZXR3b3JrLjBdCiAgICBjaWRycyA9IFsiMC4wLjAuMC8wIl0KICAgIG5hbWUgPSAiTmV0d29yayAwIgoKW3Vwc3RyZWFtXQoKICBbdXBzdHJlYW0uMF0KICAgIGJvb3RzdHJhcF9pcCA9ICI3Ni43Ni4yLjExIgogICAgZW5kcG9pbnQgPSAiaHR0cHM6Ly9mcmVlZG5zLmNvbnRyb2xkLmNvbS9wMSIKICAgIG5hbWUgPSAiQ29udHJvbCBEIC0gQW50aS1NYWx3YXJlIgogICAgdGltZW91dCA9IDUwMDAKICAgIHR5cGUgPSAiZG9oIgoKICBbdXBzdHJlYW0uMV0KICAgIGJvb3RzdHJhcF9pcCA9ICI3Ni43Ni4yLjExIgogICAgZW5kcG9pbnQgPSAicDIuZnJlZWRucy5jb250cm9sZC5jb20iCiAgICBuYW1lID0gIkNvbnRyb2wgRCAtIE5vIEFkcyIKICAgIHRpbWVvdXQgPSAzMDAwCiAgICB0eXBlID0gImRvcSIK" ``` diff --git a/docs/firewall-mode.md b/docs/firewall-mode.md index 3423154..cfccdb5 100644 --- a/docs/firewall-mode.md +++ b/docs/firewall-mode.md @@ -34,7 +34,7 @@ IPs, direct-IP fallbacks, or alternative DNS resolvers to bypass DNS-based filte ### CLI Flag ```bash -ctrld start --firewall-mode on --intercept-mode hard +ctrld-client start --firewall-mode on --intercept-mode hard ``` ### Remote API diff --git a/docs/known-issues.md b/docs/known-issues.md index 314192a..4ce2f5f 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -14,7 +14,7 @@ This document outlines known issues with ctrld and their current status, workaro **Workarounds**: 1. **Recommended**: Upgrade your macOS system to Darwin 15.6 or later, which has been tested and verified to work correctly with ctrld self-upgrade functionality. -2. **Alternative**: Run `ctrld upgrade prod` directly to manually upgrade ctrld to the latest version on Darwin 15.5. +2. **Alternative**: Run `ctrld-client upgrade prod` directly to manually upgrade ctrld to the latest version on Darwin 15.5. **Affected Versions**: ctrld v1.4.2 and later on macOS Darwin 15.5 diff --git a/docs/pf-dns-intercept.md b/docs/pf-dns-intercept.md index cb25b67..540cdc1 100644 --- a/docs/pf-dns-intercept.md +++ b/docs/pf-dns-intercept.md @@ -217,7 +217,7 @@ Both anchors appended. This is a degenerate case that shouldn't occur in practic ### What happens if ctrld crashes (SIGKILL)? - pf anchor rules persist in kernel memory - DNS is redirected to 127.0.0.1:53 but nothing is listening → DNS breaks -- On next `ctrld start`, we detect the stale anchor file, flush the anchor, and start fresh +- On next `ctrld-client start`, we detect the stale anchor file, flush the anchor, and start fresh - Without ctrld restart: `sudo pfctl -a com.controld.ctrld -F all` manually clears it ### What if another program flushes all pf rules? diff --git a/docs/provisioning-failure-codes.md b/docs/provisioning-failure-codes.md index d391353..ac23056 100644 --- a/docs/provisioning-failure-codes.md +++ b/docs/provisioning-failure-codes.md @@ -16,7 +16,7 @@ stable code on three surfaces: risking token leakage from other output. - **Exit code** — stage-scoped: bootstrap 30–39, listener 40–49, service 50–59. Unrelated existing contracts are unchanged - (`ctrld status` exits 0–3; invalid deactivation pin exits 126). + (`ctrld-client status` exits 0–3; invalid deactivation pin exits 126). A customer or administrator only needs to report the code (or the whole output line). The table below is the maintained support mapping; it must @@ -31,9 +31,9 @@ stay in sync with `cmd/cli/provision_result.go` and changes in the same MR. | `API_DEVICE_INVALID` | bootstrap | 32 | The API reports the device/resolver no longer exists (error code 40402). ctrld self-uninstalls its service because the identity is gone server-side. | Confirm the device was deleted or re-provisioned in the dashboard; re-provision with a current token. No local evidence needed beyond the code. | | `LISTENER_BIND_FAILED` | listener | 41 | No listen address could be bound after all fallbacks (configured address, 0.0.0.0:53, localhost:53, port 5354, random) were exhausted. `detail.attempts` records each tried address with the UDP/TCP OS error, e.g. `address already in use` (another DNS service owns the port) or `can't assign requested address` (address not on any interface). | Read `detail.attempts`: `address already in use` → find the process owning the port (`sudo lsof -i :53 -nP`); `can't assign requested address` → the configured IP is not present on the device. Then fix the conflict or the listener config. | | `LISTENER_CONFIGURED_ADDR_UNAVAILABLE` | listener | 42 | An explicitly configured listener address could not be bound and configuration checks forbid falling back to another address, or (macOS intercept mode) the required explicit address is unavailable. | The configured `ip:port` in the listener config is wrong for this device or occupied. Verify the address exists on an interface and nothing else binds it; correct the config rather than expecting fallback. | -| `SERVICE_INSTALL_FAILED` | service | 51 | The OS service manager refused to install the service (launchd/systemd/SCM registration failed). | Check OS-level constraints: permissions/elevation, MDM policy blocking daemon installation, corrupted previous install. Evidence: result file `message` (service manager error), plus `launchctl print system/ctrld` / `systemctl status ctrld` / SCM state. | +| `SERVICE_INSTALL_FAILED` | service | 51 | The OS service manager refused to install the service (launchd/systemd/SCM registration failed). | Check OS-level constraints: permissions/elevation, MDM policy blocking daemon installation, corrupted previous install. Evidence: result file `message` (service manager error), plus `launchctl print system/ctrld-client` / `systemctl status ctrld-client` / SCM state. | | `SERVICE_START_FAILED` | service | 52 | The service installed but the service manager could not start it. | Check the service manager's own log for the start error, then the ctrld home dir `ctrld.log`. Often permissions or a binary quarantined by security tooling. | -| `SERVICE_SELFCHECK_FAILED` | service | 53 | The service started but never became healthy: no fresher failure was reported by the daemon, and the post-install DNS self-check failed. The just-installed service is rolled back (uninstalled). If the daemon itself recorded a more specific failure (e.g. a listener code), that code is reported instead of this one. | Ask for the drained service log printed by `ctrld start` and the result file. If the service was running but unreachable, check host firewall rules intercepting DNS to the listener. | +| `SERVICE_SELFCHECK_FAILED` | service | 53 | The service started but never became healthy: no fresher failure was reported by the daemon, and the post-install DNS self-check failed. The just-installed service is rolled back (uninstalled). If the daemon itself recorded a more specific failure (e.g. a listener code), that code is reported instead of this one. | Ask for the drained service log printed by `ctrld-client start` and the result file. If the service was running but unreachable, check host firewall rules intercepting DNS to the listener. | ## Reading the result file @@ -43,7 +43,7 @@ macOS and Linux (default service home is `/etc/controld`): sudo cat /etc/controld/provision_result.json ``` -On Windows the file sits next to `ctrld.exe` in the install directory. A +On Windows the file sits next to `ctrld-client.exe` in the install directory. A custom `homedir` config moves it accordingly; routers and mobile use their platform home directory. diff --git a/docs/username-detection.md b/docs/username-detection.md index 18cd77f..ad1db72 100644 --- a/docs/username-detection.md +++ b/docs/username-detection.md @@ -97,8 +97,8 @@ System metadata (OS, chassis, username, domain) is sent to the Control D API via | Scenario | Metadata sent? | Username included? | |---|---|---| -| `ctrld start` with `--cd-org` (provisioning via `cdUIDFromProvToken`) | ✅ Full | ✅ Yes | -| `ctrld run` startup (config validation / processCDFlags) | ✅ Lightweight | ❌ No | +| `ctrld-client start` with `--cd-org` (provisioning via `cdUIDFromProvToken`) | ✅ Full | ✅ Yes | +| `ctrld-client run` startup (config validation / processCDFlags) | ✅ Lightweight | ❌ No | | Runtime config reload (`doReloadApiConfig`) | ✅ Lightweight | ❌ No | | Runtime self-uninstall check | ✅ Lightweight | ❌ No | | Runtime deactivation pin refresh | ✅ Lightweight | ❌ No | diff --git a/docs/v2.0.0-breaking-changes.md b/docs/v2.0.0-breaking-changes.md index 30ac034..5de5ced 100644 --- a/docs/v2.0.0-breaking-changes.md +++ b/docs/v2.0.0-breaking-changes.md @@ -38,9 +38,9 @@ ctrld v1.x.x will continue to be supported for router and server platforms: If you're currently using ctrld v1.x.x on router or server platforms, you need to follow these steps to migrate to v2.0.0: -### Step 1: Downloading ctrld v2 binary +### Step 1: Downloading the ctrld-client binary -To download ctrld v2.0.0, follow these steps: +To download the new client, follow these steps: Stop the current ctrld service: @@ -54,24 +54,24 @@ Or uninstall the current version: ctrld uninstall ``` -Download the appropriate binary for your platform: https://dl.controld.com/v2/linux-amd64/ctrld +Download the appropriate binary for your platform: https://dl.controld.com/linux-amd64/ctrld-client > **Note**: Replace `amd64` with your platform architecture as needed. Verify that the binary was updated correctly: ```sh -ctrld --version +ctrld-client --version ``` Expected output: ``` -ctrld version v2.0.0 +ctrld-client version v1.0.0 ``` -### Step 2: Start ctrld without self-checking +### Step 2: Start ctrld-client without self-checking -You have two ways to start ctrld: +You have two ways to start ctrld-client: **Option A: Use Remote Configuration (Recommended)** 1. **Export your current configuration:** @@ -81,22 +81,22 @@ You have two ways to start ctrld: - Log into your Control D dashboard - Use the remote configuration feature to upload your configuration -3. **Start ctrld with remote config:** +3. **Start ctrld-client with remote config:** ```bash - sudo ctrld service start --cd= --skip_self_checks + sudo ctrld-client service start --cd= --skip_self_checks ``` -> **Note**: You must use `ctrld service start` to prevent DNS being set automatically by ctrld. +> **Note**: You must use `ctrld-client service start` to prevent DNS being set automatically by ctrld-client. **Option B: Use Local Configuration** ```bash -sudo ctrld service start --skip_self_checks +sudo ctrld-client service start --skip_self_checks ``` -### Step 3: Configure DNS Software to Use ctrld as Upstream +### Step 3: Configure DNS Software to Use ctrld-client as Upstream **For dnsmasq users:** -1. Configure dnsmasq to use ctrld as upstream: +1. Configure dnsmasq to use ctrld-client as upstream: ```bash # Add to dnsmasq.conf no-resolv @@ -118,7 +118,7 @@ sudo ctrld service start --skip_self_checks - Open DNS Manager - Right-click on your server name - Select "Properties" → "Forwarders" tab - - Add `` as a forwarder + - Add `` as a forwarder ## Getting Help diff --git a/docs/wfp-dns-intercept.md b/docs/wfp-dns-intercept.md index ada8225..745d7a2 100644 --- a/docs/wfp-dns-intercept.md +++ b/docs/wfp-dns-intercept.md @@ -452,7 +452,7 @@ nslookup example.com 127.0.0.1 gpupdate /target:computer /force # Verify service registration -sc qc ctrld +sc qc ctrld-client ``` ### Service Verification @@ -461,10 +461,10 @@ After install, verify the Windows service is correctly registered: ```powershell # Check binary path and start type -sc qc ctrld +sc qc ctrld-client # Should show: -# BINARY_PATH_NAME: "C:\...\ctrld.exe" run --cd xxxxx --intercept-mode dns +# BINARY_PATH_NAME: "C:\...\ctrld-client.exe" run --cd xxxxx --intercept-mode dns # START_TYPE: AUTO_START ``` diff --git a/scripts/build.sh b/scripts/build.sh index fa36598..aff8166 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,7 +1,7 @@ #!/bin/bash go=${GOBIN:-go} -executable_name="ctrld" +executable_name="ctrld-client" os_archs=( darwin/arm64 darwin/amd64 diff --git a/scripts/docker-smoke.sh b/scripts/docker-smoke.sh new file mode 100755 index 0000000..a5005bc --- /dev/null +++ b/scripts/docker-smoke.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Build a Docker image and prove the binary its ENTRYPOINT names is actually in it. +# +# The container path has no compile-time link to the build: the image copies an +# artifact whose name comes from scripts/build.sh and starts it by a name spelled +# out in the Dockerfile. Renaming the binary once left both Dockerfiles copying a +# glob that matched nothing and starting a file that did not exist - the image +# built clean and only failed at "docker run". Nothing in the test suite covers +# that, so this does. +# +# Usage: docker-smoke.sh [dockerfile ...] (default: both docker/Dockerfile*) + +set -euo pipefail + +dockerfiles=("$@") +if [ ${#dockerfiles[@]} -eq 0 ]; then + dockerfiles=(docker/Dockerfile docker/Dockerfile.debug) +fi + +repo_root="$(cd "$(dirname "$0")/.." && pwd)" +cd -- "$repo_root" + +status=0 +for dockerfile in "${dockerfiles[@]}"; do + tag="ctrld-client-smoke:$(basename "$dockerfile" | tr '[:upper:]' '[:lower:]')" + echo ">>> building $dockerfile as $tag" + docker build -q -t "$tag" -f "$dockerfile" . + + # Override the entrypoint rather than appending an argument to it: the image's + # ENTRYPOINT is " run", so a bare "docker run --version" asks the + # run subcommand for a version and starts the proxy instead of exiting. Naming + # the binary explicitly is also the stronger check - it fails if the file is + # absent or not executable, which is the regression this guards. + echo ">>> running $tag entrypoint binary with --version" + if ! out=$(docker run --rm --entrypoint ./ctrld-client "$tag" --version 2>&1); then + echo >&2 "FAIL: $dockerfile: could not run the image's entrypoint: $out" + status=1 + docker image rm -f "$tag" >/dev/null 2>&1 || : + continue + fi + + echo " $out" + case "$out" in + "ctrld-client version "*) echo " OK: $dockerfile" ;; + *) + echo >&2 "FAIL: $dockerfile: unexpected --version output: $out" + status=1 + ;; + esac + docker image rm -f "$tag" >/dev/null 2>&1 || : +done + +exit "$status" diff --git a/test-scripts/darwin/test-pkg-intercept-mode.sh b/test-scripts/darwin/test-pkg-intercept-mode.sh index 47ffd5e..8c3c73a 100755 --- a/test-scripts/darwin/test-pkg-intercept-mode.sh +++ b/test-scripts/darwin/test-pkg-intercept-mode.sh @@ -36,9 +36,9 @@ printf 'launchctl %s\n' "$*" >>"$CALLS" exit 0 EOF -cat >"$bin/ctrld" <<'EOF' +cat >"$bin/ctrld-client" <<'EOF' #!/bin/sh -printf 'ctrld %s\n' "$*" >>"$CALLS" +printf 'ctrld-client %s\n' "$*" >>"$CALLS" if [ "${FAKE_CTRLD_EXIT:-0}" = "0" ]; then case " $* " in *" --cd-org="*) : >"$FAKE_PLIST" ;; @@ -47,7 +47,7 @@ fi exit "${FAKE_CTRLD_EXIT:-0}" EOF -chmod +x "$bin/defaults" "$bin/launchctl" "$bin/ctrld" +chmod +x "$bin/defaults" "$bin/launchctl" "$bin/ctrld-client" assert_contains() { expected=$1 @@ -78,7 +78,7 @@ run_case() { expected_status=${6:-0} case_dir="$fixture/$name" mkdir -p "$case_dir" - plist="$case_dir/ctrld.plist" + plist="$case_dir/ctrld-client.plist" prefs="$case_dir/preferences" calls="$case_dir/calls" output="$case_dir/output" @@ -89,12 +89,30 @@ run_case() { fi sed \ - -e "s|^PLIST=\"/Library/LaunchDaemons/ctrld.plist\"$|PLIST=\"$plist\"|" \ - -e "s|^CTRLD=\"/usr/local/bin/ctrld\"$|CTRLD=\"$bin/ctrld\"|" \ + -e "s|^PLIST=\"/Library/LaunchDaemons/ctrld-client.plist\"$|PLIST=\"$plist\"|" \ + -e "s|^CTRLD=\"/usr/local/bin/ctrld-client\"$|CTRLD=\"$bin/ctrld-client\"|" \ -e "s|^PREFS=\"/Library/Managed Preferences/com.controld.ctrld\"$|PREFS=\"$prefs\"|" \ "$postinstall_source" >"$postinstall" chmod +x "$postinstall" + # A substitution that stops matching is the dangerous failure here, not a + # noisy one: the generated script would keep the real /usr/local/bin and + # /Library paths and the case would exercise the installed system instead of + # this fixture. Renaming the client once already did exactly that, so check + # that every redirection actually landed. + for expected in "PLIST=\"$plist\"" "CTRLD=\"$bin/ctrld-client\"" "PREFS=\"$prefs\""; do + if ! grep -Fq -- "$expected" "$postinstall"; then + printf 'FAIL: %s: fixture substitution did not apply (%s); postinstall paths changed?\n' \ + "$name" "$expected" >&2 + exit 1 + fi + done + if grep -Eq '^(PLIST|CTRLD|PREFS)="(/Library|/usr/local)' "$postinstall"; then + printf 'FAIL: %s: a real system path survived substitution\n' "$name" >&2 + grep -E '^(PLIST|CTRLD|PREFS)=' "$postinstall" >&2 + exit 1 + fi + status=0 PATH="$bin:$PATH" \ CALLS="$calls" \ @@ -118,26 +136,26 @@ run_case() { assert_not_contains 'CTRLD_POSTINSTALL_' "$postinstall_source" case_dir=$(run_case fresh-legacy 0 0 '') -assert_contains 'ctrld start --cd-org=test-token' "$case_dir/calls" +assert_contains 'ctrld-client 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_contains 'ctrld-client 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" +assert_contains 'ctrld-client 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" +assert_not_contains 'ctrld-client start' "$case_dir/calls" case_dir=$(run_case upgrade-standard 1 1 standard) -assert_contains 'ctrld start --intercept-mode off' "$case_dir/calls" +assert_contains 'ctrld-client 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_contains 'ctrld-client start --intercept-mode dns' "$case_dir/calls" assert_not_contains 'launchctl load' "$case_dir/calls" case_dir=$(run_case fresh-invalid 0 1 invalid) @@ -147,10 +165,10 @@ assert_not_contains '--intercept-mode' "$case_dir/calls" case_dir=$(run_case upgrade-invalid 1 1 invalid) assert_contains 'WARNING: unsupported InterceptMode in managed preferences; preserving existing service mode' "$case_dir/output" assert_contains 'launchctl load' "$case_dir/calls" -assert_not_contains 'ctrld start' "$case_dir/calls" +assert_not_contains 'ctrld-client start' "$case_dir/calls" case_dir=$(run_case upgrade-standard-failure 1 1 standard 1 1) -assert_contains 'ctrld start --intercept-mode off' "$case_dir/calls" +assert_contains 'ctrld-client start --intercept-mode off' "$case_dir/calls" assert_contains 'ERROR: upgrade installed but managed InterceptMode could not be applied' "$case_dir/output" assert_not_contains 'launchctl load' "$case_dir/calls"