feat: rebuild warm operations workbench
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user