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
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ __pycache__
!.env.example
runtime
secrets
# Codex OAuth tokens (compose's default CODEX_AUTH_PATH) and dashboard-added
# accounts: `COPY . .` would bake them into the image.
codex-auth*.json
codex-accounts
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ RUN test -f core/router.lua && test -f core/llm_policy.lua

EXPOSE 8080

CMD ["python", "serve.py", "--config", "config.internal.lua", "--default-profile", "agent", "--host", "0.0.0.0", "--port", "8080"]
# Default to the authenticated ingress. The router (serve.py) has no caller auth
# of its own and must only ever be reached through it, so every deployment that
# runs the router names that command explicitly (compose, k8s, CI).
CMD ["uvicorn", "auth_proxy:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers"]
9 changes: 9 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ extra care:
as critical and rotate immediately. This provider is opt-in (off by default).
- **Codex provider** uses an unofficial ChatGPT-subscription auth path and is
ToS-risky; see `docs/OPENAI-CODEX.md`.
- **Router admin endpoints** (`/x/wallet*`, `/x/providers`, `/x/provider-key`,
`/x/config/reload`, `/x/codex/reload`, `/x/calls`, `/x/sessions`,
`/x/session/*`) require `x-internal-secret` = `CONTROL_PLANE_INTERNAL_SECRET`
(the ingress sends it). With no secret configured only a loopback (same-pod)
caller is accepted; `ROUTER_ADMIN_ALLOW_UNAUTHENTICATED=1` restores the old
open behaviour for a trusted local stack only.
- **AntSeed buyer proxy** (`:8378`) spends the funded wallet: set
`ANTSEED_PROXY_TOKEN` (>= 32 chars) on the sidecar and the router so the port
requires a bearer. Unset keeps the legacy unauthenticated forwarder.
22 changes: 22 additions & 0 deletions antseed/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Shared-secret checks and bind addresses for the sidecar's HTTP listeners.
// Dependency-free so auth.test.js runs outside the sidecar image (control.js
// pulls in `pg`, which only exists inside it).
'use strict';
const { timingSafeEqual } = require('crypto');

// Constant-time: a `!==` compare leaks the token prefix through response timing
// to anyone who can reach the port. Length mismatch is not secret (it is fixed
// by the deployment), so it short-circuits.
function tokenMatches(presented, token) {
if (typeof presented !== 'string' || typeof token !== 'string' || !token) return false;
const a = Buffer.from(presented), b = Buffer.from(token);
return a.length === b.length && timingSafeEqual(a, b);
}

// Pod-local by default: in k8s the router shares the pod and dials 127.0.0.1.
// Compose runs the sidecar as its own service and opts in to 0.0.0.0 explicitly.
function listenHost(env, name) {
return (env && env[name]) || '127.0.0.1';
}

module.exports = { tokenMatches, listenHost };
24 changes: 24 additions & 0 deletions antseed/auth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { tokenMatches, listenHost } = require('./auth.js');

test('token compare is exact and total', () => {
const t = 'x'.repeat(32);
assert.equal(tokenMatches(t, t), true);
assert.equal(tokenMatches(t + 'y', t), false);
assert.equal(tokenMatches(t.slice(1), t), false);
assert.equal(tokenMatches(undefined, t), false);
assert.equal(tokenMatches(['a'], t), false);
assert.equal(tokenMatches('', ''), false); // unset token never matches
assert.equal(tokenMatches('é'.repeat(16), t), false); // multibyte, same char count
});

