Merge branch 'main' into feature/twitch-plugin

This commit is contained in:
jaydenfyi 2026-01-26 21:18:27 +08:00 committed by GitHub
commit a7bab031c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 313 additions and 88 deletions

View File

@ -35,7 +35,9 @@ Status: unreleased.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
### Fixes
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
- Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed).
## 2026.1.24-3

View File

@ -2867,21 +2867,22 @@ Notes:
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
- Gateway auth is required by default (token/password or Tailscale Serve identity). Non-loopback binds require a shared token/password.
- The onboarding wizard generates a gateway token by default (even on loopback).
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`). When unset, token auth is assumed.
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
(`tailscale-user-login`) to satisfy auth when the request arrives on loopback
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. When
`true`, Serve requests do not need a token/password; set `false` to require
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
auth mode is not `password`.
with `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`. Clawdbot
verifies the identity by resolving the `x-forwarded-for` address via
`tailscale whois` before accepting it. When `true`, Serve requests do not need
a token/password; set `false` to require explicit credentials. Defaults to
`true` when `tailscale.mode = "serve"` and auth mode is not `password`.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.

View File

@ -37,7 +37,7 @@ pnpm gateway:watch
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
- **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts).
- Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token <value>` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`.
- Gateway auth is required by default: set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) or `gateway.auth.password`. Clients must send `connect.params.auth.token/password` unless using Tailscale Serve identity.
- The wizard now generates a token by default, even on loopback.
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.

View File

@ -280,7 +280,7 @@ The Gateway multiplexes **WebSocket + HTTP** on a single port:
Bind mode controls where the Gateway listens:
- `gateway.bind: "loopback"` (default): only local clients can connect.
- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with `gateway.auth` enabled and a real firewall.
- Non-loopback binds (`"lan"`, `"tailnet"`, `"custom"`) expand the attack surface. Only use them with a shared token/password and a real firewall.
Rules of thumb:
- Prefer Tailscale Serve over LAN binds (Serve keeps the Gateway on loopback, and Tailscale handles access).
@ -289,13 +289,11 @@ Rules of thumb:
### 0.5) Lock down the Gateway WebSocket (local auth)
Gateway auth is **only** enforced when you set `gateway.auth`. If its unset,
loopback WS clients are unauthenticated — any local process can connect and call
`config.apply`.
Gateway auth is **required by default**. If no token/password is configured,
the Gateway refuses WebSocket connections (failclosed).
The onboarding wizard now generates a token by default (even for loopback) so
local clients must authenticate. If you skip the wizard or remove auth, youre
back to open loopback.
The onboarding wizard generates a token by default (even for loopback) so
local clients must authenticate.
Set a token so **all** WS clients must authenticate:
@ -333,9 +331,11 @@ Rotation checklist (token/password):
When `gateway.auth.allowTailscale` is `true` (default for Serve), Clawdbot
accepts Tailscale Serve identity headers (`tailscale-user-login`) as
authentication. This only triggers for requests that hit loopback and include
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as injected by
Tailscale.
authentication. Clawdbot verifies the identity by resolving the
`x-forwarded-for` address through the local Tailscale daemon (`tailscale whois`)
and matching it to the header. This only triggers for requests that hit loopback
and include `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as
injected by Tailscale.
**Security rule:** do not forward these headers from your own reverse proxy. If
you terminate TLS or proxy in front of the gateway, disable

View File

@ -25,9 +25,12 @@ Set `gateway.auth.mode` to control the handshake:
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
valid Serve proxy requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) without supplying a token/password. Clawdbot only
treats a request as Serve when it arrives from loopback with Tailscales
`x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` headers.
(`tailscale-user-login`) without supplying a token/password. Clawdbot verifies
the identity by resolving the `x-forwarded-for` address via the local Tailscale
daemon (`tailscale whois`) and matching it to the header before accepting it.
Clawdbot only treats a request as Serve when it arrives from loopback with
Tailscales `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`
headers.
To require explicit credentials, set `gateway.auth.allowTailscale: false` or
force `gateway.auth.mode: "password"`.

View File

