Skip to content

feat: portal gateway mode - #45

Merged
monody0007 merged 38 commits into
mainfrom
feat/portal-gateway
Oct 14, 2026
Merged

monody0007 merged 38 commits into
mainfrom
feat/portal-gateway

Conversation

@monody0007

Copy link
Copy Markdown
Contributor

Problem

TSLink runs one tsnet node per app, and a Tailscale device share covers one device. Someone outside the owner's tailnet therefore needs one share per app, plus one for the portal, and every new app means another invitation. In practice a guest who receives several invitations accepts one and cannot reach the rest.

Change

Portal gateway mode: the guest accepts one share, of the portal node, and the owner then decides which apps each person can use without any further action from the guest.

  • tslink portal enable --gateway [--gateway-ports] [--gateway-tag] makes the portal node persistent (a non-ephemeral key is requested before minting; existing ephemeral enrolment is reset through a validated helper and a marker proves persistent enrolment) and records its NodeID with ownership proof. :443 stays the per-visitor directory.
  • tslink portal route add|remove|list serves a private HTTP or file app on its own TLS port of the portal node, at its root path. Ports come from a range (default 8443-8499) that skips the Fetch Standard bad ports, 80 and 443, and stay stable across restarts. Route changes hot-reload only that listener.
  • Three gates on every route port: a connection gate that runs WhoIs before any TLS byte (strangers are closed; people with access only to other routed apps get a deny page); a request gate that decides every request and WebSocket upgrade afresh with no pass-through path (routed apps are always people-scoped); and a same-origin gate, needed because every page in a tailnet is same-site (ts.net is a public suffix), with a per-route --allow-cross-origin opt-out.
  • Proxy hygiene: client identity headers stripped, a reserved gateway cookie prefix never forwarded, X-Forwarded-Host with the port, Location/Content-Location/Refresh rewritten to the route origin, offline and deny pages that reveal neither upstream addresses nor apps the caller cannot see.
  • tslink add --portal-only registers an app with no node of its own. tslink add --remote https://<node>.<tailnet>.ts.net serves an app on another host: the target is resolved only from the gateway node's own peer list and dialled only through its netstack, never the host network or host resolver, and only while the gateway is tagged; remote upstreams receive no visitor identity headers. Portal-only apps, local and remote, get periodic health probes.
  • People: with gateway mode on, people add|update --invite sends at most one device invite per person, for the gateway node; grant changes never send or revoke it; a stale invite left by a gateway identity change can be retired explicitly.
  • MCP: portal_enable gains gateway options; new portal_route_add, portal_route_remove and portal_route_list; add gains portal_only, remote and portal_port. Over HTTP every route-changing path checks the current portal owner inside the registry write transaction.
  • Status, list, url, doctor and the access log gain gateway fields and reason codes; all changes are additive.
  • The registry moves to schema version 3 only when gateway fields are present; older releases refuse it, which is the intended downgrade guard (steps in docs/portal.md and the changelog).
  • Also fixed: tslink invite list no longer fails when the user-invite list is JSON null; scripts/systemd-e2e.sh anchors on the current verifySystemdServiceRunning(ctx context.Context) signature again.

Docs: docs/portal.md, docs/people.md, docs/architecture.md, docs/sharing.md, docs/roadmap.md, docs/cli-reference.md, SECURITY.md, and a dated 0.2.0 changelog section.

Validation and evidence

  • Five implementation rounds (the fifth adds only tests and documentation), each reviewed independently on a different model from the author's. Findings went from 4 severe and 13 moderate (round 1) to 2 severe and 5 moderate (round 2) to 0 severe and 3 moderate (round 3, approved). Round 4 closed the remaining moderate items and nits; round 4 review: approved with no severe findings. Its two test gaps are closed in round 5 by tests that fail on the reviewer's mutants, and its documentation corrections are applied.
  • Round 3 review: full suite 3,061 top-level tests and 4,891 subtests pass (6 skips, 0 failures); -race on 9 packages, 6,707 passes and no races; full scripts/check.sh exit 0; 82 mutants, 77 killed, 5 survivors explained as redundant guards.
  • Every earlier severe fix was re-injected in natural form and its guarding test turned red: a remote dial through tsnet Dial (which can fall back to the host network and resolver), route teardown under the routes lock combined with a lock-taking admission call, credentialed upgrade and persistent restart, dashboard audience scoping, isolation from unrelated invalid services, and an unknown visitor with no people configured.
  • Round 4 (final code): full suite 3,065 top-level tests and 4,896 subtests pass with 0 failures; -race on the changed packages, 5,886 passes and no races; full scripts/check.sh exit 0; 48 mutants, 42 killed, 4 survivors now killed by the round 5 tests, 2 explained as redundant guards; every earlier severe fix re-injected and caught. Round 5 adds only tests and documentation.
  • Compatibility: registries without gateway fields keep schema 1 or 2 and round-trip byte-identically; v0.1.2 refuses a schema 3 registry. With gateway mode off, existing JSON output (status, list, access log, doctor, version string), node ownership and the registry fingerprint are unchanged.
  • macOS 27 arm64: scripts/check.sh -race passes; the service-manager guard recorded no real service-manager calls; goreleaser check passes.
  • Linux (OrbStack VM with systemd): scripts/systemd-e2e.sh unmodified gives RESULT: OK with real systemd supervision, and its negative control fails as designed; a 19-step staged CLI smoke passes.
  • Windows: the release-candidate gate on this PR runs the suite on Windows.

Remaining platform coverage

  • Windows 11 ARM64 (VM) install-and-run smoke against the published release artifacts.
  • The owner's macOS host upgraded through its local release pipeline, followed by business probes (service count and health, URLs, a cross-host request from a second Mac).

@monody0007
monody0007 requested a review from a team as a code owner October 14, 2026 06:07
@monody0007
monody0007 merged commit a9ac2cf into main Oct 14, 2026
17 checks passed
@monody0007
monody0007 deleted the feat/portal-gateway branch October 14, 2026 06:28
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.

1 participant