feat(gateway): add tailscale auth + pam
This commit is contained in:
@@ -224,6 +224,8 @@ Defaults:
|
|||||||
mode: "local", // or "remote"
|
mode: "local", // or "remote"
|
||||||
bind: "loopback",
|
bind: "loopback",
|
||||||
// controlUi: { enabled: true }
|
// controlUi: { enabled: true }
|
||||||
|
// auth: { mode: "token" | "password" | "system" }
|
||||||
|
// tailscale: { mode: "off" | "serve" | "funnel" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -231,6 +233,16 @@ Defaults:
|
|||||||
Notes:
|
Notes:
|
||||||
- `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
- `clawdis gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||||
|
|
||||||
|
Auth and Tailscale:
|
||||||
|
- `gateway.auth.mode` sets the handshake requirements (`token`, `password`, or `system`/PAM).
|
||||||
|
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||||
|
- `gateway.auth.password` can be set here, or via `CLAWDIS_GATEWAY_PASSWORD` (recommended).
|
||||||
|
- `gateway.auth.username` defaults to the current OS user; override with `CLAWDIS_GATEWAY_USERNAME`.
|
||||||
|
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
|
||||||
|
- `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.
|
||||||
|
|
||||||
### `canvasHost` (LAN/tailnet Canvas file server + live reload)
|
### `canvasHost` (LAN/tailnet Canvas file server + live reload)
|
||||||
|
|
||||||
The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it.
|
The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it.
|
||||||
|
|||||||
16
docs/dashboard.md
Normal file
16
docs/dashboard.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
summary: "Gateway dashboard (Control UI) access and auth"
|
||||||
|
read_when:
|
||||||
|
- Changing dashboard authentication or exposure modes
|
||||||
|
---
|
||||||
|
# Dashboard (Control UI)
|
||||||
|
|
||||||
|
The Gateway dashboard is the browser Control UI served at `/ui/`.
|
||||||
|
|
||||||
|
Key references:
|
||||||
|
- `docs/control-ui.md` for usage and UI capabilities.
|
||||||
|
- `docs/tailscale.md` for Serve/Funnel automation.
|
||||||
|
- `docs/web.md` for bind modes and security notes.
|
||||||
|
|
||||||
|
Authentication is enforced at the WebSocket handshake via `connect.params.auth`
|
||||||
|
(token or password). See `gateway.auth` in `docs/configuration.md`.
|
||||||
87
docs/tailscale.md
Normal file
87
docs/tailscale.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
summary: "Integrated Tailscale Serve/Funnel for the Gateway dashboard"
|
||||||
|
read_when:
|
||||||
|
- Exposing the Gateway Control UI outside localhost
|
||||||
|
- Automating tailnet or public dashboard access
|
||||||
|
---
|
||||||
|
# Tailscale (Gateway dashboard)
|
||||||
|
|
||||||
|
Clawdis can auto-configure Tailscale **Serve** (tailnet) or **Funnel** (public) for the
|
||||||
|
Gateway dashboard and WebSocket port. This keeps the Gateway bound to loopback while
|
||||||
|
Tailscale provides HTTPS, routing, and (for Serve) identity headers.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
- `serve`: Tailnet-only HTTPS via `tailscale serve`. The gateway stays on `127.0.0.1`.
|
||||||
|
- `funnel`: Public HTTPS via `tailscale funnel`. Requires auth.
|
||||||
|
- `off`: Default (no Tailscale automation).
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
Set `gateway.auth.mode` to control the handshake:
|
||||||
|
|
||||||
|
- `token` (default when `CLAWDIS_GATEWAY_TOKEN` is set)
|
||||||
|
- `password` (shared secret via `CLAWDIS_GATEWAY_PASSWORD` or config)
|
||||||
|
- `system` (PAM, validates your OS password)
|
||||||
|
|
||||||
|
When `tailscale.mode = "serve"`, the gateway trusts Tailscale identity headers by
|
||||||
|
default unless you force `gateway.auth.mode` to `password`/`system` or set
|
||||||
|
`gateway.auth.allowTailscale: false`.
|
||||||
|
|
||||||
|
## Config examples
|
||||||
|
|
||||||
|
### Tailnet-only (Serve)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "loopback",
|
||||||
|
tailscale: { mode: "serve" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Open: `https://<magicdns>/ui/`
|
||||||
|
|
||||||
|
### Public internet (Funnel + system password)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "loopback",
|
||||||
|
tailscale: { mode: "funnel" },
|
||||||
|
auth: { mode: "system" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Open: `https://<magicdns>/ui/` (public)
|
||||||
|
|
||||||
|
### Public internet (Funnel + shared password)
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
gateway: {
|
||||||
|
bind: "loopback",
|
||||||
|
tailscale: { mode: "funnel" },
|
||||||
|
auth: { mode: "password", password: "replace-me" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Prefer `CLAWDIS_GATEWAY_PASSWORD` over committing a password to disk.
|
||||||
|
|
||||||
|
## CLI examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdis gateway --tailscale serve
|
||||||
|
clawdis gateway --tailscale funnel --auth system
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tailscale Serve/Funnel requires the `tailscale` CLI to be installed and logged in.
|
||||||
|
- System auth uses the optional `authenticate-pam` native module; install if missing.
|
||||||
|
- `tailscale.mode: "funnel"` refuses to start without auth to avoid public exposure.
|
||||||
|
- Set `gateway.tailscale.resetOnExit` if you want Clawdis to undo `tailscale serve`
|
||||||
|
or `tailscale funnel` configuration on shutdown.
|
||||||
@@ -68,6 +68,9 @@
|
|||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"authenticate-pam": "^1.0.5"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.3.10",
|
"@biomejs/biome": "^2.3.10",
|
||||||
"@lit-labs/signals": "^0.1.3",
|
"@lit-labs/signals": "^0.1.3",
|
||||||
|
|||||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -165,6 +165,10 @@ importers:
|
|||||||
wireit:
|
wireit:
|
||||||
specifier: ^0.14.12
|
specifier: ^0.14.12
|
||||||
version: 0.14.12
|
version: 0.14.12
|
||||||
|
optionalDependencies:
|
||||||
|
authenticate-pam:
|
||||||
|
specifier: ^1.0.5
|
||||||
|
version: 1.0.5
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -1162,6 +1166,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==}
|
resolution: {integrity: sha512-En9AY6EG1qYqEy5L/quryzbA4akBpJrnBZNxeKTqGHC2xT9Qc4aZ8b7CcbOMFTTc/MGdoNyp+SN4zInZNKxMYA==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
authenticate-pam@1.0.5:
|
||||||
|
resolution: {integrity: sha512-zaPml3/19Sa3XLewuOoUNsxwnNz13mTNoO4Q09vr93cjTrH0dwXOU49Bcetk/XWl22bw9zO9WovSKkddGvBEsQ==}
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
@@ -1907,6 +1914,9 @@ packages:
|
|||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||||
|
|
||||||
|
nan@2.24.0:
|
||||||
|
resolution: {integrity: sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==}
|
||||||
|
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -3487,6 +3497,11 @@ snapshots:
|
|||||||
audio-type@2.2.1:
|
audio-type@2.2.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
authenticate-pam@1.0.5:
|
||||||
|
dependencies:
|
||||||
|
nan: 2.24.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
balanced-match@3.0.1: {}
|
balanced-match@3.0.1: {}
|
||||||
@@ -4274,6 +4289,9 @@ snapshots:
|
|||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
thenify-all: 1.6.0
|
thenify-all: 1.6.0
|
||||||
|
|
||||||
|
nan@2.24.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { forceFreePortAndWait } from "./ports.js";
|
|||||||
type GatewayRpcOpts = {
|
type GatewayRpcOpts = {
|
||||||
url?: string;
|
url?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
timeout?: string;
|
timeout?: string;
|
||||||
expectFinal?: boolean;
|
expectFinal?: boolean;
|
||||||
};
|
};
|
||||||
@@ -25,6 +27,8 @@ const gatewayCallOpts = (cmd: Command) =>
|
|||||||
cmd
|
cmd
|
||||||
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
|
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
|
||||||
.option("--token <token>", "Gateway token (if required)")
|
.option("--token <token>", "Gateway token (if required)")
|
||||||
|
.option("--username <username>", "Gateway username (system auth)")
|
||||||
|
.option("--password <password>", "Gateway password (password/system auth)")
|
||||||
.option("--timeout <ms>", "Timeout in ms", "10000")
|
.option("--timeout <ms>", "Timeout in ms", "10000")
|
||||||
.option("--expect-final", "Wait for final response (agent)", false);
|
.option("--expect-final", "Wait for final response (agent)", false);
|
||||||
|
|
||||||
@@ -36,6 +40,8 @@ const callGatewayCli = async (
|
|||||||
callGateway({
|
callGateway({
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
token: opts.token,
|
token: opts.token,
|
||||||
|
username: opts.username,
|
||||||
|
password: opts.password,
|
||||||
method,
|
method,
|
||||||
params,
|
params,
|
||||||
expectFinal: Boolean(opts.expectFinal),
|
expectFinal: Boolean(opts.expectFinal),
|
||||||
@@ -57,6 +63,18 @@ export function registerGatewayCli(program: Command) {
|
|||||||
"--token <token>",
|
"--token <token>",
|
||||||
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
||||||
)
|
)
|
||||||
|
.option("--auth <mode>", 'Gateway auth mode ("token"|"password"|"system")')
|
||||||
|
.option("--password <password>", "Password for auth mode=password")
|
||||||
|
.option("--username <username>", "Default username for system auth")
|
||||||
|
.option(
|
||||||
|
"--tailscale <mode>",
|
||||||
|
'Tailscale exposure mode ("off"|"serve"|"funnel")',
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--tailscale-reset-on-exit",
|
||||||
|
"Reset Tailscale serve/funnel configuration on shutdown",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.option("--verbose", "Verbose logging to stdout/stderr", false)
|
.option("--verbose", "Verbose logging to stdout/stderr", false)
|
||||||
.option(
|
.option(
|
||||||
"--ws-log <style>",
|
"--ws-log <style>",
|
||||||
@@ -97,6 +115,34 @@ export function registerGatewayCli(program: Command) {
|
|||||||
if (opts.token) {
|
if (opts.token) {
|
||||||
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
|
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
|
||||||
}
|
}
|
||||||
|
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
|
||||||
|
const authMode =
|
||||||
|
authModeRaw === "token" ||
|
||||||
|
authModeRaw === "password" ||
|
||||||
|
authModeRaw === "system"
|
||||||
|
? authModeRaw
|
||||||
|
: null;
|
||||||
|
if (authModeRaw && !authMode) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
'Invalid --auth (use "token", "password", or "system")',
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
|
||||||
|
const tailscaleMode =
|
||||||
|
tailscaleRaw === "off" ||
|
||||||
|
tailscaleRaw === "serve" ||
|
||||||
|
tailscaleRaw === "funnel"
|
||||||
|
? tailscaleRaw
|
||||||
|
: null;
|
||||||
|
if (tailscaleRaw && !tailscaleMode) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
'Invalid --tailscale (use "off", "serve", or "funnel")',
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
|
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
|
||||||
const bind =
|
const bind =
|
||||||
@@ -157,7 +203,24 @@ export function registerGatewayCli(program: Command) {
|
|||||||
process.once("SIGINT", onSigint);
|
process.once("SIGINT", onSigint);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
server = await startGatewayServer(port, { bind });
|
server = await startGatewayServer(port, {
|
||||||
|
bind,
|
||||||
|
auth:
|
||||||
|
authMode || opts.password || opts.username || authModeRaw
|
||||||
|
? {
|
||||||
|
mode: authMode ?? undefined,
|
||||||
|
password: opts.password ? String(opts.password) : undefined,
|
||||||
|
username: opts.username ? String(opts.username) : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
tailscale:
|
||||||
|
tailscaleMode || opts.tailscaleResetOnExit
|
||||||
|
? {
|
||||||
|
mode: tailscaleMode ?? undefined,
|
||||||
|
resetOnExit: Boolean(opts.tailscaleResetOnExit),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GatewayLockError) {
|
if (err instanceof GatewayLockError) {
|
||||||
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
|
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
|
||||||
@@ -183,6 +246,18 @@ export function registerGatewayCli(program: Command) {
|
|||||||
"--token <token>",
|
"--token <token>",
|
||||||
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
||||||
)
|
)
|
||||||
|
.option("--auth <mode>", 'Gateway auth mode ("token"|"password"|"system")')
|
||||||
|
.option("--password <password>", "Password for auth mode=password")
|
||||||
|
.option("--username <username>", "Default username for system auth")
|
||||||
|
.option(
|
||||||
|
"--tailscale <mode>",
|
||||||
|
'Tailscale exposure mode ("off"|"serve"|"funnel")',
|
||||||
|
)
|
||||||
|
.option(
|
||||||
|
"--tailscale-reset-on-exit",
|
||||||
|
"Reset Tailscale serve/funnel configuration on shutdown",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.option(
|
.option(
|
||||||
"--allow-unconfigured",
|
"--allow-unconfigured",
|
||||||
"Allow gateway start without gateway.mode=local in config",
|
"Allow gateway start without gateway.mode=local in config",
|
||||||
@@ -267,6 +342,34 @@ export function registerGatewayCli(program: Command) {
|
|||||||
if (opts.token) {
|
if (opts.token) {
|
||||||
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
|
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
|
||||||
}
|
}
|
||||||
|
const authModeRaw = opts.auth ? String(opts.auth) : undefined;
|
||||||
|
const authMode =
|
||||||
|
authModeRaw === "token" ||
|
||||||
|
authModeRaw === "password" ||
|
||||||
|
authModeRaw === "system"
|
||||||
|
? authModeRaw
|
||||||
|
: null;
|
||||||
|
if (authModeRaw && !authMode) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
'Invalid --auth (use "token", "password", or "system")',
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined;
|
||||||
|
const tailscaleMode =
|
||||||
|
tailscaleRaw === "off" ||
|
||||||
|
tailscaleRaw === "serve" ||
|
||||||
|
tailscaleRaw === "funnel"
|
||||||
|
? tailscaleRaw
|
||||||
|
: null;
|
||||||
|
if (tailscaleRaw && !tailscaleMode) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
'Invalid --tailscale (use "off", "serve", or "funnel")',
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const configExists = fs.existsSync(CONFIG_PATH_CLAWDIS);
|
const configExists = fs.existsSync(CONFIG_PATH_CLAWDIS);
|
||||||
const mode = cfg.gateway?.mode;
|
const mode = cfg.gateway?.mode;
|
||||||
@@ -344,7 +447,24 @@ export function registerGatewayCli(program: Command) {
|
|||||||
process.once("SIGINT", onSigint);
|
process.once("SIGINT", onSigint);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
server = await startGatewayServer(port, { bind });
|
server = await startGatewayServer(port, {
|
||||||
|
bind,
|
||||||
|
auth:
|
||||||
|
authMode || opts.password || opts.username || authModeRaw
|
||||||
|
? {
|
||||||
|
mode: authMode ?? undefined,
|
||||||
|
password: opts.password ? String(opts.password) : undefined,
|
||||||
|
username: opts.username ? String(opts.username) : undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
tailscale:
|
||||||
|
tailscaleMode || opts.tailscaleResetOnExit
|
||||||
|
? {
|
||||||
|
mode: tailscaleMode ?? undefined,
|
||||||
|
resetOnExit: Boolean(opts.tailscaleResetOnExit),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GatewayLockError) {
|
if (err instanceof GatewayLockError) {
|
||||||
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
|
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
|
||||||
|
|||||||
@@ -106,6 +106,28 @@ export type GatewayControlUiConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GatewayAuthMode = "token" | "password" | "system";
|
||||||
|
|
||||||
|
export type GatewayAuthConfig = {
|
||||||
|
/** Authentication mode for Gateway connections. Defaults to token when set. */
|
||||||
|
mode?: GatewayAuthMode;
|
||||||
|
/** Username for system auth (PAM). Defaults to current user. */
|
||||||
|
username?: string;
|
||||||
|
/** Shared password for password mode (consider env instead). */
|
||||||
|
password?: string;
|
||||||
|
/** Allow Tailscale identity headers when serve mode is enabled. */
|
||||||
|
allowTailscale?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GatewayTailscaleMode = "off" | "serve" | "funnel";
|
||||||
|
|
||||||
|
export type GatewayTailscaleConfig = {
|
||||||
|
/** Tailscale exposure mode for the Gateway control UI. */
|
||||||
|
mode?: GatewayTailscaleMode;
|
||||||
|
/** Reset serve/funnel configuration on shutdown. */
|
||||||
|
resetOnExit?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type GatewayConfig = {
|
export type GatewayConfig = {
|
||||||
/**
|
/**
|
||||||
* Explicit gateway mode. When set to "remote", local gateway start is disabled.
|
* Explicit gateway mode. When set to "remote", local gateway start is disabled.
|
||||||
@@ -118,6 +140,8 @@ export type GatewayConfig = {
|
|||||||
*/
|
*/
|
||||||
bind?: BridgeBindMode;
|
bind?: BridgeBindMode;
|
||||||
controlUi?: GatewayControlUiConfig;
|
controlUi?: GatewayControlUiConfig;
|
||||||
|
auth?: GatewayAuthConfig;
|
||||||
|
tailscale?: GatewayTailscaleConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SkillConfig = {
|
export type SkillConfig = {
|
||||||
@@ -370,6 +394,28 @@ const ClawdisSchema = z.object({
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
auth: z
|
||||||
|
.object({
|
||||||
|
mode: z
|
||||||
|
.union([
|
||||||
|
z.literal("token"),
|
||||||
|
z.literal("password"),
|
||||||
|
z.literal("system"),
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
allowTailscale: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
tailscale: z
|
||||||
|
.object({
|
||||||
|
mode: z
|
||||||
|
.union([z.literal("off"), z.literal("serve"), z.literal("funnel")])
|
||||||
|
.optional(),
|
||||||
|
resetOnExit: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
skillsLoad: z
|
skillsLoad: z
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { PROTOCOL_VERSION } from "./protocol/index.js";
|
|||||||
export type CallGatewayOptions = {
|
export type CallGatewayOptions = {
|
||||||
url?: string;
|
url?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
method: string;
|
method: string;
|
||||||
params?: unknown;
|
params?: unknown;
|
||||||
expectFinal?: boolean;
|
expectFinal?: boolean;
|
||||||
@@ -35,6 +37,8 @@ export async function callGateway<T = unknown>(
|
|||||||
const client = new GatewayClient({
|
const client = new GatewayClient({
|
||||||
url: opts.url,
|
url: opts.url,
|
||||||
token: opts.token,
|
token: opts.token,
|
||||||
|
username: opts.username,
|
||||||
|
password: opts.password,
|
||||||
instanceId: opts.instanceId ?? randomUUID(),
|
instanceId: opts.instanceId ?? randomUUID(),
|
||||||
clientName: opts.clientName ?? "cli",
|
clientName: opts.clientName ?? "cli",
|
||||||
clientVersion: opts.clientVersion ?? "dev",
|
clientVersion: opts.clientVersion ?? "dev",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type Pending = {
|
|||||||
export type GatewayClientOptions = {
|
export type GatewayClientOptions = {
|
||||||
url?: string; // ws://127.0.0.1:18789
|
url?: string; // ws://127.0.0.1:18789
|
||||||
token?: string;
|
token?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
clientName?: string;
|
clientName?: string;
|
||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
@@ -81,6 +83,14 @@ export class GatewayClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sendConnect() {
|
private sendConnect() {
|
||||||
|
const auth =
|
||||||
|
this.opts.token || this.opts.password || this.opts.username
|
||||||
|
? {
|
||||||
|
token: this.opts.token,
|
||||||
|
username: this.opts.username,
|
||||||
|
password: this.opts.password,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
const params: ConnectParams = {
|
const params: ConnectParams = {
|
||||||
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||||
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||||
@@ -92,7 +102,7 @@ export class GatewayClient {
|
|||||||
instanceId: this.opts.instanceId,
|
instanceId: this.opts.instanceId,
|
||||||
},
|
},
|
||||||
caps: [],
|
caps: [],
|
||||||
auth: this.opts.token ? { token: this.opts.token } : undefined,
|
auth,
|
||||||
};
|
};
|
||||||
|
|
||||||
void this.request<HelloOk>("connect", params)
|
void this.request<HelloOk>("connect", params)
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export const ConnectParamsSchema = Type.Object(
|
|||||||
Type.Object(
|
Type.Object(
|
||||||
{
|
{
|
||||||
token: Type.Optional(Type.String()),
|
token: Type.Optional(Type.String()),
|
||||||
|
username: Type.Optional(Type.String()),
|
||||||
|
password: Type.Optional(Type.String()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ let testAllowFrom: string[] | undefined;
|
|||||||
let testCronStorePath: string | undefined;
|
let testCronStorePath: string | undefined;
|
||||||
let testCronEnabled: boolean | undefined = false;
|
let testCronEnabled: boolean | undefined = false;
|
||||||
let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
||||||
|
let testGatewayAuth: Record<string, unknown> | undefined;
|
||||||
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||||
vi.mock("../config/sessions.js", async () => {
|
vi.mock("../config/sessions.js", async () => {
|
||||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||||
@@ -190,7 +191,12 @@ vi.mock("../config/config.js", () => {
|
|||||||
agent: { provider: "anthropic", model: "claude-opus-4-5" },
|
agent: { provider: "anthropic", model: "claude-opus-4-5" },
|
||||||
session: { mainKey: "main", store: testSessionStorePath },
|
session: { mainKey: "main", store: testSessionStorePath },
|
||||||
},
|
},
|
||||||
gateway: testGatewayBind ? { bind: testGatewayBind } : undefined,
|
gateway: (() => {
|
||||||
|
const gateway: Record<string, unknown> = {};
|
||||||
|
if (testGatewayBind) gateway.bind = testGatewayBind;
|
||||||
|
if (testGatewayAuth) gateway.auth = testGatewayAuth;
|
||||||
|
return Object.keys(gateway).length > 0 ? gateway : undefined;
|
||||||
|
})(),
|
||||||
cron: (() => {
|
cron: (() => {
|
||||||
const cron: Record<string, unknown> = {};
|
const cron: Record<string, unknown> = {};
|
||||||
if (typeof testCronEnabled === "boolean")
|
if (typeof testCronEnabled === "boolean")
|
||||||
@@ -244,6 +250,7 @@ beforeEach(async () => {
|
|||||||
sessionStoreSaveDelayMs.value = 0;
|
sessionStoreSaveDelayMs.value = 0;
|
||||||
testTailnetIPv4.value = undefined;
|
testTailnetIPv4.value = undefined;
|
||||||
testGatewayBind = undefined;
|
testGatewayBind = undefined;
|
||||||
|
testGatewayAuth = undefined;
|
||||||
__resetModelCatalogCacheForTest();
|
__resetModelCatalogCacheForTest();
|
||||||
piAiMock.enabled = false;
|
piAiMock.enabled = false;
|
||||||
piAiMock.getModelsCalls.length = 0;
|
piAiMock.getModelsCalls.length = 0;
|
||||||
@@ -335,6 +342,8 @@ async function connectReq(
|
|||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
opts?: {
|
opts?: {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
minProtocol?: number;
|
minProtocol?: number;
|
||||||
maxProtocol?: number;
|
maxProtocol?: number;
|
||||||
client?: {
|
client?: {
|
||||||
@@ -362,7 +371,14 @@ async function connectReq(
|
|||||||
mode: "test",
|
mode: "test",
|
||||||
},
|
},
|
||||||
caps: [],
|
caps: [],
|
||||||
auth: opts?.token ? { token: opts.token } : undefined,
|
auth:
|
||||||
|
opts?.token || opts?.password || opts?.username
|
||||||
|
? {
|
||||||
|
token: opts?.token,
|
||||||
|
username: opts?.username,
|
||||||
|
password: opts?.password,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1851,6 +1867,35 @@ describe("gateway server", () => {
|
|||||||
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
|
process.env.CLAWDIS_GATEWAY_TOKEN = prevToken;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("accepts password auth when configured", async () => {
|
||||||
|
testGatewayAuth = { mode: "password", password: "secret" };
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port);
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
|
|
||||||
|
const res = await connectReq(ws, { password: "secret" });
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid password", async () => {
|
||||||
|
testGatewayAuth = { mode: "password", password: "secret" };
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port);
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
|
|
||||||
|
const res = await connectReq(ws, { password: "wrong" });
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error?.message ?? "").toContain("unauthorized");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"closes silent handshakes after timeout",
|
"closes silent handshakes after timeout",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
|
|||||||
@@ -181,3 +181,37 @@ export async function ensureFunnel(
|
|||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function enableTailscaleServe(
|
||||||
|
port: number,
|
||||||
|
exec: typeof runExec = runExec,
|
||||||
|
) {
|
||||||
|
await exec("tailscale", ["serve", "--bg", "--yes", `${port}`], {
|
||||||
|
maxBuffer: 200_000,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableTailscaleServe(exec: typeof runExec = runExec) {
|
||||||
|
await exec("tailscale", ["serve", "reset"], {
|
||||||
|
maxBuffer: 200_000,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enableTailscaleFunnel(
|
||||||
|
port: number,
|
||||||
|
exec: typeof runExec = runExec,
|
||||||
|
) {
|
||||||
|
await exec("tailscale", ["funnel", "--bg", "--yes", `${port}`], {
|
||||||
|
maxBuffer: 200_000,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
|
||||||
|
await exec("tailscale", ["funnel", "reset"], {
|
||||||
|
maxBuffer: 200_000,
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type Pending = {
|
|||||||
export type GatewayBrowserClientOptions = {
|
export type GatewayBrowserClientOptions = {
|
||||||
url: string;
|
url: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
clientName?: string;
|
clientName?: string;
|
||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
@@ -96,6 +98,14 @@ export class GatewayBrowserClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sendConnect() {
|
private sendConnect() {
|
||||||
|
const auth =
|
||||||
|
this.opts.token || this.opts.password || this.opts.username
|
||||||
|
? {
|
||||||
|
token: this.opts.token,
|
||||||
|
username: this.opts.username,
|
||||||
|
password: this.opts.password,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
const params = {
|
const params = {
|
||||||
minProtocol: 2,
|
minProtocol: 2,
|
||||||
maxProtocol: 2,
|
maxProtocol: 2,
|
||||||
@@ -107,7 +117,7 @@ export class GatewayBrowserClient {
|
|||||||
instanceId: this.opts.instanceId,
|
instanceId: this.opts.instanceId,
|
||||||
},
|
},
|
||||||
caps: [],
|
caps: [],
|
||||||
auth: this.opts.token ? { token: this.opts.token } : undefined,
|
auth,
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
locale: navigator.language,
|
locale: navigator.language,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const KEY = "clawdis.control.settings.v1";
|
|||||||
export type UiSettings = {
|
export type UiSettings = {
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
username: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export function loadSettings(): UiSettings {
|
|||||||
const defaults: UiSettings = {
|
const defaults: UiSettings = {
|
||||||
gatewayUrl: defaultUrl,
|
gatewayUrl: defaultUrl,
|
||||||
token: "",
|
token: "",
|
||||||
|
username: "",
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +30,8 @@ export function loadSettings(): UiSettings {
|
|||||||
? parsed.gatewayUrl.trim()
|
? parsed.gatewayUrl.trim()
|
||||||
: defaults.gatewayUrl,
|
: defaults.gatewayUrl,
|
||||||
token: typeof parsed.token === "string" ? parsed.token : defaults.token,
|
token: typeof parsed.token === "string" ? parsed.token : defaults.token,
|
||||||
|
username:
|
||||||
|
typeof parsed.username === "string" ? parsed.username : defaults.username,
|
||||||
sessionKey:
|
sessionKey:
|
||||||
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
||||||
? parsed.sessionKey.trim()
|
? parsed.sessionKey.trim()
|
||||||
@@ -41,4 +45,3 @@ export function loadSettings(): UiSettings {
|
|||||||
export function saveSettings(next: UiSettings) {
|
export function saveSettings(next: UiSettings) {
|
||||||
localStorage.setItem(KEY, JSON.stringify(next));
|
localStorage.setItem(KEY, JSON.stringify(next));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user