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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ Before starting, confirm that `OPENSWARM_WORKSPACE` contains every configured
same `OPENSWARM_IMAGE` image ID; keep the prior immutable image tag for
rollback. Do not rely on a pre-existing mutable `openswarm:latest` image.

Without `OPENSWARM_WEB_TOKEN` the dashboard binds only inside the container — an unauthenticated `0.0.0.0` bind is refused by design — so the published port answers nothing while the daemon itself keeps running. With the token set, browser/API access from the host sends it as the `X-OpenSwarm-Token` header (`/api/health` stays token-less).
Without `OPENSWARM_WEB_TOKEN` the dashboard binds only inside the container — an unauthenticated all-interfaces bind is refused by design — so the published port answers nothing while the daemon itself keeps running. With the token set, browser/API access from the host sends it as the `X-OpenSwarm-Token` header (`/api/health` stays token-less).

The compose file wires the persistent and local-data mounts that matter:

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ services:
environment:
- TZ=Asia/Seoul
# The dashboard binds 127.0.0.1 (container-internal) unless a token is
# set — an unauthenticated 0.0.0.0 bind is refused by design. Without
# set — an unauthenticated all-interfaces bind is refused by design. Without
# this, the published port below answers nothing; the daemon itself and
# the in-container healthcheck still work. Reads/mutations from outside
# then require the X-OpenSwarm-Token header (/api/health stays open).
Expand Down
44 changes: 40 additions & 4 deletions src/support/tailscaleNetwork.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { networkInterfaces } from 'node:os';
import { isIPv6 } from 'node:net';

export function isLoopbackAddress(address: string | undefined): boolean {
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
Expand All @@ -17,12 +18,39 @@ export function authorizedTailscalePeers(): ReadonlySet<string> {
const raw = process.env.OPENSWARM_TAILSCALE_PEERS ?? '';
const peers = new Set<string>();
for (const entry of raw.split(',')) {
const peer = entry.trim().toLowerCase();
const peer = canonicalIpv6(entry.trim());
if (peer) peers.add(peer);
}
return peers;
}

/**
* One spelling per address.
*
* IPv6 has many textual forms of the same address and Node hands us RFC 5952
* canonical form on the wire, so a plain string compare rejects an operator
* who wrote the expanded form — or who copied the address out of a browser and
* kept the brackets. That failure is closed, but it presents as "still asked
* for a token", which is the exact symptom this whole path exists to remove.
*/
export function canonicalIpv6(value: string): string {
let text = value.trim().toLowerCase();
if (!text) return '';
// A URL-bar copy keeps the brackets; a zone suffix names a local interface
// and is not part of the address identity.
if (text.startsWith('[') && text.endsWith(']')) text = text.slice(1, -1);
const zone = text.indexOf('%');
if (zone !== -1) text = text.slice(0, zone);
if (!isIPv6(text)) return text;
// Round-trip through the platform parser: URL normalises to RFC 5952, the
// same form `remoteAddress` arrives in.
try {
return new URL(`http://[${text}]`).hostname.replace(/^\[|\]$/g, '');
} catch {
return text;
}
}

/**
* Checks if an address belongs to Tailscale's known ranges.
*
Expand All @@ -48,16 +76,24 @@ export function isTailscaleAddress(address: string | undefined): boolean {
*/
export function isAuthorizedTailscalePeer(address: string | undefined): boolean {
if (!address) return false;
const normalized = address.startsWith('::ffff:') ? address.slice(7) : address.toLowerCase();
const normalized = canonicalIpv6(address.startsWith('::ffff:') ? address.slice(7) : address);
if (!isTailscaleAddress(normalized)) return false;
return authorizedTailscalePeers().has(normalized);
}

/** This machine's Tailscale IPv4 address, detected dynamically. */
/**
* This machine's Tailscale address, detected dynamically.
*
* Previously this skipped every non-IPv4 interface and then asked
* `isTailscaleAddress`, which only ever accepts the IPv6 ULA prefix — two
* conditions that cannot both hold, so it always returned undefined and the
* startup banner fell through to "token required" even on a Tailscale-only
* daemon. That banner is what an operator reads to find out how to connect.
*/
export function detectTailscaleIP(): string | undefined {
for (const addresses of Object.values(networkInterfaces())) {
for (const address of addresses ?? []) {
if (address.family !== 'IPv4' || address.internal) continue;
if (address.internal) continue;
if (isTailscaleAddress(address.address)) return address.address;
}
}
Expand Down
43 changes: 38 additions & 5 deletions src/support/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1411,28 +1411,61 @@ export async function startWebServer(port: number = 3847): Promise<void> {
}
});

