Skip to content

feat(site-router): add routed site-to-site IPsec gateway app (Phase 1) - #3426

Open
myasnikovdaniil wants to merge 52 commits into
mainfrom
feat/site-router
Open

myasnikovdaniil wants to merge 52 commits into
mainfrom
feat/site-router

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds site-router, a catalog app for routed (L3), source-IP-preserving tenant site-to-site connectivity over an IKEv2 IPsec tunnel terminated in a VyOS KubeVirt gateway VM. The chart materializes the gateway (VM + boot disk + tunnel LoadBalancer Service + credential Secrets + Cilium guards). A new site-router-controller mediates the pieces the chart cannot express: deny-set validation of the tunnel's remote networks, the kube-ovn return-route annotation, gateway-port port_security relaxation gated on the guest source filter, the live VyOS configuration push over the management API, tunnel/BGP observability, and status. No new tenant CRD, the apps.cozystack.io/SiteRouter app instance is the whole contract.

This is a port + productization of the reference implementation's VyOS router into the open-source monorepo, subset to the routed feature set and aligned to the catalog-app model. NAT/DNAT (a future site-gateway) and HA/VRRP are deliberately out of scope.

E2E: what the suite proves today

Merge-gating lanes run management nodes as Talos containers. On 35857497716 (head dcb658953) all 57 chainsaw tests passed, site-router among them (516s):

  • guests boot, controller applies config, IPsec SA comes up
  • the whole kube-ovn mediation chain lands
  • inbound traffic from B reaches the backend with B's source preserved
  • MSS clamp holds a multi-MB transfer
  • negative-security gate drops and attributes all eight forbidden flows, the declared one passes
  • delete-propagation withdraws the route and restores port_security

assert-inbound-source-preserved used to fire one probe the moment backend went Ready. Gateway accepts decrypted traffic only for destinations the controller has pushed, and controller picks up a new tenant pod on its 30s poll, so the step now waits for reachability (180s ceiling) and then checks the source once.

Suite also never deleted remote site B. The VM kept running into sandbox teardown, teardown hung until the job cap on site-router runs only, and a job cancelled at the cap reports E2E Tests red. B has its own cleanup now, teardown on that run took 1.5 minutes.

What's included

  • App chart packages/apps/site-router - gateway VirtualMachine (512/4096 blockSize for DRBD), boot DataVolume, tunnel Service type: LoadBalancer (UDP 500/4500, native loadBalancerClass), PSK + RBAC-isolated api-key Secrets, first-boot cloud-init, WorkloadMonitor, and two net-new Cilium policies (gateway egressDeny + gateway ingress).
  • site-router-controller (internal/controller/siterouter, cmd/site-router-controller) wired into the platform - watches SiteRouter HelmReleases + gateway pods, runs the ordered mediation pipeline, finalizer-restores state on delete.
  • VyOS core library internal/vyos (client/parse/observation) + routed render internal/vyos/render (interfaces, management firewall, IPsec forced-UDP, static routes, BGP, MSS clamp, tunnel-ingress source filter, forward default-deny, Boundary-A drop).
  • Shared pure internal/siterouter/denyset validator + a SiteRouter-scoped admission check (pkg/registry/apps/application) that reject a cluster-overlapping remoteCIDR identically at apply time and reconcile time.
  • Docs: README.md (prose + generated params), docs/security-model.md, docs/image-lifecycle.md, docs/followups.md.
  • Tests: helm-unittest chart render (9 suites) + Go unit tests (render subset/security/net-new, deny-set, CNI mediation, status mapping, VyOS push, admission).

Phase-1 acceptance checklist

Status legend: done = implemented and unit-tested in this PR; live = additionally observed working on the last suite run; failing = the suite reaches it and it does not pass; not reached = the suite stops before it; follow-up = tracked, out of Phase-1 scope (docs/followups.md).

# Acceptance item Status
1 Tenant deploys from the catalog; VyOS gateway VM boots (512-native on DRBD), dual-homed on the tenant pod network done (chart render + blockSize, unit-tested) / live (VM schedules, goes Ready, and the guest boots)
2 IKEv2 IPsec tunnel over forced ESP-in-UDP, MSS clamp by default done (render + unit tests: forced encapsulation unconditional, clamp 1320→1280) / live (SA established on the last run)
3 Decrypted traffic L3-forwarded, source IP preserved; whole-subnet + ICMP for TCP/UDP/ICMP/SCTP done (local selector 0.0.0.0/0 + forward filter) / live (passed on the last run)
4 Tunnel endpoint via native Service type: LoadBalancer (UDP) with cluster loadBalancerClass done (unit-tested, VIP assigned live)
5 Controller mediation: deny-set, namespace routes, gateway-only port_security relax, source filter, status, delete-restore done (unit-tested, the kube-ovn v1.15.10 live-toggle no-op was confirmed, so the relaxation is baked at pod creation and D8 is a status/progression gate, documented in security-model.md) / live (the whole mediation chain asserted on the last run)
6 Security guards: source allow-list, Cilium egressDeny (169.254 + mgmt), forward default-deny, two-boundary API isolation, api-key not tenant-readable done (unit-tested, VyOS 1.5 firewall syntax validated live) / follow-up (Boundary-B additive-ingress residual)
7 Tunnel-state observability (SA up/down, rekey, counters) done (up/down + BGP gauges) / follow-up (byte + rekey counters need a guest-command + parser change)
8 Negative-security acceptance suite passes (undeclared source / other tenant / node / API / metadata / mgmt-API / world dropped; declared source → tenant dest passes, source preserved) done / live (the gate passed on the last run, all forbidden flows dropped and attributed)
9 helm-unittest + Go unit + Chainsaw e2e (two-VM) green in CI done / live (helm-unittest, Go unit and the two-VM chainsaw suite green on the last run)

Item 8 is the Phase-1 acceptance gate and is authored as a chainsaw e2e. VyOS-version-specific firewall leaf syntax has been validated live against the shipped image (the firewall ipv4 ... family and the ipsec match-ipsec-in/match-none-in matchers) and is kept behind single-point helpers, so a future image whose syntax differs is a one-place change. The last run got through it: every forbidden flow was dropped and attributed to its guard.

E2E suite: enabled, no longer parked

