Skip to content

design-proposal: tenant-managed site-to-site connectivity via gateway VMs (site-router + site-gateway)#30

Open
myasnikovdaniil wants to merge 5 commits into
mainfrom
design-proposal/tenant-site-gateway
Open

design-proposal: tenant-managed site-to-site connectivity via gateway VMs (site-router + site-gateway)#30
myasnikovdaniil wants to merge 5 commits into
mainfrom
design-proposal/tenant-site-gateway

Conversation

@myasnikovdaniil

@myasnikovdaniil myasnikovdaniil commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a design proposal for tenant-managed external network connectivity:
terminating a site-to-site VPN (IPsec or WireGuard) inside a KubeVirt VM that
NAT-bridges an external site to the tenant's managed apps, in both directions,
for any L4 protocol — without granting tenants privileged host-cluster pods.

The proposal covers:

The design was prototyped and validated end-to-end (two gateway VMs + a real
managed Postgres); results are included, notably the finding that native ESP is
dropped by the Cilium/kube-ovn overlay (forced UDP encapsulation required for
IPsec; WireGuard sidesteps it).

Test plan

Design proposal; no code. Implementation testing (helm-unittest across backends,
e2e of the two-VM topology asserting inbound/outbound + SNAT-required + MSS
behavior) is scoped in the proposal and will follow in implementation PRs.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added a design proposal for tenant-managed site-to-site connectivity using gateway VMs.
    • Documents routed and NAT connectivity modes, including reachability and source IP behavior.
    • Details the configuration model, security requirements, failure/edge-case handling, rollout phases, and rollback behavior.
    • Includes validation status for routed (production) and NAT (dev), HA approach, open questions, and alternatives evaluated.

Adds a design proposal for tenant-managed site-to-site connectivity
(IPsec or WireGuard) terminated in a KubeVirt VM that NAT-bridges an
external site to the tenant's managed-app ClusterIPs, both directions,
without granting tenants privileged host-cluster pods. Includes the
end-to-end prototype validation results and the relationship to the
ClusterMesh/Kilo proposal (#7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 77788c47-2d8d-4778-9743-88280430fbca

📥 Commits

Reviewing files that changed from the base of the PR and between 48ebb1a and 8bad3a6.

📒 Files selected for processing (1)
  • design-proposals/tenant-site-connectivity/README.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • design-proposals/tenant-site-connectivity/README.md

📝 Walkthrough

Walkthrough

Changes

Tenant Site Connectivity Proposal

Layer / File(s) Summary
Scope and gateway architecture
design-proposals/tenant-site-connectivity/README.md
Defines the proposal scope, shared KubeVirt gateway foundation, tunnel exposure, lifecycle model, and routed versus NAT connectivity paths.
Tenant configuration and security
design-proposals/tenant-site-connectivity/README.md
Documents catalog schemas, single-peer semantics, additive lifecycle behavior, management API enforcement, and network security guardrails.
Validation and rollout operations
design-proposals/tenant-site-connectivity/README.md
Records routed and NAT validation results, overlay constraints, failure cases, deferred HA, rollout phases, and acceptance criteria.
Open questions and alternatives
design-proposals/tenant-site-connectivity/README.md
Lists unresolved design questions and compares rejected approaches with ClusterMesh and Kilo.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested reviewers: kvaps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the proposal’s main change: tenant-managed site-to-site connectivity via gateway VMs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch design-proposal/tenant-site-gateway

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

❤️ Share

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a design proposal for tenant-managed external network connectivity via a gateway VM (IPsec / WireGuard) in Cozystack, which terminates VPN tunnels inside a KubeVirt VM to isolate privileged networking. The review feedback highlights several key technical refinements for the proposal: ensuring the guest OS is configured to own virtual service-exposure addresses for inbound traffic, addressing potential port collisions for outbound traffic to multiple remote targets by mapping them to unique targetPorts, providing concrete KubeVirt API examples for the block size override, and noting that standard VRRP for high availability may be blocked by CNI-level IP/MAC spoofing prevention, requiring alternative failover mechanisms.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.


### Inbound path (external site → any tenant app)

The remote peer targets a virtual "service-exposure" address that the gateway owns and maps to an app `ClusterIP`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

For the inbound path, the remote peer targets a virtual "service-exposure" address. To ensure the gateway VM can receive and DNAT this traffic, the guest OS needs to configure these virtual IPs (e.g., as alias IPs or on a dummy interface) so that the guest kernel accepts packets destined for them.

Suggested change
The remote peer targets a virtual "service-exposure" address that the gateway owns and maps to an app `ClusterIP`.
The remote peer targets a virtual "service-exposure" address that the gateway VM owns (configured as an alias or dummy interface IP inside the guest OS via cloud-init) and maps to an app ClusterIP.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This isn't needed for the validated design: the tunnel is route-based (VTI), so the decrypted packet arrives on the tunnel interface and is DNAT'd in PREROUTING — which runs before the kernel's local-delivery check — so the gateway need not own the virtual service-exposure address on an interface. The dev-cluster prototype confirmed this: nat destination rule inbound-interface vti0 destination <vip> → <ClusterIP> worked with no alias/dummy IP assigned. Keeping the text as-is to avoid documenting a requirement that doesn't apply to the route-based path.

Comment on lines +93 to +95
1. The app connects to a local Service name the chart creates (e.g. `remote-db.<tenant>.svc:port`) — the only app-side change is the target hostname.
2. The node routes the `ClusterIP` to the gateway VM.
3. **DNAT**: the local listener → the real remote `IP:port` behind the tunnel; the VM encrypts and sends it over the tunnel; SNAT into the tunnel's inner subnet lets replies return.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When using a local ClusterIP Service to route outbound traffic to the gateway VM, the CNI/kube-proxy on the client node will perform DNAT, translating the Service's ClusterIP to the gateway VM's pod IP before the packet reaches the VM.\n\nIf a tenant has multiple remote targets that use the same destination port (e.g., two different remote databases on port 5432), the gateway VM will receive packets for both targets with the same destination IP (the VM's pod IP) and destination port (5432). It will have no way to distinguish which remote target was originally intended.\n\nTo resolve this, the design should explicitly specify that each remote target must map to a unique targetPort (local listener port) on the gateway VM. The local Services can still expose the standard port (e.g., 5432), but they must map to these unique targetPorts on the gateway VM (e.g., 5432 and 5433), which the gateway VM then DNATs to the respective remote IP:port.