const trustTailscale = process.env.OPENSWARM_TRUST_TAILSCALE === 'true';
// '::' rather than '0.0.0.0', and the difference decides whether the
// Tailscale trust path is reachable at all. `isTailscaleAddress` trusts
// ONLY the IPv6 ULA prefix — CGNAT is refused on purpose, because
// 100.64.0.0/10 is shared with carriers and proves no identity. Binding
// IPv4-only left that the one trusted address shape nothing could connect
// to, so an operator who had allowlisted their peer exactly was still
// asked for a token on every remote request (AGT-4290).
//
// Node defaults to dual-stack (ipv6Only false), so IPv4 clients keep
// working and arrive as '::ffff:…'. The auth layer already expects that
// form: isLoopbackAddress lists '::ffff:127.0.0.1' and isTailscaleAddress
// strips the prefix before matching.
const ALL_INTERFACES = '::';
const listenHost = process.env.OPENSWARM_WEB_TOKEN?.trim() || trustTailscale ? ALL_INTERFACES : '127.0.0.1';
// A host with IPv6 disabled (ipv6.disable=1, or a container run with
// net.ipv6.conf.all.disable_ipv6=1) refuses an AF_INET6 bind outright.
// Without this, that error rejects, `startService` rethrows it, and the
// daemon does not start at all — strictly worse than the IPv4-only reach
// the '::' bind set out to widen. Retried once, then it is a real
// failure. (AGT-4290)
const IPV6_UNAVAILABLE = new Set(['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL', 'EPROTONOSUPPORT']);
let triedIpv4Fallback = false;

server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
console.warn(`Port ${port} is already in use, skipping web server...`);
server = null;
resolve();
} else if (!triedIpv4Fallback && listenHost === ALL_INTERFACES && IPV6_UNAVAILABLE.has(err.code ?? '')) {
triedIpv4Fallback = true;
console.warn(`[Web] IPv6 unavailable (${err.code}); falling back to 0.0.0.0. `
+ 'Tailscale trust requires IPv6 and will not work on this host.');
server?.listen(port, '0.0.0.0');
} else {
reject(err);
}
});