Both blockers that held the suite out of CI are gone. Build VyOS runs on this PR, publishes the appliance and stamps the real ref@digest into packages/apps/site-router/images/vyos-router-disk.tag through a patch fragment that Finalize merges before the E2E job, so the committed v0.0.0 placeholder is never what the sandbox pulls, and _probe-lib.sh is the live implementation rather than a set of seams. That placeholder is left behind for everyone else, though, and that part is fixed in a separate PR rather than here. hack/overlay-main-images.sh classifies a changed line as reference-bearing only when it carries @sha256: or a YAML key, and a bare images/*.tag line has neither until a build stamps it, so the overlay that exists to put current-main refs onto packages a PR did not rebuild skips this one and keeps the placeholder. The gap is latent rather than new: 17 of the 19 .tag files in the tree carry a digest and pass on that branch alone, and the only other digest-less one is third-party and never rebuilt. It is a shared CI script and the script has moved on main since this branch's base, so it went against main as #4362, which has merged.

Selector problem is fixed upstream. Enabling the suite used to make site-router the first suite downstream of cozystack.cozystack-basics, which collapsed that package from 22 suites to site-router alone and turned hack/select-e2e_test.bats red on the guard that pins exactly that. #3817 excludes basics from the reverse-dependency walk as an install-ordering hub, the way cozystack.cozystack-engine already was, and it merged, so the un-park lands here rather than waiting.

Merge order

#4411 had to land first and has merged. postgres-2-backup-roundtrip failed on two of three runs here: this branch keeps controller finalizers across Update, which removed a helm-controller requeue that was hiding a bug on main, Update dropped spec.suspend, so CNPG restore driver's suspend was undone by its own app patch. #4411 fixed that on main, and main is merged back here.

Downstream repositories

Both follow-ups are issues for now, they depend on the final shape of this PR.

Follow-ups (drafts, to be filed by the maintainer)

Full detail in packages/apps/site-router/docs/followups.md. Consolidated list:

  • Publish the cozystack-owned VyOS golden image (unblocks the default install).
  • Reproducible in-repo VyOS build (pin snapshot, vyos-build, publish like the Talos image, then pin URL + @sha256).
  • Negative-security e2e runtime proof (the VyOS 1.5 syntax is validated live, the e2e still proves at runtime that an undeclared-source packet and a valid-source/world-destination packet are both dropped).
  • Scoped port_security (pending upstream kube-ovn CIDR-AAP support).
  • Tenant-baseline Cilium exclusion for the gateway (Boundary-B hardening).
  • Controller-namespace API key + post-boot rotation.
  • Tunnel byte / rekey counter metrics (guest-command + parser change).
  • Management-CIDR mismatch diagnostic (the value itself is now resolved from the cozystack ConfigMap on both sides, so only an explicit override can still disagree, and that disagreement has no dedicated error).
  • IPsec local-address / LB tunnel-address wiring.
  • Boot image + cloud-init lock-step for an external API consumer that advances the image (external-repo hand-off).
  • Phase 2 site-gateway (NAT) / Phase 3 WireGuard backend / Phase 4 HA + per-tenant egress IP + initiator model.

Repo hygiene (pre-merge)

  • Add a changelog entry for the release that ships site-router.
  • API Review Gate is red by design, PR adds an API group so it needs an approving review from one of the API owners.

Summary by CodeRabbit

  • New Features
    • Added the Site Router application for routed site-to-site connectivity over IPsec (static routes + optional BGP) with a VM-based VyOS gateway.
    • Introduced a full controller mediation flow: fail-closed management access, namespace route programming, pending-route event surfacing, and per-peer tunnel/BGP state metrics.
    • Enforced safe remoteCIDR configuration via deny-set validation during application admission.
    • Added a new SiteRouter configuration CRD schema and supporting types.
  • Documentation
    • Added Site Router README plus security model, image lifecycle, and phase/follow-up docs.
  • Tests
    • Added admission, controller, metrics, VyOS render/parse, chart/template, and deferred e2e suite scaffolding.
  • Chores
    • Updated CI/build gating and the VyOS dedicated image build pipeline; extended build matrix selection.

Since the last review

2026-09-23. Last run is green end to end, what stood in the way is in the E2E section above. From that day's review round:

  • a failed read of the cozystack ConfigMap no longer re-stamps management ACL with the platform default (on a cluster with another pod CIDR that shut the controller out of its gateway), push is skipped and guest keeps the ACL it has
  • tunnel Service fails early on a loadBalancerClass change with the command that recreates it, the guard extra/ingress has for the same value
  • step 12 comment names the controller poll as reason for the wait

Rest of that round is non-blocking, per the reviewer's follow-up comment.

2026-09-12 round. Eleven of the fourteen findings landed as changes; the other three I answered in the threads, with the reasoning and the measurements there. The CRITICAL is fixed in #4362 against main, because it is a shared CI script whose gap predates this branch.

The overlay that puts current-main image references onto packages a PR did not rebuild cannot read a bare images/*.tag line, so the unstamped placeholder would survive into the full-suite runs of other PRs once this merges. Fixed in the classifier rather than by committing a placeholder digest, which would have defeated the chart's own refusal to render an unpinned appliance and turned a legible render error into an opaque import failure.

The controller took its management CIDR from a hard-coded default in its own chart while the app chart read the cluster's. The two agreed only on a cluster whose pod CIDR is the default one, which is every cluster CI runs on, and disagreed silently everywhere else. Both ends now read the same ConfigMap key with the same fallback.

The kubebuilder RBAC markers had drifted from the chart role in three places. One of them a grant the comment directly above them argues at length for not having, one a grant they never mentioned at all. Corrected, and hack/site-router-rbac.bats renders the chart and diffs the two sets so the next drift fails a test. The ConfigMap read is scoped by name to the single object it reads.

Four documentation claims the tree no longer supported are corrected: the finalizer that no longer cleans up the port_security annotation, the deny-set exemption list that named one of the two unjudged fields, the InsecureSkipVerify line above, and both places still calling the negative-security e2e blocked on the appliance image.

Four more on the tenant-facing surface and the gateway channel. security.egressDenyCIDRs described itself as platform-owned from inside the tenant schema, so it moves to _egressDenyCIDRs where the aggregated API's rejection of top-level _ keys is the enforcement. remoteCIDRs is capped at 16: it is one side of a product whose other side is every Pod IP and Service ClusterIP in the namespace, and only the tenant-controlled side can be bounded without a policy for what to do when a namespace outgrows it. The unverified TLS fallback is dropped. And gateway-pod selection now comes from the VMI's own status.activePods and status.nodeName rather than list order, which separated a migration's source from its target by chance; the VM as shipped cannot migrate, its boot PVC is RWO, but the selection is three lines either way.

2026-09-08 round. Three review passes landed on this branch: a full one from IvanHunters on 2026-09-08, and two independent model passes I ran over the whole diff. Twenty-four findings, all addressed. This section is the summary; the reasoning for each is in the commit that made it.

Security boundary. The blocker was that the guest is the only compensating control for the relaxed OVN port, and tenant use RBAC reaches inside it. Worse than reported: stock VyOS ships a "Password reset" boot entry running standalone_root_pw_reset, so no GRUB editing was needed. The appliance now sets GRUB superusers with a build-time password that is discarded, and marks the normal-boot entry --unrestricted in both the generated file and the template a structure migration re-renders from, without that second half the VM stops at a prompt nobody can answer. Measured in qemu rather than reasoned about: without the flag an untouched boot stops at Enter username; with it the machine boots through while e, c and a restricted entry each still stop there.

The config-push channel verifies the appliance certificate. The chart mints a per-instance certificate, seeds it under pki certificate, and the controller verifies against it by a fixed name rather than the pod IP it dials. The unverified fallback is gone rather than merely unused: DefaultVyOSClientFactory returns an error when the api-key Secret carries no CA, so a push fails and names the cause instead of dialling a channel nobody verifies. It stood for an appliance predating the seeded certificate, which is an empty set, since site-router has never been released.

The deny set guarded one of three fields that program the gateway; staticRoutes[].destination and bgp.neighbors[].address go through it now, and a rejection names the field. An empty tunnel destination set used to degrade to a source-only accept, a terminal verdict that forwards anywhere, guarded by a comment claiming the controller could not produce one. It could; it now emits no new-flow accept at all.

Correctness. Return routes are no longer written before the guest has an IPsec policy, so tenant traffic for a remote network cannot leave in the clear during the documented setup order. Route ownership is recorded after the entries it claims rather than before, which was strandable. DataVolume.spec is immutable in CDI, so the chart re-emits an installed disk's spec instead of re-rendering the digest, otherwise every release broke helm upgrade for every installed instance. A malformed cluster CIDR fails the deny set closed instead of vanishing from it. A spec.values that does not decode is rejected instead of silently meaning "no remoteCIDRs".

Surface. managementCIDR and allowOpenManagement were tenant-settable while the security model called the second test-only; both are platform-injected _ values now, with the CIDR read from the cluster's own ConfigMap so a non-default pod CIDR no longer needs manual agreement. The controller lost a cluster-wide pods patch grant it held for a documented no-op. The aggregated API stopped dropping controller finalizers and annotations on every tenant edit, on the first write and on the conflict retry.

Tests and CI. The e2e remote site still shipped a #cloud-config after the appliance stopped having cloud-init, so the live suite could not have passed; its unit test read the stale shape and stayed green. build-vyos gained the fork export path it was missing. Two negative-security cases asserted a Cilium drop record for packets the guest drops first, they failed because the isolation is stronger than they assumed.

Still open. The appliance build has to confirm that nginx serves the seeded certificate at boot. Node paths were checked against upstream's own interface definitions and the seeded base64 decodes to a DER certificate with the expected CN and SAN, but nginx's behaviour needs the real image. That build is required by this PR regardless, since the containerDisk digest is still a placeholder.

Add `site-router`, a catalog app for routed site-to-site IPsec connectivity between tenants over a VyOS gateway VM.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files labels Jul 22, 2026
@dosubot dosubot Bot added the area/networking Issues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn) label Jul 22, 2026
@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds the SiteRouter API, Helm charts, VyOS image pipeline and renderer, controller reconciliation with metrics and security mediation, deny-set admission validation, platform packaging, and a deferred live Chainsaw acceptance suite.

Changes

SiteRouter contracts and packaging

Layer / File(s) Summary
API and application packaging
api/apps/v1alpha1/siterouter/*, packages/apps/site-router/*, packages/system/site-router-rd/*
Defines SiteRouter configuration types, Helm values and templates, tenant resources, application metadata, and generated deepcopy methods.
Deny-set admission validation
internal/siterouter/denyset/*, pkg/registry/apps/application/*, pkg/apis/apps/validation/validation.go
Rejects malformed, unsupported, reserved, or cluster-overlapping remoteCIDRs during admission and reconciliation.
Platform package wiring
packages/core/platform/*, packages/system/cozystack-api/templates/rbac.yaml
Adds SiteRouter package sources and conditionally emits them with the IaaS-enabled NaaS bundle.
VyOS image pipeline
packages/system/vyos-router-image/*, packages/system/vm-default-images/*, hack/build-matrix*, Makefile
Builds a pinned VyOS qcow2/containerDisk image, publishes its digest tag, registers the golden image, and adds the controller image build.
CI and deferred E2E selection
.github/workflows/pull-requests.yaml, hack/select-e2e*
Adds VyOS-specific CI gating and excludes the deferred site-router suite from automatic E2E selection.

Controller and VyOS runtime

Layer / File(s) Summary
VyOS client and rendering
internal/vyos/*
Adds the VyOS HTTP client, observation parsers, deterministic configuration renderer, firewall rules, IPsec/BGP/static-route operations, and renderer tests.
Controller reconciliation and state
internal/controller/siterouter/*
Adds SiteRouter reconciliation, route annotation mediation, configuration hashing and confirmation, source-filter gating, metrics, status events, cleanup, and controller tests.
Controller deployment
cmd/site-router-controller/main.go, packages/system/site-router-controller/*
Wires controller-runtime setup, management CIDR validation, metrics and health endpoints, RBAC, deployment security settings, image packaging, and scraping.

Deferred acceptance coverage

Layer / File(s) Summary
Live SiteRouter Chainsaw suite
hack/e2e-chainsaw/site-router/*
Defines the two-site topology, route propagation checks, tunnel and MSS validation, negative-security attribution stubs, and teardown assertions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: kind/api-change, area/api, area/virtualization

Suggested reviewers: kvaps

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding the routed site-to-site IPsec gateway application for Phase 1.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/site-router

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 13

🧹 Nitpick comments (5)
internal/controller/siterouter/status.go (1)

65-68: 🚀 Performance & Scalability | 🔵 Trivial

Uncached full-namespace pod List on every 30s poll can pressure the apiserver at scale.

surfacePendingRoutePods runs on every reconcile (runtimePollInterval), and this List goes straight to the apiserver (uncached). In a busy tenant namespace with many pods and/or several SiteRouter instances, this is a recurring unindexed list against the API server. Consider gating it (e.g. only re-list when the programmed route set actually changed, or back off the surfacing cadence relative to the runtime poll) to bound the load.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/controller/siterouter/status.go` around lines 65 - 68, Reduce
repeated API-server load from the pod List in surfacePendingRoutePods by gating
or backing off the namespace relist instead of executing it on every
runtimePollInterval reconcile. Prefer relisting only when the programmed route
set changes, or otherwise enforce a slower bounded surfacing cadence, while
preserving pending-route detection and existing error handling.
packages/system/vyos-router-image/images/vyos-router-disk/Dockerfile (1)

10-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer COPY over ADD for local files.

ADD is only needed for its URL-fetch/archive-auto-extraction behavior; a local qcow2 copy doesn't use either. COPY is the idiomatic, unambiguous choice here.

🐛 Proposed fix
-ADD _out/assets/vyos-router-amd64.qcow2 /disk/vyos-router.qcow2
+COPY _out/assets/vyos-router-amd64.qcow2 /disk/vyos-router.qcow2
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/system/vyos-router-image/images/vyos-router-disk/Dockerfile` around
lines 10 - 11, Replace the ADD instruction in the Dockerfile with COPY for the
local vyos-router-amd64.qcow2 file, preserving the existing source and
destination paths.

Source: Linters/SAST tools

hack/e2e-chainsaw/site-router/chainsaw-test.yaml (1)

557-566: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use bracket notation for the annotation key in this JSONPath filter. It’s the safer form for dotted annotation keys in kubectl JSONPath predicates, and this final check should avoid escaping quirks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@hack/e2e-chainsaw/site-router/chainsaw-test.yaml` around lines 557 - 566,
Update the JSONPath predicate in the gateway pod query to access the
ovn.kubernetes.io/port_security annotation using bracket notation instead of
escaped dotted-key notation. Keep the existing filtering for annotations equal
to "false" and the surrounding validation unchanged.
internal/vyos/client.go (1)

97-113: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

WithInsecureSkipVerify clones the wrong base transport and skips MinVersion.

Two issues in this option:

  1. It clones http.DefaultTransport unconditionally instead of the client's current c.http.Transport. If a caller combines WithHTTPClient (supplying a custom *http.Client/Transport, e.g. for a proxy) with WithInsecureSkipVerify, this silently discards that custom transport's settings and mutates the caller-supplied http.Client object's Transport field in place — a surprising side effect for API consumers who don't expect option ordering to matter this much.
  2. Both tls.Config{InsecureSkipVerify: true} literals omit MinVersion. Even given the documented in-band-token rationale for skipping cert verification, pinning MinVersion: tls.VersionTLS12 (or higher) is still cheap defense-in-depth against protocol downgrade.
🔧 Suggested fix
 func WithInsecureSkipVerify() Option {
 	return func(c *Client) {
-		if base, ok := http.DefaultTransport.(*http.Transport); ok {
+		base, ok := c.http.Transport.(*http.Transport)
+		if !ok {
+			base, ok = http.DefaultTransport.(*http.Transport)
+		}
+		if ok {
 			clone := base.Clone()
-			clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // VyOS self-signed cert; in-band token authenticates the channel
+			clone.TLSClientConfig = &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12} //nolint:gosec // VyOS self-signed cert; in-band token authenticates the channel
 			c.http.Transport = clone

 			return
 		}

-		// Fallback when callers have replaced http.DefaultTransport with
-		// something that is not an *http.Transport.
 		c.http.Transport = &http.Transport{
-			TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // VyOS self-signed cert; in-band token authenticates the channel
+			TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, //nolint:gosec // VyOS self-signed cert; in-band token authenticates the channel
 		}
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/vyos/client.go` around lines 97 - 113, Update WithInsecureSkipVerify
to derive the transport from the client’s current c.http.Transport, preserving
custom transport settings supplied through WithHTTPClient and avoiding mutation
of the caller’s client transport. Apply TLS configuration with
InsecureSkipVerify and MinVersion set to tls.VersionTLS12 in both the cloned and
fallback transport paths.

Source: Linters/SAST tools

packages/system/cozystack-api/templates/rbac.yaml (1)

9-17: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

ClusterRole grant is not namespace-scoped.

resourceNames: ["cozystack"] restricts by name but this is a ClusterRole, so the grant applies to any configmaps/cozystack in any namespace, not only cozy-system/cozystack (the only one rest_siterouter.go reads). Low risk given a colliding ConfigMap name is unlikely, but worth noting as a least-privilege gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/system/cozystack-api/templates/rbac.yaml` around lines 9 - 17,
Restrict the RBAC permission for the ConfigMap named cozystack to the
cozy-system namespace instead of granting it cluster-wide through the
ClusterRole. Update the surrounding rbac.yaml binding or role structure while
preserving the existing get-only, name-scoped access required by SiteRouter
admission.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cmd/site-router-controller/main.go`:
- Around line 66-70: Update the shared ValidateManagementCIDR validator to
reject parsed networks whose IP does not have To4() != nil, while preserving
valid IPv4 CIDR handling and existing validation errors. Ensure the
managementCIDR flag path uses this validator so IPv6 values cannot start the
controller.

In `@hack/e2e-chainsaw/site-router/chainsaw-test.yaml`:
- Around line 425-494: Validate that GW_IP, BACKEND_IP, NODE_IP, API_CLUSTERIP,
and OTHER_POD_IP are all non-empty immediately after resolution and before any
assert_dropped or probe_from_b_stdout calls. Fail the negative-security step
with a clear error identifying every missing target, while preserving the
existing probe and attribution checks when all targets resolve.

In `@hack/select-e2e.sh`:
- Around line 37-40: Update the TODO comment in the site-router enablement
instructions to reference the active VyOS entry in
packages/system/vm-default-images/values.yaml and instruct CI to stamp
packages/system/vm-default-images/images/vyos-router-disk.tag, removing the
incorrect packages/system/vm-images and uncomment guidance.

In `@internal/vyos/render/render.go`:
- Around line 824-875: Update renderIPSec and the peer-name generation around
sanitisePeerName so names are tracked for the current tunnel batch and duplicate
sanitized descriptions receive a deterministic unique suffix, such as their
tunnel index. Use the disambiguated name consistently for both the site-to-site
peer and authentication PSK paths, while preserving existing names for
non-colliding descriptions and fallback behavior for empty descriptions.

In `@packages/apps/site-router/docs/PR-BODY.md`:
- Around line 45-47: Update the follow-up list in
packages/apps/site-router/docs/PR-BODY.md around lines 45-47 by removing the
landed reproducible VyOS build item and retaining only still-pending publishing,
digest stamping, or empirical validation work. Update
packages/apps/site-router/docs/image-lifecycle.md around line 3 to describe the
VyOS image entry as enabled and digest-pinned, replacing the outdated
“commented-out” wording.

In `@packages/apps/site-router/docs/security-model.md`:
- Around line 33-40: Update the Boundary A description to state that the
IPsec-decrypted local-input drop is emitted only when managementCIDR enforcement
is active, and explicitly qualify the open-management test path where the rule
is absent. Keep the existing explanation of tunnel coverage and the separate
Boundary B behavior unchanged.

In `@packages/apps/site-router/templates/_helpers.tpl`:
- Around line 109-120: Update the managementCIDR validation in
site-router.assertSafeVyOSInputs to perform semantic IPv4 CIDR and prefix-range
validation, not only regex shape checking. Reuse an available real IPv4-prefix
parser during template admission/rendering, and fail with the existing refusal
behavior when parsing fails before managementCIDR reaches the VyOS
configuration.

In `@packages/apps/site-router/tests/secret_cloudinit_test.yaml`:
- Around line 208-263: Update the Boundary A security-model documentation to
state that the guest guard seeds are applied only when managementCIDR
enforcement is enabled, not regardless of managementCIDR. Align the description
with the T08 tests and preserve the documented exception that
allowOpenManagement=true seeds no guest firewall guards.

In `@packages/apps/site-router/values.yaml`:
- Around line 98-100: Update the managementCIDR validation in values.yaml and
its admission/render validation to reject out-of-range IPv4 octets and prefixes
outside /0–/32, not merely unsafe characters. Preserve support for an empty
value when allowOpenManagement=true and the documented default CIDR, then add
regression cases covering malformed octets and prefix lengths.
- Around line 74-81: Enforce a minimum of one CPU core for resources.cpu in the
values schema/API definition, rejecting zero and negative values while retaining
whole-core validation. Regenerate the derived schema and API types, then extend
the validation tests to cover both zero and negative CPU values. Anchor the
changes to the resources.cpu definition and its existing validation tests.

In `@packages/system/site-router-controller/templates/rbac.yaml`:
- Around line 27-39: Restrict the RBAC permissions represented by the
ClusterRole rather than granting cluster-wide Secret and ConfigMap list/watch
access. Replace broad resources under the unnamed core API rules with
namespace-scoped bindings or narrowly targeted named-object access for the
specific per-instance Secrets and the cozy-system/cozystack ConfigMap required
by the controller, while preserving only the necessary read operations.
- Around line 16-24: Constrain the RBAC rules in rbac.yaml so the controller
does not receive cluster-wide patch access to arbitrary Pods or Namespaces.
Replace the broad patch permissions for the resources listed in the rules with
the narrowest supported authorization scope, and preserve only the read verbs
needed for discovery; if Kubernetes RBAC cannot express the required
single-resource or label restriction, remove patch from these cluster-wide rules
and handle the targeted updates through an appropriately scoped mechanism.

In `@packages/system/site-router-controller/values.yaml`:
- Around line 7-16: The values configuration currently hard-codes the kube-ovn
default in managementCidr. Update managementCidr to derive from the cluster’s
networking.podCIDR, or make it unset and require an explicit override for
non-default Pod CIDRs, while preserving the required match with the controller
--management-cidr and app chart managementCIDR values.

---

Nitpick comments:
In `@hack/e2e-chainsaw/site-router/chainsaw-test.yaml`:
- Around line 557-566: Update the JSONPath predicate in the gateway pod query to
access the ovn.kubernetes.io/port_security annotation using bracket notation
instead of escaped dotted-key notation. Keep the existing filtering for
annotations equal to "false" and the surrounding validation unchanged.

In `@internal/controller/siterouter/status.go`:
- Around line 65-68: Reduce repeated API-server load from the pod List in
surfacePendingRoutePods by gating or backing off the namespace relist instead of
executing it on every runtimePollInterval reconcile. Prefer relisting only when
the programmed route set changes, or otherwise enforce a slower bounded
surfacing cadence, while preserving pending-route detection and existing error
handling.

In `@internal/vyos/client.go`:
- Around line 97-113: Update WithInsecureSkipVerify to derive the transport from
the client’s current c.http.Transport, preserving custom transport settings
supplied through WithHTTPClient and avoiding mutation of the caller’s client
transport. Apply TLS configuration with InsecureSkipVerify and MinVersion set to
tls.VersionTLS12 in both the cloned and fallback transport paths.

In `@packages/system/cozystack-api/templates/rbac.yaml`:
- Around line 9-17: Restrict the RBAC permission for the ConfigMap named
cozystack to the cozy-system namespace instead of granting it cluster-wide
through the ClusterRole. Update the surrounding rbac.yaml binding or role
structure while preserving the existing get-only, name-scoped access required by
SiteRouter admission.

In `@packages/system/vyos-router-image/images/vyos-router-disk/Dockerfile`:
- Around line 10-11: Replace the ADD instruction in the Dockerfile with COPY for
the local vyos-router-amd64.qcow2 file, preserving the existing source and
destination paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8c8c7016-ab40-47eb-bbaa-21e2ba4539b7

📥 Commits

Reviewing files that changed from the base of the PR and between 706c3f0 and d5375fb.

⛔ Files ignored due to path filters (1)
  • packages/apps/site-router/logos/site-router.svg is excluded by !**/*.svg
