Files
multi-simadmin/packages/contracts/src/automation.ts
T
chick 2977f75129 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
2026-09-07 01:57:47 +08:00

507 lines
20 KiB
TypeScript

import type { PageEnvelope, PageQuery } from './instances.js';
export const AUTOMATION_TIMEZONE = 'Asia/Shanghai' as const;
export const SCHEDULED_OPERATION_TYPES = [
'restart-service',
'reboot-system',
'restart-baseband',
'send-sms',
'backup-data',
] as const;
export const SCHEDULE_MISFIRE_POLICIES = ['skip', 'catch-up-once'] as const;
export const SCHEDULE_OVERLAP_POLICIES = ['skip', 'queue-once'] as const;
export const SCHEDULED_RUN_OUTCOMES = [
'succeeded',
'partially-succeeded',
'failed',
'skipped',
'no-targets',
'needs-attention',
] as const;
export const SCHEDULED_RUN_TRIGGER_SOURCES = ['scheduled', 'manual'] as const;
export const SCHEDULE_TRIGGER_KINDS = ['cron', 'fixed', 'interval'] as const;
export const SCHEDULE_INTERVAL_UNITS = ['mins', 'hours', 'days'] as const;
export type ScheduledOperationType = (typeof SCHEDULED_OPERATION_TYPES)[number];
export type ScheduleMisfirePolicy = (typeof SCHEDULE_MISFIRE_POLICIES)[number];
export type ScheduleOverlapPolicy = (typeof SCHEDULE_OVERLAP_POLICIES)[number];
export type ScheduledRunOutcome = (typeof SCHEDULED_RUN_OUTCOMES)[number];
export type ScheduledRunTriggerSource = (typeof SCHEDULED_RUN_TRIGGER_SOURCES)[number];
export type ScheduleTriggerKind = (typeof SCHEDULE_TRIGGER_KINDS)[number];
export type ScheduleIntervalUnit = (typeof SCHEDULE_INTERVAL_UNITS)[number];
export type ScheduleTargetSelector =
| { readonly mode: 'fixed'; readonly instanceIds: readonly string[] }
| { readonly mode: 'tags'; readonly match: 'any' | 'all'; readonly tags: readonly string[] }
| { readonly mode: 'group'; readonly groupId: string }
| { readonly mode: 'groups'; readonly groupIds: readonly string[] }
| { readonly mode: 'all' };
/**
* Hub-style scheduling vocabulary. `cron` keeps the existing five-field engine, `fixed` is a set of
* weekday/time pairs, and `interval` is a plain wall-clock period.
*/
export type ScheduleTrigger =
| { readonly kind: 'cron'; readonly expression: string }
| {
readonly kind: 'fixed';
readonly weekdays: readonly number[];
readonly times: readonly string[];
}
| { readonly kind: 'interval'; readonly value: number; readonly unit: ScheduleIntervalUnit };
export interface ScheduleRetryPolicy {
readonly maxRetries: number;
readonly intervalSeconds: number;
}
export interface ScheduledSmsInput {
readonly recipients: readonly string[];
readonly content: string;
/** Hub parity: jitter applied before each send so bulk schedules do not fire simultaneously. */
readonly randomDelaySeconds?: number;
}
export interface ScheduledSmsSummary {
readonly configured: boolean;
readonly recipientCount: number;
}
export interface CreateScheduledTaskRequest {
readonly name: string;
readonly operationType: ScheduledOperationType;
/** Optional so legacy cron-only callers keep working; parsers always fill it in. */
readonly trigger?: ScheduleTrigger;
readonly cronExpression: string;
readonly timezone: typeof AUTOMATION_TIMEZONE;
readonly targetSelector: ScheduleTargetSelector;
readonly sms?: ScheduledSmsInput;
readonly delaySeconds?: number;
readonly effectiveStartAt?: string;
readonly effectiveEndAt?: string;
readonly misfirePolicy: ScheduleMisfirePolicy;
readonly overlapPolicy: ScheduleOverlapPolicy;
readonly retryPolicy: ScheduleRetryPolicy;
readonly enabled: boolean;
}
export interface UpdateScheduledTaskRequest {
readonly version: number;
readonly name?: string;
readonly operationType?: ScheduledOperationType;
readonly trigger?: ScheduleTrigger;
readonly cronExpression?: string;
readonly timezone?: typeof AUTOMATION_TIMEZONE;
readonly targetSelector?: ScheduleTargetSelector;
readonly sms?: ScheduledSmsInput | null;
readonly delaySeconds?: number | null;
readonly effectiveStartAt?: string | null;
readonly effectiveEndAt?: string | null;
readonly misfirePolicy?: ScheduleMisfirePolicy;
readonly overlapPolicy?: ScheduleOverlapPolicy;
readonly retryPolicy?: ScheduleRetryPolicy;
readonly enabled?: boolean;
}
export interface ScheduledTask {
readonly id: string;
readonly name: string;
readonly operationType: ScheduledOperationType;
readonly trigger?: ScheduleTrigger;
readonly cronExpression: string;
readonly timezone: typeof AUTOMATION_TIMEZONE;
readonly targetSelector: ScheduleTargetSelector;
readonly sms?: ScheduledSmsSummary;
readonly delaySeconds?: number;
readonly effectiveStartAt?: string;
readonly effectiveEndAt?: string;
readonly misfirePolicy: ScheduleMisfirePolicy;
readonly overlapPolicy: ScheduleOverlapPolicy;
readonly retryPolicy: ScheduleRetryPolicy;
readonly enabled: boolean;
readonly version: number;
readonly nextDueAt?: string;
readonly lastEvaluatedAt?: string;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdAt: string;
readonly updatedAt: string;
}
export interface ScheduledRun {
readonly id: string;
readonly scheduledTaskId: string;
readonly scheduleVersion: number;
readonly taskName: string;
readonly operationType: ScheduledOperationType;
readonly dueAt: string;
readonly claimedAt: string;
readonly startedAt?: string;
readonly finishedAt?: string;
readonly targetSnapshot: readonly string[];
readonly outcome?: ScheduledRunOutcome;
readonly reason?: string;
readonly jobIds: readonly string[];
readonly triggerSource: ScheduledRunTriggerSource;
readonly attempt: number;
}
export type ScheduledTaskPageQuery = PageQuery<'name' | 'nextDueAt' | 'updatedAt'> & {
readonly enabled?: boolean;
readonly operationType?: ScheduledOperationType;
};
export type ScheduledTaskPage = PageEnvelope<ScheduledTask>;
export type ScheduledRunPageQuery = PageQuery<'dueAt' | 'finishedAt'> & {
readonly scheduledTaskId?: string;
readonly outcome?: ScheduledRunOutcome;
};
export type ScheduledRunPage = PageEnvelope<ScheduledRun>;
export interface CronPreview {
readonly timezone: typeof AUTOMATION_TIMEZONE;
readonly occurrences: readonly string[];
}
const CREATE_KEYS = new Set([
'name',
'operationType',
'trigger',
'cronExpression',
'timezone',
'targetSelector',
'sms',
'delaySeconds',
'effectiveStartAt',
'effectiveEndAt',
'misfirePolicy',
'overlapPolicy',
'retryPolicy',
'enabled',
]);
const UPDATE_KEYS = new Set([...CREATE_KEYS, 'version']);
function object(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value))
throw new TypeError(`${label} must be an object`);
return value as Record<string, unknown>;
}
function exactKeys(value: Record<string, unknown>, allowed: ReadonlySet<string>): void {
const unknown = Object.keys(value).find((key) => !allowed.has(key));
if (unknown) throw new TypeError(`Unknown field: ${unknown}`);
}
function string(value: unknown, label: string, maximum: number): string {
if (typeof value !== 'string') throw new TypeError(`${label} must be a string`);
const clean = value.trim();
if (clean.length === 0 || clean.length > maximum)
throw new TypeError(`${label} must contain 1-${maximum} characters`);
return clean;
}
function member<T extends string>(value: unknown, values: readonly T[], label: string): T {
if (typeof value !== 'string' || !values.includes(value as T))
throw new TypeError(`${label} is invalid`);
return value as T;
}
function strings(value: unknown, label: string, maximum: number): string[] {
if (!Array.isArray(value) || value.length === 0 || value.length > maximum)
throw new TypeError(`${label} must contain 1-${maximum} values`);
const result = value.map((item) => string(item, label, 120));
if (new Set(result).size !== result.length) throw new TypeError(`${label} must be unique`);
return result;
}
function cronExpression(value: unknown): string {
const expression = string(value, 'cronExpression', 120).replace(/\s+/g, ' ');
if (expression.split(' ').length !== 5)
throw new TypeError('cronExpression must use standard five-field syntax');
return expression;
}
function isoDate(value: unknown, label: string): string {
const raw = string(value, label, 64);
const time = Date.parse(raw);
if (!Number.isFinite(time)) throw new TypeError(`${label} must be an ISO timestamp`);
return new Date(time).toISOString();
}
function targetSelector(value: unknown): ScheduleTargetSelector {
const source = object(value, 'targetSelector');
if (source.mode === 'fixed') {
exactKeys(source, new Set(['mode', 'instanceIds']));
return { mode: 'fixed', instanceIds: strings(source.instanceIds, 'instanceIds', 200) };
}
if (source.mode === 'tags') {
exactKeys(source, new Set(['mode', 'match', 'tags']));
return {
mode: 'tags',
match: member(source.match, ['any', 'all'] as const, 'tag match'),
tags: strings(source.tags, 'tags', 200),
};
}
if (source.mode === 'group') {
exactKeys(source, new Set(['mode', 'groupId']));
return { mode: 'group', groupId: string(source.groupId, 'groupId', 120) };
}
if (source.mode === 'groups') {
exactKeys(source, new Set(['mode', 'groupIds']));
return { mode: 'groups', groupIds: strings(source.groupIds, 'groupIds', 200) };
}
if (source.mode === 'all') {
exactKeys(source, new Set(['mode']));
return { mode: 'all' };
}
throw new TypeError('targetSelector mode is invalid');
}
const INTERVAL_UNIT_MS: Record<ScheduleIntervalUnit, number> = {
mins: 60_000,
hours: 3_600_000,
days: 86_400_000,
};
export function intervalMilliseconds(
trigger: Extract<ScheduleTrigger, { kind: 'interval' }>,
): number {
return trigger.value * INTERVAL_UNIT_MS[trigger.unit];
}
function timeOfDay(value: unknown): string {
const raw = string(value, 'time', 5);
const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(raw);
const hour = match?.[1];
const minute = match?.[2];
if (!hour || !minute) throw new TypeError('time must use HH:MM syntax');
return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`;
}
function weekdays(value: unknown): number[] {
if (!Array.isArray(value) || value.length === 0 || value.length > 7)
throw new TypeError('weekdays must contain 1-7 values');
const result = value.map((item) => {
if (!Number.isSafeInteger(item) || (item as number) < 1 || (item as number) > 7)
throw new TypeError('weekdays must be between Monday (1) and Sunday (7)');
return item as number;
});
if (new Set(result).size !== result.length) throw new TypeError('weekdays must be unique');
return result.sort((left, right) => left - right);
}
function times(value: unknown): string[] {
if (!Array.isArray(value) || value.length === 0 || value.length > 48)
throw new TypeError('times must contain 1-48 values');
const result = value.map(timeOfDay);
if (new Set(result).size !== result.length) throw new TypeError('times must be unique');
return result.sort((left, right) => left.localeCompare(right));
}
export function parseScheduleTrigger(value: unknown): ScheduleTrigger {
const source = object(value, 'trigger');
if (source.kind === 'cron') {
exactKeys(source, new Set(['kind', 'expression']));
return { kind: 'cron', expression: cronExpression(source.expression) };
}
if (source.kind === 'fixed') {
exactKeys(source, new Set(['kind', 'weekdays', 'times']));
return { kind: 'fixed', weekdays: weekdays(source.weekdays), times: times(source.times) };
}
if (source.kind === 'interval') {
exactKeys(source, new Set(['kind', 'value', 'unit']));
if (!Number.isSafeInteger(source.value) || (source.value as number) < 1)
throw new TypeError('interval value must be a positive integer');
if ((source.value as number) > 10_000)
throw new TypeError('interval value must be at most 10000');
return {
kind: 'interval',
value: source.value as number,
unit: member(source.unit, SCHEDULE_INTERVAL_UNITS, 'interval unit'),
};
}
throw new TypeError('trigger kind is invalid');
}
/**
* Canonical five-field cron kept for the legacy `cron_expression` column and for display. Only the
* `cron` kind is exact; `fixed` collapses to its earliest pair and `interval` to its step form.
*/
export function deriveCronExpression(trigger: ScheduleTrigger): string {
if (trigger.kind === 'cron') return trigger.expression;
if (trigger.kind === 'interval') {
if (trigger.unit === 'mins')
return trigger.value === 1 ? '* * * * *' : `*/${trigger.value} * * * *`;
if (trigger.unit === 'hours')
return trigger.value === 1 ? '0 * * * *' : `0 */${trigger.value} * * *`;
return trigger.value === 1 ? '0 0 * * *' : `0 0 */${trigger.value} * *`;
}
const earliest = trigger.times[0] ?? '00:00';
const [hour, minute] = earliest.split(':');
const days = trigger.weekdays.length === 7 ? '*' : trigger.weekdays.join(',');
return `${Number(minute)} ${Number(hour)} * * ${days}`;
}
/** Resolve the effective trigger for a stored or legacy task. */
export function scheduleTriggerOf(task: {
readonly trigger?: ScheduleTrigger;
readonly cronExpression: string;
}): ScheduleTrigger {
return task.trigger ?? { kind: 'cron', expression: task.cronExpression };
}
function sms(value: unknown): ScheduledSmsInput {
const source = object(value, 'sms');
exactKeys(source, new Set(['recipients', 'content', 'randomDelaySeconds']));
const randomDelaySeconds =
source.randomDelaySeconds === undefined
? undefined
: numberInRange(source.randomDelaySeconds, 'sms.randomDelaySeconds', 0, 600);
return {
recipients: strings(source.recipients, 'SMS recipients', 50).map((recipient) => {
if (!/^\+?[0-9]{3,20}$/.test(recipient)) throw new TypeError('SMS recipient is invalid');
return recipient;
}),
content: string(source.content, 'SMS content', 2_000),
...(randomDelaySeconds === undefined ? {} : { randomDelaySeconds }),
};
}
function numberInRange(value: unknown, label: string, minimum: number, maximum: number): number {
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum)
throw new TypeError(`${label} must be between ${minimum} and ${maximum}`);
return value as number;
}
function retryPolicy(value: unknown, operationType: ScheduledOperationType): ScheduleRetryPolicy {
if (value === undefined) return { maxRetries: 0, intervalSeconds: 60 };
const source = object(value, 'retryPolicy');
exactKeys(source, new Set(['maxRetries', 'intervalSeconds']));
const maxRetries = numberInRange(source.maxRetries, 'retryPolicy.maxRetries', 0, 10);
const intervalSeconds = numberInRange(
source.intervalSeconds,
'retryPolicy.intervalSeconds',
1,
86_400,
);
if (operationType === 'reboot-system' && maxRetries > 0)
throw new TypeError('System reboot automatic retries are not supported');
return { maxRetries, intervalSeconds };
}
export function parseCreateScheduledTaskRequest(value: unknown): CreateScheduledTaskRequest {
const source = object(value, 'schedule');
exactKeys(source, CREATE_KEYS);
const operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
const timezone = source.timezone ?? AUTOMATION_TIMEZONE;
if (timezone !== AUTOMATION_TIMEZONE) throw new TypeError('timezone must be Asia/Shanghai');
if (source.trigger === undefined && source.cronExpression === undefined)
throw new TypeError('trigger or cronExpression is required');
const trigger =
source.trigger === undefined
? ({ kind: 'cron', expression: cronExpression(source.cronExpression) } as const)
: parseScheduleTrigger(source.trigger);
const smsValue = source.sms === undefined ? undefined : sms(source.sms);
if (operationType === 'send-sms' && !smsValue)
throw new TypeError('SMS configuration is required');
if (operationType !== 'send-sms' && smsValue)
throw new TypeError('SMS is only valid for send-sms');
const effectiveStartAt =
source.effectiveStartAt === undefined
? undefined
: isoDate(source.effectiveStartAt, 'effectiveStartAt');
const effectiveEndAt =
source.effectiveEndAt === undefined
? undefined
: isoDate(source.effectiveEndAt, 'effectiveEndAt');
if (effectiveStartAt && effectiveEndAt && effectiveEndAt <= effectiveStartAt)
throw new TypeError('effectiveEndAt must be later than effectiveStartAt');
if (source.enabled !== undefined && typeof source.enabled !== '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 =
source.delaySeconds === undefined
? undefined
: numberInRange(source.delaySeconds, 'delaySeconds', 3, 3);
if (delaySeconds !== undefined && operationType !== 'reboot-system')
throw new TypeError('delaySeconds is only valid for reboot-system');
return {
name: string(source.name, 'name', 120),
operationType,
trigger,
cronExpression: deriveCronExpression(trigger),
timezone: AUTOMATION_TIMEZONE,
targetSelector: targetSelector(source.targetSelector),
...(smsValue ? { sms: smsValue } : {}),
...(delaySeconds === undefined ? {} : { delaySeconds }),
...(effectiveStartAt ? { effectiveStartAt } : {}),
...(effectiveEndAt ? { effectiveEndAt } : {}),
misfirePolicy:
source.misfirePolicy === undefined
? 'skip'
: member(source.misfirePolicy, SCHEDULE_MISFIRE_POLICIES, 'misfirePolicy'),
overlapPolicy:
source.overlapPolicy === undefined
? 'skip'
: member(source.overlapPolicy, SCHEDULE_OVERLAP_POLICIES, 'overlapPolicy'),
retryPolicy: retryPolicy(source.retryPolicy, operationType),
enabled: source.enabled === undefined ? true : source.enabled,
};
}
export function parseUpdateScheduledTaskRequest(value: unknown): UpdateScheduledTaskRequest {
const source = object(value, 'schedule update');
exactKeys(source, UPDATE_KEYS);
if (!Number.isSafeInteger(source.version) || (source.version as number) < 1)
throw new TypeError('version must be a positive integer');
if (Object.keys(source).length === 1)
throw new TypeError('At least one editable field is required');
const result: Record<string, unknown> = { version: source.version };
if (source.name !== undefined) result.name = string(source.name, 'name', 120);
if (source.operationType !== undefined)
result.operationType = member(source.operationType, SCHEDULED_OPERATION_TYPES, 'operationType');
if (source.trigger !== undefined) {
result.trigger = parseScheduleTrigger(source.trigger);
result.cronExpression = deriveCronExpression(result.trigger as ScheduleTrigger);
} else if (source.cronExpression !== undefined) {
result.cronExpression = cronExpression(source.cronExpression);
result.trigger = { kind: 'cron', expression: result.cronExpression } as ScheduleTrigger;
}
if (source.timezone !== undefined) {
if (source.timezone !== AUTOMATION_TIMEZONE)
throw new TypeError('timezone must be Asia/Shanghai');
result.timezone = AUTOMATION_TIMEZONE;
}
if (source.targetSelector !== undefined)
result.targetSelector = targetSelector(source.targetSelector);
if (source.sms !== undefined) result.sms = source.sms === null ? null : sms(source.sms);
if (source.delaySeconds !== undefined)
result.delaySeconds =
source.delaySeconds === null
? null
: numberInRange(source.delaySeconds, 'delaySeconds', 0, 3_600);
if (source.effectiveStartAt !== undefined)
result.effectiveStartAt =
source.effectiveStartAt === null
? null
: isoDate(source.effectiveStartAt, 'effectiveStartAt');
if (source.effectiveEndAt !== undefined)
result.effectiveEndAt =
source.effectiveEndAt === null ? null : isoDate(source.effectiveEndAt, 'effectiveEndAt');
if (source.misfirePolicy !== undefined)
result.misfirePolicy = member(source.misfirePolicy, SCHEDULE_MISFIRE_POLICIES, 'misfirePolicy');
if (source.overlapPolicy !== undefined)
result.overlapPolicy = member(source.overlapPolicy, SCHEDULE_OVERLAP_POLICIES, 'overlapPolicy');
if (source.retryPolicy !== undefined)
result.retryPolicy = retryPolicy(
source.retryPolicy,
(result.operationType ?? 'restart-service') as ScheduledOperationType,
);
if (source.enabled !== undefined) {
if (typeof source.enabled !== 'boolean') throw new TypeError('enabled must be boolean');
result.enabled = source.enabled;
}
return result as unknown as UpdateScheduledTaskRequest;
}