Rename ctrld v2.0.0 to ctrld-client

Updates #565
This commit is contained in:
Cuong Manh Le
2026-09-03 13:57:38 +07:00
parent 80d1acdfd5
commit d8994cc7d8
30 changed files with 403 additions and 111 deletions
+21 -21
View File
@@ -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.
+42 -6
View File
@@ -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" {
+52
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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{},
}
+1 -1
View File
@@ -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{},
}
+8 -3
View File
@@ -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",
}
+23 -4
View File
@@ -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 "<name> version <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{
+77 -1
View File
@@ -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
// "<Use> version <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)
}
})
}
}
+2 -2
View File
@@ -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)
}
+17 -1
View File
@@ -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.
+5 -6
View File
@@ -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
}
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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".
+1 -1
View File
@@ -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
+13 -4
View File
@@ -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"]
+13 -4
View File
@@ -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"]
+1 -1
View File
@@ -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.
+5 -5
View File
@@ -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 <resolver-uid>
ctrld-client start --intercept-mode dns --cd <resolver-uid>
# Hard intercept: all DNS through ctrld, no VPN split routing
ctrld start --intercept-mode hard --cd <resolver-uid>
ctrld-client start --intercept-mode hard --cd <resolver-uid>
# 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 <resolver-uid>
ctrld run --intercept-mode hard --cd <resolver-uid>
ctrld-client run --intercept-mode dns --cd <resolver-uid>
ctrld-client run --intercept-mode hard --cd <resolver-uid>
```
### Intercept Modes
+4 -4
View File
@@ -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"
```
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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?
+4 -4
View File
@@ -16,7 +16,7 @@ stable code on three surfaces:
risking token leakage from other output.
- **Exit code** — stage-scoped: bootstrap 3039, listener 4049,
service 5059. Unrelated existing contracts are unchanged
(`ctrld status` exits 03; invalid deactivation pin exits 126).
(`ctrld-client status` exits 03; 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.
+2 -2
View File
@@ -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 |
+14 -14
View File
@@ -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=<your_uid> --skip_self_checks
sudo ctrld-client service start --cd=<your_uid> --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 `<ctrld listener IP>` as a forwarder
- Add `<ctrld-client listener IP>` as a forwarder
## Getting Help
+3 -3
View File
@@ -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
```
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
go=${GOBIN:-go}
executable_name="ctrld"
executable_name="ctrld-client"
os_archs=(
darwin/arm64
darwin/amd64
+53
View File
@@ -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 "<binary> run", so a bare "docker run <image> --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"
+32 -14
View File
@@ -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"