Skip to content

test(edge): Prove MASQUE packet access in the lab - #1140

Merged
privateip merged 3 commits into
mainfrom
fix/issue-974
Oct 27, 2026
Merged

privateip merged 3 commits into
mainfrom
fix/issue-974

Conversation

@privateip

@privateip privateip commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The MASQUE milestone needs proof that a CONNECT-IP server can run as an unprivileged Galactic Edge provider and keep tenants with identical addresses apart.

This adds a test-only fixture provider and a lab suite that checks isolation in both directions, NAT egress, a half-set-up instance killed without shutdown, pod recreation and asserted cleanup.

The proof covers the containerlab lab only. The target-platform run the issue also requires has no harness yet, so the issue stays open.

Compatibility and rollback

Test code, one lab image and one doc paragraph. Core behavior does not change. Revert the PR's commits to roll back.

Test plan

  • Lint, build, unit and end-to-end tests pass locally
  • Every lab row passes on this branch: each tenant's own workloads answer 250 of 250 probes, no probe crosses tenants, and cleanup is asserted
  • CI passes
  • Not run: the target-platform rows, which have no harness

Related to #974

The MASQUE packet-access checks need a CONNECT-IP server that runs as an ordinary Galactic Edge provider. internal/edge/connectip has the protocol component and a TUN packet backend, but nothing connected them to EdgeInstance, EdgeAttachment and EdgeRoute.

tests/edgeconformance/masqueprovider is that fixture. It registers its pod's EdgeInstance and holds the instance lease through provider.InstanceStore and claim.Instance with the cell's timing. It requests one PacketAccess attachment per configured EdgeService and claims one EdgeRoute per connector /96. Once core accepts a route, it carries that connector's packets through connectip.TUNBackend on the device galactic-edge created in the pod. It uses no host namespace, hostPath, NET_ADMIN or BPF.

Admission is a test fixture, not a product authority. Each connector is a static bearer credential bound to one service's VPC and one /96. A connector is admitted only while the lease is held and its route is accepted on a programmed attachment, and losing any of those ends that connector's sessions without touching others. Packets stop at the lease deadline on CLOCK_BOOTTIME even while the reconcile loop waits on the API. On SIGTERM the fixture ends sessions before it releases routes, deletes attachments and releases the lease.

The Dockerfile builds a scratch image that runs as 65532, and deploy/ holds its registration and an unprivileged Deployment.
The MASQUE fixture provider has no proof yet that the foundation carries its packets correctly. TestMASQUE runs it on the lab's dfw cluster and checks what the packet-access milestone requires.

- M16: the fixture pod shares no host namespace, mounts no hostPath and adds no capability, and its process holds none. galactic-edge creates each TUN device in the pod, owned by 65532, and the fixture opens it. An unknown credential gets 401, a credential presented for the other VPC gets 403, and a connector whose route core refused gets 503.
- M07: VPCs 10 and 20 hold the same connector /96 in one provider process. Neighbour caches are flushed first. Each tenant pings its own workloads on the compute node and on the fixture's node, and the other tenant's. Each client uses its own ICMPv6 echo identifier, so captures on every workload link show which client a packet came from. The own-tenant capture is the positive control.
- NAT egress: both tenants reach the off-fabric host through the NAT shards from the attachment node.
- Partial setup: a second instance whose only route is refused is killed without shutdown. The first instance keeps answering, and core reaps the attachment, instance, lease and route, and the node removes the host link.
- Recreation: the fixture's pod is deleted, and the replacement serves both tenants in isolation. The old pod's objects and host links are reaped, and the replacement's routes stay accepted.
- Teardown fails the run unless the provider and core have removed every attachment, instance, lease and route before the namespace is deleted, no fixture advertisement or host link remains, and the tenants' edge-client pods are unchanged and still reach each other.

Every packet row also requires an accepted request with the configured /96 that sent through its whole window, so a refused or cut-short request cannot pass on its reply ratio.

edgeprobe gains a connectip client and an ICMPv6 echo capture. The client runs on the fixture's node, because the lab's pod network does not carry UDP from a node to a pod on another node. Where the client runs does not change the gVPC path behind the fixture. task verify:masque-conformance runs the rows, and build:masque-provider builds and loads the image.
@privateip privateip self-assigned this Oct 27, 2026
@privateip

privateip commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Validation run locally. The lab run is from a clean checkout of 98f59d6, with the fixture image rebuilt from it. The repository checks ran at 8e84b06, and 98f59d6 only adds the teardown's removal of the fixture CA from the nodes.

Lab run, task verify:masque-conformance against the containerlab lab's dfw cluster. Core images were built from this branch's base (509c9c9) and loaded on dfw. Every subtest and the teardown passed, and the CA was gone from the nodes afterwards.

