@lamalibre/create-lamaste 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +118 -0
- package/README.md +114 -0
- package/bin/create-lamaste.js +15 -0
- package/package.json +54 -0
- package/scripts/bundle-vendor.js +110 -0
- package/scripts/lamaste-cert-info +62 -0
- package/scripts/lamaste-certbot +96 -0
- package/scripts/lamaste-priv +693 -0
- package/src/index.js +646 -0
- package/src/lib/accounts.js +86 -0
- package/src/lib/binaries.js +137 -0
- package/src/lib/cert-help-page.js +160 -0
- package/src/lib/env.js +132 -0
- package/src/lib/http-redirect.js +38 -0
- package/src/lib/ownership.js +231 -0
- package/src/lib/relay-services.js +103 -0
- package/src/lib/secrets.js +19 -0
- package/src/lib/service-config.js +311 -0
- package/src/lib/summary.js +131 -0
- package/src/lib/wrappers.js +63 -0
- package/src/tasks/gatekeeper.js +233 -0
- package/src/tasks/harden.js +293 -0
- package/src/tasks/mtls.js +193 -0
- package/src/tasks/nginx.js +265 -0
- package/src/tasks/node.js +110 -0
- package/src/tasks/panel.js +408 -0
- package/src/tasks/redeploy.js +438 -0
- package/vendor/docs/00-introduction/how-it-works.md +447 -0
- package/vendor/docs/00-introduction/quickstart.md +472 -0
- package/vendor/docs/00-introduction/what-is-lamaste.md +112 -0
- package/vendor/docs/01-concepts/authentication.md +464 -0
- package/vendor/docs/01-concepts/authorization.md +242 -0
- package/vendor/docs/01-concepts/certificates.md +452 -0
- package/vendor/docs/01-concepts/dns-and-domains.md +343 -0
- package/vendor/docs/01-concepts/mtls.md +490 -0
- package/vendor/docs/01-concepts/nginx-reverse-proxy.md +540 -0
- package/vendor/docs/01-concepts/security-model.md +600 -0
- package/vendor/docs/01-concepts/tickets.md +414 -0
- package/vendor/docs/01-concepts/tunneling.md +367 -0
- package/vendor/docs/02-guides/agent-setup.md +194 -0
- package/vendor/docs/02-guides/certificate-management.md +341 -0
- package/vendor/docs/02-guides/cloud-provisioning.md +406 -0
- package/vendor/docs/02-guides/desktop-app-setup.md +226 -0
- package/vendor/docs/02-guides/disaster-recovery.md +464 -0
- package/vendor/docs/02-guides/first-tunnel.md +230 -0
- package/vendor/docs/02-guides/installation.md +393 -0
- package/vendor/docs/02-guides/managing-users.md +255 -0
- package/vendor/docs/02-guides/onboarding.md +244 -0
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +375 -0
- package/vendor/docs/02-guides/static-sites.md +364 -0
- package/vendor/docs/03-architecture/e2e-three-vm-sequences.md +457 -0
- package/vendor/docs/03-architecture/installer-flow.md +151 -0
- package/vendor/docs/03-architecture/installer.md +445 -0
- package/vendor/docs/03-architecture/management-flow.md +288 -0
- package/vendor/docs/03-architecture/nginx-configuration.md +830 -0
- package/vendor/docs/03-architecture/onboarding-flow.md +164 -0
- package/vendor/docs/03-architecture/overview.md +541 -0
- package/vendor/docs/03-architecture/panel-client.md +416 -0
- package/vendor/docs/03-architecture/panel-server.md +695 -0
- package/vendor/docs/03-architecture/state-management.md +529 -0
- package/vendor/docs/03-architecture/system-overview.md +167 -0
- package/vendor/docs/04-api-reference/certificates.md +1141 -0
- package/vendor/docs/04-api-reference/gatekeeper.md +592 -0
- package/vendor/docs/04-api-reference/onboarding.md +473 -0
- package/vendor/docs/04-api-reference/overview.md +404 -0
- package/vendor/docs/04-api-reference/services.md +267 -0
- package/vendor/docs/04-api-reference/settings.md +343 -0
- package/vendor/docs/04-api-reference/sites.md +716 -0
- package/vendor/docs/04-api-reference/system.md +268 -0
- package/vendor/docs/04-api-reference/tickets.md +618 -0
- package/vendor/docs/04-api-reference/tunnels.md +417 -0
- package/vendor/docs/04-api-reference/users.md +333 -0
- package/vendor/docs/05-operations/backup-and-restore.md +342 -0
- package/vendor/docs/05-operations/monitoring.md +285 -0
- package/vendor/docs/05-operations/uninstalling.md +344 -0
- package/vendor/docs/05-operations/upgrades.md +270 -0
- package/vendor/docs/06-reference/config-files.md +1080 -0
- package/vendor/docs/06-reference/glossary.md +528 -0
- package/vendor/docs/06-reference/installer-flags.md +260 -0
- package/vendor/docs/06-reference/ports-and-services.md +244 -0
- package/vendor/docs/06-reference/troubleshooting.md +665 -0
- package/vendor/docs/_index.json +233 -0
- package/vendor/docs/gatekeeper-design.md +953 -0
- package/vendor/docs/package.json +15 -0
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +641 -0
- package/vendor/gatekeeper/dist/boot.d.ts +1 -0
- package/vendor/gatekeeper/dist/boot.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/boot.js +17 -0
- package/vendor/gatekeeper/dist/boot.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/access.d.ts +2 -0
- package/vendor/gatekeeper/dist/cli/commands/access.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/access.js +33 -0
- package/vendor/gatekeeper/dist/cli/commands/access.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.d.ts +2 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.js +110 -0
- package/vendor/gatekeeper/dist/cli/commands/grants.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.d.ts +2 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.js +98 -0
- package/vendor/gatekeeper/dist/cli/commands/groups.js.map +1 -0
- package/vendor/gatekeeper/dist/cli/index.d.ts +3 -0
- package/vendor/gatekeeper/dist/cli/index.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/cli/index.js +45 -0
- package/vendor/gatekeeper/dist/cli/index.js.map +1 -0
- package/vendor/gatekeeper/dist/index.d.ts +9 -0
- package/vendor/gatekeeper/dist/index.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/index.js +13 -0
- package/vendor/gatekeeper/dist/index.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts +16 -0
- package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/authz.js +42 -0
- package/vendor/gatekeeper/dist/lib/authz.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.d.ts +15 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.js +19 -0
- package/vendor/gatekeeper/dist/lib/cache-bust.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/constants.d.ts +82 -0
- package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/constants.js +79 -0
- package/vendor/gatekeeper/dist/lib/constants.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts +32 -0
- package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/grants.js +314 -0
- package/vendor/gatekeeper/dist/lib/grants.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts +10 -0
- package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/groups.js +247 -0
- package/vendor/gatekeeper/dist/lib/groups.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/migration.d.ts +17 -0
- package/vendor/gatekeeper/dist/lib/migration.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/migration.js +79 -0
- package/vendor/gatekeeper/dist/lib/migration.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0001_groups.sql +7 -0
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0002_access_grants.sql +14 -0
- package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0003_access_request_log.sql +9 -0
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +7 -0
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +14 -0
- package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +9 -0
- package/vendor/gatekeeper/dist/lib/state-db.d.ts +16 -0
- package/vendor/gatekeeper/dist/lib/state-db.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/state-db.js +154 -0
- package/vendor/gatekeeper/dist/lib/state-db.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts +21 -0
- package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/templates.js +151 -0
- package/vendor/gatekeeper/dist/lib/templates.js.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts +178 -0
- package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/lib/types.js +2 -0
- package/vendor/gatekeeper/dist/lib/types.js.map +1 -0
- package/vendor/gatekeeper/dist/server/index.d.ts +15 -0
- package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/index.js +251 -0
- package/vendor/gatekeeper/dist/server/index.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.d.ts +7 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.js +17 -0
- package/vendor/gatekeeper/dist/server/routes/access-request.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/authz.js +279 -0
- package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts +13 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js +249 -0
- package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/grants.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/grants.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/grants.js +112 -0
- package/vendor/gatekeeper/dist/server/routes/grants.js.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/groups.d.ts +3 -0
- package/vendor/gatekeeper/dist/server/routes/groups.d.ts.map +1 -0
- package/vendor/gatekeeper/dist/server/routes/groups.js +139 -0
- package/vendor/gatekeeper/dist/server/routes/groups.js.map +1 -0
- package/vendor/gatekeeper/package.json +49 -0
- package/vendor/server/bin/lamaste-server.js +204 -0
- package/vendor/server/package.json +31 -0
- package/vendor/server/src/commands/certs.js +224 -0
- package/vendor/server/src/commands/chisel.js +132 -0
- package/vendor/server/src/commands/logs.js +49 -0
- package/vendor/server/src/commands/plugins.js +321 -0
- package/vendor/server/src/commands/reset-admin.js +478 -0
- package/vendor/server/src/commands/restart.js +94 -0
- package/vendor/server/src/commands/sites.js +257 -0
- package/vendor/server/src/commands/status.js +158 -0
- package/vendor/server/src/commands/tunnels.js +365 -0
- package/vendor/server/src/commands/uninstall.js +326 -0
- package/vendor/server/src/config.js +38 -0
- package/vendor/server/src/exec.js +26 -0
- package/vendor/server/src/logger.js +39 -0
- package/vendor/server/src/ndjson.js +56 -0
- package/vendor/server/src/panel-api.js +85 -0
- package/vendor/server/src/state.js +45 -0
- package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +257 -0
- package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +1 -0
- package/vendor/server-ui/dist/index.html +13 -0
- package/vendor/serverd/package.json +44 -0
- package/vendor/serverd/src/boot.js +13 -0
- package/vendor/serverd/src/index.js +191 -0
- package/vendor/serverd/src/lib/app-error.js +14 -0
- package/vendor/serverd/src/lib/authelia.js +85 -0
- package/vendor/serverd/src/lib/certbot.js +74 -0
- package/vendor/serverd/src/lib/chisel-args.js +6 -0
- package/vendor/serverd/src/lib/chisel-reconcile.js +232 -0
- package/vendor/serverd/src/lib/chisel-runtime.js +46 -0
- package/vendor/serverd/src/lib/chisel-users.js +109 -0
- package/vendor/serverd/src/lib/chisel.js +81 -0
- package/vendor/serverd/src/lib/config.js +190 -0
- package/vendor/serverd/src/lib/constants.js +8 -0
- package/vendor/serverd/src/lib/csr-signing.js +331 -0
- package/vendor/serverd/src/lib/enrollment.js +302 -0
- package/vendor/serverd/src/lib/files.js +19 -0
- package/vendor/serverd/src/lib/gatekeeper-client.js +135 -0
- package/vendor/serverd/src/lib/invite-page.js +281 -0
- package/vendor/serverd/src/lib/ip-utils.js +79 -0
- package/vendor/serverd/src/lib/migrations/state/0001_enrollment_tokens.sql +17 -0
- package/vendor/serverd/src/lib/migrations/state/0002_tickets.sql +70 -0
- package/vendor/serverd/src/lib/migrations/state/0003_plugins.sql +15 -0
- package/vendor/serverd/src/lib/migrations/state/0004_agents.sql +19 -0
- package/vendor/serverd/src/lib/mtls.js +795 -0
- package/vendor/serverd/src/lib/nginx.js +1090 -0
- package/vendor/serverd/src/lib/pki-paths.js +41 -0
- package/vendor/serverd/src/lib/pki-sign.js +103 -0
- package/vendor/serverd/src/lib/plugins.js +538 -0
- package/vendor/serverd/src/lib/push-install.js +390 -0
- package/vendor/serverd/src/lib/revocation.js +112 -0
- package/vendor/serverd/src/lib/services.js +131 -0
- package/vendor/serverd/src/lib/session.js +151 -0
- package/vendor/serverd/src/lib/state-db.js +130 -0
- package/vendor/serverd/src/lib/state.js +140 -0
- package/vendor/serverd/src/lib/storage.js +63 -0
- package/vendor/serverd/src/lib/system-stats.js +58 -0
- package/vendor/serverd/src/lib/tickets.js +1556 -0
- package/vendor/serverd/src/lib/totp.js +76 -0
- package/vendor/serverd/src/lib/tunnel-deps.js +41 -0
- package/vendor/serverd/src/lib/user-access-session.js +142 -0
- package/vendor/serverd/src/lib/user-access.js +52 -0
- package/vendor/serverd/src/middleware/errors.js +63 -0
- package/vendor/serverd/src/middleware/mtls.js +120 -0
- package/vendor/serverd/src/middleware/onboarding-guard.js +30 -0
- package/vendor/serverd/src/middleware/role-guard.js +58 -0
- package/vendor/serverd/src/middleware/twofa-session.js +65 -0
- package/vendor/serverd/src/middleware/user-access-session.js +52 -0
- package/vendor/serverd/src/routes/enrollment.js +120 -0
- package/vendor/serverd/src/routes/health.js +22 -0
- package/vendor/serverd/src/routes/invite.js +163 -0
- package/vendor/serverd/src/routes/management/agents.js +182 -0
- package/vendor/serverd/src/routes/management/certs.js +911 -0
- package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +160 -0
- package/vendor/serverd/src/routes/management/identity.js +107 -0
- package/vendor/serverd/src/routes/management/invitations.js +166 -0
- package/vendor/serverd/src/routes/management/logs.js +218 -0
- package/vendor/serverd/src/routes/management/plugins.js +560 -0
- package/vendor/serverd/src/routes/management/services.js +63 -0
- package/vendor/serverd/src/routes/management/settings.js +325 -0
- package/vendor/serverd/src/routes/management/sites.js +522 -0
- package/vendor/serverd/src/routes/management/storage.js +192 -0
- package/vendor/serverd/src/routes/management/system.js +201 -0
- package/vendor/serverd/src/routes/management/tickets.js +457 -0
- package/vendor/serverd/src/routes/management/tunnels.js +709 -0
- package/vendor/serverd/src/routes/management/users.js +377 -0
- package/vendor/serverd/src/routes/management.js +36 -0
- package/vendor/serverd/src/routes/onboarding/dns.js +101 -0
- package/vendor/serverd/src/routes/onboarding/domain.js +42 -0
- package/vendor/serverd/src/routes/onboarding/index.js +18 -0
- package/vendor/serverd/src/routes/onboarding/provision.js +327 -0
- package/vendor/serverd/src/routes/onboarding/status.js +12 -0
- package/vendor/serverd/src/routes/plugin-router.js +79 -0
- package/vendor/serverd/src/routes/user-access.js +389 -0
|
@@ -0,0 +1,1556 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
setTicketScopeCapabilitiesOnMtls,
|
|
5
|
+
loadAgentRegistry,
|
|
6
|
+
getValidCapabilities,
|
|
7
|
+
} from './mtls.js';
|
|
8
|
+
import { getPluginCapabilities } from './plugins.js';
|
|
9
|
+
import { RESERVED_API_PREFIXES } from './constants.js';
|
|
10
|
+
import { PLUGIN_CAPABILITY_REGEX } from '@lamalibre/lamaste';
|
|
11
|
+
import { getStateDb } from './state-db.js';
|
|
12
|
+
|
|
13
|
+
// Module-scoped logger used as the default for `validateTicket` when no
|
|
14
|
+
// per-request logger is passed. Routes still hand in their own request-scoped
|
|
15
|
+
// logger for normal operations.
|
|
16
|
+
let moduleLogger = {
|
|
17
|
+
warn: (..._args) => {},
|
|
18
|
+
info: (..._args) => {},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function setTicketModuleLogger(logger) {
|
|
22
|
+
if (logger && typeof logger.warn === 'function') {
|
|
23
|
+
moduleLogger = logger;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Failed-validation surveillance (per-process, resets on restart) ---
|
|
28
|
+
//
|
|
29
|
+
// Brute-force of ticket tokens is mathematically infeasible (256 bits), but
|
|
30
|
+
// we still want operators to see active enumeration attempts. Two tiers:
|
|
31
|
+
//
|
|
32
|
+
// 1. Cumulative counter, logged every FAILURE_LOG_INTERVAL failures.
|
|
33
|
+
// 2. 5-minute rolling rate window — sustained > THRESHOLD/min for
|
|
34
|
+
// RATE_BURST_MINUTES consecutive minutes triggers a one-time warn until
|
|
35
|
+
// the rate drops back below threshold.
|
|
36
|
+
//
|
|
37
|
+
// Defense-in-depth only: the panel still returns a generic 401 to the caller.
|
|
38
|
+
const FAILURE_LOG_INTERVAL = 50;
|
|
39
|
+
const RATE_THRESHOLD_PER_MIN = 10;
|
|
40
|
+
const RATE_BURST_MINUTES = 5;
|
|
41
|
+
const RATE_WINDOW_MS = RATE_BURST_MINUTES * 60 * 1000;
|
|
42
|
+
let ticketValidationFailureCount = 0;
|
|
43
|
+
const ticketValidationFailureTimestamps = [];
|
|
44
|
+
let ticketValidationBurstActive = false;
|
|
45
|
+
|
|
46
|
+
function recordTicketValidationFailure(logger, reason) {
|
|
47
|
+
ticketValidationFailureCount += 1;
|
|
48
|
+
|
|
49
|
+
if (ticketValidationFailureCount % FAILURE_LOG_INTERVAL === 0) {
|
|
50
|
+
logger.warn(
|
|
51
|
+
{ count: ticketValidationFailureCount, reason },
|
|
52
|
+
'ticket validation repeated failures — possible enumeration attack',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const cutoff = now - RATE_WINDOW_MS;
|
|
58
|
+
ticketValidationFailureTimestamps.push(now);
|
|
59
|
+
while (
|
|
60
|
+
ticketValidationFailureTimestamps.length > 0 &&
|
|
61
|
+
ticketValidationFailureTimestamps[0] < cutoff
|
|
62
|
+
) {
|
|
63
|
+
ticketValidationFailureTimestamps.shift();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Sustained-burst escalation. Only fires once per "above threshold" episode
|
|
67
|
+
// — clears when the window drops back below threshold so a renewed burst
|
|
68
|
+
// can re-escalate without log spam in between.
|
|
69
|
+
const ratePerMinute = ticketValidationFailureTimestamps.length / RATE_BURST_MINUTES;
|
|
70
|
+
if (ratePerMinute >= RATE_THRESHOLD_PER_MIN) {
|
|
71
|
+
if (!ticketValidationBurstActive) {
|
|
72
|
+
ticketValidationBurstActive = true;
|
|
73
|
+
logger.warn(
|
|
74
|
+
{
|
|
75
|
+
rate: Number(ratePerMinute.toFixed(2)),
|
|
76
|
+
threshold: RATE_THRESHOLD_PER_MIN,
|
|
77
|
+
windowMinutes: RATE_BURST_MINUTES,
|
|
78
|
+
note: 'possible ticket enumeration attack',
|
|
79
|
+
},
|
|
80
|
+
'ticket validation failure rate sustained above threshold',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
} else if (ticketValidationBurstActive) {
|
|
84
|
+
ticketValidationBurstActive = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- Ticket expiry constants ---
|
|
89
|
+
const TICKET_EXPIRY_MS = 30 * 1000; // 30 seconds
|
|
90
|
+
const TICKET_CLEANUP_MS = 60 * 60 * 1000; // 1 hour
|
|
91
|
+
const INSTANCE_STALE_MS = 5 * 60 * 1000; // 5 minutes → stale
|
|
92
|
+
const INSTANCE_DEAD_MS = 60 * 60 * 1000; // 1 hour → dead
|
|
93
|
+
const SESSION_STALE_MS = 10 * 60 * 1000; // 10 minutes without activity → dead
|
|
94
|
+
const SESSION_CLEANUP_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
95
|
+
|
|
96
|
+
// --- Hard caps (DoS protection on 512MB droplets) ---
|
|
97
|
+
const MAX_INSTANCES = 200;
|
|
98
|
+
const MAX_TICKETS = 1000;
|
|
99
|
+
const MAX_SESSIONS = 500;
|
|
100
|
+
|
|
101
|
+
// --- Rate limiting ---
|
|
102
|
+
const TICKET_RATE_LIMIT = 10; // per agent per minute
|
|
103
|
+
const TICKET_RATE_WINDOW_MS = 60 * 1000;
|
|
104
|
+
const MAX_RATE_ENTRIES = 1000;
|
|
105
|
+
const ticketRateCounts = new Map();
|
|
106
|
+
|
|
107
|
+
// --- Promise-chain mutex ---
|
|
108
|
+
//
|
|
109
|
+
// SQLite's BEGIN IMMEDIATE serialises a single write transaction, but many
|
|
110
|
+
// exported operations are read-modify-write sequences spanning multiple
|
|
111
|
+
// statements. This lock keeps each such sequence atomic. Tracking as a
|
|
112
|
+
// follow-up: collapse each sequence into a single transaction, then drop
|
|
113
|
+
// the mutex.
|
|
114
|
+
let ticketLock = Promise.resolve();
|
|
115
|
+
function withTicketLock(fn) {
|
|
116
|
+
const prev = ticketLock;
|
|
117
|
+
let resolve;
|
|
118
|
+
ticketLock = new Promise((r) => {
|
|
119
|
+
resolve = r;
|
|
120
|
+
});
|
|
121
|
+
return prev.then(fn).finally(resolve);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Zod schemas ---
|
|
125
|
+
|
|
126
|
+
const RESERVED_NAMES = RESERVED_API_PREFIXES;
|
|
127
|
+
|
|
128
|
+
export const TicketScopeNameSchema = z
|
|
129
|
+
.string()
|
|
130
|
+
.min(1)
|
|
131
|
+
.max(50)
|
|
132
|
+
.regex(/^[a-z0-9-]+$/, 'Must be lowercase alphanumeric with hyphens')
|
|
133
|
+
.refine((v) => !RESERVED_NAMES.includes(v), 'Name is reserved');
|
|
134
|
+
|
|
135
|
+
// Ticket scope sub-scope names share the plugin capability namespace
|
|
136
|
+
// (`plugin:<short-name>:<action>`) — this is the only top-level namespace
|
|
137
|
+
// any non-core code can declare. Forces sub-scopes to coexist cleanly with
|
|
138
|
+
// plugin-declared capabilities and makes "plugin owns its own scopes"
|
|
139
|
+
// the only mental model needed at call sites.
|
|
140
|
+
const CapabilityStringSchema = z
|
|
141
|
+
.string()
|
|
142
|
+
.min(1)
|
|
143
|
+
.max(100)
|
|
144
|
+
.regex(
|
|
145
|
+
PLUGIN_CAPABILITY_REGEX,
|
|
146
|
+
'Must use the "plugin:<short-name>:<action>" namespace (lowercase alphanumeric with optional internal hyphens)',
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const ScopeDeclarationSchema = z.object({
|
|
150
|
+
name: CapabilityStringSchema,
|
|
151
|
+
description: z.string().min(1).max(500),
|
|
152
|
+
instanceScoped: z.boolean(),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const TransportStrategySchema = z.enum(['tunnel', 'relay', 'direct']);
|
|
156
|
+
|
|
157
|
+
const TransportSchema = z
|
|
158
|
+
.object({
|
|
159
|
+
strategies: z.array(TransportStrategySchema).min(1),
|
|
160
|
+
preferred: TransportStrategySchema,
|
|
161
|
+
port: z
|
|
162
|
+
.number()
|
|
163
|
+
.int()
|
|
164
|
+
.refine((v) => v === 0 || (v >= 1024 && v <= 65535), 'Port must be 0 or 1024-65535'),
|
|
165
|
+
protocol: z.enum(['wss', 'tcp']),
|
|
166
|
+
})
|
|
167
|
+
.refine(
|
|
168
|
+
(t) => t.strategies.includes(t.preferred),
|
|
169
|
+
'Preferred strategy must be in strategies array',
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
export const RegisterScopeSchema = z.object({
|
|
173
|
+
name: TicketScopeNameSchema,
|
|
174
|
+
version: z.string().min(1).max(50),
|
|
175
|
+
description: z.string().min(1).max(500),
|
|
176
|
+
scopes: z.array(ScopeDeclarationSchema).min(1).max(50),
|
|
177
|
+
transport: TransportSchema,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Hostname/IP validation: reject private, loopback, link-local, and metadata IPs
|
|
181
|
+
const HostnameSchema = z
|
|
182
|
+
.string()
|
|
183
|
+
.min(1)
|
|
184
|
+
.max(255)
|
|
185
|
+
.refine(
|
|
186
|
+
(host) => {
|
|
187
|
+
// Block metadata endpoint (AWS/GCP/Azure)
|
|
188
|
+
if (host === '169.254.169.254' || host === 'metadata.google.internal') return false;
|
|
189
|
+
// Block loopback
|
|
190
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return false;
|
|
191
|
+
// Block IPv4 private ranges and link-local
|
|
192
|
+
const ipv4Match = host.match(/^(\d{1,3})\.(\d{1,3})\.\d{1,3}\.\d{1,3}$/);
|
|
193
|
+
if (ipv4Match) {
|
|
194
|
+
const [, a, b] = ipv4Match.map(Number);
|
|
195
|
+
if (a === 10) return false; // 10.0.0.0/8
|
|
196
|
+
if (a === 172 && b >= 16 && b <= 31) return false; // 172.16.0.0/12
|
|
197
|
+
if (a === 192 && b === 168) return false; // 192.168.0.0/16
|
|
198
|
+
if (a === 169 && b === 254) return false; // 169.254.0.0/16 link-local
|
|
199
|
+
if (a === 0) return false; // 0.0.0.0/8
|
|
200
|
+
}
|
|
201
|
+
return true;
|
|
202
|
+
},
|
|
203
|
+
{ message: 'Host must be a public hostname or IP address' },
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
export const RegisterInstanceSchema = z.object({
|
|
207
|
+
scope: CapabilityStringSchema,
|
|
208
|
+
transport: z.object({
|
|
209
|
+
strategies: z.array(TransportStrategySchema).min(1),
|
|
210
|
+
preferred: TransportStrategySchema.optional(),
|
|
211
|
+
direct: z
|
|
212
|
+
.object({
|
|
213
|
+
host: HostnameSchema,
|
|
214
|
+
port: z.number().int().min(1024).max(65535),
|
|
215
|
+
})
|
|
216
|
+
.optional(),
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
export const RequestTicketSchema = z.object({
|
|
221
|
+
scope: CapabilityStringSchema,
|
|
222
|
+
instanceId: z
|
|
223
|
+
.string()
|
|
224
|
+
.min(1)
|
|
225
|
+
.max(64)
|
|
226
|
+
.regex(/^[a-f0-9]+$/),
|
|
227
|
+
// max 150 to accommodate plugin-agent:<delegating>:<plugin> labels
|
|
228
|
+
target: z.string().min(1).max(150),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
export const ValidateTicketSchema = z.object({
|
|
232
|
+
ticketId: z
|
|
233
|
+
.string()
|
|
234
|
+
.min(1)
|
|
235
|
+
.max(128)
|
|
236
|
+
.regex(/^[a-f0-9]+$/),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
export const CreateSessionSchema = z.object({
|
|
240
|
+
ticketId: z
|
|
241
|
+
.string()
|
|
242
|
+
.min(1)
|
|
243
|
+
.max(128)
|
|
244
|
+
.regex(/^[a-f0-9]+$/),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
export const UpdateSessionSchema = z.object({
|
|
248
|
+
status: z.enum(['active', 'grace']),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
export const AssignmentSchema = z.object({
|
|
252
|
+
agentLabel: z.string().min(1).max(100),
|
|
253
|
+
instanceScope: z
|
|
254
|
+
.string()
|
|
255
|
+
.min(1)
|
|
256
|
+
.max(200)
|
|
257
|
+
.regex(/^plugin:[a-z0-9-]+:[a-z0-9-]+:[a-f0-9]+$/),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// --- SQLite prepared-statement bundle (lazy init) ---
|
|
261
|
+
|
|
262
|
+
let stmts = null;
|
|
263
|
+
|
|
264
|
+
async function getStmts() {
|
|
265
|
+
if (stmts) return stmts;
|
|
266
|
+
|
|
267
|
+
const db = await getStateDb();
|
|
268
|
+
|
|
269
|
+
stmts = {
|
|
270
|
+
db,
|
|
271
|
+
|
|
272
|
+
// Scope statements
|
|
273
|
+
selectAllScopes: db.prepare('SELECT * FROM ticket_scopes'),
|
|
274
|
+
selectScopeByName: db.prepare('SELECT * FROM ticket_scopes WHERE name = ?'),
|
|
275
|
+
insertScope: db.prepare(`
|
|
276
|
+
INSERT INTO ticket_scopes
|
|
277
|
+
(name, version, description, scopes, transport, hooks, installed_at)
|
|
278
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
279
|
+
`),
|
|
280
|
+
deleteScope: db.prepare('DELETE FROM ticket_scopes WHERE name = ?'),
|
|
281
|
+
|
|
282
|
+
// Instance statements
|
|
283
|
+
selectAllInstances: db.prepare('SELECT * FROM ticket_instances'),
|
|
284
|
+
selectInstanceById: db.prepare('SELECT * FROM ticket_instances WHERE instance_id = ?'),
|
|
285
|
+
selectInstanceByScopeAgent: db.prepare(
|
|
286
|
+
'SELECT * FROM ticket_instances WHERE scope = ? AND agent_label = ?',
|
|
287
|
+
),
|
|
288
|
+
countInstances: db.prepare('SELECT COUNT(*) AS n FROM ticket_instances'),
|
|
289
|
+
insertInstance: db.prepare(`
|
|
290
|
+
INSERT INTO ticket_instances
|
|
291
|
+
(instance_id, scope, agent_label, registered_at, last_heartbeat, status, transport)
|
|
292
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
293
|
+
`),
|
|
294
|
+
updateInstanceTransportHeartbeat: db.prepare(`
|
|
295
|
+
UPDATE ticket_instances
|
|
296
|
+
SET transport = ?, last_heartbeat = ?, status = 'active'
|
|
297
|
+
WHERE instance_id = ?
|
|
298
|
+
`),
|
|
299
|
+
updateInstanceHeartbeat: db.prepare(`
|
|
300
|
+
UPDATE ticket_instances
|
|
301
|
+
SET last_heartbeat = ?, status = 'active'
|
|
302
|
+
WHERE instance_id = ?
|
|
303
|
+
`),
|
|
304
|
+
updateInstanceStatus: db.prepare(`
|
|
305
|
+
UPDATE ticket_instances SET status = ? WHERE instance_id = ?
|
|
306
|
+
`),
|
|
307
|
+
deleteInstance: db.prepare('DELETE FROM ticket_instances WHERE instance_id = ?'),
|
|
308
|
+
deleteInstanceByStatus: db.prepare('DELETE FROM ticket_instances WHERE status = ?'),
|
|
309
|
+
|
|
310
|
+
// Assignment statements
|
|
311
|
+
selectAllAssignments: db.prepare('SELECT * FROM ticket_assignments'),
|
|
312
|
+
selectAssignment: db.prepare(
|
|
313
|
+
'SELECT * FROM ticket_assignments WHERE agent_label = ? AND instance_scope = ?',
|
|
314
|
+
),
|
|
315
|
+
insertAssignment: db.prepare(`
|
|
316
|
+
INSERT INTO ticket_assignments
|
|
317
|
+
(agent_label, instance_scope, assigned_at, assigned_by)
|
|
318
|
+
VALUES (?, ?, ?, ?)
|
|
319
|
+
`),
|
|
320
|
+
deleteAssignment: db.prepare(
|
|
321
|
+
'DELETE FROM ticket_assignments WHERE agent_label = ? AND instance_scope = ?',
|
|
322
|
+
),
|
|
323
|
+
deleteAssignmentsByInstanceScope: db.prepare(
|
|
324
|
+
'DELETE FROM ticket_assignments WHERE instance_scope = ?',
|
|
325
|
+
),
|
|
326
|
+
deleteAssignmentsByInstanceScopePrefix: db.prepare(
|
|
327
|
+
'DELETE FROM ticket_assignments WHERE instance_scope LIKE ?',
|
|
328
|
+
),
|
|
329
|
+
|
|
330
|
+
// Ticket statements
|
|
331
|
+
selectAllTickets: db.prepare('SELECT * FROM tickets'),
|
|
332
|
+
selectTicketByIndex: db.prepare('SELECT * FROM tickets WHERE index_hash = ?'),
|
|
333
|
+
selectTicketsByInstance: db.prepare('SELECT * FROM tickets WHERE instance_id = ?'),
|
|
334
|
+
selectTicketsByScope: db.prepare('SELECT * FROM tickets WHERE scope = ?'),
|
|
335
|
+
countTickets: db.prepare('SELECT COUNT(*) AS n FROM tickets'),
|
|
336
|
+
insertTicket: db.prepare(`
|
|
337
|
+
INSERT INTO tickets
|
|
338
|
+
(index_hash, id, scope, instance_id, source, target, created_at, expires_at, used, used_at, session_id, transport)
|
|
339
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
340
|
+
`),
|
|
341
|
+
markTicketUsed: db.prepare('UPDATE tickets SET used = 1, used_at = ? WHERE index_hash = ?'),
|
|
342
|
+
setTicketSession: db.prepare('UPDATE tickets SET session_id = ? WHERE index_hash = ?'),
|
|
343
|
+
deleteTicketsCreatedBefore: db.prepare('DELETE FROM tickets WHERE created_at <= ?'),
|
|
344
|
+
|
|
345
|
+
// Session statements
|
|
346
|
+
selectAllSessions: db.prepare('SELECT * FROM ticket_sessions'),
|
|
347
|
+
selectSessionById: db.prepare('SELECT * FROM ticket_sessions WHERE session_id = ?'),
|
|
348
|
+
selectSessionsByInstance: db.prepare('SELECT * FROM ticket_sessions WHERE instance_id = ?'),
|
|
349
|
+
countLiveSessions: db.prepare(
|
|
350
|
+
"SELECT COUNT(*) AS n FROM ticket_sessions WHERE status != 'dead'",
|
|
351
|
+
),
|
|
352
|
+
insertSession: db.prepare(`
|
|
353
|
+
INSERT INTO ticket_sessions
|
|
354
|
+
(session_id, ticket_id, scope, instance_id, source, target, created_at, last_activity_at, status, reconnect_grace_seconds, terminated_by, terminated_at)
|
|
355
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
356
|
+
`),
|
|
357
|
+
updateSessionActivity: db.prepare(
|
|
358
|
+
'UPDATE ticket_sessions SET last_activity_at = ? WHERE session_id = ?',
|
|
359
|
+
),
|
|
360
|
+
updateSessionStatusActivity: db.prepare(
|
|
361
|
+
'UPDATE ticket_sessions SET status = ?, last_activity_at = ? WHERE session_id = ?',
|
|
362
|
+
),
|
|
363
|
+
killSessionStmt: db.prepare(`
|
|
364
|
+
UPDATE ticket_sessions
|
|
365
|
+
SET status = 'dead', terminated_by = ?, terminated_at = ?
|
|
366
|
+
WHERE session_id = ?
|
|
367
|
+
`),
|
|
368
|
+
deleteDeadOldSessions: db.prepare(
|
|
369
|
+
"DELETE FROM ticket_sessions WHERE status = 'dead' AND created_at <= ?",
|
|
370
|
+
),
|
|
371
|
+
|
|
372
|
+
// Transaction control
|
|
373
|
+
begin: db.prepare('BEGIN IMMEDIATE'),
|
|
374
|
+
commit: db.prepare('COMMIT'),
|
|
375
|
+
rollback: db.prepare('ROLLBACK'),
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
return stmts;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// --- Row → JS object helpers (one per table) ---
|
|
382
|
+
|
|
383
|
+
function rowToScope(row) {
|
|
384
|
+
return {
|
|
385
|
+
name: row.name,
|
|
386
|
+
version: row.version,
|
|
387
|
+
description: row.description,
|
|
388
|
+
scopes: JSON.parse(row.scopes),
|
|
389
|
+
transport: JSON.parse(row.transport),
|
|
390
|
+
hooks: JSON.parse(row.hooks),
|
|
391
|
+
installedAt: row.installed_at,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function rowToInstance(row) {
|
|
396
|
+
return {
|
|
397
|
+
instanceId: row.instance_id,
|
|
398
|
+
scope: row.scope,
|
|
399
|
+
agentLabel: row.agent_label,
|
|
400
|
+
registeredAt: row.registered_at,
|
|
401
|
+
lastHeartbeat: row.last_heartbeat,
|
|
402
|
+
status: row.status,
|
|
403
|
+
transport: JSON.parse(row.transport),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function rowToAssignment(row) {
|
|
408
|
+
return {
|
|
409
|
+
agentLabel: row.agent_label,
|
|
410
|
+
instanceScope: row.instance_scope,
|
|
411
|
+
assignedAt: row.assigned_at,
|
|
412
|
+
assignedBy: row.assigned_by,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function rowToTicket(row) {
|
|
417
|
+
return {
|
|
418
|
+
id: row.id,
|
|
419
|
+
scope: row.scope,
|
|
420
|
+
instanceId: row.instance_id,
|
|
421
|
+
source: row.source,
|
|
422
|
+
target: row.target,
|
|
423
|
+
createdAt: row.created_at,
|
|
424
|
+
expiresAt: row.expires_at,
|
|
425
|
+
used: row.used === 1,
|
|
426
|
+
usedAt: row.used_at ?? null,
|
|
427
|
+
sessionId: row.session_id ?? null,
|
|
428
|
+
transport: JSON.parse(row.transport),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function rowToSession(row) {
|
|
433
|
+
const session = {
|
|
434
|
+
sessionId: row.session_id,
|
|
435
|
+
ticketId: row.ticket_id,
|
|
436
|
+
scope: row.scope,
|
|
437
|
+
instanceId: row.instance_id,
|
|
438
|
+
source: row.source,
|
|
439
|
+
target: row.target,
|
|
440
|
+
createdAt: row.created_at,
|
|
441
|
+
lastActivityAt: row.last_activity_at,
|
|
442
|
+
status: row.status,
|
|
443
|
+
reconnectGraceSeconds: row.reconnect_grace_seconds,
|
|
444
|
+
};
|
|
445
|
+
if (row.terminated_by) session.terminatedBy = row.terminated_by;
|
|
446
|
+
if (row.terminated_at) session.terminatedAt = row.terminated_at;
|
|
447
|
+
return session;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// --- Ticket index (hash of full token) ---
|
|
451
|
+
//
|
|
452
|
+
// Tickets are stored keyed by `sha256(ticketId)` (base64url) so validate /
|
|
453
|
+
// revoke / createSession lookups are O(1) and constant-cost regardless of
|
|
454
|
+
// how many tickets are live. The hash is purely an index — it is recomputed
|
|
455
|
+
// from the inbound token, so the persisted form leaks no usable secret.
|
|
456
|
+
function ticketIndex(ticketId) {
|
|
457
|
+
return crypto
|
|
458
|
+
.createHash('sha256')
|
|
459
|
+
.update(ticketId, 'utf-8')
|
|
460
|
+
.digest('base64')
|
|
461
|
+
.replace(/\+/g, '-')
|
|
462
|
+
.replace(/\//g, '_')
|
|
463
|
+
.replace(/=+$/, '');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function constantTimeStringEqual(a, b) {
|
|
467
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
468
|
+
if (a.length !== b.length) return false;
|
|
469
|
+
return crypto.timingSafeEqual(Buffer.from(a, 'utf-8'), Buffer.from(b, 'utf-8'));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// --- Cleanup helpers ---
|
|
473
|
+
//
|
|
474
|
+
// Delete expired tickets, mark stale sessions as dead. Writes happen
|
|
475
|
+
// in-place; the helper returns void.
|
|
476
|
+
function cleanExpiredTickets() {
|
|
477
|
+
const cutoff = new Date(Date.now() - TICKET_CLEANUP_MS).toISOString();
|
|
478
|
+
stmts.deleteTicketsCreatedBefore.run(cutoff);
|
|
479
|
+
|
|
480
|
+
// Mark stale (no heartbeat for SESSION_STALE_MS) sessions as dead.
|
|
481
|
+
const now = Date.now();
|
|
482
|
+
const staleCutoff = new Date(now - SESSION_STALE_MS).toISOString();
|
|
483
|
+
const allSessions = stmts.selectAllSessions.all();
|
|
484
|
+
for (const row of allSessions) {
|
|
485
|
+
if (row.status !== 'dead' && row.last_activity_at < staleCutoff) {
|
|
486
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), row.session_id);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const sessionCutoff = new Date(now - SESSION_CLEANUP_MS).toISOString();
|
|
491
|
+
stmts.deleteDeadOldSessions.run(sessionCutoff);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// --- Rate limiting ---
|
|
495
|
+
|
|
496
|
+
function checkRateLimit(agentLabel) {
|
|
497
|
+
const now = Date.now();
|
|
498
|
+
const key = agentLabel;
|
|
499
|
+
let entry = ticketRateCounts.get(key);
|
|
500
|
+
|
|
501
|
+
if (!entry || now - entry.windowStart > TICKET_RATE_WINDOW_MS) {
|
|
502
|
+
// Evict oldest entries if map is too large
|
|
503
|
+
if (ticketRateCounts.size >= MAX_RATE_ENTRIES && !ticketRateCounts.has(key)) {
|
|
504
|
+
const firstKey = ticketRateCounts.keys().next().value;
|
|
505
|
+
ticketRateCounts.delete(firstKey);
|
|
506
|
+
}
|
|
507
|
+
entry = { windowStart: now, count: 0 };
|
|
508
|
+
ticketRateCounts.set(key, entry);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
entry.count++;
|
|
512
|
+
if (entry.count > TICKET_RATE_LIMIT) {
|
|
513
|
+
throw Object.assign(new Error('Rate limit exceeded'), { statusCode: 429 });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Periodic cleanup of stale rate limit entries
|
|
518
|
+
const rateLimitInterval = setInterval(() => {
|
|
519
|
+
const cutoff = Date.now() - TICKET_RATE_WINDOW_MS * 2;
|
|
520
|
+
for (const [key, entry] of ticketRateCounts) {
|
|
521
|
+
if (entry.windowStart < cutoff) ticketRateCounts.delete(key);
|
|
522
|
+
}
|
|
523
|
+
}, TICKET_RATE_WINDOW_MS * 2);
|
|
524
|
+
rateLimitInterval.unref();
|
|
525
|
+
|
|
526
|
+
export function clearRateLimitInterval() {
|
|
527
|
+
clearInterval(rateLimitInterval);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// --- Ticket scope capabilities ---
|
|
531
|
+
|
|
532
|
+
async function refreshTicketScopeCapabilities() {
|
|
533
|
+
const scopeRows = stmts.selectAllScopes.all();
|
|
534
|
+
const caps = [];
|
|
535
|
+
for (const row of scopeRows) {
|
|
536
|
+
const scope = rowToScope(row);
|
|
537
|
+
for (const s of scope.scopes) {
|
|
538
|
+
caps.push(s.name);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
setTicketScopeCapabilitiesOnMtls([...new Set(caps)]);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export async function loadTicketScopeCapabilities() {
|
|
545
|
+
await getStmts();
|
|
546
|
+
await refreshTicketScopeCapabilities();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Check whether a given capability name is a registered ticket scope capability.
|
|
551
|
+
*
|
|
552
|
+
* Iterates the scope registry and checks if any registered scope has a
|
|
553
|
+
* sub-scope whose name matches the provided capability.
|
|
554
|
+
*
|
|
555
|
+
* @param {string} capability - Capability string to check (e.g., "plugin:sync:connect")
|
|
556
|
+
* @returns {Promise<boolean>}
|
|
557
|
+
*/
|
|
558
|
+
export function isRegisteredTicketScope(capability) {
|
|
559
|
+
return withTicketLock(async () => {
|
|
560
|
+
await getStmts();
|
|
561
|
+
const scopeRows = stmts.selectAllScopes.all();
|
|
562
|
+
for (const row of scopeRows) {
|
|
563
|
+
const scope = rowToScope(row);
|
|
564
|
+
for (const s of scope.scopes) {
|
|
565
|
+
if (s.name === capability) {
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return false;
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* List every sub-scope name currently held by the ticket scope registry.
|
|
576
|
+
*
|
|
577
|
+
* Used by the plugin install path to detect a collision between an incoming
|
|
578
|
+
* plugin's declared capability and an already-registered ticket scope. The
|
|
579
|
+
* namespacing rule guarantees both sides live under `plugin:*`, so a flat
|
|
580
|
+
* set intersection is sufficient.
|
|
581
|
+
*
|
|
582
|
+
* @returns {Promise<string[]>}
|
|
583
|
+
*/
|
|
584
|
+
export function listRegisteredSubScopeNames() {
|
|
585
|
+
return withTicketLock(async () => {
|
|
586
|
+
await getStmts();
|
|
587
|
+
const scopeRows = stmts.selectAllScopes.all();
|
|
588
|
+
const names = [];
|
|
589
|
+
for (const row of scopeRows) {
|
|
590
|
+
const scope = rowToScope(row);
|
|
591
|
+
for (const s of scope.scopes) {
|
|
592
|
+
names.push(s.name);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return names;
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// --- Instance ownership check ---
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Check whether an agent owns at least one active instance for the given scope.
|
|
603
|
+
*
|
|
604
|
+
* Used by delegated enrollment to verify the delegating agent has a live
|
|
605
|
+
* ticket instance for the scope it is delegating.
|
|
606
|
+
*
|
|
607
|
+
* @param {string} agentLabel - Agent label to check
|
|
608
|
+
* @param {string} scope - Ticket scope (e.g., "sync:connect")
|
|
609
|
+
* @returns {Promise<boolean>}
|
|
610
|
+
*/
|
|
611
|
+
export function agentOwnsInstanceForScope(agentLabel, scope) {
|
|
612
|
+
return withTicketLock(async () => {
|
|
613
|
+
await getStmts();
|
|
614
|
+
const rows = stmts.selectInstanceByScopeAgent.all(scope, agentLabel);
|
|
615
|
+
return rows.some((r) => r.status !== 'dead');
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// --- Scope management ---
|
|
620
|
+
|
|
621
|
+
export function registerScope(body, logger) {
|
|
622
|
+
return withTicketLock(async () => {
|
|
623
|
+
await getStmts();
|
|
624
|
+
|
|
625
|
+
const existing = stmts.selectScopeByName.get(body.name);
|
|
626
|
+
if (existing) {
|
|
627
|
+
throw Object.assign(new Error(`Ticket scope "${body.name}" is already registered`), {
|
|
628
|
+
statusCode: 409,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Sub-scope name uniqueness across the registry: even with namespacing,
|
|
633
|
+
// two unrelated scope manifests could declare the same `plugin:foo:bar`
|
|
634
|
+
// sub-scope and the union in `getValidCapabilities()` would prevent
|
|
635
|
+
// either owner from cleanly revoking it. Reject the second registration
|
|
636
|
+
// deterministically.
|
|
637
|
+
const subScopeNames = body.scopes.map((s) => s.name);
|
|
638
|
+
const duplicateInBody = subScopeNames.find((n, i) => subScopeNames.indexOf(n) !== i);
|
|
639
|
+
if (duplicateInBody) {
|
|
640
|
+
throw Object.assign(
|
|
641
|
+
new Error(`Ticket scope manifest declares sub-scope "${duplicateInBody}" twice`),
|
|
642
|
+
{ statusCode: 400 },
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const allScopeRows = stmts.selectAllScopes.all();
|
|
647
|
+
let collisionWithExistingScope = null;
|
|
648
|
+
let offender = null;
|
|
649
|
+
for (const row of allScopeRows) {
|
|
650
|
+
const scope = rowToScope(row);
|
|
651
|
+
const collide = scope.scopes.find((sub) => subScopeNames.includes(sub.name));
|
|
652
|
+
if (collide) {
|
|
653
|
+
collisionWithExistingScope = scope;
|
|
654
|
+
offender = collide;
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
if (collisionWithExistingScope) {
|
|
659
|
+
throw Object.assign(
|
|
660
|
+
new Error(
|
|
661
|
+
`Sub-scope "${offender?.name}" is already registered by ticket scope ` +
|
|
662
|
+
`"${collisionWithExistingScope.name}"`,
|
|
663
|
+
),
|
|
664
|
+
{ statusCode: 409 },
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Cross-namespace collision: a sub-scope name MUST NOT match any
|
|
669
|
+
// capability already contributed by an installed (enabled) plugin. The
|
|
670
|
+
// namespacing rule enforces both sides live in `plugin:*` so the check
|
|
671
|
+
// is a flat set intersection.
|
|
672
|
+
const pluginCaps = new Set(await getPluginCapabilities());
|
|
673
|
+
const collidingPluginCap = subScopeNames.find((n) => pluginCaps.has(n));
|
|
674
|
+
if (collidingPluginCap) {
|
|
675
|
+
throw Object.assign(
|
|
676
|
+
new Error(
|
|
677
|
+
`Sub-scope "${collidingPluginCap}" is already declared by an enabled plugin — ` +
|
|
678
|
+
'pick a different action name or revoke the plugin first',
|
|
679
|
+
),
|
|
680
|
+
{ statusCode: 409 },
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const installedAt = new Date().toISOString();
|
|
685
|
+
|
|
686
|
+
stmts.begin.run();
|
|
687
|
+
try {
|
|
688
|
+
stmts.insertScope.run(
|
|
689
|
+
body.name,
|
|
690
|
+
body.version,
|
|
691
|
+
body.description,
|
|
692
|
+
JSON.stringify(body.scopes),
|
|
693
|
+
JSON.stringify(body.transport),
|
|
694
|
+
JSON.stringify({}),
|
|
695
|
+
installedAt,
|
|
696
|
+
);
|
|
697
|
+
stmts.commit.run();
|
|
698
|
+
} catch (err) {
|
|
699
|
+
stmts.rollback.run();
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
await refreshTicketScopeCapabilities();
|
|
704
|
+
|
|
705
|
+
logger.info({ name: body.name, registered: subScopeNames }, 'Ticket scope registered');
|
|
706
|
+
return { ok: true, registered: subScopeNames };
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function listScopes() {
|
|
711
|
+
return withTicketLock(async () => {
|
|
712
|
+
await getStmts();
|
|
713
|
+
const scopeRows = stmts.selectAllScopes.all();
|
|
714
|
+
const instanceRows = stmts.selectAllInstances.all();
|
|
715
|
+
const assignmentRows = stmts.selectAllAssignments.all();
|
|
716
|
+
return {
|
|
717
|
+
scopes: scopeRows.map(rowToScope),
|
|
718
|
+
instances: instanceRows.map(rowToInstance),
|
|
719
|
+
assignments: assignmentRows.map(rowToAssignment),
|
|
720
|
+
};
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export function unregisterScope(name, logger) {
|
|
725
|
+
return withTicketLock(async () => {
|
|
726
|
+
await getStmts();
|
|
727
|
+
const scopeRow = stmts.selectScopeByName.get(name);
|
|
728
|
+
if (!scopeRow) {
|
|
729
|
+
throw Object.assign(new Error(`Ticket scope "${name}" not found`), { statusCode: 404 });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const scope = rowToScope(scopeRow);
|
|
733
|
+
const scopeNames = scope.scopes.map((s) => s.name);
|
|
734
|
+
|
|
735
|
+
stmts.begin.run();
|
|
736
|
+
try {
|
|
737
|
+
// Remove instances for any sub-scope of this manifest (cascade).
|
|
738
|
+
const allInstances = stmts.selectAllInstances.all();
|
|
739
|
+
for (const inst of allInstances) {
|
|
740
|
+
if (scopeNames.includes(inst.scope)) {
|
|
741
|
+
stmts.deleteInstance.run(inst.instance_id);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Remove assignments whose instanceScope starts with `${subScope}:`.
|
|
746
|
+
for (const sn of scopeNames) {
|
|
747
|
+
stmts.deleteAssignmentsByInstanceScopePrefix.run(`${sn}:%`);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Delete the scope row itself.
|
|
751
|
+
stmts.deleteScope.run(name);
|
|
752
|
+
|
|
753
|
+
// Invalidate active tickets for removed scopes (mark used).
|
|
754
|
+
const usedAt = new Date().toISOString();
|
|
755
|
+
for (const sn of scopeNames) {
|
|
756
|
+
const ticketRows = stmts.selectTicketsByScope.all(sn);
|
|
757
|
+
for (const tr of ticketRows) {
|
|
758
|
+
if (tr.used === 0) {
|
|
759
|
+
stmts.markTicketUsed.run(usedAt, tr.index_hash);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
stmts.commit.run();
|
|
765
|
+
} catch (err) {
|
|
766
|
+
stmts.rollback.run();
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
await refreshTicketScopeCapabilities();
|
|
771
|
+
|
|
772
|
+
logger.info({ name }, 'Ticket scope unregistered');
|
|
773
|
+
return { ok: true, name };
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// --- Instance management ---
|
|
778
|
+
|
|
779
|
+
export function registerInstance(scope, transport, agentLabel, logger) {
|
|
780
|
+
return withTicketLock(async () => {
|
|
781
|
+
await getStmts();
|
|
782
|
+
|
|
783
|
+
// Verify scope exists (search across all scope manifests)
|
|
784
|
+
const scopeRows = stmts.selectAllScopes.all();
|
|
785
|
+
let scopeEntry = null;
|
|
786
|
+
for (const row of scopeRows) {
|
|
787
|
+
const s = rowToScope(row);
|
|
788
|
+
for (const decl of s.scopes) {
|
|
789
|
+
if (decl.name === scope) {
|
|
790
|
+
scopeEntry = decl;
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (scopeEntry) break;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (!scopeEntry) {
|
|
798
|
+
throw Object.assign(new Error('Scope not registered'), { statusCode: 404 });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Check for existing instance from this agent for this scope (idempotent re-registration)
|
|
802
|
+
const existingRows = stmts.selectInstanceByScopeAgent.all(scope, agentLabel);
|
|
803
|
+
if (existingRows.length > 0) {
|
|
804
|
+
const existing = existingRows[0];
|
|
805
|
+
stmts.begin.run();
|
|
806
|
+
try {
|
|
807
|
+
stmts.updateInstanceTransportHeartbeat.run(
|
|
808
|
+
JSON.stringify(transport),
|
|
809
|
+
new Date().toISOString(),
|
|
810
|
+
existing.instance_id,
|
|
811
|
+
);
|
|
812
|
+
stmts.commit.run();
|
|
813
|
+
} catch (err) {
|
|
814
|
+
stmts.rollback.run();
|
|
815
|
+
throw err;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
logger.info(
|
|
819
|
+
{ scope, agentLabel, instanceId: existing.instance_id },
|
|
820
|
+
'Instance re-registered',
|
|
821
|
+
);
|
|
822
|
+
return {
|
|
823
|
+
ok: true,
|
|
824
|
+
instanceId: existing.instance_id,
|
|
825
|
+
instanceScope: `${scope}:${existing.instance_id}`,
|
|
826
|
+
isReregistration: true,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// New registration — enforce hard cap
|
|
831
|
+
const { n } = stmts.countInstances.get();
|
|
832
|
+
if (n >= MAX_INSTANCES) {
|
|
833
|
+
throw Object.assign(new Error('Instance limit reached'), { statusCode: 503 });
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const instanceId = crypto.randomBytes(16).toString('hex');
|
|
837
|
+
const now = new Date().toISOString();
|
|
838
|
+
|
|
839
|
+
stmts.begin.run();
|
|
840
|
+
try {
|
|
841
|
+
stmts.insertInstance.run(
|
|
842
|
+
instanceId,
|
|
843
|
+
scope,
|
|
844
|
+
agentLabel,
|
|
845
|
+
now,
|
|
846
|
+
now,
|
|
847
|
+
'active',
|
|
848
|
+
JSON.stringify(transport),
|
|
849
|
+
);
|
|
850
|
+
stmts.commit.run();
|
|
851
|
+
} catch (err) {
|
|
852
|
+
stmts.rollback.run();
|
|
853
|
+
throw err;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
logger.info({ scope, agentLabel, instanceId }, 'Instance registered');
|
|
857
|
+
return {
|
|
858
|
+
ok: true,
|
|
859
|
+
instanceId,
|
|
860
|
+
instanceScope: `${scope}:${instanceId}`,
|
|
861
|
+
isReregistration: false,
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export function deregisterInstance(instanceId, callerLabel, callerRole, logger) {
|
|
867
|
+
return withTicketLock(async () => {
|
|
868
|
+
await getStmts();
|
|
869
|
+
|
|
870
|
+
const instanceRow = stmts.selectInstanceById.get(instanceId);
|
|
871
|
+
if (!instanceRow) {
|
|
872
|
+
throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Only the owning agent or admin can deregister (return 404 to avoid leaking existence)
|
|
876
|
+
if (callerRole !== 'admin' && callerLabel !== instanceRow.agent_label) {
|
|
877
|
+
throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const instanceScope = `${instanceRow.scope}:${instanceId}`;
|
|
881
|
+
|
|
882
|
+
stmts.begin.run();
|
|
883
|
+
try {
|
|
884
|
+
// Remove assignments for this instance.
|
|
885
|
+
stmts.deleteAssignmentsByInstanceScope.run(instanceScope);
|
|
886
|
+
|
|
887
|
+
// Delete the instance row.
|
|
888
|
+
stmts.deleteInstance.run(instanceId);
|
|
889
|
+
|
|
890
|
+
// Invalidate pending tickets and kill active sessions for this instance.
|
|
891
|
+
const usedAt = new Date().toISOString();
|
|
892
|
+
const ticketRows = stmts.selectTicketsByInstance.all(instanceId);
|
|
893
|
+
for (const tr of ticketRows) {
|
|
894
|
+
if (tr.used === 0) {
|
|
895
|
+
stmts.markTicketUsed.run(usedAt, tr.index_hash);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const sessionRows = stmts.selectSessionsByInstance.all(instanceId);
|
|
899
|
+
for (const sr of sessionRows) {
|
|
900
|
+
if (sr.status !== 'dead') {
|
|
901
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sr.session_id);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
stmts.commit.run();
|
|
905
|
+
} catch (err) {
|
|
906
|
+
stmts.rollback.run();
|
|
907
|
+
throw err;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
logger.info({ instanceId, scope: instanceRow.scope }, 'Instance deregistered');
|
|
911
|
+
return { ok: true, instanceId };
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export function instanceHeartbeat(instanceId, agentLabel) {
|
|
916
|
+
return withTicketLock(async () => {
|
|
917
|
+
await getStmts();
|
|
918
|
+
const instanceRow = stmts.selectInstanceById.get(instanceId);
|
|
919
|
+
|
|
920
|
+
if (!instanceRow || instanceRow.agent_label !== agentLabel) {
|
|
921
|
+
throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Verify agent still has the scope capability. Filter the stored list
|
|
925
|
+
// through the live valid set so that capabilities contributed by an
|
|
926
|
+
// uninstalled plugin or deregistered scope are not honored.
|
|
927
|
+
const agentRegistry = await loadAgentRegistry();
|
|
928
|
+
const agent = agentRegistry.agents.find((a) => a.label === agentLabel && !a.revoked);
|
|
929
|
+
if (!agent) {
|
|
930
|
+
throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
|
|
931
|
+
}
|
|
932
|
+
if (!liveCapsInclude(agent, instanceRow.scope)) {
|
|
933
|
+
throw Object.assign(new Error('Instance not found'), { statusCode: 404 });
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
stmts.begin.run();
|
|
937
|
+
try {
|
|
938
|
+
stmts.updateInstanceHeartbeat.run(new Date().toISOString(), instanceId);
|
|
939
|
+
stmts.commit.run();
|
|
940
|
+
} catch (err) {
|
|
941
|
+
stmts.rollback.run();
|
|
942
|
+
throw err;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
return { ok: true };
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Check whether `capability` is currently honored for `agent`.
|
|
951
|
+
*
|
|
952
|
+
* Single source of truth for "does this agent currently hold this cap?".
|
|
953
|
+
* Reads `agent.capabilities` and intersects with the live valid set
|
|
954
|
+
* (`getValidCapabilities()`), so an entry that became invalid after a
|
|
955
|
+
* plugin uninstall or ticket scope deregistration is treated as absent.
|
|
956
|
+
*
|
|
957
|
+
* Defaults a missing capability list to ['tunnels:read'] to preserve the
|
|
958
|
+
* legacy behaviour for agents enrolled before capabilities were stored.
|
|
959
|
+
*/
|
|
960
|
+
function liveCapsInclude(agent, capability) {
|
|
961
|
+
const stored = Array.isArray(agent?.capabilities) ? agent.capabilities : ['tunnels:read'];
|
|
962
|
+
if (!stored.includes(capability)) return false;
|
|
963
|
+
const valid = new Set(getValidCapabilities());
|
|
964
|
+
return valid.has(capability);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// --- Instance assignment ---
|
|
968
|
+
|
|
969
|
+
export function createAssignment(agentLabel, instanceScope, logger) {
|
|
970
|
+
return withTicketLock(async () => {
|
|
971
|
+
await getStmts();
|
|
972
|
+
|
|
973
|
+
// Parse instanceScope: "plugin:<route>:<action>:<hex-id>"
|
|
974
|
+
const parts = instanceScope.match(/^(plugin:[a-z0-9-]+:[a-z0-9-]+):([a-f0-9]+)$/);
|
|
975
|
+
if (!parts) {
|
|
976
|
+
throw Object.assign(new Error('Invalid instance scope format'), { statusCode: 400 });
|
|
977
|
+
}
|
|
978
|
+
const [, baseScope, instanceId] = parts;
|
|
979
|
+
|
|
980
|
+
// Verify agent exists and is not revoked
|
|
981
|
+
const agentRegistry = await loadAgentRegistry();
|
|
982
|
+
const agent = agentRegistry.agents.find((a) => a.label === agentLabel && !a.revoked);
|
|
983
|
+
if (!agent) {
|
|
984
|
+
throw Object.assign(new Error('Agent not found or revoked'), { statusCode: 404 });
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Verify agent has the base capability — check against the live valid
|
|
988
|
+
// set so caps from uninstalled plugins / deregistered scopes are not
|
|
989
|
+
// honored (see liveCapsInclude).
|
|
990
|
+
if (!liveCapsInclude(agent, baseScope)) {
|
|
991
|
+
throw Object.assign(new Error(`Agent "${agentLabel}" lacks capability "${baseScope}"`), {
|
|
992
|
+
statusCode: 400,
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Verify instance exists and is active
|
|
997
|
+
const instanceRow = stmts.selectInstanceById.get(instanceId);
|
|
998
|
+
if (!instanceRow || instanceRow.scope !== baseScope || instanceRow.status === 'dead') {
|
|
999
|
+
throw Object.assign(new Error('Instance not found or not active'), { statusCode: 404 });
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// Check for duplicate assignment
|
|
1003
|
+
const existingRow = stmts.selectAssignment.get(agentLabel, instanceScope);
|
|
1004
|
+
if (existingRow) {
|
|
1005
|
+
return { ok: true, assignment: rowToAssignment(existingRow), isExisting: true };
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const assignedAt = new Date().toISOString();
|
|
1009
|
+
const assignedBy = 'admin';
|
|
1010
|
+
|
|
1011
|
+
stmts.begin.run();
|
|
1012
|
+
try {
|
|
1013
|
+
stmts.insertAssignment.run(agentLabel, instanceScope, assignedAt, assignedBy);
|
|
1014
|
+
stmts.commit.run();
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
stmts.rollback.run();
|
|
1017
|
+
throw err;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const assignment = { agentLabel, instanceScope, assignedAt, assignedBy };
|
|
1021
|
+
logger.info({ agentLabel, instanceScope }, 'Assignment created');
|
|
1022
|
+
return { ok: true, assignment };
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
export function removeAssignment(agentLabel, instanceScope, logger) {
|
|
1027
|
+
return withTicketLock(async () => {
|
|
1028
|
+
await getStmts();
|
|
1029
|
+
const existingRow = stmts.selectAssignment.get(agentLabel, instanceScope);
|
|
1030
|
+
if (!existingRow) {
|
|
1031
|
+
throw Object.assign(new Error('Assignment not found'), { statusCode: 404 });
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
stmts.begin.run();
|
|
1035
|
+
try {
|
|
1036
|
+
stmts.deleteAssignment.run(agentLabel, instanceScope);
|
|
1037
|
+
stmts.commit.run();
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
stmts.rollback.run();
|
|
1040
|
+
throw err;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
logger.info({ agentLabel, instanceScope }, 'Assignment removed');
|
|
1044
|
+
return { ok: true };
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export function listAssignments(filters) {
|
|
1049
|
+
return withTicketLock(async () => {
|
|
1050
|
+
await getStmts();
|
|
1051
|
+
let assignments = stmts.selectAllAssignments.all().map(rowToAssignment);
|
|
1052
|
+
|
|
1053
|
+
if (filters?.agentLabel) {
|
|
1054
|
+
assignments = assignments.filter((a) => a.agentLabel === filters.agentLabel);
|
|
1055
|
+
}
|
|
1056
|
+
if (filters?.instanceScope) {
|
|
1057
|
+
assignments = assignments.filter((a) => a.instanceScope === filters.instanceScope);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
return { assignments };
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// --- Ticket operations ---
|
|
1065
|
+
|
|
1066
|
+
export function requestTicket(scope, instanceId, target, sourceLabel, logger) {
|
|
1067
|
+
return withTicketLock(async () => {
|
|
1068
|
+
await getStmts();
|
|
1069
|
+
|
|
1070
|
+
// Rate limit
|
|
1071
|
+
checkRateLimit(sourceLabel);
|
|
1072
|
+
|
|
1073
|
+
cleanExpiredTickets();
|
|
1074
|
+
|
|
1075
|
+
// Stage 1: Verify source/target agents have the base capability —
|
|
1076
|
+
// checked through liveCapsInclude so caps from uninstalled plugins or
|
|
1077
|
+
// deregistered scopes do not satisfy the check.
|
|
1078
|
+
const agentRegistry = await loadAgentRegistry();
|
|
1079
|
+
const sourceAgent = agentRegistry.agents.find((a) => a.label === sourceLabel && !a.revoked);
|
|
1080
|
+
if (!sourceAgent || !liveCapsInclude(sourceAgent, scope)) {
|
|
1081
|
+
throw Object.assign(new Error('Not found'), { statusCode: 404 });
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const targetAgent = agentRegistry.agents.find((a) => a.label === target && !a.revoked);
|
|
1085
|
+
if (!targetAgent || !liveCapsInclude(targetAgent, scope)) {
|
|
1086
|
+
throw Object.assign(new Error('Not found'), { statusCode: 404 });
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Stage 2: Verify source owns the instance
|
|
1090
|
+
const instanceRow = stmts.selectInstanceById.get(instanceId);
|
|
1091
|
+
if (!instanceRow || instanceRow.scope !== scope || instanceRow.agent_label !== sourceLabel) {
|
|
1092
|
+
throw Object.assign(new Error('Not found'), { statusCode: 404 });
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Check instance status
|
|
1096
|
+
if (instanceRow.status === 'stale') {
|
|
1097
|
+
throw Object.assign(new Error('Instance is stale'), { statusCode: 503 });
|
|
1098
|
+
}
|
|
1099
|
+
if (instanceRow.status === 'dead') {
|
|
1100
|
+
throw Object.assign(new Error('Not found'), { statusCode: 404 });
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Reject self-tickets (source cannot also be the target)
|
|
1104
|
+
if (sourceLabel === target) {
|
|
1105
|
+
throw Object.assign(new Error('Not found'), { statusCode: 404 });
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Stage 3: Verify target is assigned to this instance
|
|
1109
|
+
const instanceScope = `${scope}:${instanceId}`;
|
|
1110
|
+
const assignmentRow = stmts.selectAssignment.get(target, instanceScope);
|
|
1111
|
+
if (!assignmentRow) {
|
|
1112
|
+
throw Object.assign(new Error('Not found'), { statusCode: 404 });
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Enforce ticket cap
|
|
1116
|
+
const { n } = stmts.countTickets.get();
|
|
1117
|
+
if (n >= MAX_TICKETS) {
|
|
1118
|
+
throw Object.assign(new Error('Ticket limit reached'), { statusCode: 503 });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// Create ticket
|
|
1122
|
+
const ticketId = crypto.randomBytes(32).toString('hex');
|
|
1123
|
+
const now = new Date();
|
|
1124
|
+
const expiresAt = new Date(now.getTime() + TICKET_EXPIRY_MS);
|
|
1125
|
+
const transportObj = JSON.parse(instanceRow.transport);
|
|
1126
|
+
|
|
1127
|
+
stmts.begin.run();
|
|
1128
|
+
try {
|
|
1129
|
+
stmts.insertTicket.run(
|
|
1130
|
+
ticketIndex(ticketId),
|
|
1131
|
+
ticketId,
|
|
1132
|
+
scope,
|
|
1133
|
+
instanceId,
|
|
1134
|
+
sourceLabel,
|
|
1135
|
+
target,
|
|
1136
|
+
now.toISOString(),
|
|
1137
|
+
expiresAt.toISOString(),
|
|
1138
|
+
0,
|
|
1139
|
+
null,
|
|
1140
|
+
null,
|
|
1141
|
+
instanceRow.transport,
|
|
1142
|
+
);
|
|
1143
|
+
stmts.commit.run();
|
|
1144
|
+
} catch (err) {
|
|
1145
|
+
stmts.rollback.run();
|
|
1146
|
+
throw err;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
logger.info(
|
|
1150
|
+
{ ticketId: ticketId.slice(0, 8), scope, source: sourceLabel, target },
|
|
1151
|
+
'Ticket issued',
|
|
1152
|
+
);
|
|
1153
|
+
return {
|
|
1154
|
+
ok: true,
|
|
1155
|
+
ticket: {
|
|
1156
|
+
id: ticketId,
|
|
1157
|
+
scope,
|
|
1158
|
+
instanceId,
|
|
1159
|
+
source: sourceLabel,
|
|
1160
|
+
target,
|
|
1161
|
+
expiresAt: expiresAt.toISOString(),
|
|
1162
|
+
transport: transportObj,
|
|
1163
|
+
},
|
|
1164
|
+
};
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
export function getTicketInbox(agentLabel) {
|
|
1169
|
+
return withTicketLock(async () => {
|
|
1170
|
+
await getStmts();
|
|
1171
|
+
const now = Date.now();
|
|
1172
|
+
const allTickets = stmts.selectAllTickets.all().map(rowToTicket);
|
|
1173
|
+
const tickets = allTickets.filter(
|
|
1174
|
+
(t) => t.target === agentLabel && !t.used && new Date(t.expiresAt).getTime() > now,
|
|
1175
|
+
);
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
tickets: tickets.map((t) => ({
|
|
1179
|
+
id: t.id,
|
|
1180
|
+
scope: t.scope,
|
|
1181
|
+
instanceId: t.instanceId,
|
|
1182
|
+
source: t.source,
|
|
1183
|
+
expiresAt: t.expiresAt,
|
|
1184
|
+
transport: t.transport,
|
|
1185
|
+
})),
|
|
1186
|
+
};
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Validate a ticket and atomically mark it used.
|
|
1192
|
+
*
|
|
1193
|
+
* `logger` is the request-scoped Fastify logger; structured `reason` for the
|
|
1194
|
+
* specific failure mode is written to the panel log on every denial. The HTTP
|
|
1195
|
+
* response always uses the generic "Invalid ticket" message — no information
|
|
1196
|
+
* leakage to the calling agent.
|
|
1197
|
+
*/
|
|
1198
|
+
export function validateTicket(ticketId, callerLabel, logger = moduleLogger) {
|
|
1199
|
+
return withTicketLock(async () => {
|
|
1200
|
+
await getStmts();
|
|
1201
|
+
cleanExpiredTickets();
|
|
1202
|
+
|
|
1203
|
+
// Discriminated reason for the panel log only — never returned to caller.
|
|
1204
|
+
const deny = (reason) => {
|
|
1205
|
+
logger.info(
|
|
1206
|
+
{ ticketId: ticketId.slice(0, 8), reason, caller: callerLabel },
|
|
1207
|
+
'ticket validation denied',
|
|
1208
|
+
);
|
|
1209
|
+
recordTicketValidationFailure(logger, reason);
|
|
1210
|
+
throw Object.assign(new Error('Invalid ticket'), { statusCode: 401, reason });
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
const key = ticketIndex(ticketId);
|
|
1214
|
+
const row = stmts.selectTicketByIndex.get(key);
|
|
1215
|
+
|
|
1216
|
+
if (!row) deny('not_found');
|
|
1217
|
+
// Defense against a hash collision: confirm the stored full token matches
|
|
1218
|
+
// the caller-supplied one with a constant-time compare. ticketIndex is a
|
|
1219
|
+
// SHA-256 — a collision is computationally infeasible — but the equality
|
|
1220
|
+
// is cheap and removes any reliance on collision resistance for auth.
|
|
1221
|
+
if (!constantTimeStringEqual(row.id, ticketId)) deny('not_found');
|
|
1222
|
+
|
|
1223
|
+
if (row.used === 1) deny('already_used');
|
|
1224
|
+
if (new Date(row.expires_at).getTime() < Date.now()) deny('expired');
|
|
1225
|
+
if (row.target !== callerLabel) deny('target_mismatch');
|
|
1226
|
+
|
|
1227
|
+
// Mark as used atomically
|
|
1228
|
+
stmts.begin.run();
|
|
1229
|
+
try {
|
|
1230
|
+
stmts.markTicketUsed.run(new Date().toISOString(), key);
|
|
1231
|
+
stmts.commit.run();
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
stmts.rollback.run();
|
|
1234
|
+
throw err;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
return {
|
|
1238
|
+
valid: true,
|
|
1239
|
+
scope: row.scope,
|
|
1240
|
+
instanceId: row.instance_id,
|
|
1241
|
+
source: row.source,
|
|
1242
|
+
target: row.target,
|
|
1243
|
+
transport: JSON.parse(row.transport),
|
|
1244
|
+
};
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
export function listTickets() {
|
|
1249
|
+
return withTicketLock(async () => {
|
|
1250
|
+
await getStmts();
|
|
1251
|
+
cleanExpiredTickets();
|
|
1252
|
+
// Admin is fully trusted (mTLS admin cert) — return full IDs so revoke works.
|
|
1253
|
+
// Tickets are stored hash-indexed in SQLite for O(1) validate; the admin UI
|
|
1254
|
+
// consumes a plain array, so flatten on the way out.
|
|
1255
|
+
return { tickets: stmts.selectAllTickets.all().map(rowToTicket) };
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
export function revokeTicket(ticketId, logger) {
|
|
1260
|
+
return withTicketLock(async () => {
|
|
1261
|
+
await getStmts();
|
|
1262
|
+
const key = ticketIndex(ticketId);
|
|
1263
|
+
const row = stmts.selectTicketByIndex.get(key);
|
|
1264
|
+
|
|
1265
|
+
// Defense against hash collision (see validateTicket): require the stored
|
|
1266
|
+
// full token to constant-time-equal the inbound one.
|
|
1267
|
+
if (!row || !constantTimeStringEqual(row.id, ticketId)) {
|
|
1268
|
+
throw Object.assign(new Error('Ticket not found'), { statusCode: 404 });
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
stmts.begin.run();
|
|
1272
|
+
try {
|
|
1273
|
+
if (row.used === 0) {
|
|
1274
|
+
stmts.markTicketUsed.run(new Date().toISOString(), key);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// If the ticket had a session, mark it for termination
|
|
1278
|
+
if (row.session_id) {
|
|
1279
|
+
const sessionRow = stmts.selectSessionById.get(row.session_id);
|
|
1280
|
+
if (sessionRow && sessionRow.status !== 'dead') {
|
|
1281
|
+
stmts.killSessionStmt.run('admin', new Date().toISOString(), row.session_id);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
stmts.commit.run();
|
|
1285
|
+
} catch (err) {
|
|
1286
|
+
stmts.rollback.run();
|
|
1287
|
+
throw err;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
logger.info({ ticketId: ticketId.slice(0, 8) }, 'Ticket revoked');
|
|
1291
|
+
return { ok: true };
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// --- Session management ---
|
|
1296
|
+
|
|
1297
|
+
export function createSession(ticketId, callerLabel, logger) {
|
|
1298
|
+
return withTicketLock(async () => {
|
|
1299
|
+
await getStmts();
|
|
1300
|
+
|
|
1301
|
+
const key = ticketIndex(ticketId);
|
|
1302
|
+
const row = stmts.selectTicketByIndex.get(key);
|
|
1303
|
+
if (
|
|
1304
|
+
!row ||
|
|
1305
|
+
!constantTimeStringEqual(row.id, ticketId) ||
|
|
1306
|
+
row.used !== 1 ||
|
|
1307
|
+
row.target !== callerLabel
|
|
1308
|
+
) {
|
|
1309
|
+
throw Object.assign(new Error('Invalid ticket'), { statusCode: 400 });
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Prevent duplicate sessions for the same ticket
|
|
1313
|
+
if (row.session_id) {
|
|
1314
|
+
throw Object.assign(new Error('Session already exists for this ticket'), { statusCode: 409 });
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Enforce session cap
|
|
1318
|
+
const { n } = stmts.countLiveSessions.get();
|
|
1319
|
+
if (n >= MAX_SESSIONS) {
|
|
1320
|
+
throw Object.assign(new Error('Session limit reached'), { statusCode: 503 });
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Generate session ID server-side for uniqueness guarantee
|
|
1324
|
+
const sessionId = crypto.randomBytes(16).toString('hex');
|
|
1325
|
+
const now = new Date().toISOString();
|
|
1326
|
+
const session = {
|
|
1327
|
+
sessionId,
|
|
1328
|
+
ticketId,
|
|
1329
|
+
scope: row.scope,
|
|
1330
|
+
instanceId: row.instance_id,
|
|
1331
|
+
source: row.source,
|
|
1332
|
+
target: row.target,
|
|
1333
|
+
createdAt: now,
|
|
1334
|
+
lastActivityAt: now,
|
|
1335
|
+
status: 'active',
|
|
1336
|
+
reconnectGraceSeconds: 60,
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
stmts.begin.run();
|
|
1340
|
+
try {
|
|
1341
|
+
stmts.insertSession.run(
|
|
1342
|
+
sessionId,
|
|
1343
|
+
ticketId,
|
|
1344
|
+
row.scope,
|
|
1345
|
+
row.instance_id,
|
|
1346
|
+
row.source,
|
|
1347
|
+
row.target,
|
|
1348
|
+
now,
|
|
1349
|
+
now,
|
|
1350
|
+
'active',
|
|
1351
|
+
60,
|
|
1352
|
+
null,
|
|
1353
|
+
null,
|
|
1354
|
+
);
|
|
1355
|
+
stmts.setTicketSession.run(sessionId, key);
|
|
1356
|
+
stmts.commit.run();
|
|
1357
|
+
} catch (err) {
|
|
1358
|
+
stmts.rollback.run();
|
|
1359
|
+
throw err;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
logger.info({ sessionId, ticketId: ticketId.slice(0, 8), scope: row.scope }, 'Session created');
|
|
1363
|
+
return { ok: true, session };
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
export function sessionHeartbeat(sessionId, callerLabel) {
|
|
1368
|
+
return withTicketLock(async () => {
|
|
1369
|
+
await getStmts();
|
|
1370
|
+
const sessionRow = stmts.selectSessionById.get(sessionId);
|
|
1371
|
+
|
|
1372
|
+
if (!sessionRow || sessionRow.target !== callerLabel) {
|
|
1373
|
+
throw Object.assign(new Error('Session not found'), { statusCode: 404 });
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (sessionRow.status === 'dead') {
|
|
1377
|
+
return { authorized: false, reason: 'admin_killed' };
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const session = rowToSession(sessionRow);
|
|
1381
|
+
|
|
1382
|
+
// Re-validate authorization
|
|
1383
|
+
const agentRegistry = await loadAgentRegistry();
|
|
1384
|
+
|
|
1385
|
+
// Check source cert is not revoked
|
|
1386
|
+
const sourceAgent = agentRegistry.agents.find((a) => a.label === session.source);
|
|
1387
|
+
if (!sourceAgent || sourceAgent.revoked) {
|
|
1388
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
|
|
1389
|
+
return { authorized: false, reason: 'source_revoked' };
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// Check source still has capability — through the live valid set so a
|
|
1393
|
+
// capability whose backing plugin/scope was removed counts as gone.
|
|
1394
|
+
if (!liveCapsInclude(sourceAgent, session.scope)) {
|
|
1395
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
|
|
1396
|
+
return { authorized: false, reason: 'capability_removed' };
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Check target agent still has capability (defense-in-depth)
|
|
1400
|
+
const targetAgent = agentRegistry.agents.find((a) => a.label === session.target);
|
|
1401
|
+
if (!targetAgent || targetAgent.revoked) {
|
|
1402
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
|
|
1403
|
+
return { authorized: false, reason: 'target_revoked' };
|
|
1404
|
+
}
|
|
1405
|
+
if (!liveCapsInclude(targetAgent, session.scope)) {
|
|
1406
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
|
|
1407
|
+
return { authorized: false, reason: 'capability_removed' };
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Check assignment still valid
|
|
1411
|
+
const instanceScope = `${session.scope}:${session.instanceId}`;
|
|
1412
|
+
const assignmentRow = stmts.selectAssignment.get(callerLabel, instanceScope);
|
|
1413
|
+
if (!assignmentRow) {
|
|
1414
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
|
|
1415
|
+
return { authorized: false, reason: 'assignment_removed' };
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// All checks passed
|
|
1419
|
+
stmts.updateSessionActivity.run(new Date().toISOString(), sessionId);
|
|
1420
|
+
return { authorized: true };
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
export function updateSession(sessionId, status, callerLabel) {
|
|
1425
|
+
return withTicketLock(async () => {
|
|
1426
|
+
await getStmts();
|
|
1427
|
+
const sessionRow = stmts.selectSessionById.get(sessionId);
|
|
1428
|
+
if (!sessionRow || (sessionRow.target !== callerLabel && sessionRow.source !== callerLabel)) {
|
|
1429
|
+
throw Object.assign(new Error('Session not found'), { statusCode: 404 });
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// Prevent reactivation of admin-killed or system-terminated sessions
|
|
1433
|
+
if (sessionRow.status === 'dead') {
|
|
1434
|
+
throw Object.assign(new Error('Session is terminated'), { statusCode: 409 });
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const session = rowToSession(sessionRow);
|
|
1438
|
+
|
|
1439
|
+
// Re-validate authorization on every status transition
|
|
1440
|
+
const agentRegistry = await loadAgentRegistry();
|
|
1441
|
+
|
|
1442
|
+
// Check source cert is not revoked and still has capability
|
|
1443
|
+
const sourceAgent = agentRegistry.agents.find((a) => a.label === session.source);
|
|
1444
|
+
if (!sourceAgent || sourceAgent.revoked) {
|
|
1445
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
|
|
1446
|
+
throw Object.assign(new Error('Session is terminated'), { statusCode: 409 });
|
|
1447
|
+
}
|
|
1448
|
+
if (!liveCapsInclude(sourceAgent, session.scope)) {
|
|
1449
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
|
|
1450
|
+
throw Object.assign(new Error('Session is terminated'), { statusCode: 409 });
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Check target assignment is still valid (always check against target, not caller)
|
|
1454
|
+
const instanceScope = `${session.scope}:${session.instanceId}`;
|
|
1455
|
+
const assignmentRow = stmts.selectAssignment.get(session.target, instanceScope);
|
|
1456
|
+
if (!assignmentRow) {
|
|
1457
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sessionId);
|
|
1458
|
+
throw Object.assign(new Error('Session is terminated'), { statusCode: 409 });
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Always set server-side timestamp to prevent clients from extending session lifetime
|
|
1462
|
+
stmts.updateSessionStatusActivity.run(status, new Date().toISOString(), sessionId);
|
|
1463
|
+
|
|
1464
|
+
return { ok: true };
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
export function killSession(sessionId, logger) {
|
|
1469
|
+
return withTicketLock(async () => {
|
|
1470
|
+
await getStmts();
|
|
1471
|
+
const sessionRow = stmts.selectSessionById.get(sessionId);
|
|
1472
|
+
if (!sessionRow) {
|
|
1473
|
+
throw Object.assign(new Error('Session not found'), { statusCode: 404 });
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
stmts.killSessionStmt.run('admin', new Date().toISOString(), sessionId);
|
|
1477
|
+
|
|
1478
|
+
logger.info({ sessionId }, 'Session killed by admin');
|
|
1479
|
+
return { ok: true };
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
export function listSessions() {
|
|
1484
|
+
return withTicketLock(async () => {
|
|
1485
|
+
await getStmts();
|
|
1486
|
+
cleanExpiredTickets();
|
|
1487
|
+
return { sessions: stmts.selectAllSessions.all().map(rowToSession) };
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// --- Instance liveness check (call periodically) ---
|
|
1492
|
+
|
|
1493
|
+
export async function checkInstanceLiveness(logger) {
|
|
1494
|
+
return withTicketLock(async () => {
|
|
1495
|
+
await getStmts();
|
|
1496
|
+
const now = Date.now();
|
|
1497
|
+
|
|
1498
|
+
const allInstances = stmts.selectAllInstances.all();
|
|
1499
|
+
|
|
1500
|
+
for (const inst of allInstances) {
|
|
1501
|
+
const lastBeat = new Date(inst.last_heartbeat).getTime();
|
|
1502
|
+
const elapsed = now - lastBeat;
|
|
1503
|
+
|
|
1504
|
+
if (inst.status === 'active' && elapsed > INSTANCE_STALE_MS) {
|
|
1505
|
+
stmts.updateInstanceStatus.run('stale', inst.instance_id);
|
|
1506
|
+
inst.status = 'stale';
|
|
1507
|
+
logger.warn({ instanceId: inst.instance_id, scope: inst.scope }, 'Instance marked stale');
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (inst.status !== 'dead' && elapsed > INSTANCE_DEAD_MS) {
|
|
1511
|
+
stmts.updateInstanceStatus.run('dead', inst.instance_id);
|
|
1512
|
+
inst.status = 'dead';
|
|
1513
|
+
logger.warn({ instanceId: inst.instance_id, scope: inst.scope }, 'Instance marked dead');
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Remove dead instances and their assignments to free up capacity
|
|
1518
|
+
const deadIds = allInstances
|
|
1519
|
+
.filter((inst) => inst.status === 'dead')
|
|
1520
|
+
.map((inst) => ({ instanceId: inst.instance_id, scope: inst.scope }));
|
|
1521
|
+
|
|
1522
|
+
if (deadIds.length > 0) {
|
|
1523
|
+
const usedAt = new Date().toISOString();
|
|
1524
|
+
|
|
1525
|
+
stmts.begin.run();
|
|
1526
|
+
try {
|
|
1527
|
+
for (const { instanceId, scope } of deadIds) {
|
|
1528
|
+
const instanceScope = `${scope}:${instanceId}`;
|
|
1529
|
+
stmts.deleteAssignmentsByInstanceScope.run(instanceScope);
|
|
1530
|
+
stmts.deleteInstance.run(instanceId);
|
|
1531
|
+
logger.info({ instanceId, scope }, 'Dead instance removed');
|
|
1532
|
+
|
|
1533
|
+
// Invalidate pending tickets for this dead instance.
|
|
1534
|
+
const ticketRows = stmts.selectTicketsByInstance.all(instanceId);
|
|
1535
|
+
for (const tr of ticketRows) {
|
|
1536
|
+
if (tr.used === 0) {
|
|
1537
|
+
stmts.markTicketUsed.run(usedAt, tr.index_hash);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
// Kill active sessions for this dead instance.
|
|
1542
|
+
const sessionRows = stmts.selectSessionsByInstance.all(instanceId);
|
|
1543
|
+
for (const sr of sessionRows) {
|
|
1544
|
+
if (sr.status !== 'dead') {
|
|
1545
|
+
stmts.killSessionStmt.run('system', new Date().toISOString(), sr.session_id);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
stmts.commit.run();
|
|
1550
|
+
} catch (err) {
|
|
1551
|
+
stmts.rollback.run();
|
|
1552
|
+
throw err;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
}
|