@ -566,7 +566,6 @@ Remote access: [Gateway remote](/gateway/remote).
We keep a **hosting hub** with the common providers. Pick one and follow the guide:
- [VPS hosting](/vps) (all providers in one place)
- [Railway](/railway) (oneclick, browserbased setup)
- [Fly.io](/platforms/fly)
- [Hetzner](/platforms/hetzner)
- [exe.dev](/platforms/exe-dev)
@ -1451,7 +1450,7 @@ Have Bot A send a message to Bot B, then let Bot B reply as usual.
**CLI bridge (generic):** run a script that calls the other Gateway with
`clawdbot agent --message ... --deliver`, targeting a chat where the other bot
listens. If one bot is on Railway/VPS, point your CLI at that remote Gateway
listens. If one bot is on a remote VPS, point your CLI at that remote Gateway
via SSH/Tailscale (see [Remote access](/gateway/remote)).
Example pattern (run from a machine that can reach the target Gateway):

View File

@ -90,10 +90,10 @@ The wizard will walk you through:
clawdbot status
# Check service
systemctl status clawdbot
systemctl --user status clawdbot-gateway.service
# View logs
journalctl -u clawdbot -f
journalctl --user -u clawdbot-gateway.service -f
```
## 6) Access the Dashboard
@ -108,18 +108,30 @@ ssh -L 18789:localhost:18789 root@YOUR_DROPLET_IP
# Then open: http://localhost:18789
```
**Option B: Tailscale (easier long-term)**
**Option B: Tailscale Serve (HTTPS, loopback-only)**
```bash
# On the droplet
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
# Configure gateway to bind to Tailscale
# Configure Gateway to use Tailscale Serve
clawdbot config set gateway.tailscale.mode serve
clawdbot gateway restart
```
Open: `https://<magicdns>/`
Notes:
- Serve keeps the Gateway loopback-only and authenticates via Tailscale identity headers.
- To require token/password instead, set `gateway.auth.allowTailscale: false` or use `gateway.auth.mode: "password"`.
**Option C: Tailnet bind (no Serve)**
```bash
clawdbot config set gateway.bind tailnet
clawdbot gateway restart
```
Then access via your Tailscale IP: `http://100.x.x.x:18789`
Open: `http://<tailscale-ip>:18789` (token required).
## 7) Connect Your Channels

View File

@ -24,7 +24,6 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
## VPS & hosting
- VPS hub: [VPS hosting](/vps)
- Railway (one-click): [Railway](/railway)
- Fly.io: [Fly.io](/platforms/fly)
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
- GCP (Compute Engine): [GCP](/platforms/gcp)

View File

@ -1,5 +1,5 @@
---
summary: "VPS hosting hub for Clawdbot (Railway/Fly/Hetzner/exe.dev)"
summary: "VPS hosting hub for Clawdbot (Fly/Hetzner/GCP/exe.dev)"
read_when:
- You want to run the Gateway in the cloud
- You need a quick map of VPS/hosting guides
@ -11,7 +11,6 @@ deployments work at a high level.
## Pick a provider
- **Railway** (oneclick + browser setup): [Railway](/railway)
- **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
- **GCP (Compute Engine)**: [GCP](/platforms/gcp)
@ -24,6 +23,8 @@ deployments work at a high level.
- The **Gateway runs on the VPS** and owns state + workspace.
- You connect from your laptop/phone via the **Control UI** or **Tailscale/SSH**.
- Treat the VPS as the source of truth and **back up** the state + workspace.
- Secure default: keep the Gateway on loopback and access it via SSH tunnel or Tailscale Serve.
If you bind to `lan`/`tailnet`, require `gateway.auth.token` or `gateway.auth.password`.
Remote access: [Gateway remote](/gateway/remote)
Platforms hub: [Platforms](/platforms)

View File

@ -70,10 +70,11 @@ Open:
By default, Serve requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. Clawdbot
only accepts these when the request hits loopback with Tailscales
`x-forwarded-*` headers. Set `gateway.auth.allowTailscale: false` (or force
`gateway.auth.mode: "password"`) if you want to require a token/password even
for Serve traffic.
verifies the identity by resolving the `x-forwarded-for` address with
`tailscale whois` and matching it to the header, and only accepts these when the
request hits loopback with Tailscales `x-forwarded-*` headers. Set
`gateway.auth.allowTailscale: false` (or force `gateway.auth.mode: "password"`)
if you want to require a token/password even for Serve traffic.
### Bind to tailnet + token

