@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,529 @@
1
+ # State Management Architecture
2
+
3
+ > Lamaste stores all state in JSON and YAML files on disk — no database, no external services. State changes use atomic writes to prevent corruption from crashes or power loss.
4
+
5
+ ## In Plain English
6
+
7
+ Lamaste does not use a database. All configuration and state is stored in plain files on the VPS:
8
+
9
+ - `panel.json` holds the system configuration (IP address, domain, onboarding progress)
10
+ - `tunnels.json` holds the list of configured tunnels
11
+ - `sites.json` holds the list of configured static sites
12
+ - `invitations.json` holds pending user invitations
13
+ - `users.yml` holds the Authelia user database (usernames, password hashes, TOTP secrets)
14
+
15
+ These files are small (a few kilobytes each) and are read/written atomically — meaning the system writes to a temporary file first, then renames it into place. If the server crashes mid-write, the old file is preserved intact. You never end up with a half-written, corrupted file.
16
+
17
+ This approach works because Lamaste manages a small amount of state (one admin, a handful of tunnels and users). A database would add RAM overhead and operational complexity for no benefit at this scale.
18
+
19
+ ## Overview
20
+
21
+ ```
22
+ /etc/lamalibre/lamaste/
23
+ ├── panel.json ← Central config (IP, domain, onboarding state)
24
+ ├── tunnels.json ← Tunnel definitions
25
+ ├── sites.json ← Static site definitions
26
+ ├── chisel-credentials.json ← Per-agent Chisel credentials
27
+ ├── chisel-users ← Chisel authfile, rendered from credentials + tunnels.json (0640, group lamaste-chisel)
28
+ ├── chisel-sentinel ← Password of the no-grants sentinel Chisel user
29
+ ├── invitations.json ← Pending user invitations
30
+ ├── plugins.json ← Plugin registry (installed plugins, enabled state)
31
+ ├── ticket-scopes.json ← Ticket scope registry (scopes, instances, assignments)
32
+ ├── tickets.json ← Ticket and session store
33
+ ├── storage-config.json ← Storage server registry and plugin bindings
34
+ ├── storage-master.key ← 32-byte master key for storage credential encryption
35
+ ├── groups.json ← Lamaste groups (separate from Authelia groups)
36
+ ├── access-grants.json ← Generic access grants (principal → resource)
37
+ ├── gatekeeper.json ← Gatekeeper settings (cache TTL, admin contact, logging)
38
+ ├── access-request-log.json ← Optional denied access log (for admin review)
39
+ └── pki/
40
+ ├── ca.key ← CA private key
41
+ ├── ca.crt ← CA certificate
42
+ ├── client.key ← Client private key
43
+ ├── client.crt ← Client certificate
44
+ ├── client.p12 ← PKCS12 bundle for browser import
45
+ ├── .p12-password ← PKCS12 password
46
+ ├── self-signed.pem ← Self-signed TLS cert for IP vhost
47
+ ├── self-signed-key.pem ← Self-signed TLS key for IP vhost
48
+ ├── revoked.json ← Revoked certificate serial numbers
49
+ ├── enrollment-tokens.json ← One-time enrollment tokens
50
+ └── agents/ ← Agent certificate storage
51
+ ├── registry.json ← Metadata for all agent certs
52
+ └── <label>/ ← Per-agent directory
53
+ ├── client.key ← Agent private key
54
+ ├── client.crt ← Agent certificate
55
+ └── client.p12 ← Agent PKCS12 bundle
56
+
57
+ /etc/authelia/
58
+ ├── configuration.yml ← Authelia main config
59
+ ├── users.yml ← User database (usernames, bcrypt hashes, groups)
60
+ ├── .secrets.json ← JWT + session + storage encryption secrets
61
+ ├── db.sqlite3 ← Authelia session/TOTP storage
62
+ └── notifications.txt ← Notification log (filesystem notifier)
63
+
64
+ /var/www/lamaste/
65
+ └── <site-uuid>/ ← Static site file roots (one per site)
66
+ ├── index.html
67
+ └── ...
68
+ ```
69
+
70
+ ## Atomic Writes Pattern
71
+
72
+ All JSON state files use the same atomic write pattern, implemented in `packages/lamaste-serverd/src/lib/state.js` and `packages/lamaste-serverd/src/lib/config.js`:
73
+
74
+ ```
75
+ 1. Serialize data to JSON string
76
+ 2. Write to <path>.tmp in the same directory
77
+ 3. Open the temp file and call fd.sync() (fsync)
78
+ 4. Rename temp file to final path
79
+ ```
80
+
81
+ In code:
82
+
83
+ ```javascript
84
+ export async function writeTunnels(tunnels) {
85
+ const filePath = tunnelsPath();
86
+ const tmpPath = `${filePath}.tmp`;
87
+
88
+ const content = JSON.stringify(tunnels, null, 2) + '\n';
89
+ await writeFile(tmpPath, content, 'utf-8');
90
+
91
+ // fsync: flush data to disk before rename
92
+ const fd = await open(tmpPath, 'r');
93
+ await fd.sync();
94
+ await fd.close();
95
+
96
+ await rename(tmpPath, filePath);
97
+ }
98
+ ```
99
+
100
+ **Why this works:**
101
+
102
+ - `rename()` is atomic on POSIX filesystems — it either completes fully or not at all
103
+ - `fd.sync()` ensures the data is flushed to the physical disk before the rename
104
+ - If the process crashes during `writeFile`, only the `.tmp` file is affected — the original state file is untouched
105
+ - If the process crashes after `fd.sync()` but before `rename()`, the `.tmp` file has valid data and the original is untouched
106
+ - The worst case is losing the most recent write, which preserves the previous consistent state
107
+
108
+ **Why the temp file is in the same directory:**
109
+
110
+ - `rename()` is only atomic within the same filesystem
111
+ - `/etc/lamalibre/lamaste/tunnels.json.tmp` and `/etc/lamalibre/lamaste/tunnels.json` are on the same filesystem, so the rename is guaranteed to be atomic
112
+
113
+ ## Config Schema (`panel.json`)
114
+
115
+ The central configuration file, validated by Zod on every load and update.
116
+
117
+ **Location:** `/etc/lamalibre/lamaste/panel.json` (production) or `dev/panel.json` (development)
118
+
119
+ **Schema:**
120
+
121
+ ```json
122
+ {
123
+ "ip": "203.0.113.42",
124
+ "domain": "example.com",
125
+ "email": "admin@example.com",
126
+ "dataDir": "/etc/lamalibre/lamaste",
127
+ "staticDir": "/opt/lamalibre/lamaste/lamaste-server-ui/dist",
128
+ "maxSiteSize": 524288000,
129
+ "onboarding": {
130
+ "status": "COMPLETED"
131
+ }
132
+ }
133
+ ```
134
+
135
+ **Field definitions:**
136
+
137
+ | Field | Type | Required | Description |
138
+ | ------------------- | ------------------------ | -------- | ----------------------------------------------------------------------- |
139
+ | `ip` | `string` | Yes | VPS public IP address (detected during install) |
140
+ | `domain` | `string \| null` | Yes | Base domain (set during onboarding, null before) |
141
+ | `email` | `string (email) \| null` | Yes | Admin email for Let's Encrypt (set during onboarding) |
142
+ | `dataDir` | `string` | Yes | Path to state directory (`/etc/lamalibre/lamaste`) |
143
+ | `serverId` | `string (uuid)` | No | Auto-generated UUIDv4, bucket prefix for multi-server storage isolation |
144
+ | `staticDir` | `string` | No | Path to lamaste-server-ui dist (overrides default resolution) |
145
+ | `maxSiteSize` | `number` | No | Maximum static site size in bytes (default: 500 MB) |
146
+ | `adminAuthMode` | `enum` | No | `"p12"` (default) or `"hardware-bound"` |
147
+ | `panel2fa` | `object` | No | Built-in TOTP 2FA configuration |
148
+ | `sessionSecret` | `string \| null` | No | HMAC key for signed session cookies (default: null) |
149
+ | `onboarding.status` | `enum` | Yes | Current onboarding state |
150
+
151
+ **Zod validation:**
152
+
153
+ ```javascript
154
+ const ConfigSchema = z.object({
155
+ ip: z.string().min(1),
156
+ domain: z.string().nullable(),
157
+ email: z.string().email().nullable(),
158
+ dataDir: z.string().min(1),
159
+ serverId: z.string().uuid().optional(),
160
+ staticDir: z.string().optional(),
161
+ maxSiteSize: z
162
+ .number()
163
+ .optional()
164
+ .default(500 * 1024 * 1024),
165
+ adminAuthMode: z.enum(['p12', 'hardware-bound']).optional().default('p12'),
166
+ panel2fa: z
167
+ .object({
168
+ enabled: z.boolean(),
169
+ secret: z.string().nullable(),
170
+ setupComplete: z.boolean(),
171
+ })
172
+ .optional()
173
+ .default({ enabled: false, secret: null, setupComplete: false }),
174
+ sessionSecret: z.string().nullable().optional().default(null),
175
+ onboarding: z.object({
176
+ status: z.enum(['FRESH', 'DOMAIN_SET', 'DNS_READY', 'PROVISIONING', 'COMPLETED']),
177
+ }),
178
+ });
179
+ ```
180
+
181
+ Validation runs:
182
+
183
+ - At server startup (`loadConfig()`)
184
+ - Before every config update (`updateConfig()`)
185
+ - Invalid data throws a Zod error, caught by the error handler and returned as a 400
186
+
187
+ **Config loading chain:**
188
+
189
+ ```
190
+ 1. Check LAMALIBRE_LAMASTE_CONFIG environment variable → use if set
191
+ 2. Check NODE_ENV:
192
+ - "development" (or unset) → dev/panel.json relative to package root
193
+ - "production" → /etc/lamalibre/lamaste/panel.json
194
+ 3. Read and parse JSON
195
+ 4. Validate with Zod schema
196
+ 5. Cache in module-level variable
197
+ ```
198
+
199
+ **Config updates (`updateConfig`):**
200
+
201
+ ```javascript
202
+ export async function updateConfig(patch) {
203
+ // Deep clone current config
204
+ const merged = structuredClone(config);
205
+
206
+ // Merge patch (onboarding and panel2fa are merged as sub-objects, others replaced)
207
+ for (const key of Object.keys(patch)) {
208
+ if (key === 'onboarding' && typeof patch.onboarding === 'object' && patch.onboarding !== null) {
209
+ merged.onboarding = { ...merged.onboarding, ...patch.onboarding };
210
+ } else if (
211
+ key === 'panel2fa' &&
212
+ typeof patch.panel2fa === 'object' &&
213
+ patch.panel2fa !== null
214
+ ) {
215
+ merged.panel2fa = { ...merged.panel2fa, ...patch.panel2fa };
216
+ } else {
217
+ merged[key] = patch[key];
218
+ }
219
+ }
220
+
221
+ // Re-validate
222
+ const validated = ConfigSchema.parse(merged);
223
+
224
+ // Atomic write
225
+ const tmpPath = `${configPath}.tmp`;
226
+ await writeFile(tmpPath, JSON.stringify(validated, null, 2) + '\n', {
227
+ encoding: 'utf-8',
228
+ mode: 0o600,
229
+ });
230
+
231
+ // fsync: flush data to disk before rename
232
+ const fd = await open(tmpPath, 'r');
233
+ await fd.sync();
234
+ await fd.close();
235
+
236
+ await rename(tmpPath, configPath);
237
+
238
+ // Update cache
239
+ config = validated;
240
+ return structuredClone(config);
241
+ }
242
+ ```
243
+
244
+ The `onboarding` and `panel2fa` fields get special merge treatment — updating `{ onboarding: { status: 'COMPLETED' } }` or `{ panel2fa: { enabled: true } }` preserves other sub-fields rather than replacing the entire object.
245
+
246
+ All reads via `getConfig()` return a `structuredClone`, preventing callers from accidentally mutating the cached config.
247
+
248
+ ## Onboarding State Machine
249
+
250
+ The onboarding process follows a linear state progression stored in `panel.json`:
251
+
252
+ ```
253
+ FRESH ──────────► DOMAIN_SET ──────────► DNS_READY
254
+ │ │ │
255
+ │ POST /domain │ POST /verify-dns │ POST /provision
256
+ │ Sets domain+email │ Confirms DNS ok │ Starts provisioning
257
+ │ │ │
258
+ │ │ ▼
259
+ │ │ PROVISIONING
260
+ │ │ │
261
+ │ │ │ Background task completes
262
+ │ │ │
263
+ │ │ ▼
264
+ │ │ COMPLETED
265
+ │ │ │
266
+ └────────────────────┘──────────────────────┘
267
+ │
268
+ Onboarding routes → 410 Gone
269
+ Management routes → accessible
270
+ ```
271
+
272
+ State transitions are enforced at multiple levels:
273
+
274
+ 1. **Route guards** — `onboardingOnly()` returns 410 for any onboarding route when status is `COMPLETED`; `managementOnly()` returns 503 when status is not `COMPLETED`
275
+ 2. **Provisioning endpoint** — validates that status is `DNS_READY` or `PROVISIONING` before starting
276
+ 3. **Domain endpoint** — validates that status is `FRESH`
277
+ 4. **DNS endpoint** — validates that status is `DOMAIN_SET`
278
+
279
+ Once provisioning completes and status is set to `COMPLETED`, onboarding routes are permanently locked out (410 Gone). The only way to re-run onboarding would be to manually edit `panel.json`, which is an intentional safety measure.
280
+
281
+ ## Tunnel State (`tunnels.json`)
282
+
283
+ Stored at `/etc/lamalibre/lamaste/tunnels.json`. An array of tunnel objects.
284
+
285
+ **Example:**
286
+
287
+ ```json
288
+ [
289
+ {
290
+ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
291
+ "subdomain": "myapp",
292
+ "fqdn": "myapp.example.com",
293
+ "port": 3000,
294
+ "description": "My Web App",
295
+ "type": "app",
296
+ "accessMode": "restricted",
297
+ "agentLabel": "laptop",
298
+ "maxBodySizeMb": 10,
299
+ "enabled": true,
300
+ "createdAt": "2024-03-14T10:30:00.000Z"
301
+ },
302
+ {
303
+ "id": "f9e8d7c6-b5a4-3210-fedc-ba0987654321",
304
+ "subdomain": "api",
305
+ "fqdn": "api.example.com",
306
+ "port": 8080,
307
+ "description": "API Server",
308
+ "type": "app",
309
+ "accessMode": "authenticated",
310
+ "agentLabel": "build-server",
311
+ "maxBodySizeMb": 100,
312
+ "enabled": true,
313
+ "createdAt": "2024-03-14T11:00:00.000Z"
314
+ }
315
+ ]
316
+ ```
317
+
318
+ **Operations:**
319
+
320
+ - `readTunnels()` — returns the array, or `[]` if the file does not exist
321
+ - `writeTunnels(tunnels)` — atomic write of the full array
322
+
323
+ Read returns `[]` for missing files (ENOENT), enabling the system to start with no tunnels defined.
324
+
325
+ **Tunnel lifecycle:**
326
+
327
+ 1. `POST /api/tunnels` — issues TLS cert, writes nginx vhost, adds the tunnel object (with its `agentLabel`)
328
+ 2. `PATCH /api/tunnels/:id` — moves the tunnel to another agent, re-renders its vhost (access mode, body limit), or enables/disables it
329
+ 3. `DELETE /api/tunnels/:id` — removes nginx vhost, removes the tunnel object
330
+ 4. On all: the full array is written atomically, **then** Chisel is synced — `chisel-users` is re-rendered from the new state (each agent may bind only its enabled tunnels' ports). Chisel reloads the file itself; it is restarted only when a grant or password was withdrawn. The authfile is derived state; `tunnels.json` and `chisel-credentials.json` are the sources of truth
331
+ 5. `DELETE /api/certs/agent/:label` (revocation) — the agent's app and plugin tunnels lose their `agentLabel` (unassigned), and its `agent-<label>` panel tunnel is removed
332
+ 6. At startup (background, fail closed): an ownerless `agent-<label>` panel tunnel is bound to the agent it names when that agent is active; other ownerless tunnels (from versions before tunnel ownership) are bound to the sole active agent, or left unassigned when there are several. Then the pinned Chisel binary, the unit and the authfile are re-established — and Chisel is stopped if they cannot be
333
+
334
+ Every one of these workflows runs under the single tunnel lock (`withTunnelLock` in `@lamalibre/lamaste@4.0.0/server`), with its validation inside the lock.
335
+
336
+ ## Site State (`sites.json`)
337
+
338
+ Stored at `/etc/lamalibre/lamaste/sites.json`. An array of site objects.
339
+
340
+ **Example:**
341
+
342
+ ```json
343
+ [
344
+ {
345
+ "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
346
+ "name": "blog",
347
+ "fqdn": "blog.example.com",
348
+ "type": "managed",
349
+ "spaMode": false,
350
+ "autheliaProtected": false,
351
+ "rootPath": "/var/www/lamaste/b2c3d4e5-f6a7-8901-bcde-f12345678901",
352
+ "createdAt": "2024-03-14T12:00:00.000Z"
353
+ }
354
+ ]
355
+ ```
356
+
357
+ **Operations:**
358
+
359
+ - `readSites()` — returns the array, or `[]` if the file does not exist
360
+ - `writeSites(sites)` — atomic write of the full array
361
+
362
+ Same atomic write pattern and ENOENT handling as tunnels.
363
+
364
+ Custom-domain sites may also carry `aliases` (hostnames redirected with a 301 to the site's `fqdn`); see the [Sites API](../04-api-reference/sites.md). The full field list is in [Config Files](../06-reference/config-files.md).
365
+
366
+ ## YAML Writes for Authelia (`users.yml`)
367
+
368
+ Authelia reads its user database from `/etc/authelia/users.yml` at runtime. Lamaste writes this file when creating, updating, or deleting users.
369
+
370
+ **Format:**
371
+
372
+ ```yaml
373
+ users:
374
+ admin:
375
+ displayname: admin
376
+ password: $2b$12$... # bcrypt hash
377
+ email: admin@lamaste.local
378
+ groups:
379
+ - admins
380
+ alice:
381
+ displayname: alice
382
+ password: $2b$12$...
383
+ email: alice@example.com
384
+ groups:
385
+ - admins
386
+ ```
387
+
388
+ **Write pattern:**
389
+
390
+ The Panel Server writes Authelia's files itself, without sudo (`writeAutheliaFile()` in `packages/core/lib/src/server/authelia.ts`): `/etc/authelia` is owned by `lamaste` with group `lamaste-authelia` and the setgid bit (`2770`), so the panel can create files there and every new file belongs to Authelia's group.
391
+
392
+ 1. Writes a temp file in `/etc/authelia/` (`open(..., 'wx')`), sets its mode, `fsync()`s it
393
+ 2. `rename()`s it over the destination — atomic on the same filesystem, so Authelia never reads a partial file
394
+ 3. The mode decides Authelia's access: `users.yml` `0660` (Authelia rewrites it when a user changes a password), `configuration.yml` `0640`, `.secrets.json` `0600` (the panel only)
395
+
396
+ **Critical invariant:** After writing `users.yml`, the Authelia service must be restarted to pick up changes. Authelia does not watch the file for changes. The Panel Server calls `reloadAuthelia()` (which runs `systemctl restart authelia`) after every user modification.
397
+
398
+ **Safety rule:** The Panel Server prevents deleting the last user in the Authelia database. Without at least one user, no one could authenticate to access tunneled apps.
399
+
400
+ ## Storage Server Registry (`storage-config.json`)
401
+
402
+ The storage system manages S3-compatible object storage servers and their bindings to plugins. Storage credentials (access key, secret key) are encrypted at rest using AES-256-GCM with scrypt key derivation.
403
+
404
+ **Encryption scheme:**
405
+
406
+ 1. A 32-byte master key is generated once and stored at `/etc/lamalibre/lamaste/storage-master.key` (mode 0600)
407
+ 2. For each credential, a random 16-byte salt is generated
408
+ 3. The master key is passed through scrypt (N=16384, r=8, p=1) with the salt to derive a 32-byte encryption key
409
+ 4. The credential is encrypted with AES-256-GCM using a random 12-byte IV
410
+ 5. The output is packed as `[salt (16)] [iv (12)] [authTag (16)] [ciphertext (...)]` and base64-encoded
411
+
412
+ **State file (`storage-config.json`):**
413
+
414
+ ```json
415
+ {
416
+ "servers": [
417
+ {
418
+ "id": "uuid",
419
+ "label": "my-storage",
420
+ "provider": "digitalocean",
421
+ "region": "fra1",
422
+ "bucket": "my-bucket",
423
+ "endpoint": "https://fra1.digitaloceanspaces.com",
424
+ "accessKeyEncrypted": "<base64-encoded encrypted credential>",
425
+ "secretKeyEncrypted": "<base64-encoded encrypted credential>",
426
+ "registeredAt": "2026-03-30T10:00:00.000Z"
427
+ }
428
+ ],
429
+ "bindings": [
430
+ {
431
+ "pluginName": "sync",
432
+ "storageServerId": "uuid",
433
+ "boundAt": "2026-03-30T10:05:00.000Z"
434
+ }
435
+ ]
436
+ }
437
+ ```
438
+
439
+ **Concurrency:** All read-modify-write operations are serialized via a promise-chain mutex. Writes use the same atomic pattern (temp file, fsync, rename).
440
+
441
+ ## Config Path Resolution
442
+
443
+ Different config files are resolved through different mechanisms:
444
+
445
+ | File | Resolution |
446
+ | ------------------ | -------------------------------------------------------------------------------------------------------- |
447
+ | `panel.json` | `LAMALIBRE_LAMASTE_CONFIG` env var → `dev/panel.json` (dev) → `/etc/lamalibre/lamaste/panel.json` (prod) |
448
+ | `tunnels.json` | `LAMALIBRE_LAMASTE_STATE_DIR` env var → `/etc/lamalibre/lamaste` + `/tunnels.json` |
449
+ | `sites.json` | `LAMALIBRE_LAMASTE_STATE_DIR` env var → `/etc/lamalibre/lamaste` + `/sites.json` |
450
+ | `invitations.json` | `LAMALIBRE_LAMASTE_STATE_DIR` env var → `/etc/lamalibre/lamaste` + `/invitations.json` |
451
+ | `users.yml` | Hardcoded: `/etc/authelia/users.yml` |
452
+ | PKI files | `LAMALIBRE_LAMASTE_PKI_DIR` env var → `/etc/lamalibre/lamaste/pki` |
453
+
454
+ Environment variables allow overriding paths for development and testing without modifying code.
455
+
456
+ ## File Permissions
457
+
458
+ | File | Mode | Owner | Rationale |
459
+ | ------------------------- | ------ | -------------------------- | -------------------------------------------------------------------- |
460
+ | `panel.json` | `0600` | `lamaste:lamaste` | Contains sensitive config, owner-only access |
461
+ | `tunnels.json` | `0600` | `lamaste:lamaste` | Written by Panel Server |
462
+ | `sites.json` | `0600` | `lamaste:lamaste` | Written by Panel Server |
463
+ | `chisel-credentials.json` | `0600` | `lamaste:lamaste` | Per-agent Chisel passwords |
464
+ | `chisel-users` | `0640` | `lamaste:lamaste-chisel` | Chisel authfile (read by the Chisel process, group `lamaste-chisel`) |
465
+ | `chisel-sentinel` | `0600` | `lamaste:lamaste` | Sentinel Chisel user password |
466
+ | `invitations.json` | `0600` | `lamaste:lamaste` | Written by Panel Server |
467
+ | `storage-config.json` | `0600` | `lamaste:lamaste` | Storage registry (credentials AES-256-GCM encrypted) |
468
+ | `storage-master.key` | `0600` | `lamaste:lamaste` | 32-byte master key for storage encryption |
469
+ | `groups.json` | `0600` | `lamaste:lamaste` | Lamaste group definitions and membership |
470
+ | `access-grants.json` | `0600` | `lamaste:lamaste` | Generic access grants (principal → resource) |
471
+ | `gatekeeper.json` | `0600` | `lamaste:lamaste` | Gatekeeper settings (cache TTL, logging) |
472
+ | `access-request-log.json` | `0600` | `lamaste:lamaste` | Optional denied access log |
473
+ | `pki/ca.key` | `0600` | `lamaste:lamaste` | CA private key — most sensitive file |
474
+ | `pki/ca.crt` | `0644` | `lamaste:lamaste` | CA cert — needs to be readable by nginx |
475
+ | `pki/client.key` | `0600` | `lamaste:lamaste` | Client private key |
476
+ | `pki/client.crt` | `0644` | `lamaste:lamaste` | Client cert |
477
+ | `pki/client.p12` | `0600` | `lamaste:lamaste` | PKCS12 bundle with private key |
478
+ | `pki/.p12-password` | `0600` | `lamaste:lamaste` | Password for PKCS12 bundle |
479
+ | `users.yml` | `0660` | `lamaste:lamaste-authelia` | Contains bcrypt password hashes; Authelia rewrites it |
480
+ | `configuration.yml` | `0640` | `lamaste:lamaste-authelia` | Contains JWT and session secrets; Authelia reads it |
481
+ | `.secrets.json` | `0600` | `lamaste:lamaste-authelia` | Encryption keys; the panel only |
482
+
483
+ The PKI directory belongs to `lamaste` because the panel is the CA (it signs agent certificates itself); the installer creates it as root and hands it over, and every redeploy re-applies that ownership. Authelia's files belong to `lamaste` with the `lamaste-authelia` group, so the panel reads and writes them directly and Authelia (running as `lamaste-authelia`) reads them through its group. Nothing here is read or written with `sudo`.
484
+
485
+ ## Concurrency Safety
486
+
487
+ The Panel Server is a single-process Node.js application, but a single-threaded event loop does not serialize asynchronous workflows: two requests that each read `tunnels.json`, await a certificate or an nginx test, and then write it back would interleave, and both could pass the same uniqueness check. Serialization is therefore explicit:
488
+
489
+ **Tunnel state** is guarded by one promise-chain lock, `withTunnelLock` in `@lamalibre/lamaste@4.0.0/server`'s `tunnels.ts`. Every tunnel workflow — create, delete, toggle, assign, reconfigure, the combined `PATCH /api/tunnels/:id`, releasing a revoked agent's tunnels, and startup reconciliation — takes it for its whole duration, and does its validation (subdomain and port uniqueness, reserved ports, agent checks, authorization against the current entry) inside it.
490
+
491
+ **Chisel authfile renders** are serialized by a promise-chain mutex in `chisel-users.ts`, keyed by the credentials file, around every render (enroll, revoke, rotate, tunnel change). The daemon's `syncChisel()` renders after the tunnel state is written and restarts Chisel (`systemctl try-restart`) only when the render withdrew something.
492
+
493
+ This ensures that concurrent tunnel or credential operations never interleave state or authfile writes.
494
+
495
+ ## Key Files
496
+
497
+ | File | Role |
498
+ | ----------------------------------------------- | ---------------------------------------------------------------- |
499
+ | `packages/lamaste-serverd/src/lib/storage.js` | Storage server registry, plugin bindings, AES-256-GCM encryption |
500
+ | `packages/lamaste-gatekeeper/src/lib/groups.ts` | Lamaste group read/write with atomic writes |
501
+ | `packages/lamaste-gatekeeper/src/lib/grants.ts` | Access grant read/write with atomic writes |
502
+ | `packages/lamaste-serverd/src/lib/config.js` | Config loading, Zod validation, atomic updates |
503
+ | `packages/lamaste-serverd/src/lib/state.js` | tunnels.json + sites.json atomic read/write |
504
+ | `packages/core/lib/src/server/authelia.ts` | users.yml / configuration.yml read and atomic write (no sudo) |
505
+ | `packages/lamaste-serverd/src/lib/files.js` | Static site file operations |
506
+ | `packages/create-lamaste/src/tasks/panel.js` | Initial panel.json creation |
507
+ | `packages/create-lamaste/src/tasks/mtls.js` | Initial PKI file creation |
508
+
509
+ ## Design Decisions
510
+
511
+ ### Why JSON instead of SQLite?
512
+
513
+ SQLite would work, but adds a binary dependency and ~1 MB of RAM for the database engine. JSON files can be read with `cat`, edited with any text editor, and backed up with `cp`. At the current scale (tens of entries), JSON parsing is effectively free. The atomic write pattern provides the same crash-safety guarantees as SQLite's WAL journal.
514
+
515
+ ### Why not watch files for changes?
516
+
517
+ File watching (via `fs.watch` or inotify) adds complexity and platform-specific behavior. Since all state modifications go through the Panel Server API, the in-memory cache is always consistent with the disk. Authelia is the exception — it reads `users.yml` independently — which is why a restart is required after modifications.
518
+
519
+ ### Why structuredClone for getConfig?
520
+
521
+ Without cloning, `getConfig()` would return a reference to the cached config object. Any modification to the returned object (even accidental) would corrupt the cache. `structuredClone` creates a deep copy, making the config effectively immutable from the caller's perspective. This is a small performance cost that prevents a class of subtle bugs.
522
+
523
+ ### Why fsync before rename?
524
+
525
+ On Linux, `writeFile` may return before data is flushed to the physical disk (the data may be in the kernel's page cache). If power is lost at this point, the temp file could be empty or corrupted. `fd.sync()` forces the data to disk, ensuring the rename always produces a valid file. This is the standard pattern for durable writes in database systems.
526
+
527
+ ### Why YAML for Authelia users instead of JSON?
528
+
529
+ Authelia's file-based authentication backend expects YAML format. This is an external constraint, not a choice. The Panel Server uses the `js-yaml` library for serialization and reads the file directly (it owns it).