0126. FQDN rules for ichor-ui's external endpoint and no metrics scraping unless a namespace is named
Status: Accepted; built; hardware verification pending (FQDN rules and metrics policy) · Date: 2026-11-04 · Design section: Upgrades (Ichor), Security · Touches: 0100, 0105, 0108, 0114
Decided: the last world egress rule of Ichor's own policies, ichor-ui to its external OIDC issuer on 443, is replaced by an FQDN rule that the controller derives from the issuer the live ichor-ui Deployment is configured with (--oidc-issuer or ICHOR_OIDC_ISSUER) and writes as the CiliumNetworkPolicy ichor-ui-oidc-egress; the static install copy (config/cilium-network-policy, used by ichor init and the installer Job) therefore carries no world egress rule, and the chart renders one only as a fallback (networkPolicy.cilium.dynamicTalosEgress=false or externalCIDRs) and only while ichorUI.oidc.enabled; the controller's /metrics port is no longer open to the whole cluster entity: neither the static copy nor the chart opens it unless the chart's networkPolicy.cilium.metricsNamespaces names a namespace (optionally narrowed by pod labels, metricsPodLabels), and an installer or ichor init install allows a scraper with a policy of the operator's own, which the guide gives.
Context
ADR 0108 deferred "FQDN network-policy rules and a metrics-namespace restriction" because the static install copy has no values to carry names. ADR 0100's amendment of 2026-11-02 has since moved most of what was world to controller-written policies: Talos nodes, cluster endpoints and S3 stores come from the CRs (ichor-controller-talos-egress), and the registries of the release check from the controller's flags (ichor-controller-release-check, ADR 0114). Both already use toFQDNs with the DNS proxy rule on kube-dns. What is left, read from the policies and the code:
| Left | Where | Why it is still wide |
|---|---|---|
ichor-ui egress world 443 | static copy, chart | the external OIDC issuer; the pull request host of PR mode went away with PR mode. The issuer lives in a Deployment argument or environment variable, which the static copy cannot see. |
controller ingress cluster 8443 | static copy, chart default | /metrics; any pod in the cluster can open a connection (the endpoint still needs a token the SubjectAccessReview accepts, ADR 0108 F15). |
ichor-ui /metrics | serving port | shares the one HTTPS port with the UI and its Connect API, so no L4 rule can separate it. |
The controller reaches no registry, IdP or other external host in process apart from the S3 stores and the release-check registries, both already named by rule; the Cilium Helm Job and the kubelet's image pulls are outside these policies (ADR 0100).
What the docs confirm about toFQDNs
Cilium, Layer 3 policy "DNS based" and Layer 7 "DNS Proxy" (Context7 /cilium/cilium, Documentation/security/policy/layer3.rst and layer7.rst; the repository pins Cilium 1.20, pkg/release/compat.go, whose page set Context7 lists only up to 1.19, and the pages are the same):
- A
toFQDNsrule works only with--enable-l7-proxy=true(Helml7Proxy, default true; Ichor's own rendered Cilium config hasenable-l7-proxy: "true",pkg/cni/cilium/testdata/bootstrap-*.golden.yaml) and a rule that sends the endpoint's DNS through the proxy: an egresstoEndpointsrule tokube-systemk8s-app=kube-dnson port 53 withrules.dns. Without that rule the names never resolve to allowed addresses and every dial fails. Ichor's static and chart policies carry the rule (matchPattern: "*") since ADR 0100's amendment. toFQDNscannot share a rule with another L3 selector (toEndpoints,toCIDR); each kind is its own rule, asbuildEgressalready writes them. It may carrytoPorts.- The DNS proxy trusts the cluster DNS: the docs advise intercepting only the cluster resolver, which is what the rule does.
- Policies add up, so a
worldrule in a static policy would cancel any narrowing done elsewhere. That is why the static copy loses itsworldrule rather than the dynamic policy merely adding to it. - An in-cluster issuer (
keycloak.sso.svc) cannot be matched by name: the ClusterIP is translated to a backend before policy.toServiceswithk8sServiceis used for it, as for an in-cluster S3 store (ADR 0100); an IP-literal issuer becomes atoCIDRSet.
On a cluster Ichor adopts without managing Cilium (cni.preset: unmanaged, or an existing Cilium it only detects), enable-l7-proxy is the operator's setting. Ichor does not change it (restrict Ichor, not the user's cluster). With the proxy off, the new rule and the earlier S3 and release-check rules cannot match: the OIDC login fails with a timeout, and the log of ichor-ui shows the dial refused. The escape hatches stay: networkPolicy.cilium.dynamicTalosEgress=false (chart) renders the old world rules and stops the controller from writing the policies, and networkPolicy.cilium.enabled=false removes Ichor's policies altogether.
Decision
- Where the names come from: the object that uses them. The controller reads the ichor-ui Deployment(s) in its namespace (label
control-plane=ui-backend; the Role already hasget;liston Deployments), takes the issuer from the containerichor-ui(an--oidc-issuer=argument wins overICHOR_OIDC_ISSUER, as the flag does incmd/ichor-ui), and writesichor-ui-oidc-egress: the ichor-ui pods to that host on the URL's port (443 and 80 by scheme). No issuer, no policy (the controller deletes a stale one). It uses the same helpers as the S3 rule (toFQDNs,toServicesforsvc.ns.svc,toCIDRSetfor an IP).- Works the same for the installer path,
ichor init, and the chart: the chart no longer repeats the issuer in a second place, so the policy cannot drift from what ichor-ui actually dials. - Written only beside Ichor's installed
controller-managerpolicy (a lone egress rule would cut every other flow), disabled by--no-cilium-policiesand--talos-egress-policy=false, like the other two policies. Requeued every 30 seconds; the controller has no watch on Deployments (its Role grants nowatch). A newly configured issuer is therefore reachable within about a minute; ichor-ui retries its provider setup. - The policy selects the ichor-ui pods only. The shared
ichor-controller-talos-egressselects both workloads and must not carry an IdP the controller never dials.
- Works the same for the installer path,
- The static copy and the chart.
uilosesworld443 inconfig/cilium-network-policy. The chart renders an external rule for ichor-ui only whenichorUI.oidc.enabledand eitherdynamicTalosEgressis false (thenworldorexternalCIDRsonexternalPorts, as before) orexternalCIDRsis set (a fallback next to the dynamic policy). A chart install with OIDC off renders no external egress for ichor-ui at all.ichor.cnp.worldstays for the controller's own fallback. - Metrics.
controller-managerloses theclusterrule on the metrics port in the static copy, and the chart renders the rule only whennetworkPolicy.cilium.metricsNamespacesis not empty:fromEndpointsfor each namespace (k8s:io.kubernetes.pod.namespace), plus, whenmetricsPodLabelsis set, those labels (k8s:<key>) on every listed namespace, so that only the Prometheus pods, not the namespace's other pods, can connect. The default is empty, no scraping. A chart install with metrics enabled and no namespace named gets a NOTES.txt notice. An installer orichor initinstall has no value to carry the namespace; the operator adds a CiliumNetworkPolicy of their own for the metrics port, whichdocs/guides/install-with-helm.md(section "Network policies") gives as a ready example. Policies add up, so it also works with the chart. - ichor-ui's
/metricsis not restricted by network policy. It is the serving port; an L4 rule cannot tell the UI from the scrape, and L7 inspection is not possible on a TLS port Cilium does not terminate. The restriction there is the token check (a caller needsgeton the non-resource URL/metrics, ADR 0108 F15).uiIngressCIDRsstill narrows who reaches the port from outside the cluster. Stated, not fixed.
Alternatives rejected
- A new
IchorInstallation.spec.network(metrics namespaces, extra egress hosts). It is the place the project keeps runtime settings (ADR 0105), but a field needs the effective-status mirror, the settings gate in the admission webhook, the proto messages, the UI and the CLI; for a value an operator sets once and that only a policy reads, that surface is out of proportion, and for the issuer it would duplicate a value that already exists and could disagree with it. Appending the field later stays possible (CRD fields are append-only). - The installer customizes the
uipolicy from the settings it reads back from the live Deployments (ReadUISettings), as it does for--ui-url. That only updates on an upgrade, so an OIDC issuer added to the Deployment afterwards would be blocked until the next upgrade, and every issuer change would be a manual Ichor upgrade. - Keep
worldand only narrow the port. Unchanged exposure to any host on 443 from a pod that holds the impersonation right. - A shared dynamic policy for the issuer. The existing one selects both workloads; adding the issuer to it would open the IdP to the controller.
- Metrics from
clusterby default, namespaces optional (the old chart default). Any pod in the cluster could open the port. The endpoint is authenticated, but least privilege says closed until named; v0.1.0 has no installed users to break. - A controller-maintained metrics policy fed by a flag. The installer would overwrite the flag on the next upgrade, so a flag cannot be the operator's persistent setting.
Consequences
- An install with an external OIDC issuer needs the controller running and the L7 proxy on for the login to reach the IdP, within about a minute of the setting. Without the proxy the chart's escape hatch above is the way back.
- Nothing in
ichor-systemcan scrape the controller until the operator names a source. A Prometheus that worked before an upgrade stops until the namespace is named (the chart NOTES and the guide say so); the controller's own ServiceMonitor is unchanged. - The static policies have no
worldegress rule left: every egress allow is the Kubernetes API, this cluster's nodes, DNS, or a name or address that an Ichor object or the Deployment names.
Not verified (hardware, after integration)
On a Talos cluster with Cilium (the outer cluster, ichor-e2e only), hubble observe --verdict DROPPED -n ichor-e2e-ichor or cilium-dbg monitor --type drop on the node of the pod:
- Set an external issuer on the ichor-ui Deployment (env
ICHOR_OIDC_ISSUER=https://<a host with a name>): within about a minutekubectl get ciliumnetworkpolicy ichor-ui-oidc-egressexists with onetoFQDNsrule on 443; ichor-ui logs the provider as reachable;curlfrom the ichor-ui pod's network namespace (a debug pod with the ichor-ui labels in the same namespace) to that host succeeds and to another host (https://example.com) is dropped. - Remove the issuer: the policy is deleted and the issuer host is dropped.
cilium-dbg fqdn cache liston the pod's node shows the issuer name with its addresses;cilium-dbg policy getshows noworldegress rule for the ichor-ui endpoint.- An in-cluster issuer (
http://dex.dex.svc:5556) is allowed throughtoServices. - Metrics: from a pod in another namespace,
curl -k https://<controller pod>:8443/metricstimes out with the static copy; with a user-written policy naming that namespace it answers 401 without a token and 200 with a token that mayget/metrics; from a third namespace it still times out. With the chart: the same withmetricsNamespaces={monitoring}andmetricsPodLabels={app.kubernetes.io/name: prometheus}, a pod of the namespace without the label timing out. - The earlier rules still work: the S3 backup, the release check, discovery, the UI behind its LoadBalancer or NodePort, the webhook across nodes.
- With
enable-l7-proxyturned off in a test cluster'scilium-configand the agents restarted, the issuer is unreachable (the documented limit), anddynamicTalosEgress=falserestores it.
As built
- Controller:
internal/controller/ui_egress.go(UIEgress, a leader-only runnable added incmd/main.go, 30 seconds),talos_egress.go(urlTarget.rule()is the shared per-target rule,parseURLTargetreplacesparseS3Endpoint). Tests:ui_egress_test.go(issuer parsing, the spec, and an envtest spec for gating, writing, no rewrite when unchanged, removal with the issuer, disabled). - Static copy and chart:
config/cilium-network-policy/policies.yaml,dist/chart/templates/network-policy/cilium.yaml,values.yaml(metricsPodLabels, new comments),NOTES.txt. Tests:dist/chart/netpol_test.goandnetpol_egress_test.go(chart equals the static copy; noworldegress for ichor-ui; the external rule only as a fallback and only with OIDC on; the metrics port closed by default; namespaces and pod labels). - Docs:
docs/guides/install-with-helm.md"Network policies",docs/design/architecture.md. - No CRD, RBAC, proto, UI or CLI change: the Role already grants
get;liston Deployments in the namespace.