fix: apply security and correctness review findings across both stacks

Legacy panel:
- upstream body reads now carry their own deadline and a 10 MB byte budget;
  a stalled modem can no longer hang /api/status fan-out forever nor OOM
  the proxy (request headers alone had the timeout, bodies had none)
- add X-Frame-Options DENY / CSP frame-ancestors none / nosniff; the panel
  (delete-instance and confirmed-write dialogs) is no longer clickjackable
- only send a JSON content-type when the API console request has a body, so
  payload-less dangerous writes stop failing with 400 and burning the
  one-use confirmation token
- register a form-urlencoded parser (the proxy branch was unreachable) and
  drop the multipart parser that buffered up to 60 MB before rejecting;
  bodyLimit drops to 2 MB; framework-level 415 keeps the stable error body
- remove /api/sms/send from readable paths: GET bypassed the write
  confirmation for a send endpoint
- /api/instances/:id/login maps upstream failures to a stable 502 instead
  of leaking raw error text
- guard MULTI_SIMADMIN_TIMEOUT_MS parsing (NaN aborted every request);
  prune dead code (buildClients, cookie expando no-op)

Control plane:
- deleting a notification channel detaches it from rules instead of leaving
  dangling ids that made every referencing rule unreadable and silently
  dropped future notifications; rule reads tolerate unknown ids
- startup sweep resets notification_queue rows stranded in 'sending' by a
  crash (mirrors the sms outbox sweep); terminal outbox rows are pruned on
  the retention timer
- /api/v1/metrics no longer emits operator-assigned node names on the
  session-free scrape; login limiter map is bounded and pruned; Secure
  cookie honors the gateway-declared x-forwarded-proto
- webhook delivery sets redirect: manual (signed payloads are not replayed)
- SMTP envelope sender is validated against CR/LF smuggling
- scheduled reboots with delaySeconds != 3 fail fast at the dispatcher with
  a clear reason instead of burning every retry; contract narrowed to the
  pinned baseline
