---
title: "Portal Gateway: One Device Share"
description: "Opt into one portal device share for selected local and remote apps, with people grants checked on every request."
url: "https://tslink.md/docs/portal-gateway"
locale: "en"
product_version: "0.2.0"
source: "https://github.com/anydoor7/tslink/blob/v0.2.0/docs/portal.md"
---

> Documentation index: https://tslink.md/llms.txt · Installed binary is authoritative: `tslink manifest`.

To give someone outside your tailnet access to one or more private apps, enable TSLink gateway mode, route the apps and grant their actual Tailscale login access until a deadline. They must install Tailscale, sign in and accept one device share of the portal node; later routed-app grants need no new invitation. Network policy must permit the connection. Gateway mode is opt-in; **per-app sharing remains the default**.

Use gateway mode when the same people need several private HTTP or file apps and their app set changes. Keep default per-app sharing when you want independent hostnames and cookies. Gateway routes share one hostname, use one HTTPS port per app and preserve the app's root path. Ports are separate browser origins, but **do not isolate cookies**: apps can collide on cookie names or set cookies affecting another port. Cookie namespacing is not implemented. TCP, public Funnel and browser guest-link apps cannot be gateway routes.

## Enable and route local apps

```bash
tslink portal enable --owner you@example.com --gateway
tslink portal route add photos
tslink add notes --proxy localhost:3000 --portal-only
tslink add files --dir /srv/shared --portal-only --portal-port 8445
tslink portal route list --json
tslink status --urls
```

`photos` must already be a private HTTP/file app; adding its route keeps its own node running. A new `--portal-only` app has no app node. The private dashboard listens on 443; each routed app has its own HTTPS port, allocated by default from 8443-8499. Use the exact URL from status or `tslink url notes --wait`, such as `https://home.example.ts.net:8443/`; do not guess a hostname or port. A newly enabled node may need enrollment before it is ready.

```bash
tslink portal route add photos --port 8447
```

Re-adding without `--port` preserves the assigned port. `tslink portal route remove photos` removes only that route. A portal-only app must retain a route; remove that app with `tslink remove notes` instead. To choose a range, use `tslink portal enable --owner you@example.com --gateway --gateway-ports 9000-9099`; existing ports must fit the range.

Enabling without gateway options preserves an existing gateway. `--gateway` alone keeps its range and tag; range/tag options require `--gateway`. After removing every route, `tslink portal enable --owner you@example.com --gateway=false` explicitly turns gateway mode off. `tslink portal disable` closes the dashboard and all routes while retaining configuration and enrollment state. Public portal Funnel is refused.

## Share the gateway with a person

```bash
tslink people add alice@example.com --apps photos,notes,files --for 7d --invite --print-links --qr
tslink people update alice@example.com --apps notes,files --for 7d
tslink people update alice@example.com --invite
```

Invitation creation needs a stored user-owned Tailscale API token; OAuth client tokens cannot create device invites. Send the generated message yourself. `--print-links` reveals bearer links; keep them private. For routed apps, TSLink records one gateway device invitation per person, and later routed-app grant changes need no new invitation. Selected non-routed apps still need their own shares and produce a warning. Direct per-app URLs keep their own device shares and access checks. Ordinary QR/phone guides point to the gateway dashboard; an invitation QR uses `--qr-invite @gateway` with `--invite --print-links` and `--qr` or `--qr-png`.

`list`, `status` and `add` report `allow.mode: people` for people-scoped apps, even outside gateway mode. Revocation denies subsequent requests; already accepted streams may finish. Removing a person denies locally first and attempts to revoke pending gateway invitations. An accepted device share may remain, but cannot bypass local denial. Shared-in outsiders do not gain the tailnet-member-only access-request form.

## Route an app on another host

```bash
tslink portal enable --owner you@example.com --gateway --gateway-tag tag:gateway
tslink add media --remote https://mini.example.ts.net:9443 --portal-port 8446
```

`--remote https://<node>.<tailnet>.ts.net` creates a portal-only HTTPS proxy. The gateway must actually be tagged according to LocalAPI, not merely have a configured tag. `--gateway-tag` requires credentials; zero-credential gateways support local apps. The remote app must allow the gateway tag in its own policy (for a TSLink app, its explicit tag allow list). Registration does not edit remote ACLs or registrations.

Remote targets must be peer MagicDNS names in the gateway's observed tailnet suffix. Port 443 is the default. HTTP, IP literals, URL credentials, fragments and foreign suffixes are refused; custom control-server domains outside `.ts.net` are unsupported. Peer identity is checked on route activation, each new upstream connection and health probes. Tag and tailnet suffix are rechecked on every request. Requests and probes dial the peer's Tailscale IP through the gateway's own netstack, never the host network or resolver, and TLS verifies the registered hostname.

The remote host authenticates the gateway node itself. **Visitor identity headers and forwarded visitor IP are not sent to remote apps**; an app needing individual human identities must keep its own login. Local upstreams receive verified visitor Tailscale identity headers. This is explicit registration of remote targets, not multi-host discovery or aggregate inventory.

