local connect: add opt-in --local-dns for resolving cluster names via a local DNS resolver#342
local connect: add opt-in --local-dns for resolving cluster names via a local DNS resolver#342scott-cotton wants to merge 25 commits into
Conversation
DEV-ONLY: enables the replace directive so the CLI builds against the local libconnect phase-1 branch (localdns). Revert and bump the pinned libconnect version before this lands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the opt-in `--local-dns` flag to `signadot local connect` and threads it through ConnectInvocationConfig.EnableLocalDNS (the JSON config that crosses the sudo boundary to the root-manager). The root-privilege rationale message reflects DNS-resolver configuration when the flag is set. No behavior yet — the root-manager wiring lands next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the libconnect localdns resolver into the root-manager as the cluster-name resolution service, selected by ConnectInvocationConfig. EnableLocalDNS: - runNameService/stopNameService dispatch to localdns (ManageResolver + Forward) or the legacy etchosts based on the flag, used at both start sites (direct-SOCKS5 path and the tunnel-proxy monitor restart). - runLocalDNSService creates the injected tunnel-api client (CLI-owned) and starts localdns.New; stopLocalDNSService closes the service then the client. A start failure tears down localnet and fails the connect. - Teardown reorders name-resolution-before-NAT so localdns restores the OS resolver path before the cluster goes unreachable. - rootServer holds the localdns service + its apiclient alongside the existing localnet/etchosts slots. Status rendering for localdns lands separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a LocalDNSStatus message to common.proto and a field on both the root-manager and sandbox-manager StatusResponses (regenerated). The root-manager populates it from localdns.Service.Status(); the sandbox manager forwards it; the CLI renders it. `local status` shows the local DNS resolver (record count, bind address, and a prominent warning when a system DNS manager may revert resolv.conf) in place of the /etc/hosts line when --local-dns is active, in both the table and JSON/details output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
--local-dns manages the system DNS resolver (and binds 127.0.0.54:53 on Linux), which requires root. In --unprivileged mode there is no root-manager to run it, so fail fast with a clear error instead of silently ignoring the flag. (Logically belongs with the --local-dns flag commit; kept separate since this environment can't interactive-rebase to fold it in.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CheckStatusConnectErrors always validated status.Hosts, which is nil under --local-dns (the root manager runs localdns in place of the /etc/hosts mechanism). Connect therefore failed the health gate with "failed to configure hosts in /etc/hosts" even though local DNS was healthy. Branch on EnableLocalDNS to check the LocalDns status instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the local-checkout replace used during phase-1 development and pin libconnect to the pushed feat/localdns-1f tip (cb3c7da), so the branch builds and CI runs against the real module. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts: # go.mod # go.sum
|
Just adding a comment here @scott-cotton as per our call earlier - from a usability perspective, using the current |
Good call-out, and I had forgotten about that. In 0e6c121 ptal |
TIL; PTAL! LGTM 😂 Tested locally with the new commit on this branch it looks good (same output from |
foxish
left a comment
There was a problem hiding this comment.
Reviewed and tested this locally: all packages build, go vet is clean, the full test suite passes, and I smoke-tested the new CLI surface (--local-dns + --unprivileged fails fast as intended, help text, local hosts output paths). Nice work overall: the runNameService/stopNameService split is clean, teardown ordering (resolver restored before the tunnel drops) is well thought out, and localdns.New failing synchronously on bind conflicts while refreshing records asynchronously gives the right failure semantics.
Inline comments cover the code-level items. Three of them I'd consider blocking: the tpMonitor restart loop in --local-dns mode, the stray tp_monitor.go.head file, and the orphaned sandboxmanager on the new startup error path.
Cross-cutting items that don't anchor to a single line:
1. Crash recovery only runs if the next connect also passes --local-dns. The repair logic lives in libconnect's localdns installer and only executes inside localdns.New, which the CLI only calls when EnableLocalDNS is set. On Linux, a --local-dns session that dies uncleanly leaves /etc/resolv.conf pointing at a dead 127.0.0.54:53, i.e. all DNS on the machine is broken; if the user then runs a plain signadot local connect (the default, and the likely move for someone whose DNS just broke), recovery never runs and system DNS stays broken. That contradicts the description's claim that "the next signadot local connect detects the leftover configuration and repairs it on startup". Suggest exporting a standalone repair entry point from libconnect (feedback for signadot/libconnect#163 while it's still open) and calling it unconditionally at root-manager startup in both modes; at minimum the PR description should be corrected.
2. signadot local hosts is a new user-facing command but isn't mentioned in the PR description. It's independently useful (works in both resolution modes since it reads the shared ipmap); it should be described here and get docs.
3. No tests accompany the change. pickIP, checkLocalDNSStatus, and the status-printer gating are pure functions that are cheap to unit test, and a mode-matrix test on the tpMonitor health check would have caught the restart loop.
Also: we'd like the config file, not the flag, to be the encouraged way to opt in; see the inline comment on the flag definition for a concrete field proposal (resolver: EtcHosts | LocalDNS per connection).
| mon.root.runEtcHostsService(ctx, mon.tpLocalAddr, mon.ipMap) | ||
| // Restart name resolution (localdns or etchosts) | ||
| mon.root.stopNameService() | ||
| if err := mon.root.runNameService(ctx, mon.tpLocalAddr, mon.ipMap); err != nil { |
There was a problem hiding this comment.
Blocking: restart loop in --local-dns mode. checkRootServer (L206) still gates health on the /etc/hosts service: if r.etcHostsSVC == nil || !r.etcHostsSVC.Status().Healthy. With --local-dns, etcHostsSVC is always nil (localDNSSVC is set instead), and this monitor runs for exactly the PortForward and ControlPlaneProxy link types. So once the startup grace period (connectTimeout, default 10s) expires, every check returns restart=true, and localnet + localdns get torn down and restarted every ~10s indefinitely, uninstalling and reinstalling the OS resolver config each cycle. It also means localdns health is never actually monitored.
checkRootServer needs to branch on ciConfig.EnableLocalDNS and check localDNSSVC health instead, ideally via the mutex-guarded accessors (the raw field reads here are a pre-existing race that now extends to the new fields).
There was a problem hiding this comment.
Confirmed empirically: built this branch, connected to a real cluster with --local-dns over a PortForward link. The daemon restarts localnet + localdns every ~10s, continuously (observed for over an hour), binding a new ephemeral port each cycle and rewriting all 12 /etc/resolver files + HUPing mDNSResponder each time:
15:50:41 INFO msg="restarting localnet and name-resolution services"
15:50:41 INFO msg="localdns listening" addr=127.0.0.1:55286
15:50:51 INFO msg="restarting localnet and name-resolution services"
15:50:51 INFO msg="localdns listening" addr=127.0.0.1:63746
15:51:01 INFO msg="restarting localnet and name-resolution services"
15:51:02 INFO msg="localdns listening" addr=127.0.0.1:53087
...
Easy to miss because local status reports healthy between restarts and resolution genuinely works (FQDN/short forms resolve, end-to-end curl through localnet returns 200).
| @@ -0,0 +1,218 @@ | |||
| package rootmanager | |||
There was a problem hiding this comment.
Blocking (trivial): this looks like a leftover merge-conflict artifact, an old copy of tp_monitor.go (it still has the pre-connectTimeout maxStartingTime logic). The extension keeps it out of the build, but it should be deleted.
| m.runEtcHostsService(ctx, m.ciConfig.ConnectionConfig.ProxyAddress, ipMap) | ||
| if err := m.runNameService(ctx, m.ciConfig.ConnectionConfig.ProxyAddress, ipMap); err != nil { | ||
| _ = m.stopLocalnetService() | ||
| return fmt.Errorf("error starting name resolution: %w", err) |
There was a problem hiding this comment.
Blocking: orphaned sandboxmanager on this error path. This early return skips the cleanup block at the end of Run(), notably m.sbmMonitor.stop(). The sandbox manager was already spawned above (go m.sbmMonitor.run()) as a separately daemonized child via sudo -u <user>, and stop() is the only thing that kills it. So a localdns startup failure here (realistic case: something else already bound 127.0.0.54:53 on Linux) exits the root manager but leaves an orphaned sandboxmanager running with its pidfile held. Before this PR this branch had no error path, so this is new. Suggest routing the error through the same cleanup used by the normal shutdown path below.
| cmd.Flags().StringVar(&c.Cluster, "cluster", "", "specify cluster connection config") | ||
|
|
||
| cmd.Flags().BoolVar(&c.Unprivileged, "unprivileged", false, "run without root privileges") | ||
| cmd.Flags().BoolVar(&c.LocalDNS, "local-dns", false, |
There was a problem hiding this comment.
We'd like the config file to be the encouraged way to opt in, with this flag as a per-invocation override: forgetting the flag on the next connect is the natural failure mode, and a --local-dns session followed by a plain reconnect is also exactly the case where crash recovery doesn't run (see review body).
Concrete proposal: a per-connection field in libconnect's ConnectionConfig, e.g.
local:
connections:
- cluster: demo
resolver: LocalDNS # or EtcHosts; omitted = EtcHostsEnum rather than bool (leaves room for Auto later), PascalCase values to match type:, zero value EtcHosts so existing configs are untouched. runConnect already has the ConnectionConfig in hand before building the ciConfig, so the CLI plumbing is small; the --unprivileged conflict check should then also fire when the mode comes from config, with an error naming the config field. Fine as a fast-follow alongside libconnect#163, but worth agreeing on the field now so docs can lead with it.
There was a problem hiding this comment.
This is a good idea @foxish.
One question here @scott-cotton - do we still check at runtime, or record at the actual point of connecting, which resolver method was used, rather than relying on the config file/command-line switches to tell us?
I'm just thinking of the case where the value in the config file changes while the CLI is connected, eg someone connects using the default /etc/hosts method, then while connected modifies the config file to use the LocalDNS setting.
There was a problem hiding this comment.
The CLI is designed to have race-free local connect config, it records the config at invocation time and sends that to the locald process, thus making it impossible to unexpectedly change the config or be unaware of what the config is due to it changing while running.
this is actually necessary because the config includes, for example, the auth (and apiserver) and the possibility of having parse errors, of cli invocation overriding the config, etc. All these things make the expected flow quite complex for a live config file.
So, if someone changes anything in the config, it has no effect and they should restart, which IMO is by far the least friction approach.
| // both are present a resolver answers a name with one address at a time. | ||
| type printableHost struct { | ||
| Name string `json:"name"` | ||
| IP string `json:"ip"` |
There was a problem hiding this comment.
Schema forward-compat: this bakes a single ip into the -o json/-o yaml output, while the proto already carries repeated ips and this PR lays IPv6 dual-stack groundwork. When AAAA support lands, either ip gets a breaking rename to ips for script consumers, or it stays and silently under-reports dual-stack hosts. Emitting "ips": [...] from day one avoids the breaking change; the human-readable view can keep showing one address.
There was a problem hiding this comment.
I will change the proto to be singleton ip. This doesn't affect ipv6/dual-stack forward compatibility and is simpler, following the table below:
| ipv4 | dual-stack | ipv6 |
|---|---|---|
| use ipv4 | use ipv4 (both virtual addrs reach the service; v4 by preference) | use ipv6 |
the ipMap representation retains []ip as it is the backing for the resolver which needs to answer AAAA and A record requests.
|
|
||
| // printHosts writes one "<fqdn> <ip>" line per host. The entries arrive already | ||
| // sorted by name from the root controller (see rootServer.GetHosts). | ||
| func printHosts(out io.Writer, hosts []printableHost) error { |
There was a problem hiding this comment.
Default output should use internal/sdtab like every other list command (cluster, routegroup, resourceplugin, ...), so humans get an aligned NAME/IP table with a header, and scripts use -o json. The bare "%s %s" lines are also not hosts-file format (that's <ip> <name>, reversed), so there's no copy-paste argument for keeping them. Following the pattern from cluster/printers.go:
type hostRow struct {
Name string `sdtab:"NAME"`
IP string `sdtab:"IP"`
}
func printHosts(out io.Writer, hosts []printableHost) error {
t := sdtab.New[hostRow](out)
t.AddHeader()
for _, h := range hosts {
t.AddRow(hostRow{Name: h.Name, IP: h.IP})
}
return t.Flush()
}There was a problem hiding this comment.
My reasoning for the standard output was that this command is different because it is likely to get many many more outputs than another list using sdtab. So, the headers basically are likely to be invisible, off screen in any case -- why force the acrobatics and extra machinery of json or header filtering on consumers?
More generally, removing the headers is a basic UX concern regarding standard unix pipelining.
But, since it seems this reasoning only subtly justifies deviating from the unusable-in-pipelines UX elsewhere, I'll add the sdtab.
|
|
||
| // EnableLocalDNS runs the libconnect localdns resolver (managing the OS | ||
| // resolver config) in place of the /etc/hosts mechanism for cluster names. | ||
| EnableLocalDNS bool `json:"enableLocalDNS,omitempty"` |
There was a problem hiding this comment.
gofmt: this insertion breaks the struct-tag alignment for the rest of the field block; gofmt -l flags this file on the branch (it's clean on main).
| return result | ||
| } | ||
|
|
||
| func getRawLocalDNS(cfg *config.LocalStatus, ciConfig *config.ConnectInvocationConfig, |
There was a problem hiding this comment.
The inverse gating is missing on getRawHosts: in --local-dns mode the root manager reports Hosts == nil, so the non-details JSON/YAML status output still emits "hosts": {"healthy": false, "numHosts": 0}, which reads as a failure even though /etc/hosts management is intentionally not running. Suggest getRawHosts return nil when ciConfig.EnableLocalDNS, mirroring how this function returns nil when local DNS is disabled. (The human-readable view is already handled by the printSuccess branch.)
| sbManagerClient := sbmapi.NewSandboxManagerAPIClient(grpcConn) | ||
| resp, err := sbManagerClient.GetHosts(context.Background(), &sbmapi.GetHostsRequest{}) | ||
| if err != nil { | ||
| return nil, processGRPCError("unable to get hosts from sandboxmanager", err) |
There was a problem hiding this comment.
Tested against a running daemon from a previous CLI release: this surfaces as tunnel-api unsupported method: consider upgrading the operator, because processGRPCError maps codes.Unimplemented to operator guidance. For GetHosts the call is CLI-to-local-daemon, and Unimplemented means the running locald predates this CLI, which will happen to everyone who upgrades the CLI while connected. Worth catching Unimplemented here with a targeted message, e.g. "the running local daemon predates 'local hosts'; run signadot local disconnect && signadot local connect".
Pulls in the standalone crash-recovery entry point and the per-connection resolver enum (EtcHosts|LocalDNS) added on the localdns branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route the name-service startup error through the full cleanup so the already-spawned sandboxmanager child isn't orphaned. Also call localdns.Recover unconditionally at startup, so a plain connect after a crashed --local-dns session repairs the OS resolver regardless of this session's mode. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
checkRootServer gated tunnel-proxy health on the /etc/hosts service, which is always nil under --local-dns, so PortForward/ControlPlaneProxy links tore down and reinstalled localnet + the OS resolver config every ~10s. Gate on whichever name service is active for the mode, via the mutex-guarded accessors (closing a pre-existing race). The decision is extracted into a pure evalRootServerHealth for a mode-matrix test. Also drops a stray tp_monitor.go.head merge artifact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A connection's resolver: EtcHosts|LocalDNS field is now the encouraged way to opt in; --local-dns remains a per-invocation override applied only when explicitly set. The --unprivileged conflict check fires for the config-sourced mode too, naming the source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A host resolves to one virtual address, so collapse HostEntry.ips to a scalar ip at the gRPC boundary and select it (IPv4-preferred) in the root manager; the ipMap keeps []ip as the backing the resolver needs for A/AAAA answers. Default output uses the shared sdtab table for consistency with the other list commands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ofmt getRawHosts returns nil under --local-dns so status JSON/YAML no longer emits a bogus failed /etc/hosts section. GetHosts maps a daemon-side Unimplemented to disconnect/reconnect guidance rather than the operator-upgrade message. Realign ConnectInvocationConfig struct tags. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @foxish for the thorough review. All items addressed, some in-line comments have specific responses. Everything else is straightforward implementation of your comments. Last 6 commits attempt to break down the changes into independently reviewable chunks. |
|
@scott-cotton, check this: $ signadot local connect --local-dns
signadot local connect needs root privileges for:
- configuring the system DNS resolver for cluster service names
- configuring networking to direct local traffic to the cluster
signadot local connect has been started ✓
* runtime config: cluster signadot-staging, running with root-daemon
✗ Local connection not healthy!
* failed to configure local DNS resolver
disconnected.
Error: connect failed and is no longer running
$ cat /etc/resolv.conf
# This is /run/systemd/resolve/stub-resolv.conf managed by man:systemd-resolved(8).
# Do not edit.
#
# This file might be symlinked as /etc/resolv.conf. If you're looking at
# /etc/resolv.conf and seeing this text, you have followed the symlink.
#
# This is a dynamic resolv.conf file for connecting local clients to the
# internal DNS stub resolver of systemd-resolved. This file lists all
# configured search domains.
#
# Run "resolvectl status" to see details about the uplink DNS servers
# currently in use.
#
# Third party programs should typically not access this file directly, but only
# through the symlink at /etc/resolv.conf. To manage man:resolv.conf(5) in a
# different way, replace this symlink by a static file or a different symlink.
#
# See man:systemd-resolved.service(8) for details about the supported modes of
# operation for /etc/resolv.conf.
nameserver 127.0.0.53
options edns0 trust-ad
search tail473aa.ts.net lan
$ sudo netstat -lnp | grep ":53 "
tcp 0 0 127.0.0.54:53 0.0.0.0:* LISTEN 920/systemd-resolve
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN 920/systemd-resolve
udp 0 0 127.0.0.54:53 0.0.0.0:* 920/systemd-resolve
udp 0 0 127.0.0.53:53 0.0.0.0:* 920/systemd-resolve Note that systemd-resolved is the default DNS resolver on Ubuntu (since version 18.04), but not sure about other distributions, but this seems like a common problem to me. |
Pulls in the fix for the 127.0.0.54 collision with systemd-resolved: localdns now probes for a free loopback address instead of hardcoding one. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@daniel-de-vera thanks for the test and report. Fixed in libconnect and that fix linked here in last commit. |
Thanks Scott, it works now, but I noticed this behaviour, which I think it'd be worth flagging: # Using --local-dns
#
$ time curl location.hotrod-devmesh.svc.cluster.local:8081/locations
[{"id":1,"name":"My Home","coordinates":"231,773"},{"id":123,"name":"Rachel's Floral Designs","coordinates":"115,277"},{"id":392,"name":"Trom Chocolatier","coordinates":"577,322"},{"id":567,"name":"Amazing Coffee Roasters","coordinates":"211,653"},{"id":731,"name":"Japanese Desserts","coordinates":"728,326"}]
real 0m0,549s
user 0m0,002s
sys 0m0,007s
# Note this one was veeery slow (almost 7sec, compared to the 500ms before):
$ time curl location.hotrod-devmesh:8081/locations
[{"id":1,"name":"My Home","coordinates":"231,773"},{"id":123,"name":"Rachel's Floral Designs","coordinates":"115,277"},{"id":392,"name":"Trom Chocolatier","coordinates":"577,322"},{"id":567,"name":"Amazing Coffee Roasters","coordinates":"211,653"},{"id":731,"name":"Japanese Desserts","coordinates":"728,326"}]
real 0m6,743s
user 0m0,007s
sys 0m0,011s
# Using /etc/hosts
#
$ time curl location.hotrod-devmesh.svc.cluster.local:8081/locations
[{"id":1,"name":"My Home","coordinates":"231,773"},{"id":123,"name":"Rachel's Floral Designs","coordinates":"115,277"},{"id":392,"name":"Trom Chocolatier","coordinates":"577,322"},{"id":567,"name":"Amazing Coffee Roasters","coordinates":"211,653"},{"id":731,"name":"Japanese Desserts","coordinates":"728,326"}]
real 0m0,709s
user 0m0,005s
sys 0m0,007s
$ time curl location.hotrod-devmesh:8081/locations
[{"id":1,"name":"My Home","coordinates":"231,773"},{"id":123,"name":"Rachel's Floral Designs","coordinates":"115,277"},{"id":392,"name":"Trom Chocolatier","coordinates":"577,322"},{"id":567,"name":"Amazing Coffee Roasters","coordinates":"211,653"},{"id":731,"name":"Japanese Desserts","coordinates":"728,326"}]
real 0m0,999s
user 0m0,008s
sys 0m0,008sNote sure if there's anything we can do about the above, but could have a clear impact in performance for some users. Also, when using |
Pulls in options ndots:5 in the managed resolv.conf so cluster short names (<svc>.<ns>) resolve via the search list locally instead of a slow absolute upstream query (~7s -> sub-second). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thanks again, please see last commit and ndots option for fixing. Could you verify if that fixes the problem? |
Now the problem got inverted: $ time curl location.hotrod-devmesh.svc.cluster.local:8081/locations
[{"id":1,"name":"My Home","coordinates":"231,773"},{"id":123,"name":"Rachel's Floral Designs","coordinates":"115,277"},{"id":392,"name":"Trom Chocolatier","coordinates":"577,322"},{"id":567,"name":"Amazing Coffee Roasters","coordinates":"211,653"},{"id":731,"name":"Japanese Desserts","coordinates":"728,326"}]
real 0m9,067s
user 0m0,003s
sys 0m0,006s
$ time curl location.hotrod-devmesh:8081/locations
[{"id":1,"name":"My Home","coordinates":"231,773"},{"id":123,"name":"Rachel's Floral Designs","coordinates":"115,277"},{"id":392,"name":"Trom Chocolatier","coordinates":"577,322"},{"id":567,"name":"Amazing Coffee Roasters","coordinates":"211,653"},{"id":731,"name":"Japanese Desserts","coordinates":"728,326"}]
real 0m0,633s
user 0m0,003s
sys 0m0,006s |
Replaces the resolv.conf ndots:5 approach with cluster short-name synthesis on Linux, so both FQDNs and <svc>.<ns> short names resolve on the absolute query (fixes the FQDN-vs-short-name latency inversion). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sigh. Last commits in libconnect and here remove the dependency on ndots and embed the name expansion in the resolver lookup, can you try again? |
Yes! 🙂 $ time curl location.hotrod-devmesh.svc.cluster.local:8081/locations
[{"id":1,"name":"My Home","coordinates":"231,773"},{"id":123,"name":"Rachel's Floral Designs","coordinates":"115,277"},{"id":392,"name":"Trom Chocolatier","coordinates":"577,322"},{"id":567,"name":"Amazing Coffee Roasters","coordinates":"211,653"},{"id":731,"name":"Japanese Desserts","coordinates":"728,326"}]
real 0m0,601s
user 0m0,005s
sys 0m0,014s
$ time curl location.hotrod-devmesh:8081/locations
[{"id":1,"name":"My Home","coordinates":"231,773"},{"id":123,"name":"Rachel's Floral Designs","coordinates":"115,277"},{"id":392,"name":"Trom Chocolatier","coordinates":"577,322"},{"id":567,"name":"Amazing Coffee Roasters","coordinates":"211,653"},{"id":731,"name":"Japanese Desserts","coordinates":"728,326"}]
real 0m0,606s
user 0m0,007s
sys 0m0,009s |
foxish
left a comment
There was a problem hiding this comment.
Re-reviewed the latest state (f2ebe46, libconnect pinned at c142183 which matches the current feat/localdns-1f head) and ran it live against the demo cluster on macOS: PortForward link, --local-dns, 15+ minute session.
Everything from the previous round is verifiably fixed, in code and live:
- No restart loop: exactly one "restarting localnet and name-resolution services" at startup (the expected initial start when the port-forward address first materializes), then stable for the whole session on one bind address, resolver-file mtimes frozen at daemon start.
- All three name forms resolve through the real macOS resolver path (
dscacheutil, i.e. getaddrinfo): FQDN,<svc>.<ns>.svc,<svc>.<ns>. External names unaffected. - Correct DNS semantics probed directly: A answers authoritatively; AAAA is authoritative NODATA (the documented IPv6 groundwork behavior); a miss under
cluster.localis an authoritative NXDOMAIN; a miss under a borrowed namespace zone is forwarded upstream (verified against captured Tailscale MagicDNS upstreams), so the short-name zones don't shadow real domains. TCP works. - Hygiene held:
/etc/hostshas zero signadot entries in this mode, all managed files carry the sentinel, and a pre-existing foreign/etc/resolver/testfile on my machine was correctly left untouched.scutil --dnsshows all scoped resolvers registered. local hosts(table/JSON),local status(table/JSON/--details) all gate correctly per mode; clean disconnect left nothing behind.
Approving, modulo the two bugs below being addressed. Both live in the pinned libconnect branch rather than this repo, so practically: fix in signadot/libconnect#163 and bump the pin here, which the merge-order note requires before undrafting anyway.
1. Linux: a stale sentinel resolv.conf without its backup produces a self-forwarding loop
The chain: a crashed --local-dns session leaves /etc/resolv.conf signadot-managed (sentinel + nameserver 127.0.0.54), and /var/lib/signadot/resolv.conf.bak is missing (wiped dir, failed backup write). On the next connect:
localdns.Recoverno-ops, because Linux recovery is keyed on the backup file existing (installer_linux.go recover()).localdns.Newbinds 127.0.0.54 (free, the old process is dead).captureUpstreamsreads the stale resolv.conf and captures our own address as the upstream. Every non-cluster query now self-forwards: each forwarded query arrives as a new inbound query, so this is a query loop with amplification, not just a timeout.- Worse,
linuxInstaller.capture()then records the stale sentinel file as the "original", so disconnect restores broken DNS.
Two cheap defenses, both worth doing:
- Filter the server's own bind IP out of captured upstreams.
buildResolvConfalready does exactly this for secondary nameservers (if ns == bindIP { continue }), so the forwarder is asymmetric today. - Make
capture()refuse to treat a sentinel-bearing file as the original (treat it as "no usable original" and log loudly).
There's also a milder in-session variant: on a tunnel-proxy restart where the outgoing service's resolv.conf restore failed, New runs captureUpstreams before newInstaller's recovery, so the forwarder can capture the previous (dead) bind address as first upstream and eat the 2s per-server timeout on every forwarded query for the rest of the session. The self-filter above covers most of it.
2. macOS: "writing more than 10 resolver files" warns every refresh cycle, forever
darwinInstaller.install() emits the len(desired) > 10 warning unconditionally, and the refresh loop calls install() every 15s. Observed live with 12 zones on the demo cluster: ~4 warnings/minute into root-manager.log for the lifetime of the session (thousands of lines/day). Fix: gate the warning on the desired set changing (there's already a changed flag in that function) or on first crossing the threshold.
Not a bug, but noticed live and worth a wording tweak: local status reports "147 cluster names resolvable via local DNS" while local hosts lists 49 rows. Both are correct (147 is exactly 49 x 3: each FQDN plus its two synthesized short forms), but a user comparing them will wonder where 98 names went. Something like "147 names (49 hosts)" in the status line, or reporting the host count there, would preempt the confusion.
`local status` printed "N cluster names resolvable via local DNS" using the resolver's record count, which includes the synthesized short forms (<svc>.<ns>, <svc>.<ns>.svc) and is thus a multiple of the host count shown by `local hosts` (e.g. 147 vs 49). Say "names" to avoid the mismatch reading as a discrepancy between the two commands. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Picks up signadot/libconnect@5dc7dd1: Linux self-forwarding-loop guard (drop own bind address from captured upstreams; capture() refuses a leftover sentinel resolv.conf) and the macOS resolver-file warning gated on change. Addresses the two bugs from the #342 live review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Stale comment
Risk: medium. Cursor Bugbot is not configured on this PR. Human re-review is still needed: foxish's conditional approval predates two commits that address the noted libconnect fixes and status wording. daniel-de-vera remains assigned; foxish requested for re-verification.
Sent by Cursor Approval Agent: Pull Request Approver
`local status` reported only the resolver's record count (147), which includes synthesized short forms and reads as a discrepancy against the 49 rows `local hosts` lists. Add a host_count field to LocalDNSStatus (populated from libconnect's new Status.HostCount) and render "N names (M hosts) resolvable via local DNS", also exposing hostCount in the JSON/details view. Bumps libconnect to 5a7b7ce (adds Status.HostCount) and regenerates common.pb.go (protoc-gen-go v1.26.0, matching the repo). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Stale comment
Risk: medium. Cursor Bugbot is not configured on this PR. Human re-review is still needed: foxish's conditional approval predates three commits that address the noted libconnect fixes and status wording, and review threads remain open. daniel-de-vera and foxish remain assigned as reviewers.
Sent by Cursor Approval Agent: Pull Request Approver
Picks up signadot/libconnect@0788891, which removes the ">10 resolver files" warning that fired on the normal case (one resolver file per namespace). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Stale comment
Risk: medium. Cursor Bugbot is not configured on this PR. Human re-review is still needed: foxish's conditional approval predates four commits that address the noted libconnect fixes and status wording, and nine review threads remain unresolved. daniel-de-vera and foxish remain assigned as reviewers.
Sent by Cursor Approval Agent: Pull Request Approver
Picks up signadot/libconnect@45a1c0f: the managed bind probe now skips loopback addresses already referenced in /etc/resolv.conf, so localdns can never bind an address the system forwards to (structural self-forward-loop prevention, including the crashed-session leftover case). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Stale comment
Risk: medium. Cursor Bugbot is not configured on this PR. Not approving: foxish's conditional approval predates five commits that address the noted libconnect fixes and status wording, and nine review threads remain unresolved. daniel-de-vera and foxish are already assigned as reviewers.
Sent by Cursor Approval Agent: Pull Request Approver
Picks up signadot/libconnect@008c84a: - robust identification/cleanup of a crashed session's leftover /etc/resolv.conf (in-file @@@SIGNADOT-BIND-<ip>@@@ marker + uncontested bind record, surgical strip that preserves a competing DNS manager's entries, loud logging); - de-race of server startup vs shutdown that flaked the lifecycle tests on Linux. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Stale comment
Risk: medium. Cursor Bugbot is not configured on this PR. Not approving: foxish's conditional approval predates five commits that address the noted libconnect fixes, and nine review threads remain unresolved. daniel-de-vera and foxish are already assigned as reviewers.
Sent by Cursor Approval Agent: Pull Request Approver
A new CLI can query an older local daemon (the user upgraded the CLI while connected). Such a daemon predates the host_count field, which protobuf decodes as 0. Omit the "(N hosts)" suffix when the count is 0 so the line reads "147 names resolvable via local DNS" instead of a confusing "(0 hosts)". Adds cross-version rendering tests (new daemon reports the host count, old daemon omits it, resolver-not-running). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks — all of the 07-20 review is addressed, plus the hardening that came out of the discussion. Both blocking bugs are fixed in libconnect (signadot/libconnect#163) and the pin here is bumped to include them. Bug 1 — Linux self-forward loop — fixed structurally, not just patched:
Bug 2 — macOS Host count (the 147-vs-49 note) — went with the explicit count: added Also de-raced a pre-existing flake I hit testing on Linux: Merge order unchanged: land libconnect#163 first, then I'll bump the pin here to the merged commit and mark ready. |


What
Adds opt-in local-DNS resolution to
signadot local connect. When enabled, the CLI resolves Kubernetes service names through a local DNS resolver wired into your OS resolver, instead of the default mechanism that edits/etc/hosts.Two ways to opt in:
resolver: LocalDNSon the connection in your config.resolver: EtcHosts(the default when the field is omitted) keeps the/etc/hostsbehavior:--local-dnsflag overrides the config field for a single connect.--local-dns=falseforces/etc/hostseven when the config selectsLocalDNS:Default behavior is unchanged — omitted config field and no flag = current
/etc/hostsbehavior, byte-for-byte. This is additive and experimental.Why
The default path enumerates a fixed set of hostnames per service and rewrites the shared
/etc/hostsfile on every cluster change. Local DNS instead runs a real resolver that is authoritative for the cluster zones: it answers cluster names directly, forwards everything else to your existing upstream resolvers, and restores your DNS configuration on disconnect. If a session exits uncleanly, DNS can be left pointed at the stopped resolver; the nextsignadot local connectrepairs it on startup regardless of mode — recovery is no longer gated on--local-dns, so a plain reconnect (the natural move when your DNS just broke) restores working resolution. Net effect for users: cluster name resolution that behaves more like in-cluster DNS, without touching/etc/hosts.What resolves
With local DNS active, all the usual service name forms resolve on both macOS and Linux:
my-svc.my-ns.svc.cluster.local(FQDN)my-svc.my-ns.svcmy-svc.my-nsNon-cluster names (e.g.
github.com) continue to resolve normally — they're forwarded to the resolvers you were already using.local hostssignadot local hostslists the cluster hostnames currently resolvable from your machine and the address each maps to. It reads the shared address map, so it works in both resolution modes (/etc/hostsand local DNS):Each host reports a single virtual address (IPv4-preferred); the addresses are the synthetic ones the tunnel intercepts, deterministic and name-sorted.
Requires root
Local DNS configures the system DNS resolver (and on Linux binds
127.0.0.54:53), so it needs the same root privilegeslocal connectalready escalates for. The privilege-rationale prompt now mentions DNS-resolver configuration when active. It is not supported with--unprivileged— combining them fails fast with a clear error rather than silently ignoring the setting (there's no root-manager in that mode to run the resolver). This applies however local DNS was selected: the same conflict check fires when the mode comes from theresolver:config field, and the error names the source.local statusWhen local DNS is active,
signadot local statusshows the resolver in place of the/etc/hostsline — resolvable record count, the resolver's bind address, and a prominent warning if a system DNS manager (e.g. systemd-resolved owning/etc/resolv.conf) might revert the configuration. The/etc/hostssection is omitted in this mode rather than reported as a failure. Reflected in both the table and JSON/--detailsoutput.Compatibility & scope
/etc/hostsbehavior, byte-for-byte./etc/hosts: the resolver mirrors what a real cluster serves, so it will not resolve non-standard truncations the old/etc/hostspath happened to emit (e.g.my-svc.my-ns.svc.cluster) — those never resolve in-cluster either. Surfacing this as a deliberate behavior change since it's opt-in.local hostsaccordingly reports one address per host; the resolver's[]ipbacking (for future A/AAAA answers) is unaffected.my-svc→ current namespace, not yet.Try it
This branch pins
libconnectto an unmerged branch pseudo-version (feat/localdns-1f, signadot/libconnect#163) that carries the resolver, the standalone crash-recovery entry point, and the per-connectionresolverconfig type. Do not merge until that lands; then repointgo.modto the merged libconnect commit/tag. Kept as a draft for this reason.