📒 Files selected for processing (97)
  • .github/workflows/pull-requests.yaml
  • Makefile
  • api/apps/v1alpha1/siterouter/types.go
  • api/apps/v1alpha1/siterouter/zz_generated.deepcopy.go
  • cmd/site-router-controller/main.go
  • hack/build-matrix.sh
  • hack/build-matrix_test.bats
  • hack/e2e-chainsaw/site-router/_probe-lib.sh
  • hack/e2e-chainsaw/site-router/backend.yaml
  • hack/e2e-chainsaw/site-router/chainsaw-test.yaml
  • hack/e2e-chainsaw/site-router/fresh-route-pod.yaml
  • hack/e2e-chainsaw/site-router/pending-route-pod.yaml
  • hack/e2e-chainsaw/site-router/remote-site-b.yaml
  • hack/e2e-chainsaw/site-router/site-router-a.yaml
  • hack/select-e2e.sh
  • hack/select-e2e_test.bats
  • internal/controller/siterouter/cnimediation.go
  • internal/controller/siterouter/cnimediation_test.go
  • internal/controller/siterouter/metrics.go
  • internal/controller/siterouter/metrics_test.go
  • internal/controller/siterouter/reconciler.go
  • internal/controller/siterouter/reconciler_test.go
  • internal/controller/siterouter/status.go
  • internal/controller/siterouter/status_test.go
  • internal/controller/siterouter/vyospush.go
  • internal/controller/siterouter/vyospush_test.go
  • internal/siterouter/denyset/denyset.go
  • internal/siterouter/denyset/denyset_test.go
  • internal/vyos/client.go
  • internal/vyos/client_test.go
  • internal/vyos/observation.go
  • internal/vyos/parse.go
  • internal/vyos/parse_test.go
  • internal/vyos/render/render.go
  • internal/vyos/render/render_netnew_test.go
  • internal/vyos/render/render_security_test.go
  • internal/vyos/render/render_test.go
  • packages/apps/site-router/.helmignore
  • packages/apps/site-router/Chart.yaml
  • packages/apps/site-router/Makefile
  • packages/apps/site-router/README.md
  • packages/apps/site-router/charts/cozy-lib
  • packages/apps/site-router/docs/PR-BODY.md
  • packages/apps/site-router/docs/followups.md
  • packages/apps/site-router/docs/image-lifecycle.md
  • packages/apps/site-router/docs/security-model.md
  • packages/apps/site-router/templates/_helpers.tpl
  • packages/apps/site-router/templates/dashboard-resourcemap.yaml
  • packages/apps/site-router/templates/dv.yaml
  • packages/apps/site-router/templates/networkpolicy.yaml
  • packages/apps/site-router/templates/secret-cloudinit.yaml
  • packages/apps/site-router/templates/secret-psk.yaml
  • packages/apps/site-router/templates/service.yaml
  • packages/apps/site-router/templates/vm.yaml
  • packages/apps/site-router/templates/workloadmonitor.yaml
  • packages/apps/site-router/tests/dv_test.yaml
  • packages/apps/site-router/tests/networkpolicy_test.yaml
  • packages/apps/site-router/tests/policy_workloadmonitor_todo_test.yaml
  • packages/apps/site-router/tests/secret_cloudinit_test.yaml
  • packages/apps/site-router/tests/secret_psk_test.yaml
  • packages/apps/site-router/tests/service_test.yaml
  • packages/apps/site-router/tests/values_matrix_test.yaml
  • packages/apps/site-router/tests/vm_test.yaml
  • packages/apps/site-router/tests/workloadmonitor_test.yaml
  • packages/apps/site-router/values.schema.json
  • packages/apps/site-router/values.yaml
  • packages/core/platform/sources/site-router-application.yaml
  • packages/core/platform/sources/site-router-controller.yaml
  • packages/core/platform/templates/bundles/naas.yaml
  • packages/core/platform/tests/bundles_site_router_naas_test.yaml
  • packages/system/cozystack-api/templates/rbac.yaml
  • packages/system/site-router-controller/Chart.yaml
  • packages/system/site-router-controller/Makefile
  • packages/system/site-router-controller/images/site-router-controller/Dockerfile
  • packages/system/site-router-controller/templates/deployment.yaml
  • packages/system/site-router-controller/templates/podscrape.yaml
  • packages/system/site-router-controller/templates/rbac-bind.yaml
  • packages/system/site-router-controller/templates/rbac.yaml
  • packages/system/site-router-controller/templates/sa.yaml
  • packages/system/site-router-controller/values.yaml
  • packages/system/site-router-rd/Chart.yaml
  • packages/system/site-router-rd/Makefile
  • packages/system/site-router-rd/cozyrds/site-router.yaml
  • packages/system/site-router-rd/templates/cozyrd.yaml
  • packages/system/site-router-rd/values.yaml
  • packages/system/vm-default-images/images/vyos-router-disk.tag
  • packages/system/vm-default-images/templates/dv.yaml
  • packages/system/vm-default-images/values.yaml
  • packages/system/vyos-router-image/Chart.yaml
  • packages/system/vyos-router-image/Makefile
  • packages/system/vyos-router-image/flavors/vyos-router.toml
  • packages/system/vyos-router-image/hack/build-qcow2.sh
  • packages/system/vyos-router-image/images/vyos-router-disk/Dockerfile
  • pkg/apis/apps/validation/validation.go
  • pkg/registry/apps/application/rest.go
  • pkg/registry/apps/application/rest_siterouter.go
  • pkg/registry/apps/application/rest_siterouter_admission_test.go

Comment thread cmd/site-router-controller/main.go Outdated
Comment thread hack/e2e-chainsaw/site-router/chainsaw-test.yaml
Comment thread hack/select-e2e.sh Outdated
Comment thread internal/vyos/render/render.go
Comment thread packages/apps/site-router/docs/PR-BODY.md Outdated
Comment thread packages/apps/site-router/values.yaml
Comment thread packages/apps/site-router/values.yaml Outdated
Comment thread packages/system/site-router-controller/templates/rbac.yaml Outdated
Comment thread packages/system/site-router-controller/templates/rbac.yaml Outdated
Comment thread packages/system/site-router-controller/values.yaml Outdated

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: NOT LGTM

Large, well-executed Phase-1 feature, but it is enabled in the default presets (isp-full / isp-full-generic) and registered in the NaaS catalog, while its only supported load path cannot succeed on any cluster today (the golden VyOS image is not published and the digest is a zero placeholder). The failure surfaces as a silently stuck DataVolume with no legible status. The key negative-security acceptance suite that would prove the isolation model is deferred and never runs in CI.

What the PR does

Adds a catalog app site-router: routed (L3) site-to-site connectivity between tenants over an IKEv2 IPsec tunnel terminated in a VyOS gateway (KubeVirt VM). The chart materializes the VM + boot disk + tunnel Service type: LoadBalancer + Secrets (PSK, api-key) + Cilium policies. A new site-router-controller does what the chart cannot express: deny-set validation of remote networks, kube-ovn return-route annotation, relaxation of port_security on the gateway port, live config push to VyOS over its management API, tunnel/BGP observability and status. Scope: +14239/-17, 100 files.

Blocking findings

[MAJOR] Feature is enabled by default without a working default load path
packages/core/platform/values-isp-full.yaml:11, values-isp-full-generic.yaml:11, templates/bundles/naas.yaml:17-20, packages/apps/site-router/templates/dv.yaml:21-23. On a standard install the AppDefinition is registered in the NaaS catalog and the controller starts, but the default instance (image.enabled=false) clones a non-existent PVC cozy-public/vm-default-images-vyos-router (the golden image is an unpublished follow-up). A catalog tenant gets a DataVolume stuck in Pending, a VM that never boots, and no Ready=False / Event. Suggested fix: do not include site-router in the presets and do not register it in the catalog until the golden image is published and vyos-router-disk.tag is stamped with a real digest; also emit a legible status on the missing-source path.

[MAJOR] Non-resolvable image reference (zero-placeholder digest)
packages/system/vm-default-images/images/vyos-router-disk.tag:1 = ...@sha256:0000...0000. dv.yaml is an unconditional {{- range .Values.images }}, so the added vyos-router entry renders a DataVolume importing docker://…@sha256:0000…, which never resolves. The package is opt-in, but on every cluster that enables it an upgrade adds a permanently failing CDI importer and a stuck 12Gi PVC. Suggested fix: skip entries whose digest is the placeholder until a real one is stamped.

Minor findings

[MINOR] internal/controller/siterouter/reconciler.go:398-421,662-669 — a deny-set failure on the reconcile path is invisible, and the docstring over-promises. validateRemoteCIDRs returns a hard error and classify does return ctrl.Result{}, err (669) before updateStatus (332): no Event, no condition, although the docstring (400-402) claims "status surfaces it machine-readably". On a CIDR reconfigure (or if admission is bypassed) the HR stays Ready=True while routes are silently not programmed. Minor because the primary tenant path is synchronous fail-closed admission. Fix: emit a Warning Event (as vyospush.go:329 does) and correct the docstring.

[MINOR] packages/apps/site-router/values.schema.json (bgp.required=["enabled"]) + internal/controller/siterouter/vyospush_test.go:786 — bgp.enabled=true without localASN is a silent no-op. The schema only requires enabled, though the description says localASN is required. The controller silently skips BGP with no Event/condition. Fix: make localASN required-when-enabled, or surface Ready=False / Event.

