Skip to content

feat(system-tests): make the local backend's ic-gateway reachable by name - #11270

Draft
basvandijk wants to merge 4 commits into
masterfrom
bas/local-backend-gateway-cert
Draft

feat(system-tests): make the local backend's ic-gateway reachable by name#11270
basvandijk wants to merge 4 commits into
masterfrom
bas/local-backend-gateway-cert

Conversation

@basvandijk

@basvandijk basvandijk commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Problem

On the local backend the ic-gateway served a self-signed certificate for <vm name>.local. Neither half is usable from inside the group, so every client papered over both — a reqwest resolve override plus danger_accept_invalid_certs.

That works for driver-side clients and for nothing else. A node has no such knobs, which is why a nested node on this backend registers straight against a replica's :8080 instead of going through the gateway as it does on Farm — and why it needs with_group_wide_firewall_whitelist() to get there.

Both halves turn out to be fixable without touching any node.

The domain

<vm name>.ic.net, registered with the group's dnsmasq through add_dns_record — the same mechanism setup_api_bn_local_playnet already uses for apibn-{idx}.ic.net. The suffix becomes one shared constant.

It could not have stayed under .local: GuestOS and HostOS resolve through systemd-resolved, which routes *.local to mDNS and never to the unicast DNS= servers dnsmasq answers on. No amount of dnsmasq configuration would make such a name resolve in a guest.

Only the apex is registered — a hosts file has no wildcards, and nothing inside a VM resolves a canister subdomain today. A wildcard would mean --address=/<domain>/<addr> on dnsmasq's command line, which SIGHUP does not re-read.

The certificate

Issued by the dev root CA instead of self-signed. That CA is already in every dev IC-OS image's trust store — ic-os/{guestos,hostos}/context/Dockerfile installs canister_http_test_ca.cert and runs update-ca-certificates, in the output_dev stage only — and its private key is checked in beside it. This is how canister_http and ckbtc already serve HTTPS that nodes accept, and the dev-certs/README.md documents exactly this use.

So a node reaches the gateway with no node side configuration: no config-image plumbing, no CONFIG_VERSION bump, no production code change. Production images are unaffected — the CA lives in the output_dev stage.

