## Security (red-team audit 2026-04-12) Public data exposure (P0): - Public map converted to server-side heatmap, 2-decimal (~1.1km) bucketing, no addresses/support-levels/sign-info returned - Petition signers endpoint strips displayName/signerComment/geoCity/geoCountry - Petition public-stats drops recentSigners entirely - Response wall strips userComment + submittedByName - Campaign createdByUserEmail + moderation fields gated to SUPER_ADMIN Access control (P1): - Campaign findById/update/delete/email-stats enforce owner === req.user.id (SUPER_ADMIN bypasses), return 404 to avoid enumeration - GPS tracking session route restricted to session owner or SUPER_ADMIN - Canvass volunteer stats restricted to self or SUPER_ADMIN - People household endpoints restricted to INFLUENCE + MAP roles (was ADMIN*) - CCP upgrade.service.ts + certificate.service.ts gate user-controlled shell inputs (branch, path, slug, SAN hostname) behind regex validators Token security (P2): - Query-param JWT auth replaced with HMAC-signed short-lived URLs (utils/signed-url.ts + /api/media/sign endpoint); legacy ?token= removed from media streaming, photos, chat-notifications, and social SSE - GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT now REQUIRED (min 32 chars); JWT_ACCESS_SECRET fallback removed — BREAKING for existing deployments - Refresh tokens bound to device fingerprint (UA + /24 IP) via `df` JWT claim; mismatch revokes all user sessions - Refresh expiry reduced 7d → 24h - Refresh/logout via request body removed — httpOnly cookie only - Password-reset + verification-resend rate limits now keyed on (IP, email) composite to prevent both IP rotation and email enumeration Defense-in-depth (P3): - DOMPurify sanitization applied to GrapesJS landing page HTML/CSS - /api/health?detailed=true disk-space leak removed - Password-reset/verification token log lines no longer include userId ## Deployment - docker-compose.yml + docker-compose.prod.yml: media-api now receives GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT; empty fallbacks removed - CCP templates/env.hbs adds both new secrets; refresh expiry → 24h - CCP secret-generator.ts generates giteaSsoSecret + servicePasswordSalt - leaflet.heat added to admin/package.json for heatmap rendering ## Operator action required on existing installs Run `./config.sh` once (idempotent — only fills empty values) or manually add GITEA_SSO_SECRET + SERVICE_PASSWORD_SALT to .env via `openssl rand -hex 32`. Startup fails with a clear Zod error otherwise. See SECURITY_REDTEAM_2026-04-12.md for full audit and verification matrix. ## Other Includes in-flight CCP work: instance schema tweaks, agent server updates, health service, tunnel service, DEV_WORKFLOW doc updates, and new migration dropping composeProject uniqueness. Bunker Admin
502 lines
16 KiB
TypeScript
502 lines
16 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import { AuditAction } from '@prisma/client';
|
|
import rateLimit from 'express-rate-limit';
|
|
import { prisma } from '../../lib/prisma';
|
|
import { authenticate, requireRole } from '../../middleware/auth';
|
|
import { validate } from '../../middleware/validate';
|
|
import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, configureTunnelSchema, importInstancesSchema, startUpgradeSchema, setupRemoteTunnelSchema } from './instances.schemas';
|
|
import * as instancesService from './instances.service';
|
|
import * as healthService from '../../services/health.service';
|
|
import * as backupService from '../../services/backup.service';
|
|
import * as restoreService from '../../services/restore.service';
|
|
import * as upgradeService from '../../services/upgrade.service';
|
|
import * as tunnelService from '../../services/tunnel.service';
|
|
import { discoverInstances } from '../../services/discovery.service';
|
|
|
|
const secretsLimiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000,
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: { message: 'Too many secrets requests, please try again later', code: 'RATE_LIMITED' } },
|
|
});
|
|
|
|
const router = Router();
|
|
|
|
// All instance routes require authentication
|
|
router.use(authenticate);
|
|
|
|
// ─── Discovery Endpoints ────────────────────────────────────────────
|
|
|
|
router.post(
|
|
'/discover',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (_req: Request, res: Response) => {
|
|
const result = await discoverInstances();
|
|
res.json({ data: result });
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/import',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
validate(importInstancesSchema),
|
|
async (req: Request, res: Response) => {
|
|
const { instances } = req.body as { instances: Array<Record<string, unknown>> };
|
|
const results: Array<{ slug: string; success: boolean; instanceId?: string; error?: string }> = [];
|
|
|
|
for (const inst of instances) {
|
|
try {
|
|
const registered = await instancesService.registerInstance(
|
|
inst as Parameters<typeof instancesService.registerInstance>[0],
|
|
req.user!.id,
|
|
req.ip
|
|
);
|
|
results.push({ slug: inst.slug as string, success: true, instanceId: registered?.id });
|
|
} catch (err) {
|
|
results.push({ slug: inst.slug as string, success: false, error: (err as Error).message });
|
|
}
|
|
}
|
|
|
|
const succeeded = results.filter((r) => r.success).length;
|
|
const failed = results.filter((r) => !r.success).length;
|
|
res.json({
|
|
data: {
|
|
results,
|
|
summary: { total: results.length, succeeded, failed },
|
|
},
|
|
});
|
|
}
|
|
);
|
|
|
|
// ─── CRUD Endpoints ──────────────────────────────────────────────────
|
|
|
|
router.get('/', requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => {
|
|
const instances = await instancesService.listInstances();
|
|
res.json({ data: instances });
|
|
});
|
|
|
|
// Register an existing (externally-managed) instance for monitoring
|
|
router.post(
|
|
'/register',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
validate(registerInstanceSchema),
|
|
async (req: Request, res: Response) => {
|
|
const instance = await instancesService.registerInstance(req.body, req.user!.id, req.ip);
|
|
res.status(201).json({ data: instance });
|
|
}
|
|
);
|
|
|
|
router.get('/:id', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
|
|
const instance = await instancesService.getInstance(req.params.id as string);
|
|
res.json({ data: instance });
|
|
});
|
|
|
|
router.post(
|
|
'/',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
validate(createInstanceSchema),
|
|
async (req: Request, res: Response) => {
|
|
const instance = await instancesService.createInstance(req.body, req.user!.id, req.ip);
|
|
res.status(201).json({ data: instance });
|
|
}
|
|
);
|
|
|
|
router.put(
|
|
'/:id',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
validate(updateInstanceSchema),
|
|
async (req: Request, res: Response) => {
|
|
const instance = await instancesService.updateInstance(
|
|
req.params.id as string,
|
|
req.body,
|
|
req.user!.id,
|
|
req.ip
|
|
);
|
|
res.json({ data: instance });
|
|
}
|
|
);
|
|
|
|
router.delete(
|
|
'/:id',
|
|
requireRole('SUPER_ADMIN'),
|
|
async (req: Request, res: Response) => {
|
|
const result = await instancesService.deleteInstance(req.params.id as string, req.user!.id, req.ip);
|
|
res.json(result);
|
|
}
|
|
);
|
|
|
|
// Get decrypted secrets (SUPER_ADMIN only, rate limited)
|
|
router.get(
|
|
'/:id/secrets',
|
|
secretsLimiter,
|
|
requireRole('SUPER_ADMIN'),
|
|
async (req: Request, res: Response) => {
|
|
const secrets = await instancesService.getInstanceSecrets(req.params.id as string);
|
|
|
|
// Audit log: someone viewed secrets
|
|
await prisma.auditLog.create({
|
|
data: {
|
|
userId: req.user!.id,
|
|
instanceId: req.params.id as string,
|
|
action: AuditAction.SECRETS_VIEWED,
|
|
details: { instanceId: req.params.id as string },
|
|
ipAddress: req.ip,
|
|
},
|
|
});
|
|
|
|
res.set('Cache-Control', 'no-store');
|
|
res.json({ data: secrets });
|
|
}
|
|
);
|
|
|
|
// ─── Reconfiguration ────────────────────────────────────────────────
|
|
|
|
router.post(
|
|
'/:id/reconfigure',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
validate(reconfigureInstanceSchema),
|
|
async (req: Request, res: Response) => {
|
|
const result = await instancesService.reconfigureInstance(
|
|
req.params.id as string,
|
|
req.body,
|
|
req.user!.id,
|
|
req.ip
|
|
);
|
|
res.json({ data: result });
|
|
}
|
|
);
|
|
|
|
// ─── Tunnel Management ──────────────────────────────────────────────
|
|
|
|
router.post(
|
|
'/:id/tunnel',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
validate(configureTunnelSchema),
|
|
async (req: Request, res: Response) => {
|
|
const result = await instancesService.configureTunnel(
|
|
req.params.id as string,
|
|
req.body,
|
|
req.user!.id,
|
|
req.ip
|
|
);
|
|
res.json({ data: result });
|
|
}
|
|
);
|
|
|
|
router.delete(
|
|
'/:id/tunnel',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
// Branch: remote instances use the CCP's Pangolin API to teardown;
|
|
// local instances use the existing manual removal logic.
|
|
const instance = await prisma.instance.findUnique({ where: { id: req.params.id as string } });
|
|
if (instance?.isRemote && instance.pangolinSiteId) {
|
|
const result = await tunnelService.teardownTunnel(
|
|
req.params.id as string,
|
|
req.user!.id,
|
|
req.ip
|
|
);
|
|
res.json({ data: result });
|
|
return;
|
|
}
|
|
const result = await instancesService.removeTunnel(
|
|
req.params.id as string,
|
|
req.user!.id,
|
|
req.ip
|
|
);
|
|
res.json({ data: result });
|
|
}
|
|
);
|
|
|
|
// Remote tunnel setup via CCP's Pangolin API credentials
|
|
router.post(
|
|
'/:id/tunnel/setup',
|
|
requireRole('SUPER_ADMIN'),
|
|
validate(setupRemoteTunnelSchema),
|
|
async (req: Request, res: Response) => {
|
|
const { subdomainPrefix } = req.body || {};
|
|
const result = await tunnelService.setupTunnel(
|
|
req.params.id as string,
|
|
{ subdomainPrefix },
|
|
req.user!.id,
|
|
req.ip
|
|
);
|
|
res.status(201).json({ data: result });
|
|
}
|
|
);
|
|
|
|
// Get tunnel status (resource matrix) — works for both local and remote
|
|
router.get(
|
|
'/:id/tunnel/status',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const status = await tunnelService.getTunnelStatus(req.params.id as string);
|
|
res.json({ data: status });
|
|
}
|
|
);
|
|
|
|
// Re-sync resources (idempotent — creates missing, leaves existing)
|
|
router.post(
|
|
'/:id/tunnel/sync',
|
|
requireRole('SUPER_ADMIN'),
|
|
async (req: Request, res: Response) => {
|
|
const result = await tunnelService.syncResources(
|
|
req.params.id as string,
|
|
req.user!.id,
|
|
req.ip
|
|
);
|
|
res.json({ data: result });
|
|
}
|
|
);
|
|
|
|
// Adopt a tunnel that was set up outside CCP (e.g. by config.sh --pangolin-site new)
|
|
router.post(
|
|
'/:id/tunnel/import',
|
|
requireRole('SUPER_ADMIN'),
|
|
async (req: Request, res: Response) => {
|
|
const result = await tunnelService.importTunnel(
|
|
req.params.id as string,
|
|
req.user!.id,
|
|
req.ip
|
|
);
|
|
res.json({ data: result });
|
|
}
|
|
);
|
|
|
|
// ─── Lifecycle Endpoints ─────────────────────────────────────────────
|
|
|
|
router.post(
|
|
'/:id/provision',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const result = await instancesService.provisionInstance(req.params.id as string, req.user!.id, req.ip);
|
|
res.json(result);
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/:id/start',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const result = await instancesService.startInstance(req.params.id as string, req.user!.id, req.ip);
|
|
res.json(result);
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/:id/stop',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const result = await instancesService.stopInstance(req.params.id as string, req.user!.id, req.ip);
|
|
res.json(result);
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/:id/restart',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const service = req.query.service as string | undefined;
|
|
const result = await instancesService.restartInstance(
|
|
req.params.id as string,
|
|
req.user!.id,
|
|
req.ip,
|
|
service
|
|
);
|
|
res.json(result);
|
|
}
|
|
);
|
|
|
|
// ─── Services & Logs ─────────────────────────────────────────────────
|
|
|
|
router.get(
|
|
'/:id/services',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const services = await instancesService.getInstanceServices(req.params.id as string);
|
|
res.json({ data: services });
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
'/:id/logs',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const { service, tail, since } = req.query;
|
|
const tailNum = tail ? Math.min(Math.max(parseInt(tail as string, 10) || 200, 1), 2000) : 200;
|
|
const logs = await instancesService.getInstanceLogs(
|
|
req.params.id as string,
|
|
service as string | undefined,
|
|
tailNum,
|
|
since as string | undefined
|
|
);
|
|
res.json({ data: logs });
|
|
}
|
|
);
|
|
|
|
// ─── Upgrades ──────────────────────────────────────────────────────
|
|
|
|
router.post(
|
|
'/:id/check-update',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const status = await upgradeService.checkForUpdates(req.params.id as string);
|
|
res.json({ data: status });
|
|
}
|
|
);
|
|
|
|
router.post(
|
|
'/:id/upgrade',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
validate(startUpgradeSchema),
|
|
async (req: Request, res: Response) => {
|
|
const { skipBackup, useRegistry, branch } = req.body || {};
|
|
const upgrade = await upgradeService.startUpgrade(
|
|
req.params.id as string,
|
|
req.user!.id,
|
|
req.ip,
|
|
{ skipBackup, useRegistry, branch }
|
|
);
|
|
res.status(201).json({ data: upgrade });
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
'/:id/upgrade-status',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const status = await upgradeService.getUpgradeStatus(req.params.id as string);
|
|
res.json({ data: status });
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
'/:id/upgrade-history',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
|
|
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20));
|
|
const result = await upgradeService.getUpgradeHistory(req.params.id as string, page, limit);
|
|
res.json(result);
|
|
}
|
|
);
|
|
|
|
// ─── Health Checks ──────────────────────────────────────────────────
|
|
|
|
router.post(
|
|
'/:id/health-check',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const check = await healthService.checkInstanceHealth(req.params.id as string);
|
|
res.json({ data: check });
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
'/:id/health-history',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
|
|
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20));
|
|
const result = await healthService.getHealthHistory(req.params.id as string, page, limit);
|
|
res.json(result);
|
|
}
|
|
);
|
|
|
|
// ─── Backups ────────────────────────────────────────────────────────
|
|
|
|
router.post(
|
|
'/:id/backup',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const backup = await backupService.createBackup(req.params.id as string, req.user!.id, req.ip);
|
|
res.status(201).json({ data: backup });
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
'/:id/backups',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
|
|
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50));
|
|
const result = await backupService.listBackups(req.params.id as string, page, limit);
|
|
res.json(result);
|
|
}
|
|
);
|
|
|
|
// ─── Restores ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* POST /:id/restore
|
|
* Body: { backupId, options? }
|
|
* Starts a restore of the given backup onto this instance. Returns the
|
|
* InstanceRestore row immediately; caller polls GET /:id/restores or
|
|
* GET /:id/restores/:restoreId for status.
|
|
*
|
|
* DESTRUCTIVE: overwrites databases and uploads. Requires SUPER_ADMIN.
|
|
*/
|
|
router.post(
|
|
'/:id/restore',
|
|
requireRole('SUPER_ADMIN'),
|
|
async (req: Request, res: Response) => {
|
|
const instanceId = req.params.id as string;
|
|
const { backupId, options } = req.body ?? {};
|
|
if (!backupId || typeof backupId !== 'string') {
|
|
res.status(400).json({ error: { message: 'backupId (string) is required', code: 'VALIDATION' } });
|
|
return;
|
|
}
|
|
|
|
// Defensive: ensure the backup belongs to this instance
|
|
const backup = await prisma.backup.findUnique({ where: { id: backupId } });
|
|
if (!backup) {
|
|
res.status(404).json({ error: { message: 'Backup not found', code: 'NOT_FOUND' } });
|
|
return;
|
|
}
|
|
if (backup.instanceId !== instanceId) {
|
|
res.status(400).json({
|
|
error: {
|
|
message: 'Backup does not belong to this instance (cross-instance restore is not supported)',
|
|
code: 'CROSS_INSTANCE_RESTORE',
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const restore = await restoreService.createRestore({
|
|
backupId,
|
|
triggeredById: req.user!.id,
|
|
ipAddress: req.ip,
|
|
options,
|
|
});
|
|
res.status(201).json({ data: restore });
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
'/:id/restores',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
|
|
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50));
|
|
const result = await restoreService.listRestores(req.params.id as string, page, limit);
|
|
res.json(result);
|
|
}
|
|
);
|
|
|
|
router.get(
|
|
'/:id/restores/:restoreId',
|
|
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
|
async (req: Request, res: Response) => {
|
|
const restore = await restoreService.getRestore(req.params.restoreId as string);
|
|
if (restore.instanceId !== req.params.id) {
|
|
res.status(404).json({ error: { message: 'Restore not found', code: 'NOT_FOUND' } });
|
|
return;
|
|
}
|
|
res.json({ data: restore });
|
|
}
|
|
);
|
|
|
|
export default router;
|