Discrepancies with the PR claims

  • [PARTIAL] PR body item 8 "Negative-security acceptance suite passes": the suite is committed, but hack/select-e2e.sh:42 sets DEFERRED_SUITES="site-router" and strip_deferred removes it from all CI paths, so it never runs. The security guards (Cilium egressDeny, forward default-deny, source filter, two-boundary API isolation) have zero executed evidence of a packet drop, only render-shape unit tests.
  • [OK, verified] "api-key not tenant-readable": confirmed. The api-key Secret is absent from both the AppDefinition secrets.include and the dashboard-resourcemap.yaml Role; only the PSK (tenant's own key) and the tunnel Service are exposed to use.

Main operational risk

The isolation security model is not proven on the data plane. templates/vm.yaml:28 permanently bakes ovn.kubernetes.io/port_security: "false" onto the gateway pod (the controller only verifies, it does not re-enforce: reconciler.go:502), with an acknowledged boot window where the port is relaxed before the VyOS firewall is stamped. The only compensating control is the in-guest VyOS firewall, whose drop behavior is exactly what the deferred negative-security e2e would prove, and that suite does not run in CI. Cross-tenant reach is bounded by Cilium identity (hence not Critical), but the headline isolation claim rests on render-shape unit tests rather than executed evidence.

Verified, non-blocking notes

  • charts-direct-edit on packages/apps/site-router/charts/cozy-lib is a false positive: it is a symlink (git mode 120000), same as apps/vpn / apps/kafka. Not a vendored edit.
  • The 3 chart_lint.render_errors are harness artifacts (site-router renders cleanly under tenant-*; platform OCIRepository is a lookup dependency; the cozystack-api _cluster nil is a runtime cozystack-values injection).
  • The deny-set (denyset.go:84-89) intentionally leaves NodeCIDRs / LBPools empty, so overlap with the node subnet / LB pool is not rejected, but the effect is self-inflicted (tenant's own routing), not cross-tenant.
  • VyOS management uses InsecureSkipVerify (internal/vyos/client.go:97), bounded by --management-cidr + a per-instance token.
  • The upgrade adds a cluster-wide read-only grant (secrets / configmaps / services) to the controller SA, judged acceptable least-privilege (read-only, no exec / secret writes).
  • Watch / pod-discovery wiring is correct (the lineage webhook stamps apps.cozystack.io/application.* on the virt-launcher pod), admission is fail-closed on Create+Update, leader election is on (--leader-elect, replicas:1), securityContext is hardened, cozyrds resourceNames match the Role, no reconcile amplifier, go build/vet/test green, deny-set / admission tests are non-vacuous (checked by mutation).

Recommended follow-up

Run an end-to-end test on a disposable dev cluster: (1) whether the zero-digest DataVolume wedges the vm-default-images HelmRelease on an opt-in upgrade or just retries in the background; (2) whether the default site-router instance without the golden image gives any legible status; (3) wire up and run the deferred negative-security Chainsaw suite to get real evidence of a packet drop.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

IvanHunters Both MAJORs were real and are fixed, though the first one's root cause turned out to be deeper than the gating change you suggested. Range f0619b4a3..10cb5f302.

The default load path could not work whether or not the image was published. vm-default-images is registered through cozystack.platform.package.optional.default, so the golden PVC was never guaranteed on any install, and f0619b4a3 had already dropped the dependsOn to escape an install-time deadlock — leaving nothing guaranteeing the clone source. Not registering the app until publication would have hidden that rather than fixed it.

The boot disk now imports the digest-pinned appliance containerDisk straight from the registry, per instance, with the reference living in the app chart it belongs to. Worth being straight about the path there, since the history shows it: I first moved the golden into a dedicated package shipped with the app, and reviewing that found three defects in it. CDI populates a DataVolume only at creation, so a shared golden could never be advanced — a new digest would either fail the upgrade on an immutable field or be silently ignored, leaving later gateways booting the previous appliance while its cloud-init contract had moved on. Its hard dependsOn reintroduced exactly the deadlock class f0619b4a3 escaped, because bundles.disabledPackages is per-name and an operator could strand the app by disabling only the golden. And the namespaced Role it needed in cozy-public had no ordering guarantee, since site-router-controller's dependency closure never reaches cozystack-basics. Importing per instance removes all three: nothing shared to go stale, no package to disable, no cross-chart name contract. The cost is one 12Gi import per gateway instead of a shared clone, which is the right trade for an appliance a tenant runs one or two of.

On the placeholder digest — a real one is a merge prerequisite, and that is now mechanical rather than remembered: hack/image-refs-no-placeholder.bats fails the build on any all-zero digest anywhere in packages/, and is red right now on purpose. Worth recording why the shape is dangerous: it passes required and every schema check, so the failure moves from render time to a stuck import at runtime, and nightly-mirror.sh skips it silently because its ownership filter matches the build-registry host while a not-yet-published ref still carries the public one.

Both MINORs are fixed. The deny-set rejection records a Warning Event and the docstring no longer claims status surfaces it, since it returns a hard error and classify stops the pipeline before updateStatus runs. bgp.enabled without localASN records a Warning too — localASN is optional in the schema, so that document is valid and silently rendered no BGP at all.

Your "legible status on the missing-source path" is addressed, but not the way I first tried. I added a controller-side BootImageUnavailable Event and then found it unreachable in the only scenario it existed for: no image means no gateway pod, so pushVyOSConfig returns GatewayPending and classify requeues before updateStatus — which is what called it. It also never set Ready=False as I had written, since this package makes no condition write at all (D9 keeps status off the app CR). Rather than ship dead code I removed it: with a per-instance import there is no missing-source state, and an unresolvable digest now surfaces as ImportFailed on the tenant's own boot DataVolume, in their namespace, where they can actually see it.

On item 8 — the status column did read deferred-to-empirical and item 9 disclosed the CI exclusion, but the item title said "suite passes", which is what invited the reading. It now says authored-not-executed and states the zero executed drop evidence explicitly.

On the port_security operational risk: that trade-off was raised and accepted in the design proposal, cozystack/community#30. §Design records that kube-ovn v1.15.10 cannot express a CIDR in its allowed-address-pair path, so Phase 1 ships the gateway port fully relaxed with scoped port security as follow-up hardening; §Security states the guest source allow-list is therefore mandatory and platform-owned rather than defence in depth, and that Cilium bounds destinations rather than claimed source identity. Worth re-reading there — if that conclusion should change, the proposal is the place to change it.

Also narrowed the RBAC you judged acceptable, since two verbs were dead: list/watch are gone from Secrets, Services, ConfigMaps and Namespaces.

Your recommended dev-cluster run is still outstanding: build-vyos has not produced a digest yet. Its first run failed on an unrelated stale-branch test failure — this branch is 113 commits behind main, and main's naas.yaml gained a bundles.system.enabled guard that the suite's set: blocks did not satisfy, which is fixed in 10cb5f302.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

NOT LGTM. The engineering quality is high and most Phase-1 residuals are documented honestly, but on several tenant-reachable paths the security model's stated isolation properties do not hold as written, and the guards' runtime efficacy is deferred from CI. Reviewed at c2abcf19 against merge-base 2f8c953c.

Findings

[MAJOR] internal/siterouter/denyset/denyset.go:90-106, internal/controller/siterouter/reconciler.go:407-431, internal/vyos/render/render.go:649-709 — node/host network is not in the deny-set → node source-impersonation into tenant pods.

ClusterNetworksFromConfigMap only ever populates pod/service/join (plus the always-reserved link-local/loopback/default-route); NodeCIDRs/LBPools stay empty. A tenant may therefore legally declare the node subnet as a remoteCIDR — admission and reconcile both pass — and the controller then emits a TUNNEL-INGRESS accept source ∈ <node-subnet> AND dest ∈ pod/svc. The authenticated tunnel peer (the tenant's remote site) can then send decrypted packets sourced as a cluster node IP to that tenant's workloads, defeating any node-IP-based trust those workloads apply. Separately this is a doc/comment contradiction: reconciler.go:407-408 and the security-model "Deny-set validation" section state node/LB-pool are rejected, while residual #6 admits they are not. Fix: reject any remoteCIDR overlapping the node/host network, and correct the overclaiming comment + doc.

[MAJOR] internal/controller/siterouter/vyospush.go:648-661 → internal/vyos/render/render.go:686-695 — the guest source filter is not a tenant-isolation control, contrary to the doc.

tenantNetworkCIDRs returns the whole cluster pod+service CIDR, so each TUNNEL-INGRESS accept is destination-constrained to all tenants, not this instance's networks. A decrypted packet with a valid remote source and a destination that is another tenant's pod IP is accepted and forwarded by the gateway. docs/security-model.md presents this filter as enforcing "must never let tunnel traffic reach ... unrelated tenants" — it does not; cross-tenant containment rests entirely on Cilium identity at the destination pod (which a static review cannot verify). Either stop claiming the guest filter provides tenant isolation, or scope the destination set per-tenant.

[MAJOR] values.schema.json (image), templates/dv.yaml:35-37, templates/vm.yaml:28, docs/security-model.md — tenant-settable boot image nullifies every guest-side guard while port_security is off from boot.

image.enabled/url is exposed in the cozyrd openAPISchema to tenant use (dashboard-resourcemap binds use) with no operator gating, letting a tenant boot an arbitrary image as the gateway VM, which carries ovn.kubernetes.io/port_security: "false" from pod creation. Every guest-side guard (source filter, forward default-deny, Boundary-A management drop, management firewall) lives inside the image the tenant just replaced. The threat model assumes the shipped VyOS guest enforces them and does not cover the custom-image case. The remaining controls are the two namespaced Cilium policies (egressDeny covers only 169.254.0.0/16 by default; gateway-ingress is additively re-opened per the documented Boundary-B residual) plus the unverified baseline Cilium identity. Net: a tenant obtains an anti-spoof-disabled pod running arbitrary code on the tenant OVN subnet, egress-denied only to link-local. Fix: gate image.* behind an operator/admin tier (not tenant use), or document the trust boundary and default security.egressDenyCIDRs to include node/management ranges.

[MINOR] internal/controller/siterouter/cnimediation.go:143-160 — removeRoutes deletes a co-tenant's live route on delete. When the deleting instance's gateway pod is already gone (gatewayIP==""), ownership falls back to dst-matching and drops every entry whose dst ∈ this instance's remoteCIDRs, including a co-tenant site-router's entry for the same dst (gw = the co-tenant's IP). Transient blackhole of the co-tenant's return path until its next 30s reconcile — the exact co-tenant clobber the gateway-IP-known branch was written to avoid.

[MINOR] internal/controller/siterouter/cnimediation.go:85-118 — route flap when two same-namespace SiteRouters declare the same remoteCIDR. Each reconcile upserts the shared dst's gw to its own gateway IP, so the two instances flip the route back and forth every 30s, churning the namespace annotation and disrupting that dst's return path. No conflict detection.

[MINOR] internal/controller/siterouter/reconciler.go:312-317 — deny-set hard error short-circuits before route withdrawal. A remoteCIDR valid when routes were programmed but later overlapping a cluster network (admin re-scopes pod/svc CIDR) makes validateRemoteCIDRs return before programNamespaceRoutes/removeNamespaceRoutes, so stale kube-ovn return routes persist and blackhole the now-cluster-owned range. Silent (Event only; HR stays Ready).

[MINOR] mediation health has no durable status signal. The SiteRouter app-instance has no status subresource (D9); every mediation failure (InvalidRemoteCIDR, ConfigureFailed, SourceFilterPending, PortSecurityRelaxationPending, PSK/APIKey/TunnelAddress pending) surfaces only as an ephemeral Warning Event on the HelmRelease, which stays Ready=True (chart applied) while the tunnel is down. Events age out (~1h); reconciler.go:420 acknowledges this. Recommend a durable condition or a documented "check Events + WorkloadMonitor" runbook.

[MINOR] values.schema.json — input-validation asymmetry. peer.address, staticRoutes.destination/nextHop, bgp.neighbors.address are unconstrained free strings and are not deny-set-checked, while managementCIDR has a strict IPv4 pattern and remoteCIDRs is deny-set-validated. Not an injection hole (the API push uses structured JSON Operation values; the cloud-init path is guarded by assertSafeVyOSInputs), but a hostile value fails late as a ConfigureFailed→Degraded requeue loop rather than a clean admission rejection, and a staticRoute toward a cluster CIDR is pushed to the gateway. Add CIDR/IP/hostname patterns for admission-time rejection + defense-in-depth.

[MINOR] cmd/site-router-controller/main.go:67 — --management-cidr help says it governs "HTTPS 443 and SSH (22)", but the render (render.go:517-521) and cloud-init never open 22 (the appliance has no SSH). Remove the SSH mention so operators don't assume 22 is reachable/controlled by this flag.

Claim mismatches

  • [PARTIAL] security-model.md "Deny-set validation ... rejects ... node / LB-pool networks" — node/LB-pool are not enforced (Finding 1).
  • [PARTIAL] security-model.md threat model "must never let tunnel traffic reach ... unrelated tenants" as a property of the guest source filter — the destination set is cluster-wide pod+svc; cross-tenant containment is Cilium-identity-only (Finding 2).
  • [PARTIAL] security-model.md guest-guard guarantees are silently void when image.enabled=true (Finding 3).

Caveats

  • Upgrade path: N/A — all-new packages (apps/site-router, system/site-router-controller, system/site-router-rd, system/vyos-router-image), new SiteRouter kind, new cozy-site-router namespace. No pre-existing customer objects; no upgrade-regression surface (verified: no removed/renamed in-tree identifiers).
  • Fresh-install: sound — PackageSource dependsOn (networking/kubevirt/cdi/engine/controller), naas gated on bundles.iaas.enabled (avoids the isp-hosted #3376 deadlock), controller dependsOn victoria-metrics-operator for its unconditional VMPodScrape, cozystack-api ConfigMap grant name-scoped. go build + all Go unit tests + all 63 helm-unittests green; make generate not stale.
  • Render-blindness / live-only claims: the kube-ovn v1.15.10 "port_security reconciled only at pod create" behavior and the VyOS 1.5 firewall leaf syntax are author-claimed "validated live" — not executed by this static review (needs a live cluster / booted gateway).
  • Cross-tenant/node containment (Findings 1-3) cannot be verified statically (live Cilium/kube-ovn required).
  • Negative-security e2e (the Phase-1 acceptance gate proving the guards actually drop spoofed / world-destined packets) is committed but deferred from CI (hack/select-e2e.sh DEFERRED_SUITES="site-router"), so the guards' runtime efficacy is unproven in CI.
  • Management-API isolation: managementCIDR defaults to the whole pod CIDR (every pod), and Boundary B is additively re-opened, so the api-key Secret's namespace RBAC isolation is effectively the sole control on the API. That isolation holds cross-tenant (api-key excluded from the dashboard Role + AppDef secrets.include — verified), but a tenant admin can read its own gateway's token and reconfigure the router (within the tenant's own blast radius).

Recommended follow-ups

  • cozystack-pr-test negative-isolation run: (a) a spoofing custom-image gateway, (b) a tunnel-peer packet sourced as a node IP and destined to another tenant's pod IP — verify no cross-tenant/node reach; plus run the deferred negative-security chainsaw suite once the golden image ships.

Checked and sound (recorded so they need not be re-litigated): deny-set admission wired into both Create and Update; controller RBAC is strictly least-privilege (get-only on secrets/services/configmaps); secrets are redacted before truncation (correct order — a secret straddling the 256-byte cut cannot leak); deny-set masks host bits, rejects /0 and non-IPv4 (incl. IPv4-mapped-IPv6), fails closed on a ConfigMap read error, and matches admission↔reconcile via the shared helper; the cozyrd hides the api-key and exposes PSK + tunnel Service with the correct release.prefix; push failure is fail-closed (hash not recorded, Degraded + requeue, no more-open end state); reconcile is single-writer under leader election; the cloud-init config.boot path is guarded by assertSafeVyOSInputs.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

IvanHunters Addressed the actionable code and documentation gaps in f1ded230b and 869bafbea.

  • Node and LoadBalancer protection: admission and reconciliation now share live discovery of Node Internal/External IPs and allocated Service external/LoadBalancer IPs, represented as /32s. A topology change that makes an existing remote CIDR unsafe withdraws its programmed route.
  • Tenant isolation: the guest filter now admits the pod CIDR plus same-namespace Service ClusterIPs as /32s, not the entire service CIDR. The documentation explicitly assigns cross-tenant pod isolation to Cilium identity policy; the guest filter blocks world/non-cluster forwarding but does not claim to distinguish tenant pod identities.
  • Boot-image trust: the tenant-settable image override is removed. Gateways always use the platform’s digest-pinned appliance because the relaxed OVN port relies on guest-enforced controls.
  • Route cleanup: route ownership is persisted on the HelmRelease. Cleanup uses persisted/current gateway IPs and never falls back to destination matching, so it cannot remove a sibling’s same-destination route after the pod disappears.
  • Route conflicts: sibling instances declaring the same destination now produce RouteConflict, preserve the first route, and stop before guest configuration instead of flapping the namespace annotation.
  • Deny-set failures: a newly unsafe CIDR withdraws existing owned routes before returning the hard error, and mediation failures remove stale tunnel/BGP gauges.
  • Observability: the controller still has no durable per-instance condition, and the documentation no longer claims one. Typed pending states now emit Warning Events, while a durable status design remains an explicit follow-up.
  • Input validation: peer, static-route, and BGP-neighbor fields now receive admission-time hostname/IP/CIDR validation. The deny-set remains specific to remoteCIDRs, which are the values that program namespace return routes.
  • Flag documentation: the incorrect SSH references were removed from --management-cidr help and warnings.

The tenant-admin RBAC omission for siterouters was also corrected, and the controller/API RBAC now covers the live Node and Service reads used by deny-set discovery.

Validation passed with the full Go suite, the standalone application API module, all affected Helm unit suites, package generation, and make manifests. The runtime negative-security e2e and durable controller-owned status remain documented follow-ups rather than being claimed as completed here.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: request changes

One hard merge blocker (the PR's own new CI gate fails on the committed tree), and one cross-tenant reachability question on the tunnel-ingress filter that the stated Cilium mitigation does not actually close. The security spine (deny-set, admission fail-closed, api-key isolation, RBAC) is otherwise solid and was verified against the code.

Blockers

1. Placeholder image digest breaks this PR's own CI gate and makes every gateway non-bootable

packages/apps/site-router/images/vyos-router-disk.tag carries an all-zeros digest (...@sha256:0000…0000).

  • This PR adds hack/image-refs-no-placeholder.bats, which make unit-tests runs in CI. Reproduced: grep -rIlE '@sha256:0{64}' packages/ --exclude-dir=charts returns exactly this file, so the gate exit 1s. The PR ships a guard its own tree violates — the unit-tests job is red and it cannot merge as-is.
  • Functionally: the VyOS golden image is not published, so a tenant's SiteRouter boot DataVolume resolves nowhere → boot PVC stays Pending forever and the VM never boots. dv.yaml's required guard only rejects an empty .tag; a well-formed placeholder digest passes render. Since the app is wired into the naas bundle, an install/upgrade exposes the SiteRouter kind in the tenant catalog while no gateway can boot.

Fix: publish the cozystack-owned VyOS image and stamp the real digest before merge; do not activate the catalog entry until the image exists.

Security (needs resolution before merge)

2. Decrypted tunnel ingress can reach any tenant's pods, and the documented Cilium backstop does not enforce cross-tenant isolation for it

  • internal/controller/siterouter/vyospush.go:665 (tenantNetworkCIDRs) builds the decrypted-traffic destination allow-list as PodCIDR (the whole cluster pod CIDR, e.g. 10.244.0.0/16) plus this-namespace Service ClusterIPs. Services are tenant-scoped; pods are not.
  • internal/vyos/render/render.go:686 (renderTunnelIngressFilter) turns that into accepts of source ∈ remoteCIDR AND destination ∈ podCIDR. The IPsec local selector is 0.0.0.0/0 and no NAT is rendered, so the decrypted packet keeps the remote peer's real source IP.
  • docs/security-model.md:27 states this pod-CIDR breadth is safe because "a destination inside the cluster pod CIDR proceeds to Cilium, whose identity policy enforces cross-tenant isolation." The tenant baseline allow-external-communication (packages/apps/tenant/templates/networkpolicy.yaml) unconditionally admits fromEntities: [world, cluster]. A decrypted packet with an external source IP is classified as identity world, so a victim pod in another tenant governed by that baseline will accept it. The stated mitigation therefore does not hold for this traffic.

Failure scenario: tenant A sets remoteCIDRs=[203.0.113.0/24]; a host on that subnet sends a packet to tenant B's pod IP; it is decrypted, matches source∈203.0.113.0/24 AND dest∈podCIDR, is forwarded, and tenant B's pod accepts it as world ingress.

What is proven from code: the filter's destination breadth (whole pod CIDR), source-IP preservation, and that the baseline admits world. What is not empirically confirmed (needs a live cluster, blocked on the golden image): the actual cross-tenant delivery through kube-ovn. Please either scope the destination to the owning tenant's own pods, or empirically prove the delivery is denied and correct the security-model wording.

Non-blocking findings

3. BGP is a half-wired feature

The public schema api/apps/v1alpha1/siterouter/types.go (BGP) exposes only enabled / localASN / neighbors. resolveInputs (vyospush.go:591) constructs render.BGPPeer{PeerAddress, PeerAsn} only. The render fields AdvertisedNetworks, RouterID, Timers, and BGPPasswords (render.go:140) are unreachable from the controller — dead code. A tenant setting bgp.enabled=true gets a session that originates no routes; the instance stays Ready with no warning event (unlike the missing-ASN path, which does warn). Either document this as receive-only for Phase 1, or wire advertisedNetworks into the schema.

4. No runtime detection of a managementCIDR lockout

The chart and controller defaults are both the whole pod CIDR 10.244.0.0/16. ValidateManagementCIDR only checks the flag is a valid CIDR, not that it covers the controller's source. On a cluster with a non-default pod CIDR, or if the chart managementCIDR and the controller --management-cidr drift, the controller silently firewalls itself out of every gateway, surfacing only as an opaque ConfigureFailed. Consider deriving the CIDR from the same ConfigMap the deny-set already reads, or emitting a distinct diagnostic when pushes time out.

5. Checklist item 9 ("unit green in CI") is partial

helm-unittest (60 tests) and the Go unit tests pass locally, but make unit-tests (the CI job) also runs the new placeholder-digest bats gate, which is red on the committed tag (see Blocker 1).

6. Stale scaffolding comments

internal/controller/siterouter/reconciler.go:15,258,414 describe fully-implemented methods (validateRemoteCIDRs, programNamespaceRoutes, verifyGatewayPortSecurityRelaxed, removeNamespaceRoutes, …) as "ordered stubs" / "no-op stub" / "placeholder returning nil". Update the comments so they don't misrepresent what is implemented.

7. Repo hygiene

No ```release-note ``` fenced block in the PR body (CONTRIBUTING requires one). Add'site-router': 'area/networking'to.github/workflows/pr-labeler.yaml` (already noted in the description).

Verified sound

  • Deny-set validator (denyset.Validate): masking, IPv4-only rejection (incl. 4-in-6), /0 rejection, unconditional link-local/loopback, and bidirectional Overlaps are all correct; admission and controller share the same helper, so they cannot diverge.
  • Admission fails closed and is wired on both Create and Update (pkg/registry/apps/application/rest.go:208,561); deny-set discovery errors (ConfigMap non-NotFound / Node / Service list) propagate.
  • The api-key Secret is genuinely not tenant-readable: not in the cozyrd secrets.include, not in dashboard-resourcemap.yaml, and the tenant ClusterRoles grant no blanket get secrets; the guest login is locked (encrypted-password "*", no service ssh).
  • Controller RBAC is least-privilege (secrets: get only, no cluster-wide list); secret redaction happens before message truncation; route merge/withdraw keying does not hijack a co-tenant's same-destination route; --leader-elect is hardcoded, so the in-memory hash cache is safe.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

IvanHunters rebased this onto current main (branch was 276 commits behind) and fixed what CI actually fails on now, old logs were useless. Your two blockers are untouched, details at the end.

Result is one squashed commit 8fbe38938 on 09dc7f47f. Only two textual conflicts, both in hack/select-e2e.sh, and resolving them turned up something worse than the deferred-suite gap you flagged.

DEFERRED_SUITES="site-router" stripped the name from selector's final output. Main has since grown per-path coverage escalation (#3330). Together they give empty selection, not reduced one: site-router-application depends on kubevirt-cdi, so reverse walk gave kubevirt-cdi and cozystack-basics exactly one suite, and stripping turned that one into zero. Both e2e lanes read empty as "skip chainsaw" and post E2E Tests green.

packages/system/cozystack-basics        21 suites -> EMPTY
packages/system/kubevirt-cdi            21 suites -> EMPTY
packages/system/site-router-controller  21 suites -> EMPTY

Fixed by parking the suite as chainsaw-test.yaml.disabled, which is main's own convention already (hack/e2e-chainsaw/backup/). That drops it out of selector universe and out of select-install.sh round-trip at once, so hack/select-e2e.sh is byte-identical to main now and un-parking is just renaming the file back. One deliberate behaviour change, a packages/apps/site-router change escalates to full suite instead of selecting nothing, which is what #3330 prescribes for a package with no runnable suite.

Three real unit test failures behind your item 5:

  1. cozystack-api rbac_test pinned rule count at 16, branch adds name-scoped cozystack ConfigMap read for deny-set. Bumped to 17 and added coverage pinning resourceNames and verbs, so a widening cannot pass under it.
  2. Main now errors when a package declares test: and runs zero suites, and site-router-controller chart shipped none. Added rbac and deployment tests, 18 of them, pinning what the RBAC comments claim: Secret get only, Namespace get and patch only, HelmRelease never update or delete, and fail-closed --allow-open-management.
  3. EXIT traps in the 4 new bats files. Per docs/agents/e2e-testing.md that count is a ratchet review keeps from growing, so i removed the traps instead of raising it.

Security surface is byte-identical to pre-rebase, checked file by file: denyset.go, discovery.go, rest_siterouter.go, render.go, cnimediation.go and the whole chart including both Cilium policies. rest.go was auto-merged so i verified admission separately rather than trusting the merge, still invoked fail-closed on create and update.

Both your blockers stay open. Placeholder digest is deliberate, hack/image-refs-no-placeholder.bats is red until the appliance image is published and that is what its commit message says it is for, so make unit-tests cannot go green before that ships. Tunnel-ingress destination breadth needs the scoping call, i did not touch it.

Please take another look at the delta.

@myasnikovdaniil
myasnikovdaniil force-pushed the feat/site-router branch 2 times, most recently from b17e76d to ce01379 Compare August 15, 2026 07:21
myasnikovdaniil added a commit that referenced this pull request Aug 16, 2026
Both are cases of the walk reaching the wrong set of suites for a
package, and both were found by enabling the site-router suite on #3426,
which is the first suite in the tree to land downstream of kubevirt-cdi.

`vm-disk-application` now maps to the `vminstance` suite. It mapped to a
`vm-disk` suite that does not exist, which intersect_suites() drops, so
the source reached nothing runnable and neither did kubevirt-cdi above
it: every CDI change ran all 21 suites. The vminstance suite creates a
VMDisk and asserts the DataVolume behind it
(hack/e2e-chainsaw/vminstance/vmdisk.yaml, vmdisk-vmi.yaml), so it is the
suite that covers both. A CDI change now selects `vminstance` alone.

`cozystack.cozystack-basics` joins `cozystack.cozystack-engine` as a
propagation hub excluded from the reverse-dependency walk. Every edge
into it exists so a namespace or a platform-wide policy is in place
before the dependent installs -- kubevirt-cdi says exactly that in its
own source -- which is install ordering, not behaviour. The damage runs
opposite to the engine's: the engine fans one change out to every app,
while basics narrows down instead. It reaches no suite today so it
escalates, but it sits upstream of kubevirt-cdi, so the first suite to
land under CDI silently converts the platform's namespace-and-policy
package from the full run to that one suite. On #3426 that is 22 suites
down to `site-router` alone, with nothing in the output to say coverage
was lost. Dropping the reverse edges keeps the package reachable, stops
it propagating, and leaves a change to it running everything through the
per-path escalation.

Both rules have a test, each verified to redden with its rule removed:
the mapping test falls back to the full-suite escalation, and the hub
test -- which seeds the hazard by giving redis-application an edge onto
basics, since no suite sits downstream of it in this tree yet -- selects
`redis vminstance` instead of everything.

Assisted-By: Claude <[email protected]>
Signed-off-by: Myasnikov Daniil <[email protected]>
The security model said a finalizer's best-effort cleanup still removes
the port_security annotation on delete. reconcileDelete stopped touching
it when the `pods patch` grant went; the annotation lives on the VM's own
pod template and goes when the VM does.

The deny-set section exists to enumerate what is NOT judged, and named
only staticRoutes[].nextHop. tunnel.ipsec.peer.address is unjudged too.
Its exposure is small -- it programs no accept and no route -- but an
enumeration that is the argument for its own completeness has to be
complete.

Both documents still called the negative-security e2e blocked on the
published appliance image. It is not: the step is written, the image
builds in CI, and the suite runs on every full-suite escalation. What it
is waiting for is a run that reaches it.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
…eason

Neither is a Phase-1 change, and recording why in the tree beats leaving
it implied.

The tunnel-ingress rule set grows as remoteCIDRs x live tenant
destinations with no cap anywhere. A cap is only meaningful next to a
decision about what happens when it is hit -- dropping destinations
narrows the data plane silently, refusing the push freezes it -- and
that decision belongs with the per-member addressing work.

Cluster-network discovery lists all Nodes and all Services uncached on
every reconcile. Uncached is deliberate and argued in CacheByObject, so
the fix is not to cache them but to share one discovery behind a short
TTL or to narrow the reads.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
…teway

Which pod it talks to, and on what channel. The second change alters a
signature the first one touches, which is why they are one commit.

discoverGatewayPod took the first Running pod the List returned. That
pod's IP becomes the tenant's kube-ovn next hop and the address the
rendered router config is POSTed to, and virt-launcher names carry a
random suffix, so with two Running launchers the choice separates a
migration's source from its target by chance. The VMI already answers
this and the controller already reads it for MAC discovery:
status.activePods maps pod UID to node, status.nodeName is the node the
guest runs on, and their intersection is the one pod that is the VM right
now. An unreadable VMI falls back to the previous selection, which is
what a non-migrating instance gets anyway and what first boot looks like
before the VMI exists. The gateway as shipped cannot live-migrate, its
boot PVC is RWO and the VMI says so with LiveMigratable=False, so nothing
reaches this today; it is three lines and it stops being theoretical the
day that disk becomes RWX.

DefaultVyOSClientFactory fell back to an unverified connection whenever
the api-key Secret carried no certificate. The fallback existed for an
appliance predating the seeded certificate, which is an empty set:
site-router has never been released, so no such instance exists anywhere.
The in-band token does not make the channel safe either, because whoever
can intercept it collects the token too. An endpoint that arrives without
a CA is a chart that failed to seed one, so the push now fails and says
that, rather than dialling a connection nobody verifies for the life of
the instance.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
…nder

Both blocks explain something the rendered manifest cannot: why
port_security is baked on the pod template instead of patched live, and
why the boot disk asks for 12Gi against a 10Gi image. Their audience is
whoever reads the template, so they belong in a Helm comment, which is
the form the rest of this chart already uses.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
…emoteCIDRs

security.egressDenyCIDRs described itself as platform-owned while
sitting in the tenant schema, so nothing stopped a tenant setting it and
a UI reading the schema would present it as read-only when it was not.
The exposure was small, since the template emits the built-in
169.254.0.0/16 deny unconditionally and only appends this list, so it
can add denies and never remove one. Small is not owned: the ranges
worth listing are management and node networks an operator knows and a
tenant does not, and a wrong one denies the gateway a path it needs. It
moves to `_egressDenyCIDRs`, where the aggregated API's rejection of
top-level `_` keys is the enforcement rather than the description.

remoteCIDRs had no bound anywhere. It is one side of a product: the
guest's tunnel-ingress filter emits one accept per (remoteCIDR x tenant
destination), the other side is every live Pod IP and Service ClusterIP
in the namespace, and the whole rule set is pushed on every config
change. 16 is well past what a site-to-site peering declares and bounds
the multiplier a tenant controls. Bounding the other side needs a policy
for what happens when a namespace outgrows it, which stays deferred.

Generated files regenerated with the CI-pinned cozyvalues-gen v1.6.0.

Assisted-By: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
# Conflicts:
#	.github/workflows/pull-requests.yaml
#	hack/build-matrix.sh
…ter mounts over

The appliance seed is ordered Before=vyos-router.service, and vyos-router
mounts the persistent configuration over /config while it starts. The
emitter was installed under /config/scripts, so it was shadowed before
cron ever read it: the console reported the install, /etc/cron.d kept its
entry, and the file was gone. Every run of this feature has produced a
guest with no bring-up diagnostics and no explanation for the silence.

The root filesystem is where both survivors already live, and the seed
reinstalls the emitter from the diagnostics disk on every boot, so
persistence buys nothing here. The seed also stopped announcing an
install it never checked, which is what let the shadowed path read as a
working one.

Nothing rendered by the chart can see where the seed writes, so the two
literals are held together in the bats suite instead: it reads the
install path out of the seed and requires the chart's cron entry and
remote site B's copy of it to name that same path.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
The data-plane collector gates its heavy per-node capture on a live
reachability probe, and that probe is a TCP connect. Against a Service
whose port is UDP every attempt fails for a reason that has nothing to do
with the datapath, so the collector wrote the address up as UNREACHABLE
and spent tens of seconds characterising an announcer and an endpoint
that were fine. site-router's tunnel LoadBalancer is 500/4500 UDP and hit
this on every failed run; the conntrack dump it produced shows what was
actually measured, `tcp SYN_SENT dport=500 [UNREPLIED]`.

Probing UDP instead is not available: without an application reply, a
delivered datagram and a dropped one are the same observation. The
collector already distinguishes unprobed from unreachable, so a non-TCP
port belongs in that bucket, and the reasons a probe cannot be attempted
now sit in one helper the unit suite can reach.

The Service row grows a protocol column, placed ahead of the two
display-only ones because a field count cannot detect a cut inside the
last value and this column decides whether a probe runs.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
…asks

Two lines in the failure diagnostics reported the opposite of the truth.

`grep ... | tail -n 120 || echo` takes the pipeline's status, which is
tail's 0 whether or not grep matched, so the line explaining an absent
emitter could never run: a guest with no diagnostics printed the heading
and nothing under it, which is the one case the line exists for.

The Cilium policy listing selected on apps.cozystack.io/application.name,
which the platform stamps onto the workload and not onto the objects a
chart renders. It printed "No resources found" for a gateway whose two
policies were present and admitted, which reads as a render that failed.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
# Conflicts:
#	packages/system/cozystack-api/tests/rbac_test.yaml
myasnikovdaniil added a commit that referenced this pull request Sep 22, 2026
…4362)

## What

`hack/overlay-main-images.sh` walks `images/*.tag`, but every branch of
its line classifier needs either a YAML key or an `@sha256:`. A `.tag`
file holds one bare reference and nothing else, so until a build stamps
it the line has neither, the drift guard reads it as a non-ref change,
and the committed reference survives.

For a package onboarded with the `:v0.0.0` placeholder this repo already
uses for a new image, that surviving reference is one no registry
serves. The overlay is the only thing that would replace it for a PR
that does not rebuild the package, so once such a package is on main
every full-suite run that skips its build keeps the placeholder. The
same placeholder inside a `values.yaml` repairs itself, because there it
sits under an `image:` key the classifier recognises. The difference is
the file shape, not the placeholder.

## Reproduction

A fixture holding a committed bare tag against an artifact copy carrying
a stamped one:

```
$ cd fixture/fakerepo && sh ../../hack/overlay-main-images.sh ../mainpkgs '[]' ''
overlay: packages/system/x/values.yaml -> current-main
drift (non-ref change) in packages/apps/x/images/x.tag -> keeping committed ref
```

Both files differ only in their reference. The `values.yaml` one is
repaired; the `.tag` one is not. Adding any digest to the same
placeholder makes it pass, which is what narrows this to the classifier
rather than the file.

## Why it has not bitten yet

17 of the 19 `.tag` files in the tree carry `@sha256:` and pass on that
branch alone. The only other digest-less one is
`packages/extra/seaweedfs/images/seaweedfs-cosi-driver.tag`, and it
survives only because it is third-party: `build-main` never rebuilds it,
the artifact copy is byte-identical, and the classifier is never
reached. The first digest-less first-party `.tag` is what turns the gap
live, and one is coming in #3426.

## The fix

`hack/promote-rewrite-tags.sh` already carries a second match branch for
exactly this shape, with a comment saying why ("the second branch is
what catches a bare `.tag` file, whose content has no YAML key at all").
This gives the overlay the same one, scoped to `*.tag` so an unkeyed
line in a `values.yaml` still counts as drift, because only in a `.tag`
file is a whole line known to be a reference and nothing else.

Two tests in `hack/overlay-main-images_test.bats`: the unstamped
placeholder is overlaid, and a `.tag` whose non-reference line differs
still counts as drift, so the rule cannot quietly widen into "every line
in a `.tag` file is a reference". Removing the fix fails the first one.

```release-note
NONE
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **Bug Fixes**
- Fixed image overlay handling for `.tag` files containing bare image
references, allowing current references to be applied correctly.
- Preserved drift detection when `.tag` files also contain unrelated,
non-reference changes.

- **Documentation**
- Clarified how image-reference tooling handles bare references and
package-tree processing.

- **Tests**
- Added coverage for bare-reference overlays and mixed reference/comment
changes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…d source

The backend reporting Ready does not mean the route back to the remote
site is carrying traffic. The annotation is stamped at pod CREATE and is
there the moment the pod exists, but ovn-controller installs the flow
behind it, and kubelet readiness observes neither. The step fired one
probe the instant the pod went Ready and read the miss as a product
failure, which is what the suite has been reporting: curl 28, empty body,
nothing else to go on.

Measured by recreating the backend on a three-node stand and probing from
the instant it went Ready: the first answer came at 20s, 25s and 21s
across three runs, every earlier attempt returning 28 and nothing. The
same loop with the wait in place passed three for three, each time with
the first probe failing and the second answering.

This waits for reachability rather than retrying an assertion. The answer
is still checked once and strictly, so a NAT'd source fails the step as
before, and a backend that never answers inside the budget fails it too,
carrying the last status and output. The wait also protects the steps
after it: the negative-security cases assert that traffic is refused, and
an unprogrammed route would have satisfied them for the wrong reason.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
B is applied with kubectl from inside a script step, so Chainsaw never
records it and its cleanup never deletes it. The VM kept running after
the suite finished, through every later suite and into the sandbox
teardown. Both runs that left it running are also the two whose
teardown hung until the job reached its 215-minute cap, while teardown
on other pull requests takes a minute or two, and a job cancelled at
the cap reports the required E2E status as failed whatever the tests
did.

A step cleanup block runs when the test ends, whatever its outcome, and
after everything Chainsaw applied in later steps is gone. The
DataVolume and the claim are deleted explicitly so the wait covers the
disk rather than only the VM that owns it.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

NOT LGTM. Still not reviewable in one round: +20948/-84 over 127 files, so silence outside the findings is budget, not approval.

31 of 41 earlier findings are closed, each re-checked by mutation; nothing closed has regressed.

Findings

  • [MAJOR] internal/controller/siterouter/reconciler.go:1015, a failed ConfigMap read re-stamps the management ACL and locks the controller out
  • [MAJOR] hack/e2e-chainsaw/site-router/chainsaw-test.yaml:903, the set -e diagnostic loss fixed in step 12 survives in the acceptance gate
  • [MAJOR] packages/apps/site-router/templates/dashboard-resourcemap.yaml:17, nothing holds the tenant-facing Secret grant, and the excluded Secret has tls.key
  • [MAJOR] internal/controller/siterouter/reconciler.go:1081, a new tenant pod is unreachable until the 30s poll; the e2e cause is not established
  • [MAJOR] internal/vyos/render/render.go:692, the rule set still has one unbounded factor, pushed under a fixed 30s timeout
  • [MAJOR] packages/apps/site-router/templates/secret-cloudinit.yaml:49, an appliance bump desynchronises the pinned disk from the config.boot re-applied every boot
  • [MAJOR] packages/apps/site-router/templates/_helpers.tpl:120, the management firewall admits 443 from the whole pod CIDR, so the token is the only control
  • [MAJOR] packages/apps/site-router/values.yaml:129, the platform-owned _ values have no writer, and a hand-set one dies on the next edit
  • [MAJOR] packages/apps/site-router/templates/service.yaml:20, loadBalancerClass has no immutability guard, though this repo has one for the same value
  • [MAJOR] packages/system/site-router-rd/cozyrds/site-router.yaml:4, still no install-wait annotations on a release that waits on a LoadBalancer and a 12Gi import
  • [MAJOR] internal/siterouter/denyset/discovery.go:41, still two uncached cluster-wide LISTs per instance every 30s, on one worker
  • [MINOR] pkg/registry/apps/application/rest.go:662, the conflict-retry carry-over re-sends a changed annotation stale
  • [MINOR] internal/siterouter/denyset/denyset.go:161, a deny-set rejection echoes a node or LoadBalancer address back to the tenant
  • [MINOR] internal/controller/siterouter/reconciler.go:505, the controller half of the deny-set enumeration is untested
  • [MINOR] internal/vyos/client.go:150, WithInsecureSkipVerify is dead exported code, and three comments still describe it as live

Still open from my earlier rounds

The placeholder-appliance CRITICAL is fixed, and not here: 41ef4a643 on main teaches the overlay to repair an unstamped images/*.tag. Run against the fixture that fails on this head it gives overlaid=1 drift=0, where this head gives drift=1. Rebase and it retires. Nothing here reverts it: this branch does not touch that script.

Four stay open, now recorded in followups.md as accepted residuals: the uncached LISTs, the unbounded rule product, the install-wait annotations, D9. An acknowledgement is a decision, so the first three are re-filed above.

Caveats

  • The e2e enters through a hand-written HelmRelease, not a kind: SiteRouter, so aggregated-API admission, the _-key rejection and the cozyrd conversion all go unexercised.
  • E2E (in-tree) was still running when this was written, so nothing rests on it. The one red check is the API-owner governance gate, not code.
  • Needs a cluster: the LoadBalancer wait with no announcer, a rejected loadBalancerClass change, the VyOS config migration on a bump.

// something narrower or wider than the chart seeded.
log.FromContext(ctx).Info("could not read the cozystack ConfigMap for the management CIDR; using the platform default",
"configMap", key.String(), "default", denyset.DefaultPodCIDR, "error", err.Error())
return denyset.DefaultPodCIDR

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] a failed ConfigMap read re-stamps the management ACL and locks the controller out

The comment on this branch states the requirement: "a transient read failure must not rewrite the management ACL to something narrower or wider than the chart seeded". The next statement returns denyset.DefaultPodCIDR, which on any cluster whose pod CIDR is not 10.244.0.0/16 is exactly that rewrite.

$ sed -n '1007,1016p' internal/controller/siterouter/reconciler.go
	if err := r.reader().Get(ctx, key, cm); err != nil && !apierrors.IsNotFound(err) {
		// ... a transient read failure must not rewrite the management ACL to
		// something narrower or wider than the chart seeded.
		log.FromContext(ctx).Info("could not read the cozystack ConfigMap for the management CIDR; using the platform default", ...)
		return denyset.DefaultPodCIDR
	}

resolveManagementCIDR feeds every push, and its value becomes the guest's rule 10 accept on TCP 443 next to default-action drop. On a cluster with pod CIDR 10.112.0.0/12 the chart seeds the discovered value and the controller normally agrees, so this round's fix holds on the happy path. One failed ConfigMap read at push time re-stamps source address 10.244.0.0/16 instead, and the controller's own pod address is then outside the accept. Rule 5 keeps the in-flight session alive, so the push that installs the lockout succeeds; every later connection is dropped. The controller cannot correct it, because correcting it requires the API it just locked itself out of, and there is no SSH and no console login. Recovery is recreating the VM.

That is the same failure the static default produced before this round, reached through the error branch instead of through a constant.

Fix: do not substitute a value. return "", err and let the caller classify it, so a failed read skips the push and the guest keeps the ACL it already has.


# ── ALLOW case: declared remote source → intended tenant dest ─────
# Reachable AND source preserved (192.0.2.x observed at the backend).
observed=$(probe_from_b_stdout http "$BACKEND_IP" 8080 "/clientip")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] the set -e diagnostic loss fixed in step 12 survives in the acceptance gate

f44df5a57 fixed exactly this shape one step earlier, and its own comment explains why: "under set -e a failing command substitution kills the script before the next line". The acceptance gate still has it. The step opens with set -eu and this line is a bare command substitution with no set +e, no captured status and no record.

$ sh -c 'set -eu
probe_from_b_stdout() { return 28; }
observed=$(probe_from_b_stdout http 10.0.0.1 8080 /clientip)
case "$observed" in
  192.0.2.*) echo "OK [allowed flow]" ;;
  *) echo "FAIL [allowed flow]: got \"$observed\"" ;;
esac
echo "reached the end"'
$ echo "exit=$?"
exit=28

Nothing printed. The FAIL [allowed flow] branch, the observed value and the end-of-step line are all unreachable when the probe fails, which is the only case whose output matters. The report then carries chainsaw's wrapper error and nothing about this flow, exactly as assert-inbound-source-preserved did before the fix.

This is the Phase-1 acceptance gate, so it is the step whose diagnostics are worth the most. Give it the same set +e / capture / record treatment step 12 now has. Worth a grep for the shape across the rest of the suite while you are in there.

- ""
resources:
- secrets
resourceNames:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] nothing holds the tenant-facing Secret grant, and the excluded Secret has tls.key

The header comment calls the exclusion load-bearing: the api-key Secret is "deliberately NOT listed here ... it stays RBAC-isolated". templates/secret-cloudinit.yaml shows that Secret holding token, tls.crt and tls.key. No test holds the comment.

Mutation against the shipped suite, restored afterwards:

$ helm unittest packages/apps/site-router | grep '^Tests:'
Tests:       74 passed, 74 total
$ # remove the resourceNames blocks: the rule becomes every Secret in the namespace
$ helm unittest packages/apps/site-router | grep '^Tests:'
Tests:       74 passed, 74 total
$ git diff --stat -- packages/apps/site-router/templates/dashboard-resourcemap.yaml
(empty)

So widening a tenant-facing Role from one named Secret to every Secret in the namespace, including the one holding the gateway's private key and its management token, does not redden anything. The reason is that tests/values_matrix_test.yaml lists this template at suite level but every case narrows templates: to vm.yaml or service.yaml, so the Role renders and is never asserted on.

The controller's own RBAC did get a drift test (hack/site-router-rbac.bats, which renders and diffs properly). The grant facing the untrusted principal got none. One suite with documentSelector on kind: Role, asserting contains the -psk name, notContains the -api-key name and an exact verb list, closes it.

if !ok {
return nil
}
if pod.Labels[appKindLabelKey] != siteRouterKind {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] a new tenant pod is unreachable until the 30s poll; the e2e cause is not established

The tunnel-ingress accept set is built from every Pod IP and Service ClusterIP in the namespace (vyospush.go:818). The Pod watch enqueues only pods carrying the site-router kind label, and the manager's Pod informer is scoped to the same label (CacheByObject). There is no Service watch at all. So the watch fires for precisely the pods the set excludes, and never for the pods the set is made of, and the only thing that refreshes it is the runtimePollInterval = 30 * time.Second requeue.

Operationally: a tenant scales a Deployment and the new replica is unreachable from the remote site for up to 30 seconds. During a rolling replacement the departing pod leaves the set immediately (terminating pods are skipped) while its replacement waits for the poll, so a single-replica workload can be dark for that window with nothing reporting it.

This also bears on the newest commit. fix(site-router): wait for the return route before reading the inbound source attributes the e2e failure to ovn-controller installing the flow behind the annotation, measured at 20s, 25s and 21s to first answer. I read the failing run's log (job 106707466930, head daf553b) and the timing points elsewhere:

11:15:10  backend pod created
11:15:11  controller reconcile   <- the last one before the probe
11:15:18  backend pod gets its IP (AddedInterface)
11:15:19  probe from B
11:15:27  curl --max-time 8 expires -> rc=28
~11:15:41 next reconcile (30s poll)

The controller logged nothing between 11:15:12 and 11:15:40, so the reconcile that built the accept set ran seven seconds before the address existed. The rule for that IP could not be present at probe time, and 20-25s to first answer fits a uniform wait inside a 30s poll better than a flow install. The wait is the right fix either way because it is cause-agnostic, but the stated cause is not established, and if the poll is the real one then the wait has hidden a product property rather than a test race.

Either map tenant Pods and Services in a SiteRouter namespace to the instance, or state in the docs and the chart that reachability is bounded by the poll and cite vyospush.go:36 from the test.

// the boundary.
rule := 10
for _, cidr := range in.RemoteCIDRs {
for _, tenantNet := range in.TenantNetworkCIDRs {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] the rule set still has one unbounded factor, pushed under a fixed 30s timeout

remoteCIDRs gained maxItems: 16 this round, which caps one factor. The other is tenantNetworkCIDRs: one /32 per Pod IP and per Service ClusterIP in the namespace, filtered only by role (gateway, terminating, succeeded/failed, hostNetwork) and capped nowhere.

Measured against the real renderer in an earlier round, three operations per pair:

remoteCIDRs=5   tenantDests=50   -> ops=807     json=92643 bytes
remoteCIDRs=20  tenantDests=200  -> ops=12057   json=1419746 bytes

With the cap in place the 16-remoteCIDR column is the relevant one, and a namespace running a couple of hundred pods and services still lands in the thousands of rules and megabytes of body. That goes out as a single POST on a client whose timeout is the constant 30 * time.Second (internal/vyos/client.go:175), which the production factory does not override, and which equals the poll interval. A commit the guest cannot finish inside 30s fails the push, the hash is deliberately not recorded, and the next reconcile re-renders and re-sends the same batch. The tunnel then never converges, and because the destination set also moves on ordinary pod churn the hash never stabilises either.

followups.md:26 records the rule count as unbounded but frames it as "a capacity question rather than a correctness one". With this timeout it is a convergence question. Bound the destination set with a legible refusal when it is exceeded, or chunk the push and derive the timeout from the payload. Note also render.go:656 still sizes the slice as len(in.RemoteCIDRs)*2+4, from the old source-only shape.

nets := ClusterNetworksFromConfigMap(data)

nodes := &corev1.NodeList{}
if err := reader.List(ctx, nodes); err != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MAJOR] still two uncached cluster-wide LISTs per instance every 30s, on one worker

Carried from the previous round and re-filed for the same reason as the item above: followups.md:32 now records it with the proposed fix, which is an acknowledgement rather than a bound.

Both LISTs here are cluster-wide with no field or label selector, and they run through mgr.GetAPIReader(), uncached, in production. The path is unconditional: validateDeclaredNetworks is pipeline step one, and the only instance that escapes it is one that declares no remote networks, no static routes and no BGP neighbours, which is one that does nothing. The steady-state requeue is 30s, and SetupWithManager sets no MaxConcurrentReconciles, so controller-runtime's default of one worker applies.

So each instance pulls every Service in every namespace plus every Node twice a minute, past the cache, and the cost scales with instance count on a single worker: as it grows, the poll cadence stretches silently, and that same cadence is what the tunnel-state gauges, the drift re-stamp and the D8 source-filter re-confirm all depend on.

The comment at reconciler.go:137 names the alternative that was not taken. A short TTL shared across reconciles, or narrowly scoped cached reads, both work; so would raising the worker count, which is independent of the caching question.

return getErr
}
helmRelease.SetResourceVersion(cur.GetResourceVersion())
helmRelease.SetResourceVersion(latest.GetResourceVersion())
carryOverRuntimeMetadata(helmRelease, latest)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] the conflict-retry carry-over re-sends a changed annotation stale

The comment above the retry says re-sending the object built from the stale read "would drop whatever they had just added — the exact loss the carry-over was added to stop". Calling the helper a second time does not close that, because it copies a key only when it is absent from dst, and dst is the same object the first call already populated from the stale read.

Probe against the real function, replicating Update's two-call sequence, then removed:

$ go test ./pkg/registry/apps/application/ -run TestProbe_CarryOverAcrossConflictRetry -v
    annotation after retry = "10.244.0.1" (cur="10.244.0.1", latest="10.244.0.2")
    finalizers after retry = [apps.cozystack.io/site-router-mediation finalizers.fluxcd.io/release]
    control, key absent from cur: "10.244.0.2"

Finalizers are refreshed wholesale and are correct. An annotation that changed in the conflict window keeps the stale value; the control shows a key absent from the first read lands correctly, so only the changed and removed shapes are affected.

I grade this MINOR rather than higher because the one annotation in play self-heals: rememberRouteGatewayIP compares the recorded value against the live gateway IP and re-patches on mismatch, so a reverted value is corrected within a poll interval, and the stranded-route outcome needs a delete inside that window with the gateway pod already gone. I also looked for a consumer that would not self-heal, since reconcile.fluxcd.io/requestedAt and forceAt are unprefixed and are written onto HelmReleases here, and did not find one: forceHelmRelease targets a configured platform release, not an Application-backed instance.

The guard does nothing useful on the first call, since a tenant cannot set an unprefixed annotation through this API at all, and the wrong thing on the second. Track the keys the carry-over itself set and let a later call refresh those. rest_controller_metadata_test.go covers only the add case; the changed and removed shapes are red today. This is Update for every app kind, not only SiteRouter.

if r.Network == NetworkUnsupported {
return fmt.Sprintf("%s %q uses an unsupported address family; only IPv4 is supported", field, r.Value)
}
return fmt.Sprintf("%s %q overlaps the cluster %s network %s", field, r.Value, r.Network, r.Collides)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] a deny-set rejection echoes a node or LoadBalancer address back to the tenant

Rejection.Message formats the colliding network into the text, and for the discovered classes that value is a live address as a /32:

$ sed -n '161p' internal/siterouter/denyset/denyset.go
	return fmt.Sprintf("%s %q overlaps the cluster %s network %s", field, r.Value, r.Network, r.Collides)
$ sed -n '256,260p' internal/siterouter/denyset/denyset.go
					Network:  d.label,
					Collides: d.prefix.String(),

d.prefix for the node and LB-pool classes comes from live discovery: each Node's internal and external addresses and each allocated LoadBalancer ingress address. The message reaches the tenant verbatim, through apierrors.NewForbidden at admission and through a Warning Event on the HelmRelease at reconcile time.

So a tenant who can only edit apps in their own namespace can declare a broad remoteCIDR, read back one colliding address, exclude it and repeat, enumerating node addresses and other tenants' LoadBalancer addresses. They have no RBAC path to either otherwise.

MINOR because the practical value is limited: a workload can usually observe its own node address by other means, and a LoadBalancer address is externally visible by definition. It is still a confidentiality boundary the design does not intend to cross, and it is cheap to close: name the network class without the prefix, or echo only the tenant's own value. If it is kept deliberately, it belongs in the residuals list with the others.

// parity the two checks claim is only true for whichever field both happen to
// read.
func denysetInputs(values map[string]any) denyset.Inputs {
in := denyset.Inputs{RemoteCIDRs: stringSlice(values[remoteCIDRsValueKey])}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] the controller half of the deny-set enumeration is untested

rest_siterouter.go states the invariant plainly: the admission and reconcile inputs "must enumerate the same fields". Only the admission twin has a test holding it.

Mutation against the shipped suite, both restored:

$ # drop the bgp.neighbors branch from the controller's denysetInputs
$ go test ./internal/controller/siterouter/
ok
$ # drop the staticRoutes destinations branch instead
$ go test ./internal/controller/siterouter/
ok

The same mutations on the admission side redden TestSiteRouterAdmission_RejectsStaticRouteAndBGPNeighbor, so the asymmetry is in the coverage, not in the code: both halves are correct today, and only one of them is held there.

That matters because the reconcile half is the one that catches a value which becomes unsafe after admission, when a node or a LoadBalancer address moves into a range a tenant already declared. A future divergence would be silent on exactly that path. A table test over denysetInputs asserting all three fields reach the validator closes it.

Comment thread internal/vyos/client.go
// proxy settings (HTTPS_PROXY, NO_PROXY) and the rest of the default
// behaviour (dial timeouts, keep-alives, connection pooling) survive.
// Only TLSClientConfig is replaced.
func WithInsecureSkipVerify() Option {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[MINOR] WithInsecureSkipVerify is dead exported code, and three comments still describe it as live

The behaviour change is real and I verified it: DefaultVyOSClientFactory returns an error when the api-key Secret carries no CA, and grep -rn 'WithInsecureSkipVerify' --include='*.go' . finds no call site anywhere, tests included. That closes the earlier finding.

What is left is the option itself, still exported and settable by any future caller, and three comments that now contradict the code:

  • vyospush.go:166 says "the factory falls back to an unverified connection and the caller records that it had to", contradicted eleven lines later by "There is deliberately no unverified fallback".
  • vyospush.go:326 calls an instance without a seeded certificate "still usable"; the push errors.
  • client.go:141 says the option "remains only for an appliance built before the chart seeded a certificate", and the factory says no such appliance is supported.

This is the same doc-outlives-code shape as the RBAC marker drift fixed this round, one file over. Delete the option and the three sentences, or keep the option and say what is allowed to construct it.

resolveManagementCIDR substituted the platform default whenever the
cozystack ConfigMap could not be read. The push installs that value as
the only source allowed to reach the gateway's management API, so on a
cluster whose pod CIDR is not the default one a single failed read shut
the controller out of the gateway for good: undoing it needs the API it
had just closed, and recreating the VM was the only way back.

A read error now fails the push instead, which leaves the guest on the
ACL it already has until the next reconcile tries again. A missing
ConfigMap, or one without the key, still falls back to the default the
chart uses.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
…wait

The comment on the inbound-source wait put the delay down to
ovn-controller installing the return-route flow after the pod went
Ready. That was never shown. The gateway accepts decrypted traffic only
for destinations the controller has pushed, and the controller's Pod
watch covers gateway pods only, so a new tenant pod enters that set on
the next 30-second poll. The measured 20 to 25 seconds to first answer
fits that. The wait itself does not change.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
@IvanHunters

Copy link
Copy Markdown
Collaborator

Correction to my last review: I over-graded it

My previous review filed 11 MAJOR findings. That grading was wrong, and the effect on this PR was to block it on a list far longer than the evidence supports. Sorry for the churn that caused.

I checked my own calibration across 358 reviews rather than guess. The median review I write has 1 MAJOR; 164 have none, and only 14 of 358 have five or more. The round I posted here had 11, the highest I have ever produced. On this PR the trend is worse than the number: across five rounds my MAJOR count went 3, 0, 3, 5, 11, while you closed 31 of the 41 findings carried forward with zero regressions. The code got better and my verdict got harder. That is my failure, not yours.

What actually blocks, in my judgement, is two things:

  • internal/controller/siterouter/reconciler.go:1015 — a failed ConfigMap read re-stamps the management ACL to the hard-coded default and locks the controller out of the gateway until the VM is recreated.
  • packages/apps/site-router/templates/service.yaml:20 — no loadBalancerClass immutability guard, where extra/ingress implements one for the same platform value.

Everything else in that review is non-blocking. The rule-count bound is a strong should-fix. The rest (the set -e diagnostics in negative-security, the missing test on the dashboard Secret grant, the 30s convergence window, the config.boot skew I could not verify, the management-firewall scope I said was not exploitable, the _ values) are MINOR or notes. Take or leave them.

I also withdraw three findings outright: the uncached LISTs, the unbounded rule product as a re-file, and the install-wait annotations. You had moved those to docs/followups.md as accepted residuals and I re-filed them as MAJOR anyway. That was me re-opening your decision, which is not review. I will not re-file an accepted residual again.

Separately: E2E (in-tree) is green on dcb65895, and the negative-security gate passed with the allowed flow preserved. That closes the acceptance item I had been carrying as unverifiable.

myasnikovdaniil added a commit that referenced this pull request Sep 24, 2026
…#4411)

## What this PR does

This PR makes `REST.Update` keep live `spec.suspend` of the HelmRelease,
on first write and on conflict retry. Update rebuilds HelmRelease from
Application and writes it back whole, Application has no suspension
field, so every edit through aggregated API resumed a release that
operator or controller had suspended.

Caller that depends on it is cnpg restore driver. It suspends target
release, patches Postgres app through this API, purges Cluster and
resumes only after purge is done, so next chart render lands
`bootstrap.recovery` on empty namespace. When the patch resumes release,
helm-controller upgrades right away, chart can render recovery Cluster
while purge is still running, then driver deletes it as the Cluster
being replaced and nothing renders it again, because values do not
change anymore. RestoreJob stays `Running` until its deadline.

On main it works only because of timing. Same write also drops
`finalizers.fluxcd.io`, and helm-controller v1.5.0 spends its first pass
after that on putting finalizer back and requeue (750ms minimum retry
delay) before it upgrades, this delay usually lets purge finish first.
#3426 keeps controller finalizers across this write, so delay is gone,
and `postgres-2-backup-roundtrip` failed on both its runs there while it
passes on other PRs. In
[35714873620](https://github.com/cozystack/cozystack/actions/runs/35714873620)
stuck restore is `pg-src-to-pg-target-pitr`: `startedAt` 11:40:37, helm
upgrade v3 at 11:40:37, new Cluster created at 11:40:38 and gone by
11:40:47, no render after that.
[35652855170](https://github.com/cozystack/cozystack/actions/runs/35652855170)
has same picture on first restore, all inside 22:10:51.

Tests are in `rest_suspend_test.go`: edit leaves `spec.suspend` as it
was (both values) with values still taken from Application, and suspend
that lands between read and write survives conflict retry. Removing
either line fails its test.

### Downstream repositories

Change is in how aggregated API writes HelmRelease back, no downstream
repo restates that. Rollback guide on website (`guides/concepts.md`)
tells operators to suspend HelmRelease while they work, with this fix
that suspension holds across app edit, so docs need no change.

- [x] No downstream repository is affected by this change
- [ ] [cozystack/website](https://github.com/cozystack/website) -
follow-up:
- [ ]
[cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack)
- follow-up:
- [ ]
[cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack)
- follow-up:
- [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up:
- [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up:
- [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) -
follow-up:
- [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) -
follow-up:
- [ ]
[cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server)
- follow-up:
- [ ]
[cozystack/external-apps-example](https://github.com/cozystack/external-apps-example)
- follow-up:
- [ ] [cozystack/examples](https://github.com/cozystack/examples) -
follow-up:
- [ ] [cozystack/community](https://github.com/cozystack/community) -
follow-up:

### Release note

```release-note
fix(api): app edit through cozystack API no longer resumes a suspended HelmRelease, so postgres restore waits for its purge before cluster is rendered again.
```


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Application updates now preserve a Helm release’s suspended or active
state instead of unintentionally changing it.
* The release state is also preserved when an update encounters a
conflict and retries, preventing a concurrent suspension from being
lost.
* Application updates continue to use the application’s configured
values while retaining the release’s current suspension state.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
spec.loadBalancerClass is immutable while a Service is type LoadBalancer:
setting, changing or clearing it is rejected. The tunnel Service takes
the class from the platform's publishing.loadBalancerClass, so an
operator changing that value later wedged the upgrade of every
installed instance on an opaque "may not change once set", and finding
the way out meant knowing that the Service had to be recreated.

The chart now looks the Service up and fails with the existing and
requested class and the command that recreates it, the guard
extra/ingress already carries for the same value. Nothing changes on a
fresh install, where the lookup is empty.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>
# Conflicts:
#	pkg/registry/apps/application/rest.go
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

IvanHunters thanks for recalibrating, both blockers are fixed: ConfigMap read error in e7c1664 and loadBalancerClass guard in 5deb9cf.

Step 12 comment says now it is the 30s poll, you were right there (ae26708).

Main is merged back with #4411 in it, rule-count bound stays in followups.md.

Andrei Kvapil (@kvaps) Timofei Larkin (@lllamnyp) API Review Gate needs one of you.

… role

The follow-up about keeping a boot image and its first-boot config in
step pointed at one specific downstream product. What the entry needs to
say is who owes the change: any external API consumer that advances the
app's boot image, which is where it has to be filed.

Assisted-by: LLM
Signed-off-by: Myasnikov Daniil <[email protected]>

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM.

Both blockers from my last review are closed, each checked by mutation rather than by reading:

  • e7c1664c9 — resolveManagementCIDR now returns an error and resolveInputs propagates it, so a failed ConfigMap read skips the push and the guest keeps the ACL it has. Restoring the default substitution reddens TestResolveManagementCIDR/a_ConfigMap_that_cannot_be_read_fails_instead_of_falling_back.
  • 5deb9cfe8 — the loadBalancerClass guard is ported from extra/ingress, with an error naming the exact recovery command and warning that the external address is reallocated. Removing the guard reddens three tests covering set, change and clear.

The rebase also brought 41ef4a643, which retires the placeholder-appliance finding entirely, and ae2670869 corrects the cause recorded for the probe wait.

Scope of this approval, stated plainly: it clears the findings I raised. It is not a certification of all 21k lines, and the non-blocking notes from my previous review stay non-blocking. The Require API owner review gate is separate and still needs an API owner.

@lllamnyp Timofei Larkin (lllamnyp) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking this pending a more detailed review. VyOS' license is not compatible with Cozystack.

@lllamnyp

Copy link
Copy Markdown
Member

I'm requesting changes on licensing grounds. The problem is with the appliance image this PR builds and publishes, and it blocks the PR regardless of the technical review.

packages/system/vyos-router-image downloads the official VyOS Stream 2026.03 ISO, converts it with build-vyos-image --reuse-iso, adds an overlay, and publishes the result as a containerDisk. make build pushes it to ghcr.io/cozystack/cozystack/vyos-router-disk, and every installation that enables site-router imports it from there. The ISO carries its own EULA at /usr/share/vyos/EULA, and the build leaves the branding unchanged (/etc/os-release still reads NAME="VyOS"). My reading of that EULA, which is not legal advice:

  • §2.2: the licensee "shall not unbundle or repackage the Software for distribution". Converting the ISO to a qcow2 and then to a containerDisk is repackaging it for distribution.
  • §3.1(g) prohibits "duplicate the Software or publish the Software for others to copy". The only exception §3.1 allows is "a separate custom agreement". Publishing the image to a public registry is exactly this.
  • §4: the VyOS trademarks and logo artwork, boot splash included, are all rights reserved, and the EULA "does not permit you to distribute the Software using VyOS trademarks, regardless of whether the Software has been modified".
  • §3.1(a) and (c) prohibit "using the Software on behalf of third parties" and "providing use of the Software in a service bureau arrangement". Running this image as a router for tenants is the app's whole use case. So even if the project published nothing, the app's default path would leave every operator who adopts it in breach of the EULA of the image it boots.

The same EULA says the open-source components remain under their own licences (§2, §12), and §4 allows redistribution once every VyOS mark and logo is removed and replaced. That leaves an open-source project three ways forward:

  1. Operator-supplied image. Cozystack ships no VyOS binary. The app takes the appliance image as a platform-level value, each operator obtains VyOS under terms that cover their own use, and the controller and chart remain Apache-2.0 code that manages a VyOS-API-compatible appliance.

  2. A debranded build from source.

    • Every VyOS mark and logo is replaced, boot splash included.
    • The EULA file and every reference to it are left out.
    • Sources are published as the GPL requires.

    This is closer to what community#30 planned. It also brings back the dependency on the public rolling repository, which the Makefile records dropping the pinned kernel package on 2026-09-08, so someone has to own that build.

  3. A non-VyOS appliance (for example FRR, strongSwan and nftables on a plain distribution). It needs no licence carve-outs, but internal/vyos and internal/vyos/render would have to be rewritten against a different target.

Whichever option is chosen, vyos-router-image needs to come out of make build and out of this PR. The build-vyos job also needs to stop pushing the image, which it currently does to the CI registry on every run.

The image-lifecycle section of community#30 should also be amended to record the choice. Its premise, that the platform builds and publishes the image, doesn't hold for the Stream binary, and it doesn't hold reliably for the public rolling repository either.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/networking Issues or PRs related to networking (ingress, gateway, vpn, metallb, kube-ovn) area/uncategorized PR auto-labeler could not map title scope to a known area/*; please review kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants