Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
([#303](https://github.com/devswha/herdr-web-ui/pull/303))

### Changed
- With an access token set (`HERDR_WEB_TOKEN`), your own Tailscale devices are asked for it too:
once per device, and that browser then stays signed in for a year. A paired device still gets in
without it, and nothing changes when no token is set. Before, the token was skipped for a request
that named this PC's Tailscale login, and any other proxy on the same PC (nginx, Caddy, a tunnel)
passes a visitor's copy of that name on unless it is told to drop it, so the token did not keep
such a visitor out. ([#309](https://github.com/devswha/herdr-web-ui/pull/309))
- A mirrored terminal (a Windows PC, or one with no Node for the terminal attach) sends the rows
that changed instead of the whole screen each time, and shows what you type sooner. An agent at
work repaints a spinner about 12 times a second: measured with gjc, that was 88 KB a second to
Expand Down
16 changes: 8 additions & 8 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,14 @@ Anyone who can reach the server can type into your terminals, so what matters is

- **It is you, says Tailscale.** `tailscale serve` states the requesting device's Tailscale login in a header it strips from anything incoming. A login that matches this PC's own gets in; another login is refused, and a tagged device (one with no person's login) needs pairing. Nothing to set up, unless this PC's own Tailscale node is tagged: it then has no login of its own, so every device pairs, yours included, or you name your login in `HERDR_WEB_TAILSCALE_OWNER`.
- **It is a paired device.** **Settings → Devices**, on the PC (or on a device already paired), shows a six-digit code that lives ten minutes and a QR code that carries it. On a headless PC, the `pair` command prints the same in its terminal (see [In a terminal](#in-a-terminal)); Devices also shows the pairing link as text, to send to the other device. The other device enters it once and keeps its own credential in an HttpOnly cookie; the list shows it, and **Revoke** immediately closes its terminal connections and roster stream and refuses subsequent requests.
- **It holds the token.** `HERDR_WEB_TOKEN`, for scripts and proxies, as a cookie after sign-in or as `Authorization: Bearer <token>`. When a token is set, everything else needs it, this computer included; a paired device and your own Tailscale login still get in without it.
- **It holds the token.** `HERDR_WEB_TOKEN`, for scripts and proxies, as a cookie after sign-in or as `Authorization: Bearer <token>`. When a token is set, everything else needs it, this computer and your own Tailscale login included: enter it once on each device, and that browser stays signed in for a year. A paired device still gets in without it.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

| How you reach it | What gets you in |
| --- | --- |
| This computer only (default) | Nothing needed |
| SSH tunnel (`ssh -L 7317:127.0.0.1:7317 host`) | Nothing needed |
| `tailscale serve`, your own devices | Nothing needed: your login |
| `tailscale serve` on a tailnet you share with others | Your devices: your login. Theirs: refused unless you pair them |
| This computer only (default) | Nothing needed. With a token set, the token |
| SSH tunnel (`ssh -L 7317:127.0.0.1:7317 host`) | Nothing needed. With a token set, the token |
| `tailscale serve`, your own devices | Nothing needed: your login. With a token set, the token, once per device |
| `tailscale serve` on a tailnet you share with others | Your devices: your login (with a token set, the token once). Theirs: refused unless you pair them |
| Your LAN (`HOST=0.0.0.0` or a LAN address) | Pair each device, or set a token |
| A public domain or reverse proxy | Set a token, with HTTPS, and set the proxy up as in [Behind a reverse proxy](#behind-a-reverse-proxy). Never `tailscale funnel` it |

Expand Down Expand Up @@ -309,13 +309,13 @@ server {

The `map` block goes in the `http` section. `proxy_read_timeout` keeps an idle terminal connected, and `proxy_buffering off` lets the PC list update live.

Both examples drop a `Tailscale-User-Login` header a visitor sends. That header is how `tailscale serve` names your own login, and on a PC that runs Tailscale the server trusts it from any proxy on this PC, so a public proxy must not pass a visitor's copy on.
Both examples drop a `Tailscale-User-Login` header a visitor sends. That header is how `tailscale serve` names your own login, and on a PC that runs Tailscale the server trusts it from any proxy on this PC, so a public proxy must not pass a visitor's copy on. With a token set the server asks for the token whatever that header says, so a proxy that does pass it on gives a visitor nothing.

To check it, open `https://herdr.example.com/api/session` from another device without signing in: it must answer 401.

**Sign out** in the header or command palette clears this browser's token and device cookies; terminal sessions and agents keep running. It is shown for token or device authentication, not automatic local or Tailscale access.

If `devices.json` under `HERDR_WEB_STATE_DIR` (default `~/.config/herdr-web-ui`) is corrupt or unreadable, the server keeps unrecognized external clients out and preserves the file. Local access without a configured token, a valid token, and the owner's trusted Tailscale login still work. Settings → Devices and the server log explain the error. Restore a valid registry from backup or fix its permissions, then restart; pairing and device changes remain disabled until it is repaired.
If `devices.json` under `HERDR_WEB_STATE_DIR` (default `~/.config/herdr-web-ui`) is corrupt or unreadable, the server keeps unrecognized external clients out and preserves the file. Local access and the owner's trusted Tailscale login without a configured token, and a valid token, still work. Settings → Devices and the server log explain the error. Restore a valid registry from backup or fix its permissions, then restart; pairing and device changes remain disabled until it is repaired.

Nothing is typed without you:
- Input typed while disconnected waits as a draft for you to send or discard.
Expand All @@ -331,7 +331,7 @@ Attaches never use `--takeover`, so they coexist with your own herdr TUI.
| `HOST` | `127.0.0.1` | Bind address. Use `0.0.0.0` or a LAN address only with `HERDR_WEB_TOKEN` set or once a device is paired: until then that address is open to anyone who reaches it (see [Access and safety](#access-and-safety)). |
| `PORT` | `7317` | HTTP and WebSocket port |
| `HERDR_SOCKET` | `~/.config/herdr/herdr.sock` | herdr socket for API calls and terminal attach. For a named session, use `~/.config/herdr/sessions/<name>/herdr.sock`. |
| `HERDR_WEB_TOKEN` | unset | Shared token for scripts and proxies. Once set, every client that is not a paired device or your own Tailscale login needs it, this computer included |
| `HERDR_WEB_TOKEN` | unset | Shared token for scripts and proxies. Once set, every client that is not a paired device needs it, this computer and your own Tailscale login included |
| `HERDR_WEB_TAILSCALE_OWNER` | this PC's Tailscale login | The Tailscale login that gets in through `tailscale serve` without pairing. Set it on a PC whose Tailscale node is tagged, which has no login of its own |
| `HERDR_WEB_STATE_DIR` | `~/.config/herdr-web-ui` | Push keys, device subscriptions, PC registrations and update builds |
| `HERDR_WEB_AUTO_UPDATE` | `0` | `1` installs new releases without asking |
Expand Down
2 changes: 1 addition & 1 deletion scripts/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ async function phone(): Promise<number> {
say(`On your phone: ${link(url)}`);
// the name, not the IP, is what the HTTPS certificate is for: https://100.x.y.z would warn
if (ip !== null) say(`Tailscale IP of this PC: ${ip}. Open the name above, not the IP: the HTTPS certificate is for the name.`);
say(`Scan this with a phone signed in to Tailscale${owner === null ? "" : ` as ${owner}`}; that login gets in without a code.`);
say(`Scan this with a phone signed in to Tailscale${owner === null ? "" : ` as ${owner}`}; that login gets in without a code (with HERDR_WEB_TOKEN set, after entering the token once).`);
say("Anyone else on your tailnet needs a pairing code: Settings → Devices, or the pair command.");
say(qr(url));
return 0;
Expand Down
9 changes: 6 additions & 3 deletions server/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,15 @@ describe("decideAccess", () => {
expect(via({ forwarded: true, tailscaleLogin: "me@example.com", owner: "me@example.com" })).toBe("tailscale");
});

it("a configured token gates everything, this PC included, and still admits identity and devices", () => {
it("a configured token gates everything but a paired device, this PC and its Tailscale login included", () => {
expect(via({ tokenConfigured: true })).toBe("refused:token_required");
expect(via({ tokenConfigured: true, tokenMatched: true })).toBe("token");
expect(via({ tokenConfigured: true, device })).toBe("device");
expect(via({ tokenConfigured: true, forwarded: true, tailscaleLogin: "me@example.com", owner: "me@example.com" })).toBe("tailscale");
expect(via({ tokenConfigured: true, forwarded: true, tailscaleLogin: "them@example.com", owner: "me@example.com" })).toBe("refused:other_user");
// any proxy on this PC can pass a visitor's copy of the login header on: a token holds against it
expect(via({ tokenConfigured: true, forwarded: true, tailscaleLogin: "me@example.com", owner: "me@example.com" })).toBe("refused:token_required");
expect(via({ tokenConfigured: true, forwarded: true, tailscaleLogin: "them@example.com", owner: "me@example.com" })).toBe("refused:token_required");
expect(via({ tokenConfigured: true, tokenMatched: true, forwarded: true, tailscaleLogin: "me@example.com", owner: "me@example.com" })).toBe("token");
expect(via({ tokenConfigured: true, device, forwarded: true, tailscaleLogin: "me@example.com", owner: "me@example.com" })).toBe("device");
});

it("a paired device gets in from anywhere, with its role", () => {
Expand Down
10 changes: 6 additions & 4 deletions server/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
* for where it comes from (this PC, with no proxy in front), for who Tailscale says it is
* (the PC's own login, which `tailscale serve` states in a header it strips from what it
* receives), or for what it holds (a paired device's cookie, or the shared token). A token,
* when one is configured, is required of everything else, this PC included: a paired device
* and the PC's own Tailscale login still get in without it, but a plain local connection does
* not, since the remote-PC bridge runs on a loopback port of a PC other people may use.
* when one is configured, is required of everything but a paired device, this PC and its own
* Tailscale login included: a plain local connection, since the remote-PC bridge runs on a
* loopback port of a PC other people may use, and the login, since any other proxy on this PC
* (nginx, Caddy, a tunnel) passes a visitor's copy of that header on unless told to drop it.
* Without a token, and until the first device is paired, anything that reaches the server is
* let in as it always was, except through a proxy
* on a PC whose Tailscale login is known: there, a request with no login header is a tagged
Expand Down Expand Up @@ -82,12 +83,13 @@ export function cameThroughProxy(headers: Headers): boolean {
export function decideAccess(input: AccessInput): Access {
if (input.tokenMatched) return { level: "full", via: "token", role: "drive" };
if (input.device !== null) return { level: "full", via: "device", role: input.device.role, device: input.device };
// before the login header: a token must hold against a header a visitor can send through another proxy
if (input.tokenConfigured) return { level: "none", reason: "token_required" };
// the identity header is only worth something from the local tailscaled, never from a LAN client
if (input.loopback && input.tailscaleLogin !== null && input.owner !== null) {
if (input.tailscaleLogin.toLowerCase() === input.owner.toLowerCase()) return { level: "full", via: "tailscale", role: "drive", login: input.tailscaleLogin };
return { level: "none", reason: "other_user" };
}
if (input.tokenConfigured) return { level: "none", reason: "token_required" };
if (input.loopback && !input.forwarded) return { level: "full", via: "local", role: "drive" };
if (input.loopback && input.forwarded && (input.owner !== null || input.tagged)) return { level: "none", reason: "pairing_required" };
// the public internet is never "open", whatever is paired
Expand Down
11 changes: 8 additions & 3 deletions server/api.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1334,15 +1334,20 @@ describe("pairing and identity", () => {
} finally { rmSync(configDir, { recursive: true, force: true }); }
});

it("a configured token still gates this PC, and identity and devices get past it", async () => {
it("a configured token gates this PC and its Tailscale login, and a paired device gets past it", async () => {
const state = mkdtempSync(join(tmpdir(), "herdr-pairing-token-"));
const secured = createServer({ port: 0, stateDir: state, token: "t0k3n", tailscaleOwner: OWNER });
const at = (headers: Record<string, string> = {}) => fetch(`http://127.0.0.1:${secured.port}/api/health?scope=bridge`, { headers }).then((r) => r.json() as Promise<{ auth: HealthAuth }>).then((b) => b.auth);
try {
expect(await at()).toMatchObject({ authenticated: false, reason: "token_required" });
expect(await at({ authorization: "Bearer t0k3n" })).toMatchObject({ authenticated: true, via: "token" });
expect(await at(proxied(OWNER))).toMatchObject({ authenticated: true, via: "tailscale" });
expect(await at(proxied("someone@example.com"))).toMatchObject({ authenticated: false, reason: "other_user" });
// another proxy on this PC can pass a visitor's copy of the login header on: the token is asked for anyway
expect(await at(proxied(OWNER))).toMatchObject({ authenticated: false, reason: "token_required" });
expect(await at(proxied("someone@example.com"))).toMatchObject({ authenticated: false, reason: "token_required" });
expect(await at({ ...proxied(OWNER), authorization: "Bearer t0k3n" })).toMatchObject({ authenticated: true, via: "token" });
// health only reports the decision: the guarded routes and the upgrade must refuse the header too
expect((await fetch(`http://127.0.0.1:${secured.port}/api/session`, { headers: proxied(OWNER) })).status).toBe(401);
expect((await fetch(`http://127.0.0.1:${secured.port}/ws`, { headers: proxied(OWNER) })).status).toBe(401);
// a pairing started with the token lets a device in without it
const started = await fetch(`http://127.0.0.1:${secured.port}/api/devices/pair/start`, { method: "POST", headers: { ...guard, authorization: "Bearer t0k3n" } });
const { code } = (await started.json()) as { code: string };
Expand Down
4 changes: 2 additions & 2 deletions shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ export type { Machine, MachineEvent, PaneTarget, SetupJob, SetupRequest, SetupAc
* /ws upgrade, needs the request to be one of: from this PC itself (no proxy in front);
* the PC's own Tailscale login, as `tailscale serve` states it; a paired device's cookie;
* the shared token (HERDR_WEB_TOKEN) as cookie or `Authorization: Bearer <token>`. With a
* token configured, only the last three count, this PC included. Without one, and while no
* token configured, only the last two count, this PC and its Tailscale login included. Without one, and while no
* device is paired, anything that reaches the server is let in as before (the startup
* warning says so). Refusals answer 401 `unauthorized` (403 `other_user` for another Tailscale
* login); the upgrade is refused. Static files are always public.
* login, when no token is configured); the upgrade is refused. Static files are always public.
*/
export interface ApiError {
error: { code: string; message: string };
Expand Down
2 changes: 1 addition & 1 deletion src/components/PhonePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,5 @@ function Command({ command }: { command: string }) {

function Sharing() {
const t = useT();
return <p className="settings-hint">{t("Your own Tailscale devices get in as you. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.")} <a href={README_SAFETY} target="_blank" rel="noreferrer">{t("Access and safety")}</a></p>;
return <p className="settings-hint">{t("Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.")} <a href={README_SAFETY} target="_blank" rel="noreferrer">{t("Access and safety")}</a></p>;
}
2 changes: 1 addition & 1 deletion src/lib/i18n.ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,7 @@ export const JA: Record<string, string> = {
"Other routes": "その他の方法",
"This PC did not say how it can be reached.": "この PC から接続方法の情報が得られませんでした。",
"Copy": "コピー",
"Your own Tailscale devices get in as you. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "自分の Tailscale デバイスは本人としてアクセスできます。他のユーザーのデバイスや、LAN・パブリックアドレスからのアクセスにはペアリングが必要です。下の「デバイス」を参照してください。",
"Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "自分の Tailscale デバイスは本人としてアクセスできます。アクセストークンを設定している場合は、デバイスごとに一度トークンを求められます。他のユーザーのデバイスや、LAN・パブリックアドレスからのアクセスにはペアリングが必要です。下の「デバイス」を参照してください。",
"Access and safety": "アクセスと安全性",
"…": "…",
"Alerts": "通知",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/i18n.ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ export const KO: Record<string, string> = {
"Other routes": "다른 방법",
"This PC did not say how it can be reached.": "이 PC가 어떻게 접속할 수 있는지 알려주지 않았습니다.",
"Copy": "복사",
"Your own Tailscale devices get in as you. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "내 Tailscale 기기는 나로 인식되어 들어옵니다. 다른 사람의 기기나 LAN·공개 주소는 페어링이 필요합니다. 아래 기기 항목을 보세요.",
"Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "내 Tailscale 기기는 나로 인식되어 들어옵니다. 접근 토큰을 설정했다면 기기마다 한 번 토큰을 묻습니다. 다른 사람의 기기나 LAN·공개 주소는 페어링이 필요합니다. 아래 기기 항목을 보세요.",
"Access and safety": "접근과 안전",
"…": "…",
"Alerts": "알림",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/i18n.zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ export const ZH: Record<string, string> = {
"Other routes": "其他方式",
"This PC did not say how it can be reached.": "此 PC 未提供访问方式。",
"Copy": "复制",
"Your own Tailscale devices get in as you. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "你自己的 Tailscale 设备会以你的身份进入。其他人的设备,或通过局域网或公网地址访问,则需要配对:请见下方“设备”。",
"Your own Tailscale devices get in as you; with an access token set, each asks for it once. Anyone else's device, or a LAN or public address, needs pairing: Devices, below.": "你自己的 Tailscale 设备会以你的身份进入;如果设置了访问令牌,每台设备会询问一次令牌。其他人的设备,或通过局域网或公网地址访问,则需要配对:请见下方“设备”。",
"Access and safety": "访问与安全",
"…": "…",
"Alerts": "提醒",
Expand Down
Loading