151 lines
8.9 KiB
JavaScript
151 lines
8.9 KiB
JavaScript
// Bus-only in-memory management client. Values never enter receipts or errors.
|
|
import http from 'node:http';
|
|
import { ProxyError, refuse, sha256, regularBytes, validManagementKey } from './common.mjs';
|
|
import { lstatSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
const KEY_PATH = '/v8/management/config/access/api-keys';
|
|
const validKeys = value => Array.isArray(value) && value.length <= 100000 && value.every(k => typeof k === 'string' && k.length > 0 && k.length <= 4096 && !/[\r\n\x00]/.test(k));
|
|
export class ManagementClient {
|
|
#base; #key; #receipt; #timeout;
|
|
constructor({ baseUrl, key, receipt = () => {}, timeout = 5000 }) {
|
|
let u; try { u = new URL(baseUrl); } catch { refuse('proxy-client-invalid'); }
|
|
if (u.protocol !== 'http:' || u.hostname !== '127.0.0.1' || !u.port || Number(u.port) < 1024 || u.port === '8317'
|
|
|| u.username || u.password || u.pathname !== '/' || u.search || u.hash
|
|
|| !validManagementKey(key)
|
|
|| typeof receipt !== 'function' || !Number.isSafeInteger(timeout) || timeout <= 0) refuse('proxy-client-invalid');
|
|
this.#base = u.origin; this.#key = key; this.#receipt = receipt; this.#timeout = timeout;
|
|
}
|
|
#request(method, endpoint, payload, onStatus) {
|
|
// node:http addresses the fixed numeric loopback directly. An ambient fetch
|
|
// dispatcher or HTTP_PROXY cannot send the Authorization header elsewhere.
|
|
let deadline;
|
|
return new Promise((resolve, reject) => {
|
|
let received = false;
|
|
const req = http.request(this.#base + endpoint, {
|
|
method, headers: { Authorization: `Bearer ${this.#key}`, ...(payload !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
}, response => {
|
|
received = true; onStatus(response.statusCode);
|
|
const error = response.statusCode >= 300 && response.statusCode < 400 ? 'proxy-client-redirect'
|
|
: response.statusCode < 200 || response.statusCode >= 300 ? 'proxy-client-refused' : null;
|
|
if (error) { response.destroy(); reject(new ProxyError(error)); return; }
|
|
let size = 0; const chunks = [];
|
|
response.on('data', chunk => {
|
|
size += chunk.length;
|
|
if (size > 8 * 1024 * 1024) { response.destroy(); reject(new ProxyError('proxy-client-response')); }
|
|
else chunks.push(chunk);
|
|
});
|
|
response.on('aborted', () => reject(new ProxyError('proxy-client-response')));
|
|
response.on('error', () => reject(new ProxyError('proxy-client-response')));
|
|
response.on('end', () => {
|
|
try { resolve(JSON.parse(Buffer.concat(chunks))); }
|
|
catch { reject(new ProxyError('proxy-client-response')); }
|
|
});
|
|
});
|
|
req.on('error', () => reject(new ProxyError(received ? 'proxy-client-response' : 'proxy-client-down')));
|
|
deadline = setTimeout(() => { req.destroy(); reject(new ProxyError('proxy-client-down')); }, this.#timeout);
|
|
req.end(payload === undefined ? undefined : JSON.stringify(payload));
|
|
}).finally(() => clearTimeout(deadline));
|
|
}
|
|
async #call(method, keys) {
|
|
let status = 0, ids = [];
|
|
try {
|
|
const body = await this.#request(method, KEY_PATH, keys, value => { status = value; });
|
|
if (method === 'GET') {
|
|
if (!validKeys(body)) refuse('proxy-client-response');
|
|
ids = body.map(sha256).map(h => h.slice(0, 12));
|
|
return body;
|
|
}
|
|
if (!body || body.status !== 'ok') refuse('proxy-client-response');
|
|
ids = keys.map(sha256).map(h => h.slice(0, 12));
|
|
return { ok: true };
|
|
} finally { this.#receipt({ call: `${method} ${KEY_PATH}`, status, keyIds: ids }); }
|
|
}
|
|
getKeys() { return this.#call('GET'); }
|
|
putKeys(keys) { if (!validKeys(keys)) refuse('proxy-client-invalid'); return this.#call('PUT', keys); }
|
|
// Fixed management operations for the bus's native page. Queries contain login
|
|
// states; neither queries nor request/response bodies belong in a receipt.
|
|
async #page(method, endpoint, payload, expectedStatus = null) {
|
|
let status = 0;
|
|
try {
|
|
const answer = await this.#request(method, endpoint, payload, value => { status = value; });
|
|
if (expectedStatus !== null && status !== expectedStatus) refuse('proxy-client-refused');
|
|
return answer;
|
|
} catch (error) {
|
|
if (method === 'GET' && endpoint === '/v8/management/config/api-keys/claude' && status === 404) return [];
|
|
throw error;
|
|
}
|
|
finally { this.#receipt({ call: `${method} ${endpoint.split('?')[0]}`, status, keyIds: [] }); }
|
|
}
|
|
// The queue drains at most 100 events. Credential-bearing strings exist only
|
|
// inside this bounded read, never in a plug-in result, receipt, store or view.
|
|
async readUsageQueue() {
|
|
const events = await this.#page('GET', '/v8/management/observability/usage/queue?count=100');
|
|
if (!Array.isArray(events) || events.length > 100) refuse('proxy-client-response');
|
|
const string = v => typeof v === 'string' && v.length > 0 && v.length <= 4096 && !/[\r\n\x00]/.test(v);
|
|
const amount = v => v === undefined ? 0 : Number.isSafeInteger(v) && v >= 0 ? v : refuse('proxy-client-response');
|
|
return events.map(event => {
|
|
if (!event || !string(event.api_key) || !string(event.source) || typeof event.failed !== 'boolean'
|
|
|| !event.tokens || typeof event.tokens !== 'object' || Array.isArray(event.tokens)) refuse('proxy-client-response');
|
|
const statusCode = event.fail?.status_code ?? null;
|
|
if (statusCode !== null && (!Number.isSafeInteger(statusCode) || statusCode < 0 || statusCode > 599)) refuse('proxy-client-response');
|
|
return { keyId: sha256(event.api_key).slice(0, 12), accountId: sha256(event.source).slice(0, 12),
|
|
managed: event.api_key.startsWith('msk-'), failed: event.failed, statusCode,
|
|
input: amount(event.tokens.input_tokens), output: amount(event.tokens.output_tokens),
|
|
cacheWrite: amount(event.tokens.cache_creation_tokens), cacheRead: amount(event.tokens.cache_read_tokens) };
|
|
});
|
|
}
|
|
getCredentials() { return this.#page('GET', '/v8/management/credentials'); }
|
|
getCredentialModels(name) {
|
|
if (typeof name !== 'string' || !name || name.length > 512 || /[\r\n\x00]/.test(name)) refuse('proxy-client-invalid');
|
|
return this.#page('GET', `/v8/management/credentials/models?name=${encodeURIComponent(name)}`);
|
|
}
|
|
getUpstreamGroups() { return this.#page('GET', '/v8/management/config/api-keys/claude'); }
|
|
putUpstreamGroups(groups) {
|
|
// auth_index is derived response metadata, not writable provider config.
|
|
const payload = groups.map(group => ({ ...group, keys: group.keys.map(({ auth_index, ...key }) => key) }));
|
|
return this.#page('PUT', '/v8/management/config/api-keys/claude', payload, 200);
|
|
}
|
|
patchCredential(name, priority) { return this.#page('PATCH', '/v8/management/credentials/fields', { name, priority }, 200); }
|
|
oauthStart(provider) {
|
|
if (!['claude', 'codex'].includes(provider)) refuse('proxy-client-invalid');
|
|
return this.#page('GET', `/v8/management/oauth/auth-url?provider=${provider}`);
|
|
}
|
|
oauthCallback(redirect_url) { return this.#page('POST', '/v8/management/oauth/callback', { redirect_url }); }
|
|
oauthStatus(state) { return this.#page('GET', `/v8/management/oauth/status?state=${encodeURIComponent(state)}`); }
|
|
oauthCancel(state) { return this.#page('DELETE', `/v8/management/oauth/session?state=${encodeURIComponent(state)}`); }
|
|
// Client-key authentication only. Never send the management key to the model route,
|
|
// follow a Location, consume/log the response body or trust an ambient dispatcher.
|
|
async probeKey(key, { timeout = this.#timeout } = {}) {
|
|
if (!validManagementKey(key) || !Number.isFinite(timeout) || timeout <= 0) refuse('proxy-client-invalid');
|
|
let timer, status = 0;
|
|
try {
|
|
return await new Promise((resolve, reject) => {
|
|
const req = http.request(this.#base + '/v1/models', { method: 'GET', headers: { Authorization: `Bearer ${key}` } }, response => {
|
|
status = response.statusCode;
|
|
response.destroy();
|
|
resolve(status);
|
|
});
|
|
req.on('error', () => reject(new ProxyError('proxy-down')));
|
|
timer = setTimeout(() => { req.destroy(); reject(new ProxyError('proxy-down')); }, timeout);
|
|
req.end();
|
|
});
|
|
} finally {
|
|
clearTimeout(timer);
|
|
this.#receipt({ call: 'GET /v1/models', status, keyIds: [sha256(key).slice(0, 12)] });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trusted bus boot only: no CLI/Console/session exports this value or client.
|
|
export async function loadManagementClient({ configDir, proxy, receipt }) {
|
|
const file = join(configDir, 'secrets', 'proxy-management.key');
|
|
let key;
|
|
try {
|
|
const stat = lstatSync(file);
|
|
if ((stat.mode & 0o777) !== 0o600 || !stat.isFile() || stat.isSymbolicLink()) refuse('proxy-management-key-invalid');
|
|
key = regularBytes(file, 'proxy-management-key-invalid').toString('utf8');
|
|
if (!validManagementKey(key)) refuse('proxy-management-key-invalid');
|
|
} catch { refuse('proxy-management-key-invalid'); }
|
|
return new ManagementClient({ baseUrl: `http://127.0.0.1:${proxy.port}`, key, receipt });
|
|
}
|