test('control server compares in constant time and binds pod-local by default', () => {
const src = fs.readFileSync(__dirname + '/control.js', 'utf8');
assert.doesNotMatch(src, /!==\s*TOKEN/);
assert.match(src, /tokenMatches\(req\.headers\['x-antseed-control-token'\], TOKEN\)/);
assert.match(src, /server\.listen\(PORT, HOST,/);
assert.equal(listenHost({}, 'ANTSEED_CONTROL_HOST'), '127.0.0.1');
assert.equal(listenHost({ ANTSEED_CONTROL_HOST: '0.0.0.0' }, 'ANTSEED_CONTROL_HOST'), '0.0.0.0');
});
11 changes: 7 additions & 4 deletions antseed/control.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
// -> THIS server (:8379) -> `antseed buyer <cmd>`
//
// Guarded by a shared token (ANTSEED_CONTROL_TOKEN); if unset the server does
// not start (feature disabled, router degrades to 503). Listens on the pod/
// container network only — never published. Subcommands are whitelisted and run
// not start (feature disabled, router degrades to 503). Binds 127.0.0.1 (the
// router shares the pod) unless ANTSEED_CONTROL_HOST says otherwise — compose,
// where the sidecar is its own service, sets 0.0.0.0. Never published. Subcommands are whitelisted and run
// via execFile with array args (no shell); amounts are strictly validated.
'use strict';
const http = require('http');
Expand All @@ -26,11 +27,13 @@ const { createQueue } = require('./queue.js');
// deserve a file. See antseed/broadcast.js.
const { classifyCliFailure } = require('./broadcast.js');
const { walletCommandArgs } = require('./cli-args.js');
const { tokenMatches, listenHost } = require('./auth.js');

const path = require('path');

const PORT = parseInt(process.env.ANTSEED_CONTROL_PORT || '8379', 10);
const TOKEN = process.env.ANTSEED_CONTROL_TOKEN || '';
const HOST = listenHost(process.env, 'ANTSEED_CONTROL_HOST');
const PID = process.env.ANTSEED_BUYER_PID || 'antseed';
const DEPOSIT_TIMEOUT_MS = 120000; // on-chain tx
const STATUS_TIMEOUT_MS = 30000;
Expand Down Expand Up @@ -179,7 +182,7 @@ function inconclusive(res, status, error) { // a broadcast cannot be ruled ou
}

const server = http.createServer(async (req, res) => {
if (req.headers['x-antseed-control-token'] !== TOKEN) {
if (!tokenMatches(req.headers['x-antseed-control-token'], TOKEN)) {
return refuse(res, 401, 'unauthorized');
}
const url = (req.url || '').split('?')[0];
Expand Down Expand Up @@ -283,4 +286,4 @@ const server = http.createServer(async (req, res) => {
return refuse(res, 404, 'not found');
});

server.listen(PORT, () => console.error('[control] listening on :' + PORT));
server.listen(PORT, HOST, () => console.error('[control] listening on ' + HOST + ':' + PORT));
11 changes: 10 additions & 1 deletion antseed/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,16 @@ antseed buyer start -p "$PORT_PROXY" \
PROXY_PID=$!

# Expose the localhost-only proxy on the container network for the router.
socat "TCP-LISTEN:${PORT_PUBLIC},fork,reuseaddr" "TCP:127.0.0.1:${PORT_PROXY}" &
# Every call through it spends the funded wallet, so with ANTSEED_PROXY_TOKEN
# set the network face demands that bearer (public-proxy.js). Unset keeps the
# legacy unauthenticated socat forwarder for routers that do not send it yet —
# then only network policy stands between the pod network and the wallet.
if [ -n "${ANTSEED_PROXY_TOKEN:-}" ]; then
node "$LIB/public-proxy.js" &
else
echo "[entrypoint] WARNING: ANTSEED_PROXY_TOKEN unset — :${PORT_PUBLIC} forwards to the funded buyer without auth" >&2
socat "TCP-LISTEN:${PORT_PUBLIC},fork,reuseaddr" "TCP:127.0.0.1:${PORT_PROXY}" &
fi

# Wallet control server (deposit/withdraw/status from the dashboard, no kubectl).
# Self-disables when ANTSEED_CONTROL_TOKEN is unset. See antseed/control.js.
Expand Down
43 changes: 43 additions & 0 deletions antseed/public-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Authenticated network face of the buyer proxy. The buyer binds 127.0.0.1:8377
// and spends the funded wallet on every call, so whatever exposes it beyond the
// pod must check a credential: with ANTSEED_PROXY_TOKEN set, entrypoint.sh runs
// THIS instead of the bare socat forwarder and every request needs
// `Authorization: Bearer <token>` (the router sends it via the antseed
// provider's auth). Transparent otherwise: any method/path, streamed both ways.
'use strict';
const http = require('http');
const { tokenMatches } = require('./auth.js');

function createPublicProxy({ token, proxyPort = 8377 }) {
if (!token || token.length < 32) throw new Error('ANTSEED_PROXY_TOKEN must have at least 32 characters');
return http.createServer((req, res) => {
if (!tokenMatches(req.headers.authorization, 'Bearer ' + token)) {
res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
return res.end(JSON.stringify({ error: 'unauthorized' }));
}
const headers = { ...req.headers };
delete headers.authorization; // the buyer proxy never needs our credential
const upstream = http.request({ hostname: '127.0.0.1', port: proxyPort,
path: req.url, method: req.method, headers }, response => {
res.writeHead(response.statusCode, response.headers);
response.on('error', () => res.destroy());
response.pipe(res);
});
upstream.on('error', () => {
if (!res.headersSent) {
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'buyer_unavailable' }));
} else res.destroy();
});
res.on('close', () => upstream.destroy());
req.pipe(upstream);
});
}

if (require.main === module) {
createPublicProxy({ token: process.env.ANTSEED_PROXY_TOKEN,
proxyPort: Number(process.env.ANTSEED_PROXY_PORT || 8377) })
.listen(Number(process.env.ANTSEED_PUBLIC_PORT || 8378), '0.0.0.0');
}

module.exports = { createPublicProxy };
45 changes: 45 additions & 0 deletions antseed/public-proxy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const fs = require('node:fs');
const { createPublicProxy } = require('./public-proxy.js');

test('network face of the buyer proxy demands the bearer and forwards transparently', async () => {
const received = [];
const buyer = http.createServer((req, res) => {
let body = '';
req.on('data', c => { body += c; });
req.on('end', () => {
received.push({ method: req.method, url: req.url, headers: req.headers, body });
res.setHeader('content-type', 'text/event-stream');
res.end('data: {"ok":true}\n\n');
});
});
await new Promise(resolve => buyer.listen(0, '127.0.0.1', resolve));
const token = 'p'.repeat(40);
assert.throws(() => createPublicProxy({ token: 'short' }));
const proxy = createPublicProxy({ token, proxyPort: buyer.address().port });
await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve));
const base = 'http://127.0.0.1:' + proxy.address().port;
try {
assert.equal((await fetch(base + '/v1/chat/completions', { method: 'POST', body: '{}' })).status, 401);
assert.equal((await fetch(base + '/v1/chat/completions', { method: 'POST', body: '{}',
headers: { authorization: 'Bearer ' + 'q'.repeat(40) } })).status, 401);
assert.equal(received.length, 0);
const r = await fetch(base + '/v1/chat/completions', { method: 'POST', body: '{"a":1}',
headers: { authorization: 'Bearer ' + token, 'x-antseed-pin-peer': 'peer-a' } });
assert.equal(r.status, 200);
assert.equal(await r.text(), 'data: {"ok":true}\n\n');
assert.equal(received[0].url, '/v1/chat/completions');
assert.equal(received[0].body, '{"a":1}');
assert.equal(received[0].headers['x-antseed-pin-peer'], 'peer-a');
assert.equal(received[0].headers.authorization, undefined);
} finally {
await Promise.all([new Promise(r => proxy.close(r)), new Promise(r => buyer.close(r))]);
}
});

