@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.
Files changed (270) hide show
  1. package/LICENSE.md +118 -0
  2. package/README.md +114 -0
  3. package/bin/create-lamaste.js +15 -0
  4. package/package.json +54 -0
  5. package/scripts/bundle-vendor.js +110 -0
  6. package/scripts/lamaste-cert-info +62 -0
  7. package/scripts/lamaste-certbot +96 -0
  8. package/scripts/lamaste-priv +693 -0
  9. package/src/index.js +646 -0
  10. package/src/lib/accounts.js +86 -0
  11. package/src/lib/binaries.js +137 -0
  12. package/src/lib/cert-help-page.js +160 -0
  13. package/src/lib/env.js +132 -0
  14. package/src/lib/http-redirect.js +38 -0
  15. package/src/lib/ownership.js +231 -0
  16. package/src/lib/relay-services.js +103 -0
  17. package/src/lib/secrets.js +19 -0
  18. package/src/lib/service-config.js +311 -0
  19. package/src/lib/summary.js +131 -0
  20. package/src/lib/wrappers.js +63 -0
  21. package/src/tasks/gatekeeper.js +233 -0
  22. package/src/tasks/harden.js +293 -0
  23. package/src/tasks/mtls.js +193 -0
  24. package/src/tasks/nginx.js +265 -0
  25. package/src/tasks/node.js +110 -0
  26. package/src/tasks/panel.js +408 -0
  27. package/src/tasks/redeploy.js +438 -0
  28. package/vendor/docs/00-introduction/how-it-works.md +447 -0
  29. package/vendor/docs/00-introduction/quickstart.md +472 -0
  30. package/vendor/docs/00-introduction/what-is-lamaste.md +112 -0
  31. package/vendor/docs/01-concepts/authentication.md +464 -0
  32. package/vendor/docs/01-concepts/authorization.md +242 -0
  33. package/vendor/docs/01-concepts/certificates.md +452 -0
  34. package/vendor/docs/01-concepts/dns-and-domains.md +343 -0
  35. package/vendor/docs/01-concepts/mtls.md +490 -0
  36. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +540 -0
  37. package/vendor/docs/01-concepts/security-model.md +600 -0
  38. package/vendor/docs/01-concepts/tickets.md +414 -0
  39. package/vendor/docs/01-concepts/tunneling.md +367 -0
  40. package/vendor/docs/02-guides/agent-setup.md +194 -0
  41. package/vendor/docs/02-guides/certificate-management.md +341 -0
  42. package/vendor/docs/02-guides/cloud-provisioning.md +406 -0
  43. package/vendor/docs/02-guides/desktop-app-setup.md +226 -0
  44. package/vendor/docs/02-guides/disaster-recovery.md +464 -0
  45. package/vendor/docs/02-guides/first-tunnel.md +230 -0
  46. package/vendor/docs/02-guides/installation.md +393 -0
  47. package/vendor/docs/02-guides/managing-users.md +255 -0
  48. package/vendor/docs/02-guides/onboarding.md +244 -0
  49. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +375 -0
  50. package/vendor/docs/02-guides/static-sites.md +364 -0
  51. package/vendor/docs/03-architecture/e2e-three-vm-sequences.md +457 -0
  52. package/vendor/docs/03-architecture/installer-flow.md +151 -0
  53. package/vendor/docs/03-architecture/installer.md +445 -0
  54. package/vendor/docs/03-architecture/management-flow.md +288 -0
  55. package/vendor/docs/03-architecture/nginx-configuration.md +830 -0
  56. package/vendor/docs/03-architecture/onboarding-flow.md +164 -0
  57. package/vendor/docs/03-architecture/overview.md +541 -0
  58. package/vendor/docs/03-architecture/panel-client.md +416 -0
  59. package/vendor/docs/03-architecture/panel-server.md +695 -0
  60. package/vendor/docs/03-architecture/state-management.md +529 -0
  61. package/vendor/docs/03-architecture/system-overview.md +167 -0
  62. package/vendor/docs/04-api-reference/certificates.md +1141 -0
  63. package/vendor/docs/04-api-reference/gatekeeper.md +592 -0
  64. package/vendor/docs/04-api-reference/onboarding.md +473 -0
  65. package/vendor/docs/04-api-reference/overview.md +404 -0
  66. package/vendor/docs/04-api-reference/services.md +267 -0
  67. package/vendor/docs/04-api-reference/settings.md +343 -0
  68. package/vendor/docs/04-api-reference/sites.md +716 -0
  69. package/vendor/docs/04-api-reference/system.md +268 -0
  70. package/vendor/docs/04-api-reference/tickets.md +618 -0
  71. package/vendor/docs/04-api-reference/tunnels.md +417 -0
  72. package/vendor/docs/04-api-reference/users.md +333 -0
  73. package/vendor/docs/05-operations/backup-and-restore.md +342 -0
  74. package/vendor/docs/05-operations/monitoring.md +285 -0
  75. package/vendor/docs/05-operations/uninstalling.md +344 -0
  76. package/vendor/docs/05-operations/upgrades.md +270 -0
  77. package/vendor/docs/06-reference/config-files.md +1080 -0
  78. package/vendor/docs/06-reference/glossary.md +528 -0
  79. package/vendor/docs/06-reference/installer-flags.md +260 -0
  80. package/vendor/docs/06-reference/ports-and-services.md +244 -0
  81. package/vendor/docs/06-reference/troubleshooting.md +665 -0
  82. package/vendor/docs/_index.json +233 -0
  83. package/vendor/docs/gatekeeper-design.md +953 -0
  84. package/vendor/docs/package.json +15 -0
  85. package/vendor/docs/todo/lib-cli-desktop-refactor.md +641 -0
  86. package/vendor/gatekeeper/dist/boot.d.ts +1 -0
  87. package/vendor/gatekeeper/dist/boot.d.ts.map +1 -0
  88. package/vendor/gatekeeper/dist/boot.js +17 -0
  89. package/vendor/gatekeeper/dist/boot.js.map +1 -0
  90. package/vendor/gatekeeper/dist/cli/commands/access.d.ts +2 -0
  91. package/vendor/gatekeeper/dist/cli/commands/access.d.ts.map +1 -0
  92. package/vendor/gatekeeper/dist/cli/commands/access.js +33 -0
  93. package/vendor/gatekeeper/dist/cli/commands/access.js.map +1 -0
  94. package/vendor/gatekeeper/dist/cli/commands/grants.d.ts +2 -0
  95. package/vendor/gatekeeper/dist/cli/commands/grants.d.ts.map +1 -0
  96. package/vendor/gatekeeper/dist/cli/commands/grants.js +110 -0
  97. package/vendor/gatekeeper/dist/cli/commands/grants.js.map +1 -0
  98. package/vendor/gatekeeper/dist/cli/commands/groups.d.ts +2 -0
  99. package/vendor/gatekeeper/dist/cli/commands/groups.d.ts.map +1 -0
  100. package/vendor/gatekeeper/dist/cli/commands/groups.js +98 -0
  101. package/vendor/gatekeeper/dist/cli/commands/groups.js.map +1 -0
  102. package/vendor/gatekeeper/dist/cli/index.d.ts +3 -0
  103. package/vendor/gatekeeper/dist/cli/index.d.ts.map +1 -0
  104. package/vendor/gatekeeper/dist/cli/index.js +45 -0
  105. package/vendor/gatekeeper/dist/cli/index.js.map +1 -0
  106. package/vendor/gatekeeper/dist/index.d.ts +9 -0
  107. package/vendor/gatekeeper/dist/index.d.ts.map +1 -0
  108. package/vendor/gatekeeper/dist/index.js +13 -0
  109. package/vendor/gatekeeper/dist/index.js.map +1 -0
  110. package/vendor/gatekeeper/dist/lib/authz.d.ts +16 -0
  111. package/vendor/gatekeeper/dist/lib/authz.d.ts.map +1 -0
  112. package/vendor/gatekeeper/dist/lib/authz.js +42 -0
  113. package/vendor/gatekeeper/dist/lib/authz.js.map +1 -0
  114. package/vendor/gatekeeper/dist/lib/cache-bust.d.ts +15 -0
  115. package/vendor/gatekeeper/dist/lib/cache-bust.d.ts.map +1 -0
  116. package/vendor/gatekeeper/dist/lib/cache-bust.js +19 -0
  117. package/vendor/gatekeeper/dist/lib/cache-bust.js.map +1 -0
  118. package/vendor/gatekeeper/dist/lib/constants.d.ts +82 -0
  119. package/vendor/gatekeeper/dist/lib/constants.d.ts.map +1 -0
  120. package/vendor/gatekeeper/dist/lib/constants.js +79 -0
  121. package/vendor/gatekeeper/dist/lib/constants.js.map +1 -0
  122. package/vendor/gatekeeper/dist/lib/grants.d.ts +32 -0
  123. package/vendor/gatekeeper/dist/lib/grants.d.ts.map +1 -0
  124. package/vendor/gatekeeper/dist/lib/grants.js +314 -0
  125. package/vendor/gatekeeper/dist/lib/grants.js.map +1 -0
  126. package/vendor/gatekeeper/dist/lib/groups.d.ts +10 -0
  127. package/vendor/gatekeeper/dist/lib/groups.d.ts.map +1 -0
  128. package/vendor/gatekeeper/dist/lib/groups.js +247 -0
  129. package/vendor/gatekeeper/dist/lib/groups.js.map +1 -0
  130. package/vendor/gatekeeper/dist/lib/migration.d.ts +17 -0
  131. package/vendor/gatekeeper/dist/lib/migration.d.ts.map +1 -0
  132. package/vendor/gatekeeper/dist/lib/migration.js +79 -0
  133. package/vendor/gatekeeper/dist/lib/migration.js.map +1 -0
  134. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0001_groups.sql +7 -0
  135. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0002_access_grants.sql +14 -0
  136. package/vendor/gatekeeper/dist/lib/migrations/gatekeeper/0003_access_request_log.sql +9 -0
  137. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0001_groups.sql +7 -0
  138. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0002_access_grants.sql +14 -0
  139. package/vendor/gatekeeper/dist/lib/migrations/migrations/gatekeeper/0003_access_request_log.sql +9 -0
  140. package/vendor/gatekeeper/dist/lib/state-db.d.ts +16 -0
  141. package/vendor/gatekeeper/dist/lib/state-db.d.ts.map +1 -0
  142. package/vendor/gatekeeper/dist/lib/state-db.js +154 -0
  143. package/vendor/gatekeeper/dist/lib/state-db.js.map +1 -0
  144. package/vendor/gatekeeper/dist/lib/templates.d.ts +21 -0
  145. package/vendor/gatekeeper/dist/lib/templates.d.ts.map +1 -0
  146. package/vendor/gatekeeper/dist/lib/templates.js +151 -0
  147. package/vendor/gatekeeper/dist/lib/templates.js.map +1 -0
  148. package/vendor/gatekeeper/dist/lib/types.d.ts +178 -0
  149. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -0
  150. package/vendor/gatekeeper/dist/lib/types.js +2 -0
  151. package/vendor/gatekeeper/dist/lib/types.js.map +1 -0
  152. package/vendor/gatekeeper/dist/server/index.d.ts +15 -0
  153. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -0
  154. package/vendor/gatekeeper/dist/server/index.js +251 -0
  155. package/vendor/gatekeeper/dist/server/index.js.map +1 -0
  156. package/vendor/gatekeeper/dist/server/routes/access-request.d.ts +7 -0
  157. package/vendor/gatekeeper/dist/server/routes/access-request.d.ts.map +1 -0
  158. package/vendor/gatekeeper/dist/server/routes/access-request.js +17 -0
  159. package/vendor/gatekeeper/dist/server/routes/access-request.js.map +1 -0
  160. package/vendor/gatekeeper/dist/server/routes/authz.d.ts +3 -0
  161. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -0
  162. package/vendor/gatekeeper/dist/server/routes/authz.js +279 -0
  163. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -0
  164. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts +13 -0
  165. package/vendor/gatekeeper/dist/server/routes/diagnostic.d.ts.map +1 -0
  166. package/vendor/gatekeeper/dist/server/routes/diagnostic.js +249 -0
  167. package/vendor/gatekeeper/dist/server/routes/diagnostic.js.map +1 -0
  168. package/vendor/gatekeeper/dist/server/routes/grants.d.ts +3 -0
  169. package/vendor/gatekeeper/dist/server/routes/grants.d.ts.map +1 -0
  170. package/vendor/gatekeeper/dist/server/routes/grants.js +112 -0
  171. package/vendor/gatekeeper/dist/server/routes/grants.js.map +1 -0
  172. package/vendor/gatekeeper/dist/server/routes/groups.d.ts +3 -0
  173. package/vendor/gatekeeper/dist/server/routes/groups.d.ts.map +1 -0
  174. package/vendor/gatekeeper/dist/server/routes/groups.js +139 -0
  175. package/vendor/gatekeeper/dist/server/routes/groups.js.map +1 -0
  176. package/vendor/gatekeeper/package.json +49 -0
  177. package/vendor/server/bin/lamaste-server.js +204 -0
  178. package/vendor/server/package.json +31 -0
  179. package/vendor/server/src/commands/certs.js +224 -0
  180. package/vendor/server/src/commands/chisel.js +132 -0
  181. package/vendor/server/src/commands/logs.js +49 -0
  182. package/vendor/server/src/commands/plugins.js +321 -0
  183. package/vendor/server/src/commands/reset-admin.js +478 -0
  184. package/vendor/server/src/commands/restart.js +94 -0
  185. package/vendor/server/src/commands/sites.js +257 -0
  186. package/vendor/server/src/commands/status.js +158 -0
  187. package/vendor/server/src/commands/tunnels.js +365 -0
  188. package/vendor/server/src/commands/uninstall.js +326 -0
  189. package/vendor/server/src/config.js +38 -0
  190. package/vendor/server/src/exec.js +26 -0
  191. package/vendor/server/src/logger.js +39 -0
  192. package/vendor/server/src/ndjson.js +56 -0
  193. package/vendor/server/src/panel-api.js +85 -0
  194. package/vendor/server/src/state.js +45 -0
  195. package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +257 -0
  196. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +1 -0
  197. package/vendor/server-ui/dist/index.html +13 -0
  198. package/vendor/serverd/package.json +44 -0
  199. package/vendor/serverd/src/boot.js +13 -0
  200. package/vendor/serverd/src/index.js +191 -0
  201. package/vendor/serverd/src/lib/app-error.js +14 -0
  202. package/vendor/serverd/src/lib/authelia.js +85 -0
  203. package/vendor/serverd/src/lib/certbot.js +74 -0
  204. package/vendor/serverd/src/lib/chisel-args.js +6 -0
  205. package/vendor/serverd/src/lib/chisel-reconcile.js +232 -0
  206. package/vendor/serverd/src/lib/chisel-runtime.js +46 -0
  207. package/vendor/serverd/src/lib/chisel-users.js +109 -0
  208. package/vendor/serverd/src/lib/chisel.js +81 -0
  209. package/vendor/serverd/src/lib/config.js +190 -0
  210. package/vendor/serverd/src/lib/constants.js +8 -0
  211. package/vendor/serverd/src/lib/csr-signing.js +331 -0
  212. package/vendor/serverd/src/lib/enrollment.js +302 -0
  213. package/vendor/serverd/src/lib/files.js +19 -0
  214. package/vendor/serverd/src/lib/gatekeeper-client.js +135 -0
  215. package/vendor/serverd/src/lib/invite-page.js +281 -0
  216. package/vendor/serverd/src/lib/ip-utils.js +79 -0
  217. package/vendor/serverd/src/lib/migrations/state/0001_enrollment_tokens.sql +17 -0
  218. package/vendor/serverd/src/lib/migrations/state/0002_tickets.sql +70 -0
  219. package/vendor/serverd/src/lib/migrations/state/0003_plugins.sql +15 -0
  220. package/vendor/serverd/src/lib/migrations/state/0004_agents.sql +19 -0
  221. package/vendor/serverd/src/lib/mtls.js +795 -0
  222. package/vendor/serverd/src/lib/nginx.js +1090 -0
  223. package/vendor/serverd/src/lib/pki-paths.js +41 -0
  224. package/vendor/serverd/src/lib/pki-sign.js +103 -0
  225. package/vendor/serverd/src/lib/plugins.js +538 -0
  226. package/vendor/serverd/src/lib/push-install.js +390 -0
  227. package/vendor/serverd/src/lib/revocation.js +112 -0
  228. package/vendor/serverd/src/lib/services.js +131 -0
  229. package/vendor/serverd/src/lib/session.js +151 -0
  230. package/vendor/serverd/src/lib/state-db.js +130 -0
  231. package/vendor/serverd/src/lib/state.js +140 -0
  232. package/vendor/serverd/src/lib/storage.js +63 -0
  233. package/vendor/serverd/src/lib/system-stats.js +58 -0
  234. package/vendor/serverd/src/lib/tickets.js +1556 -0
  235. package/vendor/serverd/src/lib/totp.js +76 -0
  236. package/vendor/serverd/src/lib/tunnel-deps.js +41 -0
  237. package/vendor/serverd/src/lib/user-access-session.js +142 -0
  238. package/vendor/serverd/src/lib/user-access.js +52 -0
  239. package/vendor/serverd/src/middleware/errors.js +63 -0
  240. package/vendor/serverd/src/middleware/mtls.js +120 -0
  241. package/vendor/serverd/src/middleware/onboarding-guard.js +30 -0
  242. package/vendor/serverd/src/middleware/role-guard.js +58 -0
  243. package/vendor/serverd/src/middleware/twofa-session.js +65 -0
  244. package/vendor/serverd/src/middleware/user-access-session.js +52 -0
  245. package/vendor/serverd/src/routes/enrollment.js +120 -0
  246. package/vendor/serverd/src/routes/health.js +22 -0
  247. package/vendor/serverd/src/routes/invite.js +163 -0
  248. package/vendor/serverd/src/routes/management/agents.js +182 -0
  249. package/vendor/serverd/src/routes/management/certs.js +911 -0
  250. package/vendor/serverd/src/routes/management/gatekeeper-proxy.js +160 -0
  251. package/vendor/serverd/src/routes/management/identity.js +107 -0
  252. package/vendor/serverd/src/routes/management/invitations.js +166 -0
  253. package/vendor/serverd/src/routes/management/logs.js +218 -0
  254. package/vendor/serverd/src/routes/management/plugins.js +560 -0
  255. package/vendor/serverd/src/routes/management/services.js +63 -0
  256. package/vendor/serverd/src/routes/management/settings.js +325 -0
  257. package/vendor/serverd/src/routes/management/sites.js +522 -0
  258. package/vendor/serverd/src/routes/management/storage.js +192 -0
  259. package/vendor/serverd/src/routes/management/system.js +201 -0
  260. package/vendor/serverd/src/routes/management/tickets.js +457 -0
  261. package/vendor/serverd/src/routes/management/tunnels.js +709 -0
  262. package/vendor/serverd/src/routes/management/users.js +377 -0
  263. package/vendor/serverd/src/routes/management.js +36 -0
  264. package/vendor/serverd/src/routes/onboarding/dns.js +101 -0
  265. package/vendor/serverd/src/routes/onboarding/domain.js +42 -0
  266. package/vendor/serverd/src/routes/onboarding/index.js +18 -0
  267. package/vendor/serverd/src/routes/onboarding/provision.js +327 -0
  268. package/vendor/serverd/src/routes/onboarding/status.js +12 -0
  269. package/vendor/serverd/src/routes/plugin-router.js +79 -0
  270. package/vendor/serverd/src/routes/user-access.js +389 -0
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Tunnel management routes.
3
+ *
4
+ * Mutations delegate to @lamalibre/lamaste/server core workflows, which run
5
+ * under the tunnel-state lock. Read-only endpoints and HTTP-specific auth
6
+ * checks remain here; checks that depend on the tunnel's current state run
7
+ * inside the lock through the workflows' `authorize` hook.
8
+ */
9
+ import { z } from 'zod';
10
+ import {
11
+ createTunnel,
12
+ deleteTunnel,
13
+ updateTunnel,
14
+ TUNNEL_MAX_BODY_MB,
15
+ grantedTunnelsFor,
16
+ TunnelError,
17
+ } from '@lamalibre/lamaste/server';
18
+ import {
19
+ AGENT_LABEL_REGEX,
20
+ PLUGIN_AGENT_CN_PREFIX,
21
+ RESERVED_TUNNEL_PORTS,
22
+ } from '@lamalibre/lamaste';
23
+ import { getConfig } from '../../lib/config.js';
24
+ import { readTunnels, readSites } from '../../lib/state.js';
25
+ import { loadAgentRegistry } from '../../lib/mtls.js';
26
+ import { getChiselCredentialIssuedAt } from '../../lib/chisel-users.js';
27
+ import { buildChiselArgs } from '../../lib/chisel-args.js';
28
+ import {
29
+ tunnelNginxDeps,
30
+ tunnelCertbotDeps,
31
+ tunnelChiselDeps,
32
+ tunnelStateDeps,
33
+ } from '../../lib/tunnel-deps.js';
34
+
35
+ const IdParamSchema = z.object({ id: z.string().uuid() });
36
+
37
+ const TunnelPortSchema = z
38
+ .number()
39
+ .int('Port must be an integer')
40
+ .min(1024, 'Port must be at least 1024')
41
+ .max(65535, 'Port must be at most 65535')
42
+ .refine((p) => !RESERVED_TUNNEL_PORTS.includes(p), {
43
+ message: `Ports ${RESERVED_TUNNEL_PORTS.join(', ')} belong to Lamaste services on the relay`,
44
+ });
45
+
46
+ const AgentLabelSchema = z.string().regex(AGENT_LABEL_REGEX, 'Invalid agent label format');
47
+
48
+ const AccessModeSchema = z.enum(['public', 'authenticated', 'restricted']);
49
+
50
+ const MaxBodySizeSchema = z
51
+ .number()
52
+ .int('maxBodySizeMb must be an integer')
53
+ .min(TUNNEL_MAX_BODY_MB.min, `maxBodySizeMb must be at least ${TUNNEL_MAX_BODY_MB.min}`)
54
+ .max(TUNNEL_MAX_BODY_MB.max, `maxBodySizeMb must be at most ${TUNNEL_MAX_BODY_MB.max}`);
55
+
56
+ const AgentConfigQuerySchema = z.object({
57
+ agent: AgentLabelSchema.optional(),
58
+ });
59
+
60
+ const UpdateTunnelSchema = z
61
+ .object({
62
+ enabled: z.boolean().optional(),
63
+ agentLabel: AgentLabelSchema.optional(),
64
+ accessMode: AccessModeSchema.optional(),
65
+ maxBodySizeMb: MaxBodySizeSchema.optional(),
66
+ })
67
+ .strict()
68
+ .refine((d) => Object.values(d).some((v) => v !== undefined), {
69
+ message: 'Provide at least one of enabled, agentLabel, accessMode, maxBodySizeMb',
70
+ });
71
+
72
+ // `z.coerce.number()` lets browser query strings pass `?limit=50` without
73
+ // requiring callers to JSON-encode their numeric params.
74
+ const ListTunnelsQuerySchema = z.object({
75
+ limit: z.coerce.number().int().min(1).max(500).optional().default(100),
76
+ offset: z.coerce.number().int().min(0).optional().default(0),
77
+ sort: z.enum(['createdAt', 'name']).optional().default('createdAt'),
78
+ order: z.enum(['asc', 'desc']).optional().default('desc'),
79
+ });
80
+
81
+ const CreateTunnelSchema = z
82
+ .object({
83
+ subdomain: z
84
+ .string()
85
+ .regex(
86
+ /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/,
87
+ 'Subdomain must be lowercase alphanumeric with optional hyphens, cannot start or end with a hyphen',
88
+ )
89
+ .max(63, 'Subdomain must be at most 63 characters'),
90
+ port: TunnelPortSchema,
91
+ description: z
92
+ .string()
93
+ .max(200, 'Description must be at most 200 characters')
94
+ .optional()
95
+ .default(''),
96
+ type: z.enum(['app', 'panel', 'plugin']).optional().default('app'),
97
+ pluginName: z
98
+ .string()
99
+ .min(1)
100
+ .max(200)
101
+ .regex(
102
+ /^@lamalibre\/[a-z0-9][a-z0-9._-]*$/,
103
+ 'Invalid plugin name — must be @lamalibre/ scoped with valid npm characters',
104
+ )
105
+ .optional(),
106
+ // The agent that carries the tunnel. Admins must name it; an agent may
107
+ // omit it (it defaults to the agent itself) but may not name another.
108
+ agentLabel: AgentLabelSchema.optional(),
109
+ accessMode: AccessModeSchema.optional().default('restricted'),
110
+ maxBodySizeMb: MaxBodySizeSchema.optional(),
111
+ })
112
+ .refine((d) => d.type !== 'plugin' || (d.pluginName && d.agentLabel), {
113
+ message: 'pluginName and agentLabel are required for plugin tunnels',
114
+ });
115
+
116
+ const ExposePanelSchema = z.object({
117
+ port: TunnelPortSchema,
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Dependency adapters for core functions
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function buildAgentDeps() {
125
+ return {
126
+ async isActiveAgent(label) {
127
+ const registry = await loadAgentRegistry();
128
+ return registry.agents.some((a) => a.label === label && !a.revoked);
129
+ },
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Resolve which agent's tunnels a request may see and act on.
135
+ * Agents are confined to their own label; admins see everything.
136
+ * @returns {string | null} the confining label, or null for admin
137
+ */
138
+ function confinedLabel(request) {
139
+ return request.certRole === 'admin' ? null : request.certLabel;
140
+ }
141
+
142
+ /** True when the requester may act on this tunnel. */
143
+ function mayActOn(request, tunnel) {
144
+ const label = confinedLabel(request);
145
+ return label === null || tunnel.agentLabel === label;
146
+ }
147
+
148
+ /**
149
+ * `authorize` hook for workflows on an existing tunnel, evaluated under the
150
+ * tunnel lock: an agent sees only its own tunnels (anything else is "not
151
+ * found"), and an agent panel tunnel needs `panel:expose`.
152
+ */
153
+ function authorizeFor(request, action) {
154
+ return (tunnel) => {
155
+ if (!mayActOn(request, tunnel)) {
156
+ throw new TunnelError('Tunnel not found', 'NOT_FOUND');
157
+ }
158
+ if (tunnel.type === 'panel' && request.certRole !== 'admin') {
159
+ const caps = request.certCapabilities || [];
160
+ if (!caps.includes('panel:expose')) {
161
+ throw new TunnelError(
162
+ `Cannot ${action} a panel tunnel without the panel:expose capability`,
163
+ 'FORBIDDEN',
164
+ );
165
+ }
166
+ }
167
+ };
168
+ }
169
+
170
+ /** The largest body limit the requester may set. */
171
+ function maxBodyCeiling(request) {
172
+ return request.certRole === 'admin' ? TUNNEL_MAX_BODY_MB.max : TUNNEL_MAX_BODY_MB.agentMax;
173
+ }
174
+
175
+ /** A static site (or one of its aliases) that already answers on `fqdn`. */
176
+ async function siteServing(fqdn) {
177
+ const sites = await readSites();
178
+ return sites.find((s) => s.fqdn === fqdn || (s.aliases ?? []).includes(fqdn)) ?? null;
179
+ }
180
+
181
+ /**
182
+ * Map a TunnelError code to an HTTP status code.
183
+ */
184
+ function tunnelErrorStatus(code) {
185
+ switch (code) {
186
+ case 'RESERVED_SUBDOMAIN':
187
+ case 'RESERVED_AGENT_PREFIX':
188
+ case 'SUBDOMAIN_IN_USE':
189
+ case 'PORT_IN_USE':
190
+ case 'DOMAIN_NOT_CONFIGURED':
191
+ case 'RESERVED_PLUGIN_ROUTE':
192
+ case 'INVALID_AGENT_LABEL':
193
+ case 'UNKNOWN_AGENT':
194
+ case 'INVALID_SETTING':
195
+ case 'RESERVED_PORT':
196
+ return 400;
197
+ case 'FORBIDDEN':
198
+ return 403;
199
+ case 'NOT_FOUND':
200
+ return 404;
201
+ default:
202
+ return 500;
203
+ }
204
+ }
205
+
206
+ export default async function tunnelRoutes(fastify, _opts) {
207
+ const nginxDeps = tunnelNginxDeps;
208
+ const certbotDeps = tunnelCertbotDeps;
209
+ const chiselDeps = tunnelChiselDeps;
210
+ const stateDeps = tunnelStateDeps;
211
+ const agentDeps = buildAgentDeps();
212
+
213
+ // GET /api/tunnels/agent-config — must be registered BEFORE /:id
214
+ fastify.get(
215
+ '/tunnels/agent-config',
216
+ {
217
+ preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'tunnels:read' }),
218
+ },
219
+ async (request, reply) => {
220
+ // Parsed outside the try so a malformed ?agent= is a 400 from the
221
+ // shared Zod error handler, not a 500 from the catch below.
222
+ const { agent } = AgentConfigQuerySchema.parse(request.query);
223
+ try {
224
+ const config = getConfig();
225
+ const tunnels = await readTunnels();
226
+
227
+ if (!config.domain) {
228
+ return reply.code(400).send({ error: 'Domain not configured' });
229
+ }
230
+
231
+ // An agent always gets its own tunnels and nothing else. An admin
232
+ // names the agent with ?agent=<label>; without it the admin gets the
233
+ // domain only, because a chisel config is meaningless without an
234
+ // agent to carry it.
235
+ const label = confinedLabel(request) ?? agent ?? null;
236
+
237
+ const base = {
238
+ domain: config.domain,
239
+ chiselServerUrl: `https://tunnel.${config.domain}:443`,
240
+ };
241
+ if (label === null) {
242
+ return base;
243
+ }
244
+
245
+ // Exactly what chisel grants this agent: a tunnel chisel withholds
246
+ // (reserved port, duplicate claim) must not be requested, or chisel
247
+ // refuses the agent's whole session.
248
+ const carried = grantedTunnelsFor(tunnels, label);
249
+ return {
250
+ ...base,
251
+ agentLabel: label,
252
+ chiselArgs: buildChiselArgs(carried, config.domain),
253
+ // When the agent's chisel password was issued (not the password).
254
+ // An agent holding an older one fetches the current credential.
255
+ chiselCredentialIssuedAt: AGENT_LABEL_REGEX.test(label)
256
+ ? await getChiselCredentialIssuedAt(label)
257
+ : null,
258
+ tunnels: carried.map((t) => ({
259
+ port: t.port,
260
+ subdomain: t.subdomain,
261
+ })),
262
+ };
263
+ } catch (err) {
264
+ request.log.error(err, 'Failed to generate agent config');
265
+ return reply.code(500).send({ error: 'Failed to generate agent config' });
266
+ }
267
+ },
268
+ );
269
+
270
+ // GET /api/tunnels
271
+ //
272
+ // Paginated. Defaults (limit=100, offset=0, sort=createdAt desc) preserve
273
+ // the prior unpaginated UX for installations under 100 tunnels. The
274
+ // `tunnels` array stays the response envelope's primary field; older
275
+ // clients that ignore `total`/`limit`/`offset` continue to function and
276
+ // simply see the first window.
277
+ fastify.get(
278
+ '/tunnels',
279
+ {
280
+ preHandler: fastify.requireRole(['admin', 'agent']),
281
+ },
282
+ async (request, _reply) => {
283
+ const { limit, offset, sort, order } = ListTunnelsQuerySchema.parse(request.query);
284
+ // Agents see only the tunnels they carry — the hostnames and ports of
285
+ // other agents' tunnels are none of their business.
286
+ const label = confinedLabel(request);
287
+ const tunnels = (await readTunnels()).filter((t) => label === null || t.agentLabel === label);
288
+
289
+ // Sort the in-process state-file copy. Tunnel counts are bounded by
290
+ // operational reality (no installation realistically holds 10k+);
291
+ // an in-memory sort is the right tool here, no DB index needed.
292
+ const direction = order === 'asc' ? 1 : -1;
293
+ tunnels.sort((a, b) => {
294
+ if (sort === 'name') {
295
+ const av = String(a.subdomain ?? '');
296
+ const bv = String(b.subdomain ?? '');
297
+ return av < bv ? -direction : av > bv ? direction : 0;
298
+ }
299
+ const at = new Date(a.createdAt ?? 0).getTime();
300
+ const bt = new Date(b.createdAt ?? 0).getTime();
301
+ return (at - bt) * direction;
302
+ });
303
+
304
+ const total = tunnels.length;
305
+ const windowed = tunnels.slice(offset, offset + limit);
306
+
307
+ return { tunnels: windowed, total, limit, offset };
308
+ },
309
+ );
310
+
311
+ // POST /api/tunnels
312
+ fastify.post(
313
+ '/tunnels',
314
+ {
315
+ preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'tunnels:write' }),
316
+ },
317
+ async (request, reply) => {
318
+ const body = CreateTunnelSchema.parse(request.body);
319
+ const { subdomain, port, description, type, pluginName, accessMode, maxBodySizeMb } = body;
320
+
321
+ // --- HTTP-specific auth checks (cannot be done in core) ---
322
+
323
+ // Resolve the carrying agent. An agent can only create tunnels it
324
+ // carries itself; an admin must say which agent carries it.
325
+ let agentLabel;
326
+ if (request.certRole === 'admin') {
327
+ if (!body.agentLabel) {
328
+ return reply
329
+ .code(400)
330
+ .send({ error: 'agentLabel is required: name the agent that will carry the tunnel' });
331
+ }
332
+ agentLabel = body.agentLabel;
333
+ } else {
334
+ if (!request.certLabel) {
335
+ return reply.code(403).send({ error: 'Agent certificate has no label' });
336
+ }
337
+ // A plugin-agent certificate belongs to a plugin running on an agent,
338
+ // not to a machine that runs a chisel client: it holds no chisel
339
+ // credential and can carry nothing. Tunnels for a plugin are created
340
+ // with the agent's own certificate, or by an administrator.
341
+ if (request.certLabel.startsWith(PLUGIN_AGENT_CN_PREFIX)) {
342
+ return reply.code(403).send({
343
+ error:
344
+ 'Plugin-agent certificates cannot create tunnels — use the agent certificate of ' +
345
+ 'the machine that carries the tunnel',
346
+ });
347
+ }
348
+ if (body.agentLabel && body.agentLabel !== request.certLabel) {
349
+ return reply
350
+ .code(403)
351
+ .send({ error: 'Agents can only create tunnels they carry themselves' });
352
+ }
353
+ agentLabel = request.certLabel;
354
+ }
355
+
356
+ // Non-restricted access modes are admin-only
357
+ if (accessMode !== 'restricted' && request.certRole !== 'admin') {
358
+ return reply.code(403).send({
359
+ error: 'Only administrators can set tunnel access mode to public or authenticated',
360
+ });
361
+ }
362
+
363
+ if (maxBodySizeMb !== undefined && maxBodySizeMb > maxBodyCeiling(request)) {
364
+ return reply.code(403).send({
365
+ error: `Only administrators can set a body limit above ${TUNNEL_MAX_BODY_MB.agentMax} MiB`,
366
+ });
367
+ }
368
+
369
+ // Plugin tunnels are admin-only
370
+ if (type === 'plugin' && request.certRole !== 'admin') {
371
+ return reply
372
+ .code(403)
373
+ .send({ error: 'Plugin tunnels can only be created by administrators' });
374
+ }
375
+
376
+ // Panel tunnels require panel:expose capability and must match the requesting agent's label
377
+ if (type === 'panel') {
378
+ const caps = request.certCapabilities || [];
379
+ if (request.certRole !== 'admin' && !caps.includes('panel:expose')) {
380
+ return reply.code(403).send({ error: 'Agent does not have panel:expose capability' });
381
+ }
382
+ if (request.certRole === 'agent' && request.certLabel) {
383
+ const expectedSubdomain = `agent-${request.certLabel}`;
384
+ if (subdomain !== expectedSubdomain) {
385
+ return reply
386
+ .code(403)
387
+ .send({ error: 'Agents can only create panel tunnels for their own label' });
388
+ }
389
+ }
390
+ }
391
+
392
+ // --- Delegate to core ---
393
+
394
+ const config = getConfig();
395
+ if (!config.domain || !config.email) {
396
+ return reply.code(400).send({
397
+ error: 'Domain and email must be configured before creating tunnels',
398
+ });
399
+ }
400
+
401
+ // A static site (or one of its aliases) may already answer on this
402
+ // hostname; two vhosts with one server_name make nginx pick silently.
403
+ const fqdn = `${subdomain}.${config.domain}`;
404
+ if (await siteServing(fqdn)) {
405
+ return reply.code(400).send({
406
+ error: 'Failed to create tunnel',
407
+ details: `Domain '${fqdn}' is already served by a static site`,
408
+ });
409
+ }
410
+
411
+ try {
412
+ const tunnel = await createTunnel({
413
+ subdomain,
414
+ port,
415
+ description,
416
+ type,
417
+ accessMode,
418
+ pluginName,
419
+ agentLabel,
420
+ maxBodySizeMb,
421
+ domain: config.domain,
422
+ email: config.email,
423
+ nginx: nginxDeps,
424
+ certbot: certbotDeps,
425
+ chisel: chiselDeps,
426
+ state: stateDeps,
427
+ agents: agentDeps,
428
+ logger: request.log,
429
+ });
430
+ return reply.code(201).send({ ok: true, tunnel });
431
+ } catch (err) {
432
+ if (err instanceof TunnelError) {
433
+ const status = tunnelErrorStatus(err.code);
434
+ return reply.code(status).send({
435
+ error: 'Failed to create tunnel',
436
+ details: err.message,
437
+ });
438
+ }
439
+ request.log.error(err, 'Failed to create tunnel');
440
+ return reply.code(500).send({
441
+ error: 'Failed to create tunnel',
442
+ details: err.message,
443
+ });
444
+ }
445
+ },
446
+ );
447
+
448
+ // PATCH /api/tunnels/:id — enable/disable, reassign carrier, change access
449
+ // mode or body limit, in one workflow under the tunnel lock (see core
450
+ // updateTunnel for the order).
451
+ fastify.patch(
452
+ '/tunnels/:id',
453
+ {
454
+ preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'tunnels:write' }),
455
+ },
456
+ async (request, reply) => {
457
+ const { id } = IdParamSchema.parse(request.params);
458
+ const body = UpdateTunnelSchema.parse(request.body);
459
+
460
+ if (body.agentLabel !== undefined && request.certRole !== 'admin') {
461
+ return reply
462
+ .code(403)
463
+ .send({ error: 'Only administrators can change which agent carries a tunnel' });
464
+ }
465
+
466
+ if (
467
+ body.accessMode !== undefined &&
468
+ body.accessMode !== 'restricted' &&
469
+ request.certRole !== 'admin'
470
+ ) {
471
+ return reply.code(403).send({
472
+ error: 'Only administrators can set tunnel access mode to public or authenticated',
473
+ });
474
+ }
475
+
476
+ if (body.maxBodySizeMb !== undefined && body.maxBodySizeMb > maxBodyCeiling(request)) {
477
+ return reply.code(403).send({
478
+ error: `Only administrators can set a body limit above ${TUNNEL_MAX_BODY_MB.agentMax} MiB`,
479
+ });
480
+ }
481
+
482
+ const config = getConfig();
483
+ if (
484
+ (body.accessMode !== undefined || body.maxBodySizeMb !== undefined) &&
485
+ (!config.domain || !config.email)
486
+ ) {
487
+ return reply.code(400).send({ error: 'Domain and email must be configured' });
488
+ }
489
+
490
+ try {
491
+ return await updateTunnel({
492
+ id,
493
+ enabled: body.enabled,
494
+ agentLabel: body.agentLabel,
495
+ accessMode: body.accessMode,
496
+ maxBodySizeMb: body.maxBodySizeMb,
497
+ domain: config.domain,
498
+ email: config.email,
499
+ nginx: nginxDeps,
500
+ certbot: certbotDeps,
501
+ chisel: chiselDeps,
502
+ state: stateDeps,
503
+ agents: agentDeps,
504
+ logger: request.log,
505
+ authorize: authorizeFor(request, 'change'),
506
+ });
507
+ } catch (err) {
508
+ if (err instanceof TunnelError) {
509
+ const status = tunnelErrorStatus(err.code);
510
+ return reply.code(status).send({
511
+ error: status === 404 ? 'Tunnel not found' : 'Failed to update tunnel',
512
+ details: err.message,
513
+ });
514
+ }
515
+ request.log.error(err, 'Failed to update tunnel');
516
+ return reply.code(500).send({
517
+ error: 'Failed to update tunnel',
518
+ details: err.message,
519
+ });
520
+ }
521
+ },
522
+ );
523
+
524
+ // DELETE /api/tunnels/:id
525
+ fastify.delete(
526
+ '/tunnels/:id',
527
+ {
528
+ preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'tunnels:write' }),
529
+ },
530
+ async (request, reply) => {
531
+ const { id } = IdParamSchema.parse(request.params);
532
+
533
+ try {
534
+ await deleteTunnel({
535
+ id,
536
+ nginx: nginxDeps,
537
+ chisel: chiselDeps,
538
+ state: stateDeps,
539
+ logger: request.log,
540
+ authorize: authorizeFor(request, 'delete'),
541
+ });
542
+ return { ok: true };
543
+ } catch (err) {
544
+ if (err instanceof TunnelError) {
545
+ const status = tunnelErrorStatus(err.code);
546
+ return reply.code(status).send({
547
+ error: status === 404 ? 'Tunnel not found' : 'Failed to delete tunnel',
548
+ details: err.message,
549
+ });
550
+ }
551
+ request.log.error(err, 'Failed to delete tunnel');
552
+ return reply.code(500).send({
553
+ error: 'Failed to delete tunnel',
554
+ details: err.message,
555
+ });
556
+ }
557
+ },
558
+ );
559
+
560
+ // GET /api/tunnels/agent-panel-status — check if agent has a panel tunnel
561
+ fastify.get(
562
+ '/tunnels/agent-panel-status',
563
+ {
564
+ preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'panel:expose' }),
565
+ },
566
+ async (request, _reply) => {
567
+ const label = request.certLabel;
568
+ const tunnels = await readTunnels();
569
+ const subdomain = label ? `agent-${label}` : null;
570
+ const panelTunnel = tunnels.find((t) => t.type === 'panel' && t.subdomain === subdomain);
571
+
572
+ if (!panelTunnel) {
573
+ return { enabled: false, fqdn: null, port: null };
574
+ }
575
+
576
+ return {
577
+ enabled: panelTunnel.enabled !== false,
578
+ fqdn: panelTunnel.fqdn,
579
+ port: panelTunnel.port,
580
+ };
581
+ },
582
+ );
583
+
584
+ // POST /api/tunnels/expose-panel — create a panel tunnel for the requesting agent
585
+ fastify.post(
586
+ '/tunnels/expose-panel',
587
+ {
588
+ preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'panel:expose' }),
589
+ },
590
+ async (request, reply) => {
591
+ const { port } = ExposePanelSchema.parse(request.body);
592
+ const label = request.certLabel;
593
+
594
+ if (!label) {
595
+ return reply
596
+ .code(400)
597
+ .send({ error: 'Agent label is required (must use agent certificate)' });
598
+ }
599
+
600
+ const subdomain = `agent-${label}`;
601
+ const config = getConfig();
602
+
603
+ if (!config.domain || !config.email) {
604
+ return reply.code(400).send({
605
+ error: 'Domain and email must be configured before exposing agent panel',
606
+ });
607
+ }
608
+
609
+ const fqdn = `${subdomain}.${config.domain}`;
610
+ if (await siteServing(fqdn)) {
611
+ return reply.code(400).send({
612
+ error: 'Failed to expose agent panel',
613
+ details: `Domain '${fqdn}' is already served by a static site`,
614
+ });
615
+ }
616
+
617
+ // Check if a panel tunnel already exists for this agent
618
+ const existing = await readTunnels();
619
+ const existingPanel = existing.find((t) => t.type === 'panel' && t.subdomain === subdomain);
620
+ if (existingPanel) {
621
+ return reply.code(409).send({
622
+ error: 'Agent panel tunnel already exists',
623
+ tunnel: existingPanel,
624
+ });
625
+ }
626
+
627
+ try {
628
+ const tunnel = await createTunnel({
629
+ subdomain,
630
+ port,
631
+ description: `Agent management panel for ${label}`,
632
+ type: 'panel',
633
+ agentLabel: label,
634
+ domain: config.domain,
635
+ email: config.email,
636
+ nginx: nginxDeps,
637
+ certbot: certbotDeps,
638
+ chisel: chiselDeps,
639
+ state: stateDeps,
640
+ agents: agentDeps,
641
+ logger: request.log,
642
+ });
643
+ return reply.code(201).send({ ok: true, tunnel });
644
+ } catch (err) {
645
+ if (err instanceof TunnelError) {
646
+ const status = tunnelErrorStatus(err.code);
647
+ return reply.code(status).send({
648
+ error: 'Failed to expose agent panel',
649
+ details: err.message,
650
+ });
651
+ }
652
+ request.log.error(err, 'Failed to expose agent panel');
653
+ return reply.code(500).send({
654
+ error: 'Failed to expose agent panel',
655
+ details: err.message,
656
+ });
657
+ }
658
+ },
659
+ );
660
+
661
+ // DELETE /api/tunnels/retract-panel — remove the panel tunnel for the requesting agent
662
+ fastify.delete(
663
+ '/tunnels/retract-panel',
664
+ {
665
+ preHandler: fastify.requireRole(['admin', 'agent'], { capability: 'panel:expose' }),
666
+ },
667
+ async (request, reply) => {
668
+ const label = request.certLabel;
669
+
670
+ if (!label) {
671
+ return reply
672
+ .code(400)
673
+ .send({ error: 'Agent label is required (must use agent certificate)' });
674
+ }
675
+
676
+ const subdomain = `agent-${label}`;
677
+ const tunnels = await readTunnels();
678
+ const panelTunnel = tunnels.find((t) => t.type === 'panel' && t.subdomain === subdomain);
679
+
680
+ if (!panelTunnel) {
681
+ return reply.code(404).send({ error: 'No panel tunnel found for this agent' });
682
+ }
683
+
684
+ try {
685
+ await deleteTunnel({
686
+ id: panelTunnel.id,
687
+ nginx: nginxDeps,
688
+ chisel: chiselDeps,
689
+ state: stateDeps,
690
+ logger: request.log,
691
+ });
692
+ return { ok: true };
693
+ } catch (err) {
694
+ if (err instanceof TunnelError) {
695
+ const status = tunnelErrorStatus(err.code);
696
+ return reply.code(status).send({
697
+ error: 'Failed to retract agent panel',
698
+ details: err.message,
699
+ });
700
+ }
701
+ request.log.error(err, 'Failed to retract agent panel');
702
+ return reply.code(500).send({
703
+ error: 'Failed to retract agent panel',
704
+ details: err.message,
705
+ });
706
+ }
707
+ },
708
+ );
709
+ }