feat(webhooks): add CI/CD webhook integration for triggering stack actions (Pro) (#177)

Add custom webhooks allowing external CI/CD systems (GitHub Actions, GitLab CI, etc.)
to trigger stack actions via HTTP POST with HMAC-SHA256 signature authentication.
Includes webhook CRUD management UI in Settings, execution history tracking,
one-time secret reveal, enable/disable toggle, and comprehensive documentation.
This commit is contained in:
Anso
2026-03-26 09:23:23 -04:00
committed by GitHub
parent 95f46c9d15
commit 4fc363301a
8 changed files with 824 additions and 9 deletions
+112
View File
@@ -37,6 +37,28 @@ export interface Node {
api_token?: string;
}
export interface Webhook {
id?: number;
name: string;
stack_name: string;
action: 'deploy' | 'restart' | 'stop' | 'start' | 'pull';
secret: string;
enabled: boolean;
created_at: number;
updated_at: number;
}
export interface WebhookExecution {
id?: number;
webhook_id: number;
action: string;
status: 'success' | 'failure';
trigger_source: string | null;
duration_ms: number | null;
error: string | null;
executed_at: number;
}
export interface NotificationHistory {
id?: number;
level: 'info' | 'warning' | 'error';
@@ -141,6 +163,31 @@ export class DatabaseService {
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
stack_name TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'deploy',
secret TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS webhook_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL,
action TEXT NOT NULL,
status TEXT NOT NULL,
trigger_source TEXT,
duration_ms INTEGER,
error TEXT,
executed_at INTEGER NOT NULL,
FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_webhook_executions_webhook ON webhook_executions(webhook_id);
`);
// Apply migrations safely (ignore if columns already exist)
@@ -481,4 +528,69 @@ export class DatabaseService {
}
return result;
}
// --- Webhooks ---
public getWebhooks(): Webhook[] {
return this.db.prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all().map((row: any) => ({
...row,
enabled: row.enabled === 1,
}));
}
public getWebhook(id: number): Webhook | undefined {
const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as any;
if (!row) return undefined;
return { ...row, enabled: row.enabled === 1 };
}
public addWebhook(webhook: Omit<Webhook, 'id' | 'created_at' | 'updated_at'>): number {
const now = Date.now();
const result = this.db.prepare(
'INSERT INTO webhooks (name, stack_name, action, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(webhook.name, webhook.stack_name, webhook.action, webhook.secret, webhook.enabled ? 1 : 0, now, now);
return result.lastInsertRowid as number;
}
public updateWebhook(id: number, updates: Partial<Pick<Webhook, 'name' | 'stack_name' | 'action' | 'enabled'>>): void {
const fields: string[] = [];
const values: (string | number)[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.stack_name !== undefined) { fields.push('stack_name = ?'); values.push(updates.stack_name); }
if (updates.action !== undefined) { fields.push('action = ?'); values.push(updates.action); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (fields.length === 0) return;
fields.push('updated_at = ?');
values.push(Date.now());
values.push(id);
this.db.prepare(`UPDATE webhooks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteWebhook(id: number): void {
this.db.prepare('DELETE FROM webhooks WHERE id = ?').run(id);
}
// --- Webhook Executions ---
public getWebhookExecutions(webhookId: number, limit = 20): WebhookExecution[] {
return this.db.prepare(
'SELECT * FROM webhook_executions WHERE webhook_id = ? ORDER BY executed_at DESC LIMIT ?'
).all(webhookId, limit) as WebhookExecution[];
}
public addWebhookExecution(execution: Omit<WebhookExecution, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO webhook_executions (webhook_id, action, status, trigger_source, duration_ms, error, executed_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(execution.webhook_id, execution.action, execution.status, execution.trigger_source, execution.duration_ms, execution.error, execution.executed_at);
// Keep only last 100 executions per webhook
this.db.prepare(
'DELETE FROM webhook_executions WHERE webhook_id = ? AND id NOT IN (SELECT id FROM webhook_executions WHERE webhook_id = ? ORDER BY executed_at DESC LIMIT 100)'
).run(execution.webhook_id, execution.webhook_id);
return result.lastInsertRowid as number;
}
}
+114
View File
@@ -0,0 +1,114 @@
import crypto from 'crypto';
import { DatabaseService } from './DatabaseService';
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
import { NodeRegistry } from './NodeRegistry';
export class WebhookService {
private static instance: WebhookService;
public static getInstance(): WebhookService {
if (!WebhookService.instance) {
WebhookService.instance = new WebhookService();
}
return WebhookService.instance;
}
public generateSecret(): string {
return crypto.randomBytes(32).toString('hex');
}
public validateSignature(payload: string, secret: string, signature: string): boolean {
// Expect format: sha256=<hex>
const parts = signature.split('=');
if (parts.length !== 2 || parts[0] !== 'sha256') return false;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(parts[1], 'hex')
);
}
public async execute(webhookId: number, action: string, triggerSource: string | null): Promise<{ success: boolean; error?: string; duration_ms: number }> {
const db = DatabaseService.getInstance();
const webhook = db.getWebhook(webhookId);
if (!webhook) throw new Error('Webhook not found');
const defaultNodeId = NodeRegistry.getInstance().getDefaultNodeId();
// Validate the stack still exists
const stacks = await FileSystemService.getInstance(defaultNodeId).getStacks();
if (!stacks.includes(webhook.stack_name)) {
const error = `Stack "${webhook.stack_name}" not found`;
db.addWebhookExecution({
webhook_id: webhookId,
action,
status: 'failure',
trigger_source: triggerSource,
duration_ms: 0,
error,
executed_at: Date.now(),
});
return { success: false, error, duration_ms: 0 };
}
const startTime = Date.now();
try {
const compose = ComposeService.getInstance(defaultNodeId);
switch (action) {
case 'deploy':
await compose.deployStack(webhook.stack_name);
break;
case 'restart':
await compose.runCommand(webhook.stack_name, 'restart');
break;
case 'stop':
await compose.runCommand(webhook.stack_name, 'stop');
break;
case 'start':
await compose.runCommand(webhook.stack_name, 'start');
break;
case 'pull':
await compose.updateStack(webhook.stack_name);
break;
default:
throw new Error(`Unknown action: ${action}`);
}
const duration_ms = Date.now() - startTime;
db.addWebhookExecution({
webhook_id: webhookId,
action,
status: 'success',
trigger_source: triggerSource,
duration_ms,
error: null,
executed_at: Date.now(),
});
return { success: true, duration_ms };
} catch (err) {
const duration_ms = Date.now() - startTime;
const error = (err as Error).message || 'Unknown error';
db.addWebhookExecution({
webhook_id: webhookId,
action,
status: 'failure',
trigger_source: triggerSource,
duration_ms,
error,
executed_at: Date.now(),
});
return { success: false, error, duration_ms };
}
}
public maskSecret(secret: string): string {
if (secret.length <= 8) return '••••••••';
return '••••••••' + secret.slice(-4);
}
}