Suggested change
1. The app connects to a local Service name the chart creates (e.g. `remote-db.<tenant>.svc:port`) — the only app-side change is the target hostname.
2. The node routes the `ClusterIP` to the gateway VM.
3. **DNAT**: the local listener → the real remote `IP:port` behind the tunnel; the VM encrypts and sends it over the tunnel; SNAT into the tunnel's inner subnet lets replies return.
1. The app connects to a local Service name the chart creates (e.g. remote-db.<tenant>.svc:port) — the only app-side change is the target hostname.\n2. The node translates the ClusterIP to the gateway VM's pod IP and a unique targetPort (local listener port) allocated on the VM. This unique port is required so the VM can distinguish between multiple remote targets that use the same destination port.\n3. **DNAT**: the unique local listener port on the VM -> the real remote IP:port behind the tunnel; the VM encrypts and sends it over the tunnel; SNAT into the tunnel's inner subnet lets replies return.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — addressed in 72db47e. The outbound path now states each remote target maps to a per-target-unique listener port on the gateway (the local Service still exposes the standard port, e.g. 5432), so the VM can disambiguate multiple targets sharing a destination port. The values schema's remoteTargets[].localPort already carries this.

- **Native ESP dropped by the CNI overlay (IPsec).** On Cilium/kube-ovn, native ESP (IP proto 50) does not traverse the overlay even pod-to-pod; ESP-in-UDP (forced UDP encapsulation) is required unconditionally (validated — see Testing). WireGuard, being UDP-native, is unaffected.
- **DNAT must target stable `ClusterIP`s**, never ephemeral pod IPs.
- **Source IP is lost inbound** (SNAT) — only relevant for apps with source-IP ACLs.
- **VM image must be bootable and 512-native.** DRBD-backed (4K-sector) StorageClasses cannot boot a 512-native GPT image; the disk must sit on a 512-native StorageClass or use a KubeVirt `blockSize` override. The image must ship a real bootloader (validated the hard way — see Testing).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To make the KubeVirt blockSize override actionable and clear for the implementer, it would be beneficial to include a brief example of the exact KubeVirt API fields used to configure it.

Suggested change
- **VM image must be bootable and 512-native.** DRBD-backed (4K-sector) StorageClasses cannot boot a 512-native GPT image; the disk must sit on a 512-native StorageClass or use a KubeVirt `blockSize` override. The image must ship a real bootloader (validated the hard way — see Testing).
- **VM image must be bootable and 512-native.** DRBD-backed (4K-sector) StorageClasses cannot boot a 512-native GPT image; the disk must sit on a 512-native StorageClass or use a KubeVirt blockSize override (e.g., setting blockSize.custom.logical to 512 and blockSize.custom.physical to 4096 in the disk spec). The image must ship a real bootloader (validated the hard way — see Testing).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 72db47e — added the concrete example (blockSize.custom.logical: 512 / physical: 4096 on the disk spec).


- **Secret handling.** Best shape for mounting PSK/cert/WireGuard-key material into the guest without persisting it in plaintext cloud-init at rest.
- **VIP allocation.** One LoadBalancer VIP per gateway vs. sharing; interaction with the tenant's LB address pool and quotas.
- **HA.** Is active/standby (e.g. VRRP + shared VIP) in scope for a later iteration? A single VM is a per-tenant SPOF.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In standard Kubernetes CNIs (including Cilium and kube-ovn), IP/MAC spoofing prevention is typically enabled by default. This means standard VRRP (keepalived) running inside guest VMs on the default pod network may fail because the CNI will block gratuitous ARPs or refuse to route the shared VIP to the standby VM.\n\nTo support HA, the platform might need to integrate with CNI-specific virtual IP features or use a Kubernetes Service/EndpointSlice-based failover mechanism instead of pure guest-level VRRP.