View File

@ -91,7 +91,8 @@ Open:
## Security notes
- Binding the Gateway to a non-loopback address **requires** auth (`gateway.auth` or `CLAWDBOT_GATEWAY_TOKEN`).
- Gateway auth is required by default (token/password or Tailscale identity headers).
- Non-loopback binds still **require** a shared token/password (`gateway.auth` or env).
- The wizard generates a gateway token by default (even on loopback).
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
- With Serve, Tailscale identity headers can satisfy auth when

View File

@ -16,7 +16,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
## Quick start
1) Start the gateway.
2) Open the WebChat UI (macOS/iOS app) or the Control UI chat tab.
3) Ensure gateway auth is configured if you are not on loopback.
3) Ensure gateway auth is configured (required by default, even on loopback).
## How it works (behavior)
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.

View File

@ -249,7 +249,7 @@ describe("gateway-cli coverage", () => {
programInvalidPort.exitOverride();
registerGatewayCli(programInvalidPort);
await expect(
programInvalidPort.parseAsync(["gateway", "--port", "0"], {
programInvalidPort.parseAsync(["gateway", "--port", "0", "--token", "test-token"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
@ -263,7 +263,7 @@ describe("gateway-cli coverage", () => {
registerGatewayCli(programForceFail);
await expect(
programForceFail.parseAsync(
["gateway", "--port", "18789", "--force", "--allow-unconfigured"],
["gateway", "--port", "18789", "--token", "test-token", "--force", "--allow-unconfigured"],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
@ -276,9 +276,12 @@ describe("gateway-cli coverage", () => {
const beforeSigterm = new Set(process.listeners("SIGTERM"));
const beforeSigint = new Set(process.listeners("SIGINT"));
await expect(
programStartFail.parseAsync(["gateway", "--port", "18789", "--allow-unconfigured"], {
from: "user",
}),
programStartFail.parseAsync(
["gateway", "--port", "18789", "--token", "test-token", "--allow-unconfigured"],
{
from: "user",
},
),
).rejects.toThrow("__exit__:1");
for (const listener of process.listeners("SIGTERM")) {
if (!beforeSigterm.has(listener)) process.removeListener("SIGTERM", listener);
@ -304,7 +307,7 @@ describe("gateway-cli coverage", () => {
registerGatewayCli(program);
await expect(
program.parseAsync(["gateway", "--allow-unconfigured"], {
program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
@ -327,7 +330,7 @@ describe("gateway-cli coverage", () => {
startGatewayServer.mockRejectedValueOnce(new Error("nope"));
await expect(
program.parseAsync(["gateway", "--allow-unconfigured"], {
program.parseAsync(["gateway", "--token", "test-token", "--allow-unconfigured"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");

View File

@ -203,6 +203,10 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
const resolvedAuthMode = resolvedAuth.mode;
const tokenValue = resolvedAuth.token;
const passwordValue = resolvedAuth.password;
const hasToken = typeof tokenValue === "string" && tokenValue.trim().length > 0;
const hasPassword = typeof passwordValue === "string" && passwordValue.trim().length > 0;
const hasSharedSecret =
(resolvedAuthMode === "token" && hasToken) || (resolvedAuthMode === "password" && hasPassword);
const authHints: string[] = [];
if (miskeys.hasGatewayToken) {
authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.');
@ -212,7 +216,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
'"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.',
);
}
if (resolvedAuthMode === "token" && !tokenValue) {
if (resolvedAuthMode === "token" && !hasToken && !resolvedAuth.allowTailscale) {
defaultRuntime.error(
[
"Gateway auth is set to token, but no token is configured.",
@ -225,7 +229,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
defaultRuntime.exit(1);
return;
}
if (resolvedAuthMode === "password" && !passwordValue) {
if (resolvedAuthMode === "password" && !hasPassword) {
defaultRuntime.error(
[
"Gateway auth is set to password, but no password is configured.",
@ -238,11 +242,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
defaultRuntime.exit(1);
return;
}
if (bind !== "loopback" && resolvedAuthMode === "none") {
if (bind !== "loopback" && !hasSharedSecret) {
defaultRuntime.error(
[
`Refusing to bind gateway to ${bind} without auth.`,
"Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) or pass --token.",
"Set gateway.auth.token/password (or CLAWDBOT_GATEWAY_TOKEN/CLAWDBOT_GATEWAY_PASSWORD) or pass --token/--password.",
...authHints,
]
.filter(Boolean)

View File

@ -369,7 +369,8 @@ const FIELD_HELP: Record<string, string> = {
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
"agents.list[].identity.avatar":
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
"gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.",
"gateway.auth.token":
"Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
"gateway.auth.password": "Required for Tailscale funnel.",
"gateway.controlUi.basePath":
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",

View File

@ -125,6 +125,7 @@ describe("gateway auth", () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: true },
connectAuth: null,
tailscaleWhois: async () => ({ login: "peter", name: "Peter" }),
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
@ -143,6 +144,28 @@ describe("gateway auth", () => {
expect(res.user).toBe("peter");
});
it("rejects mismatched tailscale identity when required", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },
connectAuth: null,
tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }),
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-for": "100.64.0.1",
"x-forwarded-proto": "https",
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
"tailscale-user-login": "peter@example.com",
"tailscale-user-name": "Peter",
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("tailscale_user_mismatch");
});
it("treats trusted proxy loopback clients as direct", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: true },

View File

@ -1,7 +1,8 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
import { isTrustedProxyAddress, resolveGatewayClientIp } from "./net.js";
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
export type ResolvedGatewayAuth = {
@ -29,11 +30,17 @@ type TailscaleUser = {
profilePic?: string;
};
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
function safeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
function normalizeLogin(login: string): string {
return login.trim().toLowerCase();
}
function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false;
if (ip === "127.0.0.1") return true;
@ -58,6 +65,12 @@ function headerValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
if (!req) return undefined;
const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]);
return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined;
}
function resolveRequestClientIp(
req?: IncomingMessage,
trustedProxies?: string[],
@ -118,6 +131,39 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
}
async function resolveVerifiedTailscaleUser(params: {
req?: IncomingMessage;
tailscaleWhois: TailscaleWhoisLookup;
}): Promise<{ ok: true; user: TailscaleUser } | { ok: false; reason: string }> {
const { req, tailscaleWhois } = params;
const tailscaleUser = getTailscaleUser(req);
if (!tailscaleUser) {
return { ok: false, reason: "tailscale_user_missing" };
}
if (!isTailscaleProxyRequest(req)) {
return { ok: false, reason: "tailscale_proxy_missing" };
}
const clientIp = resolveTailscaleClientIp(req);
if (!clientIp) {
return { ok: false, reason: "tailscale_whois_failed" };
}
const whois = await tailscaleWhois(clientIp);
if (!whois?.login) {
return { ok: false, reason: "tailscale_whois_failed" };
}
if (normalizeLogin(whois.login) !== normalizeLogin(tailscaleUser.login)) {
return { ok: false, reason: "tailscale_user_mismatch" };
}
return {
ok: true,
user: {
login: whois.login,
name: whois.name ?? tailscaleUser.name,
profilePic: tailscaleUser.profilePic,
},
};
}
export function resolveGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv;
@ -127,8 +173,7 @@ export function resolveGatewayAuth(params: {
const env = params.env ?? process.env;
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
const mode: ResolvedGatewayAuth["mode"] =
authConfig.mode ?? (password ? "password" : token ? "token" : "none");
const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token");
const allowTailscale =
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
return {
@ -141,6 +186,7 @@ export function resolveGatewayAuth(params: {
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
if (auth.mode === "token" && !auth.token) {
if (auth.allowTailscale) return;
throw new Error(
"gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN)",
);
@ -155,29 +201,26 @@ export async function authorizeGatewayConnect(params: {
connectAuth?: ConnectAuth | null;
req?: IncomingMessage;
trustedProxies?: string[];
tailscaleWhois?: TailscaleWhoisLookup;
}): Promise<GatewayAuthResult> {
const { auth, connectAuth, req, trustedProxies } = params;
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
const localDirect = isLocalDirectRequest(req, trustedProxies);
if (auth.allowTailscale && !localDirect) {
const tailscaleUser = getTailscaleUser(req);
const tailscaleProxy = isTailscaleProxyRequest(req);
if (tailscaleUser && tailscaleProxy) {
const tailscaleCheck = await resolveVerifiedTailscaleUser({
req,
tailscaleWhois,
});
if (tailscaleCheck.ok) {
return {
ok: true,
method: "tailscale",
user: tailscaleUser.login,
user: tailscaleCheck.user.login,
};
}
if (auth.mode === "none") {
if (!tailscaleUser) {
return { ok: false, reason: "tailscale_user_missing" };
}
if (!tailscaleProxy) {
return { ok: false, reason: "tailscale_proxy_missing" };
}
return { ok: false, reason: tailscaleCheck.reason };
}
}
@ -192,7 +235,7 @@ export async function authorizeGatewayConnect(params: {
if (!connectAuth?.token) {
return { ok: false, reason: "token_missing" };
}
if (connectAuth.token !== auth.token) {
if (!safeEqual(connectAuth.token, auth.token)) {
return { ok: false, reason: "token_mismatch" };
}
return { ok: true, method: "token" };

View File

@ -36,7 +36,7 @@ function stripOptionalPort(ip: string): string {
return ip;
}
function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
export function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
const raw = forwardedFor?.split(",")[0]?.trim();
if (!raw) return undefined;
return normalizeIp(stripOptionalPort(raw));

View File

@ -70,6 +70,11 @@ export async function resolveGatewayRuntimeConfig(params: {
tailscaleMode,
});
const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode;
const hasToken = typeof resolvedAuth.token === "string" && resolvedAuth.token.trim().length > 0;
const hasPassword =
typeof resolvedAuth.password === "string" && resolvedAuth.password.trim().length > 0;
const hasSharedSecret =
(authMode === "token" && hasToken) || (authMode === "password" && hasPassword);
const hooksConfig = resolveHooksConfig(params.cfg);
const canvasHostEnabled =
process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
@ -83,9 +88,9 @@ export async function resolveGatewayRuntimeConfig(params: {
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
}
if (!isLoopbackHost(bindHost) && authMode === "none") {
if (!isLoopbackHost(bindHost) && !hasSharedSecret) {
throw new Error(
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN, or pass --token)`,
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set CLAWDBOT_GATEWAY_TOKEN/CLAWDBOT_GATEWAY_PASSWORD)`,
);
}

View File

@ -34,7 +34,7 @@ const openWs = async (port: number) => {
};
describe("gateway server auth/connect", () => {
describe("default auth", () => {
describe("default auth (token)", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;
@ -234,6 +234,7 @@ describe("gateway server auth/connect", () => {
test("returns control ui hint when token is missing", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, {
skipDefaultAuth: true,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
@ -352,6 +353,7 @@ describe("gateway server auth/connect", () => {
});
test("rejects proxied connections without auth when proxy headers are untrusted", async () => {
testState.gatewayAuth = { mode: "none" };
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const port = await getFreePort();
@ -360,7 +362,7 @@ describe("gateway server auth/connect", () => {
headers: { "x-forwarded-for": "203.0.113.10" },
});
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws);
const res = await connectReq(ws, { skipDefaultAuth: true });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("gateway auth required");
ws.close();

View File

@ -28,11 +28,12 @@ let ws: WebSocket;
let port: number;
beforeAll(async () => {
const started = await startServerWithClient();
const token = "test-gateway-token-1234567890";
const started = await startServerWithClient(token);
server = started.server;
ws = started.ws;
port = started.port;
await connectOk(ws);
await connectOk(ws, { token });
});
afterAll(async () => {
@ -60,6 +61,7 @@ describe("late-arriving invoke results", () => {
mode: GATEWAY_CLIENT_MODES.NODE,
},
commands: ["canvas.snapshot"],
token: "test-gateway-token-1234567890",
});
// Send an invoke result with an unknown ID (simulating late arrival after timeout)

View File

@ -100,6 +100,10 @@ function formatGatewayAuthFailureMessage(params: {
return "unauthorized: tailscale identity missing (use Tailscale Serve auth or gateway token/password)";
case "tailscale_proxy_missing":
return "unauthorized: tailscale proxy headers missing (use Tailscale Serve or gateway token/password)";
case "tailscale_whois_failed":
return "unauthorized: tailscale identity check failed (use Tailscale Serve auth or gateway token/password)";
case "tailscale_user_mismatch":
return "unauthorized: tailscale identity mismatch (use Tailscale Serve auth or gateway token/password)";
default:
break;
}

View File

@ -111,7 +111,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) {
sessionStoreSaveDelayMs.value = 0;
testTailnetIPv4.value = undefined;
testState.gatewayBind = undefined;
testState.gatewayAuth = undefined;
testState.gatewayAuth = { mode: "token", token: "test-gateway-token-1234567890" };
testState.gatewayControlUi = undefined;
testState.hooksConfig = undefined;
testState.canvasHostPort = undefined;
@ -260,10 +260,15 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio
export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) {
let port = await getFreePort();
const prev = process.env.CLAWDBOT_GATEWAY_TOKEN;
if (token === undefined) {
const fallbackToken =
token ??
(typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
? (testState.gatewayAuth as { token?: string }).token
: undefined);
if (fallbackToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
process.env.CLAWDBOT_GATEWAY_TOKEN = fallbackToken;
}
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
@ -299,6 +304,7 @@ export async function connectReq(
opts?: {
token?: string;
password?: string;
skipDefaultAuth?: boolean;
minProtocol?: number;
maxProtocol?: number;
client?: {
@ -334,6 +340,20 @@ export async function connectReq(
mode: GATEWAY_CLIENT_MODES.TEST,
};
const role = opts?.role ?? "operator";
const defaultToken =
opts?.skipDefaultAuth === true
? undefined
: typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string"
? ((testState.gatewayAuth as { token?: string }).token ?? undefined)
: process.env.CLAWDBOT_GATEWAY_TOKEN;
const defaultPassword =
opts?.skipDefaultAuth === true
? undefined
: typeof (testState.gatewayAuth as { password?: unknown } | undefined)?.password === "string"
? ((testState.gatewayAuth as { password?: string }).password ?? undefined)
: process.env.CLAWDBOT_GATEWAY_PASSWORD;
const token = opts?.token ?? defaultToken;
const password = opts?.password ?? defaultPassword;
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
const device = (() => {
if (opts?.device === null) return undefined;
@ -347,7 +367,7 @@ export async function connectReq(
role,
scopes: requestedScopes,
signedAtMs,
token: opts?.token ?? null,
token: token ?? null,
});
return {
id: identity.deviceId,
@ -372,10 +392,10 @@ export async function connectReq(
role,
scopes: opts?.scopes,
auth:
opts?.token || opts?.password
token || password
? {
token: opts?.token,
password: opts?.password,
token,
password,
}
: undefined,
device,

View File

@ -7,6 +7,12 @@ import { createTestRegistry } from "../test-utils/channel-plugins.js";
installGatewayTestHooks({ scope: "suite" });
const resolveGatewayToken = (): string => {
const token = (testState.gatewayAuth as { token?: string } | undefined)?.token;
if (!token) throw new Error("test gateway token missing");
return token;
};
describe("POST /tools/invoke", () => {
it("invokes a tool and returns {ok:true,result}", async () => {
// Allow the sessions_list tool for main agent.
@ -25,10 +31,11 @@ describe("POST /tools/invoke", () => {
const server = await startGatewayServer(port, {
bind: "loopback",
});
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
@ -105,9 +112,10 @@ describe("POST /tools/invoke", () => {
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
try {
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({
tool: "sessions_list",
action: "json",
@ -167,10 +175,11 @@ describe("POST /tools/invoke", () => {
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
@ -198,10 +207,11 @@ describe("POST /tools/invoke", () => {
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
@ -234,17 +244,18 @@ describe("POST /tools/invoke", () => {
const server = await startGatewayServer(port, { bind: "loopback" });
const payload = { tool: "sessions_list", action: "json", args: {} };
const token = resolveGatewayToken();
const resDefault = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
});
expect(resDefault.status).toBe(200);
const resMain = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ ...payload, sessionKey: "main" }),
});
expect(resMain.status).toBe(200);

View File

@ -213,6 +213,18 @@ type ExecErrorDetails = {
code?: unknown;
};
export type TailscaleWhoisIdentity = {
login: string;
name?: string;
};
type TailscaleWhoisCacheEntry = {
value: TailscaleWhoisIdentity | null;
expiresAt: number;
};
const whoisCache = new Map<string, TailscaleWhoisCacheEntry>();
function extractExecErrorText(err: unknown) {
const errOutput = err as ExecErrorDetails;
const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
@ -381,3 +393,73 @@ export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
timeoutMs: 15_000,
});
}
function getString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function parseWhoisIdentity(payload: Record<string, unknown>): TailscaleWhoisIdentity | null {
const userProfile =
readRecord(payload.UserProfile) ?? readRecord(payload.userProfile) ?? readRecord(payload.User);
const login =
getString(userProfile?.LoginName) ??
getString(userProfile?.Login) ??
getString(userProfile?.login) ??
getString(payload.LoginName) ??
getString(payload.login);
if (!login) return null;
const name =
getString(userProfile?.DisplayName) ??
getString(userProfile?.Name) ??
getString(userProfile?.displayName) ??
getString(payload.DisplayName) ??
getString(payload.name);
return { login, name };
}
function readCachedWhois(ip: string, now: number): TailscaleWhoisIdentity | null | undefined {
const cached = whoisCache.get(ip);
if (!cached) return undefined;
if (cached.expiresAt <= now) {
whoisCache.delete(ip);
return undefined;
}
return cached.value;
}
function writeCachedWhois(ip: string, value: TailscaleWhoisIdentity | null, ttlMs: number) {
whoisCache.set(ip, { value, expiresAt: Date.now() + ttlMs });
}
export async function readTailscaleWhoisIdentity(
ip: string,
exec: typeof runExec = runExec,
opts?: { timeoutMs?: number; cacheTtlMs?: number; errorTtlMs?: number },
): Promise<TailscaleWhoisIdentity | null> {
const normalized = ip.trim();
if (!normalized) return null;
const now = Date.now();
const cached = readCachedWhois(normalized, now);
if (cached !== undefined) return cached;
const cacheTtlMs = opts?.cacheTtlMs ?? 60_000;
const errorTtlMs = opts?.errorTtlMs ?? 5_000;
try {
const tailscaleBin = await getTailscaleBinary();
const { stdout } = await exec(tailscaleBin, ["whois", "--json", normalized], {
timeoutMs: opts?.timeoutMs ?? 5_000,
maxBuffer: 200_000,
});
const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
const identity = parseWhoisIdentity(parsed);
writeCachedWhois(normalized, identity, cacheTtlMs);
return identity;
} catch {
writeCachedWhois(normalized, null, errorTtlMs);
return null;
}
}

View File

@ -211,8 +211,14 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? cfg.gateway.trustedProxies
: [];
const hasToken = typeof auth.token === "string" && auth.token.trim().length > 0;
const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0;
const hasSharedSecret =
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
const hasTailscaleAuth = auth.allowTailscale === true && tailscaleMode === "serve";
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
if (bind !== "loopback" && auth.mode === "none") {
if (bind !== "loopback" && !hasSharedSecret) {
findings.push({
checkId: "gateway.bind_no_auth",
severity: "critical",
@ -236,13 +242,13 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
});
}
if (bind === "loopback" && controlUiEnabled && auth.mode === "none") {
if (bind === "loopback" && controlUiEnabled && !hasGatewayAuth) {
findings.push({
checkId: "gateway.loopback_no_auth",
severity: "critical",
title: "Gateway auth disabled on loopback",
title: "Gateway auth missing on loopback",
detail:
"gateway.bind is loopback and gateway.auth is disabled. " +
"gateway.bind is loopback but no gateway auth secret is configured. " +
"If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.",
remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.",
});