mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +00:00
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:
+144
-4
@@ -26,6 +26,7 @@ import { templateService } from './services/TemplateService';
|
||||
import { ErrorParser } from './utils/ErrorParser';
|
||||
import { NodeRegistry } from './services/NodeRegistry';
|
||||
import { LicenseService } from './services/LicenseService';
|
||||
import { WebhookService } from './services/WebhookService';
|
||||
import { isValidStackName, isValidRemoteUrl } from './utils/validation';
|
||||
import YAML from 'yaml';
|
||||
import fs, { promises as fsPromises } from 'fs';
|
||||
@@ -142,7 +143,8 @@ app.use((req: Request, res: Response, next: NextFunction): void => {
|
||||
!req.path.startsWith('/api/auth/') &&
|
||||
!req.path.startsWith('/api/nodes') &&
|
||||
!req.path.startsWith('/api/license') &&
|
||||
!req.path.startsWith('/api/fleet')
|
||||
!req.path.startsWith('/api/fleet') &&
|
||||
!req.path.startsWith('/api/webhooks')
|
||||
) {
|
||||
// Preserve body stream for proxy piping
|
||||
next();
|
||||
@@ -174,7 +176,8 @@ const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction)
|
||||
!req.path.startsWith('/api/auth/') &&
|
||||
!req.path.startsWith('/api/nodes') &&
|
||||
!req.path.startsWith('/api/license') &&
|
||||
!req.path.startsWith('/api/fleet')
|
||||
!req.path.startsWith('/api/fleet') &&
|
||||
!req.path.startsWith('/api/webhooks')
|
||||
) {
|
||||
const node = DatabaseService.getInstance().getNode(req.nodeId);
|
||||
if (!node) {
|
||||
@@ -424,7 +427,7 @@ app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, r
|
||||
|
||||
// Apply authentication middleware to all /api/* routes except /api/auth/*
|
||||
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (req.path.startsWith('/auth/')) {
|
||||
if (req.path.startsWith('/auth/') || /^\/webhooks\/\d+\/trigger$/.test(req.path)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -748,6 +751,143 @@ async function fetchRemoteNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Webhooks (Pro) ─── CRUD requires auth + Pro, trigger is public with HMAC ───
|
||||
|
||||
// Webhook CRUD (auth + Pro required)
|
||||
app.get('/api/webhooks', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(_req, res)) return;
|
||||
try {
|
||||
const webhooks = DatabaseService.getInstance().getWebhooks();
|
||||
const svc = WebhookService.getInstance();
|
||||
res.json(webhooks.map(w => ({ ...w, secret: svc.maskSecret(w.secret) })));
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] List error:', error);
|
||||
res.status(500).json({ error: 'Failed to list webhooks' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/webhooks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const { name, stack_name, action, enabled } = req.body;
|
||||
if (!name || !stack_name || !action) {
|
||||
res.status(400).json({ error: 'name, stack_name, and action are required' });
|
||||
return;
|
||||
}
|
||||
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull'];
|
||||
if (!validActions.includes(action)) {
|
||||
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const svc = WebhookService.getInstance();
|
||||
const secret = svc.generateSecret();
|
||||
const id = DatabaseService.getInstance().addWebhook({
|
||||
name, stack_name, action, secret, enabled: enabled !== false,
|
||||
});
|
||||
|
||||
// Return the full secret only on creation
|
||||
res.status(201).json({ id, secret });
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] Create error:', error);
|
||||
res.status(500).json({ error: 'Failed to create webhook' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const webhook = DatabaseService.getInstance().getWebhook(id);
|
||||
if (!webhook) { res.status(404).json({ error: 'Webhook not found' }); return; }
|
||||
|
||||
const { name, stack_name, action, enabled } = req.body;
|
||||
const validActions = ['deploy', 'restart', 'stop', 'start', 'pull'];
|
||||
if (action && !validActions.includes(action)) {
|
||||
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().updateWebhook(id, { name, stack_name, action, enabled });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] Update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update webhook' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/webhooks/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
DatabaseService.getInstance().deleteWebhook(id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] Delete error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete webhook' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/webhooks/:id/history', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePro(req, res)) return;
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const executions = DatabaseService.getInstance().getWebhookExecutions(id);
|
||||
res.json(executions);
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] History error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch webhook history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Webhook trigger — public endpoint, authenticated via HMAC signature
|
||||
app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string, 10);
|
||||
const db = DatabaseService.getInstance();
|
||||
const webhook = db.getWebhook(id);
|
||||
|
||||
if (!webhook || !webhook.enabled) {
|
||||
res.status(404).json({ error: 'Webhook not found or disabled' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Pro gate — trigger only works with an active Pro license
|
||||
if (LicenseService.getInstance().getTier() !== 'pro') {
|
||||
res.status(403).json({ error: 'This feature requires Sencho Pro.', code: 'PRO_REQUIRED' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate HMAC signature
|
||||
const signature = req.headers['x-webhook-signature'] as string;
|
||||
if (!signature) {
|
||||
res.status(401).json({ error: 'Missing X-Webhook-Signature header' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBody = JSON.stringify(req.body ?? {});
|
||||
const svc = WebhookService.getInstance();
|
||||
if (!svc.validateSignature(rawBody, webhook.secret, signature)) {
|
||||
res.status(401).json({ error: 'Invalid signature' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Use action from body if provided, otherwise use webhook default
|
||||
const action = req.body?.action || webhook.action;
|
||||
const triggerSource = req.headers['user-agent'] || req.ip || null;
|
||||
|
||||
// Execute asynchronously — return 202 immediately
|
||||
res.status(202).json({ message: 'Webhook accepted', action });
|
||||
|
||||
svc.execute(id, action, triggerSource).catch(err => {
|
||||
console.error(`[Webhooks] Execution error for webhook ${id}:`, err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Webhooks] Trigger error:', error);
|
||||
res.status(500).json({ error: 'Failed to process webhook' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remote Node HTTP Proxy - single global instance.
|
||||
// Previously, createProxyMiddleware was called inside the request handler on every API
|
||||
// call, spawning a new proxy instance (and http-proxy server) each time. This caused:
|
||||
@@ -823,7 +963,7 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
|
||||
// Intercepts all /api/ requests for remote Distributed API nodes and forwards them
|
||||
// to the target Sencho instance. Node management and auth routes always execute locally.
|
||||
app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet')) {
|
||||
if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet') || req.path.startsWith('/webhooks')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user