This commit is contained in:
chick
2026-09-07 01:57:47 +08:00
parent 236e78327c
commit 2977f75129
16 changed files with 168 additions and 26 deletions
@@ -114,6 +114,14 @@ export class ScheduledOperationDispatcher {
const schema = operationSchemas[task.operationType]; const schema = operationSchemas[task.operationType];
if (!operationId || !schema) if (!operationId || !schema)
return { outcome: 'needs-attention', reason: 'Unsupported scheduled operation', jobIds: [] }; return { outcome: 'needs-attention', reason: 'Unsupported scheduled operation', jobIds: [] };
// The execution baseline pins delay_seconds to 3; anything else could never
// succeed, so refuse up front instead of exhausting the retry policy.
if (task.operationType === 'reboot-system' && (task.delaySeconds ?? 3) !== 3)
return {
outcome: 'needs-attention',
reason: 'Scheduled reboots only support the pinned 3-second delay',
jobIds: [],
};
const fields = const fields =
task.operationType === 'reboot-system' task.operationType === 'reboot-system'
? [ ? [
@@ -559,7 +559,28 @@ export class CentralNotificationService {
async deleteChannel(id: string): Promise<void> { async deleteChannel(id: string): Promise<void> {
const channel = this.getChannel(id); const channel = this.getChannel(id);
this.#db.prepare('DELETE FROM notification_channels WHERE id=?').run(id); this.#db.transaction(() => {
this.#db.prepare('DELETE FROM notification_channels WHERE id=?').run(id);
// Rules keep channel ids in a JSON column, so no FK cascade applies; a
// dangling id would make every referencing rule unreadable and drop
// future notifications on the floor.
const rules = this.#db
.prepare('SELECT id, channel_ids_json FROM notification_rules')
.all() as Array<{ id: string; channel_ids_json: string }>;
for (const rule of rules) {
let ids: unknown;
try {
ids = JSON.parse(String(rule.channel_ids_json));
} catch {
continue;
}
if (!Array.isArray(ids) || !ids.includes(id)) continue;
const next = JSON.stringify(ids.filter((entry) => entry !== id));
this.#db
.prepare('UPDATE notification_rules SET channel_ids_json=?, updated_at=? WHERE id=?')
.run(next, this.#now(), rule.id);
}
})();
if (channel.secretReference) if (channel.secretReference)
await this.#options.store.delete(channel.secretReference).catch(() => false); await this.#options.store.delete(channel.secretReference).catch(() => false);
} }
@@ -1202,6 +1223,19 @@ export class CentralNotificationService {
return { processed: succeeded + failed, succeeded, failed }; return { processed: succeeded + failed, succeeded, failed };
} }
/** Startup sweep: rows claimed as 'sending' before a crash would never drain again. */
reconcileInterrupted(): number {
return Number(
this.#db
.prepare(
`UPDATE notification_queue
SET status='pending',updated_at=?
WHERE status='sending'`,
)
.run(this.#now(), ).changes,
);
}
retryQueueItem(id: string): void { retryQueueItem(id: string): void {
const result = this.#db const result = this.#db
.prepare( .prepare(
@@ -1338,7 +1372,12 @@ export class CentralNotificationService {
#queueItem(row: Record<string, unknown>): NotificationQueueItem { #queueItem(row: Record<string, unknown>): NotificationQueueItem {
const status = String(row.status) as NotificationQueueItem['status']; const status = String(row.status) as NotificationQueueItem['status'];
const attempts = Number(row.attempts ?? 0); const attempts = Number(row.attempts ?? 0);
const payload = record(JSON.parse(String(row.payload_json))) ?? {}; let payload: Record<string, unknown> = {};
try {
payload = record(JSON.parse(String(row.payload_json))) ?? {};
} catch {
payload = {};
}
const deliveredAt = const deliveredAt =
typeof row.delivered_at === 'string' && row.delivered_at ? row.delivered_at : undefined; typeof row.delivered_at === 'string' && row.delivered_at ? row.delivered_at : undefined;
const ruleId = typeof row.rule_id === 'string' && row.rule_id ? row.rule_id : undefined; const ruleId = typeof row.rule_id === 'string' && row.rule_id ? row.rule_id : undefined;
@@ -1560,7 +1599,12 @@ export class CentralNotificationService {
enabled: row.enabled === 1, enabled: row.enabled === 1,
condition: parseJson(String(row.condition_json), this.#condition.bind(this)), condition: parseJson(String(row.condition_json), this.#condition.bind(this)),
scope: parseJson(String(row.scope_json), this.#scope.bind(this)), scope: parseJson(String(row.scope_json), this.#scope.bind(this)),
channels: Object.freeze(channelIds.map((id) => this.getChannel(id))), channels: Object.freeze(
channelIds.flatMap((id) => {
const row = this.#row(`SELECT * FROM notification_channels WHERE id = ?`, id);
return row ? [this.#channel(row)] : [];
}),
),
templates: parseJson(String(row.templates_json), this.#templates.bind(this)), templates: parseJson(String(row.templates_json), this.#templates.bind(this)),
rateLimit: parseJson(String(row.rate_limit_json), validateRateLimit), rateLimit: parseJson(String(row.rate_limit_json), validateRateLimit),
quietHours: parseJson(String(row.quiet_hours_json), validateQuietHours), quietHours: parseJson(String(row.quiet_hours_json), validateQuietHours),
@@ -160,6 +160,8 @@ async function defaultRequester(request: HttpRequest, timeoutMs: number): Promis
headers: request.headers, headers: request.headers,
...(request.body === undefined ? {} : { body: request.body }), ...(request.body === undefined ? {} : { body: request.body }),
signal: AbortSignal.timeout(timeoutMs), signal: AbortSignal.timeout(timeoutMs),
// A signed payload must never be replayed to a redirect target.
redirect: 'manual',
}); });
const text = (await response.text()).slice(0, MAX_RESPONSE_BYTES); const text = (await response.text()).slice(0, MAX_RESPONSE_BYTES);
return { status: response.status, body: text }; return { status: response.status, body: text };
@@ -220,6 +220,9 @@ export async function sendEmailNotification(
const receivers = splitAddresses(configText(input.config, 'receiver_addresses')); const receivers = splitAddresses(configText(input.config, 'receiver_addresses'));
if (!host) return { ok: false, detail: '缺少 SMTP 地址' }; if (!host) return { ok: false, detail: '缺少 SMTP 地址' };
if (!sender) return { ok: false, detail: '缺少发件地址' }; if (!sender) return { ok: false, detail: '缺少发件地址' };
// Envelope commands are line-framed; reject anything that could break the frame.
if (!/^[^\s@<>,;:"']+@[^\s@<>,;:"']+$/u.test(sender))
return { ok: false, detail: '发件地址无效' };
if (receivers.length === 0) return { ok: false, detail: '缺少有效的收件地址' }; if (receivers.length === 0) return { ok: false, detail: '缺少有效的收件地址' };
const port = configNumber(input.config, 'smtp_port', 465); const port = configNumber(input.config, 'smtp_port', 465);
const security = configText(input.config, 'smtp_security') || 'implicit_tls'; const security = configText(input.config, 'smtp_security') || 'implicit_tls';
@@ -80,7 +80,9 @@ describe('MetricsService', () => {
const text = metrics.render(); const text = metrics.render();
expect(text).toContain('multi_simadmin_nodes_total 2'); expect(text).toContain('multi_simadmin_nodes_total 2');
expect(text).toContain('multi_simadmin_node_enabled{node="node-b"} 0'); expect(text).toContain('multi_simadmin_node_enabled{node="node-b"} 0');
expect(text).toContain('multi_simadmin_node_info{node="node-a",name="机房 A"} 1'); expect(text).toContain('multi_simadmin_node_info{node="node-a"} 1');
// Operator-assigned names stay out of the session-free exposition entirely.
expect(text).not.toContain('机房 A');
expect(text).toContain('multi_simadmin_node_probe_total{node="node-a",outcome="success"} 38'); expect(text).toContain('multi_simadmin_node_probe_total{node="node-a",outcome="success"} 38');
expect(text).toContain('multi_simadmin_node_availability_ratio{node="node-a"} 0.950'); expect(text).toContain('multi_simadmin_node_availability_ratio{node="node-a"} 0.950');
expect(text).toContain('multi_simadmin_sms_outbox{status="failed"} 1'); expect(text).toContain('multi_simadmin_sms_outbox{status="failed"} 1');
@@ -108,14 +110,15 @@ describe('MetricsService', () => {
expect(text).toContain('multi_simadmin_identity_pending_total 2'); expect(text).toContain('multi_simadmin_identity_pending_total 2');
}); });
it('escapes label values so a node name cannot forge a series', () => { it('never emits a node name, so a hostile name cannot forge a series', () => {
const { db: database, metrics } = fixture(); const { db: database, metrics } = fixture();
seedNode(database, 'node-a', 'evil" name\nwith break'); seedNode(database, 'node-a', 'evil" name\nwith break');
const text = metrics.render(); const text = metrics.render();
expect(text).toContain('name="evil\\" name\\nwith break"'); // Names are excluded from the session-free exposition outright; escaping a
// The injected newline must not become its own sample line. // label is not the right tool for untrusted operator input.
expect(text.split('\n').filter((line) => line.includes('with break'))).toHaveLength(1); expect(text).not.toContain('evil"');
expect(text.split('\n').filter((line) => line.includes('with break'))).toHaveLength(0);
}); });
it('emits a valid exposition: help and type precede every series', () => { it('emits a valid exposition: help and type precede every series', () => {
@@ -77,9 +77,9 @@ export class MetricsService {
]), ]),
); );
const info = nodes.map( // Operator-assigned names can leak hostnames/locations to unauthenticated
(node) => `${PREFIX}_node_info{node="${label(node.id)}",name="${label(node.name)}"} 1`, // scrapers; the series intentionally stays opaque to node ids.
); const info = nodes.map((node) => `${PREFIX}_node_info{node="${label(node.id)}"} 1`);
lines.push( lines.push(
...sample(`${PREFIX}_node_info`, 'Registered nodes and their addresses.', 'gauge', info), ...sample(`${PREFIX}_node_info`, 'Registered nodes and their addresses.', 'gauge', info),
...sample(`${PREFIX}_nodes_total`, 'Number of registered nodes.', 'gauge', [ ...sample(`${PREFIX}_nodes_total`, 'Number of registered nodes.', 'gauge', [
+3
View File
@@ -107,6 +107,9 @@ function trustedUpstreamHeaders(
delete sanitized[GATEWAY_CLIENT_IP_HEADER]; delete sanitized[GATEWAY_CLIENT_IP_HEADER];
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken; if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
if (clientIp !== undefined) sanitized[GATEWAY_CLIENT_IP_HEADER] = clientIp; if (clientIp !== undefined) sanitized[GATEWAY_CLIENT_IP_HEADER] = clientIp;
// The browser connection's protocol is the gateway's to declare; the API uses
// it to decide whether session cookies may carry the Secure flag.
sanitized['x-forwarded-proto'] = (headers['x-forwarded-proto'] as string | undefined) ?? 'http';
return sanitized; return sanitized;
} }
+5
View File
@@ -320,6 +320,7 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
...(options.now ? { now: options.now } : {}), ...(options.now ? { now: options.now } : {}),
}); });
smsOutbox.reconcileInterrupted(); smsOutbox.reconcileInterrupted();
centralNotifications.reconcileInterrupted();
// Removal follows the hub rule the operator already knows: an online device is told to release // Removal follows the hub rule the operator already knows: an online device is told to release
// its own binding first and a refusal keeps the record, while a device that cannot be reached // its own binding first and a refusal keeps the record, while a device that cannot be reached
// at all is forgotten locally. The closure is late-bound because the device action service is // at all is forgotten locally. The closure is late-bound because the device action service is
@@ -581,6 +582,10 @@ export function buildControlPlaneApp(options: ControlPlaneOptions): ControlPlane
void Promise.resolve() void Promise.resolve()
.then(() => centralNotifications.pruneLogs()) .then(() => centralNotifications.pruneLogs())
.catch(() => undefined); .catch(() => undefined);
// Delivered and abandoned outbox rows otherwise accumulate forever.
void Promise.resolve()
.then(() => smsOutbox.prune(30 * 24 * 60 * 60 * 1000))
.catch(() => undefined);
}, logPruneIntervalMs) }, logPruneIntervalMs)
: undefined; : undefined;
// Scheduled component backups: the tick is cheap and only writes when a period is overdue. // Scheduled component backups: the tick is cheap and only writes when a period is overdue.
@@ -33,7 +33,8 @@ const cookie = (token: string, secure: boolean): string =>
`${CONSOLE_SESSION_COOKIE}=${token}; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${MAX_AGE_SECONDS}`; `${CONSOLE_SESSION_COOKIE}=${token}; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=${MAX_AGE_SECONDS}`;
const clearCookie = (secure: boolean): string => const clearCookie = (secure: boolean): string =>
`${CONSOLE_SESSION_COOKIE}=; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=0`; `${CONSOLE_SESSION_COOKIE}=; Path=${COOKIE_PATH}; HttpOnly; SameSite=Strict${secure ? '; Secure' : ''}; Max-Age=0`;
const usesSecureCookie = (request: FastifyRequest): boolean => request.protocol === 'https'; const usesSecureCookie = (request: FastifyRequest): boolean =>
request.protocol === 'https' || request.headers['x-forwarded-proto'] === 'https';
const problem = (request: FastifyRequest, status: number, code: string, detail: string) => ({ const problem = (request: FastifyRequest, status: number, code: string, detail: string) => ({
type: 'about:blank', type: 'about:blank',
@@ -83,8 +84,26 @@ const loginSchema = {
properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } }, properties: { password: { type: 'string', minLength: 1, maxLength: 1024 } },
} as const; } as const;
const LOGIN_BUCKETS_MAX = 10_000;
export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void { export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthService): void {
const loginAttempts = new Map<string, { count: number; resetAt: number }>(); const loginAttempts = new Map<string, { count: number; resetAt: number }>();
const rememberAttempt = (key: string, attempt: { count: number; resetAt: number }): void => {
// Direct-to-API callers can send arbitrary client-ip values; cap the map so
// bucket keys cannot grow without bound.
if (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
const now = Date.now();
for (const [existing, value] of loginAttempts) {
if (value.resetAt <= now) loginAttempts.delete(existing);
}
while (loginAttempts.size >= LOGIN_BUCKETS_MAX) {
const oldest = loginAttempts.keys().next().value;
if (oldest === undefined) break;
loginAttempts.delete(oldest);
}
}
loginAttempts.set(key, attempt);
};
app.addHook('preHandler', async (request, reply) => { app.addHook('preHandler', async (request, reply) => {
const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url; const pathname = request.url.split(/[?#]/u, 1)[0] ?? request.url;
if (!auth.isProtectedPath(pathname)) return; if (!auth.isProtectedPath(pathname)) return;
@@ -121,7 +140,7 @@ export function registerConsoleAuth(app: FastifyInstance, auth: ConsoleAuthServi
attempts && attempts.resetAt > now attempts && attempts.resetAt > now
? attempts ? attempts
: { count: 0, resetAt: now + LOGIN_WINDOW_MS }; : { count: 0, resetAt: now + LOGIN_WINDOW_MS };
loginAttempts.set(key, { count: active.count + 1, resetAt: active.resetAt }); rememberAttempt(key, { count: active.count + 1, resetAt: active.resetAt });
return handle(request, reply, error); return handle(request, reply, error);
} }
}); });
+3 -1
View File
@@ -417,10 +417,12 @@ export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduled
throw new TypeError('effectiveEndAt must be later than effectiveStartAt'); throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
if (source.enabled !== undefined && typeof source.enabled !== 'boolean') if (source.enabled !== undefined && typeof source.enabled !== 'boolean')
throw new TypeError('enabled must be boolean'); throw new TypeError('enabled must be boolean');
// The upstream execution baseline pins reboot delay_seconds to 3; accepting a
// wider range here would only produce tasks that can never run.
const delaySeconds = const delaySeconds =
source.delaySeconds === undefined source.delaySeconds === undefined
? undefined ? undefined
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600); : numberInRange(source.delaySeconds, 'delaySeconds', 3, 3);
if (delaySeconds !== undefined && operationType !== 'reboot-system') if (delaySeconds !== undefined && operationType !== 'reboot-system')
throw new TypeError('delaySeconds is only valid for reboot-system'); throw new TypeError('delaySeconds is only valid for reboot-system');
return { return {
+1 -1
View File
@@ -100,7 +100,7 @@ async function runEndpoint(confirmed=false){
} catch(error){ persistentError(`请求参数无效:${error.message||error}`); return } } catch(error){ persistentError(`请求参数无效:${error.message||error}`); return }
const {owner,method,path:endpoint,body}=draft, btn=$('runEndpoint'), token=requests.next('api-console'); if(btn.disabled)return const {owner,method,path:endpoint,body}=draft, btn=$('runEndpoint'), token=requests.next('api-console'); if(btn.disabled)return
btn.dataset.idleLabel='发送请求'; setPending(btn,true,'发送中…'); $('rawOutput').textContent='请求进行中…'; persistentError(''); const started=performance.now() btn.dataset.idleLabel='发送请求'; setPending(btn,true,'发送中…'); $('rawOutput').textContent='请求进行中…'; persistentError(''); const started=performance.now()
try{ const init={method}; if(isWriteMethod(method)){ const prepared=await api.prepareConfirmation({instanceId:owner,method,path:endpoint,body}); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; init.headers={'content-type':'application/json','x-confirmation-token':prepared.token}; if(body!==undefined)init.body=JSON.stringify(body) } const res=await api.proxy(owner,endpoint,init), text=await res.text(); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; const elapsed=Math.round(performance.now()-started); outputText=text; try{outputPrettyText=JSON.stringify(JSON.parse(text),null,2)}catch{outputPrettyText=text} $('rawOutput').textContent=`HTTP ${res.status} ${res.statusText} · ${elapsed}ms · ${draft.method} ${draft.path}\n\n${$('outputPretty').getAttribute('aria-pressed')==='true'?outputPrettyText:outputText}`; if(!res.ok)persistentError(`API 请求失败:HTTP ${res.status} ${res.statusText}\n${text}`) }catch(error){ if(requests.isCurrent('api-console',token)&&owner===activeId){$('rawOutput').textContent=error.message||String(error);persistentError(`API 请求失败:${error.message||error}`)} }finally{ if(requests.isCurrent('api-console',token)) setPending(btn,false,'发送中…') } try{ const init={method}; if(isWriteMethod(method)){ const prepared=await api.prepareConfirmation({instanceId:owner,method,path:endpoint,body}); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; init.headers={'x-confirmation-token':prepared.token}; if(body!==undefined){ init.body=JSON.stringify(body); init.headers['content-type']='application/json' } } const res=await api.proxy(owner,endpoint,init), text=await res.text(); if(!requests.isCurrent('api-console',token)||owner!==activeId)return; const elapsed=Math.round(performance.now()-started); outputText=text; try{outputPrettyText=JSON.stringify(JSON.parse(text),null,2)}catch{outputPrettyText=text} $('rawOutput').textContent=`HTTP ${res.status} ${res.statusText} · ${elapsed}ms · ${draft.method} ${draft.path}\n\n${$('outputPretty').getAttribute('aria-pressed')==='true'?outputPrettyText:outputText}`; if(!res.ok)persistentError(`API 请求失败:HTTP ${res.status} ${res.statusText}\n${text}`) }catch(error){ if(requests.isCurrent('api-console',token)&&owner===activeId){$('rawOutput').textContent=error.message||String(error);persistentError(`API 请求失败:${error.message||error}`)} }finally{ if(requests.isCurrent('api-console',token)) setPending(btn,false,'发送中…') }
} }
async function refreshStatus({silent=false}={}){ const btn=$('refreshBtn'), token=requests.next('fleet-status'); btn.disabled=true; try{const data=await api.status(); if(!requests.isCurrent('fleet-status',token))return; statuses=new Map((data.instances||[]).map(x=>[x.id,x]));lastSuccessfulRefresh=new Date();$('staleBanner').hidden=true;persistentError('');renderHomeCards();if(activeId)renderDetail();if(!silent)showToast(`已刷新 ${statuses.size} 个设备`)}catch(error){if(!requests.isCurrent('fleet-status',token))return;$('staleBanner').textContent=`刷新失败,当前显示上次成功数据。上次成功:${lastSuccessfulRefresh?lastSuccessfulRefresh.toLocaleString():'尚无'}${error.message}`;$('staleBanner').hidden=false;persistentError(`状态刷新失败:${error.message}`)}finally{if(requests.isCurrent('fleet-status',token))btn.disabled=false} } async function refreshStatus({silent=false}={}){ const btn=$('refreshBtn'), token=requests.next('fleet-status'); btn.disabled=true; try{const data=await api.status(); if(!requests.isCurrent('fleet-status',token))return; statuses=new Map((data.instances||[]).map(x=>[x.id,x]));lastSuccessfulRefresh=new Date();$('staleBanner').hidden=true;persistentError('');renderHomeCards();if(activeId)renderDetail();if(!silent)showToast(`已刷新 ${statuses.size} 个设备`)}catch(error){if(!requests.isCurrent('fleet-status',token))return;$('staleBanner').textContent=`刷新失败,当前显示上次成功数据。上次成功:${lastSuccessfulRefresh?lastSuccessfulRefresh.toLocaleString():'尚无'}${error.message}`;$('staleBanner').hidden=false;persistentError(`状态刷新失败:${error.message}`)}finally{if(requests.isCurrent('fleet-status',token))btn.disabled=false} }
async function loadConfig(){ const [data,cat]=await Promise.all([api.config(),api.catalog().catch(()=>({groups:[]}))]);instances=data.instances||[];$('configPath').textContent=data.configPath||'config.json';catalog=cat.groups||[];renderEndpointList();const saved=localStorage.getItem('multi-simadmin-active');activeId=saved&&instances.some(x=>x.id===saved)?saved:null;renderHomeCards() } async function loadConfig(){ const [data,cat]=await Promise.all([api.config(),api.catalog().catch(()=>({groups:[]}))]);instances=data.instances||[];$('configPath').textContent=data.configPath||'config.json';catalog=cat.groups||[];renderEndpointList();const saved=localStorage.getItem('multi-simadmin-active');activeId=saved&&instances.some(x=>x.id===saved)?saved:null;renderHomeCards() }
+19 -3
View File
@@ -24,7 +24,7 @@ const CATALOG = [
export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100, proxyPolicy = defaultProxyPolicy, confirmationOptions = {} } = {}) { export async function buildApp({ configStore, clientRegistry, logger = false, staticFiles = true, publicDir = path.join(ROOT, 'public'), reconcileRetryMs = 100, proxyPolicy = defaultProxyPolicy, confirmationOptions = {} } = {}) {
if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required') if (!configStore || !clientRegistry) throw new TypeError('configStore and clientRegistry are required')
const app = Fastify({ logger, bodyLimit: 60 * 1024 * 1024 }) const app = Fastify({ logger, bodyLimit: 2 * 1024 * 1024 })
const parseAuthority = raw => { const parseAuthority = raw => {
const value = String(raw || '').trim().toLowerCase() const value = String(raw || '').trim().toLowerCase()
if (!value || /[\s/@]/.test(value)) return null if (!value || /[\s/@]/.test(value)) return null
@@ -46,6 +46,11 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
const configuredRawHost = String(configStore.snapshot.server.host).replace(/^\[|\]$/g, '').toLowerCase() const configuredRawHost = String(configStore.snapshot.server.host).replace(/^\[|\]$/g, '').toLowerCase()
const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost const configuredHost = isIP(configuredRawHost) === 6 ? '::1' : configuredRawHost
const configuredPort = String(configStore.snapshot.server.port) const configuredPort = String(configStore.snapshot.server.port)
app.addHook('onRequest', async (_request, reply) => {
reply.header('x-frame-options', 'DENY')
reply.header('content-security-policy', "frame-ancestors 'none'")
reply.header('x-content-type-options', 'nosniff')
})
app.addHook('onRequest', async (request, reply) => { app.addHook('onRequest', async (request, reply) => {
const authority=loopbackAuthority(request.headers.host) const authority=loopbackAuthority(request.headers.host)
const injectDefault = request.headers.host === 'localhost:80' && request.raw.socket?.localPort == null const injectDefault = request.headers.host === 'localhost:80' && request.raw.socket?.localPort == null
@@ -58,7 +63,14 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
} }
}) })
app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body)) app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (_request, body, done) => done(null, body))
app.addContentTypeParser(/^multipart\//, { parseAs: 'buffer' }, (_request, body, done) => done(null, body)) app.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => done(null, body))
// Unparsable content types (e.g. multipart) must fail with the same stable body
// the proxy policy would have produced, without buffering the payload first.
app.setErrorHandler((error, _request, reply) => {
if (error?.code === 'FST_ERR_CTP_INVALID_MEDIA_TYPE') return reply.code(415).send({ error: 'unsupported proxy content type' })
if (error?.code === 'FST_ERR_CTP_EMPTY_JSON_BODY') return reply.code(400).send({ error: 'empty JSON body' })
reply.send(error)
})
if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' }) if (staticFiles) await app.register(fastifyStatic, { root: publicDir, prefix: '/' })
const findClient = (id, reply) => { const findClient = (id, reply) => {
const client = clientRegistry.get(id) const client = clientRegistry.get(id)
@@ -101,7 +113,11 @@ export async function buildApp({ configStore, clientRegistry, logger = false, st
app.post('/api/instances/:id/login', async (request, reply) => { app.post('/api/instances/:id/login', async (request, reply) => {
const client = findClient(request.params.id, reply); if (!client) return const client = findClient(request.params.id, reply); if (!client) return
const supplied = Object.hasOwn(request.body || {}, 'password') const supplied = Object.hasOwn(request.body || {}, 'password')
return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) } try {
return { ...(await client.ensureAuthenticated(supplied ? { credential: request.body.password } : {})), hasSavedPassword: Boolean(configStore.snapshot.instances.find(item => item.id === request.params.id)?.auth?.password) }
} catch (error) {
return reply.code(502).send({ error: 'upstream request failed' })
}
}) })
app.post('/api/instances/:id/logout', async (request, reply) => { app.post('/api/instances/:id/logout', async (request, reply) => {
const client = findClient(request.params.id, reply); if (!client) return const client = findClient(request.params.id, reply); if (!client) return
+40 -5
View File
@@ -1,7 +1,13 @@
export { normalizeBaseUrl, normalizeInstance } from './config/schema.js' export { normalizeBaseUrl, normalizeInstance } from './config/schema.js'
export const DEFAULT_TIMEOUT_MS = Number(process.env.MULTI_SIMADMIN_TIMEOUT_MS || 6000) function parsePositiveIntEnv(value, fallback) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
}
export const DEFAULT_TIMEOUT_MS = parsePositiveIntEnv(process.env.MULTI_SIMADMIN_TIMEOUT_MS, 6000)
export const DEFAULT_MAX_BODY_BYTES = parsePositiveIntEnv(process.env.MULTI_SIMADMIN_MAX_BODY_BYTES, 10 * 1024 * 1024)
export function redactInstance(instance) { export function redactInstance(instance) {
return { return {
id: instance.id, id: instance.id,
@@ -165,7 +171,6 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
const headers = new Headers(options.headers || {}) const headers = new Headers(options.headers || {})
const cookie = jar.header() const cookie = jar.header()
if (cookie && !headers.has('cookie')) headers.set('cookie', cookie) if (cookie && !headers.has('cookie')) headers.set('cookie', cookie)
if (headers.has('cookie')) headers.cookie = headers.get('cookie')
try { try {
const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' }) const response = await fetchImpl(url, { ...options, headers, signal: controller.signal, redirect: options.redirect || 'manual' })
jar.setFromHeaders(response.headers) jar.setFromHeaders(response.headers)
@@ -178,7 +183,7 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
async function fetchJson(endpoint, options = {}) { async function fetchJson(endpoint, options = {}) {
const startedAt = Date.now() const startedAt = Date.now()
const response = await request(endpoint, { method: 'GET', ...options, headers: { accept: 'application/json,text/plain,*/*', ...(options.headers || {}) } }) const response = await request(endpoint, { method: 'GET', ...options, headers: { accept: 'application/json,text/plain,*/*', ...(options.headers || {}) } })
const text = await response.text() const text = (await readResponseBody(response, { bodyTimeoutMs: options.timeoutMs || timeoutMs })).toString('utf8')
let data = null let data = null
try { data = text ? JSON.parse(text) : null } catch {} try { data = text ? JSON.parse(text) : null } catch {}
return { ok: response.ok, status: response.status, latencyMs: Date.now() - startedAt, data, text: data ? undefined : text.slice(0, 1000), headers: response.headers } return { ok: response.ok, status: response.status, latencyMs: Date.now() - startedAt, data, text: data ? undefined : text.slice(0, 1000), headers: response.headers }
@@ -212,6 +217,36 @@ export function createSimAdminClient(instance, { fetchImpl = fetch, timeoutMs =
return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret } return { instance, jar, request, fetchJson, ensureAuthenticated, clearEphemeralSecret }
} }
export function buildClients(instances, options = {}) { // The header timeout ends once headers arrive; body reads need their own
return new Map(instances.map(instance => [instance.id, createSimAdminClient(instance, options)])) // deadline and a byte budget so a stalled or hostile upstream can neither hang
// status aggregation nor exhaust memory.
export async function readResponseBody(response, { maxBytes = DEFAULT_MAX_BODY_BYTES, bodyTimeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const reader = response.body?.getReader()
if (!reader) return Buffer.alloc(0)
const chunks = []
let size = 0
let failure = null
const timer = setTimeout(() => {
failure = new Error('upstream response body timed out')
reader.cancel(failure).catch(() => {})
}, bodyTimeoutMs)
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
size += value.byteLength
if (size > maxBytes) {
failure = new Error('upstream response body exceeded the size limit')
throw failure
}
chunks.push(Buffer.from(value))
}
} finally {
clearTimeout(timer)
}
if (failure) {
await reader.cancel(failure).catch(() => {})
throw failure
}
return Buffer.concat(chunks)
} }
+1 -1
View File
@@ -2,7 +2,7 @@ const AUTH_PATH = /^\/api\/(?:auth(?:\/|$)|login(?:\/|$)|logout(?:\/|$))/i
export const DEFAULT_READ_PATHS = Object.freeze([ export const DEFAULT_READ_PATHS = Object.freeze([
'/api/health', '/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity', '/api/health', '/api/device', '/api/sim', '/api/network', '/api/stats', '/api/connectivity',
'/api/sms/stats', '/api/sms/list', '/api/sms/send', '/api/sms/conversation', '/api/sms/stats', '/api/sms/list', '/api/sms/conversation',
'/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells', '/api/data', '/api/roaming', '/api/airplane-mode', '/api/radio-mode', '/api/band-lock', '/api/cell-lock', '/api/apn', '/api/cells',
'/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles', '/api/device-network/ddns/status', '/api/device-network/ddns/config', '/api/device-network/wlan/status', '/api/device-network/wlan/profiles',
'/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status', '/api/calls', '/api/call/history', '/api/ims/status', '/api/voicemail/status',
+3 -1
View File
@@ -1,3 +1,5 @@
import { readResponseBody } from '../core.js'
const BLOCKED_REQUEST_HEADERS = new Set([ const BLOCKED_REQUEST_HEADERS = new Set([
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer',
'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization', 'transfer-encoding', 'upgrade', 'host', 'content-length', 'cookie', 'authorization',
@@ -63,5 +65,5 @@ export async function proxyToInstance({ client, request, reply, rest }) {
upstream.headers.forEach((value, name) => { upstream.headers.forEach((value, name) => {
if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value) if (!BLOCKED_RESPONSE_HEADERS.has(name.toLowerCase())) reply.header(name, value)
}) })
return reply.send(Buffer.from(await upstream.arrayBuffer())) return reply.send(await readResponseBody(upstream))
} }
+1 -1
View File
@@ -35,7 +35,7 @@ test('client stores simadmin_session from login and sends it on later proxied re
assert.equal(auth.authenticated, true) assert.equal(auth.authenticated, true)
const proxied = await client.fetchJson('/api/device') const proxied = await client.fetchJson('/api/device')
assert.equal(proxied.status, 200) assert.equal(proxied.status, 200)
assert.match(calls.at(-1).options.headers.cookie, /simadmin_session=abc123/) assert.match(calls.at(-1).options.headers.get('cookie'), /simadmin_session=abc123/)
}) })
test('snapshot summary exposes device-monitoring fields from stats, sms, data and hardware endpoints', () => { test('snapshot summary exposes device-monitoring fields from stats, sms, data and hardware endpoints', () => {