## Connection and request checks

Each connection needs successful WhoIs within five seconds before TLS. A caller allowed for any routed app can complete TLS on another route, but its HTTP gate denies an app they cannot use. Strangers and people granted only non-routed apps get no TLS bytes on route ports. Each HTTP request and WebSocket upgrade repeats WhoIs, registry validation and the people decision; registry failures fail closed. Tailscale network policy and backend authentication still apply.

Browser fetches, forms and WebSocket upgrades require the same origin by default, including the HTTPS scheme, hostname and port. Top-level GET/HEAD navigation is allowed; non-browser requests without Origin/fetch metadata still undergo identity checks. `tslink portal route add photos --allow-cross-origin` deliberately weakens this browser request boundary; sharing a tailnet is not a reason to enable it. Ports do not provide cookie isolation.

## Health and access evidence

Portal-only proxy/file apps are checked periodically without visitor traffic. Remote checks use the same tagged tsnet transport. Unconfigured HTTP status ranges accept 200-399 without following redirects; 401/403 need an explicit range. An unexpected response stays `degraded`, with no `app_down` alert or “Offline since”; only unreachable backends become `down` after three consecutive failures. Observations older than twice the interval plus timeout become `unknown`. Backend health does not prove visitor reachability. Use [health settings](https://tslink.md/docs/health-and-alerts.md) and `tslink health set notes --path /health` rather than replacing the app with `add`.

`status` and `doctor` expose `portal.gateway`: observed persistence, tag, NodeID, ownership evidence, range, route states and a per-run `dropped` connection count. Connection-drop logs are rate-limited per gate; the dashboard uses `@portal`. The count includes drops omitted by rate limiting. Request events retain the app name and add `via: portal` and a stable reason.

| Reason                            | Meaning                                                                                                            |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `remote_upstream_requires_tag`    | Gateway is not observed as tagged                                                                                  |
| `remote_upstream_outside_tailnet` | Remote host is outside the observed suffix                                                                         |
| `remote_upstream_not_peer`        | Registered host is absent from the gateway's peer map                                                              |
| `upstream_down`                   | Upstream failure, including a failed remote trust check on a reused connection                                     |
| `client_canceled`                 | Client left during proxying before response headers; allowed, status 499                                           |
| `route_closed` / `node_closed`    | TSLink cut a request during route/node teardown; allowed, 503 before headers or the forwarded status after headers |

Cancellation during identification leaves no event. Cancellation after response headers keeps the forwarded status. Real backend failures add `error_class`: `connection_refused`, `connection_reset`, `tls`, `timeout` or `other`. See [access history](https://tslink.md/docs/access-history.md) for retention, drops and crash gaps.

## Recover a stale invitation

Gateway enrollment is persistent on credentialed and interactive paths. Route, grant, owner/admin and port-range edits retain the shared node. Enable/disable, hostname/tag edits, or credential-mode/control-URL changes on daemon restart can replace it. A changed NodeID produces `gateway_node_identity_changed`; an accepted share of the old node does not transfer.

First use `tslink doctor --json` and `tslink status --json` to prove the new running gateway is persistent and owned. Compare live invitation `node_id` values in `tslink people list --json` with `portal.gateway.node_id`. A stale live attempt makes `people update --invite` report `complete: false` and `gateway_invite_stale`.

```bash
tslink people update alice@example.com --invite --replace-invite @gateway=12345
```

Replace `12345` with the recorded old ID. This retires the stale local attempt, including an accepted share, as `target_gone` and allows an invitation for the current gateway. It does not revoke the old remote share. A stale pending/sending/unknown attempt without a recorded ID needs explicit `tslink people update alice@example.com --invite --reconcile-invite @gateway=none`; later retries use plain `--invite`. The recipient must accept the new device invitation. The identity-change warning persists as history; recovered `target_gone` records need no further replacement. Never copy enrollment markers between nodes. Full procedure: [TSLink v0.2.0 people guide](https://github.com/anydoor7/tslink/blob/v0.2.0/docs/people.md#gateway-or-per-app-invitations).

## Registry schema and downgrade

Gateway configuration uses **registry schema 3**. Existing registries retain their version until gateway configuration is used; older readers refuse schema 3. Before downgrading:

1. Stop the new daemon and back up the registry and portal state. Follow [verified shutdown](https://tslink.md/docs/daemon.md#verified-shutdown) for a managed service.
2. Using v0.2.0, intentionally remove or replace portal-only and remote apps, remove all routes, then turn gateway mode off.
3. Recorded gateway invitations keep schema 3 even with gateway off. If any remain, restore a registry backup taken before gateway mode; subsequent people and invite changes are lost.
4. Confirm guest network shares and authorization expectations before starting an older daemon. Replacing only the binary is not a downgrade.

For complete transport, persistence, browser and MCP boundaries, read [TSLink v0.2.0 portal.md](https://github.com/anydoor7/tslink/blob/v0.2.0/docs/portal.md#gateway-mode-one-device-share-for-routed-apps).