test('entrypoint uses the authenticated forwarder whenever a proxy token is configured', () => {
const src = fs.readFileSync(__dirname + '/entrypoint.sh', 'utf8');
assert.match(src, /if \[ -n "\$\{ANTSEED_PROXY_TOKEN:-\}" \]; then\n\s+node "\$LIB\/public-proxy\.js" &\nelse/);
});
9 changes: 8 additions & 1 deletion antseed/write-market.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ function numOrNull(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
// peerId/service are seller-announced strings that end up as a DB key, an
// x-antseed-pin-peer header and dashboard text. Bound them: an oversized or
// control-char id is not one a real peer announces, so the row is dropped.
const PEER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
const SERVICE_RE = /^[A-Za-z0-9][A-Za-z0-9_.:@\/+-]{0,127}$/;
const validId = (re, v) => typeof v === "string" && re.test(v);
function posIntOrNull(v) { // maxConcurrency: positive int, else ungated (null)
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
Expand All @@ -43,7 +49,7 @@ if (fresh === null || typeof fresh !== "object" || !Array.isArray(fresh.peers))
// row per (peerId, service). No services -> the peer contributes no rows.
const rows = [];
for (const peer of fresh.peers) {
if (!peer || !peer.peerId) continue;
if (!peer || !validId(PEER_ID_RE, peer.peerId)) continue;
const maxc = posIntOrNull(peer.maxConcurrency);
const rep = numOrNull(peer.onChainReputationScore);
const lastSeen = numOrNull(peer.lastSeen);
Expand All @@ -54,6 +60,7 @@ for (const peer of fresh.peers) {
const lastReachedAt = numOrNull(peer.lastReachedAt);
for (const [provider, pricing] of Object.entries(peer.providerPricing || {})) {
for (const [service, sp] of Object.entries((pricing || {}).services || {})) {
if (!validId(SERVICE_RE, service)) continue;
const rawProtocols = peer.providerServiceApiProtocols?.[provider]?.services?.[service];
const protocols = Array.isArray(rawProtocols) && rawProtocols.every(p => typeof p === 'string')
? rawProtocols : null;
Expand Down
31 changes: 31 additions & 0 deletions antseed/write-market.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,34 @@ test('browse writer preserves decision protocols and supports older announcement
assert.equal(inserts[2].params[12], null);
assert.match(inserts[0].sql, /protocols=COALESCE\(EXCLUDED.protocols, peer_offers.protocols\)/);
});

test('browse writer drops peers and services with oversized or non-id names', async () => {
const queries = [];
const svc = name => ({ [name]: { inputUsdPerMillion: 1, outputUsdPerMillion: 2 } });
const peer = (id, services) => ({ peerId: id, providerPricing: { p: { services } } });
const input = JSON.stringify({ peers: [
peer('4668854ba3e8b094e6f48fbeb59cec1cfde162f2', { ...svc('anthropic/claude-opus-4.8'), ...svc('x'.repeat(129)),
...svc('evil\r\nx-injected: 1'), ...svc('qwen3-235b-a22b@fast') }),
peer('a'.repeat(129), svc('ok-service')),
peer('bad peer\n', svc('ok-service')),
peer({ nested: true }, svc('ok-service')),
] });
class Client {
async connect() {}
async end() {}
async query(sql, params) { queries.push({ sql, params }); }
}
const processMock = { env: {}, stderr: { write() {} }, exit(code) { throw new Error('exit ' + code); } };
vm.runInNewContext(fs.readFileSync(__dirname + '/write-market.js', 'utf8'), {
process: processMock, console,
require(name) {
if (name === 'pg') return { Client };
if (name === './db.js') return { pgConfig: () => ({}) };
if (name === 'fs') return { readFileSync: () => input };
throw new Error('unexpected import');
},
});
await new Promise(resolve => setImmediate(resolve));
const inserts = queries.filter(q => q.sql.startsWith('INSERT'));
assert.deepEqual(inserts.map(q => q.params[1]), ['anthropic/claude-opus-4.8', 'qwen3-235b-a22b@fast']);
});
Loading
Loading