mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -41,6 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* **fleet:** Critical node detection with red badge for nodes exceeding 90% CPU or disk usage
|
||||
* **fleet:** Error toasts on stack and container fetch failures (replaces silent error swallowing)
|
||||
* **fleet:** ProGate now wraps placeholder content instead of empty children for a better upgrade preview
|
||||
* **webhooks:** Custom CI/CD webhooks — create webhooks targeting specific stacks and actions (deploy, restart, stop, start, pull), trigger them from GitHub Actions, GitLab CI, or any HTTP client with HMAC-SHA256 signature authentication (Pro)
|
||||
* **webhooks:** Execution history tracking — last 100 executions per webhook with status, duration, and error details
|
||||
* **webhooks:** Webhook management UI in Settings with create/edit/delete, enable/disable toggle, one-time secret reveal, and copy-to-clipboard for trigger URLs
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -36,7 +36,8 @@
|
||||
"features/host-console",
|
||||
"features/multi-node",
|
||||
"features/fleet-view",
|
||||
"features/alerts-notifications"
|
||||
"features/alerts-notifications",
|
||||
"features/webhooks"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -47,6 +47,10 @@ Browse 190+ pre-configured application templates. Filter by category (Media, Aut
|
||||
|
||||
Open an interactive terminal on the host OS directly in the browser — full xterm.js emulation with color support. No SSH client required.
|
||||
|
||||
## Webhooks
|
||||
|
||||
Trigger stack actions from external CI/CD pipelines via HTTP webhooks. Create a webhook targeting a specific stack and action, then call it from GitHub Actions, GitLab CI, or any system that can send an HTTP POST. Requests are authenticated with HMAC-SHA256 signatures — no session cookies required. [Learn more →](/features/webhooks)
|
||||
|
||||
## Alerts & notifications
|
||||
|
||||
Configure threshold-based alerts (CPU, memory, network, restart count) per stack. Route notifications to Discord, Slack, or any generic webhook endpoint. [Learn more →](/features/alerts-notifications)
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: Webhooks
|
||||
description: Trigger stack actions from CI/CD pipelines via HTTP webhooks with HMAC signature authentication.
|
||||
---
|
||||
|
||||
<Note>
|
||||
Webhooks require a Sencho Pro license.
|
||||
</Note>
|
||||
|
||||
Sencho webhooks let external systems trigger stack actions over HTTP. The typical use case: your CI pipeline builds a new image, then calls a Sencho webhook to deploy the updated stack — no manual intervention required.
|
||||
|
||||
## How it works
|
||||
|
||||
1. You create a webhook in **Settings → Webhooks**, targeting a specific stack and action
|
||||
2. Sencho generates a unique secret for HMAC-SHA256 signature validation
|
||||
3. Your CI/CD system sends a `POST` request to the trigger URL with the correct signature
|
||||
4. Sencho validates the signature and executes the action asynchronously
|
||||
|
||||
## Creating a webhook
|
||||
|
||||
Open **Settings → Webhooks** and click **Create Webhook**. Fill in:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Name** | A display name (e.g. "Deploy on push") |
|
||||
| **Stack** | The target stack to act on |
|
||||
| **Action** | One of: `deploy`, `restart`, `stop`, `start`, `pull` |
|
||||
|
||||
After creation, Sencho shows the webhook secret **once**. Copy it immediately — it cannot be retrieved later.
|
||||
|
||||
### Actions explained
|
||||
|
||||
| Action | What it does |
|
||||
|--------|-------------|
|
||||
| **Deploy** | Runs `docker compose down` then `docker compose up -d` (full redeploy) |
|
||||
| **Restart** | Runs `docker compose restart` |
|
||||
| **Stop** | Runs `docker compose stop` |
|
||||
| **Start** | Runs `docker compose start` |
|
||||
| **Pull** | Pulls latest images and recreates changed containers |
|
||||
|
||||
## Triggering a webhook
|
||||
|
||||
Send a POST request to the trigger URL with an HMAC-SHA256 signature:
|
||||
|
||||
```
|
||||
POST /api/webhooks/{id}/trigger
|
||||
Content-Type: application/json
|
||||
X-Webhook-Signature: sha256={hmac}
|
||||
```
|
||||
|
||||
The signature is computed as `HMAC-SHA256(request_body, webhook_secret)` and sent in the `X-Webhook-Signature` header with a `sha256=` prefix.
|
||||
|
||||
### Example with curl
|
||||
|
||||
```bash
|
||||
SECRET="your-webhook-secret"
|
||||
BODY='{}'
|
||||
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)
|
||||
|
||||
curl -X POST https://your-sencho.com/api/webhooks/3/trigger \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Webhook-Signature: sha256=$SIGNATURE" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
### Overriding the action
|
||||
|
||||
By default, the webhook executes the action configured at creation time. You can override it per-request by including an `action` field in the body:
|
||||
|
||||
```json
|
||||
{ "action": "restart" }
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
A successful trigger returns `202 Accepted` immediately. The action executes asynchronously in the background.
|
||||
|
||||
```json
|
||||
{ "message": "Webhook accepted", "action": "deploy" }
|
||||
```
|
||||
|
||||
## CI/CD integration examples
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
- name: Deploy via Sencho
|
||||
run: |
|
||||
BODY='{}'
|
||||
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "${{ secrets.SENCHO_WEBHOOK_SECRET }}" | cut -d' ' -f2)
|
||||
curl -X POST "${{ secrets.SENCHO_URL }}/api/webhooks/${{ secrets.SENCHO_WEBHOOK_ID }}/trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Webhook-Signature: sha256=$SIGNATURE" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
### GitLab CI
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
stage: deploy
|
||||
script:
|
||||
- BODY='{}'
|
||||
- SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SENCHO_WEBHOOK_SECRET" | cut -d' ' -f2)
|
||||
- 'curl -X POST "$SENCHO_URL/api/webhooks/$SENCHO_WEBHOOK_ID/trigger"
|
||||
-H "Content-Type: application/json"
|
||||
-H "X-Webhook-Signature: sha256=$SIGNATURE"
|
||||
-d "$BODY"'
|
||||
```
|
||||
|
||||
## Execution history
|
||||
|
||||
Each webhook tracks its last 100 executions. Click **Recent executions** on any webhook row to see:
|
||||
|
||||
- Status (success/failure)
|
||||
- Action performed
|
||||
- Execution time
|
||||
- Duration
|
||||
- Error message (if failed)
|
||||
|
||||
## Security
|
||||
|
||||
- Webhook secrets are 64-character hex strings generated with `crypto.randomBytes(32)`
|
||||
- Signature validation uses `crypto.timingSafeEqual` to prevent timing attacks
|
||||
- Secrets are shown only once at creation — API responses return masked values
|
||||
- Webhook trigger endpoints are public (no session cookie required) but protected by HMAC signature validation
|
||||
- Each webhook targets a single stack — there is no way to execute arbitrary commands
|
||||
@@ -18,12 +18,13 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import { Shield, Activity, Bell, Code, Server, Package, RefreshCw, Database, Info, Crown, CheckCircle, XCircle, Clock, Webhook, Copy, Trash2, Plus, ChevronDown, ChevronRight, History } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { NodeManager } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { ProBadge } from './ProBadge';
|
||||
import { ProGate } from './ProGate';
|
||||
|
||||
interface Agent {
|
||||
type: 'discord' | 'slack' | 'webhook';
|
||||
@@ -45,7 +46,29 @@ interface PatchableSettings {
|
||||
log_retention_days?: string;
|
||||
}
|
||||
|
||||
type SectionId = 'account' | 'license' | 'system' | 'notifications' | 'developer' | 'nodes' | 'appstore' | 'about';
|
||||
type SectionId = 'account' | 'license' | 'system' | 'notifications' | 'webhooks' | 'developer' | 'nodes' | 'appstore' | 'about';
|
||||
|
||||
interface WebhookItem {
|
||||
id: number;
|
||||
name: string;
|
||||
stack_name: string;
|
||||
action: string;
|
||||
secret: string;
|
||||
enabled: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -65,9 +88,293 @@ const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
log_retention_days: '30',
|
||||
};
|
||||
|
||||
function WebhooksSection({ isPro }: { isPro: boolean }) {
|
||||
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [newSecret, setNewSecret] = useState<{ id: number; secret: string } | null>(null);
|
||||
const [expandedHistory, setExpandedHistory] = useState<number | null>(null);
|
||||
const [history, setHistory] = useState<Record<number, WebhookExecution[]>>({});
|
||||
const [loadingHistory, setLoadingHistory] = useState<number | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formStack, setFormStack] = useState('');
|
||||
const [formAction, setFormAction] = useState<string>('deploy');
|
||||
const [stacks, setStacks] = useState<string[]>([]);
|
||||
|
||||
const fetchWebhooks = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/webhooks', { localOnly: true });
|
||||
if (res.ok) setWebhooks(await res.json());
|
||||
} catch { /* ignore */ } finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const fetchStacks = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/stacks');
|
||||
if (res.ok) setStacks(await res.json());
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchWebhooks(); fetchStacks(); }, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formName || !formStack || !formAction) {
|
||||
toast.error('All fields are required.');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await apiFetch('/webhooks', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ name: formName, stack_name: formStack, action: formAction }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNewSecret({ id: data.id, secret: data.secret });
|
||||
setShowForm(false);
|
||||
setFormName(''); setFormStack(''); setFormAction('deploy');
|
||||
fetchWebhooks();
|
||||
toast.success('Webhook created.');
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Failed to create webhook.');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally { setCreating(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const res = await apiFetch(`/webhooks/${id}`, { method: 'DELETE', localOnly: true });
|
||||
if (res.ok) { toast.success('Webhook deleted.'); fetchWebhooks(); }
|
||||
else { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to delete.'); }
|
||||
} catch { toast.error('Network error.'); }
|
||||
};
|
||||
|
||||
const handleToggle = async (id: number, enabled: boolean) => {
|
||||
try {
|
||||
const res = await apiFetch(`/webhooks/${id}`, {
|
||||
method: 'PUT', localOnly: true,
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (res.ok) fetchWebhooks();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const fetchHistory = async (webhookId: number) => {
|
||||
if (expandedHistory === webhookId) { setExpandedHistory(null); return; }
|
||||
setExpandedHistory(webhookId);
|
||||
setLoadingHistory(webhookId);
|
||||
try {
|
||||
const res = await apiFetch(`/webhooks/${webhookId}/history`, { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setHistory(prev => ({ ...prev, [webhookId]: data }));
|
||||
}
|
||||
} catch { /* ignore */ } finally { setLoadingHistory(null); }
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard.`);
|
||||
};
|
||||
|
||||
if (!isPro) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">Webhooks <ProBadge /></h3>
|
||||
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
|
||||
</div>
|
||||
<ProGate featureName="Webhooks">
|
||||
<div className="space-y-3">
|
||||
<div className="h-16 rounded-xl border bg-card" />
|
||||
<div className="h-16 rounded-xl border bg-card" />
|
||||
</div>
|
||||
</ProGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight flex items-center gap-2">Webhooks <ProBadge /></h3>
|
||||
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setShowForm(!showForm)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Create Webhook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showForm && (
|
||||
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input placeholder="Deploy on push" value={formName} onChange={e => setFormName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Stack</Label>
|
||||
<Select value={formStack} onValueChange={setFormStack}>
|
||||
<SelectTrigger><SelectValue placeholder="Select a stack..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{stacks.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Action</Label>
|
||||
<Select value={formAction} onValueChange={setFormAction}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deploy">Deploy (down + up)</SelectItem>
|
||||
<SelectItem value="restart">Restart</SelectItem>
|
||||
<SelectItem value="stop">Stop</SelectItem>
|
||||
<SelectItem value="start">Start</SelectItem>
|
||||
<SelectItem value="pull">Pull & Update</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleCreate} disabled={creating}>
|
||||
{creating ? <><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" />Creating...</> : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secret reveal (shown once after creation) */}
|
||||
{newSecret && (
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle className="w-4 h-4" /> Webhook created — copy your secret now
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">This secret will not be shown again. Store it securely.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-xs font-mono bg-muted px-3 py-2 rounded-lg break-all">{newSecret.secret}</code>
|
||||
<Button variant="outline" size="sm" onClick={() => copyToClipboard(newSecret.secret, 'Secret')}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setNewSecret(null)}>Dismiss</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
<Skeleton className="h-20 w-full rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && webhooks.length === 0 && !showForm && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Webhook className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No webhooks configured yet.</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Create one to trigger stack actions from CI/CD.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhook list */}
|
||||
{!loading && webhooks.map(wh => {
|
||||
const triggerUrl = `${window.location.origin}/api/webhooks/${wh.id}/trigger`;
|
||||
const isExpanded = expandedHistory === wh.id;
|
||||
return (
|
||||
<div key={wh.id} className="border border-border rounded-xl overflow-hidden">
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Webhook className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium text-sm truncate">{wh.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">{wh.action}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">{wh.stack_name}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Switch checked={wh.enabled} onCheckedChange={(c) => handleToggle(wh.id!, c)} />
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleDelete(wh.id!)}>
|
||||
<Trash2 className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trigger URL */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Trigger URL</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-[11px] font-mono bg-muted px-2.5 py-1.5 rounded-md truncate">{triggerUrl}</code>
|
||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => copyToClipboard(triggerUrl, 'URL')}>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secret (masked) */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">Secret:</span>
|
||||
<code className="font-mono text-muted-foreground">{wh.secret}</code>
|
||||
</div>
|
||||
|
||||
{/* History toggle */}
|
||||
<button
|
||||
onClick={() => fetchHistory(wh.id!)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
<History className="w-3 h-3" />
|
||||
Recent executions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Execution history */}
|
||||
{isExpanded && (
|
||||
<div className="border-t bg-muted/20 px-4 py-3">
|
||||
{loadingHistory === wh.id ? (
|
||||
<Skeleton className="h-8 w-full" />
|
||||
) : (history[wh.id!] ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No executions yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{(history[wh.id!] ?? []).map(ex => (
|
||||
<div key={ex.id} className="flex items-center gap-2 text-xs">
|
||||
{ex.status === 'success'
|
||||
? <CheckCircle className="w-3 h-3 text-emerald-500 shrink-0" />
|
||||
: <XCircle className="w-3 h-3 text-red-500 shrink-0" />}
|
||||
<span className="font-medium">{ex.action}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(ex.executed_at).toLocaleString()}
|
||||
</span>
|
||||
{ex.duration_ms !== null && (
|
||||
<span className="text-muted-foreground">{(ex.duration_ms / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
{ex.error && (
|
||||
<span className="text-red-500 truncate" title={ex.error}>{ex.error}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const { license, activate, deactivate } = useLicense();
|
||||
const { license, isPro, activate, deactivate } = useLicense();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('account');
|
||||
const [licenseKeyInput, setLicenseKeyInput] = useState('');
|
||||
@@ -76,7 +383,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
|
||||
// When switching to a remote node, reset to a node-scoped section if on a global-only one
|
||||
useEffect(() => {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'notifications' || activeSection === 'nodes' || activeSection === 'appstore')) {
|
||||
if (isRemote && (activeSection === 'account' || activeSection === 'license' || activeSection === 'notifications' || activeSection === 'webhooks' || activeSection === 'nodes' || activeSection === 'appstore')) {
|
||||
setActiveSection('system');
|
||||
}
|
||||
}, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -421,6 +728,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
showDot={hasSystemChanges}
|
||||
/>
|
||||
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
|
||||
{!isRemote && (
|
||||
<NavButton section="webhooks" icon={<Webhook className="w-4 h-4 mr-2" />} label="Webhooks" />
|
||||
)}
|
||||
<NavButton
|
||||
section="developer"
|
||||
icon={<Code className="w-4 h-4 mr-2" />}
|
||||
@@ -778,6 +1088,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'webhooks' && (
|
||||
<WebhooksSection isPro={isPro} />
|
||||
)}
|
||||
|
||||
{activeSection === 'developer' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between pr-8">
|
||||
|
||||
Reference in New Issue
Block a user