Row Result
M16 The fixture process holds no capabilities and runs as 65532. galactic-edge created both TUN devices, owned by 65532. Unknown credential 401, credential for the other VPC 403, connector whose route core refused (PrefixNotGranted) 503
M07 Each tenant reached its own workloads on the compute node and on the fixture's node 250 of 250 times. It reached the other tenant's 0 times. Captures on all four workload links saw 250 of their own tenant's requests and 0 of the other's. The same holds after recreation
NAT egress 250 of 250 replies per tenant from the off-fabric host
Partial setup The first instance answered 1000 of 1000 across the kill, with a 21 ms largest gap. Core reaped the killed instance's attachment after 17 s, its instance and lease after 31 s, and its refused route. The host link was gone at the next check
Recreation The replacement served after 23 s. Core reaped the old attachments after 23 s and the old instance and lease after 24 s. The replacement's routes stayed accepted for 20 s
Teardown The provider and core removed every attachment, instance, lease and route before the namespace was deleted. No fixture advertisement or host link remained. The tenants' edge-client pods kept their UIDs and still reached each other

Repository checks:

  • task lint: golangci-lint reports 0 issues. yamlfmt passes with the repository config minus its .git/** exclude, because .git is a file in a worktree checkout.
  • task build and task test:unit pass.
  • task test:e2e passed on a second attempt. The first failed before any test ran: the image build timed out reaching the Go checksum database.
  • The fixture's own unit tests pass under go test -race. They live outside the packages task test:unit runs.

Not run: the target-platform rows. No harness exists for a cluster of real nodes, so #974 stays open.

The lab's pod network does not carry UDP from a node to a pod on another node, so the CONNECT-IP client runs on the fixture's node. That changes only the client-to-fixture leg, not the gVPC path behind the fixture.

Settled here from the #989 findings in the acceptance matrix:

  • A05: each direction has its own negative, with a positive control.
  • A08: every capture must first see its own tenant's traffic.

Still open, and outside this change:

  • A06: the registration type of the TUN device.
  • A14: a namespace-only impostor case.
  • A16: injected route drift.
  • M16's forged-pod, version and token-extra cases, which the reference-provider conformance rows cover for the foundation.

TestMASQUE copies its fixture CA onto the three dfw nodes so the CONNECT-IP client can verify the fixture's certificate. Teardown now deletes it, so the run leaves nothing of its own behind on the nodes.
@privateip privateip changed the title test(edge): Prove MASQUE packet access on the Edge foundation in the lab test(edge): Prove MASQUE packet access in the lab Oct 27, 2026
@privateip
privateip marked this pull request as ready for review October 27, 2026 17:30
@privateip
privateip requested a review from a team as a code owner October 27, 2026 17:30
@privateip
privateip enabled auto-merge October 27, 2026 17:31
@privateip

Copy link
Copy Markdown
Collaborator Author

Review record for this PR, covering two passes, with the ready phase run at the requester's direction after a split second pass.

First pass at 8e84b06: the adversarial review held, because the archived lab run was made from an uncommitted tree, and it raised a nit that teardown left the fixture CA on the lab nodes. The conventions review returned merge with nits (title length, commit subject length).

Fixes: a clean rerun from 98f59d6 passed every lab row, 98f59d6 removes the CA at teardown, and the title was shortened. Not applied: two commit subjects over 50 characters, because shortening them needs a force-push of published commits.

Second pass at 98f59d6: the re-review returned merge, confirming all three fixes against the clean run's recorded commit and dirty flag and the nodes' state. The conventions review held on the body's commit count, since corrected, and noted it did not run the lab itself.

CI on 98f59d6: all checks passed, with publish-service-api skipped. Auto-merge is enabled with the merge method, the only one the main ruleset allows. The ruleset requires one approving review, code owner review, and approval of the last push, so the PR waits for a human approval before it merges.

@ecv ecv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve.

Test-only change. Nothing in it reaches production, and the suite asserts isolation in both directions with positive controls.

  • Nothing ships: publish.yaml builds named images from containers/*/Dockerfile and bundles only config/, so the fixture Dockerfile and the manifests under tests/edgeconformance/masqueprovider/deploy are never built or published. The image is built locally by the lab Taskfile and loaded with imagePullPolicy: Never.
  • The unprivileged claim holds in the manifest (non-root 65532, drop: ["ALL"], allowPrivilegeEscalation: false, read-only root, no host namespaces or hostPath) and rowUnprivileged (masque_test.go:489-501) asserts it against the live pod, including no added capabilities and not privileged.
  • Isolation is not vacuous: each tenant's workloads must answer at least 90% (positive control), echoes to the other tenant's workloads must be zero, the client must receive nothing that is not its own reply, and sniffers on all four workload links count foreign-tenant requests and must also see about half of their own tenant's requests. It runs cold, with flushed neighbor caches, and again after pod recreation.
  • Credentials are random per run (rand.Text()), TLS material is generated per run, and the wrong-VPC credential case expects 403.
  • acceptance.md is consistent with the suite: M16 and M07 map to M16-unprivileged-provider and M07-isolation, and the paragraph says a lab result does not satisfy rows that need the target platform.

Follow-ups:

  • build:masque-provider joins the lab-wide build and load list, so every lab bring-up builds and loads this image onto the edge nodes. Consider making it opt-in with verify:masque-conformance.
  • The suite takes about 15 minutes and is outside verify and CI, so nothing catches a regression until someone runs it. Worth a nightly job once the lab runner exists.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants