feat(site-router): add routed site-to-site IPsec gateway app (Phase 1) - #3426
myasnikovdaniil wants to merge 52 commits into
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds 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. ChangesSiteRouter contracts and packaging
Controller and VyOS runtime
Deferred acceptance coverage
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
🧹 Nitpick comments (5)
internal/controller/siterouter/status.go (1)
65-68: 🚀 Performance & Scalability | 🔵 TrivialUncached full-namespace pod
Liston every 30s poll can pressure the apiserver at scale.
surfacePendingRoutePodsruns on every reconcile (runtimePollInterval), and thisListgoes 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 winPrefer
COPYoverADDfor local files.
ADDis only needed for its URL-fetch/archive-auto-extraction behavior; a local qcow2 copy doesn't use either.COPYis 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 winUse bracket notation for the annotation key in this JSONPath filter. It’s the safer form for dotted annotation keys in
kubectlJSONPath 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
WithInsecureSkipVerifyclones the wrong base transport and skipsMinVersion.Two issues in this option:
- It clones
http.DefaultTransportunconditionally instead of the client's currentc.http.Transport. If a caller combinesWithHTTPClient(supplying a custom*http.Client/Transport, e.g. for a proxy) withWithInsecureSkipVerify, this silently discards that custom transport's settings and mutates the caller-suppliedhttp.Clientobject'sTransportfield in place — a surprising side effect for API consumers who don't expect option ordering to matter this much.- Both
tls.Config{InsecureSkipVerify: true}literals omitMinVersion. Even given the documented in-band-token rationale for skipping cert verification, pinningMinVersion: 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 valueClusterRole grant is not namespace-scoped.
resourceNames: ["cozystack"]restricts by name but this is aClusterRole, so the grant applies to anyconfigmaps/cozystackin any namespace, not onlycozy-system/cozystack(the only onerest_siterouter.goreads). 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
⛔ Files ignored due to path filters (1)
packages/apps/site-router/logos/site-router.svgis excluded by!**/*.svg
📒 Files selected for processing (97)
.github/workflows/pull-requests.yamlMakefileapi/apps/v1alpha1/siterouter/types.goapi/apps/v1alpha1/siterouter/zz_generated.deepcopy.gocmd/site-router-controller/main.gohack/build-matrix.shhack/build-matrix_test.batshack/e2e-chainsaw/site-router/_probe-lib.shhack/e2e-chainsaw/site-router/backend.yamlhack/e2e-chainsaw/site-router/chainsaw-test.yamlhack/e2e-chainsaw/site-router/fresh-route-pod.yamlhack/e2e-chainsaw/site-router/pending-route-pod.yamlhack/e2e-chainsaw/site-router/remote-site-b.yamlhack/e2e-chainsaw/site-router/site-router-a.yamlhack/select-e2e.shhack/select-e2e_test.batsinternal/controller/siterouter/cnimediation.gointernal/controller/siterouter/cnimediation_test.gointernal/controller/siterouter/metrics.gointernal/controller/siterouter/metrics_test.gointernal/controller/siterouter/reconciler.gointernal/controller/siterouter/reconciler_test.gointernal/controller/siterouter/status.gointernal/controller/siterouter/status_test.gointernal/controller/siterouter/vyospush.gointernal/controller/siterouter/vyospush_test.gointernal/siterouter/denyset/denyset.gointernal/siterouter/denyset/denyset_test.gointernal/vyos/client.gointernal/vyos/client_test.gointernal/vyos/observation.gointernal/vyos/parse.gointernal/vyos/parse_test.gointernal/vyos/render/render.gointernal/vyos/render/render_netnew_test.gointernal/vyos/render/render_security_test.gointernal/vyos/render/render_test.gopackages/apps/site-router/.helmignorepackages/apps/site-router/Chart.yamlpackages/apps/site-router/Makefilepackages/apps/site-router/README.mdpackages/apps/site-router/charts/cozy-libpackages/apps/site-router/docs/PR-BODY.mdpackages/apps/site-router/docs/followups.mdpackages/apps/site-router/docs/image-lifecycle.mdpackages/apps/site-router/docs/security-model.mdpackages/apps/site-router/templates/_helpers.tplpackages/apps/site-router/templates/dashboard-resourcemap.yamlpackages/apps/site-router/templates/dv.yamlpackages/apps/site-router/templates/networkpolicy.yamlpackages/apps/site-router/templates/secret-cloudinit.yamlpackages/apps/site-router/templates/secret-psk.yamlpackages/apps/site-router/templates/service.yamlpackages/apps/site-router/templates/vm.yamlpackages/apps/site-router/templates/workloadmonitor.yamlpackages/apps/site-router/tests/dv_test.yamlpackages/apps/site-router/tests/networkpolicy_test.yamlpackages/apps/site-router/tests/policy_workloadmonitor_todo_test.yamlpackages/apps/site-router/tests/secret_cloudinit_test.yamlpackages/apps/site-router/tests/secret_psk_test.yamlpackages/apps/site-router/tests/service_test.yamlpackages/apps/site-router/tests/values_matrix_test.yamlpackages/apps/site-router/tests/vm_test.yamlpackages/apps/site-router/tests/workloadmonitor_test.yamlpackages/apps/site-router/values.schema.jsonpackages/apps/site-router/values.yamlpackages/core/platform/sources/site-router-application.yamlpackages/core/platform/sources/site-router-controller.yamlpackages/core/platform/templates/bundles/naas.yamlpackages/core/platform/tests/bundles_site_router_naas_test.yamlpackages/system/cozystack-api/templates/rbac.yamlpackages/system/site-router-controller/Chart.yamlpackages/system/site-router-controller/Makefilepackages/system/site-router-controller/images/site-router-controller/Dockerfilepackages/system/site-router-controller/templates/deployment.yamlpackages/system/site-router-controller/templates/podscrape.yamlpackages/system/site-router-controller/templates/rbac-bind.yamlpackages/system/site-router-controller/templates/rbac.yamlpackages/system/site-router-controller/templates/sa.yamlpackages/system/site-router-controller/values.yamlpackages/system/site-router-rd/Chart.yamlpackages/system/site-router-rd/Makefilepackages/system/site-router-rd/cozyrds/site-router.yamlpackages/system/site-router-rd/templates/cozyrd.yamlpackages/system/site-router-rd/values.yamlpackages/system/vm-default-images/images/vyos-router-disk.tagpackages/system/vm-default-images/templates/dv.yamlpackages/system/vm-default-images/values.yamlpackages/system/vyos-router-image/Chart.yamlpackages/system/vyos-router-image/Makefilepackages/system/vyos-router-image/flavors/vyos-router.tomlpackages/system/vyos-router-image/hack/build-qcow2.shpackages/system/vyos-router-image/images/vyos-router-disk/Dockerfilepkg/apis/apps/validation/validation.gopkg/registry/apps/application/rest.gopkg/registry/apps/application/rest_siterouter.gopkg/registry/apps/application/rest_siterouter_admission_test.go
956b527 to
4d98441
Compare
IvanHunters
left a comment
There was a problem hiding this comment.
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:42setsDEFERRED_SUITES="site-router"andstrip_deferredremoves it from all CI paths, so it never runs. The security guards (CiliumegressDeny, 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.includeand thedashboard-resourcemap.yamlRole; only the PSK (tenant's own key) and the tunnel Service are exposed touse.
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-editonpackages/apps/site-router/charts/cozy-libis a false positive: it is a symlink (git mode120000), same asapps/vpn/apps/kafka. Not a vendored edit.- The 3
chart_lint.render_errorsare harness artifacts (site-router renders cleanly undertenant-*; platform OCIRepository is alookupdependency; the cozystack-api_clusternil is a runtimecozystack-valuesinjection). - The deny-set (
denyset.go:84-89) intentionally leavesNodeCIDRs/LBPoolsempty, 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, cozyrdsresourceNamesmatch the Role, no reconcile amplifier,go build/vet/testgreen, 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.
|
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 The default load path could not work whether or not the image was published. 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 On the placeholder digest — a real one is a merge prerequisite, and that is now mechanical rather than remembered: 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 Your "legible status on the missing-source path" is addressed, but not the way I first tried. I added a controller-side On item 8 — the status column did read 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: Your recommended dev-cluster run is still outstanding: |
10cb5f3 to
c2abcf1
Compare
IvanHunters
left a comment
There was a problem hiding this comment.
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), newSiteRouterkind, newcozy-site-routernamespace. 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 onbundles.iaas.enabled(avoids the isp-hosted #3376 deadlock), controllerdependsOnvictoria-metrics-operator for its unconditional VMPodScrape, cozystack-api ConfigMap grant name-scoped.go build+ all Go unit tests + all 63 helm-unittests green;make generatenot 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.shDEFERRED_SUITES="site-router"), so the guards' runtime efficacy is unproven in CI. - Management-API isolation:
managementCIDRdefaults 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 + AppDefsecrets.include— verified), but a tenantadmincan read its own gateway's token and reconfigure the router (within the tenant's own blast radius).
Recommended follow-ups
cozystack-pr-testnegative-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.
|
IvanHunters Addressed the actionable code and documentation gaps in
The tenant-admin RBAC omission for Validation passed with the full Go suite, the standalone application API module, all affected Helm unit suites, package generation, and |
IvanHunters
left a comment
There was a problem hiding this comment.
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, whichmake unit-testsruns in CI. Reproduced:grep -rIlE '@sha256:0{64}' packages/ --exclude-dir=chartsreturns exactly this file, so the gateexit 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
DataVolumeresolves nowhere → boot PVC staysPendingforever and the VM never boots.dv.yaml'srequiredguard 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 theSiteRouterkind 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 asPodCIDR(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 ofsource ∈ remoteCIDR AND destination ∈ podCIDR. The IPsec local selector is0.0.0.0/0and no NAT is rendered, so the decrypted packet keeps the remote peer's real source IP.docs/security-model.md:27states 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 baselineallow-external-communication(packages/apps/tenant/templates/networkpolicy.yaml) unconditionally admitsfromEntities: [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),/0rejection, unconditional link-local/loopback, and bidirectionalOverlapsare 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 indashboard-resourcemap.yaml, and the tenant ClusterRoles grant no blanketget secrets; the guest login is locked (encrypted-password "*", noservice ssh). - Controller RBAC is least-privilege (
secrets: getonly, 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-electis hardcoded, so the in-memory hash cache is safe.
869bafb to
8fbe389
Compare
|
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
Fixed by parking the suite as Three real unit test failures behind your item 5:
Security surface is byte-identical to pre-rebase, checked file by file: Both your blockers stay open. Placeholder digest is deliberate, Please take another look at the delta. |
b17e76d to
ce01379
Compare
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
b66a73f to
c535238
Compare
…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
…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
left a comment
There was a problem hiding this comment.
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
loadBalancerClasschange, 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 |
There was a problem hiding this comment.
[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") |
There was a problem hiding this comment.
[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: |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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])} |
There was a problem hiding this comment.
[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.
| // 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 { |
There was a problem hiding this comment.
[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:166says "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:326calls an instance without a seeded certificate "still usable"; the push errors.client.go:141says 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]>
Correction to my last review: I over-graded itMy 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:
Everything else in that review is non-blocking. The rule-count bound is a strong should-fix. The rest (the 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 Separately: |
…#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
|
IvanHunters thanks for recalibrating, both blockers are fixed: ConfigMap read error in e7c1664 and 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 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
left a comment
There was a problem hiding this comment.
LGTM.
Both blockers from my last review are closed, each checked by mutation rather than by reading:
e7c1664c9—resolveManagementCIDRnow returns an error andresolveInputspropagates it, so a failed ConfigMap read skips the push and the guest keeps the ACL it has. Restoring the default substitution reddensTestResolveManagementCIDR/a_ConfigMap_that_cannot_be_read_fails_instead_of_falling_back.5deb9cfe8— theloadBalancerClassguard is ported fromextra/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.
Timofei Larkin (lllamnyp)
left a comment
There was a problem hiding this comment.
Blocking this pending a more detailed review. VyOS' license is not compatible with Cozystack.
|
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.
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:
Whichever option is chosen, 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. |
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 newsite-router-controllermediates the pieces the chart cannot express: deny-set validation of the tunnel's remote networks, the kube-ovn return-route annotation, gateway-portport_securityrelaxation gated on the guest source filter, the live VyOS configuration push over the management API, tunnel/BGP observability, and status. No new tenant CRD, theapps.cozystack.io/SiteRouterapp 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):port_securityassert-inbound-source-preservedused 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 Testsred. B has its own cleanup now, teardown on that run took 1.5 minutes.What's included
packages/apps/site-router- gatewayVirtualMachine(512/4096 blockSize for DRBD), boot DataVolume, tunnelService type: LoadBalancer(UDP 500/4500, nativeloadBalancerClass), PSK + RBAC-isolated api-key Secrets, first-boot cloud-init, WorkloadMonitor, and two net-new Cilium policies (gatewayegressDeny+ 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.internal/vyos(client/parse/observation) + routed renderinternal/vyos/render(interfaces, management firewall, IPsec forced-UDP, static routes, BGP, MSS clamp, tunnel-ingress source filter, forward default-deny, Boundary-A drop).internal/siterouter/denysetvalidator + a SiteRouter-scoped admission check (pkg/registry/apps/application) that reject a cluster-overlappingremoteCIDRidentically at apply time and reconcile time.README.md(prose + generated params),docs/security-model.md,docs/image-lifecycle.md,docs/followups.md.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).Service type: LoadBalancer(UDP) with clusterloadBalancerClassport_securityrelax, source filter, status, delete-restoreegressDeny(169.254 + mgmt), forward default-deny, two-boundary API isolation, api-key not tenant-readableItem 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 theipsec match-ipsec-in/match-none-inmatchers) 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 VyOSruns on this PR, publishes the appliance and stamps the realref@digestintopackages/apps/site-router/images/vyos-router-disk.tagthrough a patch fragment that Finalize merges before the E2E job, so the committedv0.0.0placeholder is never what the sandbox pulls, and_probe-lib.shis 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.shclassifies a changed line as reference-bearing only when it carries@sha256:or a YAML key, and a bareimages/*.tagline 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.tagfiles 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-routerthe first suite downstream ofcozystack.cozystack-basics, which collapsed that package from 22 suites tosite-routeralone and turnedhack/select-e2e_test.batsred on the guard that pins exactly that. #3817 excludes basics from the reverse-dependency walk as an install-ordering hub, the waycozystack.cozystack-enginealready 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-roundtripfailed on two of three runs here: this branch keeps controller finalizers acrossUpdate, which removed a helm-controller requeue that was hiding a bug on main,Updatedroppedspec.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:vyos-build, publish like the Talos image, then pin URL +@sha256).port_security(pending upstream kube-ovn CIDR-AAP support).site-gateway(NAT) / Phase 3 WireGuard backend / Phase 4 HA + per-tenant egress IP + initiator model.Repo hygiene (pre-merge)
site-router.API Review Gateis red by design, PR adds an API group so it needs an approving review from one of the API owners.Summary by CodeRabbit
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:
loadBalancerClasschange with the command that recreates it, the guardextra/ingresshas for the same valueRest 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/*.tagline, 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.batsrenders 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_securityannotation, the deny-set exemption list that named one of the two unjudged fields, theInsecureSkipVerifyline 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.egressDenyCIDRsdescribed itself as platform-owned from inside the tenant schema, so it moves to_egressDenyCIDRswhere the aggregated API's rejection of top-level_keys is the enforcement.remoteCIDRsis 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 ownstatus.activePodsandstatus.nodeNamerather 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
useRBAC reaches inside it. Worse than reported: stock VyOS ships a "Password reset" boot entry runningstandalone_root_pw_reset, so no GRUB editing was needed. The appliance now sets GRUBsuperuserswith a build-time password that is discarded, and marks the normal-boot entry--unrestrictedin 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 atEnter username; with it the machine boots through whilee,cand 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:DefaultVyOSClientFactoryreturns 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[].destinationandbgp.neighbors[].addressgo 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.specis immutable in CDI, so the chart re-emits an installed disk's spec instead of re-rendering the digest, otherwise every release brokehelm upgradefor every installed instance. A malformed cluster CIDR fails the deny set closed instead of vanishing from it. Aspec.valuesthat does not decode is rejected instead of silently meaning "no remoteCIDRs".Surface.
managementCIDRandallowOpenManagementwere 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-widepods patchgrant 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-configafter 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-vyosgained 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.