@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,1090 @@
1
+ import { execa } from 'execa';
2
+ import { access, lstat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ const SITES_AVAILABLE = '/etc/nginx/sites-available';
6
+ const SITES_ENABLED = '/etc/nginx/sites-enabled';
7
+
8
+ /**
9
+ * The root helper that installs vhosts (create-lamaste installs it; see
10
+ * provisioners/server/scripts/lamaste-priv). It accepts only the site names
11
+ * the panel manages and only vhosts built from the directives these
12
+ * generators use: nginx opens the files a configuration names as root, so a
13
+ * free-form vhost would be a root shell for whoever controls the panel.
14
+ */
15
+ const PRIV = '/usr/local/sbin/lamaste-priv';
16
+
17
+ /**
18
+ * Run one `lamaste-priv nginx-site` action.
19
+ *
20
+ * @param {'write'|'backup'|'restore'|'discard-backup'|'enable'|'disable'|'remove'} action
21
+ * @param {string} name
22
+ * @param {string} [content] - vhost text, for `write`
23
+ */
24
+ async function siteAction(action, name, content) {
25
+ try {
26
+ await execa('sudo', [PRIV, 'nginx-site', action, name], {
27
+ ...(content === undefined ? {} : { input: content }),
28
+ });
29
+ } catch (err) {
30
+ throw new Error(`nginx site ${action} failed for ${name}: ${err.stderr || err.message}`);
31
+ }
32
+ }
33
+
34
+ /** True when `name` exists in sites-available. The directory is world-readable. */
35
+ async function siteExists(name) {
36
+ try {
37
+ await access(path.join(SITES_AVAILABLE, name));
38
+ return true;
39
+ } catch (err) {
40
+ if (err.code === 'ENOENT') return false;
41
+ throw err;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Install `content` as sites-available/<name>.
47
+ */
48
+ async function writeVhostFile(name, content) {
49
+ await siteAction('write', name, content);
50
+ }
51
+
52
+ /**
53
+ * Write the panel domain vhost with mTLS and Let's Encrypt certs.
54
+ */
55
+ export async function writePanelVhost(domain) {
56
+ const fqdn = `panel.${domain}`;
57
+ const config = `# Rate limit zone for public enrollment endpoint (5 requests/minute per IP)
58
+ limit_req_zone $binary_remote_addr zone=enroll_domain:1m rate=5r/m;
59
+
60
+ # WebSocket upgrade map — must be at http context level
61
+ map $http_upgrade $connection_upgrade {
62
+ default upgrade;
63
+ '' close;
64
+ }
65
+
66
+ server {
67
+ listen 443 ssl;
68
+ server_name ${fqdn};
69
+
70
+ ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
71
+ ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
72
+
73
+ # mTLS — same as IP-based access
74
+ include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
75
+
76
+ # SSL settings
77
+ ssl_protocols TLSv1.2 TLSv1.3;
78
+ ssl_ciphers HIGH:!aNULL:!MD5;
79
+ ssl_prefer_server_ciphers on;
80
+
81
+ # Default location (mTLS required — reject if cert missing or invalid)
82
+ location / {
83
+ if ($ssl_client_verify != SUCCESS) {
84
+ return 496;
85
+ }
86
+ proxy_pass http://127.0.0.1:3100;
87
+
88
+ # Client cert headers — set from nginx TLS variables, never passed through from client
89
+ proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
90
+ proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
91
+ proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
92
+
93
+ # Strip Authelia identity headers — not trusted on mTLS vhost
94
+ proxy_set_header Remote-User "";
95
+ proxy_set_header Remote-Groups "";
96
+ proxy_set_header Remote-Name "";
97
+ proxy_set_header Remote-Email "";
98
+
99
+ # Standard proxy headers
100
+ proxy_set_header Host $host;
101
+ proxy_set_header X-Real-IP $remote_addr;
102
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
103
+ proxy_set_header X-Forwarded-Proto $scheme;
104
+ }
105
+
106
+ # Public API paths (no mTLS verification check)
107
+ location /api/enroll {
108
+ limit_req zone=enroll_domain burst=5 nodelay;
109
+ proxy_pass http://127.0.0.1:3100;
110
+ proxy_http_version 1.1;
111
+
112
+ proxy_set_header X-SSL-Client-Verify "";
113
+ proxy_set_header X-SSL-Client-DN "";
114
+ proxy_set_header X-SSL-Client-Serial "";
115
+
116
+ # Strip Authelia identity headers — not trusted on public endpoint
117
+ proxy_set_header Remote-User "";
118
+ proxy_set_header Remote-Groups "";
119
+ proxy_set_header Remote-Name "";
120
+ proxy_set_header Remote-Email "";
121
+
122
+ proxy_set_header Host $host;
123
+ proxy_set_header X-Real-IP $remote_addr;
124
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
125
+ proxy_set_header X-Forwarded-Proto $scheme;
126
+ }
127
+
128
+ location /api/invite {
129
+ proxy_pass http://127.0.0.1:3100;
130
+ proxy_http_version 1.1;
131
+
132
+ proxy_set_header X-SSL-Client-Verify "";
133
+ proxy_set_header X-SSL-Client-DN "";
134
+ proxy_set_header X-SSL-Client-Serial "";
135
+
136
+ # Strip Authelia identity headers — not trusted on public endpoint
137
+ proxy_set_header Remote-User "";
138
+ proxy_set_header Remote-Groups "";
139
+ proxy_set_header Remote-Name "";
140
+ proxy_set_header Remote-Email "";
141
+
142
+ proxy_set_header Host $host;
143
+ proxy_set_header X-Real-IP $remote_addr;
144
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
145
+ proxy_set_header X-Forwarded-Proto $scheme;
146
+ }
147
+
148
+ # User access public endpoints (no mTLS — Bearer token auth handled by panel server)
149
+ location /api/user-access/exchange {
150
+ limit_req zone=enroll_domain burst=5 nodelay;
151
+ proxy_pass http://127.0.0.1:3100;
152
+ proxy_http_version 1.1;
153
+
154
+ proxy_set_header X-SSL-Client-Verify "";
155
+ proxy_set_header X-SSL-Client-DN "";
156
+ proxy_set_header X-SSL-Client-Serial "";
157
+
158
+ proxy_set_header Remote-User "";
159
+ proxy_set_header Remote-Groups "";
160
+ proxy_set_header Remote-Name "";
161
+ proxy_set_header Remote-Email "";
162
+
163
+ proxy_set_header Host $host;
164
+ proxy_set_header X-Real-IP $remote_addr;
165
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
166
+ proxy_set_header X-Forwarded-Proto $scheme;
167
+ }
168
+
169
+ location /api/user-access/plugins {
170
+ limit_req zone=enroll_domain burst=5 nodelay;
171
+ proxy_pass http://127.0.0.1:3100;
172
+ proxy_http_version 1.1;
173
+
174
+ proxy_set_header X-SSL-Client-Verify "";
175
+ proxy_set_header X-SSL-Client-DN "";
176
+ proxy_set_header X-SSL-Client-Serial "";
177
+
178
+ proxy_set_header Remote-User "";
179
+ proxy_set_header Remote-Groups "";
180
+ proxy_set_header Remote-Name "";
181
+ proxy_set_header Remote-Email "";
182
+
183
+ proxy_set_header Host $host;
184
+ proxy_set_header X-Real-IP $remote_addr;
185
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
186
+ proxy_set_header X-Forwarded-Proto $scheme;
187
+ }
188
+
189
+ location /api/user-access/enroll {
190
+ limit_req zone=enroll_domain burst=5 nodelay;
191
+ proxy_pass http://127.0.0.1:3100;
192
+ proxy_http_version 1.1;
193
+
194
+ proxy_set_header X-SSL-Client-Verify "";
195
+ proxy_set_header X-SSL-Client-DN "";
196
+ proxy_set_header X-SSL-Client-Serial "";
197
+
198
+ proxy_set_header Remote-User "";
199
+ proxy_set_header Remote-Groups "";
200
+ proxy_set_header Remote-Name "";
201
+ proxy_set_header Remote-Email "";
202
+
203
+ proxy_set_header Host $host;
204
+ proxy_set_header X-Real-IP $remote_addr;
205
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
206
+ proxy_set_header X-Forwarded-Proto $scheme;
207
+ }
208
+
209
+ # API paths with WebSocket upgrade support (mTLS required)
210
+ location /api {
211
+ if ($ssl_client_verify != SUCCESS) {
212
+ return 496;
213
+ }
214
+ proxy_pass http://127.0.0.1:3100;
215
+ proxy_http_version 1.1;
216
+
217
+ # Client cert headers — set from nginx TLS variables, never passed through from client
218
+ proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
219
+ proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
220
+ proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
221
+
222
+ # Strip Authelia identity headers — not trusted on mTLS vhost
223
+ proxy_set_header Remote-User "";
224
+ proxy_set_header Remote-Groups "";
225
+ proxy_set_header Remote-Name "";
226
+ proxy_set_header Remote-Email "";
227
+
228
+ # Standard proxy headers
229
+ proxy_set_header Host $host;
230
+ proxy_set_header X-Real-IP $remote_addr;
231
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
232
+ proxy_set_header X-Forwarded-Proto $scheme;
233
+
234
+ # WebSocket: only upgrade when client requests it
235
+ proxy_set_header Upgrade $http_upgrade;
236
+ proxy_set_header Connection $connection_upgrade;
237
+ }
238
+ }
239
+ `;
240
+
241
+ await writeVhostFile('lamalibre-lamaste-panel-domain', config);
242
+ return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-panel-domain');
243
+ }
244
+
245
+ /**
246
+ * Write the Authelia auth portal vhost.
247
+ */
248
+ export async function writeAuthVhost(domain) {
249
+ const fqdn = `auth.${domain}`;
250
+ const config = `# Rate limit zone for user access authorize endpoint (5 requests/minute per IP)
251
+ limit_req_zone $binary_remote_addr zone=user_access_auth:1m rate=5r/m;
252
+
253
+ server {
254
+ listen 443 ssl;
255
+ server_name ${fqdn};
256
+
257
+ ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
258
+ ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
259
+
260
+ ssl_protocols TLSv1.2 TLSv1.3;
261
+ ssl_ciphers HIGH:!aNULL:!MD5;
262
+ ssl_prefer_server_ciphers on;
263
+
264
+ proxy_set_header Host $host;
265
+ proxy_set_header X-Real-IP $remote_addr;
266
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
267
+ proxy_set_header X-Forwarded-Proto $scheme;
268
+
269
+ # Invitation page — static HTML
270
+ location /invite/ {
271
+ alias /var/www/lamaste/invite/;
272
+ try_files $uri /invite/index.html;
273
+ }
274
+
275
+ # Invitation API — proxied to panel server (no mTLS)
276
+ location /api/invite/ {
277
+ proxy_pass http://127.0.0.1:3100/api/invite/;
278
+ proxy_set_header Host $host;
279
+ proxy_set_header X-Real-IP $remote_addr;
280
+ proxy_set_header X-Forwarded-For $remote_addr;
281
+ proxy_set_header X-Forwarded-Proto $scheme;
282
+ }
283
+
284
+ # User access authorization — Authelia forward auth protects this endpoint
285
+ # so Remote-User header is set by Authelia on successful authentication.
286
+ location /internal/authelia/authz {
287
+ internal;
288
+
289
+ proxy_pass http://127.0.0.1:9091/api/authz/auth-request;
290
+ proxy_pass_request_body off;
291
+
292
+ proxy_set_header Content-Length "";
293
+ proxy_set_header Connection "";
294
+ proxy_set_header X-Original-Method $request_method;
295
+ proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
296
+ proxy_set_header X-Forwarded-For $remote_addr;
297
+
298
+ proxy_http_version 1.1;
299
+ proxy_buffers 4 32k;
300
+ proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
301
+ }
302
+
303
+ location /api/user-access/authorize {
304
+ limit_req zone=user_access_auth burst=3 nodelay;
305
+
306
+ # Clear client-supplied identity headers (Authelia re-injects on success)
307
+ proxy_set_header Remote-User "";
308
+ proxy_set_header Remote-Groups "";
309
+ proxy_set_header Remote-Name "";
310
+ proxy_set_header Remote-Email "";
311
+
312
+ auth_request /internal/authelia/authz;
313
+ auth_request_set $user $upstream_http_remote_user;
314
+ auth_request_set $redirection_url $upstream_http_location;
315
+
316
+ proxy_set_header Remote-User $user;
317
+
318
+ proxy_pass http://127.0.0.1:3100;
319
+ proxy_set_header Host $host;
320
+ proxy_set_header X-Real-IP $remote_addr;
321
+ proxy_set_header X-Forwarded-For $remote_addr;
322
+ proxy_set_header X-Forwarded-Proto $scheme;
323
+
324
+ error_page 401 =302 $redirection_url;
325
+ }
326
+
327
+ # Default — proxy to Authelia
328
+ location / {
329
+ proxy_pass http://127.0.0.1:9091;
330
+ }
331
+ }
332
+ `;
333
+
334
+ await writeVhostFile('lamalibre-lamaste-auth', config);
335
+ return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-auth');
336
+ }
337
+
338
+ /**
339
+ * Write the Chisel tunnel WebSocket vhost.
340
+ */
341
+ export async function writeTunnelVhost(domain) {
342
+ const fqdn = `tunnel.${domain}`;
343
+ const config = `server {
344
+ listen 443 ssl;
345
+ server_name ${fqdn};
346
+
347
+ ssl_certificate /etc/letsencrypt/live/${fqdn}/fullchain.pem;
348
+ ssl_certificate_key /etc/letsencrypt/live/${fqdn}/privkey.pem;
349
+
350
+ ssl_protocols TLSv1.2 TLSv1.3;
351
+ ssl_ciphers HIGH:!aNULL:!MD5;
352
+ ssl_prefer_server_ciphers on;
353
+
354
+ proxy_set_header Host $host;
355
+ proxy_set_header X-Real-IP $remote_addr;
356
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
357
+ proxy_set_header X-Forwarded-Proto $scheme;
358
+
359
+ location / {
360
+ proxy_pass http://127.0.0.1:9090;
361
+ proxy_http_version 1.1;
362
+ proxy_set_header Upgrade $http_upgrade;
363
+ proxy_set_header Connection "upgrade";
364
+
365
+ # Long timeout for WebSocket tunnel connections
366
+ proxy_read_timeout 86400s;
367
+ proxy_send_timeout 86400s;
368
+ }
369
+ }
370
+ `;
371
+
372
+ await writeVhostFile('lamalibre-lamaste-tunnel', config);
373
+ return path.join(SITES_AVAILABLE, 'lamalibre-lamaste-tunnel');
374
+ }
375
+
376
+ /**
377
+ * Request headers every tunnel location sends upstream.
378
+ *
379
+ * They are declared inside each `location`, not at server level: nginx
380
+ * inherits `proxy_set_header` from the enclosing level only when a level
381
+ * declares none of its own, and every tunnel location declares some — so
382
+ * server-level headers never reached the app (it saw `Host: 127.0.0.1:<port>`).
383
+ *
384
+ * The client-certificate headers are cleared. The panel trusts
385
+ * `X-SSL-Client-*` from nginx as proof of an mTLS client certificate; a
386
+ * tunnel vhost must never pass client-supplied values through, whatever
387
+ * listens on its port.
388
+ */
389
+ const TUNNEL_PROXY_HEADERS = `
390
+ proxy_set_header Host $host;
391
+ proxy_set_header X-Real-IP $remote_addr;
392
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
393
+ proxy_set_header X-Forwarded-Proto $scheme;
394
+
395
+ # Never forward client-supplied certificate headers (see above)
396
+ proxy_set_header X-SSL-Client-Verify "";
397
+ proxy_set_header X-SSL-Client-DN "";
398
+ proxy_set_header X-SSL-Client-Serial "";
399
+
400
+ # WebSocket support
401
+ proxy_set_header Upgrade $http_upgrade;
402
+ proxy_set_header Connection "upgrade";`;
403
+
404
+ /**
405
+ * Render the per-tunnel `client_max_body_size` directive. Without it nginx
406
+ * applies its built-in 1 MiB limit, which rejects ordinary uploads with 413.
407
+ * Validated here as well as in the tunnel workflow because the value is
408
+ * interpolated into nginx configuration.
409
+ *
410
+ * @param {number | undefined} maxBodySizeMb
411
+ * @returns {string} a directive line, or '' when no limit was given
412
+ */
413
+ function bodySizeDirective(maxBodySizeMb) {
414
+ if (maxBodySizeMb === undefined) return '';
415
+ if (!Number.isInteger(maxBodySizeMb) || maxBodySizeMb < 1 || maxBodySizeMb > 10240) {
416
+ throw new Error(`Invalid maxBodySizeMb for nginx vhost: ${maxBodySizeMb}`);
417
+ }
418
+ return `\n client_max_body_size ${maxBodySizeMb}m;\n`;
419
+ }
420
+
421
+ /**
422
+ * Write a public vhost — no authentication, direct proxy.
423
+ * Used for tunnels with accessMode='public'.
424
+ */
425
+ export async function writePublicVhost(
426
+ subdomain,
427
+ domain,
428
+ port,
429
+ certPath,
430
+ { pathPrefix, maxBodySizeMb, enabled = true } = {},
431
+ ) {
432
+ if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
433
+ throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
434
+ }
435
+
436
+ const fqdn = `${subdomain}.${domain}`;
437
+ const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
438
+ let certDirClean = certDir;
439
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
440
+
441
+ const config = `server {
442
+ listen 443 ssl;
443
+ server_name ${fqdn};
444
+
445
+ ssl_certificate ${certDirClean}/fullchain.pem;
446
+ ssl_certificate_key ${certDirClean}/privkey.pem;
447
+
448
+ ssl_protocols TLSv1.2 TLSv1.3;
449
+ ssl_ciphers HIGH:!aNULL:!MD5;
450
+ ssl_prefer_server_ciphers on;
451
+ ${bodySizeDirective(maxBodySizeMb)}
452
+ location / {
453
+ # A public tunnel has no authenticated user: clear identity headers
454
+ # an app might trust from an authenticated vhost.
455
+ proxy_set_header Remote-User "";
456
+ proxy_set_header Remote-Groups "";
457
+ proxy_set_header Remote-Name "";
458
+ proxy_set_header Remote-Email "";
459
+ ${TUNNEL_PROXY_HEADERS}
460
+
461
+ ${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
462
+ proxy_http_version 1.1;
463
+
464
+ proxy_read_timeout 86400s;
465
+ proxy_send_timeout 86400s;
466
+ }
467
+ }
468
+ `;
469
+
470
+ const name = `lamalibre-lamaste-app-${subdomain}`;
471
+ return safeWriteVhost(name, config, fqdn, { enabled });
472
+ }
473
+
474
+ /**
475
+ * Write an authenticated vhost — Authelia via Gatekeeper, all authenticated users allowed.
476
+ * Used for tunnels with accessMode='authenticated'.
477
+ * Gatekeeper handles Authelia cookie validation and forwards identity headers.
478
+ */
479
+ export async function writeAuthenticatedVhost(
480
+ subdomain,
481
+ domain,
482
+ port,
483
+ certPath,
484
+ { pathPrefix, maxBodySizeMb, enabled = true } = {},
485
+ ) {
486
+ if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
487
+ throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
488
+ }
489
+
490
+ const fqdn = `${subdomain}.${domain}`;
491
+ const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
492
+ let certDirClean = certDir;
493
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
494
+
495
+ const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
496
+ pathPrefix,
497
+ maxBodySizeMb,
498
+ restricted: false,
499
+ });
500
+
501
+ const name = `lamalibre-lamaste-app-${subdomain}`;
502
+ return safeWriteVhost(name, config, fqdn, { enabled });
503
+ }
504
+
505
+ /**
506
+ * Write a restricted vhost — Authelia via Gatekeeper, only granted users/groups allowed.
507
+ * Used for tunnels with accessMode='restricted'.
508
+ * On 403, Gatekeeper returns the inline access-request HTML page.
509
+ */
510
+ export async function writeRestrictedVhost(
511
+ subdomain,
512
+ domain,
513
+ port,
514
+ certPath,
515
+ { pathPrefix, maxBodySizeMb, enabled = true } = {},
516
+ ) {
517
+ if (pathPrefix && !/^[a-z0-9][a-z0-9._-]*$/.test(pathPrefix)) {
518
+ throw new Error(`Invalid pathPrefix for nginx vhost: ${pathPrefix}`);
519
+ }
520
+
521
+ const fqdn = `${subdomain}.${domain}`;
522
+ const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
523
+ let certDirClean = certDir;
524
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
525
+
526
+ const config = buildGatekeeperVhost(fqdn, certDirClean, port, domain, {
527
+ pathPrefix,
528
+ maxBodySizeMb,
529
+ restricted: true,
530
+ });
531
+
532
+ const name = `lamalibre-lamaste-app-${subdomain}`;
533
+ return safeWriteVhost(name, config, fqdn, { enabled });
534
+ }
535
+
536
+ /**
537
+ * Build a Gatekeeper-backed nginx vhost config string.
538
+ * Both 'authenticated' and 'restricted' modes use this — the difference
539
+ * is handled server-side by Gatekeeper based on the tunnel's accessMode.
540
+ * For restricted mode, the 403 body from Gatekeeper is served inline.
541
+ */
542
+ function buildGatekeeperVhost(
543
+ fqdn,
544
+ certDirClean,
545
+ port,
546
+ domain,
547
+ { pathPrefix, maxBodySizeMb, restricted } = {},
548
+ ) {
549
+ return `server {
550
+ listen 443 ssl;
551
+ server_name ${fqdn};
552
+
553
+ ssl_certificate ${certDirClean}/fullchain.pem;
554
+ ssl_certificate_key ${certDirClean}/privkey.pem;
555
+
556
+ ssl_protocols TLSv1.2 TLSv1.3;
557
+ ssl_ciphers HIGH:!aNULL:!MD5;
558
+ ssl_prefer_server_ciphers on;
559
+ ${bodySizeDirective(maxBodySizeMb)}
560
+ # Gatekeeper authorization subrequest (handles Authelia validation + grant check)
561
+ location /internal/lamaste/authz {
562
+ internal;
563
+
564
+ proxy_pass http://127.0.0.1:9294/authz/check;
565
+ proxy_pass_request_body off;
566
+
567
+ proxy_set_header Content-Length "";
568
+ proxy_set_header Connection "";
569
+ proxy_set_header X-Original-Method $request_method;
570
+ proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
571
+ proxy_set_header X-Forwarded-For $remote_addr;
572
+ proxy_set_header Host $http_host;
573
+ proxy_set_header Cookie $http_cookie;
574
+
575
+ proxy_http_version 1.1;
576
+ proxy_buffers 4 32k;
577
+ proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
578
+ }
579
+
580
+ location / {
581
+ auth_request /internal/lamaste/authz;
582
+ auth_request_set $user $upstream_http_remote_user;
583
+ auth_request_set $groups $upstream_http_remote_groups;
584
+ auth_request_set $name $upstream_http_remote_name;
585
+ auth_request_set $email $upstream_http_remote_email;
586
+
587
+ proxy_set_header Remote-User $user;
588
+ proxy_set_header Remote-Groups $groups;
589
+ proxy_set_header Remote-Name $name;
590
+ proxy_set_header Remote-Email $email;
591
+ ${TUNNEL_PROXY_HEADERS}
592
+
593
+ ${pathPrefix ? `rewrite ^/?(.*)$ /${pathPrefix}/$1 break;\n ` : ''}proxy_pass http://127.0.0.1:${port};
594
+ proxy_http_version 1.1;
595
+
596
+ proxy_read_timeout 86400s;
597
+ proxy_send_timeout 86400s;
598
+
599
+ # Authentication failure -> redirect to Authelia login
600
+ error_page 401 =302 https://auth.${domain}/?rd=$scheme://$http_host$request_uri;
601
+
602
+ ${
603
+ restricted
604
+ ? `# Authorization failure -> Gatekeeper serves inline access-request page
605
+ error_page 403 = /internal/lamaste/authz;`
606
+ : ''
607
+ }
608
+ }
609
+ }
610
+ `;
611
+ }
612
+
613
+ /** True when `name` is linked into sites-enabled. The directory is world-readable. */
614
+ async function isSiteEnabled(name) {
615
+ try {
616
+ await lstat(path.join(SITES_ENABLED, name));
617
+ return true;
618
+ } catch (err) {
619
+ if (err.code === 'ENOENT') return false;
620
+ throw err;
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Shared safe-write logic: backup -> write -> link state -> test -> reload,
626
+ * with rollback.
627
+ *
628
+ * `enabled` decides whether the site is linked into sites-enabled after the
629
+ * write. On any failure the previous file (or its absence) and the previous
630
+ * link state are restored, so rewriting a disabled tunnel's vhost can never
631
+ * publish it — not even through a failed write.
632
+ *
633
+ * @param {string} name
634
+ * @param {string} config
635
+ * @param {string} fqdn
636
+ * @param {{ enabled?: boolean }} [opts]
637
+ */
638
+ async function safeWriteVhost(name, config, fqdn, { enabled = true } = {}) {
639
+ const availablePath = path.join(SITES_AVAILABLE, name);
640
+
641
+ const existed = await siteExists(name);
642
+ const wasEnabled = await isSiteEnabled(name);
643
+ if (existed) {
644
+ await siteAction('backup', name);
645
+ }
646
+
647
+ const restore = async () => {
648
+ if (existed) {
649
+ await siteAction('restore', name).catch(() => {});
650
+ } else {
651
+ await siteAction('remove', name).catch(() => {});
652
+ }
653
+ if (existed && wasEnabled) {
654
+ await enableSite(name).catch(() => {});
655
+ } else {
656
+ await disableSite(name).catch(() => {});
657
+ }
658
+ };
659
+
660
+ try {
661
+ await writeVhostFile(name, config);
662
+ if (enabled) {
663
+ await enableSite(name);
664
+ } else {
665
+ await disableSite(name);
666
+ }
667
+
668
+ const result = await testConfig();
669
+ if (!result.valid) {
670
+ await restore();
671
+ throw Object.assign(
672
+ new Error(`Nginx config test failed after writing vhost for ${fqdn}: ${result.error}`),
673
+ { restored: true },
674
+ );
675
+ }
676
+
677
+ await reload();
678
+
679
+ if (existed) {
680
+ await siteAction('discard-backup', name).catch(() => {});
681
+ }
682
+
683
+ return availablePath;
684
+ } catch (err) {
685
+ if (!err.restored) await restore();
686
+ throw err;
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Write a static site vhost with optional Authelia forward auth.
692
+ *
693
+ * Written through safeWriteVhost (backup, write, enable, test, reload; on
694
+ * failure the previous file and link state are restored).
695
+ *
696
+ * @param {object} site - The site object from sites.json
697
+ * @param {string} site.id - Site UUID
698
+ * @param {string} site.fqdn - Full domain (e.g., "blog.example.com")
699
+ * @param {boolean} site.spaMode - If true, try_files falls back to /index.html
700
+ * @param {boolean} site.autheliaProtected - If true, add Authelia forward auth
701
+ * @param {string} site.rootPath - Directory root (e.g., /var/www/lamaste/{id}/)
702
+ * @param {string} certDir - Certificate directory path
703
+ * @param {string} [domain] - Base domain (needed for Authelia redirect URL)
704
+ */
705
+ export async function writeStaticSiteVhost(site, certDir, domain) {
706
+ let certDirClean = certDir;
707
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
708
+ const aliasBlock = buildAliasRedirectBlock(site, certDirClean);
709
+ const tryFiles = site.spaMode ? 'try_files $uri $uri/ /index.html' : 'try_files $uri $uri/ =404';
710
+
711
+ let autheliaBlock = '';
712
+ let locationAuthDirectives = '';
713
+
714
+ if (site.autheliaProtected && domain) {
715
+ autheliaBlock = `
716
+ # Authelia forward authentication (AuthRequest implementation for nginx)
717
+ location /internal/authelia/authz {
718
+ internal;
719
+
720
+ proxy_pass http://127.0.0.1:9091/api/authz/auth-request;
721
+ proxy_pass_request_body off;
722
+
723
+ proxy_set_header Content-Length "";
724
+ proxy_set_header Connection "";
725
+ proxy_set_header X-Original-Method $request_method;
726
+ proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
727
+ proxy_set_header X-Forwarded-For $remote_addr;
728
+
729
+ proxy_http_version 1.1;
730
+ proxy_buffers 4 32k;
731
+ proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
732
+ }
733
+ `;
734
+ locationAuthDirectives = `
735
+ # Clear client-supplied identity headers (Authelia re-injects on success)
736
+ proxy_set_header Remote-User "";
737
+ proxy_set_header Remote-Groups "";
738
+ proxy_set_header Remote-Name "";
739
+ proxy_set_header Remote-Email "";
740
+
741
+ auth_request /internal/authelia/authz;
742
+ auth_request_set $user $upstream_http_remote_user;
743
+ auth_request_set $groups $upstream_http_remote_groups;
744
+ # $name and $email captured for future proxy use (no proxy_pass in static sites)
745
+ auth_request_set $name $upstream_http_remote_name;
746
+ auth_request_set $email $upstream_http_remote_email;
747
+ auth_request_set $redirection_url $upstream_http_location;`;
748
+ }
749
+
750
+ const config = `server {
751
+ listen 443 ssl;
752
+ server_name ${site.fqdn};
753
+
754
+ ssl_certificate ${certDirClean}/fullchain.pem;
755
+ ssl_certificate_key ${certDirClean}/privkey.pem;
756
+
757
+ ssl_protocols TLSv1.2 TLSv1.3;
758
+ ssl_ciphers HIGH:!aNULL:!MD5;
759
+ ssl_prefer_server_ciphers on;
760
+
761
+ root ${site.rootPath};
762
+ index index.html;
763
+
764
+ # Security headers
765
+ add_header X-Frame-Options SAMEORIGIN always;
766
+ add_header X-Content-Type-Options nosniff always;
767
+ ${autheliaBlock}
768
+ location / {${locationAuthDirectives}
769
+ ${tryFiles};
770
+ }
771
+ ${
772
+ site.autheliaProtected && domain
773
+ ? `
774
+ # Redirect unauthenticated requests to Authelia login portal
775
+ error_page 401 =302 $redirection_url;
776
+ `
777
+ : ''
778
+ }
779
+ }
780
+ ${aliasBlock}`;
781
+
782
+ const name = `lamalibre-lamaste-site-${site.id}`;
783
+ return safeWriteVhost(name, config, site.fqdn, { enabled: true });
784
+ }
785
+
786
+ /**
787
+ * Server block answering a site's aliases with a permanent redirect to the
788
+ * primary hostname, on the same certificate (its lineage covers every alias).
789
+ * Hostnames are interpolated into nginx configuration, so they are validated
790
+ * here as well as in the site workflow.
791
+ *
792
+ * @param {{ fqdn: string, aliases?: string[] }} site
793
+ * @param {string} certDirClean
794
+ * @returns {string} the server block, or '' when the site has no aliases
795
+ */
796
+ function buildAliasRedirectBlock(site, certDirClean) {
797
+ const aliases = site.aliases ?? [];
798
+ if (aliases.length === 0) return '';
799
+ const hostname = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
800
+ for (const name of [site.fqdn, ...aliases]) {
801
+ if (!hostname.test(name)) {
802
+ throw new Error(`Invalid hostname for nginx vhost: ${name}`);
803
+ }
804
+ }
805
+ return `
806
+ server {
807
+ listen 443 ssl;
808
+ server_name ${aliases.join(' ')};
809
+
810
+ ssl_certificate ${certDirClean}/fullchain.pem;
811
+ ssl_certificate_key ${certDirClean}/privkey.pem;
812
+
813
+ ssl_protocols TLSv1.2 TLSv1.3;
814
+ ssl_ciphers HIGH:!aNULL:!MD5;
815
+ ssl_prefer_server_ciphers on;
816
+
817
+ return 301 https://${site.fqdn}$request_uri;
818
+ }
819
+ `;
820
+ }
821
+
822
+ /**
823
+ * Remove a static site vhost from sites-available and sites-enabled, then test and reload nginx.
824
+ * Idempotent: if files don't exist, proceeds silently.
825
+ *
826
+ * @param {string} siteId - The site UUID
827
+ */
828
+ export async function removeStaticSiteVhost(siteId) {
829
+ const name = `lamalibre-lamaste-site-${siteId}`;
830
+ await siteAction('remove', name);
831
+
832
+ const result = await testConfig();
833
+ if (!result.valid) {
834
+ throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
835
+ }
836
+
837
+ await reload();
838
+ }
839
+
840
+ /**
841
+ * Remove an app vhost from sites-available and sites-enabled, then test and reload nginx.
842
+ * Idempotent: if files don't exist, proceeds silently.
843
+ *
844
+ * @param {string} subdomain - The subdomain name
845
+ */
846
+ export async function removeAppVhost(subdomain) {
847
+ const name = `lamalibre-lamaste-app-${subdomain}`;
848
+ await siteAction('remove', name);
849
+
850
+ const result = await testConfig();
851
+ if (!result.valid) {
852
+ throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
853
+ }
854
+
855
+ await reload();
856
+ }
857
+
858
+ /**
859
+ * Test the nginx configuration. Returns { valid: true } or { valid: false, error }.
860
+ * Does NOT throw on invalid config.
861
+ */
862
+ export async function testConfig() {
863
+ try {
864
+ await execa('sudo', ['nginx', '-t']);
865
+ return { valid: true };
866
+ } catch (err) {
867
+ return { valid: false, error: err.stderr || err.message };
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Reload the nginx service.
873
+ */
874
+ export async function reload() {
875
+ try {
876
+ await execa('sudo', ['systemctl', 'reload', 'nginx']);
877
+ return { reloaded: true };
878
+ } catch (err) {
879
+ throw new Error(`Failed to reload nginx: ${err.stderr || err.message}`);
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Enable a site by creating a symlink in sites-enabled.
885
+ */
886
+ export async function enableSite(name) {
887
+ await siteAction('enable', name);
888
+ }
889
+
890
+ /**
891
+ * Disable a site by removing its symlink from sites-enabled.
892
+ */
893
+ export async function disableSite(name) {
894
+ await siteAction('disable', name);
895
+ }
896
+
897
+ /**
898
+ * Enable an app tunnel vhost (restore symlink), test and reload nginx.
899
+ *
900
+ * @param {string} subdomain - The subdomain name
901
+ */
902
+ export async function enableAppVhost(subdomain) {
903
+ const name = `lamalibre-lamaste-app-${subdomain}`;
904
+ await enableSite(name);
905
+
906
+ const result = await testConfig();
907
+ if (!result.valid) {
908
+ // Rollback: remove the symlink we just created
909
+ await disableSite(name);
910
+ throw new Error(`Nginx config test failed after enabling vhost ${name}: ${result.error}`);
911
+ }
912
+
913
+ await reload();
914
+ }
915
+
916
+ /**
917
+ * Disable an app tunnel vhost (remove symlink only, keep config), test and reload nginx.
918
+ *
919
+ * @param {string} subdomain - The subdomain name
920
+ */
921
+ export async function disableAppVhost(subdomain) {
922
+ const name = `lamalibre-lamaste-app-${subdomain}`;
923
+ await disableSite(name);
924
+
925
+ const result = await testConfig();
926
+ if (!result.valid) {
927
+ // Rollback: re-enable the site
928
+ await enableSite(name);
929
+ throw new Error(`Nginx config test failed after disabling vhost ${name}: ${result.error}`);
930
+ }
931
+
932
+ await reload();
933
+ }
934
+
935
+ /**
936
+ * Disable the IP-based panel vhost (used when 2FA is enabled).
937
+ * Tests nginx config before reloading; rolls back on failure.
938
+ */
939
+ export async function disableIpVhost() {
940
+ await disableSite('lamalibre-lamaste-panel-ip');
941
+ const result = await testConfig();
942
+ if (!result.valid) {
943
+ await enableSite('lamalibre-lamaste-panel-ip');
944
+ throw new Error(`nginx test failed after disabling IP vhost: ${result.error}`);
945
+ }
946
+ await reload();
947
+ }
948
+
949
+ /**
950
+ * Re-enable the IP-based panel vhost (used when 2FA is disabled).
951
+ * Tests nginx config before reloading; rolls back on failure.
952
+ */
953
+ export async function enableIpVhost() {
954
+ await enableSite('lamalibre-lamaste-panel-ip');
955
+ const result = await testConfig();
956
+ if (!result.valid) {
957
+ await disableSite('lamalibre-lamaste-panel-ip');
958
+ throw new Error(`nginx test failed after enabling IP vhost: ${result.error}`);
959
+ }
960
+ await reload();
961
+ }
962
+
963
+ /**
964
+ * Write an agent panel vhost with mTLS authentication (not Authelia).
965
+ *
966
+ * Written through safeWriteVhost (backup, test, rollback).
967
+ *
968
+ * @param {string} subdomain - The subdomain name (e.g., "agent-my-agent")
969
+ * @param {string} domain - The base domain (e.g., "example.com")
970
+ * @param {number} port - The local port to proxy to
971
+ * @param {string} [certPath] - Optional cert directory path override (e.g. for wildcard certs)
972
+ */
973
+ export async function writeAgentPanelVhost(
974
+ subdomain,
975
+ domain,
976
+ port,
977
+ certPath,
978
+ { enabled = true } = {},
979
+ ) {
980
+ const fqdn = `${subdomain}.${domain}`;
981
+ const certDir = certPath || `/etc/letsencrypt/live/${fqdn}`;
982
+ let certDirClean = certDir;
983
+ while (certDirClean.endsWith('/')) certDirClean = certDirClean.slice(0, -1);
984
+
985
+ const config = `server {
986
+ listen 443 ssl;
987
+ server_name ${fqdn};
988
+
989
+ ssl_certificate ${certDirClean}/fullchain.pem;
990
+ ssl_certificate_key ${certDirClean}/privkey.pem;
991
+
992
+ # mTLS — same CA as the admin panel
993
+ include /etc/nginx/snippets/lamalibre-lamaste-mtls.conf;
994
+
995
+ ssl_protocols TLSv1.2 TLSv1.3;
996
+ ssl_ciphers HIGH:!aNULL:!MD5;
997
+ ssl_prefer_server_ciphers on;
998
+
999
+ # Reject requests without valid client certificate
1000
+ location / {
1001
+ if ($ssl_client_verify != SUCCESS) {
1002
+ return 496;
1003
+ }
1004
+
1005
+ proxy_pass http://127.0.0.1:${port};
1006
+ proxy_http_version 1.1;
1007
+
1008
+ # Client cert headers — set from nginx TLS variables, never passed through from client
1009
+ proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
1010
+ proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
1011
+ proxy_set_header X-SSL-Client-Serial $ssl_client_serial;
1012
+
1013
+ # Strip Authelia identity headers — not trusted on agent panel (mTLS only)
1014
+ proxy_set_header Remote-User "";
1015
+ proxy_set_header Remote-Groups "";
1016
+ proxy_set_header Remote-Name "";
1017
+ proxy_set_header Remote-Email "";
1018
+
1019
+ # Standard proxy headers
1020
+ proxy_set_header Host $host;
1021
+ proxy_set_header X-Real-IP $remote_addr;
1022
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1023
+ proxy_set_header X-Forwarded-Proto $scheme;
1024
+
1025
+ # WebSocket support
1026
+ proxy_set_header Upgrade $http_upgrade;
1027
+ proxy_set_header Connection "upgrade";
1028
+ proxy_read_timeout 86400s;
1029
+ proxy_send_timeout 86400s;
1030
+ }
1031
+ }
1032
+ `;
1033
+
1034
+ const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1035
+ return safeWriteVhost(name, config, fqdn, { enabled });
1036
+ }
1037
+
1038
+ /**
1039
+ * Remove an agent panel vhost from sites-available and sites-enabled, then test and reload nginx.
1040
+ * Idempotent: if files don't exist, proceeds silently.
1041
+ *
1042
+ * @param {string} subdomain - The subdomain name (e.g., "agent-my-agent")
1043
+ */
1044
+ export async function removeAgentPanelVhost(subdomain) {
1045
+ const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1046
+ await siteAction('remove', name);
1047
+
1048
+ const result = await testConfig();
1049
+ if (!result.valid) {
1050
+ throw new Error(`Nginx config test failed after removing vhost ${name}: ${result.error}`);
1051
+ }
1052
+
1053
+ await reload();
1054
+ }
1055
+
1056
+ /**
1057
+ * Enable an agent panel vhost (restore symlink), test and reload nginx.
1058
+ *
1059
+ * @param {string} subdomain - The subdomain name
1060
+ */
1061
+ export async function enableAgentPanelVhost(subdomain) {
1062
+ const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1063
+ await enableSite(name);
1064
+
1065
+ const result = await testConfig();
1066
+ if (!result.valid) {
1067
+ await disableSite(name);
1068
+ throw new Error(`Nginx config test failed after enabling vhost ${name}: ${result.error}`);
1069
+ }
1070
+
1071
+ await reload();
1072
+ }
1073
+
1074
+ /**
1075
+ * Disable an agent panel vhost (remove symlink only, keep config), test and reload nginx.
1076
+ *
1077
+ * @param {string} subdomain - The subdomain name
1078
+ */
1079
+ export async function disableAgentPanelVhost(subdomain) {
1080
+ const name = `lamalibre-lamaste-agent-panel-${subdomain}`;
1081
+ await disableSite(name);
1082
+
1083
+ const result = await testConfig();
1084
+ if (!result.valid) {
1085
+ await enableSite(name);
1086
+ throw new Error(`Nginx config test failed after disabling vhost ${name}: ${result.error}`);
1087
+ }
1088
+
1089
+ await reload();
1090
+ }