const trustTailscale = process.env.OPENSWARM_TRUST_TAILSCALE === 'true';
const listenHost = process.env.OPENSWARM_WEB_TOKEN?.trim() || trustTailscale ? '0.0.0.0' : '127.0.0.1';
server.listen(port, listenHost, () => {
const tailscaleIP = detectTailscaleIP();
console.log(`Web interface running at:`);
console.log(` - http://127.0.0.1:${port} (localhost)`);
if (listenHost === '0.0.0.0') {
if (listenHost === ALL_INTERFACES) {
const access = trustTailscale && !process.env.OPENSWARM_WEB_TOKEN?.trim()
? 'Tailscale only'
: 'token required';
if (tailscaleIP) console.log(` - http://${tailscaleIP}:${port} (${access})`);
else console.log(` - http://<this-host>:${port} (token required)`);
// The ULA is IPv6, so it needs brackets to be a usable URL — this line
// is what an operator copies into a browser.
if (tailscaleIP) console.log(` - http://[${tailscaleIP}]:${port} (${access})`);
// No Tailscale address found. Say what auth actually applies rather
// than always claiming a token: with trust on and no token configured
// there is no token to present, and that misdirection is what
// AGT-4290 was reported as.
else console.log(` - http://<this-host>:${port} (${access})`);
}
gitStatusPoller = startGitStatusPoller(() => Array.from(pinnedProjects));
startHealthCache();
Expand Down
108 changes: 103 additions & 5 deletions src/support/webAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,40 @@ import {

/** A request shaped like the parts these predicates read. */
function req(opts: {
origin?: string; host?: string; auth?: string; token?: string; remote?: string;
origin?: string; host?: string; auth?: string; token?: string; remote?: string; local?: string;
} = {}): IncomingMessage {
const headers: Record<string, string> = {};
if (opts.origin) headers.origin = opts.origin;
if (opts.host) headers.host = opts.host;
if (opts.auth) headers.authorization = opts.auth;
if (opts.token) headers['x-openswarm-token'] = opts.token;
return { headers, socket: { remoteAddress: opts.remote ?? '127.0.0.1' } } as unknown as IncomingMessage;
return {
headers,
// `localAddress` is the address the connection arrived ON. The Tailscale
// path checks it, so it is part of the request shape now.
socket: { remoteAddress: opts.remote ?? '127.0.0.1', localAddress: opts.local ?? '127.0.0.1' },
} as unknown as IncomingMessage;
}

const ORIGINAL = process.env.OPENSWARM_WEB_TOKEN;
beforeEach(() => { delete process.env.OPENSWARM_WEB_TOKEN; });
const ORIGINAL_TRUST = process.env.OPENSWARM_TRUST_TAILSCALE;
const ORIGINAL_PEERS = process.env.OPENSWARM_TAILSCALE_PEERS;
beforeEach(() => {
delete process.env.OPENSWARM_WEB_TOKEN;
delete process.env.OPENSWARM_TRUST_TAILSCALE;
delete process.env.OPENSWARM_TAILSCALE_PEERS;
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.OPENSWARM_WEB_TOKEN;
else process.env.OPENSWARM_WEB_TOKEN = ORIGINAL;
// Restore rather than delete: these are read from the environment at call
// time, so a leaked value changes what a later test file is even testing.
for (const [key, value] of [
['OPENSWARM_WEB_TOKEN', ORIGINAL],
['OPENSWARM_TRUST_TAILSCALE', ORIGINAL_TRUST],
['OPENSWARM_TAILSCALE_PEERS', ORIGINAL_PEERS],
] as const) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});

describe('isAllowedOrigin', () => {
Expand Down Expand Up @@ -211,6 +230,85 @@ describe('isAuthorizedMutation / isAuthorizedLocalRead', () => {
expect(isAuthorizedLocalRead(r)).toBe(false);
});

it('accepts an IPv4 client arriving mapped, which is how it arrives on a dual-stack bind', () => {
// The server binds '::' so the Tailscale ULA is reachable at all
// (AGT-4290). IPv4 clients then present as '::ffff:127.0.0.1' rather than
// '127.0.0.1'. If this stopped being authorized, the bind change would
// have locked every localhost browser out to fix the remote one.
const r = req({ remote: '::ffff:127.0.0.1', origin: 'http://localhost:3847', host: 'localhost:3847' });
expect(isAuthorizedLocalRead(r)).toBe(true);
expect(isAuthorizedMutation(r)).toBe(true);
});

it('lets an allowlisted Tailscale ULA peer read without a token', () => {
// The path AGT-4290 made reachable: trust on, peer named exactly, and the
// Origin matching its own Host.
process.env.OPENSWARM_TRUST_TAILSCALE = 'true';
process.env.OPENSWARM_TAILSCALE_PEERS = 'fd7a:115c:a1e0::b601:f469';
const r = req({
remote: 'fd7a:115c:a1e0::b601:f469',
local: 'fd7a:115c:a1e0::bc01:c823',
origin: 'http://[fd7a:115c:a1e0::bc01:c823]:3847',
host: '[fd7a:115c:a1e0::bc01:c823]:3847',
});
expect(isAuthorizedLocalRead(r)).toBe(true);
});

it('refuses an allowlisted ULA that did not arrive on our Tailscale address', () => {
// The exposure the dual-stack bind opens: a ULA carries no allocation
// authority, so a LAN neighbour can self-assign an allowlisted address.
// Requiring the local end of the socket to be a Tailscale address means
// the packet came to us through the tailnet, not to our LAN address with
// a forged source.
process.env.OPENSWARM_TRUST_TAILSCALE = 'true';
process.env.OPENSWARM_TAILSCALE_PEERS = 'fd7a:115c:a1e0::b601:f469';
const r = req({
remote: 'fd7a:115c:a1e0::b601:f469',
local: '192.168.50.43',
origin: 'http://192.168.50.43:3847',
host: '192.168.50.43:3847',
});
expect(isAuthorizedLocalRead(r)).toBe(false);
expect(isAuthorizedMutation(r)).toBe(false);
});

it('accepts a peer written in any of the spellings IPv6 allows', () => {
// Node hands us RFC 5952 on the wire. An operator who expanded the address
// by hand, or copied it out of a URL bar with brackets, was refused — and
// the symptom was "still asked for a token", the thing this fixes.
process.env.OPENSWARM_TRUST_TAILSCALE = 'true';
const r = () => req({
remote: 'fd7a:115c:a1e0::b601:f469',
local: 'fd7a:115c:a1e0::bc01:c823',
origin: 'http://[fd7a:115c:a1e0::bc01:c823]:3847',
host: '[fd7a:115c:a1e0::bc01:c823]:3847',
});
for (const spelling of [
'fd7a:115c:a1e0::b601:f469',
'fd7a:115c:a1e0:0:0:0:b601:f469',
'fd7a:115c:a1e0:0000:0000:0000:b601:f469',
'[fd7a:115c:a1e0::b601:f469]',
'FD7A:115C:A1E0::B601:F469',
]) {
process.env.OPENSWARM_TAILSCALE_PEERS = spelling;
expect(isAuthorizedLocalRead(r()), spelling).toBe(true);
}
});

it('still refuses CGNAT, even allowlisted — reaching IPv6 must not widen trust', () => {
// 100.64.0.0/10 is shared with carriers, so the address proves no
// identity. Binding dual-stack must not turn that judgement over.
process.env.OPENSWARM_TRUST_TAILSCALE = 'true';
process.env.OPENSWARM_TAILSCALE_PEERS = '100.123.244.103';
const r = req({
remote: '100.123.244.103',
origin: 'http://100.95.200.28:3847',
host: '100.95.200.28:3847',
});
expect(isAuthorizedLocalRead(r)).toBe(false);
expect(isAuthorizedMutation(r)).toBe(false);
});

it('refuse loopback when the Origin is not trusted', () => {
const r = req({ remote: '127.0.0.1', origin: 'http://evil.com', host: 'localhost:3847' });
expect(isAuthorizedMutation(r)).toBe(false);
Expand Down
17 changes: 16 additions & 1 deletion src/support/webAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// handling in a 1650-line file, and the surface most worth reading on its own.

import type { IncomingMessage, ServerResponse } from 'node:http';
import { isLoopbackAddress, isAuthorizedTailscalePeer } from './tailscaleNetwork.js';
import { isAuthorizedTailscalePeer, isLoopbackAddress, isTailscaleAddress } from './tailscaleNetwork.js';
import { isGraphQLRequest } from '../issues/graphql/server.js';

// CORS origin allowlist — hostname-strict match (no substring/prefix pitfalls)
Expand Down Expand Up @@ -47,6 +47,21 @@ export function isTrustedTailscaleRequest(req: IncomingMessage): boolean {
return process.env.OPENSWARM_TRUST_TAILSCALE === 'true'
// Range membership is not trust: the peer must be explicitly allowlisted.
&& isAuthorizedTailscalePeer(req.socket.remoteAddress)
// ...and the connection must have arrived ON our Tailscale address.
//
// A ULA carries no allocation authority — anyone can assign
// fd7a:115c:a1e0::… to their own interface. While the daemon bound IPv4
// only, that was moot because nothing could reach the ULA at all. Binding
// dual-stack (AGT-4290) makes it reachable over EVERY interface, so a
// neighbour on the LAN could self-assign an allowlisted address and be
// trusted. Requiring the local end of the socket to be a Tailscale address
// means the packet was addressed to us through the tailnet, not to our LAN
// address with a forged source.
//
// This is defence in depth, not proof: an on-link attacker who can also
// route our ULA prefix defeats it. Real proof needs the Tailscale control
// plane (node key / capability check), which this process does not talk to.
&& isTailscaleAddress(req.socket.localAddress)
&& isTrustedLocalOrigin(req);
}

Expand Down
Loading
Loading