From 58c0e4f15a1cccd5888966929f585615e4a92753 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Tue, 11 Mar 2025 01:16:36 +0700 Subject: [PATCH 01/14] all: remove ipv6 check polling netmon provides ipv6 availability during network event changes, so use this metadata instead of wasting on polling check. Further, repeated network errors will force marking ipv6 as disable if were being enabled, catching a rare case when ipv6 were disabled from cli or system settings. --- cmd/cli/dns_proxy.go | 4 ++++ config.go | 6 +++--- config_quic.go | 2 +- internal/net/net.go | 5 ++--- net.go | 50 +++++++++++++++++++++++--------------------- 5 files changed, 36 insertions(+), 31 deletions(-) diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index e3dbc26..eecfd6d 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -556,6 +556,10 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse { if errors.As(err, &e) && e.Timeout() { upstreamConfig.ReBootstrap() } + // For network error, turn ipv6 off if enabled. + if ctrld.HasIPv6() && (errUrlNetworkError(err) || errNetworkError(err)) { + ctrld.DisableIPv6() + } } return nil diff --git a/config.go b/config.go index 2e85e76..48736ac 100644 --- a/config.go +++ b/config.go @@ -485,7 +485,7 @@ func (uc *UpstreamConfig) setupDOHTransport() { uc.transport = uc.newDOHTransport(uc.bootstrapIPs6) case IpStackSplit: uc.transport4 = uc.newDOHTransport(uc.bootstrapIPs4) - if hasIPv6() { + if HasIPv6() { uc.transport6 = uc.newDOHTransport(uc.bootstrapIPs6) } else { uc.transport6 = uc.transport4 @@ -655,7 +655,7 @@ func (uc *UpstreamConfig) bootstrapIPForDNSType(dnsType uint16) string { case dns.TypeA: return pick(uc.bootstrapIPs4) default: - if hasIPv6() { + if HasIPv6() { return pick(uc.bootstrapIPs6) } return pick(uc.bootstrapIPs4) @@ -677,7 +677,7 @@ func (uc *UpstreamConfig) netForDNSType(dnsType uint16) (string, string) { case dns.TypeA: return "tcp4-tls", "udp4" default: - if hasIPv6() { + if HasIPv6() { return "tcp6-tls", "udp6" } return "tcp4-tls", "udp4" diff --git a/config_quic.go b/config_quic.go index a46780a..cadcb6b 100644 --- a/config_quic.go +++ b/config_quic.go @@ -24,7 +24,7 @@ func (uc *UpstreamConfig) setupDOH3Transport() { uc.http3RoundTripper = uc.newDOH3Transport(uc.bootstrapIPs6) case IpStackSplit: uc.http3RoundTripper4 = uc.newDOH3Transport(uc.bootstrapIPs4) - if hasIPv6() { + if HasIPv6() { uc.http3RoundTripper6 = uc.newDOH3Transport(uc.bootstrapIPs6) } else { uc.http3RoundTripper6 = uc.http3RoundTripper4 diff --git a/internal/net/net.go b/internal/net/net.go index 2693fbf..d5bd75e 100644 --- a/internal/net/net.go +++ b/internal/net/net.go @@ -17,9 +17,8 @@ import ( ) const ( - controldIPv6Test = "ipv6.controld.io" - v4BootstrapDNS = "76.76.2.22:53" - v6BootstrapDNS = "[2606:1a40::22]:53" + v4BootstrapDNS = "76.76.2.22:53" + v6BootstrapDNS = "[2606:1a40::22]:53" ) var Dialer = &net.Dialer{ diff --git a/net.go b/net.go index 449620d..7bbf54b 100644 --- a/net.go +++ b/net.go @@ -6,6 +6,8 @@ import ( "sync/atomic" "time" + "tailscale.com/net/netmon" + ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" ) @@ -14,38 +16,38 @@ var ( ipv6Available atomic.Bool ) -const ipv6ProbingInterval = 10 * time.Second - -func hasIPv6() bool { +// HasIPv6 reports whether the current network stack has IPv6 available. +func HasIPv6() bool { hasIPv6Once.Do(func() { - Log(context.Background(), ProxyLogger.Load().Debug(), "checking for IPv6 availability once") + ProxyLogger.Load().Debug().Msg("checking for IPv6 availability once") ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() val := ctrldnet.IPv6Available(ctx) ipv6Available.Store(val) - go probingIPv6(context.TODO(), val) + ProxyLogger.Load().Debug().Msgf("ipv6 availability: %v", val) + mon, err := netmon.New(func(format string, args ...any) {}) + if err != nil { + ProxyLogger.Load().Debug().Err(err).Msg("failed to monitor IPv6 state") + return + } + mon.RegisterChangeCallback(func(delta *netmon.ChangeDelta) { + old := ipv6Available.Load() + cur := delta.Monitor.InterfaceState().HaveV6 + if old != cur { + ProxyLogger.Load().Warn().Msgf("ipv6 availability changed, old: %v, new: %v", old, cur) + } else { + ProxyLogger.Load().Debug().Msg("ipv6 availability does not changed") + } + ipv6Available.Store(cur) + }) + mon.Start() }) return ipv6Available.Load() } -// TODO(cuonglm): doing poll check natively for supported platforms. -func probingIPv6(ctx context.Context, old bool) { - ticker := time.NewTicker(ipv6ProbingInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - cur := ctrldnet.IPv6Available(ctx) - if ipv6Available.CompareAndSwap(old, cur) { - old = cur - } - Log(ctx, ProxyLogger.Load().Debug(), "IPv6 availability: %v", cur) - }() - } +// DisableIPv6 marks IPv6 as unavailable if enabled. +func DisableIPv6() { + if ipv6Available.CompareAndSwap(true, false) { + ProxyLogger.Load().Debug().Msg("turned off IPv6 availability") } } From 7a136b8874a327ee838a78f6fe49420083ec3dc0 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 12 Mar 2025 00:09:19 +0700 Subject: [PATCH 02/14] all: disable client discover on desktop platforms Since requests are mostly originated from the machine itself, so all necessary metadata is local to it. Currently, the desktop platforms are Windows desktop and darwin. --- client_info_darwin.go | 4 ++++ client_info_others.go | 6 ++++++ client_info_windows.go | 18 ++++++++++++++++++ docs/config.md | 10 ++++++++++ internal/clientinfo/client_info.go | 24 ++++++++++++++++++------ 5 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 client_info_darwin.go create mode 100644 client_info_others.go create mode 100644 client_info_windows.go diff --git a/client_info_darwin.go b/client_info_darwin.go new file mode 100644 index 0000000..4c3d10b --- /dev/null +++ b/client_info_darwin.go @@ -0,0 +1,4 @@ +package ctrld + +// SelfDiscover reports whether ctrld should only do self discover. +func SelfDiscover() bool { return true } diff --git a/client_info_others.go b/client_info_others.go new file mode 100644 index 0000000..d728913 --- /dev/null +++ b/client_info_others.go @@ -0,0 +1,6 @@ +//go:build !windows && !darwin + +package ctrld + +// SelfDiscover reports whether ctrld should only do self discover. +func SelfDiscover() bool { return false } diff --git a/client_info_windows.go b/client_info_windows.go new file mode 100644 index 0000000..f20bca7 --- /dev/null +++ b/client_info_windows.go @@ -0,0 +1,18 @@ +package ctrld + +import ( + "golang.org/x/sys/windows" +) + +// isWindowsWorkStation reports whether ctrld was run on a Windows workstation machine. +func isWindowsWorkStation() bool { + // From https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-osversioninfoexa + const VER_NT_WORKSTATION = 0x0000001 + osvi := windows.RtlGetVersion() + return osvi.ProductType == VER_NT_WORKSTATION +} + +// SelfDiscover reports whether ctrld should only do self discover. +func SelfDiscover() bool { + return isWindowsWorkStation() +} diff --git a/docs/config.md b/docs/config.md index 4f50af1..99e98c9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -178,6 +178,8 @@ Perform LAN client discovery using mDNS. This will spawn a listener on port 5353 - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_arp Perform LAN client discovery using ARP. @@ -185,6 +187,8 @@ Perform LAN client discovery using ARP. - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_dhcp Perform LAN client discovery using DHCP leases files. Common file locations are auto-discovered. @@ -192,6 +196,8 @@ Perform LAN client discovery using DHCP leases files. Common file locations are - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_ptr Perform LAN client discovery using PTR queries. @@ -199,6 +205,8 @@ Perform LAN client discovery using PTR queries. - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_hosts Perform LAN client discovery using hosts file. @@ -206,6 +214,8 @@ Perform LAN client discovery using hosts file. - Required: no - Default: true +This config is ignored, and always set to `false` on Windows Desktop and Macos. + ### discover_refresh_interval Time in seconds between each discovery refresh loop to update new client information data. The default value is 120 seconds, lower this value to make the discovery process run more aggressively. diff --git a/internal/clientinfo/client_info.go b/internal/clientinfo/client_info.go index 06449e1..f69b670 100644 --- a/internal/clientinfo/client_info.go +++ b/internal/clientinfo/client_info.go @@ -177,15 +177,27 @@ func (t *Table) SetSelfIP(ip string) { t.dhcp.addSelf() } +// initSelfDiscover initializes necessary client metadata for self query. +func (t *Table) initSelfDiscover() { + t.dhcp = &dhcp{selfIP: t.selfIP} + t.dhcp.addSelf() + t.ipResolvers = append(t.ipResolvers, t.dhcp) + t.macResolvers = append(t.macResolvers, t.dhcp) + t.hostnameResolvers = append(t.hostnameResolvers, t.dhcp) +} + func (t *Table) init() { // Custom client ID presents, use it as the only source. if _, clientID := controld.ParseRawUID(t.cdUID); clientID != "" { - ctrld.ProxyLogger.Load().Debug().Msg("start self discovery") - t.dhcp = &dhcp{selfIP: t.selfIP} - t.dhcp.addSelf() - t.ipResolvers = append(t.ipResolvers, t.dhcp) - t.macResolvers = append(t.macResolvers, t.dhcp) - t.hostnameResolvers = append(t.hostnameResolvers, t.dhcp) + ctrld.ProxyLogger.Load().Debug().Msg("start self discovery with custom client id") + t.initSelfDiscover() + return + } + + // If we are running on platforms that should only do self discover, use it as the only source, too. + if ctrld.SelfDiscover() { + ctrld.ProxyLogger.Load().Debug().Msg("start self discovery on desktop platforms") + t.initSelfDiscover() return } From 84376ed71915afec2d6d0cb70f9bdad282fa0a64 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 13 Mar 2025 18:09:46 +0700 Subject: [PATCH 03/14] cmd/cli: add missing pre-run setup for start command Otherwise, ctrld won't be able to reset DNS correctly if problems happened during self-check process. --- cmd/cli/cli.go | 2 +- cmd/cli/commands.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index eb3b910..96ed656 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -1061,7 +1061,7 @@ func uninstall(p *prog, s service.Service) { p.resetDNS(false, true) // Iterate over all physical interfaces and restore DNS if a saved static config exists. - withEachPhysicalInterfaces("", "restore static DNS", func(i *net.Interface) error { + withEachPhysicalInterfaces(p.runningIface, "restore static DNS", func(i *net.Interface) error { file := savedStaticDnsSettingsFilePath(i) if _, err := os.Stat(file); err == nil { if err := restoreDNS(i); err != nil { diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 6c0c202..c052f44 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -234,6 +234,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c mainLog.Load().Error().Msg(err.Error()) return } + p.preRun() status, err := s.Status() isCtrldRunning := status == service.StatusRunning From 8bf654aece0ad7ac8f377e740abfd981ccdbd50f Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 13 Mar 2025 18:20:35 +0700 Subject: [PATCH 04/14] Bump golang.org/x/net to v0.36.0 Fixing https://pkg.go.dev/vuln/GO-2025-3503 --- go.mod | 14 +++++++------- go.sum | 20 ++++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 635261f..b545941 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/Control-D-Inc/ctrld -go 1.23 +go 1.23.0 -toolchain go1.23.1 +toolchain go1.23.7 require ( github.com/Masterminds/semver v1.5.0 @@ -36,9 +36,9 @@ require ( github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.9.0 github.com/vishvananda/netlink v1.2.1-beta.2 - golang.org/x/net v0.33.0 - golang.org/x/sync v0.10.0 - golang.org/x/sys v0.29.0 + golang.org/x/net v0.36.0 + golang.org/x/sync v0.11.0 + golang.org/x/sys v0.30.0 golang.zx2c4.com/wireguard/windows v0.5.3 tailscale.com v1.74.0 ) @@ -92,10 +92,10 @@ require ( go.uber.org/mock v0.4.0 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.31.0 // indirect + golang.org/x/crypto v0.35.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/mod v0.19.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/text v0.22.0 // indirect golang.org/x/tools v0.23.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 2ac97af..9687f26 100644 --- a/go.sum +++ b/go.sum @@ -346,8 +346,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -417,8 +417,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= +golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -438,8 +438,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -488,8 +488,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -500,8 +500,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= From 2de1b9929a53ee790dd5429daf627ad7314e8ed9 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 13 Mar 2025 21:04:00 +0700 Subject: [PATCH 05/14] Do not send legacy DNS queries to bootstrap DNS --- config.go | 10 ++-------- config_internal_test.go | 6 +----- dot.go | 2 +- resolver.go | 14 ++++---------- 4 files changed, 8 insertions(+), 24 deletions(-) diff --git a/config.go b/config.go index 48736ac..4ace9f1 100644 --- a/config.go +++ b/config.go @@ -402,12 +402,6 @@ func (uc *UpstreamConfig) SetCertPool(cp *x509.CertPool) { uc.certPool = cp } -// SetupBootstrapIP manually find all available IPs of the upstream. -// The first usable IP will be used as bootstrap IP of the upstream. -func (uc *UpstreamConfig) SetupBootstrapIP() { - uc.setupBootstrapIP(true) -} - // UID returns the unique identifier of the upstream. func (uc *UpstreamConfig) UID() string { return uc.uid @@ -415,11 +409,11 @@ func (uc *UpstreamConfig) UID() string { // SetupBootstrapIP manually find all available IPs of the upstream. // The first usable IP will be used as bootstrap IP of the upstream. -func (uc *UpstreamConfig) setupBootstrapIP(withBootstrapDNS bool) { +func (uc *UpstreamConfig) SetupBootstrapIP() { b := backoff.NewBackoff("setupBootstrapIP", func(format string, args ...any) {}, 10*time.Second) isControlD := uc.IsControlD() for { - uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout, withBootstrapDNS) + uc.bootstrapIPs = lookupIP(uc.Domain, uc.Timeout) // For ControlD upstream, the bootstrap IPs could not be RFC 1918 addresses, // filtering them out here to prevent weird behavior. if isControlD { diff --git a/config_internal_test.go b/config_internal_test.go index 44b7e2f..7695eb5 100644 --- a/config_internal_test.go +++ b/config_internal_test.go @@ -2,16 +2,12 @@ package ctrld import ( "net/url" - "os" "testing" - "github.com/rs/zerolog" "github.com/stretchr/testify/assert" ) func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) { - l := zerolog.New(os.Stdout) - ProxyLogger.Store(&l) uc := &UpstreamConfig{ Name: "test", Type: ResolverTypeDOH, @@ -19,7 +15,7 @@ func TestUpstreamConfig_SetupBootstrapIP(t *testing.T) { Timeout: 5000, } uc.Init() - uc.setupBootstrapIP(false) + uc.SetupBootstrapIP() if len(uc.bootstrapIPs) == 0 { t.Log(defaultNameservers()) t.Fatal("could not bootstrap ip without bootstrap DNS") diff --git a/dot.go b/dot.go index c0fe102..67d1ff8 100644 --- a/dot.go +++ b/dot.go @@ -18,7 +18,7 @@ func (r *dotResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro // dns.controld.dev first. By using a dialer with custom resolver, // we ensure that we can always resolve the bootstrap domain // regardless of the machine DNS status. - dialer := newDialer(net.JoinHostPort(controldBootstrapDns, "53")) + dialer := newDialer(net.JoinHostPort(controldPublicDns, "53")) dnsTyp := uint16(0) if msg != nil && len(msg.Question) > 0 { dnsTyp = msg.Question[0].Qtype diff --git a/resolver.go b/resolver.go index 677738b..3da2574 100644 --- a/resolver.go +++ b/resolver.go @@ -41,10 +41,7 @@ const ( ResolverTypeSDNS = "sdns" ) -const ( - controldBootstrapDns = "76.76.2.22" - controldPublicDns = "76.76.2.0" -) +const controldPublicDns = "76.76.2.0" var controldPublicDnsWithPort = net.JoinHostPort(controldPublicDns, "53") @@ -440,7 +437,7 @@ type legacyResolver struct { func (r *legacyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) { // See comment in (*dotResolver).resolve method. - dialer := newDialer(net.JoinHostPort(controldBootstrapDns, "53")) + dialer := newDialer(net.JoinHostPort(controldPublicDns, "53")) dnsTyp := uint16(0) if msg != nil && len(msg.Question) > 0 { dnsTyp = msg.Question[0].Qtype @@ -472,10 +469,10 @@ func (d dummyResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, err // LookupIP looks up host using OS resolver. // It returns a slice of that host's IPv4 and IPv6 addresses. func LookupIP(domain string) []string { - return lookupIP(domain, -1, true) + return lookupIP(domain, -1) } -func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) { +func lookupIP(domain string, timeout int) (ips []string) { resolverMutex.Lock() if or == nil { ProxyLogger.Load().Debug().Msgf("Initialize OS resolver in lookupIP") @@ -485,9 +482,6 @@ func lookupIP(domain string, timeout int, withBootstrapDNS bool) (ips []string) nss := *or.lanServers.Load() nss = append(nss, *or.publicServers.Load()...) - if withBootstrapDNS { - nss = append([]string{net.JoinHostPort(controldBootstrapDns, "53")}, nss...) - } resolver := newResolverWithNameserver(nss) ProxyLogger.Load().Debug().Msgf("resolving %q using bootstrap DNS %q", domain, nss) timeoutMs := 2000 From f27cbe3525ce1704241c96e033bf8f70dd419ba4 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Tue, 11 Mar 2025 00:27:26 +0700 Subject: [PATCH 06/14] all: fallback to use direct IPs for ControlD assets --- cmd/cli/commands.go | 3 +- cmd/cli/dns_proxy.go | 7 +-- cmd/cli/library.go | 23 ++++++++-- config.go | 78 +++++++++++++++++++++++++++++++- doh.go | 6 +++ internal/controld/config.go | 88 +++++++++++++++++++++++++------------ 6 files changed, 163 insertions(+), 42 deletions(-) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index c052f44..96e264b 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -1278,8 +1278,9 @@ func initUpgradeCmd() *cobra.Command { dlUrl := upgradeUrl(baseUrl) mainLog.Load().Debug().Msgf("Downloading binary: %s", dlUrl) - resp, err := getWithRetry(dlUrl) + resp, err := getWithRetry(dlUrl, downloadServerIp) if err != nil { + mainLog.Load().Fatal().Err(err).Msg("failed to download binary") } defer resp.Body.Close() diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index eecfd6d..6a214e5 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -519,13 +519,8 @@ func (p *prog) proxy(ctx context.Context, req *proxyRequest) *proxyResponse { ctrld.Log(ctx, mainLog.Load().Error().Err(err), "failed to create resolver") return nil, err } - resolveCtx, cancel := context.WithCancel(ctx) + resolveCtx, cancel := upstreamConfig.Context(ctx) defer cancel() - if upstreamConfig.Timeout > 0 { - timeoutCtx, cancel := context.WithTimeout(resolveCtx, time.Millisecond*time.Duration(upstreamConfig.Timeout)) - defer cancel() - resolveCtx = timeoutCtx - } return dnsResolver.Resolve(resolveCtx, msg) } resolve := func(upstream string, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg { diff --git a/cmd/cli/library.go b/cmd/cli/library.go index a5ba389..3c1db1b 100644 --- a/cmd/cli/library.go +++ b/cmd/cli/library.go @@ -28,6 +28,7 @@ type AppConfig struct { const ( defaultHTTPTimeout = 30 * time.Second defaultMaxRetries = 3 + downloadServerIp = "23.171.240.151" ) // httpClientWithFallback returns an HTTP client configured with timeout and IPv4 fallback @@ -46,10 +47,15 @@ func httpClientWithFallback(timeout time.Duration) *http.Client { } // doWithRetry performs an HTTP request with retries -func doWithRetry(req *http.Request, maxRetries int) (*http.Response, error) { +func doWithRetry(req *http.Request, maxRetries int, ip string) (*http.Response, error) { var lastErr error client := httpClientWithFallback(defaultHTTPTimeout) - + var ipReq *http.Request + if ip != "" { + ipReq = req.Clone(req.Context()) + ipReq.Host = ip + ipReq.URL.Host = ip + } for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { time.Sleep(time.Second * time.Duration(attempt+1)) // Exponential backoff @@ -59,6 +65,15 @@ func doWithRetry(req *http.Request, maxRetries int) (*http.Response, error) { if err == nil { return resp, nil } + if ipReq != nil { + mainLog.Load().Warn().Err(err).Msgf("dial to %q failed", req.Host) + mainLog.Load().Warn().Msgf("fallback to direct IP to download prod version: %q", ip) + resp, err = client.Do(ipReq) + if err == nil { + return resp, nil + } + } + lastErr = err mainLog.Load().Debug().Err(err). Str("method", req.Method). @@ -69,10 +84,10 @@ func doWithRetry(req *http.Request, maxRetries int) (*http.Response, error) { } // Helper for making GET requests with retries -func getWithRetry(url string) (*http.Response, error) { +func getWithRetry(url string, ip string) (*http.Response, error) { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } - return doWithRetry(req, defaultMaxRetries) + return doWithRetry(req, defaultMaxRetries, ip) } diff --git a/config.go b/config.go index 4ace9f1..f208f0d 100644 --- a/config.go +++ b/config.go @@ -53,10 +53,27 @@ const ( FreeDnsDomain = "freedns.controld.com" // FreeDNSBoostrapIP is the IP address of freedns.controld.com. FreeDNSBoostrapIP = "76.76.2.11" + // FreeDNSBoostrapIPv6 is the IPv6 address of freedns.controld.com. + FreeDNSBoostrapIPv6 = "2606:1a40::11" // PremiumDnsDomain is the domain name of premium ControlD service. PremiumDnsDomain = "dns.controld.com" // PremiumDNSBoostrapIP is the IP address of dns.controld.com. PremiumDNSBoostrapIP = "76.76.2.22" + // PremiumDNSBoostrapIPv6 is the IPv6 address of dns.controld.com. + PremiumDNSBoostrapIPv6 = "2606:1a40::22" + + // freeDnsDomainDev is the domain name of free ControlD service on dev env. + freeDnsDomainDev = "freedns.controld.dev" + // freeDNSBoostrapIP is the IP address of freedns.controld.dev. + freeDNSBoostrapIP = "176.125.239.11" + // freeDNSBoostrapIPv6 is the IPv6 address of freedns.controld.com. + freeDNSBoostrapIPv6 = "2606:1a40:f000::11" + // premiumDnsDomainDev is the domain name of premium ControlD service on dev env. + premiumDnsDomainDev = "dns.controld.dev" + // premiumDNSBoostrapIP is the IP address of dns.controld.dev. + premiumDNSBoostrapIP = "176.125.239.22" + // premiumDNSBoostrapIPv6 is the IPv6 address of dns.controld.dev. + premiumDNSBoostrapIPv6 = "2606:1a40:f000::22" controlDComDomain = "controld.com" controlDNetDomain = "controld.net" @@ -261,6 +278,7 @@ type UpstreamConfig struct { http3RoundTripper6 http.RoundTripper certPool *x509.CertPool u *url.URL + fallbackOnce sync.Once uid string } @@ -426,6 +444,10 @@ func (uc *UpstreamConfig) SetupBootstrapIP() { } } uc.bootstrapIPs = uc.bootstrapIPs[:n] + if len(uc.bootstrapIPs) == 0 { + uc.bootstrapIPs = bootstrapIPsFromControlDDomain(uc.Domain) + ProxyLogger.Load().Warn().Msgf("no bootstrap IPs found for %q, fallback to direct IPs", uc.Domain) + } } if len(uc.bootstrapIPs) > 0 { break @@ -538,7 +560,10 @@ func (uc *UpstreamConfig) newDOHTransport(addrs []string) *http.Transport { // Ping warms up the connection to DoH/DoH3 upstream. func (uc *UpstreamConfig) Ping() { - _ = uc.ping() + if err := uc.ping(); err != nil { + ProxyLogger.Load().Debug().Err(err).Msgf("upstream ping failed: %s", uc.Endpoint) + _ = uc.FallbackToDirectIP() + } } // ErrorPing is like Ping, but return an error if any. @@ -575,7 +600,6 @@ func (uc *UpstreamConfig) ping() error { for _, typ := range []uint16{dns.TypeA, dns.TypeAAAA} { switch uc.Type { case ResolverTypeDOH: - if err := ping(uc.dohTransport(typ)); err != nil { return err } @@ -743,6 +767,41 @@ func (uc *UpstreamConfig) initDnsStamps() error { return nil } +// Context returns a new context with timeout set from upstream config. +func (uc *UpstreamConfig) Context(ctx context.Context) (context.Context, context.CancelFunc) { + if uc.Timeout > 0 { + return context.WithTimeout(ctx, time.Millisecond*time.Duration(uc.Timeout)) + } + return context.WithCancel(ctx) +} + +// FallbackToDirectIP changes ControlD upstream endpoint to use direct IP instead of domain. +func (uc *UpstreamConfig) FallbackToDirectIP() bool { + if !uc.IsControlD() { + return false + } + if uc.u == nil || uc.Domain == "" { + return false + } + + done := false + uc.fallbackOnce.Do(func() { + var ip string + switch { + case dns.IsSubDomain(PremiumDnsDomain, uc.Domain): + ip = PremiumDNSBoostrapIP + case dns.IsSubDomain(FreeDnsDomain, uc.Domain): + ip = FreeDNSBoostrapIP + default: + return + } + ProxyLogger.Load().Warn().Msgf("using direct IP for %q: %s", uc.Endpoint, ip) + uc.u.Host = ip + done = true + }) + return done +} + // Init initialized necessary values for an ListenerConfig. func (lc *ListenerConfig) Init() { if lc.Policy != nil { @@ -889,3 +948,18 @@ func (uc *UpstreamConfig) String() string { return fmt.Sprintf("{name: %q, type: %q, endpoint: %q, bootstrap_ip: %q, domain: %q, ip_stack: %q}", uc.Name, uc.Type, uc.Endpoint, uc.BootstrapIP, uc.Domain, uc.IPStack) } + +// bootstrapIPsFromControlDDomain returns bootstrap IPs for ControlD domain. +func bootstrapIPsFromControlDDomain(domain string) []string { + switch domain { + case PremiumDnsDomain: + return []string{PremiumDNSBoostrapIP, PremiumDNSBoostrapIPv6} + case FreeDnsDomain: + return []string{FreeDNSBoostrapIP, FreeDNSBoostrapIPv6} + case premiumDnsDomainDev: + return []string{premiumDNSBoostrapIP, premiumDNSBoostrapIPv6} + case freeDnsDomainDev: + return []string{freeDNSBoostrapIP, freeDNSBoostrapIPv6} + } + return nil +} diff --git a/doh.go b/doh.go index d702995..73b2764 100644 --- a/doh.go +++ b/doh.go @@ -113,6 +113,12 @@ func (r *dohResolver) Resolve(ctx context.Context, msg *dns.Msg) (*dns.Msg, erro c.Transport = transport } resp, err := c.Do(req) + if err != nil && r.uc.FallbackToDirectIP() { + retryCtx, cancel := r.uc.Context(context.WithoutCancel(ctx)) + defer cancel() + Log(ctx, ProxyLogger.Load().Warn().Err(err), "retrying request after fallback to direct ip") + resp, err = c.Do(req.Clone(retryCtx)) + } if err != nil { if r.isDoH3 { if closer, ok := c.Transport.(io.Closer); ok { diff --git a/internal/controld/config.go b/internal/controld/config.go index 5e65fdb..23542c7 100644 --- a/internal/controld/config.go +++ b/internal/controld/config.go @@ -24,7 +24,10 @@ import ( const ( apiDomainCom = "api.controld.com" + apiDomainComIPv4 = "147.185.34.1" + apiDomainComIPv6 = "2606:1a40:3::1" apiDomainDev = "api.controld.dev" + apiDomainDevIPv4 = "23.171.240.84" apiURLCom = "https://api.controld.com" apiURLDev = "https://api.controld.dev" resolverDataURLCom = apiURLCom + "/utility" @@ -136,11 +139,11 @@ func postUtilityAPI(version string, cdDev, lastUpdatedFailed bool, body io.Reade req.URL.RawQuery = q.Encode() req.Header.Add("Content-Type", "application/json") transport := apiTransport(cdDev) - client := http.Client{ + client := &http.Client{ Timeout: defaultTimeout, Transport: transport, } - resp, err := client.Do(req) + resp, err := doWithFallback(client, req, apiServerIP(cdDev)) if err != nil { return nil, fmt.Errorf("postUtilityAPI client.Do: %w", err) } @@ -177,11 +180,11 @@ func SendLogs(lr *LogsRequest, cdDev bool) error { req.URL.RawQuery = q.Encode() req.Header.Add("Content-Type", "application/x-www-form-urlencoded") transport := apiTransport(cdDev) - client := http.Client{ + client := &http.Client{ Timeout: sendLogTimeout, Transport: transport, } - resp, err := client.Do(req) + resp, err := doWithFallback(client, req, apiServerIP(cdDev)) if err != nil { return fmt.Errorf("SendLogs client.Do: %w", err) } @@ -213,20 +216,20 @@ func apiTransport(cdDev bool) *http.Transport { transport := http.DefaultTransport.(*http.Transport).Clone() transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { apiDomain := apiDomainCom + apiIpsV4 := []string{apiDomainComIPv4} + apiIpsV6 := []string{apiDomainComIPv6} + apiIPs := []string{apiDomainComIPv4, apiDomainComIPv6} if cdDev { apiDomain = apiDomainDev - } - - // First try IPv4 - dialer := &net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 30 * time.Second, + apiIpsV4 = []string{apiDomainDevIPv4} + apiIpsV6 = []string{} + apiIPs = []string{apiDomainDevIPv4} } ips := ctrld.LookupIP(apiDomain) if len(ips) == 0 { - ctrld.ProxyLogger.Load().Warn().Msgf("No IPs found for %s, falling back to direct connection to %s", apiDomain, addr) - return dialer.DialContext(ctx, network, addr) + ctrld.ProxyLogger.Load().Warn().Msgf("No IPs found for %s, use direct IPs: %v", apiDomain, apiIPs) + ips = apiIPs } // Separate IPv4 and IPv6 addresses @@ -239,35 +242,62 @@ func apiTransport(cdDev bool) *http.Transport { } } + dial := func(ctx context.Context, network string, addrs []string) (net.Conn, error) { + d := &ctrldnet.ParallelDialer{} + return d.DialContext(ctx, network, addrs, ctrld.ProxyLogger.Load()) + } _, port, _ := net.SplitHostPort(addr) // Try IPv4 first if len(ipv4s) > 0 { - addrs := make([]string, len(ipv4s)) - for i, ip := range ipv4s { - addrs[i] = net.JoinHostPort(ip, port) - } - d := &ctrldnet.ParallelDialer{} - if conn, err := d.DialContext(ctx, "tcp4", addrs, ctrld.ProxyLogger.Load()); err == nil { + if conn, err := dial(ctx, "tcp4", addrsFromPort(ipv4s, port)); err == nil { return conn, nil } } - - // Fall back to IPv6 if available - if len(ipv6s) > 0 { - addrs := make([]string, len(ipv6s)) - for i, ip := range ipv6s { - addrs[i] = net.JoinHostPort(ip, port) - } - d := &ctrldnet.ParallelDialer{} - return d.DialContext(ctx, "tcp6", addrs, ctrld.ProxyLogger.Load()) + // Fallback to direct IPv4 + if conn, err := dial(ctx, "tcp4", addrsFromPort(apiIpsV4, port)); err == nil { + return conn, nil } - // Final fallback to direct connection - return dialer.DialContext(ctx, network, addr) + // Fallback to IPv6 if available + if len(ipv6s) > 0 { + if conn, err := dial(ctx, "tcp6", addrsFromPort(ipv6s, port)); err == nil { + return conn, nil + } + } + // Fallback to direct IPv6 + return dial(ctx, "tcp6", addrsFromPort(apiIpsV6, port)) } if router.Name() == ddwrt.Name || runtime.GOOS == "android" { transport.TLSClientConfig = &tls.Config{RootCAs: certs.CACertPool()} } return transport } + +func addrsFromPort(ips []string, port string) []string { + addrs := make([]string, len(ips)) + for i, ip := range ips { + addrs[i] = net.JoinHostPort(ip, port) + } + return addrs +} + +func doWithFallback(client *http.Client, req *http.Request, apiIp string) (*http.Response, error) { + resp, err := client.Do(req) + if err != nil { + ctrld.ProxyLogger.Load().Warn().Err(err).Msgf("failed to send request, fallback to direct IP: %s", apiIp) + ipReq := req.Clone(req.Context()) + ipReq.Host = apiIp + ipReq.URL.Host = apiIp + resp, err = client.Do(ipReq) + } + return resp, err +} + +// apiServerIP returns the direct IP to connect to API server. +func apiServerIP(cdDev bool) string { + if cdDev { + return apiDomainDevIPv4 + } + return apiDomainComIPv4 +} From c60cf33af394894cf5f7660b5a6f3143070db9d9 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Mon, 17 Mar 2025 20:44:03 +0700 Subject: [PATCH 07/14] all: implement self-upgrade flag from API So upgrading don't have to be initiated manually, helping large deployments to upgrade to latest ctrld version easily. --- cmd/cli/cli.go | 2 +- cmd/cli/prog.go | 57 +++++++++++++++++++++++++++++++++ cmd/cli/self_upgrade_others.go | 12 +++++++ cmd/cli/self_upgrade_windows.go | 18 +++++++++++ cmd/cli/service.go | 55 +++++++++++++++++++++++++++++++ cmd/cli/service_test.go | 28 ++++++++++++++++ go.mod | 2 +- go.sum | 4 +-- internal/controld/config.go | 1 + 9 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 cmd/cli/self_upgrade_others.go create mode 100644 cmd/cli/self_upgrade_windows.go create mode 100644 cmd/cli/service_test.go diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 96ed656..2a9a4e9 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -25,7 +25,7 @@ import ( "sync/atomic" "time" - "github.com/Masterminds/semver" + "github.com/Masterminds/semver/v3" "github.com/cuonglm/osinfo" "github.com/go-playground/validator/v10" "github.com/kardianos/service" diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index be9b0ae..9c2fb11 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -11,6 +11,7 @@ import ( "net/netip" "net/url" "os" + "os/exec" "runtime" "slices" "sort" @@ -21,6 +22,7 @@ import ( "syscall" "time" + "github.com/Masterminds/semver/v3" "github.com/kardianos/service" "github.com/rs/zerolog" "github.com/spf13/viper" @@ -304,6 +306,16 @@ func (p *prog) apiConfigReload() { logger := mainLog.Load().With().Str("mode", "api-reload").Logger() logger.Debug().Msg("starting custom config reload timer") lastUpdated := time.Now().Unix() + curVerStr := curVersion() + curVer, err := semver.NewVersion(curVerStr) + isStable := curVer != nil && curVer.Prerelease() == "" + if err != nil || !isStable { + l := mainLog.Load().Warn() + if err != nil { + l = l.Err(err) + } + l.Msgf("current version is not stable, skipping self-upgrade: %s", curVerStr) + } doReloadApiConfig := func(forced bool, logger zerolog.Logger) { resolverConfig, err := controld.FetchResolverConfig(cdUID, rootCmd.Version, cdDev) @@ -313,6 +325,11 @@ func (p *prog) apiConfigReload() { return } + // Performing self-upgrade check for production version. + if isStable { + selfUpgradeCheck(resolverConfig.Ctrld.VersionTarget, curVer, &logger) + } + if resolverConfig.DeactivationPin != nil { newDeactivationPin := *resolverConfig.DeactivationPin curDeactivationPin := cdDeactivationPin.Load() @@ -1422,6 +1439,46 @@ func selfUninstallCheck(uninstallErr error, p *prog, logger zerolog.Logger) { } } +// selfUpgradeCheck checks if the version target vt is greater +// than the current one cv, perform self-upgrade then. +// +// The callers must ensure curVer and logger are non-nil. +func selfUpgradeCheck(vt string, cv *semver.Version, logger *zerolog.Logger) { + if vt == "" { + logger.Debug().Msg("no version target set, skipped checking self-upgrade") + return + } + vts := vt + if !strings.HasPrefix(vts, "v") { + vts = "v" + vts + } + targetVer, err := semver.NewVersion(vts) + if err != nil { + logger.Warn().Err(err).Msgf("invalid target version, skipped self-upgrade: %s", vt) + return + } + if !targetVer.GreaterThan(cv) { + logger.Debug(). + Str("target", vt). + Str("current", cv.String()). + Msgf("target version is not greater than current one, skipped self-upgrade") + return + } + + exe, err := os.Executable() + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to get executable path, skipped self-upgrade") + return + } + cmd := exec.Command(exe, "upgrade", "prod", "-vv") + cmd.SysProcAttr = sysProcAttrForSelfUpgrade() + if err := cmd.Start(); err != nil { + mainLog.Load().Error().Err(err).Msg("failed to start self-upgrade") + return + } + mainLog.Load().Debug().Msgf("self-upgrade triggered, version target: %s", vts) +} + // leakOnUpstreamFailure reports whether ctrld should initiate a recovery flow // when upstream failures occur. func (p *prog) leakOnUpstreamFailure() bool { diff --git a/cmd/cli/self_upgrade_others.go b/cmd/cli/self_upgrade_others.go new file mode 100644 index 0000000..f1ff140 --- /dev/null +++ b/cmd/cli/self_upgrade_others.go @@ -0,0 +1,12 @@ +//go:build !windows + +package cli + +import ( + "syscall" +) + +// sysProcAttrForSelfUpgrade returns *syscall.SysProcAttr instance for running self-upgrade command. +func sysProcAttrForSelfUpgrade() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} diff --git a/cmd/cli/self_upgrade_windows.go b/cmd/cli/self_upgrade_windows.go new file mode 100644 index 0000000..213aec9 --- /dev/null +++ b/cmd/cli/self_upgrade_windows.go @@ -0,0 +1,18 @@ +package cli + +import ( + "syscall" +) + +// From: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags?redirectedfrom=MSDN + +// SYSCALL_CREATE_NO_WINDOW set flag to run process without a console window. +const SYSCALL_CREATE_NO_WINDOW = 0x08000000 + +// sysProcAttrForSelfUpgrade returns *syscall.SysProcAttr instance for running self-upgrade command. +func sysProcAttrForSelfUpgrade() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | SYSCALL_CREATE_NO_WINDOW, + HideWindow: true, + } +} diff --git a/cmd/cli/service.go b/cmd/cli/service.go index f03146d..f75ee55 100644 --- a/cmd/cli/service.go +++ b/cmd/cli/service.go @@ -4,10 +4,12 @@ import ( "bytes" "errors" "fmt" + "io" "os" "os/exec" "runtime" + "github.com/coreos/go-systemd/v22/unit" "github.com/kardianos/service" "github.com/Control-D-Inc/ctrld/internal/router" @@ -132,6 +134,59 @@ func (s *systemd) Status() (service.Status, error) { return s.Service.Status() } +func (s *systemd) Start() error { + const systemdUnitFile = "/etc/systemd/system/ctrld.service" + f, err := os.Open(systemdUnitFile) + if err != nil { + return err + } + defer f.Close() + if opts, change := ensureSystemdKillMode(f); change { + mode := os.FileMode(0644) + buf, err := io.ReadAll(unit.Serialize(opts)) + if err != nil { + return err + } + if err := os.WriteFile(systemdUnitFile, buf, mode); err != nil { + return err + } + if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { + return fmt.Errorf("systemctl daemon-reload failed: %w\n%s", err, string(out)) + } + mainLog.Load().Debug().Msg("set KillMode=process successfully") + } + return s.Service.Start() +} + +// ensureSystemdKillMode ensure systemd unit file is configured with KillMode=process. +// This is necessary for running self-upgrade flow. +func ensureSystemdKillMode(r io.Reader) (opts []*unit.UnitOption, change bool) { + opts, err := unit.DeserializeOptions(r) + if err != nil { + mainLog.Load().Error().Err(err).Msg("failed to deserialize options") + return + } + change = true + needKillModeOpt := true + killModeOpt := unit.NewUnitOption("Service", "KillMode", "process") + for _, opt := range opts { + if opt.Match(killModeOpt) { + needKillModeOpt = false + change = false + break + } + if opt.Section == killModeOpt.Section && opt.Name == killModeOpt.Name { + opt.Value = killModeOpt.Value + needKillModeOpt = false + break + } + } + if needKillModeOpt { + opts = append(opts, killModeOpt) + } + return opts, change +} + func newLaunchd(s service.Service) *launchd { return &launchd{ Service: s, diff --git a/cmd/cli/service_test.go b/cmd/cli/service_test.go new file mode 100644 index 0000000..155bd3e --- /dev/null +++ b/cmd/cli/service_test.go @@ -0,0 +1,28 @@ +package cli + +import ( + "strings" + "testing" +) + +func Test_ensureSystemdKillMode(t *testing.T) { + tests := []struct { + name string + unitFile string + wantChange bool + }{ + {"no KillMode", "[Service]\nExecStart=/bin/sleep 1", true}, + {"not KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=mixed", true}, + {"KillMode=process", "[Service]\nExecStart=/bin/sleep 1\nKillMode=process", false}, + {"invalid unit file", "[Service\nExecStart=/bin/sleep 1\nKillMode=process", false}, + } + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if _, change := ensureSystemdKillMode(strings.NewReader(tc.unitFile)); tc.wantChange != change { + t.Errorf("ensureSystemdKillMode(%q) = %v, want %v", tc.unitFile, change, tc.wantChange) + } + }) + } +} diff --git a/go.mod b/go.mod index b545941..dd80ffe 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.23.0 toolchain go1.23.7 require ( - github.com/Masterminds/semver v1.5.0 + github.com/Masterminds/semver/v3 v3.2.1 github.com/ameshkov/dnsstamps v1.0.3 github.com/coreos/go-systemd/v22 v22.5.0 github.com/cuonglm/osinfo v0.0.0-20230921071424-e0e1b1e0bbbf diff --git a/go.sum b/go.sum index 9687f26..149cb5f 100644 --- a/go.sum +++ b/go.sum @@ -40,8 +40,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c h1:UqFsxmwiCh/DBvwJB0m7KQ2QFDd6DdUkosznfMppdhE= github.com/Windscribe/zerolog v0.0.0-20241206130353-cc6e8ef5397c/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= diff --git a/internal/controld/config.go b/internal/controld/config.go index 23542c7..595e758 100644 --- a/internal/controld/config.go +++ b/internal/controld/config.go @@ -45,6 +45,7 @@ type ResolverConfig struct { Ctrld struct { CustomConfig string `json:"custom_config"` CustomLastUpdate int64 `json:"custom_last_update"` + VersionTarget string `json:"version_target"` } `json:"ctrld"` Exclude []string `json:"exclude"` UID string `json:"uid"` From dacc67e50f13f6cb4a9395c1c6d4f63c5511d8df Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 19 Mar 2025 21:40:53 +0700 Subject: [PATCH 08/14] Using LAN servers from OS resolver for private resolver So heavy functions are only called once and could be re-used in subsequent calls to NewPrivateResolver. --- resolver.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/resolver.go b/resolver.go index 3da2574..bece9c0 100644 --- a/resolver.go +++ b/resolver.go @@ -478,10 +478,10 @@ func lookupIP(domain string, timeout int) (ips []string) { ProxyLogger.Load().Debug().Msgf("Initialize OS resolver in lookupIP") or = newResolverWithNameserver(defaultNameservers()) } - resolverMutex.Unlock() - nss := *or.lanServers.Load() nss = append(nss, *or.publicServers.Load()...) + resolverMutex.Unlock() + resolver := newResolverWithNameserver(nss) ProxyLogger.Load().Debug().Msgf("resolving %q using bootstrap DNS %q", domain, nss) timeoutMs := 2000 @@ -575,12 +575,13 @@ func NewBootstrapResolver(servers ...string) Resolver { // // This is useful for doing PTR lookup in LAN network. func NewPrivateResolver() Resolver { - - logger := *ProxyLogger.Load() - - Log(context.Background(), logger.Debug(), "NewPrivateResolver called") - - nss := defaultNameservers() + resolverMutex.Lock() + if or == nil { + ProxyLogger.Load().Debug().Msgf("Initialize new OS resolver in NewPrivateResolver") + or = newResolverWithNameserver(defaultNameservers()) + } + nss := *or.lanServers.Load() + resolverMutex.Unlock() resolveConfNss := nameserversFromResolvconf() localRfc1918Addrs := Rfc1918Addresses() n := 0 From c6365e6b74c14fa784eebc47c439cdb0939e55d0 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 20 Mar 2025 22:26:35 +0700 Subject: [PATCH 09/14] cmd/cli: handle stop signal from service manager So using "ctrld stop" or service manager to stop ctrld will end up with the same result, stopped ctrld with a working DNS, and deactivation pin code will always have effects if set. --- cmd/cli/cli.go | 30 ++++++++++++++++--------- cmd/cli/commands.go | 17 -------------- cmd/cli/control_server.go | 6 ++++- cmd/cli/prog.go | 39 +++++++++++++++++++++++++-------- cmd/cli/self_kill_unix.go | 2 +- cmd/cli/self_upgrade_others.go | 4 ++-- cmd/cli/self_upgrade_windows.go | 4 ++-- 7 files changed, 60 insertions(+), 42 deletions(-) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 2a9a4e9..c0b3fe1 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -199,6 +199,7 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { p := &prog{ waitCh: waitCh, stopCh: stopCh, + pinCodeValidCh: make(chan struct{}, 1), reloadCh: make(chan struct{}), reloadDoneCh: make(chan struct{}), dnsWatcherStopCh: make(chan struct{}), @@ -421,19 +422,28 @@ func run(appCallback *AppCallback, stopCh chan struct{}) { if err := p.router.Cleanup(); err != nil { mainLog.Load().Error().Err(err).Msg("could not cleanup router") } - // restore static DNS settings or DHCP - p.resetDNS(false, true) }) } } + p.onStopped = append(p.onStopped, func() { + // restore static DNS settings or DHCP + p.resetDNS(false, true) + // Iterate over all physical interfaces and restore static DNS if a saved static config exists. + withEachPhysicalInterfaces("", "restore static DNS", func(i *net.Interface) error { + file := savedStaticDnsSettingsFilePath(i) + if _, err := os.Stat(file); err == nil { + if err := restoreDNS(i); err != nil { + mainLog.Load().Error().Err(err).Msgf("Could not restore static DNS on interface %s", i.Name) + } else { + mainLog.Load().Debug().Msgf("Restored static DNS on interface %s successfully", i.Name) + } + } + return nil + }) + }) close(waitCh) <-stopCh - - p.stopDnsWatchers() - for _, f := range p.onStopped { - f() - } } func writeConfigFile(cfg *ctrld.Config) error { @@ -609,9 +619,9 @@ func init() { cdDeactivationPin.Store(defaultDeactivationPin) } -// deactivationPinNotSet reports whether cdDeactivationPin was not set by processCDFlags. -func deactivationPinNotSet() bool { - return cdDeactivationPin.Load() == defaultDeactivationPin +// deactivationPinSet indicates if cdDeactivationPin is non-default.. +func deactivationPinSet() bool { + return cdDeactivationPin.Load() != defaultDeactivationPin } func processCDFlags(cfg *ctrld.Config) (*controld.ResolverConfig, error) { diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 96e264b..048212a 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -629,23 +629,6 @@ func initStopCmd() *cobra.Command { os.Exit(deactivationPinInvalidExitCode) } if doTasks([]task{{s.Stop, true, "Stop"}}) { - p.router.Cleanup() - // restore static DNS settings or DHCP - p.resetDNS(false, true) - - // Iterate over all physical interfaces and restore static DNS if a saved static config exists. - withEachPhysicalInterfaces("", "restore static DNS", func(i *net.Interface) error { - file := savedStaticDnsSettingsFilePath(i) - if _, err := os.Stat(file); err == nil { - if err := restoreDNS(i); err != nil { - mainLog.Load().Error().Err(err).Msgf("Could not restore static DNS on interface %s", i.Name) - } else { - mainLog.Load().Debug().Msgf("Restored static DNS on interface %s successfully", i.Name) - } - } - return nil - }) - if router.WaitProcessExited() { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() diff --git a/cmd/cli/control_server.go b/cmd/cli/control_server.go index 17f585d..9281b90 100644 --- a/cmd/cli/control_server.go +++ b/cmd/cli/control_server.go @@ -228,7 +228,7 @@ func (p *prog) registerControlServerHandler() { } // If pin code not set, allowing deactivation. - if deactivationPinNotSet() { + if !deactivationPinSet() { w.WriteHeader(http.StatusOK) return } @@ -244,6 +244,10 @@ func (p *prog) registerControlServerHandler() { switch req.Pin { case cdDeactivationPin.Load(): code = http.StatusOK + select { + case p.pinCodeValidCh <- struct{}{}: + default: + } case defaultDeactivationPin: // If the pin code was set, but users do not provide --pin, return proper code to client. code = http.StatusBadRequest diff --git a/cmd/cli/prog.go b/cmd/cli/prog.go index 9c2fb11..089bfd0 100644 --- a/cmd/cli/prog.go +++ b/cmd/cli/prog.go @@ -87,6 +87,7 @@ type prog struct { mu sync.Mutex waitCh chan struct{} stopCh chan struct{} + pinCodeValidCh chan struct{} reloadCh chan struct{} // For Windows. reloadDoneCh chan struct{} apiReloadCh chan *ctrld.Config @@ -268,13 +269,6 @@ func (p *prog) preRun() { p.requiredMultiNICsConfig = requiredMultiNICsConfig() } p.runningIface = iface - if runtime.GOOS == "darwin" { - p.onStopped = append(p.onStopped, func() { - if !service.Interactive() { - p.resetDNS(false, true) - } - }) - } } func (p *prog) postRun() { @@ -622,14 +616,41 @@ func (p *prog) metricsEnabled() bool { func (p *prog) Stop(s service.Service) error { p.stopDnsWatchers() mainLog.Load().Debug().Msg("dns watchers stopped") + for _, f := range p.onStopped { + f() + } + mainLog.Load().Debug().Msg("finish running onStopped functions") defer func() { mainLog.Load().Info().Msg("Service stopped") }() - close(p.stopCh) if err := p.deAllocateIP(); err != nil { mainLog.Load().Error().Err(err).Msg("de-allocate ip failed") return err } + if deactivationPinSet() { + select { + case <-p.pinCodeValidCh: + // Allow stopping the service, pinCodeValidCh is only filled + // after control server did validate the pin code. + case <-time.After(time.Millisecond * 100): + // No valid pin code was checked, that mean we are stopping + // because of OS signal sent directly from someone else. + // In this case, restarting ctrld service by ourselves. + mainLog.Load().Debug().Msgf("receiving stopping signal without valid pin code") + mainLog.Load().Debug().Msgf("self restarting ctrld service") + if exe, err := os.Executable(); err == nil { + cmd := exec.Command(exe, "restart") + cmd.SysProcAttr = sysProcAttrForDetachedChildProcess() + if err := cmd.Start(); err != nil { + mainLog.Load().Error().Err(err).Msg("failed to run self restart command") + } + } else { + mainLog.Load().Error().Err(err).Msg("failed to self restart ctrld service") + } + os.Exit(deactivationPinInvalidExitCode) + } + } + close(p.stopCh) return nil } @@ -1471,7 +1492,7 @@ func selfUpgradeCheck(vt string, cv *semver.Version, logger *zerolog.Logger) { return } cmd := exec.Command(exe, "upgrade", "prod", "-vv") - cmd.SysProcAttr = sysProcAttrForSelfUpgrade() + cmd.SysProcAttr = sysProcAttrForDetachedChildProcess() if err := cmd.Start(); err != nil { mainLog.Load().Error().Err(err).Msg("failed to start self-upgrade") return diff --git a/cmd/cli/self_kill_unix.go b/cmd/cli/self_kill_unix.go index 9a68e60..157425f 100644 --- a/cmd/cli/self_kill_unix.go +++ b/cmd/cli/self_kill_unix.go @@ -22,7 +22,7 @@ func selfUninstall(p *prog, logger zerolog.Logger) { logger.Fatal().Err(err).Msg("could not determine executable") } args := []string{"uninstall"} - if !deactivationPinNotSet() { + if deactivationPinSet() { args = append(args, fmt.Sprintf("--pin=%d", cdDeactivationPin.Load())) } cmd := exec.Command(bin, args...) diff --git a/cmd/cli/self_upgrade_others.go b/cmd/cli/self_upgrade_others.go index f1ff140..0250c0e 100644 --- a/cmd/cli/self_upgrade_others.go +++ b/cmd/cli/self_upgrade_others.go @@ -6,7 +6,7 @@ import ( "syscall" ) -// sysProcAttrForSelfUpgrade returns *syscall.SysProcAttr instance for running self-upgrade command. -func sysProcAttrForSelfUpgrade() *syscall.SysProcAttr { +// sysProcAttrForDetachedChildProcess returns *syscall.SysProcAttr instance for running a detached child command. +func sysProcAttrForDetachedChildProcess() *syscall.SysProcAttr { return &syscall.SysProcAttr{Setsid: true} } diff --git a/cmd/cli/self_upgrade_windows.go b/cmd/cli/self_upgrade_windows.go index 213aec9..a6f37be 100644 --- a/cmd/cli/self_upgrade_windows.go +++ b/cmd/cli/self_upgrade_windows.go @@ -9,8 +9,8 @@ import ( // SYSCALL_CREATE_NO_WINDOW set flag to run process without a console window. const SYSCALL_CREATE_NO_WINDOW = 0x08000000 -// sysProcAttrForSelfUpgrade returns *syscall.SysProcAttr instance for running self-upgrade command. -func sysProcAttrForSelfUpgrade() *syscall.SysProcAttr { +// sysProcAttrForDetachedChildProcess returns *syscall.SysProcAttr instance for running self-upgrade command. +func sysProcAttrForDetachedChildProcess() *syscall.SysProcAttr { return &syscall.SysProcAttr{ CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | SYSCALL_CREATE_NO_WINDOW, HideWindow: true, From a9ed70200bd3f568c9954d1282d8bc733fc10057 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Mon, 24 Mar 2025 23:13:11 +0700 Subject: [PATCH 10/14] internal/router: change dnsmasq config manipulation on Merlin Generally, using /jffs/scripts/dnsmasq.postconf is the right way to add custom configuration to dnsmasq on Merlin. However, we have seen many reports that the postconf does not work on their devices. This commit changes how dnsmasq config manipulation is done on Merlin, so it's expected to work on all Merlin devices: - Writing /jffs/scripts/dnsmasq.postconf script - Copy current dnsmasq.conf to /jffs/configs/dnsmasq.conf - Run postconf script directly on /jffs/configs/dnsmasq.conf - Restart dnsmasq This way, the /jffs/configs/dnsmasq.conf will contain both current dnsmasq config, and also custom config added by ctrld, without worrying about conflicting, because configuration was added by postconf. See (1) for more details about custom config files on Merlin. (1) https://github.com/RMerl/asuswrt-merlin.ng/wiki/Custom-config-files --- internal/router/dnsmasq/dnsmasq.go | 2 + internal/router/merlin/merlin.go | 78 +++++++++++++++++++++++------- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/internal/router/dnsmasq/dnsmasq.go b/internal/router/dnsmasq/dnsmasq.go index 55c62e8..819bd59 100644 --- a/internal/router/dnsmasq/dnsmasq.go +++ b/internal/router/dnsmasq/dnsmasq.go @@ -26,6 +26,8 @@ max-cache-ttl=0 {{- end}} ` +const MerlinConfPath = "/tmp/etc/dnsmasq.conf" +const MerlinJffsConfPath = "/jffs/configs/dnsmasq.conf" const MerlinPostConfPath = "/jffs/scripts/dnsmasq.postconf" const MerlinPostConfMarker = `# GENERATED BY ctrld - EOF` const MerlinPostConfTmpl = `# GENERATED BY ctrld - DO NOT MODIFY diff --git a/internal/router/merlin/merlin.go b/internal/router/merlin/merlin.go index 8b6a0fc..cacc508 100644 --- a/internal/router/merlin/merlin.go +++ b/internal/router/merlin/merlin.go @@ -3,6 +3,7 @@ package merlin import ( "bytes" "fmt" + "io" "os" "os/exec" "strings" @@ -73,30 +74,42 @@ func (m *Merlin) Setup() error { if val, _ := nvram.Run("get", nvram.CtrldSetupKey); val == "1" { return nil } - buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) - // Already setup. - if bytes.Contains(buf, []byte(dnsmasq.MerlinPostConfMarker)) { - return nil - } - if err != nil && !os.IsNotExist(err) { + + if err := m.writeDnsmasqPostconf(); err != nil { return err } - data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) + // Copy current dnsmasq config to /jffs/configs/dnsmasq.conf, + // Then we will run postconf script on this file. + // + // Normally, adding postconf script is enough. However, we see + // reports on some Merlin devices that postconf scripts does not + // work, but manipulating the config directly via /jffs/configs does. + src, err := os.Open(dnsmasq.MerlinConfPath) if err != nil { - return err + return fmt.Errorf("failed to open dnsmasq config: %w", err) } - data = strings.Join([]string{ - data, - "\n", - dnsmasq.MerlinPostConfMarker, - "\n", - string(buf), - }, "\n") - // Write dnsmasq post conf file. - if err := os.WriteFile(dnsmasq.MerlinPostConfPath, []byte(data), 0750); err != nil { - return err + defer src.Close() + + dst, err := os.Create(dnsmasq.MerlinJffsConfPath) + if err != nil { + return fmt.Errorf("failed to create %s: %w", dnsmasq.MerlinJffsConfPath, err) } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("failed to copy current dnsmasq config: %w", err) + } + if err := dst.Close(); err != nil { + return fmt.Errorf("failed to save %s: %w", dnsmasq.MerlinJffsConfPath, err) + } + + // Run postconf script on /jffs/configs/dnsmasq.conf directly. + cmd := exec.Command("/bin/sh", dnsmasq.MerlinPostConfPath, dnsmasq.MerlinJffsConfPath) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to run post conf: %s: %w", string(out), err) + } + // Restart dnsmasq service. if err := restartDNSMasq(); err != nil { return err @@ -130,6 +143,10 @@ func (m *Merlin) Cleanup() error { if err := os.WriteFile(dnsmasq.MerlinPostConfPath, merlinParsePostConf(buf), 0750); err != nil { return err } + // Remove /jffs/configs/dnsmasq.conf file. + if err := os.Remove(dnsmasq.MerlinJffsConfPath); err != nil && !os.IsNotExist(err) { + return err + } // Restart dnsmasq service. if err := restartDNSMasq(); err != nil { return err @@ -137,6 +154,31 @@ func (m *Merlin) Cleanup() error { return nil } +func (m *Merlin) writeDnsmasqPostconf() error { + buf, err := os.ReadFile(dnsmasq.MerlinPostConfPath) + // Already setup. + if bytes.Contains(buf, []byte(dnsmasq.MerlinPostConfMarker)) { + return nil + } + if err != nil && !os.IsNotExist(err) { + return err + } + + data, err := dnsmasq.ConfTmpl(dnsmasq.MerlinPostConfTmpl, m.cfg) + if err != nil { + return err + } + data = strings.Join([]string{ + data, + "\n", + dnsmasq.MerlinPostConfMarker, + "\n", + string(buf), + }, "\n") + // Write dnsmasq post conf file. + return os.WriteFile(dnsmasq.MerlinPostConfPath, []byte(data), 0750) +} + func restartDNSMasq() error { if out, err := exec.Command("service", "restart_dnsmasq").CombinedOutput(); err != nil { return fmt.Errorf("restart_dnsmasq: %s, %w", string(out), err) From b7ccfcb8b475a4471fbef9330d112a54115c481a Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 27 Mar 2025 20:11:57 +0700 Subject: [PATCH 11/14] Do not include commit hash when releasing tag --- cmd/cli/cli.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index c0b3fe1..ecdd113 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -97,6 +97,9 @@ func curVersion() string { if version != "dev" && !strings.HasPrefix(version, "v") { version = "v" + version } + if version != "" && version != "dev" { + return version + } if len(commit) > 7 { commit = commit[:7] } From c651003cc424253364ea0490aa65049af2d50add Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 27 Mar 2025 19:42:23 +0700 Subject: [PATCH 12/14] Support direct ip in lookupIP function So users can supply ip directly in config, avoiding unnecessary domain lookup while bootstrapping. --- resolver.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resolver.go b/resolver.go index bece9c0..52a17fc 100644 --- a/resolver.go +++ b/resolver.go @@ -473,6 +473,9 @@ func LookupIP(domain string) []string { } func lookupIP(domain string, timeout int) (ips []string) { + if net.ParseIP(domain) != nil { + return []string{domain} + } resolverMutex.Lock() if or == nil { ProxyLogger.Load().Debug().Msgf("Initialize OS resolver in lookupIP") From 3937e885f03b9890c9da54e4ff1774770c1520bc Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Tue, 1 Apr 2025 19:54:16 +0700 Subject: [PATCH 13/14] Bump golang.org/x/net to v0.38.0 Fixes CVE-2025-22872 --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index dd80ffe..1d94a07 100644 --- a/go.mod +++ b/go.mod @@ -36,9 +36,9 @@ require ( github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.9.0 github.com/vishvananda/netlink v1.2.1-beta.2 - golang.org/x/net v0.36.0 - golang.org/x/sync v0.11.0 - golang.org/x/sys v0.30.0 + golang.org/x/net v0.38.0 + golang.org/x/sync v0.12.0 + golang.org/x/sys v0.31.0 golang.zx2c4.com/wireguard/windows v0.5.3 tailscale.com v1.74.0 ) @@ -92,10 +92,10 @@ require ( go.uber.org/mock v0.4.0 // indirect go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.35.0 // indirect + golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/mod v0.19.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/text v0.23.0 // indirect golang.org/x/tools v0.23.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 149cb5f..25af133 100644 --- a/go.sum +++ b/go.sum @@ -346,8 +346,8 @@ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -417,8 +417,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= -golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -438,8 +438,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -488,8 +488,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -500,8 +500,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= From 433a61d2ee23a125b1877752cfb3d104991da885 Mon Sep 17 00:00:00 2001 From: Yegor Sak Date: Fri, 4 Apr 2025 19:40:27 +0000 Subject: [PATCH 14/14] Update file README.md --- README.md | 198 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 113 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index 66e70c3..5b048ca 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/Control-D-Inc/ctrld.svg)](https://pkg.go.dev/github.com/Control-D-Inc/ctrld) [![Go Report Card](https://goreportcard.com/badge/github.com/Control-D-Inc/ctrld)](https://goreportcard.com/report/github.com/Control-D-Inc/ctrld) -![ctrld spash image](/docs/ctrldsplash.png) +![ctrld splash image](/docs/ctrldsplash.png) A highly configurable DNS forwarding proxy with support for: - Multiple listeners for incoming queries - Multiple upstreams with fallbacks -- Multiple network policy driven DNS query steering +- Multiple network policy driven DNS query steering (via network cidr, MAC address or FQDN) - Policy driven domain based "split horizon" DNS with wildcard support - Integrations with common router vendors and firmware - LAN client discovery via DHCP, mDNS, ARP, NDP, hosts file parsing @@ -35,13 +35,29 @@ All DNS protocols are supported, including: ## OS Support - Windows (386, amd64, arm) -- Mac (amd64, arm64) +- Windows Server (386, amd64) +- MacOS (amd64, arm64) - Linux (386, amd64, arm, mips) -- FreeBSD -- Common routers (See Router Mode below) +- FreeBSD (386, amd64, arm) +- Common routers (See below) + + +### Supported Routers +You can run `ctrld` on any supported router. The list of supported routers and firmware includes: +- Asus Merlin +- DD-WRT +- Firewalla +- FreshTomato +- GL.iNet +- OpenWRT +- pfSense / OPNsense +- Synology +- Ubiquiti (UniFi, EdgeOS) + +`ctrld` will attempt to interface with dnsmasq (or Windows Server) whenever possible and set itself as the upstream, while running on port 5354. On FreeBSD based OSes, `ctrld` will terminate dnsmasq and unbound in order to be able to listen on port 53 directly. # Install -There are several ways to download and install `ctrld. +There are several ways to download and install `ctrld`. ## Quick Install The simplest way to download and install `ctrld` is to use the following installer command on any UNIX-like platform: @@ -50,14 +66,14 @@ The simplest way to download and install `ctrld` is to use the following install sh -c 'sh -c "$(curl -sL https://api.controld.com/dl)"' ``` -Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative cmd: +Windows user and prefer Powershell (who doesn't)? No problem, execute this command instead in administrative PowerShell: ```shell -powershell -Command "(Invoke-WebRequest -Uri 'https://api.controld.com/dl' -UseBasicParsing).Content | Set-Content 'ctrld_install.bat'" && ctrld_install.bat +(Invoke-WebRequest -Uri 'https://api.controld.com/dl/ps1' -UseBasicParsing).Content | Set-Content "$env:TEMPctrld_install.ps1"; Invoke-Expression "& '$env:TEMPctrld_install.ps1'" ``` Or you can pull and run a Docker container from [Docker Hub](https://hub.docker.com/r/controldns/ctrld) -``` -$ docker pull controldns/ctrld +```shell +docker run -d --name=ctrld -p 127.0.0.1:53:53/tcp -p 127.0.0.1:53:53/udp controldns/ctrld:latest ``` ## Download Manually @@ -67,20 +83,19 @@ Alternatively, if you know what you're doing you can download pre-compiled binar Lastly, you can build `ctrld` from source which requires `go1.21+`: ```shell -$ go build ./cmd/ctrld +go build ./cmd/ctrld ``` or ```shell -$ go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest +go install github.com/Control-D-Inc/ctrld/cmd/ctrld@latest ``` or -``` -$ docker build -t controldns/ctrld . -f docker/Dockerfile -$ docker run -d --name=ctrld -p 53:53/tcp -p 53:53/udp controldns/ctrld --cd=RESOLVER_ID_GOES_HERE -vv +```shell +docker build -t controldns/ctrld . -f docker/Dockerfile ``` @@ -101,15 +116,16 @@ Usage: Available Commands: run Run the DNS proxy server - service Manage ctrld service start Quick start service and configure DNS on interface stop Quick stop service and remove DNS from interface restart Restart the ctrld service reload Reload the ctrld service status Show status of the ctrld service uninstall Stop and uninstall the ctrld service + service Manage ctrld service clients Manage clients upgrade Upgrading ctrld to latest version + log Manage runtime debug logs Flags: -h, --help help for ctrld @@ -121,81 +137,99 @@ Use "ctrld [command] --help" for more information about a command. ``` ## Basic Run Mode -To start the server with default configuration, simply run: `./ctrld run`. This will create a generic `ctrld.toml` file in the **working directory** and start the application in foreground. -1. Start the server - ``` - $ sudo ./ctrld run +This is the most basic way to run `ctrld`, in foreground mode. Unless you already have a config file, a default one will be generated. + +### Command + +Windows (Admin Shell) + ```shell + ctrld.exe run ``` -2. Run a test query using a DNS client, for example, `dig`: +Linux or Macos + ```shell + sudo ctrld run + ``` + +You can then run a test query using a DNS client, for example, `dig`: ``` $ dig verify.controld.com @127.0.0.1 +short api.controld.com. 147.185.34.1 ``` -If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file and go nuts with it. To enforce a new config, restart the server. +If `verify.controld.com` resolves, you're successfully using the default Control D upstream. From here, you can start editing the config file that was generated. To enforce a new config, restart the server. ## Service Mode -To run the application in service mode on any Windows, MacOS, Linux distibution or supported router, simply run: `./ctrld start` as system/root user. This will create a generic `ctrld.toml` file in the **user home** directory (on Windows) or `/etc/controld/` (almost everywhere else), start the system service, and configure the listener on the default network interface. Service will start on OS boot. +This mode will run the application as a background system service on any Windows, MacOS, Linux, FreeBSD distribution or supported router. This will create a generic `ctrld.toml` file in the **C:\ControlD** directory (on Windows) or `/etc/controld/` (almost everywhere else), start the system service, and **configure the listener on all physical network interface**. Service will start on OS boot. -When Control D upstreams are used, `ctrld` willl [relay your network topology](https://docs.controld.com/docs/device-clients) to Control D (LAN IPs, MAC addresses, and hostnames), and you will be able to see your LAN devices in the web panel, view analytics and apply unique profiles to them. +When Control D upstreams are used on a router type device, `ctrld` will [relay your network topology](https://docs.controld.com/docs/device-clients) to Control D (LAN IPs, MAC addresses, and hostnames), and you will be able to see your LAN devices in the web panel, view analytics and apply unique profiles to them. -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`. +### Command +Windows (Admin Shell) + ```shell + ctrld.exe start + ``` -### Supported Routers -You can run `ctrld` on any supported router, which will function similarly to the Service Mode mentioned above. The list of supported routers and firmware includes: -- Asus Merlin -- DD-WRT -- Firewalla -- FreshTomato -- GL.iNet -- OpenWRT -- pfSense / OPNsense -- Synology -- Ubiquiti (UniFi, EdgeOS) +Linux or Macos + ``` + sudo ctrld start + ``` -`ctrld` will attempt to interface with dnsmasq whenever possible and set itself as the upstream, while running on port 5354. On FreeBSD based OSes, `ctrld` will terminate dnsmasq and unbound in order to be able to listen on port 53 directly. +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`. +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`. -### Control D Auto Configuration -Application can be started with a specific resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `run` (foreground) or `start` (service) modes. +## 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. -The following command will start the application in foreground mode, using the free "p2" resolver, which blocks Ads & Trackers. +### Command -```shell -./ctrld run --cd p2 -``` +Windows (Admin Shell) + ```shell + ctrld.exe service start + ``` -Alternatively, you can use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Device. - -```shell -./ctrld start --cd abcd1234 -``` - -Once you run the above commands (in service mode only), the following things will happen: -- You resolver configuration will be fetched from the API, and config file templated with the resolver data -- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `uninstall` sub-commands -- Your default network interface will be updated to use the listener started by the service -- All OS DNS queries will be sent to the listener +Linux or Macos + ```shell + sudo ctrld service start + ``` # Configuration -See [Configuration Docs](docs/config.md). +`ctrld` can be configured in variety of different ways, which include: API, local config file or via cli launch args. -## Example -- Start `listener.0` on 127.0.0.1:53 -- Accept queries from any source address -- Send all queries to `upstream.0` via DoH protocol +## API Based Auto Configuration +Application can be started with a specific Control D resolver config, instead of the default one. Simply supply your Resolver ID with a `--cd` flag, when using the `start` (service) mode. In this mode, the application will automatically choose a non-conflicting IP and/or port and configure itself as the upstream to whatever process is running on port 53 (like dnsmasq or Windows DNS Server). This mode is used when the 1 liner installer command from the Control D onboarding guide is executed. -### Default Config +The following command will use your own personal Control D Device resolver, and start the application in service mode. Your resolver ID is displayed on the "Show Resolvers" screen for the relevant Control D Endpoint. + +Windows (Admin Shell) +```shell +ctrld.exe start --cd abcd1234 +``` + +Linux or Macos +```shell +sudo ctrld start --cd abcd1234 +``` + +Once you run the above command, the following things will happen: +- You resolver configuration will be fetched from the API, and config file templated with the resolver data +- Application will start as a service, and keep running (even after reboot) until you run the `stop` or `uninstall` sub-commands +- All physical network interface will be updated to use the listener started by the service or dnsmasq upstream will be switched to `ctrld` +- All DNS queries will be sent to the listener + +## Manual Configuration +`ctrld` is entirely config driven and can be configured in many different ways, please see [Configuration Docs](docs/config.md). + +### Example ```toml [listener] [listener.0] - ip = "" - port = 0 - restricted = false + ip = '0.0.0.0' + port = 53 [network] @@ -203,10 +237,6 @@ See [Configuration Docs](docs/config.md). cidrs = ["0.0.0.0/0"] name = "Network 0" -[service] - log_level = "info" - log_path = "" - [upstream] [upstream.0] @@ -215,28 +245,26 @@ See [Configuration Docs](docs/config.md). name = "Control D - Anti-Malware" timeout = 5000 type = "doh" - - [upstream.1] - bootstrap_ip = "76.76.2.11" - endpoint = "p2.freedns.controld.com" - name = "Control D - No Ads" - timeout = 3000 - type = "doq" - ``` -`ctrld` will pick a working config for `listener.0` then writing the default config to disk for the first run. +The above basic config will: +- Start listener on 0.0.0.0:53 +- Accept queries from any source address +- Send all queries to `https://freedns.controld.com/p1` using DoH protocol -## Advanced Configuration -The above is the most basic example, which will work out of the box. If you're looking to do advanced configurations using policies, see [Configuration Docs](docs/config.md) for complete documentation of the config file. +## CLI Args +If you're unable to use a config file, `ctrld` can be be supplied with basic configuration via launch arguments, in [Ephemeral Mode](docs/ephemeral_mode.md). -You can also supply configuration via launch argeuments, in [Ephemeral Mode](docs/ephemeral_mode.md). +### 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 +``` + +The above will start a foreground process and: +- Listen on `127.0.0.1:53` for DNS queries +- Forward all queries to `https://freedns.controld.com/p2` using DoH protocol, while... +- Excluding `*.company.int` and `very-secure.local` matching queries, that are forwarded to `10.0.10.1:53` +- Write a debug log to `/path/to/log.log` ## Contributing See [Contribution Guideline](./docs/contributing.md) - -## Roadmap -The following functionality is on the roadmap and will be available in future releases. -- DNS intercept mode -- Direct listener mode -- Support for more routers (let us know which ones)