This deliberately does not extend to the API boundary nodes, whose ephemeral CA stays as it is. nns_delegation_manager builds its root store from the compiled-in webpki_roots and never consults /etc/ssl/certs, which is precisely why extra_api_boundary_node_trust_anchors_pem exists; the client that reaches the gateway is a different one (the orchestrator's ic-agent, which goes through rustls-platform-verifier → the OS store).

Driver side

uses_self_signed_cert gives way to root_certificate(). reqwest adds an extra root to the platform's own rather than replacing them, so clients now verify the chain instead of skipping verification.

resolve_override_for_url stays: the driver sits in the group's network namespace but reads the host's /etc/resolv.conf, whose nameserver is unreachable from there, so driver-side DNS is dead regardless of what dnsmasq knows. Fixing that means giving the driver a mount namespace, which is left for its own change.

Two mechanical additions: x509-parser in rcgen's features, for CertificateParams::from_ca_cert_pem; and rsa, to re-encode the checked-in PKCS#1 CA key as the PKCS#8 that rcgen's ring backend requires.

Verification

dfx_smoke_test_local and nns_dapp_test_local both pass, fetching canister assets over https://<canister id>.ic-gateway.ic.net with the chain verified rather than accepted blindly. dfx_smoke_test and nns_dapp_test still pass on Farm.

#11265 will be restacked on top of this, dropping its plain-HTTP nested path and the group-wide firewall whitelist that goes with it.

🤖 Generated with Claude Code

…name

On the local backend the ic-gateway served a self-signed certificate for
`<vm name>.local`. Both halves are unusable from inside the group, so every
client papered over them with a `reqwest` resolve override plus
`danger_accept_invalid_certs`. That works for driver-side clients and for
nothing else -- a *node* has no such knobs, which is why a nested node
registers straight against a replica's `:8080` on this backend instead of
going through the gateway as it does on Farm.

Both halves turn out to be fixable without touching any node.

The domain moves to `<vm name>.ic.net` and is registered with the group's
`dnsmasq` through `add_dns_record`, the same mechanism
`setup_api_bn_local_playnet` already uses for `apibn-{idx}.ic.net`; the suffix
becomes one shared constant. It could not have stayed under `.local`: GuestOS
and HostOS resolve through systemd-resolved, which routes `*.local` to mDNS and
never to the unicast `DNS=` servers `dnsmasq` answers on, so no amount of
dnsmasq configuration would make such a name resolve in a guest.

The certificate is now issued by the dev root CA instead of being self-signed.
That CA is already in every *dev* IC-OS image's trust store --
`ic-os/{guestos,hostos}/context/Dockerfile` installs
`canister_http_test_ca.cert` and runs `update-ca-certificates` in the
`output_dev` stage -- and its private key is checked in beside it, which is how
`canister_http` and `ckbtc` already serve HTTPS that nodes accept. So a node
can reach the gateway with no node-side configuration at all, and no production
code and no IC-OS config has to change.

This deliberately does not extend to the API boundary nodes, whose ephemeral CA
stays as it is: `nns_delegation_manager` builds its root store from the
compiled-in `webpki_roots` and never consults `/etc/ssl/certs`, which is
precisely why `extra_api_boundary_node_trust_anchors_pem` exists.

Driver-side, `uses_self_signed_cert` gives way to `root_certificate()`, since
`reqwest` adds an extra root to the platform's own rather than replacing them:
clients now verify the chain instead of skipping verification.
`resolve_override_for_url` stays. The driver sits in the group's network
namespace but reads the host's `/etc/resolv.conf`, whose nameserver is
unreachable from there, so driver-side DNS is dead regardless of what `dnsmasq`
knows; fixing that means giving the driver a mount namespace, and is left for
its own change.

Adding `x509-parser` to `rcgen`'s features is what makes
`CertificateParams::from_ca_cert_pem` available, and the new `rsa` dependency
re-encodes the checked-in PKCS#1 CA key as the PKCS#8 that `rcgen`'s `ring`
backend requires.

Verified on the local backend: `dfx_smoke_test_local` and `nns_dapp_test_local`
both pass, fetching canister assets over `https://<canister
id>.ic-gateway.ic.net` with the chain verified rather than accepted blindly.
`dfx_smoke_test` and `nns_dapp_test` still pass on Farm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Enables locally hosted IC gateways to use in-group DNS and CA-verified TLS.

Changes:

  • Registers local gateway domains through dnsmasq.
  • Issues gateway certificates from the dev root CA.
  • Updates clients and Rust/Bazel dependencies for certificate verification.

Reviewed changes

Copilot reviewed 10 out of 13 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
rs/tests/sdk/src/asset.rs Verifies gateway TLS certificates.
rs/tests/nns/nns_dapp_test.rs Verifies TLS for dapp requests.
rs/tests/driver/src/driver/local_backend.rs Defines the shared in-group domain.
rs/tests/driver/src/driver/ic.rs Reuses the shared domain suffix.
rs/tests/driver/src/driver/ic_gateway_vm.rs Adds CA signing, DNS registration, and trust handling.
rs/tests/driver/Cargo.toml Adds RSA support.
rs/tests/driver/BUILD.bazel Adds the Bazel RSA dependency.
rs/tests/common.bzl Provides CA certificate and key runfiles.
Cargo.toml Enables rcgen X.509 parsing.
Cargo.lock Updates Cargo dependency resolution.
Cargo.Bazel.toml.lock Updates Bazel’s Cargo lock state.
Cargo.Bazel.json.lock Updates generated Bazel crate metadata.
bazel/rust.MODULE.bazel Enables rcgen X.509 parsing for Bazel.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread rs/tests/driver/src/driver/ic_gateway_vm.rs Outdated
…cate

A wildcard SAN matches a single label, so `*.<domain>` does not cover
`<canister id>.raw.<domain>` -- the canonical raw hosts, which the Farm path
gives their own `*.raw` CNAME. Add the matching SAN so the local certificate
covers what Farm's does.

Nothing is broken today: the only three places that build a raw host are two
Prometheus scraping-target files, consumed inside a universal VM with its own
trust store, and one nns-dapp metadata string for a canister the driver does not
deploy. But the gap is a trap for the next caller, and more so once the
driver-side resolve overrides go away.

Note the certificate is now ahead of DNS here: the group's `dnsmasq` is served a
hosts file, which has no wildcards, so only the apex is registered and a
subdomain still has to be resolved by the client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
basvandijk and others added 2 commits August 23, 2026 01:03
The dev root CA was added to `IC_GATEWAY_RUNTIME_DEPS`, which feeds *every*
variant of a gateway-using target -- Farm and `_colocate` included -- even though
only the local backend ever reads it. For a colocated target that is worse than
dead weight: `colocate_test.rs` tars the runtime-deps directory and copies it to
the driver's universal VM, so around thirty Farm targets were shipping a CA
signing key to a VM with no use for it. The key is public, so nothing is
compromised, but there is no reason to send it.

Move both files to `_local_only_deps` in `system_tests.bzl`, which is plumbed
into the `_local` variant alone, and rename the variables to the `ENV_DEPS__`
prefix that channel uses. Verified through the runfiles manifests:
`dfx_smoke_test_local` has both entries, `dfx_smoke_test` has neither, and both
still pass -- the Farm path never touches them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five claims in the comments added with this feature were wrong. None changes
behaviour; all of them would mislead the next reader.

- The PKCS#1 re-encode was justified by `rcgen`'s "default `ring` backend".
  `aws_lc_rs` is in fact enabled workspace-wide (`rustls-acme` -> `ic-bn-lib` ->
  `ic-boundary` -> `pocket-ic-server`) and `rcgen` prefers it when both features
  are on, so the backend in use loads PKCS#1 directly and the conversion is not
  strictly needed. Keep it -- correctness should not hinge on a transitive
  feature of an unrelated crate, where a dependency edit surfaces as a runtime
  failure in every local gateway test -- but say that instead.
- `signed_by` was said to write the CA's subject key identifier into the leaf's
  authority key identifier. `rcgen` emits an AKI only when
  `use_authority_key_identifier_extension` is set, which `CertificateParams::new`
  leaves false; the leaf's only extension is its SAN. The issuer name and the
  signature are what make the chain verify.
- Serving the root in `chain_pem` was justified as sparing the client an AIA
  fetch. There is no intermediate to fetch, and RFC 8446 4.4.2 lets the root be
  omitted, so a trusting client gains nothing. It is kept because it makes the
  served chain self-describing while debugging a handshake.
- `root_certificate` claimed no CA vouches for an API boundary node's
  certificate. Under `with_api_boundary_nodes_playnet` one does. The reason those
  clients still need `danger_accept_invalid_certs` is that
  `IcNodeSnapshot::get_public_url` gives them an IP-literal URL, which no name in
  the certificate can match.
- Apex-only DNS registration was justified with "nothing inside a VM resolves a
  canister subdomain today". `prometheus_vm` writes `<canister id>.raw.<domain>`
  scrape targets that the Prometheus VM resolves through this very `dnsmasq`.
  They have never resolved on this backend -- the domain was an equally
  unresolvable `.local` name before -- so nothing regressed, but the comment
  asserted the opposite of the code it was justifying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants