# Edge & LAN Tunnels

An Edge tunnel lets project code reach a TCP service inside a private network (an office database, an internal HTTP API) without opening an inbound port. An Edge daemon running in that network keeps an outbound connection to the tunnels hub (tunnels.fullstacked.cloud); each tunnel under the Edge names one internal target (`host:port`). Project code registers the hub with `fullstacked/tunnel`, using the user's `AUTH_TOKEN` and the tunnel's token, and then connects to the returned name with `fetch`, `WebSocket` or any TCP driver.

## Setup

1. In the Console (https://console.fullstacked.cloud, Tunnels), enroll an Edge and run its daemon in the private network with the credential the Console shows (the daemon only connects outbound).
2. Add a tunnel under the Edge with a name and an internal target, for example `office-api` to `192.168.1.20:8080`. The target can be any TCP service the daemon's machine can reach.
3. Link the tunnel to the project (Console: Project, Linked data, Linked tunnels). Its token is then injected as `<PREFIX>_TUNNEL_TOKEN`, the prefix being the tunnel name upper-cased with non-alphanumeric runs replaced by `_` (`office-api` gives `OFFICE_API_TUNNEL_TOKEN`). Tunnel names are unique per Edge only: two linked tunnels with the same name collide (the second variable is dropped), so give one a prefix mapping on the project.

Everyone with a tier on the project may dial a linked tunnel; otherwise a user needs to own the tunnel or its Edge, or hold a connect grant on it.

## HTTP service

```ts
import tunnel from "fullstacked/tunnel";

const office = await tunnel.register({
    host: process.env.TUNNELS_HOST!,
    authorization: `${process.env.AUTH_TOKEN}:${process.env.OFFICE_API_TUNNEL_TOKEN}`,
});

const res = await fetch(`http://${office}/internal/inventory`, {
    headers: { Accept: "application/json" },
});
const inventory = await res.json();
```

Use `http://` and `ws://` URLs: the hop to the hub is already `wss://`, and the tunnel name is not a real hostname a TLS certificate could match. The URL port is ignored: the tunnel's target decides.

## TCP service (database)

```ts
import tunnel from "fullstacked/tunnel";
import pg from "pg";

const host = await tunnel.register({
    host: process.env.TUNNELS_HOST!,
    authorization: `${process.env.AUTH_TOKEN}:${process.env.OFFICE_DB_TUNNEL_TOKEN}`,
});

const pool = new pg.Pool({
    host,
    port: 5432,
    user: process.env.OFFICE_DB_USER,         // custom project variables: an Edge tunnel carries no credentials
    password: process.env.OFFICE_DB_PASSWORD,
    database: "inventory",
    ssl: false,
});
pool.on("error", (err) => console.error("office db error", err));
```

Credentials of the internal service are yours to provide (custom project variables are visible to every project tier: prefer per-user credentials or a service that authenticates the FullStacked user, see [Runtime Sign-In & BYOA](https://docs.fullstacked.cloud/auth-byoa)).

## WebSocket service

```ts
const ws = new WebSocket(`ws://${office}/events`);
ws.onmessage = (event) => console.log(event.data);
```

## How a connection flows

1. `tunnel.register` stores the hub host and the compound credential `<AUTH_TOKEN>:<tun_ token>` under a name and opens the tunnel's WebSocket session to `wss://tunnels.fullstacked.cloud` in the background. It never fails on bad credentials: errors surface on the first connection.
2. The hub authenticates the session once, at its upgrade: it checks the `fsc_*` token with the auth service, then the user's right to the tunnel.
3. Every connection to the name (socket, `fetch`, `WebSocket`) is a stream multiplexed on that session; the runtime adds sessions under load and closes idle ones. For each stream the hub asks the Edge daemon to dial the target inside its network, and the daemon splices the bytes.
4. Revoking a user's token or access, rolling or deleting the tunnel token, or the owner's transfer quota running out closes the live sessions of the tunnel, other users' included; their next connection opens a new session (refused if they no longer have access). Handle dropped connections and reconnect on demand.

## Errors

Errors reach project code as connection errors whose message names the tunnel.

A refused session (the upgrade) fails with the WebSocket handshake status, for example `expected handshake response status code 101 but got 403`; the reason word is in the hub's response body only.

| Status | Meaning |
| :--- | :--- |
| `401` | `AUTH_TOKEN` missing, expired or revoked, the credential is not `<fsc_token>:<tun_token>`, or the `tun_` token was rolled or the tunnel deleted (re-read the environment). |
| `403` | The user may not dial this tunnel (`access_denied`) or their account is not active (`principal_inactive`). |
| `402` | `quota_exceeded`: frozen tunnel or the owner's monthly Edge transfer is spent. |
| `503` | `auth_unavailable`: the hub could not check the token; retry. |

A refused stream (one connection) fails with the hub's reason, for example `tunnel <name>: target_unreachable`:

| Reason | Meaning |
| :--- | :--- |
| `edge_disconnected` | The Edge daemon is offline. |
| `target_unreachable`, `connect_timeout` | The target could not be reached in time (wrong address, service down, LAN firewall). |
| `edge_saturated`, `stream_limit` | Too many concurrent streams: retry later. |
| `relay_dial_failed` | The Edge daemon did not open a relay session to the hub in time: retry. |

From an agent, `tunnels_list` lists tunnels and Edges, and `tunnels_get_edge_status` (Edge managers only) and `tunnels_test_reachability` report whether the Edge daemon is connected. Neither probes the target behind an Edge: check that from the Edge's network.

## Quotas

On the Base plan an owner keeps 1 Edge with up to 5 tunnels and 500 MiB per UTC calendar month of Edge tunnel transfer (both directions). Edges and tunnels beyond those counts, and every non-sponsored Edge and tunnel while the owner's storage (projects and data instances) is over 1 GiB, are frozen: their sessions are refused with `402 quota_exceeded`. When the transfer is spent, new sessions get `402 quota_exceeded` and live ones are closed until the next month. A Pro owner has no limit, and neither has an Edge or tunnel shared with an accepted Pro member or a tunnel linked to a project with an accepted Pro member.

## Rules for agents

- Never hardcode the hub host or a `tun_` token: read `process.env.TUNNELS_HOST` and `process.env.<PREFIX>_TUNNEL_TOKEN`.
- Register each tunnel once and reuse the name; check the token variable is set before registering (it is absent when the tunnel is not linked or the user has no access).
- Every user tunnel belongs to an Edge (creating a direct tunnel that the hub would dial itself is refused); the target is whatever the Edge daemon can reach.

## Endpoints

### WS /

Multiplexed tunnel session dialed by fullstacked/tunnel: each registered tunnel keeps a WebSocket session (more under load), authenticated once at the upgrade, and every TCP/HTTP/WS connection to the tunnel name is a stream on it, to a data instance or Edge tunnel.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Authorization: <fsc_* token>:<tun_* token> (Bearer prefix optional)
- **In project code**: `${process.env.TUNNELS_HOST}`
- **Pitfall**: Pass both halves joined by ':': the user's AUTH_TOKEN and the env's <ENGINE>_DATA_TOKEN or <PREFIX>_TUNNEL_TOKEN (a tun_ token).

indexTs:

```ts
import tunnel from "fullstacked/tunnel";

const pgHost = await tunnel.register({
    host: process.env.TUNNELS_HOST,
    authorization: `${process.env.AUTH_TOKEN}:${process.env.PG_DATA_TOKEN}`,
});
```

### GET /api/v1/edges

Edge machines visible to the caller.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### POST /api/v1/edges

Registers an Edge { name } (201); its daemon then authenticates with an fsc_edge_ token from auth POST /v1/tokens.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### GET /api/v1/edges/:id

One Edge.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### POST /api/v1/edges/:id/roll

Rotates an Edge's edg_ key (owner or can_register grantee): the lifeline is severed, tunnels and grants stay; the daemon needs a fresh fsc_edge_ token.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### DELETE /api/v1/edges/:id

Deletes an Edge (owner or can_register grantee).

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### GET /api/v1/tunnels

LAN tunnels visible to the caller (?edgeId=); managed data tunnels are not listed.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### POST /api/v1/tunnels

Creates a tunnel { name, edgeId, internalHost, internalPort } under an Edge the caller manages (201).

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### GET /api/v1/tunnels/:id

One tunnel.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### POST /api/v1/tunnels/test

Reachability of { tunnelId }, { token }, or a candidate { edgeId, internalHost, internalPort }: for a tunnel under an Edge it reports whether the Edge daemon is connected, without probing the target.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### POST /api/v1/tunnels/:id/roll

Rolls the tunnel's tun_ token.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*

### DELETE /api/v1/tunnels/:id

Deletes a tunnel.

- **Service**: `tunnels` (`https://tunnels.fullstacked.cloud`)
- **Auth**: Bearer fsc_*
