feat(system-tests): reach the local ic-gateway by name over verified HTTPS - #11276
feat(system-tests): reach the local ic-gateway by name over verified HTTPS#11276basvandijk wants to merge 11 commits into
Conversation
`ensure_administrable_netns` puts the driver in a network namespace it owns, but not a mount namespace -- so the driver kept reading the *host's* `/etc/resolv.conf`, whose nameserver is unreachable from that netns. Driver-side DNS was therefore not merely overridden by the callers that pass `reqwest` a `.resolve()` override: it was dead. Unshare `CLONE_NEWNS` as well and bind-mount a generated `resolv.conf` naming `IPV6_NAME_SERVERS` over `/etc/resolv.conf`, so the driver resolves in-group names through the group's own `dnsmasq` exactly like the VMs do. `dnsmasq` already binds those addresses on the group bridge inside this very netns, so nothing else has to change. `/` is remounted `MS_REC | MS_PRIVATE` first: a host that leaves `/` shared -- the systemd default -- would otherwise have its real `/etc/resolv.conf` replaced for as long as the mount lived. The mount is done in-process so `CAP_SYS_ADMIN` never enters the ambient set the unprivileged `ip`/`dnsmasq`/QEMU children inherit, and `mount(2)` is called through `libc` because the workspace builds `nix` without its `mount` feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`add_dns_record` appends to `dnsmasq`'s `--addn-hosts` file, which is a hosts file and so holds exact names only. The IC gateway needs more than that: Farm's playnet DNS gives its domain an apex record plus `*` and `*.raw` CNAMEs, because clients address canisters as `<canister id>.<domain>` and `<canister id>.raw.<domain>`. Locally those subdomains resolved nowhere, for clients in a VM as much as for the driver. Add `add_wildcard_dns_record`, which answers for a name and every subdomain of it at any depth -- one `--address` option covering all three Farm records. It takes the whole address set at once because a record, not an address, is what costs a restart. A restart is what it costs, since `--address` is a command-line option and nothing re-reads those: `SIGHUP` re-reads the hosts-shaped files and `--servers-file`, and a servers-file admits nothing but `server` and `rev-server`. So the records move to a file `start_dnsmasq` turns back into options, `create_group` takes over truncating them (they have to survive a restart), and `stop_dnsmasq` now waits for `dnsmasq` to exit -- `dnsmasq` binds its listeners before daemonizing, so a restart racing the old instance for UDP port 53 fails outright. That wait escalates to `SIGKILL`, so it first confirms the pid is still a live `dnsmasq` of ours, by looking for the `--pid-file=<path>` argument it was started with in `/proc/<pid>/cmdline`: the pid comes from a pid-file a `dnsmasq` that died on its own never got to remove, and a zombie's `cmdline` reads back empty. Deliberately not the process *name* -- Bazel links the `dnsmasq` runfiles entry under a hashed basename, so `comm` is a truncation of that hash. Restarting is safe for VMs that are already up. IC GuestOS nodes are statically configured and never consult the RA; a VM that SLAAC'd its address holds it far longer than the gap; DHCPv4 leases live in the lease-file `dnsmasq` re-reads at startup; and the only real loss is its DNS cache. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the driver sharing the group's resolver and `dnsmasq` answering for the gateway's subdomains, a driver-side client can reach the gateway the same way it does on Farm. Register the domain as a wildcard record rather than an apex-only one, and drop `resolve_override_for_url`, its parameter on `await_status_is_healthy`, and both call sites. The certificate is the only thing the Local backend still needs to be told about, and only because the dev root CA issued it rather than a public one. Left alone deliberately, since each looks like an oversight otherwise: `await_api_bn_healthy_async` and `status_async` both keep their `.resolve()` calls, which are Farm machinery rather than local-backend workarounds -- an API boundary node's domain comes from its registry record and is registered with no DNS server on either backend, and nothing in the tree overrides `uses_dns()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Enables driver-side DNS resolution and wildcard gateway domains on the local system-test backend.
Changes:
- Installs an isolated resolver configuration in the driver’s mount namespace.
- Adds wildcard DNS records through managed
dnsmasqrestarts. - Removes obsolete client-side resolution overrides.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
rs/tests/sdk/src/asset.rs |
Removes gateway DNS overrides. |
rs/tests/nns/nns_dapp_test.rs |
Uses local wildcard DNS resolution. |
rs/tests/driver/src/driver/local_backend.rs |
Adds resolver mounting and wildcard DNS support. |
rs/tests/driver/src/driver/ic_gateway_vm.rs |
Registers wildcard gateway records and removes override APIs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
`stop_dnsmasq` sent `SIGKILL` and returned immediately, so `restart_dnsmasq` could launch the replacement while the killed process still owned UDP port 53 -- the bind race the wait exists to prevent. Signals are asynchronous, `SIGKILL` included, so give it its own wait. An unconfirmed exit is now an error rather than something to start a second `dnsmasq` on top of: `stop_dnsmasq` returns `Result`, `restart_dnsmasq` propagates it, and only teardown ignores it -- there the bridge and its addresses are deleted immediately afterwards, so a `dnsmasq` that outlives its `SIGKILL` has nothing left to serve. The wait watches the process's `argv` disappear, which the kernel does a hair before it closes the process's files, so the doc says so rather than claiming more than it observes. Waiting for the pid itself to disappear would be worse: `dnsmasq` daemonizes, so it lingers as a zombie until whatever it was reparented to reaps it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dnsmasq_is_alive` searched `/proc/<pid>/cmdline` for the `--pid-file=<path>` bytes, which also accepts the argument embedded in a longer one, or our path as the prefix of a longer path. Split on NUL and compare whole arguments instead. The weaker check undercut the point of having it: a false positive is what gets an unrelated process SIGTERM'd, and then SIGKILL'd, on the strength of a stale pid-file. Verified both directions against a live `dnsmasq` -- started with `--pid-file=<path>.longer`, the substring search matches and the split does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A pure move, no behaviour change. The dev root CA is about to gain a second consumer -- the local backend, which installs it into the driver's own trust store -- and it belongs to neither that nor the gateway: what trusts it is the dev IC-OS images. Putting it in `driver/dev_root_ca.rs` keeps the certificate issuance machinery (`rcgen`, `rsa`) out of the QEMU/networking module, which would otherwise own code it never calls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tore Every dev IC-OS image trusts the dev root CA, via `update-ca-certificates` in the `output_dev` stage of the GuestOS and HostOS Dockerfiles. The driver did not: each of its clients had to add the CA to its own roots to reach the local ic-gateway over verified HTTPS. Install it once instead, so trust is a property of the environment rather than of each call site -- the same move the resolver install made for DNS. The mechanism is a bind mount of `/etc/ssl/certs/ca-certificates.crt` with the CA appended, in the mount namespace `ensure_administrable_netns` already owns. That path is load-bearing through a chain nothing type-checks -- `ClientBuilder::build` -> `rustls_platform_verifier::Verifier` -> `rustls_native_certs` -> `openssl_probe::probe` -- so the const carries it in a comment, and `rs/tests/BUILD.bazel` already builds a bundle at the same path for the colocated driver image for the same reason. Deliberately not `SSL_CERT_FILE`: `rustls-native-certs` loads *only* what that names and skips the platform store entirely, so it would narrow the driver's trust rather than widen it, breaking the clients that need public roots. It is checked for and refused for that reason. `std::env::set_var` is also `unsafe` in edition 2024 and racy against threads building clients, which the mount is not. Appending, never replacing: the original bytes are copied verbatim, so this can only widen trust. The mount-propagation change moves out of the resolv.conf install into the caller, so neither installer depends on the other having run first. And the result is read back, because every step can succeed while leaving the CA unreachable, and that would not surface until a TLS handshake failed six minutes into a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With the dev root CA in the driver's trust store, a client reaching the local ic-gateway needs no configuration of its own, so `root_certificate()` and all three `add_root_certificate` call sites go. Neither `nns_dapp_test` nor the sdk asset helper now carries any backend-specific client setup -- after the resolve override went too, both just build a plain client and GET the URL. `root_certificate`'s doc carried a paragraph that was not about the method: it explains why clients dialling an API boundary node directly still need `danger_accept_invalid_certs`, which is the reasoning behind fourteen surviving call sites. That paragraph moves to `install_dev_root_ca`, where trust is now arranged, rather than being deleted with the method. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not work Three comment fixes in the dev-root-CA path, no code change. `install_dev_root_ca` argued for the bind mount by ruling out *writing* a `.crt` into `/etc/ssl/certs`. That left the variant a reader actually reaches for unaddressed: bind-mounting the CA as a *new* file there, which escapes no namespace. It does not work for a different reason -- `mount(2)` will not create a directory entry, and the driver cannot create one, since root owns `/etc/ssl/certs` and is not mapped into its user namespace -- so name that instead, and demote the `CApath` point to the secondary reason it is. The module doc justified `dev_root_ca.rs` existing with "what trusts it is the IC-OS images", which does not follow: who trusts a certificate says nothing about where the code reading it belongs, and the line above already stated it. Replaced with the reason that holds -- neither consumer can host it without owning code it never calls, or depending on the wrong module. And the note on `self_signed` shrinks from ten lines to three. The subject/authority key identifier detail was `rcgen` trivia that does not bear on whether the code is right; what does is that the re-signed certificate is not the checked-in one and is never served, which is what stops someone "simplifying" `chain_pem`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every `_local` target failed on CI with
SSL_CERT_FILE is set ("/namespace/worker/etc/ssl/certs/ca-certificates.crt"),
which would make the driver's TLS clients read only what it names and so never
see the dev root CA this installs in /etc/ssl/certs/ca-certificates.crt
which was this code refusing to run. The Namespace runner sets `SSL_CERT_FILE` in
its own environment, so it reaches the driver even though nothing in the repo sets
it -- checking `env_inherit` in `rs/tests` was the wrong place to look, and the
conclusion drawn from it ("nothing sets them today") was wrong.
Refusing was also the wrong response. An environment that designates a different
bundle is a legitimate configuration, not a misconfiguration, so honour it:
`system_ca_bundle` now resolves the bundle the way
`rustls_native_certs::load_native_certs` does -- `SSL_CERT_FILE` when set,
`openssl_probe`'s first Linux candidate otherwise -- and the append, the mount and
the read-back all target that path. The dev root CA lands in whichever bundle the
driver's clients actually read, and the runner's own roots are kept rather than
replaced.
One shape still refuses: `SSL_CERT_DIR` without `SSL_CERT_FILE`. `rustls-native-certs`
then reads only those directories and no bundle at all, so there is no file to
append to, and a file cannot be added to a directory the driver does not own.
Nothing sets it that way; it fails loudly rather than quietly omitting the CA.
`mount`'s target becomes a `&Path`, since `SSL_CERT_FILE` need not be UTF-8.
Verified by reproducing the CI shape locally with
`--test_env=SSL_CERT_FILE=<a bundle outside /etc>`: green for a gateway target and
a gateway-free one, with the shadowed file left untouched on the host, and green
again with the variable unset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Suppressed comments (2)
rs/tests/driver/src/driver/local_backend.rs:486
- This intra-doc link also refers to the nonexistent
SYSTEM_CA_BUNDLEconstant; link toDEFAULT_SYSTEM_CA_BUNDLEinstead.
/// of [`SYSTEM_CA_BUNDLE`] with that CA appended over the original.
rs/tests/driver/src/driver/local_backend.rs:482
- When neither SSL variable is set,
rustls-native-certsfollowsopenssl_probe::probe()and uses the first existing CA file from a platform list; it does not always select this Debian path. On systems such as RHEL, this either fails startup because the file is absent or modifies a bundle that TLS clients never read. Resolve the bundle fromopenssl_probe::probe().cert_file(and report when no file is found) so the mounted file matches the verifier's choice.
Ok(PathBuf::from(DEFAULT_SYSTEM_CA_BUNDLE))
Three review findings, all doc-level bar one error string. Two intra-doc links still named `SYSTEM_CA_BUNDLE` after that constant became `DEFAULT_SYSTEM_CA_BUNDLE`, so rustdoc could not resolve them. Both now name `system_ca_bundle` instead, which is the right referent anyway: the target is resolved at run time, not fixed by the constant. Nothing local catches this -- rustdoc is not part of `rust-lint.sh`, and the repo already carries older broken links -- so it took review to spot. The paragraph on shadowing the bundle had the OpenSSL argument backwards. It claimed appending to the bundle reaches `CApath` consumers; it does not. `CApath` resolves `<subject hash>.0` symlinks and never scans the bundle. What appending reaches is bundle (`CAfile`) readers: `rustls-native-certs` for the driver's own clients, and OpenSSL and `curl` by default on Debian, where the default `CAfile` is this same path. The `CApath`-only case is now documented as the limitation it is, along with the note that a plainly named file in the directory would not have served it either -- only a hash-named symlink would. Also spell out why hard-coding the Debian path is safe rather than merely convenient: it is `openssl_probe`'s *first* candidate, so if it exists it is the one `probe()` returns and the mount cannot land on a bundle the verifier ignores. The residual exposure is a platform without it, which fails loudly on the read -- and `SSL_CERT_FILE` is the supported way out now that it is honoured, so the error says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
placeholder |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
rs/tests/driver/src/driver/mod.rs:7
- Extracting the CA type into this private module leaves the existing
[DevRootCa]intra-doc link inic_gateway_vm.rs:286unresolved: that module now imports onlydev_root_ca, so the type is no longer in scope. Please update the link to the fully qualifiedcrate::driver::dev_root_ca::DevRootCapath.
mod dev_root_ca;
Third PR in the stack. Builds on #11270 (the local ic-gateway serving a dev-CA
certificate on a resolvable domain) and #11265 (nested nodes registering through
it).
On the Local backend, a driver-side client could not reach the gateway the way it
does on Farm. Two workarounds covered for that —
resolve_override_for_urlforthe name, and
root_certificate()+add_root_certificatefor the certificate —and both had to be repeated at every call site. This PR makes name resolution and
trust properties of the environment instead, so all of it goes away. Neither
nns_dapp_testnor the sdk asset helper now carries any backend-specific clientsetup: they build a plain client and GET the URL.
1. The driver had no resolver at all
ensure_administrable_netnsputs the driver in a network namespace it owns, butnot a mount namespace — so it kept reading the host's
/etc/resolv.conf, whosenameserver is unreachable from that netns. Driver-side DNS was not merely
overridden by the
.resolve()callers; it was dead.It now unshares
CLONE_NEWNSas well and bind-mounts a generatedresolv.confnaming
IPV6_NAME_SERVERSover/etc/resolv.conf, so the driver resolvesin-group names through the group's own
dnsmasqexactly like the VMs do —dnsmasqalready binds those addresses on the group bridge inside that verynetns.
2.
dnsmasqanswered no wildcardsadd_dns_recordappends to the--addn-hostsfile, which holds exact namesonly. Farm's playnet DNS gives the gateway domain an apex record plus
*and*.rawCNAMEs, because clients address canisters as<canister id>.<domain>and
<canister id>.raw.<domain>. Locally those subdomains resolved nowhere —for clients inside a VM as much as for the driver.
add_wildcard_dns_recordanswers for a name and every subdomain of it at anydepth: one
--addressoption covering all three Farm records. It takes the wholeaddress set at once because a record, not an address, is what costs a restart.
A restart is what it costs, since
--addressis a command-line option andnothing re-reads those (
SIGHUPre-reads the hosts-shaped files and--servers-file, and a servers-file admits nothing butserver/rev-server).So the records live in a file
start_dnsmasqturns back into options,create_grouptakes over truncating them, andstop_dnsmasqnow waits fordnsmasqto exit — it binds its listeners before daemonizing, so a restartracing the old instance for UDP port 53 fails outright. Restarting is safe for
VMs already up: GuestOS nodes are statically configured and never consult the RA,
a SLAAC'd address outlives the gap by far, DHCPv4 leases live in the lease-file
dnsmasqre-reads at startup, and the only real loss is its DNS cache.3. Trust was configured per client, not per environment
Every dev IC-OS image trusts the dev root CA, via
update-ca-certificatesin theoutput_devstage of the GuestOS and HostOS Dockerfiles. The driver did not, soeach of its clients had to add the CA to its own roots.
It now installs the CA once, at startup, by bind-mounting
/etc/ssl/certs/ca-certificates.crtwith the CA appended over the original — inthe mount namespace section 1 already created. That path is load-bearing through
a chain nothing type-checks (
ClientBuilder::build→rustls_platform_verifier::Verifier→rustls_native_certs→openssl_probe::probe), so the const carries it in a comment;rs/tests/BUILD.bazelalready builds a bundle at the same path for the colocated driver image for the
same reason.
It does not set
SSL_CERT_FILE, which would be the wrong tool:rustls-native-certsthen loads only what that names and skips the platform storeentirely, so it would narrow the driver's trust rather than widen it, breaking
the clients that need public roots (
Farm::new, and the log-upload client ingroup.rs).std::env::set_varis alsounsafein edition 2024 and racy againstthreads building clients, which a mount is not.
It does, however, honour the variable.
system_ca_bundleresolves the bundlethe way
rustls_native_certs::load_native_certsdoes —SSL_CERT_FILEwhen set,openssl_probe's first Linux candidate otherwise — and the append, the mount andthe read-back all target that path, so the runner's own roots are kept rather than
replaced. An earlier revision refused to run when the variable was set, which
passed locally and failed every
_localtarget on CI, where the Namespace runnersets it. Hard-coding the default is safe because it is
probe()'s first candidate:if it exists, it is the one chosen, so the mount cannot land on a bundle the
verifier ignores.
The one shape that still refuses is
SSL_CERT_DIRwithoutSSL_CERT_FILE:rustls-native-certsthen reads only those directories and no bundle at all, sothere is nothing to append to — and a file cannot be added to a directory the
driver does not own. It fails loudly rather than quietly omitting the CA.
What the mount does not reach: a consumer using OpenSSL
CApathalone, whichresolves
<subject hash>.0symlinks and never scans the bundle. Nothing in thedriver's process tree does that, and a plainly named file in the directory would
not have served it either — only a hash-named symlink would, which is what
update-ca-certificatesgenerates and what a bind mount cannot create.The append is verbatim-then-add, so it can only widen trust. The result is read
back before returning, because every step can succeed while leaving the CA
unreachable — a mount that did not take, a short write, a stale target — and none
of that would surface until a TLS handshake failed six minutes into a test.
Verification
rust-lint.sh,buildifierandbazel build //... --nobuildall clean, and://rs/tests/sdk:dfx_smoke_test_local//rs/tests/nns:nns_dapp_test_local//rs/tests/idx:basic_health_test_local//rs/tests/sdk:dfx_smoke_test(Farm regression)//rs/tests/nested:registration_localThe CI environment shape is covered too, reproduced locally with
--test_env=SSL_CERT_FILE=<a bundle outside /etc>:dfx_smoke_test_local(gateway)and
basic_health_test_local(no gateway — the majority case) both pass, theshadowed file is left untouched on the host, and both pass again with the variable
unset.
The two gateway tests are the sharpest: each fetches
https://<canister id>.ic-gateway.ic.net/…with no resolve override and noroots added, so they fail unless the mount, the wildcard, the trust store and the
certificate all hold.
basic_health_test_localis the control — it stands up nogateway, so it shows the unconditional trust-store install does not disturb the
~366 local targets that never touch one.
The logs confirm the chain rather than just a green tick:
dnsmasqis startedexactly twice (
create_groupplus the one restart, with VMs already booted),await_status_is_healthy of ic-gatewaysucceeds against the domain, and thecanister-subdomain asset fetch returns.
A few things were checked directly before writing the code, since each could have
sunk the approach:
then the driver's — with the host's files unaffected.
_localtestsdeliberately run sandboxed with
requires-networkabsent.--address=/d/aanswersd,x.dandx.raw.dfor both A and AAAA.REFUSED, which isexactly what an IPv6-only wildcard produces — and the local gateway has no
IPv4, so that is the real configuration rather than a corner case.
the append really is additive.
Not done here
No
danger_accept_invalid_certscall is removed. All 14 inrs/tests/dial anIC node or API boundary node — by IP literal, or by a domain registered with no
DNS server and pinned with
.resolve()— or plain HTTP. A trust anchor cannothelp any of them, and none targets the gateway. The reasoning behind them moved
from
root_certificate()'s doc toinstall_dev_root_ca's rather than beingdeleted with the method.
await_api_bn_healthy_asyncandstatus_asynclikewisekeep their
.resolve()calls: an API BN's domain comes from its registry recordand is registered with no DNS server on either backend, and nothing in the tree
overrides
uses_dns().prometheus_vmwrites<canister id>.raw.<domain>scrape targets, and those nowresolve — but the
ledger-canisterandbitcoin-*/dogecoin-*jobs arescheme: httpswith notls_config, unlike the neighbouringnested_hostsandnode_exporterjobs, so they would verify the dev-CA leaf against the PrometheusUVM's stock trust store and fail. Finishing that means installing the CA there
too; those are mainnet canister ids that only exist on testnets, and it is a
separate, independently testable change.
🤖 Generated with Claude Code