Suggested change
- **HA.** Is active/standby (e.g. VRRP + shared VIP) in scope for a later iteration? A single VM is a per-tenant SPOF.
- **HA.** Is active/standby in scope for a later iteration? Note that standard VRRP (e.g., keepalived with a shared VIP) inside guest VMs on the default pod network may fail due to CNI-level IP/MAC spoofing prevention (which blocks gratuitous ARPs and unauthorized IPs). The HA design will likely need to integrate with CNI-specific virtual IP features or use a Kubernetes Service/EndpointSlice-based failover mechanism.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed that guest-level VRRP with a shared VIP is problematic — the new High Availability section covers it (72db47e). One correction from validation: the blocker isn't IP/MAC anti-spoofing (that's solvable with a scoped kube-ovn allowed-address-pair — validated: the VIP moves through OVN with port-security kept on). The real blocker is that VRRP advertisements (IP proto 112) are dropped pod-to-pod by Cilium's conntrack (non-TCP/UDP/ICMP/SCTP; see Cilium CFP #39601, closed not-planned), so keepalived can't elect over the pod network. HA therefore uses Service/endpoint failover or a side-channel for the election, as documented.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@design-proposals/tenant-site-gateway/README.md`:
- Around line 157-158: The tenant-secret flow is still underspecified in the
README: define the concrete delivery path for PSKs/certs/WireGuard keys into the
guest without putting them in the VM spec or cloud-init, and document the
post-reconciliation read-access model clearly. Update the tenant-supplied
secrets section to name the component or mechanism that performs delivery and
the expected access boundaries so the security story is explicit.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: f6ce1e74-edfe-4394-9c0e-b857bcb66a81

📥 Commits

Reviewing files that changed from the base of the PR and between 4ec8567 and a29c15b.

📒 Files selected for processing (1)
  • design-proposals/tenant-site-gateway/README.md

Comment thread design-proposals/tenant-site-gateway/README.md Outdated
Adds a High availability section (KubeVirt live-migration for planned
maintenance; Service-fronted active/passive and kube-ovn allowed-address-pairs
shared-VIP for unplanned failure), with the shared-VIP mechanism validated on a
development cluster. Attributes the pod-to-pod drop of non-TCP/UDP/ICMP/SCTP IP
protocols (VRRP proto 112, ESP proto 50) to Cilium's conntrack rather than the
geneve tunnel, unifying it with the native-ESP finding.

Also addresses review feedback: per-target-unique outbound listener ports, a
concrete blockSize example, a note that SNAT keeps the gateway anti-spoofing
clean, and a stronger secret-handling open question.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>

@kvaps Andrei Kvapil (kvaps) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks — the e2e validation here (the ESP-in-UDP finding, the SNAT negative test, the AAP scoping proof) is exactly the groundwork we needed. We discussed the direction internally; three structural changes before this is ready, plus nits.

1. Routed (no-NAT) mode is the primary mechanism and belongs in Phase 1.

Tenants already run this pattern today (virtual routers: the ovn.kubernetes.io/routes namespace annotation + relaxed port security), so shipping NAT-first would regress their UX. Please restructure around two modes:

  • routed (default): the routes annotation (kubeovn-webhook already inherits it onto pods at creation) provides the return path, so SNAT is unnecessary and the client source IP is preserved. The "missing SNAT → black-hole" finding holds only for NAT mode — scope it accordingly.
  • NAT (fallback): keep the current design as an enumerated-targets mode for when routing is impossible (remote CIDR overlaps podCIDR/serviceCIDR/node networks) or the remote side only accepts narrow traffic selectors. State the limits honestly: per-host:port entries, no whole-subnet reachability, no ICMP. NETMAP-style 1:1 subnet mapping → future work.

Anti-spoofing for routed mode: disabling port_security on the gateway VM port is acceptable for v1. Containment comes from Cilium's sender-side egress enforcement — the endpoint identity is compiled into the per-endpoint eBPF program on the node-side veth, so the policy verdict doesn't depend on the packet's source IP; a spoofed source cannot widen the reachable set beyond the tenant tree + world. Document the residual risks in Security: identity spoofing within the tenant's own namespace (receiver-side identity is resolved from src IP via ipcache), and spoofed-source egress where SNAT is absent. Target state (follow-up, not v1): scoped port_security — keep it enabled and add the declared remote CIDRs to the port's allowed addresses; your AAP validation is the right mechanism, it only lacks CIDR support in kube-ovn (OVN itself accepts MAC IP/mask).

Also state explicitly: routed mode is transparent L3 for TCP/UDP/ICMP/SCTP only — per your own conntrack finding, ESP/GRE/OSPF/VRRP still won't cross the pod fabric.

2. Day-2 reconfiguration needs a section, with the mechanism agreed now.

Direction we settled on: VyOS HTTP API driven by a platform controller — render the target config from values and push it. VyOS commits are transactional, so unrelated changes don't touch the tunnel and key rotation only re-establishes the SA. The API key is delivered once at first boot via cloud-init; the API must be reachable by the platform only (define that boundary in Security). Implementation may land as a separate phase — v1 may temporarily fall back to "config change = VM reboot" if called out explicitly.

3. Add a "low-level entities & controller" section.

Tenants can't (and shouldn't) annotate their namespaces, so a platform controller mediates:

  • user input stays in the catalog app values — no new CRD (the aggregated API server just mirrors HelmReleases); validation = values.schema plus controller-side checks at reconcile, surfaced as status conditions;
  • the controller validates declared remote CIDRs (disjoint from podCIDR/serviceCIDR/join/node networks; overlap between tenants is fine — routes are namespace-scoped), sets the namespace routes annotation, relaxes/scopes port security on the gateway VM port, and cleans everything up on app deletion;
  • routes apply only to newly created pods (the webhook mutates at Create) — no auto-restart of tenant workloads; the controller reports pods whose annotation lags the namespace ("pods pending route") and the user rolls them;
  • keep the CNI-touching operations behind a small backend interface so a future CNI can implement them natively.

Nits:

  • Inbound exposure model is inconsistent: the text uses virtual IPs (10.200.0.10:5432), the values sketch uses {name, listenPort, targetService, port} — pick one. In routed mode the remote side hits ClusterIPs directly, so virtual exposure addresses are a NAT-mode-only concept.
  • HA: agreed with your recommendation (single VM + live-migration in v1). For the follow-up, prefer the Service-fronted active/passive path — CNI-agnostic, which matters since we don't want to deepen kube-ovn coupling.
  • Sync with the portal-side implementation before building the image — it uses the same VyOS + cloud-init shape; image and config format should be shared.
  • The tunnel LoadBalancer should align with the structured external exposure redesign (#29) rather than adding another ad-hoc LB path.
  • Scope: list per-tenant egress IP as explicitly deferred future work — the gateway VM is its natural home later.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — this is the right shape and most of it I'm taking as-is. Agreed on all three structural changes (two explicit modes, a day-2 section, a low-level-entities/controller section) and the nits. One adjustment to the mode ordering, reasoning below; everything else lands as you described.

1. Modes: agreed — with NAT as the default and routed as a cluster-gated opt-in

I'll restructure around the two modes you describe, and I'm keeping your framing of routed almost verbatim: routes annotation (inherited onto pods at Create by the kubeovn webhook) provides the return path, so no SNAT and the client source IP is preserved; the "missing SNAT → black-hole" finding is NAT-mode-only; whole-subnet reachability, ICMP, and no per-target enumeration are real routed advantages; port_security-off on the gateway port is acceptable for v1 with containment from Cilium's sender-side egress enforcement; scoped port_security (declared remote CIDRs added to the port's allowed addresses) is the target state, with AAP as the right mechanism once kube-ovn grows CIDR support. And routed is transparent L3 for TCP/UDP/ICMP/SCTP only — ESP/GRE/OSPF/VRRP still won't cross the fabric (same conntrack finding).

The one change I'd propose to your ordering: NAT is the default; routed is opt-in and gated by a cluster-level switch so a platform admin can disallow the port-security-relaxing, kube-ovn-coupled mode entirely. Reasoning:

  1. CNI-agnosticism — consistent with your own HA preference. In the HA nit you prefer the Service-fronted path precisely because it's CNI-agnostic and you don't want to deepen kube-ovn coupling. Routed-as-default deepens exactly that coupling (routes annotation + webhook inheritance) on the common path. NAT is the only CNI-portable mode, so it's the safer default; routed stays fully available where the admin opts in.
  2. Security-by-default. Routed relaxes port_security and leaves the residual risks you named (intra-namespace identity spoofing via the ipcache src-IP resolution; spoofed-source egress where SNAT is absent). Defaulting to the contained mode is the conservative platform choice; a tenant who needs source-IP transparency opts into routed knowingly.
  3. Zero-config correctness. Routed silently breaks when the remote CIDR overlaps pod/service/join/node networks; NAT works regardless of the remote's addressing. A default that can mis-route on overlap is a worse default.

Two supporting points, both of which you already raised:

  • Routed's "routes apply only to newly created pods" is a day-2 footgun — existing tenant workloads have no reachability until they're rolled. NAT has no equivalent wart.
  • Routed necessarily pulls in the full mediating controller from §3 (CIDR validation, namespace annotation, port_security scoping, pods-pending-route reporting). NAT needs only the thin day-2 driver. Gating routed at the cluster level keeps that heavier machinery opt-in per cluster.

Net: your two-mode structure stands unchanged — I'm only flipping which is the default and adding a cluster gate for routed. I'll be honest in the doc that NAT's cost is losing the original source IP (breaks source-IP ACLs / pg_hba-style authorization on the remote side), which is the concrete reason routed exists as a mode at all.

One NAT boundary worth stating up front, since it's a whole app class rather than a nit — protocols that advertise their own topology. Kafka (advertised.listeners), MongoDB replica-set/sharded (hello returns the members), Redis Cluster (CLUSTER SLOTS/MOVED), Cassandra/Scylla (system.peers), Elasticsearch with sniffing: the client bootstraps to one endpoint and is then handed the members' own addresses to connect to directly, so NATing just the bootstrap port isn't enough — the advertised addresses have to be reachable through the tunnel too. Two consequences: (a) it's a second concrete reason routed mode exists — in routed mode the members' real addresses are reachable, so discovery just works, provided each member advertises a routable ClusterIP rather than an internal DNS name; (b) in NAT mode this class needs per-member advertised addresses, which is exactly what the managed-app operators already do for public exposure (MongoDB via the operator's expose, Kafka via per-broker listeners). Single-endpoint protocols (Postgres, MySQL, plain Redis, AMQP, ClickHouse client, HTTP/gRPC) are transparent under NAT and need none of this. I'll classify the catalog this way in the doc and state the app-agnostic goal precisely: transparent for single-endpoint apps; topology-advertising apps reuse the operator's existing advertised-address handling.

2. Day-2 reconfiguration: agreed — VyOS HTTP API driven by the platform controller

Adding a Day-2 section built on the mechanism you settled on: the platform controller renders target config from values and pushes it over the VyOS HTTPS API; the API key is delivered once at first boot via cloud-init; the API is reachable by the platform only (boundary stated in Security). v1 may fall back to "config change = VM restart" if called out explicitly. A few points I want to bake in from validation/research so the section is precise:

  • cloud-init is a first-boot surface only. KubeVirt bakes cloud-init into a static ephemeral disk regenerated only on VM restart (and re-runs per-instance modules only if the meta-data instance-id also changes). KubeVirt exposes no generic guest-exec, and accessCredentials/qemu-guest-agent covers SSH keys and user passwords only — so live day-2 config genuinely has to go through the guest's own API. Confirms the API-driver direction.
  • VyOS /configure is atomic and surgical (batched set/delete + single commit, with a commit-confirm rollback net), so adding a peer or rotating a key doesn't touch unrelated config.
  • One caveat to design around: a commit that touches IPsec reloads the whole IPsec subsystem — existing SAs bounce. WireGuard isn't in that class (hitless peer-add — to confirm on the target build). Combined with the earlier finding that native ESP is dropped pod-to-pod (forced UDP-encap required for IPsec), this is a second, independent reason to prefer WireGuard as the default backend: it's cleaner both on the data plane and on day-2.
  • (Alternative noted for completeness: a virtiofs-shared ConfigMap propagates changes to a running guest live — EnableVirtioFsConfigVolumes, GA in v1.5 — but it needs an in-guest watcher since VyOS doesn't natively reconcile a file. The HTTPS API is lower in-guest complexity, so it stays the primary mechanism.)

3. Low-level entities & controller: agreed

Adding the section with the shape you described: input stays in the catalog-app values (no new CRD — the aggregated API server just mirrors HelmReleases); validation = values.schema plus controller-side reconcile checks surfaced as status conditions; the controller validates declared remote CIDRs (disjoint from pod/service/join/node; cross-tenant overlap is fine since routes are namespace-scoped), sets the namespace routes annotation, scopes/relaxes port_security on the gateway VM port, and cleans everything up on app deletion; routes apply only to newly created pods, so no auto-restart — the controller reports pods whose annotation lags the namespace ("pods pending route") and the user rolls them; CNI-touching operations sit behind a small backend interface so a future CNI can implement them natively.

One clarification that ties back to §1: the controller's CNI-mediation duties (routes annotation, port_security, CIDR validation, pods-pending-route) exist specifically to serve routed mode. In a NAT-only cluster the controller reduces to the thin day-2 driver (render values → VyOS API, manage the tunnel's external exposure). That's a further reason to gate routed at the cluster level — clusters that don't enable it never take on the CNI-mediation surface.

Nits

  • Inbound exposure model inconsistency. Agreed, unifying on the {name, listenPort, targetService, port} schema form as canonical; the 10.200.0.10:5432 virtual-IP notation was NAT-mode shorthand and I'll drop it. In routed mode the remote side hits ClusterIPs directly, so the virtual exposure address is a NAT-mode-only concept — stated as such.
  • HA. Agreed — single VM + live-migration in v1; Service-fronted active/passive for the follow-up (CNI-agnostic, and it reinforces the §1 default argument). Aligning the section. (Minor: the VRRP blocker is proto-112 dropped by Cilium conntrack rather than anti-spoofing — but that's moot once we go Service-fronted.)
  • Portal-side sync. Agreed — we'll share the gateway image and config format with the portal-side gateway effort; this PoC was built standalone only to validate the approach, not to fork the image.
  • Tunnel LoadBalancer / design-proposal: structured, additive external exposure for managed applications #29. Agreed — the tunnel's external endpoint will build on the structured-exposure primitives (ServiceExposure + ExposureClass) rather than a bespoke LoadBalancer path. Two notes: (a) the tunnel endpoint consumes the primitive, not the engine-specific expose: field (the gateway isn't a managed engine with named listeners), and it needs the exposure class to support UDP (IKE/NAT-T 4500, WireGuard); (b) this goes deeper than the tunnel's own LB — for the topology-advertising app class in §1, the clean model is the tunnel itself as an ExposureClass, so ServiceExposure allocates the tunnel-side address and the app operator's existing advertised-address logic points at it, instead of bespoke per-member DNAT in the gateway. That folds inbound exposure for that class into design-proposal: structured, additive external exposure for managed applications #29 rather than duplicating it.
  • Per-tenant egress IP. Agreed — listing it as explicitly deferred future work, with the gateway VM as its natural home later.

I'll push a revision restructuring the doc along these lines. Happy to keep discussing the default (§1) if you feel strongly the other way, but that's the reasoning.

@mattia-eleuteri

Copy link
Copy Markdown

Operator data point: we run the routed (no-NAT) mode in production today, and the proxied/NAT method would be a regression for us.

Our current setup is a tunnel-terminating VM used as a plain gateway: ovn.kubernetes.io/routes on the namespace with the VPN VM as next-hop, and no SNAT. Every resource behind the tunnel is reachable directly with its real IP preserved in both directions, whole-subnet reachability works, and ICMP works. This is exactly the routed mode Andrei Kvapil (@kvaps) describes.

On the "is the proxied (DNAT+SNAT) method suitable" question: for our use cases, no. Losing the original source IP and dropping to enumerated host:port entries (no whole-subnet, no ICMP) is a step back from what we run today. Source-IP visibility matters to us for remote-side firewall/ACLs and audit. We'd only reach for NAT as a fallback when the remote CIDR genuinely overlaps pod/service/node networks.

So we'd strongly favor routed being a first-class Phase-1 mode, not a fallback. On the NAT-default-vs-routed-default question, a cluster-level gate for routed is fine from our side, but routed shouldn't be relegated to "fallback only."

…+ NAT)

Split the tenant site-to-site connectivity proposal into two co-equal
catalog apps over a shared foundation — site-router (routed, source-IP
preserving, kube-ovn) and site-gateway (NAT, CNI-agnostic) — instead of a
single NAT gateway with routed as a fallback, and rename the proposal to
tenant-site-connectivity.

Adds day-2 configuration (VyOS HTTPS API driven by a controller), a
low-level-entities/controller section, and publishes the tunnel endpoint via
the structured external-exposure primitives (#29) instead of a bespoke
LoadBalancer. Tightens secret handling: the chart templates the Secret from
values (no tenant-created Secret), the WireGuard key is autogenerated
in-chart, and the IPsec PSK is the only genuinely tenant-supplied secret.
Reorders rollout to lead with routed + MSS clamping + tunnel observability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Pushed a restructure (4672a2d), and after further discussion the shape changed from my earlier "NAT-default + routed-opt-in" reply: routed is now a first-class mode, not a fallback, and instead of one app with a mode: toggle the proposal is two co-equal catalog apps over a shared foundation. Renamed to tenant-site-connectivity. Highlights against your review:

1. Modes → two co-equal apps, and routed leads Phase 1.

  • site-router = routed/L3: kube-ovn ovn.kubernetes.io/routes return path, source IP preserved, whole-subnet + ICMP; carries the CNI-mediation controller.
  • site-gateway = NAT: DNAT/SNAT to ClusterIPs, CNI-agnostic, fully contained.
  • Two apps rather than a mode toggle because the values schemas diverge (remoteCIDRs/BGP vs per-target ports), the controllers are asymmetric (routed needs privileged CNI mediation; NAT needs none), and an admin may want to gate routed independently.
  • Rollout now leads with site-router (routed) + IPsec + MSS clamping + tunnel observability; NAT, WireGuard and HA follow.
  • Added the transport caveat you flagged: routed is transparent L3 for TCP/UDP/ICMP/SCTP only — ESP/GRE/VRRP do not cross the fabric. Anti-spoofing is documented per app: site-gateway is SNAT-contained; site-router relaxes port_security (scoped as the target state), contained meanwhile by Cilium's sender-side egress enforcement.

2. Day-2 reconfiguration — new section. A platform controller drives the guest's VyOS HTTPS API (atomic set/delete batch; cloud-init is first-boot only; the API key is generated at runtime and seeded once; the API is reachable by the platform only).

3. Low-level entities & controller — new section. Input stays in the catalog-app values (no new CRD); validation = values schema + controller reconcile checks surfaced as status conditions. The controller sits behind a small backend interface so the neutral VyOS-driving core is shared; site-router adds the CNI mediation (validate remote CIDRs disjoint from pod/service/join/node, set the namespace routes annotation, scope port_security, report "pods pending route"), site-gateway adds none.

Nits:

  • Inbound exposure model unified on the schema form; the virtual-IP notation is dropped.
  • HA trimmed: single VM + live-migration for v1; Service-fronted active/passive as the CNI-agnostic follow-up.
  • Tunnel endpoint now rides the structured external-exposure primitives (design-proposal: structured, additive external exposure for managed applications #29 ServiceExposure / ExposureClass, UDP) instead of a bespoke LoadBalancer.
  • Per-tenant egress IP listed as explicitly deferred future work.
  • Secret handling tightened: no tenant-created Secret (the chart templates it from values, like apps/vpn / postgres); the WireGuard key is autogenerated in-chart (only the peer's public key is a value); the IPsec PSK is the single genuinely tenant-supplied secret.

PTAL — happy to iterate.

@myasnikovdaniil myasnikovdaniil changed the title design-proposal: tenant external network connectivity via a gateway VM (IPsec/WireGuard) design-proposal: tenant-managed site-to-site connectivity via gateway VMs (site-router + site-gateway) Jul 10, 2026
@mattia-eleuteri

Copy link
Copy Markdown

Thanks for the restructure. Splitting into two co-equal apps with routed as Phase 1 matches what we run in production, so a few operator notes.

Routed dataplane validation. The doc notes the e2e prototype so far exercised the NAT dataplane, while Phase 1 now leads with site-router (routed). Worth pointing out that routed mode formalizes the already-documented virtual-router pattern (ovn.kubernetes.io/routes on the namespace + ovn.kubernetes.io/port_security: "false" on the VM), which tenants already run today, so the mechanism itself is proven in practice even if the e2e round hasn't formally covered it yet.

Scoped port_security. +1 on making scoped port_security the target state rather than full relaxation. Note that the documented virtual-router pattern relaxes it entirely today, so scoping it would be a net improvement over current practice. As you note, the blocker is on the kube-ovn side (allowed-address-pairs lacking CIDR support), not OVN itself, which accepts MAC IP/mask.

CIDR disjointness. For the overlap-with-cluster-networks footgun, restricting the selectable remote CIDRs at the values/UI layer on top of the controller-side disjointness validation lets the two checks reinforce each other, with the controller validation as the authoritative one.

@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

so the mechanism itself is proven in practice even if the e2e round hasn't formally covered it yet

I agree, I will fold this clarification on next round of review, thanks

- Status Draft -> Review
- Declare #29 (with UDP ExposureClass) an explicit Phase 1 blocker, with a
  stopgap UDP LoadBalancer fallback if it slips
- Reframe routed (site-router) as validated in a production implementation;
  make scoped port_security a Phase 1 acceptance criterion, not a Phase 4
  relaxation
- Add an Image lifecycle subsection: VyOS image built in-repo (Talos-image
  pattern), CVE patching via re-pin+rebuild, update = new disk => VM restart
- Specify tenant-immutable enforcement for the guest management API (no
  tenant-namespace NetworkPolicy; API key in a platform-only Secret), to be
  re-verified against the production implementation
- Commit to singular peer: (one tunnel per instance); add a multi-peer open
  question instead of retrofitting peers:[] later
- Enrich the remote-CIDR deny-set (link-local/metadata, LB pool, loopback,
  default route) and surface rejection as a status condition
- Keep IPsec the default backend; WireGuard is a first-class alternative in
  Phase 3, never promoted to default

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Updated design with few improvements

@kvaps

Copy link
Copy Markdown
Member

This is a strong revision — all three structural points landed in the doc itself, and several additions go beyond the review (the remote-CIDR deny-set, the platform-only management-API enforcement via a cluster-scoped policy, the topology-advertising app class, #29 as an explicit UDP-gated Phase-1 blocker). Two adjustments before I approve:

1. Ship the two apps in sequence, not both in one design pass. The two-app split over a shared foundation is the right call — keep it. But land only site-router (routed) in Phase 1: it's the production-proven, primary-requested mode. Treat site-gateway (NAT) as a Phase-2 re-open — we'll revisit its design once Phase 1 is implemented and we've collected real operational pain, rather than freezing its schema and controller now. The doc already orders it this way; just frame Phase 2 as a deliberate revisit point, not a committed plan.

2. Don't make scoped port_security a Phase-1 acceptance criterion — it isn't achievable on our kube-ovn. I checked kube-ovn v1.15.10: allowed-address-pairs only accept single host IPs. A Vip carries a single v4ip/v6ip with no CIDR field, getVirtualIPs gates entries through IsValidIP (which rejects any ip/mask), and SetLogicalSwitchPortSecurity joins bare IPs with no mask. OVN itself would accept MAC IP/mask, but kube-ovn never emits a mask — so scoped port_security would require either an upstream kube-ovn patch or writing to the OVN NB directly, neither of which belongs in Phase 1. Revert to your original scoping: full port_security relaxation is acceptable for v1 (containment is Cilium's sender-side egress enforcement), and scoped port_security is the immediate follow-up once kube-ovn AAP grows CIDR support. Keep the residual risks documented in Security as they are.

Two nits still only in the thread, not the doc — fold both in on the next pass: the portal-side image/cloud-init sync, and NETMAP-style 1:1 subnet mapping as NAT future work.

Otherwise this is ready — thanks for the thorough turnaround.

1 similar comment
@kvaps

Copy link
Copy Markdown
Member

This is a strong revision — all three structural points landed in the doc itself, and several additions go beyond the review (the remote-CIDR deny-set, the platform-only management-API enforcement via a cluster-scoped policy, the topology-advertising app class, #29 as an explicit UDP-gated Phase-1 blocker). Two adjustments before I approve:

1. Ship the two apps in sequence, not both in one design pass. The two-app split over a shared foundation is the right call — keep it. But land only site-router (routed) in Phase 1: it's the production-proven, primary-requested mode. Treat site-gateway (NAT) as a Phase-2 re-open — we'll revisit its design once Phase 1 is implemented and we've collected real operational pain, rather than freezing its schema and controller now. The doc already orders it this way; just frame Phase 2 as a deliberate revisit point, not a committed plan.

2. Don't make scoped port_security a Phase-1 acceptance criterion — it isn't achievable on our kube-ovn. I checked kube-ovn v1.15.10: allowed-address-pairs only accept single host IPs. A Vip carries a single v4ip/v6ip with no CIDR field, getVirtualIPs gates entries through IsValidIP (which rejects any ip/mask), and SetLogicalSwitchPortSecurity joins bare IPs with no mask. OVN itself would accept MAC IP/mask, but kube-ovn never emits a mask — so scoped port_security would require either an upstream kube-ovn patch or writing to the OVN NB directly, neither of which belongs in Phase 1. Revert to your original scoping: full port_security relaxation is acceptable for v1 (containment is Cilium's sender-side egress enforcement), and scoped port_security is the immediate follow-up once kube-ovn AAP grows CIDR support. Keep the residual risks documented in Security as they are.

Two nits still only in the thread, not the doc — fold both in on the next pass: the portal-side image/cloud-init sync, and NETMAP-style 1:1 subnet mapping as NAT future work.

Otherwise this is ready — thanks for the thorough turnaround.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
@myasnikovdaniil

Copy link
Copy Markdown
Contributor Author

Thanks — agreed on point 1 and both remaining nits. I’ll make a small follow-up that:

  • reframes site-gateway as a Phase-2 design re-open rather than a committed implementation;
  • adds portal-side image/cloud-init synchronization;
  • records NETMAP-style 1:1 subnet mapping as NAT future work.

On point 2, I rechecked the kube-ovn v1.15.10 path and agree with your mechanical finding. The latest revision (8bad3a6) accepts full port_security relaxation for Phase 1 and moves CIDR-scoped port security to follow-up hardening.

I added one important qualification to the containment claim. With an empty OVN port_security list, the gateway may emit arbitrary source addresses. The controller’s remoteCIDRs validation constrains configured routes, not the source addresses of actual packets. Cozystack also disables Cilium source-IP verification in chained-CNI mode.

Cilium still provides valuable sender-side destination containment: the gateway’s endpoint identity is compiled into its eBPF program, so spoofing the packet source should not grant direct access to unrelated tenant endpoints, nodes, or the Kubernetes API. But it is not complete containment—the gateway policy permits world, the tenant tree, and selected platform services. A compromised or misconfigured remote peer could otherwise emit spoofed-source traffic toward external destinations, link-local metadata where routed, allowed platform services, or other tenants through public endpoints.

The proposal now makes the compensating controls explicit Phase-1 requirements:

  • a platform-owned VyOS tunnel-ingress rule enforcing source ∈ remoteCIDRs;
  • tunnel-to-world forwarding denied by default;
  • gateway-specific Cilium denies for metadata and concrete management ranges;
  • management-API enforcement both inside VyOS and at the pod boundary—the allow-only CCNP was insufficient because Cilium allows are additive, and decrypted guest-local traffic does not cross the pod veth;
  • negative tests for undeclared sources, another tenant, node/API, metadata, management API, public re-entry, and arbitrary world destinations.

So full relaxation is accepted for v1, but the trust boundary is explicit: without the guest source filter, a compromised remote tunnel peer can inject arbitrary-source traffic up to the destinations allowed by the gateway’s remaining policies. Scoped kube-ovn port security remains the immediate hardening follow-up once CIDR AAP support exists.

@kvaps

Copy link
Copy Markdown
Member

Thanks — confirming the state after 8bad3a6 so the remaining gate is unambiguous.

Point 2 (port_security) — landed. The routed Security posture, the Security section, the Rollout Phase 1 wording, and the open question now all reflect full Phase-1 relaxation, with Cilium sender-side egress plus the platform-owned guest source allow-list as the containment, and CIDR-scoped port_security moved to follow-up hardening. That matches the kube-ovn v1.15.10 finding — good.

Point 1 + both nits — still pending in the doc. Per your note above these land in the follow-up; just recording the gate so we don't lose it:

  1. Reframe Phase 2 / site-gateway as a deliberate design re-open rather than a committed implementation — Rollout Phase 2 still reads as committed DNAT/SNAT machinery.
  2. Portal-side image / cloud-init synchronization.
  3. NETMAP-style 1:1 subnet mapping recorded as NAT future work.

Holding approval until that follow-up is pushed; everything else is ready.

@lllamnyp Timofei Larkin (lllamnyp) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Requesting changes — the Phase-1 dependency targets an API group that's being removed.

Flagging this on an already-approved PR because the dependency moved underneath it rather than because of anything in the design itself. The good news is that this makes Phase 1 less blocked, not more.

The issue. The proposal declares #29's ServiceExposure / ExposureClass an explicit Phase-1 blocker and builds the tunnel entry point on them. cozystack/cozystack#3218 (open, approved) removes the network.cozystack.io API group — ExposureClass + ServiceExposure (#3081) — along with its reconciler and all five backends, in favor of native Service type: LoadBalancer + loadBalancerClass with an admin-provisioned pool. Rationale and discussion in cozystack/cozystack#3164. As written, this proposal's Phase 1 gates on primitives that will not exist.

Why this is good news. The blocker was "the exposure class must support UDP (IKE/NAT-T 4500, WireGuard), which #29 does not yet provide." A native Service carries protocol per port, and the LB implementations already in use handle UDP. So the dependency doesn't need re-pointing at a replacement contract — it dissolves. Phase 1 becomes unblocked.

That inverts the current fallback paragraph specifically. It reads:

If #29 stalls, the fallback is a bespoke UDP LoadBalancer Service on the gateway as a stopgap — explicitly a temporary path to be migrated onto the #29 contract once available, not a parallel long-term mechanism.

Post-3218 the "temporary stopgap" is the sanctioned mechanism, and the contract it was to migrate onto is being deleted. This needs inverting rather than trimming.

Places to touch (line numbers against the current head):

  • L23 — Scope: drop the blocking-dependency framing; state that the tunnel endpoint is a Service type: LoadBalancer + loadBalancerClass on the gateway, and that Phase 1 carries no exposure blocker.
  • L58 — "the #29 exposure of the tunnel endpoint" in the shared-foundation list.
  • L80 — the mermaid node #29 tunnel exposure (ServiceExposure, UDP).
  • L92 — "Tunnel entry point. Published via #29 ServiceExposure + ExposureClass (UDP), not a bespoke LoadBalancer" — this is now exactly backwards.
  • L112 — "the gateway's #29-exposed UDP endpoint."
  • L230 — Rollout Phase 1's "(blocked on #29 shipping UDP exposure)".
  • L240 — the open question "Tunnel exposure via #29 (blocking)". The LB-pool/quota interaction it raises is still a real open detail; it just attaches to the pool/loadBalancerClass now.
  • L121 / L241 — the topology-advertising discussion leans on "#29's target is per-named-listener, not per-broker/replica" and floats a tunnel-backed ExposureClass as future unification. The conclusion (routed is the transparent path for this class) is unaffected and correct, but the reasoning needs restating without the ExposureClass vocabulary.

A larger question this opens, worth at least a line in the doc: does the gateway need an inbound listener at all?

A site-to-site tunnel is symmetric once established — inbound "external site → tenant app" works identically regardless of which side dialled. If the gateway is the initiator, there's no listener, no Service, no pool, and no exposure story of any kind.

This design is already most of the way there: forced UDP encapsulation is mandatory unconditionally because of the native-ESP-dropped-by-the-overlay finding, and forced UDP encap is NAT-traversal mode. A NAT'd initiator needs the machinery this proposal already requires for an unrelated reason. WireGuard is more natural still — the responder identifies a peer by key and learns the endpoint from the handshake. It would also simplify the HA story: no exposure address to reconverge, the standby just dials out.

The cost is real and worth stating rather than glossing: the initiator leaves via the cluster egress masquerade wearing the node's address, shared and unstable, and remote sites routinely pin the peer's source IP in their IKE config and firewall ACL. So dial-out trades an inbound exposure dependency for a stable, tenant-owned egress identity — which this proposal currently defers to Phase 4 as a non-goal. It's also the one thing a Service type: LoadBalancer structurally cannot express: an LB Service holds an ingress address, and there's no Service to hang an egress identity on. That's the gap #35 is proposing an object for.

Not asking to resolve that here. But the choice of who initiates determines whether Phase 1 has any platform dependency at all, so it deserves to be a stated decision rather than an implicit assumption that the remote side always dials in.

Happy to review the follow-up — this fits naturally alongside the three items already recorded above (the Phase-2 reframing, portal-side image/cloud-init sync, and NETMAP-style 1:1 subnet mapping).

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants