1478 lines
60 KiB
TypeScript
1478 lines
60 KiB
TypeScript
/**
|
|
* SSH HTTP layer — Phase 5 of the SSH tool integration plan
|
|
* (docs/superpowers/plans/2026-05-12-ssh-tool-integration.md).
|
|
*
|
|
* Two router factories:
|
|
* createSshUserRouter — `/api/ssh/connections` + `/api/ssh/grants`
|
|
* createSshAdminRouter — `/api/ssh/admin/*`
|
|
*
|
|
* Both are mounted from src/bridge/server.ts ONLY when `ssh.enabled=true`
|
|
* AND `MCP_ENCRYPTION_KEY` is configured (the master key gates the envelope-
|
|
* encrypted DEKs in Phase 1a). When SSH is disabled, no router is mounted —
|
|
* the endpoints simply do not exist (no 404 distinction from a missing route).
|
|
*
|
|
* Design conventions:
|
|
* - Manual JSON validation (no zod) for consistency with pieces-api / mcp-api.
|
|
* - Reason gating: every admin write path requires `body.reason` >= 8 chars.
|
|
* - Audit logging: every admin write uses beginAudit → action → completeAudit
|
|
* (or beginAndComplete for synchronous DB-only changes); user actions audit
|
|
* only the privileged ones (test, verify, replace).
|
|
* - Maintenance mode: when active, all write endpoints — user AND admin —
|
|
* return 503 with `Retry-After: 30`. Read endpoints stay available.
|
|
* - Buffer hygiene: decrypted private keys are passed to ssh-session and then
|
|
* cleared on every code path (success, error, validation failure).
|
|
* - Response shaping: `presentConnection()` strips encrypted blob fields and
|
|
* the pending host-key token (token only returned on test / observation).
|
|
*/
|
|
|
|
import { Router, type Request, type Response, type NextFunction, type RequestHandler } from 'express';
|
|
import type Database from 'better-sqlite3';
|
|
|
|
import type { SshConnection, SshConnectionRepo, HostKeyVerifyResult } from '../ssh/connection-repo.js';
|
|
import type { SshGrant, SshGrantsRepo, SshGrantSubjectType } from '../ssh/grants-repo.js';
|
|
import type { SshAuditRepo, SshAuditRow } from '../ssh/audit-repo.js';
|
|
import type { SshAbuseRepo } from '../ssh/abuse-repo.js';
|
|
import type { SshAccessResolver } from '../ssh/access.js';
|
|
import type { MaintenanceController } from '../ssh/maintenance.js';
|
|
import type { AdminRateLimiter } from '../ssh/admin-rate-limit.js';
|
|
import { logger } from '../logger.js';
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Types
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
export interface SshTesterArgs {
|
|
connection: SshConnection;
|
|
decryptedKey: Buffer;
|
|
passphrase: Buffer | null;
|
|
timeoutMs: number;
|
|
}
|
|
|
|
export type SshTesterVerdict = 'first_observe' | 'mismatch' | 'pass' | 'alg_not_allowed';
|
|
|
|
export interface SshTesterResult {
|
|
/** SHA256 fingerprint of the observed host key. */
|
|
fingerprint: string;
|
|
/** Base64 of the observed host key (wire format). */
|
|
hostKeyB64: string;
|
|
hostKeyType: string;
|
|
verdict: SshTesterVerdict;
|
|
}
|
|
|
|
export interface SshTester {
|
|
test(args: SshTesterArgs): Promise<SshTesterResult>;
|
|
}
|
|
|
|
export interface SshEncryptResult {
|
|
blob: Buffer;
|
|
passphraseBlob: Buffer | null;
|
|
keyVersion: number;
|
|
fingerprint: string;
|
|
publicKey: string;
|
|
}
|
|
|
|
export interface SshApiDeps {
|
|
db: Database.Database;
|
|
requireAuth: RequestHandler;
|
|
requireAdmin: RequestHandler;
|
|
getUserId(req: Request): string | null;
|
|
isAdmin(req: Request): boolean;
|
|
getOrgIds(req: Request): string[];
|
|
/**
|
|
* Whether the bridge wired the auth subsystem. When `false` (no-auth
|
|
* single-user deployment) requests carry no `req.user`, so the router
|
|
* synthesizes a stable `local` owner — otherwise every endpoint 401s on
|
|
* the null userId and SSH is unusable. Mirrors notes-api / memory-api /
|
|
* user-folder-api. Defaults to `true` for existing (auth-only) callers.
|
|
*/
|
|
authActive?: boolean;
|
|
|
|
connectionRepo: SshConnectionRepo;
|
|
grantsRepo: SshGrantsRepo;
|
|
auditRepo: SshAuditRepo;
|
|
abuseRepo: SshAbuseRepo;
|
|
accessResolver: SshAccessResolver;
|
|
|
|
maintenance: MaintenanceController;
|
|
forceUnlockLimiter: AdminRateLimiter;
|
|
|
|
/**
|
|
* Encrypt a PEM (and optional passphrase) into the envelope-encrypted blobs
|
|
* stored in ssh_connections. ownerId=null = system DEK (global connection),
|
|
* ownerId=<uid> = user DEK.
|
|
*/
|
|
encryptKeyMaterial(ownerId: string | null, pem: Buffer, passphrase: Buffer | null): SshEncryptResult;
|
|
|
|
/** Decrypt blob using the correct DEK. Buffer must be cleared by caller. */
|
|
decryptKeyMaterial(ownerId: string | null, blob: Buffer): Buffer;
|
|
decryptPassphrase(ownerId: string | null, blob: Buffer | null): Buffer | null;
|
|
|
|
/**
|
|
* Generate a fresh keypair (no passphrase). The caller is expected to feed
|
|
* the returned `privateKeyPem` back through `encryptKeyMaterial` for at-rest
|
|
* encryption.
|
|
*/
|
|
generateKeypair(keyType: 'ed25519' | 'rsa-4096'): {
|
|
privateKeyPem: Buffer;
|
|
publicKey: string;
|
|
};
|
|
|
|
/**
|
|
* Derive the OpenSSH-format public key (`<algo> <base64>`) from the stored
|
|
* private-key blob. Handles decrypt + format + buffer-clear internally.
|
|
*/
|
|
derivePublicKey(
|
|
ownerId: string | null,
|
|
blob: Buffer,
|
|
passphraseBlob: Buffer | null,
|
|
): string;
|
|
|
|
/** SSH dial helper — injected so tests don't need a real SSH server. */
|
|
sshTester: SshTester;
|
|
|
|
/** Connection test timeout. Defaults to 5s. */
|
|
connectionTestTimeoutMs?: number;
|
|
|
|
/**
|
|
* Optional hook called after a grant is revoked / deleted so the caller can
|
|
* kick any active WebSocket viewers that depended on that grant. The hook
|
|
* is a no-op pass-through (returns 0) when the SSH console subsystem isn't
|
|
* wired (e.g. tests, or `ssh.console.enabled=false`).
|
|
*
|
|
* Implementation typically calls `SessionRegistry.revokeAccessFor`.
|
|
*/
|
|
onAccessRevoked?: (args: { connectionId: string; userId: string }) => number | void;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
const REASON_MIN_CHARS = 8;
|
|
|
|
function validateReason(reason: unknown): string | null {
|
|
if (typeof reason !== 'string') return 'reason is required (string >= 8 chars)';
|
|
if (reason.length < REASON_MIN_CHARS) return `reason must be at least ${REASON_MIN_CHARS} characters`;
|
|
return null;
|
|
}
|
|
|
|
function maintenance503(maintenance: MaintenanceController, res: Response): boolean {
|
|
if (!maintenance.isActive()) return false;
|
|
res.setHeader('Retry-After', '30');
|
|
res.status(503).json({
|
|
error: 'rotation_in_progress',
|
|
detail: 'SSH subsystem is in maintenance — try again shortly.',
|
|
snapshot: maintenance.snapshot(),
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function safePort(p: unknown): number | null {
|
|
if (typeof p !== 'number' || !Number.isInteger(p) || p < 1 || p > 65535) return null;
|
|
return p;
|
|
}
|
|
|
|
function safeString(v: unknown, max = 1024): string | null {
|
|
if (typeof v !== 'string') return null;
|
|
if (v.length === 0 || v.length > max) return null;
|
|
return v;
|
|
}
|
|
|
|
function safeOptionalString(v: unknown, max = 1024): string | null | undefined {
|
|
if (v === undefined || v === null) return undefined;
|
|
if (typeof v !== 'string') return null;
|
|
if (v.length > max) return null;
|
|
return v;
|
|
}
|
|
|
|
function safePathPrefix(v: unknown): string | null {
|
|
const s = safeString(v, 1024);
|
|
if (s === null) return null;
|
|
if (s.length === 0) return null;
|
|
// Reject any `..` parent-ref segment using either separator. POSIX and
|
|
// Windows-style prefixes (drive letters, UNC, no-leading-slash) are
|
|
// all allowed; the runtime validateRemotePath enforces containment.
|
|
const segments = s.split(/[\\/]/);
|
|
if (segments.includes('..')) return null;
|
|
return s;
|
|
}
|
|
|
|
function safeFingerprint(v: unknown): string | null {
|
|
if (typeof v !== 'string') return null;
|
|
// SHA256:<base64> — base64 chars + ='s, ~46 chars typical.
|
|
if (!/^SHA256:[A-Za-z0-9+/=]{20,80}$/.test(v)) return null;
|
|
return v;
|
|
}
|
|
|
|
function safeUuid(v: unknown): string | null {
|
|
if (typeof v !== 'string') return null;
|
|
if (!/^[a-f0-9-]{8,}$/.test(v)) return null;
|
|
return v;
|
|
}
|
|
|
|
function safeExpiresAt(v: unknown): string | null | undefined {
|
|
if (v === undefined || v === null) return undefined;
|
|
if (typeof v !== 'string') return null;
|
|
// ISO8601 — Date.parse returns NaN for invalid strings.
|
|
const t = Date.parse(v);
|
|
if (Number.isNaN(t)) return null;
|
|
return new Date(t).toISOString();
|
|
}
|
|
|
|
function safeSubjectType(v: unknown): SshGrantSubjectType | null {
|
|
return v === 'user' || v === 'org' ? v : null;
|
|
}
|
|
|
|
function presentConnection(conn: SshConnection): Record<string, unknown> {
|
|
// Strip the encrypted blob fields and the pending verify token (the token
|
|
// is only surfaced on /test responses; subsequent GETs do not expose it
|
|
// again — the user must hold onto it).
|
|
return {
|
|
id: conn.id,
|
|
ownerId: conn.ownerId,
|
|
label: conn.label,
|
|
host: conn.host,
|
|
port: conn.port,
|
|
username: conn.username,
|
|
keyVersion: conn.keyVersion,
|
|
keyFingerprint: conn.keyFingerprint,
|
|
hostKeyType: conn.hostKeyType,
|
|
hostKeyFingerprint: conn.hostKeyFingerprint,
|
|
hostKeyRecordedAt: conn.hostKeyRecordedAt,
|
|
hostKeyVerifiedAt: conn.hostKeyVerifiedAt,
|
|
hostKeyPending: conn.hostKeyPending,
|
|
hostKeyPendingFingerprint: conn.hostKeyPendingFingerprint,
|
|
hostKeyPendingSource: conn.hostKeyPendingSource,
|
|
commandDenyPatterns: conn.commandDenyPatterns,
|
|
commandAllowPatterns: conn.commandAllowPatterns,
|
|
remotePathPrefix: conn.remotePathPrefix,
|
|
allowRemoteUnrestricted: conn.allowRemoteUnrestricted,
|
|
allowPrivateAddresses: conn.allowPrivateAddresses,
|
|
enabled: conn.enabled,
|
|
disabledByAdmin: conn.disabledByAdmin,
|
|
disabledByAdminReason: conn.disabledByAdminReason,
|
|
disabledByAdminAt: conn.disabledByAdminAt,
|
|
disabledByAdminUserId: conn.disabledByAdminUserId,
|
|
createdAt: conn.createdAt,
|
|
updatedAt: conn.updatedAt,
|
|
};
|
|
}
|
|
|
|
function presentGrant(g: SshGrant): Record<string, unknown> {
|
|
return { ...g };
|
|
}
|
|
|
|
function presentAuditRow(a: SshAuditRow): Record<string, unknown> {
|
|
return { ...a };
|
|
}
|
|
|
|
function safeLimit(v: unknown, def = 50, max = 500): number {
|
|
if (typeof v === 'string') {
|
|
const n = Number(v);
|
|
if (Number.isInteger(n) && n > 0 && n <= max) return n;
|
|
}
|
|
if (typeof v === 'number' && Number.isInteger(v) && v > 0 && v <= max) return v;
|
|
return def;
|
|
}
|
|
|
|
function jsonError(res: Response, status: number, error: string, detail?: unknown): void {
|
|
if (detail === undefined) {
|
|
res.status(status).json({ error });
|
|
} else {
|
|
res.status(status).json({ error, detail });
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// User router — /api/ssh/connections + /api/ssh/grants
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* No-auth single-user mode: synthesize a `local` user so the per-user
|
|
* connection store has a stable owner and `getUserId`/`isAdmin` resolve.
|
|
* Role `admin` keeps the connection-management surface fully usable for the
|
|
* lone local operator (in single-user mode the admin/grant checks only ever
|
|
* compare against `local`-owned rows, so this never widens cross-user access).
|
|
* No-op when auth is active (Passport has already populated `req.user`).
|
|
*/
|
|
function makeSshLocalUserMiddleware(authActive: boolean): RequestHandler {
|
|
return (req: Request, _res: Response, next: NextFunction) => {
|
|
if (!authActive && !(req as { user?: unknown }).user) {
|
|
(req as { user?: unknown }).user = { id: 'local', role: 'admin', orgIds: [] };
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
|
|
export function createSshUserRouter(deps: SshApiDeps): Router {
|
|
const router = Router();
|
|
const testTimeoutMs = deps.connectionTestTimeoutMs ?? 5000;
|
|
router.use(makeSshLocalUserMiddleware(deps.authActive ?? true));
|
|
|
|
// GET /api/ssh/connections — list owned (and globals visible via grant).
|
|
// For Phase 5 we return:
|
|
// - all owned (owner_id == userId)
|
|
// - all globals (owner_id IS NULL) — visibility is enforced at tool-call
|
|
// time via accessResolver, so listing globals here is informational.
|
|
// Admins see all via /api/ssh/admin/connections (separate endpoint).
|
|
router.get('/connections', deps.requireAuth, (req, res) => {
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const owned = deps.connectionRepo.listOwned(userId);
|
|
const all = deps.connectionRepo.listAll();
|
|
const globals = all.filter((c) => c.ownerId === null);
|
|
// Deduplicate (owned should never overlap globals; safety net).
|
|
const seen = new Set<string>(owned.map((c) => c.id));
|
|
const list = [...owned];
|
|
for (const g of globals) {
|
|
if (!seen.has(g.id)) { list.push(g); seen.add(g.id); }
|
|
}
|
|
res.json({ connections: list.map(presentConnection) });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] list user connections failed err=${String(e)}`);
|
|
jsonError(res, 500, 'list_failed');
|
|
}
|
|
});
|
|
|
|
// POST /api/ssh/connections — create user-owned.
|
|
router.post('/connections', deps.requireAuth, async (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const body = req.body ?? {};
|
|
const label = safeString(body.label, 200);
|
|
const host = safeString(body.host, 255);
|
|
const port = safePort(body.port);
|
|
const username = safeString(body.username, 64);
|
|
const remotePathPrefix = safePathPrefix(body.remotePathPrefix);
|
|
const denyPatterns = safeOptionalString(body.commandDenyPatterns, 4096);
|
|
const allowPatterns = safeOptionalString(body.commandAllowPatterns, 4096);
|
|
// User-owned create rejects admin-only flags up front.
|
|
if (body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1) {
|
|
jsonError(res, 403, 'allow_remote_unrestricted_admin_only'); return;
|
|
}
|
|
if (body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1) {
|
|
jsonError(res, 403, 'allow_private_addresses_admin_only'); return;
|
|
}
|
|
|
|
// Keypair source: 'provided' (user uploads PEM, default) or
|
|
// 'generate' (orchestrator creates a fresh keypair). In the latter case
|
|
// the user gets the public key back exactly once in this response.
|
|
const keypairSource = body.keypairSource === 'generate' ? 'generate' : 'provided';
|
|
let pemBuf: Buffer;
|
|
let passBuf: Buffer | null;
|
|
if (keypairSource === 'generate') {
|
|
const keyType = body.generateKeyType === 'rsa-4096' ? 'rsa-4096' : 'ed25519';
|
|
const generated = deps.generateKeypair(keyType);
|
|
pemBuf = generated.privateKeyPem;
|
|
passBuf = null;
|
|
} else {
|
|
const privateKey = typeof body.privateKeyPem === 'string' ? body.privateKeyPem : null;
|
|
const passphrase = typeof body.passphrase === 'string' ? body.passphrase : null;
|
|
if (!privateKey) {
|
|
jsonError(res, 400, 'invalid_input', {
|
|
required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix'],
|
|
});
|
|
return;
|
|
}
|
|
pemBuf = Buffer.from(privateKey, 'utf8');
|
|
passBuf = passphrase ? Buffer.from(passphrase, 'utf8') : null;
|
|
}
|
|
|
|
if (!label || !host || port === null || !username || !remotePathPrefix) {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
jsonError(res, 400, 'invalid_input', {
|
|
required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix'],
|
|
});
|
|
return;
|
|
}
|
|
if (denyPatterns === null) {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
jsonError(res, 400, 'invalid_command_deny_patterns'); return;
|
|
}
|
|
if (allowPatterns === null) {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
jsonError(res, 400, 'invalid_command_allow_patterns'); return;
|
|
}
|
|
|
|
let encrypted: SshEncryptResult;
|
|
try {
|
|
encrypted = deps.encryptKeyMaterial(userId, pemBuf, passBuf);
|
|
} finally {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
}
|
|
const conn = deps.connectionRepo.create({
|
|
ownerId: userId,
|
|
label, host, port, username,
|
|
privateKeyEnc: encrypted.blob,
|
|
passphraseEnc: encrypted.passphraseBlob,
|
|
keyVersion: encrypted.keyVersion,
|
|
keyFingerprint: encrypted.fingerprint,
|
|
remotePathPrefix,
|
|
commandDenyPatterns: denyPatterns ?? null,
|
|
commandAllowPatterns: allowPatterns ?? null,
|
|
});
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.connection.upsert',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: userId,
|
|
actingUserId: userId,
|
|
detail: { op: 'create', label: conn.label, host: conn.host, port: conn.port, keypairSource },
|
|
},
|
|
'success',
|
|
);
|
|
res.status(201).json({
|
|
connection: presentConnection(conn),
|
|
publicKey: encrypted.publicKey,
|
|
});
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] create user connection failed err=${String(e)}`);
|
|
jsonError(res, 500, 'create_failed');
|
|
}
|
|
});
|
|
|
|
router.get('/connections/:id', deps.requireAuth, (req, res) => {
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
// Visibility: owner, admin, or global (info-only). Other users see 404
|
|
// (do not leak existence of someone else's user-owned connection).
|
|
if (conn.ownerId !== null && conn.ownerId !== userId && !deps.isAdmin(req)) {
|
|
jsonError(res, 404, 'not_found'); return;
|
|
}
|
|
let publicKey: string | null = null;
|
|
try {
|
|
publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc);
|
|
} catch (e) {
|
|
// Don't fail the whole response if key derivation fails (e.g. corrupt
|
|
// blob, missing passphrase) — return the connection without it and log.
|
|
logger.warn(`[ssh:api] derive public key failed id=${conn.id} err=${String(e)}`);
|
|
}
|
|
res.json({ connection: presentConnection(conn), publicKey });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] get user connection failed err=${String(e)}`);
|
|
jsonError(res, 500, 'get_failed');
|
|
}
|
|
});
|
|
|
|
router.patch('/connections/:id', deps.requireAuth, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
// Only owner may PATCH user-owned. Admin PATCHes globals via admin route.
|
|
if (conn.ownerId !== userId) { jsonError(res, 403, 'owner_only'); return; }
|
|
const body = req.body ?? {};
|
|
|
|
const patch: Parameters<SshConnectionRepo['update']>[1] = {};
|
|
if (body.label !== undefined) {
|
|
const v = safeString(body.label, 200);
|
|
if (v === null) { jsonError(res, 400, 'invalid_label'); return; }
|
|
patch.label = v;
|
|
}
|
|
if (body.host !== undefined) {
|
|
const v = safeString(body.host, 255);
|
|
if (v === null) { jsonError(res, 400, 'invalid_host'); return; }
|
|
patch.host = v;
|
|
}
|
|
if (body.port !== undefined) {
|
|
const v = safePort(body.port);
|
|
if (v === null) { jsonError(res, 400, 'invalid_port'); return; }
|
|
patch.port = v;
|
|
}
|
|
if (body.username !== undefined) {
|
|
const v = safeString(body.username, 64);
|
|
if (v === null) { jsonError(res, 400, 'invalid_username'); return; }
|
|
patch.username = v;
|
|
}
|
|
if (body.remotePathPrefix !== undefined) {
|
|
const v = safePathPrefix(body.remotePathPrefix);
|
|
if (v === null) { jsonError(res, 400, 'invalid_remote_path_prefix'); return; }
|
|
patch.remotePathPrefix = v;
|
|
}
|
|
if (body.commandDenyPatterns !== undefined) {
|
|
const v = safeOptionalString(body.commandDenyPatterns, 4096);
|
|
if (v === null) { jsonError(res, 400, 'invalid_command_deny_patterns'); return; }
|
|
patch.commandDenyPatterns = v ?? null;
|
|
}
|
|
if (body.commandAllowPatterns !== undefined) {
|
|
const v = safeOptionalString(body.commandAllowPatterns, 4096);
|
|
if (v === null) { jsonError(res, 400, 'invalid_command_allow_patterns'); return; }
|
|
patch.commandAllowPatterns = v ?? null;
|
|
}
|
|
// Users cannot toggle admin-only flags.
|
|
if (body.allowRemoteUnrestricted !== undefined || body.allowPrivateAddresses !== undefined) {
|
|
jsonError(res, 403, 'admin_only_flag'); return;
|
|
}
|
|
// Key rotation (privateKeyPem in patch). Re-encrypt and bump key_version.
|
|
if (typeof body.privateKeyPem === 'string') {
|
|
let pemBuf = Buffer.from(body.privateKeyPem, 'utf8');
|
|
let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null;
|
|
try {
|
|
const enc = deps.encryptKeyMaterial(userId, pemBuf, passBuf);
|
|
patch.privateKeyEnc = enc.blob;
|
|
patch.passphraseEnc = enc.passphraseBlob;
|
|
patch.keyVersion = enc.keyVersion;
|
|
patch.keyFingerprint = enc.fingerprint;
|
|
} finally {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
}
|
|
}
|
|
const ok = deps.connectionRepo.update(conn.id, patch);
|
|
if (!ok) { jsonError(res, 404, 'not_found'); return; }
|
|
const updated = deps.connectionRepo.resolveConnection(conn.id);
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.connection.upsert',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: userId,
|
|
actingUserId: userId,
|
|
detail: { op: 'update', fields: Object.keys(patch) },
|
|
},
|
|
'success',
|
|
);
|
|
res.json({ connection: presentConnection(updated!) });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] patch user connection failed err=${String(e)}`);
|
|
jsonError(res, 500, 'update_failed');
|
|
}
|
|
});
|
|
|
|
router.delete('/connections/:id', deps.requireAuth, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
if (conn.ownerId !== userId) { jsonError(res, 403, 'owner_only'); return; }
|
|
// Begin audit BEFORE delete so FK(connection_id) is still valid.
|
|
// FK has ON DELETE SET NULL; complete() updates by audit_id only.
|
|
const auditId = deps.auditRepo.begin({
|
|
action: 'ssh.connection.delete',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: userId,
|
|
actingUserId: userId,
|
|
detail: { label: conn.label, host: conn.host },
|
|
});
|
|
const ok = deps.connectionRepo.delete(conn.id);
|
|
if (!ok) {
|
|
deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' });
|
|
jsonError(res, 404, 'not_found');
|
|
return;
|
|
}
|
|
deps.auditRepo.complete(auditId, 'success');
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] delete user connection failed err=${String(e)}`);
|
|
jsonError(res, 500, 'delete_failed');
|
|
}
|
|
});
|
|
|
|
// POST /api/ssh/connections/:id/test — capture-only connect. Decrypts the
|
|
// key, dials the host (or asks the injected tester to do so), captures the
|
|
// host key, and stores it pending with a fresh verify token. Returns the
|
|
// fingerprint + token to the user so they can call /verify-host-key next.
|
|
router.post('/connections/:id/test', deps.requireAuth, async (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
if (conn.ownerId !== userId && !deps.isAdmin(req)) {
|
|
jsonError(res, 403, 'owner_or_admin_only'); return;
|
|
}
|
|
let decryptedKey: Buffer | null = null;
|
|
let passphrase: Buffer | null = null;
|
|
let auditId: number | null = null;
|
|
try {
|
|
auditId = deps.auditRepo.begin({
|
|
action: 'ssh.connection.host_key.tofu_record',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: conn.ownerId,
|
|
actingUserId: userId,
|
|
detail: { op: 'test' },
|
|
});
|
|
decryptedKey = deps.decryptKeyMaterial(conn.ownerId, conn.privateKeyEnc);
|
|
passphrase = deps.decryptPassphrase(conn.ownerId, conn.passphraseEnc);
|
|
const result = await deps.sshTester.test({
|
|
connection: conn,
|
|
decryptedKey,
|
|
passphrase,
|
|
timeoutMs: testTimeoutMs,
|
|
});
|
|
// alg_not_allowed: the observed host key uses a banned algorithm
|
|
// (e.g. ssh-rsa with SHA1). Don't store as pending — we'd never accept it.
|
|
// Surface the fingerprint so the operator can audit/replace the server's key.
|
|
if (result.verdict === 'alg_not_allowed') {
|
|
deps.auditRepo.complete(auditId, 'denied', {
|
|
verdict: result.verdict,
|
|
observedFingerprint: result.fingerprint,
|
|
hostKeyType: result.hostKeyType,
|
|
});
|
|
res.status(502).json({
|
|
error: 'host_key_alg_not_allowed',
|
|
verdict: result.verdict,
|
|
fingerprint: result.fingerprint,
|
|
hostKeyType: result.hostKeyType,
|
|
});
|
|
return;
|
|
}
|
|
// Store the observation as pending for first_observe and mismatch.
|
|
// 'pass' means the key already matched the verified record — no token needed.
|
|
let token: string | null = null;
|
|
if (result.verdict === 'first_observe' || result.verdict === 'mismatch') {
|
|
const stored = deps.connectionRepo.setHostKeyPendingWithToken(
|
|
conn.id,
|
|
result.hostKeyB64,
|
|
result.fingerprint,
|
|
result.verdict === 'first_observe' ? 'tofu_record' : 'mismatch',
|
|
);
|
|
token = stored?.token ?? null;
|
|
}
|
|
deps.auditRepo.complete(auditId, 'success', {
|
|
verdict: result.verdict,
|
|
observedFingerprint: result.fingerprint,
|
|
});
|
|
res.json({
|
|
verdict: result.verdict,
|
|
fingerprint: result.fingerprint,
|
|
hostKeyType: result.hostKeyType,
|
|
pendingToken: token,
|
|
});
|
|
} catch (e) {
|
|
if (auditId !== null) {
|
|
deps.auditRepo.complete(auditId, 'failed', { err: String(e) });
|
|
}
|
|
logger.warn(`[ssh:api] connection test failed id=${conn.id} err=${String(e)}`);
|
|
jsonError(res, 502, 'test_failed', { detail: String(e) });
|
|
} finally {
|
|
if (decryptedKey) decryptedKey.fill(0);
|
|
if (passphrase) passphrase.fill(0);
|
|
}
|
|
});
|
|
|
|
// POST /api/ssh/connections/:id/verify-host-key
|
|
router.post('/connections/:id/verify-host-key', deps.requireAuth, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
if (conn.ownerId !== userId && !deps.isAdmin(req)) {
|
|
jsonError(res, 403, 'owner_or_admin_only'); return;
|
|
}
|
|
const body = req.body ?? {};
|
|
const fingerprint = safeFingerprint(body.fingerprint);
|
|
const token = safeUuid(body.token);
|
|
if (!fingerprint || !token) {
|
|
jsonError(res, 400, 'invalid_input', { required: ['fingerprint', 'token'] });
|
|
return;
|
|
}
|
|
const result: HostKeyVerifyResult = deps.connectionRepo.setHostKeyVerified(conn.id, token, fingerprint);
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.connection.host_key.verify',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: conn.ownerId,
|
|
actingUserId: userId,
|
|
detail: { result, fingerprint },
|
|
},
|
|
result === 'verified' ? 'success' : 'failed',
|
|
);
|
|
if (result === 'verified') {
|
|
const updated = deps.connectionRepo.resolveConnection(conn.id)!;
|
|
res.json({ ok: true, connection: presentConnection(updated) });
|
|
} else {
|
|
jsonError(res, 409, result);
|
|
}
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] verify host key failed err=${String(e)}`);
|
|
jsonError(res, 500, 'verify_failed');
|
|
}
|
|
});
|
|
|
|
// POST /api/ssh/connections/:id/replace-host-key — rotate an already-verified key.
|
|
router.post('/connections/:id/replace-host-key', deps.requireAuth, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
if (conn.ownerId !== userId && !deps.isAdmin(req)) {
|
|
jsonError(res, 403, 'owner_or_admin_only'); return;
|
|
}
|
|
const body = req.body ?? {};
|
|
const fingerprint = safeFingerprint(body.fingerprint);
|
|
const token = safeUuid(body.token);
|
|
const reasonErr = validateReason(body.reason);
|
|
if (!fingerprint || !token) {
|
|
jsonError(res, 400, 'invalid_input', { required: ['fingerprint', 'token', 'reason'] });
|
|
return;
|
|
}
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const result: HostKeyVerifyResult = deps.connectionRepo.replaceHostKey(conn.id, token, fingerprint);
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.connection.host_key.replace',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: conn.ownerId,
|
|
actingUserId: userId,
|
|
reason: String(body.reason),
|
|
detail: { result, fingerprint },
|
|
},
|
|
result === 'verified' ? 'success' : 'failed',
|
|
);
|
|
if (result === 'verified') {
|
|
const updated = deps.connectionRepo.resolveConnection(conn.id)!;
|
|
res.json({ ok: true, connection: presentConnection(updated) });
|
|
} else {
|
|
jsonError(res, 409, result);
|
|
}
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] replace host key failed err=${String(e)}`);
|
|
jsonError(res, 500, 'replace_failed');
|
|
}
|
|
});
|
|
|
|
router.get('/connections/:id/audit', deps.requireAuth, (req, res) => {
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
if (conn.ownerId !== userId && !deps.isAdmin(req)) {
|
|
jsonError(res, 403, 'owner_or_admin_only'); return;
|
|
}
|
|
const limit = safeLimit(req.query.limit);
|
|
const rows = deps.auditRepo.listForConnection(conn.id, limit);
|
|
res.json({ audit: rows.map(presentAuditRow) });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] audit list failed err=${String(e)}`);
|
|
jsonError(res, 500, 'audit_failed');
|
|
}
|
|
});
|
|
|
|
// GET /api/ssh/grants/visible-to-me
|
|
router.get('/grants/visible-to-me', deps.requireAuth, (req, res) => {
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const orgIds = deps.getOrgIds(req);
|
|
const orgPh = orgIds.length > 0 ? orgIds.map(() => '?').join(',') : null;
|
|
const sql = `
|
|
SELECT * FROM ssh_connection_grants
|
|
WHERE (subject_type = 'user' AND subject_id = ?)
|
|
${orgPh ? `OR (subject_type = 'org' AND subject_id IN (${orgPh}))` : ''}
|
|
ORDER BY created_at DESC
|
|
`;
|
|
const params: unknown[] = [userId];
|
|
if (orgPh) params.push(...orgIds);
|
|
const rows = deps.db.prepare(sql).all(...params) as Array<{
|
|
id: string; connection_id: string; subject_type: SshGrantSubjectType; subject_id: string;
|
|
piece_name: string | null; applies_to_all_pieces: number; granted_by_user_id: string;
|
|
reason: string; expires_at: string | null; created_at: string;
|
|
}>;
|
|
const grants = rows.map((r) => ({
|
|
id: r.id,
|
|
connectionId: r.connection_id,
|
|
subjectType: r.subject_type,
|
|
subjectId: r.subject_id,
|
|
pieceName: r.piece_name,
|
|
appliesToAllPieces: r.applies_to_all_pieces === 1,
|
|
grantedByUserId: r.granted_by_user_id,
|
|
reason: r.reason,
|
|
expiresAt: r.expires_at,
|
|
createdAt: r.created_at,
|
|
}));
|
|
res.json({ grants });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] list visible grants failed err=${String(e)}`);
|
|
jsonError(res, 500, 'list_grants_failed');
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
// Admin router — /api/ssh/admin/*
|
|
// ──────────────────────────────────────────────────────────────────────
|
|
|
|
export function createSshAdminRouter(deps: SshApiDeps): Router {
|
|
const router = Router();
|
|
router.use(makeSshLocalUserMiddleware(deps.authActive ?? true));
|
|
|
|
// GET /api/ssh/admin/connections
|
|
router.get('/connections', deps.requireAdmin, (_req, res) => {
|
|
try {
|
|
const list = deps.connectionRepo.listAll();
|
|
res.json({ connections: list.map(presentConnection) });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin list connections failed err=${String(e)}`);
|
|
jsonError(res, 500, 'list_failed');
|
|
}
|
|
});
|
|
|
|
router.get('/connections/:id', deps.requireAdmin, (req, res) => {
|
|
try {
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
let publicKey: string | null = null;
|
|
try {
|
|
publicKey = deps.derivePublicKey(conn.ownerId, conn.privateKeyEnc, conn.passphraseEnc);
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin derive public key failed id=${conn.id} err=${String(e)}`);
|
|
}
|
|
res.json({ connection: presentConnection(conn), publicKey });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin get connection failed err=${String(e)}`);
|
|
jsonError(res, 500, 'get_failed');
|
|
}
|
|
});
|
|
|
|
router.patch('/connections/:id/disable', deps.requireAdmin, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const reasonErr = validateReason(req.body?.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
const ok = deps.connectionRepo.disableByAdmin(conn.id, String(req.body.reason), userId);
|
|
if (!ok) { jsonError(res, 404, 'not_found'); return; }
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.connection.disable',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: conn.ownerId,
|
|
actingUserId: userId,
|
|
reason: String(req.body.reason),
|
|
},
|
|
'success',
|
|
);
|
|
const updated = deps.connectionRepo.resolveConnection(conn.id)!;
|
|
res.json({ connection: presentConnection(updated) });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin disable failed err=${String(e)}`);
|
|
jsonError(res, 500, 'disable_failed');
|
|
}
|
|
});
|
|
|
|
router.patch('/connections/:id/enable', deps.requireAdmin, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const reasonErr = validateReason(req.body?.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
const ok = deps.connectionRepo.enableByAdmin(conn.id);
|
|
if (!ok) { jsonError(res, 404, 'not_found'); return; }
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.connection.enable',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: conn.ownerId,
|
|
actingUserId: userId,
|
|
reason: String(req.body.reason),
|
|
},
|
|
'success',
|
|
);
|
|
const updated = deps.connectionRepo.resolveConnection(conn.id)!;
|
|
res.json({ connection: presentConnection(updated) });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin enable failed err=${String(e)}`);
|
|
jsonError(res, 500, 'enable_failed');
|
|
}
|
|
});
|
|
|
|
router.delete('/connections/:id', deps.requireAdmin, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const reasonErr = validateReason(req.body?.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
const auditId = deps.auditRepo.begin({
|
|
action: 'ssh.connection.delete',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: conn.ownerId,
|
|
actingUserId: userId,
|
|
reason: String(req.body.reason),
|
|
});
|
|
const ok = deps.connectionRepo.delete(conn.id);
|
|
if (!ok) {
|
|
deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' });
|
|
jsonError(res, 404, 'not_found');
|
|
return;
|
|
}
|
|
deps.auditRepo.complete(auditId, 'success');
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin delete failed err=${String(e)}`);
|
|
jsonError(res, 500, 'delete_failed');
|
|
}
|
|
});
|
|
|
|
router.post('/connections/:id/force-unlock', deps.requireAdmin, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const reasonErr = validateReason(req.body?.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const decision = deps.forceUnlockLimiter.check(userId);
|
|
if (!decision.allowed) {
|
|
if (decision.retryAfterSeconds !== undefined) {
|
|
res.setHeader('Retry-After', String(decision.retryAfterSeconds));
|
|
}
|
|
jsonError(res, 429, 'rate_limited', { retryAfterSeconds: decision.retryAfterSeconds });
|
|
return;
|
|
}
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
const removed = deps.abuseRepo.reset(`conn:${conn.id}`);
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.abuse.unlock_manual',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: conn.ownerId,
|
|
actingUserId: userId,
|
|
reason: String(req.body.reason),
|
|
detail: { removed },
|
|
},
|
|
'success',
|
|
);
|
|
res.json({ ok: true, removed });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin force-unlock failed err=${String(e)}`);
|
|
jsonError(res, 500, 'unlock_failed');
|
|
}
|
|
});
|
|
|
|
// POST /api/ssh/admin/globals — create a global connection (owner_id=NULL).
|
|
router.post('/globals', deps.requireAdmin, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const reasonErr = validateReason(req.body?.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const body = req.body ?? {};
|
|
const label = safeString(body.label, 200);
|
|
const host = safeString(body.host, 255);
|
|
const port = safePort(body.port);
|
|
const username = safeString(body.username, 64);
|
|
const denyPatterns = safeOptionalString(body.commandDenyPatterns, 4096);
|
|
const allowPatterns = safeOptionalString(body.commandAllowPatterns, 4096);
|
|
const allowRemoteUnrestricted = body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1;
|
|
const allowPrivateAddresses = body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1;
|
|
|
|
// remote_path_prefix is required UNLESS allow_remote_unrestricted is set.
|
|
let remotePathPrefix: string | null;
|
|
if (allowRemoteUnrestricted) {
|
|
// Stored prefix is '/' (sandbox-effectively-disabled).
|
|
remotePathPrefix = '/';
|
|
} else {
|
|
remotePathPrefix = safePathPrefix(body.remotePathPrefix);
|
|
}
|
|
|
|
// Keypair source: 'provided' (admin uploads PEM) or 'generate' (orchestrator
|
|
// creates a fresh keypair; the public key is returned exactly once).
|
|
const keypairSource = body.keypairSource === 'generate' ? 'generate' : 'provided';
|
|
let pemBuf: Buffer;
|
|
let passBuf: Buffer | null;
|
|
if (keypairSource === 'generate') {
|
|
const keyType = body.generateKeyType === 'rsa-4096' ? 'rsa-4096' : 'ed25519';
|
|
const generated = deps.generateKeypair(keyType);
|
|
pemBuf = generated.privateKeyPem;
|
|
passBuf = null;
|
|
} else {
|
|
const privateKey = typeof body.privateKeyPem === 'string' ? body.privateKeyPem : null;
|
|
const passphrase = typeof body.passphrase === 'string' ? body.passphrase : null;
|
|
if (!privateKey) {
|
|
jsonError(res, 400, 'invalid_input', {
|
|
required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix (or allowRemoteUnrestricted=true)'],
|
|
});
|
|
return;
|
|
}
|
|
pemBuf = Buffer.from(privateKey, 'utf8');
|
|
passBuf = passphrase ? Buffer.from(passphrase, 'utf8') : null;
|
|
}
|
|
|
|
if (!label || !host || port === null || !username || !remotePathPrefix) {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
jsonError(res, 400, 'invalid_input', {
|
|
required: ['label', 'host', 'port (1-65535)', 'username', 'privateKeyPem', 'remotePathPrefix (or allowRemoteUnrestricted=true)'],
|
|
});
|
|
return;
|
|
}
|
|
if (denyPatterns === null) {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
jsonError(res, 400, 'invalid_command_deny_patterns'); return;
|
|
}
|
|
if (allowPatterns === null) {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
jsonError(res, 400, 'invalid_command_allow_patterns'); return;
|
|
}
|
|
|
|
let encrypted: SshEncryptResult;
|
|
try {
|
|
encrypted = deps.encryptKeyMaterial(null, pemBuf, passBuf);
|
|
} catch (e) {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
logger.warn(`[ssh:api] admin global encrypt failed err=${String(e)}`);
|
|
jsonError(res, 500, 'encrypt_failed');
|
|
return;
|
|
}
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
|
|
try {
|
|
const conn = deps.connectionRepo.create({
|
|
ownerId: null,
|
|
label, host, port, username,
|
|
privateKeyEnc: encrypted.blob,
|
|
passphraseEnc: encrypted.passphraseBlob,
|
|
keyVersion: encrypted.keyVersion,
|
|
keyFingerprint: encrypted.fingerprint,
|
|
remotePathPrefix,
|
|
allowRemoteUnrestricted,
|
|
allowPrivateAddresses,
|
|
commandDenyPatterns: denyPatterns ?? null,
|
|
commandAllowPatterns: allowPatterns ?? null,
|
|
});
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.connection.upsert',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: null,
|
|
actingUserId: userId,
|
|
reason: String(body.reason),
|
|
detail: { op: 'create', scope: 'global', allowRemoteUnrestricted, allowPrivateAddresses, keypairSource },
|
|
},
|
|
'success',
|
|
);
|
|
res.status(201).json({
|
|
connection: presentConnection(conn),
|
|
publicKey: encrypted.publicKey,
|
|
});
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin global create failed err=${String(e)}`);
|
|
jsonError(res, 500, 'create_failed');
|
|
}
|
|
});
|
|
|
|
router.patch('/globals/:id', deps.requireAdmin, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const reasonErr = validateReason(req.body?.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
if (conn.ownerId !== null) { jsonError(res, 400, 'not_global'); return; }
|
|
const body = req.body ?? {};
|
|
const patch: Parameters<SshConnectionRepo['update']>[1] = {};
|
|
if (body.label !== undefined) {
|
|
const v = safeString(body.label, 200);
|
|
if (v === null) { jsonError(res, 400, 'invalid_label'); return; }
|
|
patch.label = v;
|
|
}
|
|
if (body.host !== undefined) {
|
|
const v = safeString(body.host, 255);
|
|
if (v === null) { jsonError(res, 400, 'invalid_host'); return; }
|
|
patch.host = v;
|
|
}
|
|
if (body.port !== undefined) {
|
|
const v = safePort(body.port);
|
|
if (v === null) { jsonError(res, 400, 'invalid_port'); return; }
|
|
patch.port = v;
|
|
}
|
|
if (body.username !== undefined) {
|
|
const v = safeString(body.username, 64);
|
|
if (v === null) { jsonError(res, 400, 'invalid_username'); return; }
|
|
patch.username = v;
|
|
}
|
|
if (body.remotePathPrefix !== undefined) {
|
|
const v = safePathPrefix(body.remotePathPrefix);
|
|
if (v === null) { jsonError(res, 400, 'invalid_remote_path_prefix'); return; }
|
|
patch.remotePathPrefix = v;
|
|
}
|
|
if (body.commandDenyPatterns !== undefined) {
|
|
const v = safeOptionalString(body.commandDenyPatterns, 4096);
|
|
if (v === null) { jsonError(res, 400, 'invalid_command_deny_patterns'); return; }
|
|
patch.commandDenyPatterns = v ?? null;
|
|
}
|
|
if (body.commandAllowPatterns !== undefined) {
|
|
const v = safeOptionalString(body.commandAllowPatterns, 4096);
|
|
if (v === null) { jsonError(res, 400, 'invalid_command_allow_patterns'); return; }
|
|
patch.commandAllowPatterns = v ?? null;
|
|
}
|
|
if (body.allowRemoteUnrestricted !== undefined) {
|
|
patch.allowRemoteUnrestricted = body.allowRemoteUnrestricted === true || body.allowRemoteUnrestricted === 1;
|
|
}
|
|
if (body.allowPrivateAddresses !== undefined) {
|
|
patch.allowPrivateAddresses = body.allowPrivateAddresses === true || body.allowPrivateAddresses === 1;
|
|
}
|
|
if (typeof body.privateKeyPem === 'string') {
|
|
let pemBuf = Buffer.from(body.privateKeyPem, 'utf8');
|
|
let passBuf = typeof body.passphrase === 'string' ? Buffer.from(body.passphrase, 'utf8') : null;
|
|
try {
|
|
const enc = deps.encryptKeyMaterial(null, pemBuf, passBuf);
|
|
patch.privateKeyEnc = enc.blob;
|
|
patch.passphraseEnc = enc.passphraseBlob;
|
|
patch.keyVersion = enc.keyVersion;
|
|
patch.keyFingerprint = enc.fingerprint;
|
|
} finally {
|
|
pemBuf.fill(0);
|
|
if (passBuf) passBuf.fill(0);
|
|
}
|
|
}
|
|
const ok = deps.connectionRepo.update(conn.id, patch);
|
|
if (!ok) { jsonError(res, 404, 'not_found'); return; }
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.connection.upsert',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: null,
|
|
actingUserId: userId,
|
|
reason: String(req.body.reason),
|
|
detail: { op: 'update', scope: 'global', fields: Object.keys(patch) },
|
|
},
|
|
'success',
|
|
);
|
|
const updated = deps.connectionRepo.resolveConnection(conn.id)!;
|
|
res.json({ connection: presentConnection(updated) });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin global patch failed err=${String(e)}`);
|
|
jsonError(res, 500, 'update_failed');
|
|
}
|
|
});
|
|
|
|
router.delete('/globals/:id', deps.requireAdmin, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const reasonErr = validateReason(req.body?.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const conn = deps.connectionRepo.resolveConnection(req.params.id);
|
|
if (!conn) { jsonError(res, 404, 'not_found'); return; }
|
|
if (conn.ownerId !== null) { jsonError(res, 400, 'not_global'); return; }
|
|
const auditId = deps.auditRepo.begin({
|
|
action: 'ssh.connection.delete',
|
|
entityType: 'ssh_connection',
|
|
entityId: conn.id,
|
|
connectionId: conn.id,
|
|
ownerId: null,
|
|
actingUserId: userId,
|
|
reason: String(req.body.reason),
|
|
});
|
|
const ok = deps.connectionRepo.delete(conn.id);
|
|
if (!ok) {
|
|
deps.auditRepo.complete(auditId, 'failed', { err: 'no_changes' });
|
|
jsonError(res, 404, 'not_found');
|
|
return;
|
|
}
|
|
deps.auditRepo.complete(auditId, 'success');
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin global delete failed err=${String(e)}`);
|
|
jsonError(res, 500, 'delete_failed');
|
|
}
|
|
});
|
|
|
|
router.get('/grants', deps.requireAdmin, (req, res) => {
|
|
try {
|
|
const limit = safeLimit(req.query.limit, 100, 1000);
|
|
const rows = deps.db.prepare(
|
|
`SELECT * FROM ssh_connection_grants ORDER BY created_at DESC LIMIT ?`,
|
|
).all(limit) as Array<{
|
|
id: string; connection_id: string; subject_type: SshGrantSubjectType; subject_id: string;
|
|
piece_name: string | null; applies_to_all_pieces: number; granted_by_user_id: string;
|
|
reason: string; expires_at: string | null; created_at: string;
|
|
}>;
|
|
const grants = rows.map((r) => ({
|
|
id: r.id,
|
|
connectionId: r.connection_id,
|
|
subjectType: r.subject_type,
|
|
subjectId: r.subject_id,
|
|
pieceName: r.piece_name,
|
|
appliesToAllPieces: r.applies_to_all_pieces === 1,
|
|
grantedByUserId: r.granted_by_user_id,
|
|
reason: r.reason,
|
|
expiresAt: r.expires_at,
|
|
createdAt: r.created_at,
|
|
}));
|
|
res.json({ grants });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin list grants failed err=${String(e)}`);
|
|
jsonError(res, 500, 'list_grants_failed');
|
|
}
|
|
});
|
|
|
|
router.post('/grants', deps.requireAdmin, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const body = req.body ?? {};
|
|
const reasonErr = validateReason(body.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const connectionId = safeUuid(body.connectionId);
|
|
const subjectType = safeSubjectType(body.subjectType);
|
|
const subjectId = safeString(body.subjectId, 128);
|
|
const appliesToAllPieces = body.appliesToAllPieces === true || body.appliesToAllPieces === 1;
|
|
const pieceName = safeOptionalString(body.pieceName, 64);
|
|
const expiresAt = safeExpiresAt(body.expiresAt);
|
|
if (!connectionId || !subjectType || !subjectId) {
|
|
jsonError(res, 400, 'invalid_input', {
|
|
required: ['connectionId', 'subjectType ("user"|"org")', 'subjectId', 'reason'],
|
|
});
|
|
return;
|
|
}
|
|
if (pieceName === null) { jsonError(res, 400, 'invalid_piece_name'); return; }
|
|
if (expiresAt === null) { jsonError(res, 400, 'invalid_expires_at'); return; }
|
|
if (!appliesToAllPieces && (!pieceName || pieceName.length === 0)) {
|
|
jsonError(res, 400, 'piece_name_required_unless_applies_to_all'); return;
|
|
}
|
|
if (appliesToAllPieces && pieceName !== undefined && pieceName.length > 0) {
|
|
jsonError(res, 400, 'piece_name_conflicts_with_applies_to_all'); return;
|
|
}
|
|
// Connection must exist before granting.
|
|
const conn = deps.connectionRepo.resolveConnection(connectionId);
|
|
if (!conn) { jsonError(res, 404, 'connection_not_found'); return; }
|
|
let grant: SshGrant;
|
|
try {
|
|
grant = deps.grantsRepo.create({
|
|
connectionId,
|
|
subjectType,
|
|
subjectId,
|
|
pieceName: appliesToAllPieces ? null : (pieceName ?? null),
|
|
appliesToAllPieces,
|
|
grantedByUserId: userId,
|
|
reason: String(body.reason),
|
|
expiresAt,
|
|
});
|
|
} catch (e) {
|
|
jsonError(res, 400, 'grant_create_failed', { detail: String(e) }); return;
|
|
}
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.grant.create',
|
|
entityType: 'ssh_grant',
|
|
entityId: grant.id,
|
|
connectionId: conn.id,
|
|
ownerId: conn.ownerId,
|
|
actingUserId: userId,
|
|
reason: String(body.reason),
|
|
detail: { subjectType, subjectId, pieceName: grant.pieceName, appliesToAllPieces },
|
|
},
|
|
'success',
|
|
);
|
|
res.status(201).json({ grant: presentGrant(grant) });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin grant create failed err=${String(e)}`);
|
|
jsonError(res, 500, 'grant_create_failed');
|
|
}
|
|
});
|
|
|
|
router.delete('/grants/:id', deps.requireAdmin, (req, res) => {
|
|
if (maintenance503(deps.maintenance, res)) return;
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const reasonErr = validateReason(req.body?.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
const grant = deps.grantsRepo.getById(req.params.id);
|
|
if (!grant) { jsonError(res, 404, 'not_found'); return; }
|
|
const ok = deps.grantsRepo.delete(grant.id);
|
|
if (!ok) { jsonError(res, 404, 'not_found'); return; }
|
|
// Kick any active console-WS viewers whose access depended on this grant.
|
|
// user-subject grants map 1:1 to a single userId; org-subject grants are
|
|
// deferred (next attach will be denied; existing viewers stay until the
|
|
// session ends or the org-member's WS reconnects).
|
|
let kicked: number | void = 0;
|
|
if (grant.subjectType === 'user' && deps.onAccessRevoked) {
|
|
kicked = deps.onAccessRevoked({ connectionId: grant.connectionId, userId: grant.subjectId });
|
|
}
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.grant.delete',
|
|
entityType: 'ssh_grant',
|
|
entityId: grant.id,
|
|
connectionId: grant.connectionId,
|
|
actingUserId: userId,
|
|
reason: String(req.body.reason),
|
|
detail: {
|
|
subjectType: grant.subjectType,
|
|
subjectId: grant.subjectId,
|
|
pieceName: grant.pieceName,
|
|
viewersKicked: typeof kicked === 'number' ? kicked : 0,
|
|
},
|
|
},
|
|
'success',
|
|
);
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin grant delete failed err=${String(e)}`);
|
|
jsonError(res, 500, 'grant_delete_failed');
|
|
}
|
|
});
|
|
|
|
// POST /api/ssh/admin/rotate-master-key — stub for v1 (Phase 5).
|
|
// Sets the maintenance flag and audits the intent. The actual DEK re-wrap
|
|
// job is deferred to a follow-up PR per Phase 5 design note (line 728).
|
|
// Returns 501 in the response body but 202 status so the UI can show a
|
|
// banner; UI Phase 6 will key off the maintenance snapshot.
|
|
router.post('/rotate-master-key', deps.requireAdmin, (req, res) => {
|
|
try {
|
|
const userId = deps.getUserId(req);
|
|
if (!userId) { jsonError(res, 401, 'unauthorized'); return; }
|
|
const reasonErr = validateReason(req.body?.reason);
|
|
if (reasonErr) { jsonError(res, 400, reasonErr); return; }
|
|
if (deps.maintenance.isActive()) {
|
|
jsonError(res, 409, 'already_in_progress', { snapshot: deps.maintenance.snapshot() });
|
|
return;
|
|
}
|
|
const jobId = `rotate-${Date.now().toString(36)}`;
|
|
deps.maintenance.enter(String(req.body.reason), jobId);
|
|
deps.auditRepo.beginAndComplete(
|
|
{
|
|
action: 'ssh.master_key.rotate.start',
|
|
actingUserId: userId,
|
|
reason: String(req.body.reason),
|
|
detail: { jobId, status: 'stub', note: 'rotation job not implemented in v1; only maintenance flag set' },
|
|
},
|
|
'success',
|
|
);
|
|
res.status(202).json({
|
|
jobId,
|
|
status: 'maintenance_set',
|
|
detail: 'maintenance flag is now active; actual DEK re-wrap is not implemented in v1',
|
|
notImplemented: true,
|
|
});
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin rotate-master-key failed err=${String(e)}`);
|
|
jsonError(res, 500, 'rotate_failed');
|
|
}
|
|
});
|
|
|
|
router.get('/rotate-master-key/:jobId', deps.requireAdmin, (req, res) => {
|
|
try {
|
|
const snap = deps.maintenance.snapshot();
|
|
if (!snap.active || snap.jobId !== req.params.jobId) {
|
|
jsonError(res, 404, 'not_found_or_completed', { snapshot: snap });
|
|
return;
|
|
}
|
|
res.json({
|
|
jobId: snap.jobId,
|
|
status: 'in_progress',
|
|
startedAt: snap.enteredAt,
|
|
progress: { note: 'v1 stub: no rewrap performed' },
|
|
notImplemented: true,
|
|
});
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin rotate-master-key get failed err=${String(e)}`);
|
|
jsonError(res, 500, 'rotate_get_failed');
|
|
}
|
|
});
|
|
|
|
// GET /api/ssh/admin/audit — cross-user audit query.
|
|
router.get('/audit', deps.requireAdmin, (req, res) => {
|
|
try {
|
|
const limit = safeLimit(req.query.limit, 100, 1000);
|
|
const action = typeof req.query.action === 'string' ? req.query.action : null;
|
|
const ownerId = typeof req.query.ownerId === 'string' ? req.query.ownerId : null;
|
|
const connectionId = typeof req.query.connectionId === 'string' ? req.query.connectionId : null;
|
|
const outcome = typeof req.query.outcome === 'string' ? req.query.outcome : null;
|
|
|
|
const where: string[] = [];
|
|
const params: unknown[] = [];
|
|
if (action) { where.push('action = ?'); params.push(action); }
|
|
if (ownerId) { where.push('owner_id = ?'); params.push(ownerId); }
|
|
if (connectionId) { where.push('connection_id = ?'); params.push(connectionId); }
|
|
if (outcome) { where.push('outcome = ?'); params.push(outcome); }
|
|
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
|
const sql = `SELECT * FROM ssh_audit_log ${whereClause} ORDER BY started_at DESC LIMIT ?`;
|
|
params.push(limit);
|
|
const rows = deps.db.prepare(sql).all(...params) as Array<{
|
|
id: number; action: string; entity_type: string | null; entity_id: string | null;
|
|
connection_id: string | null; owner_id: string | null; acting_user_id: string | null;
|
|
job_id: string | null; piece_name: string | null; outcome: string;
|
|
reason: string | null; detail: string | null; started_at: string; completed_at: string | null;
|
|
}>;
|
|
const audit = rows.map((r) => ({
|
|
id: r.id,
|
|
action: r.action,
|
|
entityType: r.entity_type,
|
|
entityId: r.entity_id,
|
|
connectionId: r.connection_id,
|
|
ownerId: r.owner_id,
|
|
actingUserId: r.acting_user_id,
|
|
jobId: r.job_id,
|
|
pieceName: r.piece_name,
|
|
outcome: r.outcome,
|
|
reason: r.reason,
|
|
detail: r.detail ? JSON.parse(r.detail) : null,
|
|
startedAt: r.started_at,
|
|
completedAt: r.completed_at,
|
|
}));
|
|
res.json({ audit });
|
|
} catch (e) {
|
|
logger.warn(`[ssh:api] admin audit list failed err=${String(e)}`);
|
|
jsonError(res, 500, 'audit_failed');
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|