feat: rebuild warm operations workbench

This commit is contained in:
Codex
2026-07-30 14:49:33 +08:00
parent 25c853dea8
commit 570edf6bb2
88 changed files with 13413 additions and 2250 deletions
@@ -71,6 +71,8 @@ describe('database migrations', () => {
'job_items',
'jobs',
'operation_preparations',
'scheduled_runs',
'scheduled_tasks',
'schema_migrations',
'secret_cleanup_tasks',
'secret_references',
@@ -304,6 +304,58 @@ export const MIGRATIONS: readonly Migration[] = [
'CREATE INDEX idx_console_auth_sessions_expires_at ON console_auth_sessions(expires_at)',
],
},
{
id: 8,
name: 'scheduled-automation',
statements: [
`CREATE TABLE scheduled_tasks (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
operation_type TEXT NOT NULL CHECK (operation_type IN ('restart-service','reboot-system','send-sms')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0),
cron_expression TEXT NOT NULL,
timezone TEXT NOT NULL CHECK (timezone = 'Asia/Shanghai'),
target_selector_json TEXT NOT NULL,
sms_secret_reference TEXT,
sms_recipient_count INTEGER CHECK (sms_recipient_count IS NULL OR sms_recipient_count > 0),
effective_start_at TEXT,
effective_end_at TEXT,
misfire_policy TEXT NOT NULL CHECK (misfire_policy IN ('skip','catch-up-once')),
overlap_policy TEXT NOT NULL CHECK (overlap_policy IN ('skip','queue-once')),
retry_policy_json TEXT NOT NULL,
next_due_at TEXT,
last_evaluated_at TEXT,
created_by TEXT NOT NULL,
updated_by TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
CHECK ((operation_type = 'send-sms') = (sms_secret_reference IS NOT NULL)),
CHECK ((sms_secret_reference IS NULL) = (sms_recipient_count IS NULL))
)`,
`CREATE TABLE scheduled_runs (
id TEXT PRIMARY KEY,
scheduled_task_id TEXT NOT NULL REFERENCES scheduled_tasks(id) ON DELETE RESTRICT,
schedule_version INTEGER NOT NULL CHECK (schedule_version > 0),
task_snapshot_json TEXT NOT NULL,
due_at TEXT NOT NULL,
claimed_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
target_snapshot_json TEXT NOT NULL,
outcome TEXT CHECK (outcome IS NULL OR outcome IN ('succeeded','partially-succeeded','failed','skipped','no-targets','needs-attention')),
reason TEXT,
job_ids_json TEXT NOT NULL DEFAULT '[]',
trigger_source TEXT NOT NULL CHECK (trigger_source IN ('scheduled','manual')),
attempt INTEGER NOT NULL DEFAULT 1 CHECK (attempt > 0),
UNIQUE (scheduled_task_id, schedule_version, due_at, trigger_source)
)`,
'CREATE INDEX idx_scheduled_tasks_enabled_next_due ON scheduled_tasks(enabled, next_due_at) WHERE deleted_at IS NULL',
'CREATE INDEX idx_scheduled_runs_task_due ON scheduled_runs(scheduled_task_id, due_at DESC)',
'CREATE INDEX idx_scheduled_runs_outcome_finished ON scheduled_runs(outcome, finished_at DESC)',
],
},
];
const createMigrationsTable = `CREATE TABLE schema_migrations (
@@ -255,3 +255,67 @@ export const secretCleanupTasks = sqliteTable(
},
(table) => [index('idx_secret_cleanup_tasks_queued_at').on(table.queuedAt)],
);
export const scheduledTasks = sqliteTable(
'scheduled_tasks',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
operationType: text('operation_type').notNull(),
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
version: integer('version').notNull().default(1),
cronExpression: text('cron_expression').notNull(),
timezone: text('timezone').notNull(),
targetSelectorJson: text('target_selector_json').notNull(),
smsSecretReference: text('sms_secret_reference'),
smsRecipientCount: integer('sms_recipient_count'),
effectiveStartAt: text('effective_start_at'),
effectiveEndAt: text('effective_end_at'),
misfirePolicy: text('misfire_policy').notNull(),
overlapPolicy: text('overlap_policy').notNull(),
retryPolicyJson: text('retry_policy_json').notNull(),
nextDueAt: text('next_due_at'),
lastEvaluatedAt: text('last_evaluated_at'),
createdBy: text('created_by').notNull(),
updatedBy: text('updated_by').notNull(),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
deletedAt: text('deleted_at'),
},
(table) => [
check('scheduled_tasks_enabled_check', sql`${table.enabled} in (0, 1)`),
check('scheduled_tasks_version_check', sql`${table.version} > 0`),
index('idx_scheduled_tasks_enabled_next_due').on(table.enabled, table.nextDueAt),
],
);
export const scheduledRuns = sqliteTable(
'scheduled_runs',
{
id: text('id').primaryKey(),
scheduledTaskId: text('scheduled_task_id')
.notNull()
.references(() => scheduledTasks.id, { onDelete: 'restrict' }),
scheduleVersion: integer('schedule_version').notNull(),
taskSnapshotJson: text('task_snapshot_json').notNull(),
dueAt: text('due_at').notNull(),
claimedAt: text('claimed_at').notNull(),
startedAt: text('started_at'),
finishedAt: text('finished_at'),
targetSnapshotJson: text('target_snapshot_json').notNull(),
outcome: text('outcome'),
reason: text('reason'),
jobIdsJson: text('job_ids_json').notNull().default('[]'),
triggerSource: text('trigger_source').notNull(),
attempt: integer('attempt').notNull().default(1),
},
(table) => [
unique('scheduled_runs_occurrence_unique').on(
table.scheduledTaskId,
table.scheduleVersion,
table.dueAt,
table.triggerSource,
),
index('idx_scheduled_runs_task_due').on(table.scheduledTaskId, desc(table.dueAt)),
],
);
@@ -151,7 +151,23 @@ describe('MacOSKeychainSecretStore', () => {
}
});
it.each(['line\nbreak', 'line\rbreak', 'nul\0break', 'x'.repeat(4097)])(
it('stores a maximum-sized scheduled SMS payload', async () => {
const runner = new FakeRunner();
const store = new MacOSKeychainSecretStore(runner);
const payload = JSON.stringify({
recipients: Array.from(
{ length: 50 },
(_, index) => `1380013${String(index).padStart(4, '0')}`,
),
content: '字'.repeat(2_000),
});
await expect(
store.set({ instanceId: 'task-1', purpose: 'scheduled-sms', slot: 'rotation-1' }, payload),
).resolves.toMatch(/^keychain:/);
});
it.each(['line\nbreak', 'line\rbreak', 'nul\0break', 'x'.repeat(16_385)])(
'rejects non-line-safe or oversized secret input before invoking the runner',
async (value) => {
const runner = new FakeRunner();
@@ -181,7 +181,7 @@ export class MacOSKeychainSecretStore implements SecretStore {
typeof value !== 'string' ||
value.length === 0 ||
/[\0\r\n]/.test(value) ||
Buffer.byteLength(value, 'utf8') > 4096
Buffer.byteLength(value, 'utf8') > 16_384
) {
throw new SecretStoreError('INVALID_SECRET', 'Secret is not valid for Keychain storage');
}
@@ -4,9 +4,13 @@ import { SafeUpstreamGateway, type SafeUpstreamTransport } from './safe-upstream
export interface SafeControlPlaneUpstream extends ConnectionTransport {
request: UpstreamSessionClientOptions['request'];
postNetworkRegisterAuto(origin: string): Promise<{ readonly status: number }>;
postServiceRestart(origin: string): Promise<{ readonly status: number }>;
postSystemReboot(origin: string, delaySeconds: number): Promise<{ readonly status: number }>;
postNetworkRegisterAuto(origin: string, cookie?: string): Promise<{ readonly status: number }>;
postServiceRestart(origin: string, cookie?: string): Promise<{ readonly status: number }>;
postSystemReboot(
origin: string,
delaySeconds: number,
cookie?: string,
): Promise<{ readonly status: number }>;
}
export function createSafeControlPlaneUpstream(
transport: SafeUpstreamTransport,
@@ -15,8 +19,9 @@ export function createSafeControlPlaneUpstream(
return {
get: (url) => transport.get(url),
request: (request) => gateway.request(request),
postNetworkRegisterAuto: (origin) => gateway.postNetworkRegisterAuto(origin),
postServiceRestart: (origin) => gateway.postServiceRestart(origin),
postSystemReboot: (origin, delaySeconds) => gateway.postSystemReboot(origin, delaySeconds),
postNetworkRegisterAuto: (origin, cookie) => gateway.postNetworkRegisterAuto(origin, cookie),
postServiceRestart: (origin, cookie) => gateway.postServiceRestart(origin, cookie),
postSystemReboot: (origin, delaySeconds, cookie) =>
gateway.postSystemReboot(origin, delaySeconds, cookie),
};
}
@@ -65,7 +65,7 @@ describe('SafeUpstreamGateway', () => {
});
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/auth/login',
url: 'http://example.com/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
@@ -74,13 +74,50 @@ describe('SafeUpstreamGateway', () => {
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
await expect(
gateway.request({
url: 'http://192.168.1.20:8080/api/auth/logout',
url: 'http://example.com/api/auth/logout',
method: 'POST',
headers: { cookie: 'simadmin_session=opaque' },
}),
).rejects.toThrow('UPSTREAM_INSECURE_AUTH');
});
it('allows HTTP auth only for literal private instance hosts', async () => {
const calls: unknown[] = [];
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async (url, headers, body) => {
calls.push({ url, headers, body });
return { status: 200, headers: { 'set-cookie': 'simadmin_session=opaque' }, body: '' };
},
},
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/auth/login',
method: 'POST',
headers: { 'content-type': 'application/json' },
secret: '[REDACTED]',
body: '[REDACTED]',
});
await gateway.request({
url: 'http://192.168.1.20:8080/api/auth/logout',
method: 'POST',
headers: { cookie: 'simadmin_session=opaque' },
});
expect(calls).toEqual([
{
url: 'http://192.168.1.20:8080/api/auth/login',
headers: { 'content-type': 'application/json' },
body: '{"password":"[REDACTED]"}',
},
{
url: 'http://192.168.1.20:8080/api/auth/logout',
headers: { cookie: 'simadmin_session=opaque' },
body: '',
},
]);
});
it('allows audited resource GETs without a cookie for passwordless instances', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({
@@ -121,6 +158,23 @@ describe('SafeUpstreamGateway', () => {
);
});
it('dispatches an SMS at the 2,000-character automation limit', async () => {
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const gateway = new SafeUpstreamGateway({
transport: { get: vi.fn(), post },
});
const content = '字'.repeat(2_000);
await gateway.request({
url: 'http://192.168.1.20:8080/api/sms/send',
method: 'POST',
headers: { accept: 'application/json', 'content-type': 'application/json' },
sms: { phoneNumber: '+15550199', content },
});
expect(post).toHaveBeenCalledOnce();
});
it('rejects unallowlisted SMS queries and malformed send payloads before transport', async () => {
const get = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
const post = vi.fn(async () => ({ status: 200, headers: {}, body: '{}' }));
@@ -184,4 +238,35 @@ describe('SafeUpstreamGateway', () => {
},
]);
});
it('forwards only a canonical origin-bound simadmin_session cookie on restart ops', async () => {
const calls: unknown[] = [];
const gateway = new SafeUpstreamGateway({
transport: {
get: async () => ({ status: 200, headers: {}, body: '' }),
post: async (url, headers, body) => {
calls.push({ url, headers, body });
return { status: 204, headers: {}, body: '' };
},
},
});
const cookie = 'simadmin_session=opaque-token';
await gateway.postServiceRestart('http://192.168.1.20:8080', cookie);
await gateway.postSystemReboot('http://192.168.1.20:8080', 3, cookie);
expect(calls).toEqual([
{
url: 'http://192.168.1.20:8080/api/service/restart',
headers: { cookie },
body: '',
},
{
url: 'http://192.168.1.20:8080/api/system/reboot',
headers: { 'content-type': 'application/json', cookie },
body: '{"delay_seconds":3}',
},
]);
await expect(
gateway.postServiceRestart('http://192.168.1.20:8080', 'simadmin_session=bad; extra'),
).rejects.toMatchObject({ code: 'UPSTREAM_REQUEST_INVALID', dispatched: false });
});
});
@@ -14,6 +14,25 @@ export interface SafeUpstreamTransport {
body: string,
): Promise<TransportResponse>;
}
/** Literal private hosts only — hostnames that still need DNS stay HTTPS for auth secrets. */
const isLiteralPrivateHost = (hostname: string): boolean => {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (host.includes(':')) return host.startsWith('fc') || host.startsWith('fd');
const parts = host.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
return false;
const [a, b, c] = parts;
if (a === undefined || b === undefined || c === undefined) return false;
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 192 && b === 0 && c === 0)
);
};
export class SafeUpstreamGateway {
constructor(private readonly options: { readonly transport: SafeUpstreamTransport }) {}
private assertOrigin(origin: string): string {
@@ -35,11 +54,24 @@ export class SafeUpstreamGateway {
return parsed.origin;
}
private async postZeroBody(origin: string, path: string): Promise<UpstreamResponse> {
private sessionHeaders(
cookie: string | undefined,
extra: Readonly<Record<string, string>> = {},
): Readonly<Record<string, string>> {
if (cookie === undefined) return extra;
if (!/^simadmin_session=[^;\s,]+$/u.test(cookie)) throw this.notDispatched();
return { ...extra, cookie };
}
private async postZeroBody(
origin: string,
path: string,
cookie?: string,
): Promise<UpstreamResponse> {
const base = this.assertOrigin(origin);
const url = `${base}${path}`;
try {
return await this.options.transport.post(url, {}, '');
return await this.options.transport.post(url, this.sessionHeaders(cookie), '');
} catch (error) {
if (
error instanceof UpstreamError &&
@@ -50,22 +82,26 @@ export class SafeUpstreamGateway {
}
}
async postNetworkRegisterAuto(origin: string): Promise<UpstreamResponse> {
return this.postZeroBody(origin, '/api/network/register-auto');
async postNetworkRegisterAuto(origin: string, cookie?: string): Promise<UpstreamResponse> {
return this.postZeroBody(origin, '/api/network/register-auto', cookie);
}
async postServiceRestart(origin: string): Promise<UpstreamResponse> {
return this.postZeroBody(origin, '/api/service/restart');
async postServiceRestart(origin: string, cookie?: string): Promise<UpstreamResponse> {
return this.postZeroBody(origin, '/api/service/restart', cookie);
}
async postSystemReboot(origin: string, delaySeconds: number): Promise<UpstreamResponse> {
async postSystemReboot(
origin: string,
delaySeconds: number,
cookie?: string,
): Promise<UpstreamResponse> {
if (delaySeconds !== 3) throw this.notDispatched();
const base = this.assertOrigin(origin);
const url = `${base}/api/system/reboot`;
try {
return await this.options.transport.post(
url,
{ 'content-type': 'application/json' },
this.sessionHeaders(cookie, { 'content-type': 'application/json' }),
JSON.stringify({ delay_seconds: 3 }),
);
} catch (error) {
@@ -92,7 +128,10 @@ export class SafeUpstreamGateway {
request.body !== undefined ||
request.sms !== undefined ||
(headerKeys !== 'accept' && headerKeys !== 'accept,cookie') ||
(url.pathname !== '/api/stats' && url.pathname !== '/api/sim' && !smsQuery) ||
(url.pathname !== '/api/stats' &&
url.pathname !== '/api/sim' &&
url.pathname !== '/api/health' &&
!smsQuery) ||
(!smsList && url.search) ||
url.hash ||
url.username ||
@@ -114,8 +153,8 @@ export class SafeUpstreamGateway {
(headerKeys !== 'accept,content-type' && headerKeys !== 'accept,content-type,cookie') ||
!/^\+?[0-9][0-9 ()-]{2,31}$/u.test(sms.phoneNumber) ||
sms.content.length < 1 ||
sms.content.length > 1600 ||
Buffer.byteLength(sms.content, 'utf8') > 6400 ||
sms.content.length > 2000 ||
Buffer.byteLength(sms.content, 'utf8') > 8000 ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(sms.content) ||
url.search ||
url.hash ||
@@ -129,7 +168,9 @@ export class SafeUpstreamGateway {
JSON.stringify({ phone_number: sms.phoneNumber, content: sms.content }),
);
}
if (url.protocol !== 'https:') throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
// Auth secrets may travel over HTTPS anywhere, or over HTTP only to literal private hosts.
// Public/cleartext auth is still rejected here; SSRF private-only dial remains in transport.
if (!this.allowsAuthProtocol(url)) throw new UpstreamError('UPSTREAM_INSECURE_AUTH');
if (request.method !== 'POST') throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
if (request.url.endsWith('/api/auth/login')) {
if (typeof request.secret !== 'string' || request.body !== '[REDACTED]')
@@ -147,6 +188,12 @@ export class SafeUpstreamGateway {
}
throw new UpstreamError('UPSTREAM_REQUEST_INVALID');
}
private allowsAuthProtocol(url: URL): boolean {
if (url.protocol === 'https:') return true;
if (url.protocol !== 'http:') return false;
return isLiteralPrivateHost(url.hostname);
}
private notDispatched(): OperationNotDispatchedError {
return new OperationNotDispatchedError('UPSTREAM_REQUEST_INVALID');
}