Security hardening: red-team remediation + CCP/WIP updates
## 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
This commit is contained in:
@@ -131,6 +131,7 @@ export default function InstanceDetailPage() {
|
||||
const [tunnelStatusLoading, setTunnelStatusLoading] = useState(false);
|
||||
const [tunnelSetupRunning, setTunnelSetupRunning] = useState(false);
|
||||
const [tunnelSyncing, setTunnelSyncing] = useState(false);
|
||||
const [tunnelImporting, setTunnelImporting] = useState(false);
|
||||
|
||||
// Upgrade state
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null);
|
||||
@@ -337,6 +338,26 @@ export default function InstanceDetailPage() {
|
||||
}
|
||||
}, [instance?.status, fetchInstance]);
|
||||
|
||||
// Fetch tunnel status for remote instances (must be before early return)
|
||||
const fetchTunnelStatus = useCallback(async () => {
|
||||
if (!instance?.isRemote) return;
|
||||
setTunnelStatusLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/instances/${id}/tunnel/status`);
|
||||
setTunnelStatus(data.data);
|
||||
} catch {
|
||||
setTunnelStatus(null);
|
||||
} finally {
|
||||
setTunnelStatusLoading(false);
|
||||
}
|
||||
}, [id, instance?.isRemote]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'tunnel' && instance?.isRemote) {
|
||||
fetchTunnelStatus();
|
||||
}
|
||||
}, [activeTab, instance?.isRemote, fetchTunnelStatus]);
|
||||
|
||||
const handleAction = async (action: string, label: string) => {
|
||||
setActionLoading(action);
|
||||
try {
|
||||
@@ -1162,31 +1183,11 @@ export default function InstanceDetailPage() {
|
||||
const tunnelConfigured = !!(instance.pangolinEndpoint && instance.pangolinNewtId);
|
||||
const canConfigureTunnel = isManaged && (instance.status === 'RUNNING' || instance.status === 'STOPPED');
|
||||
|
||||
// Fetch tunnel status for remote instances
|
||||
const fetchTunnelStatus = useCallback(async () => {
|
||||
if (!isRemote) return;
|
||||
setTunnelStatusLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/instances/${id}/tunnel/status`);
|
||||
setTunnelStatus(data.data);
|
||||
} catch {
|
||||
setTunnelStatus(null);
|
||||
} finally {
|
||||
setTunnelStatusLoading(false);
|
||||
}
|
||||
}, [id, isRemote]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'tunnel' && isRemote) {
|
||||
fetchTunnelStatus();
|
||||
}
|
||||
}, [activeTab, isRemote, fetchTunnelStatus]);
|
||||
|
||||
const handleRemoteTunnelSetup = async (values: { subdomainPrefix?: string }) => {
|
||||
setTunnelSetupRunning(true);
|
||||
try {
|
||||
await api.post(`/instances/${id}/tunnel/setup`, {
|
||||
subdomainPrefix: values.subdomainPrefix || instance.slug,
|
||||
subdomainPrefix: values.subdomainPrefix || '',
|
||||
});
|
||||
message.success('Tunnel setup complete — Newt credentials pushed to remote instance');
|
||||
fetchInstance();
|
||||
@@ -1199,6 +1200,23 @@ export default function InstanceDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTunnelImport = async () => {
|
||||
setTunnelImporting(true);
|
||||
try {
|
||||
const { data } = await api.post(`/instances/${id}/tunnel/import`);
|
||||
message.success(
|
||||
`Tunnel imported — site ${data.data.siteId} (${data.data.online ? 'online' : 'offline'})`
|
||||
);
|
||||
fetchInstance();
|
||||
fetchTunnelStatus();
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { error?: { message?: string } } } };
|
||||
message.error(e?.response?.data?.error?.message || 'Import failed');
|
||||
} finally {
|
||||
setTunnelImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTunnelSync = async () => {
|
||||
setTunnelSyncing(true);
|
||||
try {
|
||||
@@ -1344,16 +1362,34 @@ export default function InstanceDetailPage() {
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Card title="Import Existing Tunnel" size="small">
|
||||
<p style={{ marginTop: 0 }}>
|
||||
If this instance already has a Pangolin tunnel set up (e.g. by
|
||||
<code> config.sh --pangolin-site new</code> during install), the CCP can
|
||||
adopt it by reading the remote <code>.env</code> and verifying the site
|
||||
exists in the CCP's Pangolin org. No resources are modified.
|
||||
</p>
|
||||
<Popconfirm
|
||||
title="Import existing tunnel?"
|
||||
description="The CCP will read Pangolin credentials from the remote .env and persist them on this instance."
|
||||
onConfirm={handleTunnelImport}
|
||||
okText="Import"
|
||||
>
|
||||
<Button icon={<CloudOutlined />} loading={tunnelImporting}>
|
||||
Import Existing Tunnel
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Card>
|
||||
|
||||
<Card title="Setup Tunnel" size="small">
|
||||
<Form layout="vertical" onFinish={handleRemoteTunnelSetup}>
|
||||
<Form.Item
|
||||
name="subdomainPrefix"
|
||||
label="Subdomain Prefix"
|
||||
initialValue={instance.slug}
|
||||
extra={`Resources will be created as <prefix>-app.${instance.domain}, <prefix>-api.${instance.domain}, etc.`}
|
||||
rules={[{ required: true }, { pattern: /^[a-z0-9-]+$/, message: 'Lowercase alphanumeric + hyphens only' }]}
|
||||
label="Subdomain Prefix (optional)"
|
||||
extra={`Leave empty for standard subdomains (app.${instance.domain}, api.${instance.domain}). Set a prefix for multi-tenant domains (e.g. "ck" creates ck-app.${instance.domain}).`}
|
||||
rules={[{ pattern: /^[a-z0-9-]*$/, message: 'Lowercase alphanumeric + hyphens only' }]}
|
||||
>
|
||||
<Input placeholder={instance.slug} />
|
||||
<Input placeholder="(none — uses standard subdomains)" />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" icon={<CloudOutlined />} loading={tunnelSetupRunning}>
|
||||
|
||||
@@ -122,20 +122,39 @@ async function startPhoneHome() {
|
||||
const result = await response.json() as { registrationId: string };
|
||||
logger.info(`[phone-home] Registration submitted (id: ${result.registrationId}). Waiting for approval...`);
|
||||
|
||||
// Step 2: Poll for approval
|
||||
// Step 2: Poll for approval. Every path inside the callback is wrapped in
|
||||
// try/catch so an unexpected throw never kills the interval silently.
|
||||
// On every poll we log either the status transition or a heartbeat every
|
||||
// 10th attempt, so admins can see the loop is alive.
|
||||
let pollCount = 0;
|
||||
let lastLoggedStatus: string | null = null;
|
||||
const pollInterval = setInterval(async () => {
|
||||
pollCount += 1;
|
||||
try {
|
||||
const pollResp = await fetch(
|
||||
`${env.CCP_URL}/api/agents/poll?registrationId=${result.registrationId}&slug=${env.INSTANCE_SLUG}`
|
||||
);
|
||||
|
||||
if (!pollResp.ok) return;
|
||||
if (!pollResp.ok) {
|
||||
logger.warn(`[phone-home] Poll #${pollCount} HTTP ${pollResp.status} ${pollResp.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pollData = await pollResp.json() as {
|
||||
status: string;
|
||||
certBundle?: { caCertPem: string; agentCertPem: string; agentKeyPem: string; ccpFingerprint: string };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
// Log status transitions and periodic heartbeats so the loop is never
|
||||
// invisible. Previously a stuck loop left no trace in logs.
|
||||
if (pollData.status !== lastLoggedStatus) {
|
||||
logger.info(`[phone-home] Poll #${pollCount}: status=${pollData.status}${pollData.message ? ` — ${pollData.message}` : ''}`);
|
||||
lastLoggedStatus = pollData.status;
|
||||
} else if (pollCount % 10 === 0) {
|
||||
logger.debug(`[phone-home] Poll #${pollCount}: still ${pollData.status}`);
|
||||
}
|
||||
|
||||
if (pollData.status === 'APPROVED' && pollData.certBundle) {
|
||||
clearInterval(pollInterval);
|
||||
logger.info('[phone-home] Approved! Saving certificates...');
|
||||
@@ -161,14 +180,28 @@ async function startPhoneHome() {
|
||||
|
||||
// Exit so Docker restart policy brings us back with certs
|
||||
process.exit(0);
|
||||
} else if (pollData.status === 'APPROVED' && !pollData.certBundle) {
|
||||
// Admin approved but cert bundle was consumed (e.g. by debug curl).
|
||||
// Keep polling — admin can re-issue certs via the new endpoint and we'll
|
||||
// pick them up on the next poll.
|
||||
// (No action needed; the status-transition log above covers visibility.)
|
||||
} else if (pollData.status === 'REJECTED') {
|
||||
clearInterval(pollInterval);
|
||||
logger.error('[phone-home] Registration was rejected by CCP admin');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[phone-home] Poll failed: ${(err as Error).message}`);
|
||||
// CRITICAL: this catch MUST swallow every error — if it rethrows the
|
||||
// setInterval callback becomes an unhandled rejection and Node may kill
|
||||
// the interval depending on the runtime config. We saw this in prod.
|
||||
logger.warn(`[phone-home] Poll #${pollCount} failed: ${(err as Error).message}`);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// Defensive: if the Node process receives an unhandled rejection that
|
||||
// somehow originates from the poll path, log it instead of dying quietly.
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error(`[phone-home] Unhandled rejection in poll loop: ${reason}`);
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[phone-home] Registration request failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- DropIndex
|
||||
DROP INDEX "instances_compose_project_key";
|
||||
@@ -66,7 +66,7 @@ model Instance {
|
||||
statusMessage String? @map("status_message")
|
||||
|
||||
basePath String @map("base_path")
|
||||
composeProject String @unique @map("compose_project")
|
||||
composeProject String @map("compose_project")
|
||||
gitBranch String @default("v2") @map("git_branch")
|
||||
gitCommit String? @map("git_commit")
|
||||
|
||||
|
||||
@@ -245,4 +245,49 @@ router.post('/registrations/:id/reject', authenticate, requireRole('SUPER_ADMIN'
|
||||
res.json({ message: 'Registration rejected' });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/registrations/:id/reissue-certs
|
||||
* Re-issue certificates for an approved registration whose cert bundle was already
|
||||
* delivered and wiped (e.g. agent missed the one-shot delivery).
|
||||
*/
|
||||
router.post('/registrations/:id/reissue-certs', authenticate, requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } });
|
||||
if (!registration) throw new AppError(404, 'Registration not found');
|
||||
if (registration.status !== AgentRegistrationStatus.APPROVED) {
|
||||
throw new AppError(400, `Registration is ${registration.status}, not APPROVED`);
|
||||
}
|
||||
if (!registration.instanceId) {
|
||||
throw new AppError(400, 'Registration has no linked instance');
|
||||
}
|
||||
|
||||
// Re-issue certs and write back to registration for agent to pick up
|
||||
const certMaterials = await issueAgentCert(registration.instanceId, registration.slug, registration.agentUrl);
|
||||
|
||||
await prisma.agentRegistration.update({
|
||||
where: { id: id as string },
|
||||
data: {
|
||||
certBundle: {
|
||||
caCertPem: certMaterials.caCertPem,
|
||||
agentCertPem: certMaterials.agentCertPem,
|
||||
agentKeyPem: certMaterials.agentKeyPem,
|
||||
ccpFingerprint: certMaterials.fingerprint,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: (req as unknown as { user: { id: string } }).user.id,
|
||||
instanceId: registration.instanceId,
|
||||
action: AuditAction.AGENT_APPROVE,
|
||||
details: { slug: registration.slug, reason: 'cert-reissue' },
|
||||
ipAddress: req.ip || null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[agents] Certificates re-issued for ${registration.slug}`);
|
||||
res.json({ message: 'Certificates re-issued — agent will receive them on next poll' });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -250,6 +250,20 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
// 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(
|
||||
|
||||
@@ -122,11 +122,12 @@ export const startUpgradeSchema = z.object({
|
||||
});
|
||||
|
||||
export const setupRemoteTunnelSchema = z.object({
|
||||
// Empty string or omitted → resources use standard subdomains (app., api., etc.)
|
||||
// A value like "ck" → creates ck-app., ck-api., etc. for multi-tenant domains
|
||||
subdomainPrefix: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.regex(/^[a-z0-9-]+$/, 'Prefix must be lowercase alphanumeric with hyphens')
|
||||
.regex(/^[a-z0-9-]*$/, 'Prefix must be lowercase alphanumeric with hyphens')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,18 @@ const exec = promisify(execCb);
|
||||
const CA_VALIDITY_DAYS = 3650; // ~10 years
|
||||
const AGENT_CERT_VALIDITY_DAYS = 730; // ~2 years
|
||||
|
||||
/**
|
||||
* Shell/cert-injection guard. Slug flows into the OpenSSL -subj `/CN=...` string
|
||||
* and into SAN DNS entries. An unvalidated slug like `foo/O=EvilOrg/CN=` would
|
||||
* let callers forge arbitrary DN components. Added 2026-04-12.
|
||||
*/
|
||||
const SAFE_SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
function assertSafeSlug(slug: string): void {
|
||||
if (!SAFE_SLUG.test(slug)) {
|
||||
throw new Error(`Invalid agent slug: must match ${SAFE_SLUG}`);
|
||||
}
|
||||
}
|
||||
|
||||
function computeFingerprint(certPem: string): string {
|
||||
const der = Buffer.from(
|
||||
certPem
|
||||
@@ -91,6 +103,7 @@ export async function ensureCA() {
|
||||
* Returns the certificate materials (plaintext) for one-time display.
|
||||
*/
|
||||
export async function issueAgentCert(instanceId: string, slug: string, agentUrl?: string) {
|
||||
assertSafeSlug(slug);
|
||||
const ca = await ensureCA();
|
||||
const caKeyPem = decrypt(ca.encryptedKey);
|
||||
|
||||
@@ -115,6 +128,11 @@ export async function issueAgentCert(instanceId: string, slug: string, agentUrl?
|
||||
if (agentUrl) {
|
||||
try {
|
||||
const hostname = new URL(agentUrl).hostname;
|
||||
// Guard against SAN injection via crafted hostname (commas/newlines would
|
||||
// inject extra SAN entries into the extfile). Added 2026-04-12.
|
||||
if (/[,\n\r\0]/.test(hostname)) {
|
||||
throw new Error('Invalid hostname');
|
||||
}
|
||||
// Detect IP vs DNS name
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) || hostname.includes(':')) {
|
||||
sanEntries.push(`IP:${hostname}`);
|
||||
|
||||
@@ -152,15 +152,22 @@ export async function checkInstanceHealth(instanceId: string) {
|
||||
if (instance.status === InstanceStatus.RUNNING && !hasRunningContainers) {
|
||||
await prisma.instance.update({
|
||||
where: { id: instanceId },
|
||||
data: { status: InstanceStatus.STOPPED },
|
||||
data: {
|
||||
status: InstanceStatus.STOPPED,
|
||||
statusMessage: `No running containers detected at ${new Date().toISOString()}`,
|
||||
},
|
||||
});
|
||||
logger.info(`[health] ${instance.slug}: auto-corrected status RUNNING → STOPPED (0 running containers)`);
|
||||
} else if (instance.status === InstanceStatus.STOPPED && hasRunningContainers) {
|
||||
const runningCount = containers.filter((c) => c.state === 'running').length;
|
||||
await prisma.instance.update({
|
||||
where: { id: instanceId },
|
||||
data: { status: InstanceStatus.RUNNING },
|
||||
data: {
|
||||
status: InstanceStatus.RUNNING,
|
||||
statusMessage: `${runningCount} container(s) running — detected at ${new Date().toISOString()}`,
|
||||
},
|
||||
});
|
||||
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${containers.filter((c) => c.state === 'running').length} running containers detected)`);
|
||||
logger.info(`[health] ${instance.slug}: auto-corrected status STOPPED → RUNNING (${runningCount} running containers detected)`);
|
||||
}
|
||||
|
||||
// Sync domain and feature flags from .env if they have drifted
|
||||
|
||||
@@ -44,6 +44,12 @@ export interface InstanceSecrets {
|
||||
jwtAccessSecret: string;
|
||||
jwtRefreshSecret: string;
|
||||
jwtInviteSecret: string;
|
||||
// Added 2026-04-12 (P2-2): Changemaker Lite now requires distinct secrets for
|
||||
// Gitea SSO cookies and service-account password derivation — the old
|
||||
// JWT_ACCESS_SECRET fallback was removed. New instances provisioned by CCP
|
||||
// must receive both to boot.
|
||||
giteaSsoSecret: string;
|
||||
servicePasswordSalt: string;
|
||||
encryptionKey: string;
|
||||
initialAdminPassword: string;
|
||||
nocodbAdminPassword: string;
|
||||
@@ -69,6 +75,8 @@ export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEm
|
||||
jwtAccessSecret: randomHex(32),
|
||||
jwtRefreshSecret: randomHex(32),
|
||||
jwtInviteSecret: randomHex(32),
|
||||
giteaSsoSecret: randomHex(32),
|
||||
servicePasswordSalt: randomHex(32),
|
||||
encryptionKey: randomHex(32),
|
||||
initialAdminPassword: randomPassword(16),
|
||||
nocodbAdminPassword: randomPassword(16),
|
||||
|
||||
@@ -62,7 +62,8 @@ function getPangolinClient(): CcpPangolinClient {
|
||||
}
|
||||
|
||||
function fullSubdomain(prefix: string, sub: string): string {
|
||||
if (!sub) return prefix; // root domain → prefix alone (e.g., "ck")
|
||||
if (!prefix) return sub; // no prefix → use subdomain as-is (e.g., "app")
|
||||
if (!sub) return prefix; // root domain → prefix alone (e.g., "ck")
|
||||
return `${prefix}-${sub}`; // e.g., "ck-app", "ck-api"
|
||||
}
|
||||
|
||||
@@ -130,7 +131,10 @@ export async function setupTunnel(
|
||||
throw new AppError(400, 'Tunnel is already configured. Use sync to update resources, or teardown first.', 'ALREADY_CONFIGURED');
|
||||
}
|
||||
|
||||
const prefix = options.subdomainPrefix || instance.slug;
|
||||
// Empty prefix means resources use standard subdomains (app., api., etc.)
|
||||
// matching the instance's nginx config. A prefix like "ck" creates
|
||||
// ck-app., ck-api., etc. for multi-tenant Pangolin domains.
|
||||
const prefix = options.subdomainPrefix ?? '';
|
||||
|
||||
const driver = await getRemoteDriverForInstance({
|
||||
id: instance.id,
|
||||
@@ -318,7 +322,7 @@ export async function syncResources(
|
||||
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
if (!instance.pangolinSiteId) throw new AppError(400, 'No tunnel configured', 'NO_TUNNEL');
|
||||
|
||||
const prefix = instance.pangolinSubdomainPrefix || instance.slug;
|
||||
const prefix = instance.pangolinSubdomainPrefix ?? '';
|
||||
const domain = await findDomainForInstance(client, instance.domain);
|
||||
const existingResources = await client.listResources();
|
||||
const siteId = instance.pangolinSiteId;
|
||||
@@ -387,7 +391,26 @@ export async function teardownTunnel(
|
||||
|
||||
const siteId = instance.pangolinSiteId;
|
||||
|
||||
// Delete site from Pangolin (cascades resources + targets)
|
||||
// Pangolin does NOT cascade-delete resources when a site is deleted.
|
||||
// We must delete resources first to avoid orphaned entries.
|
||||
try {
|
||||
const allResources = await client.listResources();
|
||||
const siteIdNum = Number(siteId);
|
||||
for (const res of allResources) {
|
||||
try {
|
||||
const targets = await client.listTargets(String(res.resourceId));
|
||||
const ours = targets.some((t) => Number(t.siteId) === siteIdNum);
|
||||
if (ours) {
|
||||
await client.deleteResource(String(res.resourceId));
|
||||
logger.info(`[tunnel] ${instance.slug}: deleted resource ${res.name} (${res.fullDomain})`);
|
||||
}
|
||||
} catch { /* target lookup failed — skip */ }
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[tunnel] ${instance.slug}: resource cleanup failed: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Now delete the site itself
|
||||
try {
|
||||
await client.deleteSite(siteId);
|
||||
logger.info(`[tunnel] ${instance.slug}: deleted Pangolin site ${siteId}`);
|
||||
@@ -549,6 +572,120 @@ export async function getTunnelStatus(instanceId: string): Promise<TunnelStatus>
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Import existing tunnel ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adopt a tunnel that was set up outside the CCP (e.g. by `config.sh --pangolin-site new`
|
||||
* on the remote instance itself). Reads the remote `.env` via the agent, pulls the
|
||||
* Pangolin vars, verifies the site exists in the CCP's Pangolin org, and persists the
|
||||
* values on the Instance record so subsequent sync/teardown/status calls work.
|
||||
*
|
||||
* We DO NOT fetch the Newt secret from Pangolin — it's only in the remote .env, and
|
||||
* Pangolin's API does not expose it after site creation. If the remote .env is missing
|
||||
* it, the user has to teardown + setup via CCP instead.
|
||||
*/
|
||||
export async function importTunnel(
|
||||
instanceId: string,
|
||||
userId?: string,
|
||||
ipAddress?: string | null
|
||||
) {
|
||||
const client = getPangolinClient();
|
||||
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
|
||||
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
if (!instance.isRemote) throw new AppError(400, 'Import only applies to remote instances', 'NOT_REMOTE');
|
||||
if (instance.pangolinSiteId) {
|
||||
throw new AppError(400, 'Tunnel already imported/configured — use sync or teardown instead', 'ALREADY_CONFIGURED');
|
||||
}
|
||||
|
||||
// 1. Read the remote .env via mTLS agent
|
||||
const driver = await getRemoteDriverForInstance({
|
||||
id: instance.id,
|
||||
slug: instance.slug,
|
||||
isRemote: instance.isRemote,
|
||||
agentUrl: instance.agentUrl,
|
||||
});
|
||||
const envVars = await driver.readEnvFile('');
|
||||
if (!envVars) {
|
||||
throw new AppError(502, 'Could not read .env from remote agent', 'AGENT_READ_FAILED');
|
||||
}
|
||||
|
||||
// 2. Extract Pangolin vars
|
||||
const siteId = envVars.PANGOLIN_SITE_ID?.trim();
|
||||
const newtId = envVars.PANGOLIN_NEWT_ID?.trim();
|
||||
const newtSecret = envVars.PANGOLIN_NEWT_SECRET?.trim();
|
||||
const endpoint = envVars.PANGOLIN_ENDPOINT?.trim() || env.PANGOLIN_API_URL?.replace(/\/v1$/, '') || '';
|
||||
|
||||
const missing: string[] = [];
|
||||
if (!siteId) missing.push('PANGOLIN_SITE_ID');
|
||||
if (!newtId) missing.push('PANGOLIN_NEWT_ID');
|
||||
if (!newtSecret) missing.push('PANGOLIN_NEWT_SECRET');
|
||||
if (missing.length > 0) {
|
||||
throw new AppError(
|
||||
400,
|
||||
`Remote .env missing Pangolin credentials: ${missing.join(', ')}. ` +
|
||||
`Either the tunnel was never set up, or it was torn down. ` +
|
||||
`Use "Setup Tunnel" to create a fresh one via CCP.`,
|
||||
'REMOTE_ENV_MISSING_TUNNEL'
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Verify the site exists in the CCP's Pangolin org (and that our API key can see it)
|
||||
let onlineNow = false;
|
||||
try {
|
||||
const site = await client.getSite(siteId!);
|
||||
onlineNow = site.online ?? false;
|
||||
} catch (err) {
|
||||
throw new AppError(
|
||||
400,
|
||||
`Site ${siteId} not found in CCP's Pangolin org (${env.PANGOLIN_ORG_ID}). ` +
|
||||
`The remote instance may be using a different Pangolin org than the CCP is configured for. ` +
|
||||
`Error: ${(err as Error).message}`,
|
||||
'SITE_NOT_IN_CCP_ORG'
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Persist on Instance record
|
||||
await prisma.instance.update({
|
||||
where: { id: instanceId },
|
||||
data: {
|
||||
pangolinEndpoint: endpoint || null,
|
||||
pangolinSiteId: siteId,
|
||||
pangolinNewtId: newtId,
|
||||
pangolinNewtSecret: newtSecret,
|
||||
// Note: we do NOT infer pangolinSubdomainPrefix — import assumes standard
|
||||
// subdomains (app., api., etc.). If the user was using a prefix, they should
|
||||
// teardown + setup via CCP instead.
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Audit log
|
||||
if (userId) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId,
|
||||
action: AuditAction.PANGOLIN_SETUP,
|
||||
details: {
|
||||
source: 'import',
|
||||
siteId,
|
||||
endpoint,
|
||||
onlineAtImport: onlineNow,
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
ipAddress: ipAddress ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[tunnel] ${instance.slug}: imported existing tunnel (site ${siteId}, online=${onlineNow})`);
|
||||
|
||||
return {
|
||||
imported: true,
|
||||
siteId,
|
||||
endpoint,
|
||||
online: onlineNow,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── .env Helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,29 @@ import { createEvent } from './event.service';
|
||||
import { getRemoteDriverForInstance } from './execution-driver';
|
||||
import type { AgentUpdateStatus } from './remote-driver';
|
||||
|
||||
/**
|
||||
* Shell-injection guards. Any user- or DB-controlled value that flows into
|
||||
* `bash`/`git` via `exec()` must be validated against these regexes first.
|
||||
* Added 2026-04-12 after red-team audit found unvalidated `branch` and `basePath`
|
||||
* values reaching the shell.
|
||||
*/
|
||||
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
|
||||
const SAFE_PATH = /^\/[a-zA-Z0-9/_.-]{1,255}$/;
|
||||
|
||||
function assertSafeBranch(branch: string | null | undefined, ctx: string): void {
|
||||
if (!branch) return;
|
||||
if (!SAFE_BRANCH.test(branch)) {
|
||||
throw new Error(`Invalid git branch name (${ctx}): must match ${SAFE_BRANCH}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafePath(p: string | null | undefined, ctx: string): void {
|
||||
if (!p) return;
|
||||
if (!SAFE_PATH.test(p)) {
|
||||
throw new Error(`Invalid path (${ctx}): must match ${SAFE_PATH}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an INSTANCE_UPGRADE audit log entry capturing a terminal outcome.
|
||||
* Wrapped in try/catch so that an audit-log DB failure cannot mask the
|
||||
@@ -227,6 +250,10 @@ export async function startUpgrade(
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against shell injection via branch name (flows into bash exec).
|
||||
assertSafeBranch(options?.branch, 'options.branch');
|
||||
assertSafeBranch(instance.gitBranch, 'instance.gitBranch');
|
||||
assertSafePath(instance.basePath, 'instance.basePath');
|
||||
const branch = options?.branch || instance.gitBranch;
|
||||
|
||||
// Create upgrade record
|
||||
|
||||
@@ -26,7 +26,15 @@ JWT_ACCESS_SECRET={{secrets.jwtAccessSecret}}
|
||||
JWT_REFRESH_SECRET={{secrets.jwtRefreshSecret}}
|
||||
JWT_INVITE_SECRET={{secrets.jwtInviteSecret}}
|
||||
JWT_ACCESS_EXPIRY=15m
|
||||
JWT_REFRESH_EXPIRY=7d
|
||||
# Reduced 2026-04-12 from 7d → 24h (P2-3). Combined with device-fingerprint
|
||||
# binding in the refresh JWT payload, this tightens the exploitation window
|
||||
# for stolen refresh tokens.
|
||||
JWT_REFRESH_EXPIRY=24h
|
||||
|
||||
# Gitea SSO cookie signing + service password salt — REQUIRED 2026-04-12 (P2-2).
|
||||
# Distinct from JWT secrets; empty values will now fail Zod validation on boot.
|
||||
GITEA_SSO_SECRET={{secrets.giteaSsoSecret}}
|
||||
SERVICE_PASSWORD_SALT={{secrets.servicePasswordSalt}}
|
||||
|
||||
# Encryption
|
||||
ENCRYPTION_KEY={{secrets.encryptionKey}}
|
||||
|
||||
Reference in New Issue
Block a user