@lamalibre/create-lamaste 4.3.0 → 4.4.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 (79) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +13 -8
  3. package/src/index.js +4 -0
  4. package/src/lib/panel-pin.js +41 -0
  5. package/src/lib/service-config.js +6 -0
  6. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  7. package/vendor/docs/00-introduction/quickstart.md +5 -5
  8. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  9. package/vendor/docs/01-concepts/certificates.md +3 -3
  10. package/vendor/docs/01-concepts/mtls.md +2 -2
  11. package/vendor/docs/01-concepts/security-model.md +50 -2
  12. package/vendor/docs/01-concepts/tickets.md +2 -2
  13. package/vendor/docs/01-concepts/tunneling.md +4 -4
  14. package/vendor/docs/02-guides/agent-setup.md +6 -6
  15. package/vendor/docs/02-guides/certificate-management.md +2 -2
  16. package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
  17. package/vendor/docs/02-guides/desktop-app-setup.md +12 -11
  18. package/vendor/docs/02-guides/installation.md +3 -3
  19. package/vendor/docs/02-guides/onboarding.md +2 -2
  20. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  21. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  22. package/vendor/docs/03-architecture/installer.md +4 -4
  23. package/vendor/docs/03-architecture/management-flow.md +1 -1
  24. package/vendor/docs/03-architecture/overview.md +5 -5
  25. package/vendor/docs/03-architecture/panel-client.md +7 -7
  26. package/vendor/docs/03-architecture/panel-server.md +2 -2
  27. package/vendor/docs/03-architecture/state-management.md +2 -2
  28. package/vendor/docs/03-architecture/system-overview.md +1 -1
  29. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
  30. package/vendor/docs/04-api-reference/app-host.md +87 -38
  31. package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
  32. package/vendor/docs/04-api-reference/certificates.md +22 -19
  33. package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
  34. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  35. package/vendor/docs/04-api-reference/overview.md +137 -135
  36. package/vendor/docs/04-api-reference/registry-and-oidc.md +1 -1
  37. package/vendor/docs/04-api-reference/tunnels.md +14 -10
  38. package/vendor/docs/05-operations/backup-and-restore.md +4 -1
  39. package/vendor/docs/05-operations/uninstalling.md +1 -1
  40. package/vendor/docs/05-operations/upgrades.md +6 -6
  41. package/vendor/docs/06-reference/config-files.md +94 -84
  42. package/vendor/docs/06-reference/glossary.md +3 -3
  43. package/vendor/docs/06-reference/installer-flags.md +13 -13
  44. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  45. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  46. package/vendor/docs/gatekeeper-design.md +8 -8
  47. package/vendor/docs/identity-directory-design.md +2 -2
  48. package/vendor/docs/package.json +1 -1
  49. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  50. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  51. package/vendor/gatekeeper/package.json +2 -2
  52. package/vendor/server/bin/lamaste-server.js +11 -1
  53. package/vendor/server/package.json +4 -3
  54. package/vendor/server/src/commands/panel-pin.js +40 -0
  55. package/vendor/server/src/commands/plugin-settings.js +541 -0
  56. package/vendor/server/src/commands/plugins.js +94 -27
  57. package/vendor/server/src/panel-api.js +281 -34
  58. package/vendor/server/src/panel-identity.js +165 -0
  59. package/vendor/server/src/plugin-approval.js +78 -0
  60. package/vendor/server/src/plugin-settings.js +699 -0
  61. package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
  62. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
  63. package/vendor/server-ui/dist/index.html +2 -2
  64. package/vendor/serverd/package.json +2 -2
  65. package/vendor/serverd/src/index.js +9 -0
  66. package/vendor/serverd/src/lib/agent-presence.js +106 -0
  67. package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
  68. package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
  69. package/vendor/serverd/src/lib/mtls.js +44 -4
  70. package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
  71. package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
  72. package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
  73. package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
  74. package/vendor/serverd/src/lib/plugins.js +73 -22
  75. package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
  76. package/vendor/serverd/src/routes/management/plugins.js +166 -19
  77. package/vendor/serverd/src/routes/management/tunnels.js +21 -1
  78. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
  79. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +0 -84
@@ -1,12 +1,12 @@
1
1
  # App Host
2
2
 
3
- When an [agent-hosted plugin](agent-hosted-plugins.md)'s manifest names an agent package (`agent.package`), Lamaste installs the plugin's **agent half** onto the hosting agent itself: a system account, a hardened systemd unit, its directories and the system packages it declares. Installing them needs root, and `lamaste-agent` runs as an unprivileged user — so it goes through one root program on the agent, **`lamaste-apphost`**, which the agent's user may run through one sudoers rule. This page is the agent machine's side: enabling the helper, what it creates, the unit, the relay-signed jobs it runs, and the contract an agent half is written to. The relay's side — hosting, approval, updates, removal — is in [Agent-Hosted Plugins](agent-hosted-plugins.md#hosting-with-an-agent-half). The design is `plugin-development/design/agent-hosted-app-plugins.md` (Slice 3).
3
+ When an [agent-hosted plugin](agent-hosted-plugins.md)'s manifest names an agent package (`agent.package`), Lamaste installs the plugin's **agent half** onto the hosting agent itself: a system account, a hardened systemd unit with a socket unit that holds its port, its directories and the system packages it declares. Installing them needs root, and `lamaste-agent` runs as an unprivileged user — so it goes through one root program on the agent, **`lamaste-apphost`**, which the agent's user may run through one sudoers rule. This page is the agent machine's side: enabling the helper, what it creates, the unit and its socket, the relay-signed jobs it runs, and the contract an agent half is written to. The relay's side — hosting, approval, updates, removal — is in [Agent-Hosted Plugins](agent-hosted-plugins.md#hosting-with-an-agent-half). The design is `plugin-development/design/agent-hosted-app-plugins.md` (Slice 3).
4
4
 
5
5
  It is a page of its own because it documents another machine and another trust boundary: a root program, its files and its unit on the agent, which the relay's API pages only refer to.
6
6
 
7
7
  ## In Plain English
8
8
 
9
- The operator of an agent machine says once: _the panel may install plugins here_ (`sudo lamaste-agent apphost enable`). From then on, hosting a plugin from the panel installs its agent half on that machine with nothing done by hand: the agent's sync timer downloads exactly the package version the relay pinned, checks it byte for byte, and hands it to the helper, which unpacks it read-only under `/opt`, gives it an account of its own and a data directory under `/var/lib`, and starts it as a locked-down service. Plugin code never runs as root.
9
+ The operator of an agent machine says once: _the panel may install plugins here_ (`sudo lamaste-agent apphost enable`). From then on, hosting a plugin from the panel installs its agent half on that machine with nothing done by hand: the agent's sync timer downloads exactly the package version the relay pinned, checks it byte for byte, and hands it to the helper, which unpacks it read-only under `/opt`, gives it an account of its own and a data directory under `/var/lib`, and starts it as a locked-down service on a port systemd holds for it. Plugin code never runs as root.
10
10
 
11
11
  The helper does only what the relay **signed**. When it is enabled it remembers (pins) the relay's public key; every install, version switch and removal the relay asks for carries the relay's signature over exactly that job, and the helper refuses anything else — so a compromised agent account cannot make it install, switch or delete anything the relay did not ask for.
12
12
 
@@ -15,12 +15,12 @@ The helper does only what the relay **signed**. When it is enabled it remembers
15
15
  Once per agent machine (Linux with systemd; macOS agents cannot host an agent half Lamaste installs):
16
16
 
17
17
  ```bash
18
- sudo npm install -g @lamalibre/lamaste-agent@4.3.0 # a root-owned install
18
+ sudo npm install -g @lamalibre/lamaste-agent@4.4.0 # a root-owned install
19
19
  sudo lamaste-agent apphost enable # the agent runs as the user who ran sudo
20
20
  sudo lamaste-agent apphost enable --user root # the agent runs as root: no sudoers rule
21
21
  ```
22
22
 
23
- `enable` refuses to act from an install another user could have changed: the package, the helper it ships and the core library it loads — and every directory above them — must be root-owned and writable by no group or other user. A user-level install (`nvm`, a home-directory prefix) is not; install it root-owned with `sudo npm install -g @lamalibre/lamaste-agent@4.3.0` and run `enable` from that install. It also needs `/usr/bin/node` (the helper's interpreter and the Node.js every agent half runs on — from the distribution or NodeSource), systemd as the system manager (`/run/systemd/system`), and `sudo` and `visudo` unless the agent runs as root.
23
+ `enable` refuses to act from an install another user could have changed: the package, the helper it ships and the core library it loads — and every directory above them — must be root-owned and writable by no group or other user. A user-level install (`nvm`, a home-directory prefix) is not; install it root-owned with `sudo npm install -g @lamalibre/lamaste-agent@4.4.0` and run `enable` from that install. It also needs `/usr/bin/node` (the helper's interpreter and the Node.js every agent half runs on — from the distribution or NodeSource), systemd as the system manager (`/run/systemd/system`), and `sudo` and `visudo` unless the agent runs as root.
24
24
 
25
25
  What `enable` does:
26
26
 
@@ -29,7 +29,7 @@ What `enable` does:
29
29
  3. Checks that the helper answers (`status`) and that the agent's user can run it with `sudo -n`.
30
30
  4. Pins the relay key of every agent of that user on the machine (see [Pinning the relay key](#pinning-the-relay-key)), printing each label's key id to compare with the relay's. A failure here leaves the helper enabled — it runs no job until `sudo lamaste-agent apphost pin` succeeds.
31
31
 
32
- The agent's next sync reports the helper to the relay in its [host facts](agent-hosted-plugins.md#host-facts); from then on the panel lists the agent as able to host plugins whose agent half Lamaste installs.
32
+ The agent's next sync reports the helper to the relay in its [host facts](agent-hosted-plugins.md#host-facts); from then on the panel lists the agent as able to host plugins whose agent half Lamaste installs. A relay that loses them — the agent enrolled again on its label, the relay restored from a backup — has them again after the agent's next sync: its agent-config answer tells the agent the hash of the facts it holds (`hostFactsHash`), and the agent reports when that is not the hash of its own.
33
33
 
34
34
  ### The sudoers rule
35
35
 
@@ -60,17 +60,17 @@ All are Linux only. `enable`, `pin`, `remove` and `disable` refuse to run from a
60
60
 
61
61
  `/usr/local/sbin/lamaste-apphost` is a self-contained CommonJS script (Node.js built-ins only, `#!/usr/bin/node`) that reads nothing the agent's user can write. Every name and path derives from validated arguments; content — a tarball, a service spec, a plugin config, a signature — arrives on stdin, so neither a tarball nor a secret is in the sudo log, and nothing read from stdin is ever echoed. One mutating command runs at a time (`/run/lamaste-apphost.lock`, holding the owner's PID; a lock whose process is gone is taken over).
62
62
 
63
- | Command | stdin | Signed | Does |
64
- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
- | `install <label> <route> <version> <integrity>` | the signature (one line of JSON), the service spec (one line of JSON), then the package `.tgz` | yes | Install that version of the agent half and make it current |
66
- | `config <label> <route>` | the plugin config (JSON) | no | Write the service's copy of the plugin config; start the unit, restart it when the port changed (a rotated secret needs no restart). Exit 3 when the agent half is not installed |
67
- | `activate <label> <route> <version>` | the signature | yes | Point `current` at a kept version, restore that version's unit, restart |
68
- | `remove <label> <route> [--purge]` | the signature | yes | Stop and disable the unit; remove unit, code and config. `--purge` also deletes the data directory, the account and the owner record |
69
- | `status [<route>]` | — | no | `{ protocol, node, apps: { <route>: { versions, current, previous, unit, owner } } }` — `node` is the Node.js the helper, and so every agent half, runs on |
63
+ | Command | stdin | Signed | Does |
64
+ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `install <label> <route> <version> <integrity>` | the signature (one line of JSON), the service spec (one line of JSON), then the package `.tgz` | yes | Install that version of the agent half and make it current |
66
+ | `config <label> <route>` | the plugin config (JSON) | no | Write the service's copy of the plugin config; write the [socket unit](#the-socket-unit) for its port, start the socket and the unit. A new port moves the socket (the unit restarts on it); a rotated secret changes nothing else and restarts nothing. Exit 3 when the agent half is not installed |
67
+ | `activate <label> <route> <version>` | the signature | yes | Point `current` at a kept version, restore that version's unit, restart |
68
+ | `remove <label> <route> [--purge]` | the signature | yes | Stop and disable the unit and its socket; remove both unit files (and the unit's drop-in directory), code and config. `--purge` also deletes the data directory, the account and the owner record |
69
+ | `status [<route>]` | — | no | `{ protocol, node, apps: { <route>: { versions, current, previous, unit, socket, owner } } }` — `node` is the Node.js the helper, and so every agent half, runs on; `socket` the socket unit's state (`active`, `inactive`, `absent` until the config is there) |
70
70
 
71
71
  `<label>` is the agent's label on this machine. `config` and `status` are not signed: the agent's user holds the plugin config already, and `status` changes nothing. An operator's `sudo lamaste-agent apphost remove|disable` loads the helper into its own root process and calls its removal directly — operator authority comes only from code already running as root, never from the helper's command line.
72
72
 
73
- The mutating commands print `{ "ok": true, "version": "1.2.0", "unit": "active" }` (`version`: what `current` points at, `null` after `remove`; `unit`: `active`, `inactive` or `absent`). Errors are one line on stderr, `lamaste-apphost: …`.
73
+ The mutating commands print `{ "ok": true, "version": "1.2.0", "unit": "active" }` (`version`: what `current` points at, `null` after `remove`; `unit`: the service's state, `active`, `inactive` or `absent`). Errors are one line on stderr, `lamaste-apphost: …`.
74
74
 
75
75
  | Exit | Meaning |
76
76
  | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -94,7 +94,7 @@ In this order; nothing of the agent half is written before they pass:
94
94
  7. At most 50 agent halves per machine; enough free space in `/opt/lamalibre/apps` for the unpacked package, 4 KiB per entry and a 64 MiB margin.
95
95
  8. **The bundled rule**, on the unpacked tree: `package.json` names the spec's package at the version; no non-empty `dependencies` or `optionalDependencies`, no `bundleDependencies`; no `preinstall`, `install` or `postinstall` script; no `binding.gyp`; the service entry is a file in the package. Every file becomes root-owned 0644, every directory 0755 — no executable or set-id bit, nothing the agent half can change.
96
96
 
97
- Then: the approved system packages it lacks are installed (`apt-get install -y --no-install-recommends`, Debian and Ubuntu only), the account, the owner record, the data and config directories are created, the version is moved into place, the unit is written, `current` switched, systemd reloaded and the unit (re)started — once its plugin config is there. A failure before the version is in place takes back what this run created; a failure after it restores the version that ran before, or for a first install removes everything. The current and previous versions are kept; older ones are deleted.
97
+ Then: the approved system packages it lacks are installed (`apt-get install -y --no-install-recommends`, Debian and Ubuntu only), the account, the owner record, the data and config directories are created, the version is moved into place, the unit is written, `current` switched, systemd reloaded and the unit (re)started — once its plugin config is there, with the socket unit that holds its port (see [The socket unit](#the-socket-unit)). A failure before the version is in place takes back what this run created; a failure after it restores the version that ran before, or for a first install removes everything. The current and previous versions are kept; older ones are deleted.
98
98
 
99
99
  ### What it creates
100
100
 
@@ -107,6 +107,7 @@ Then: the approved system packages it lacks are installed (`apt-get install -y -
107
107
  | `/etc/lamalibre/apps/<route>/` | root:\<account's group\>, 0750 | The agent half's configuration directory |
108
108
  | `/etc/lamalibre/apps/<route>/plugin-config.json` | the account, 0600 | Port and installation secret (`LAMASTE_APP_CONFIG`) |
109
109
  | `/etc/systemd/system/lamalibre-app-<route>.service` | root, 0644 | The unit (below) |
110
+ | `/etc/systemd/system/lamalibre-app-<route>.socket` | root, 0644 | The socket unit holding the agent half's port (below) — written once its plugin config is there |
110
111
  | `/var/lib/lamalibre/apps/.owners/<route>.json` | root 0644, directory root 0755 | The owner record: `{ route, package, label, since }` — outside the data the agent half can write. Kept on removal, deleted by a purge |
111
112
  | `/var/lib/lamalibre/apps/.jobs/used.json` | root 0600, directory root 0700 | The ids of the signed jobs that ran, each kept until its signature expires |
112
113
  | `/var/lib/lamalibre/apps/.trash/` | root, 0700 | A purged data directory is moved here, out of its account's reach, and deleted |
@@ -123,13 +124,17 @@ For plugin `market-indicators`, with `memoryMax: "256M"`:
123
124
 
124
125
  ```ini
125
126
  # Managed by lamaste-apphost: the agent half of Lamaste app plugin market-indicators.
127
+ # It listens on the socket systemd holds for it (lamalibre-app-market-indicators.socket), passed as descriptor 3.
126
128
  [Unit]
127
129
  Description=Lamaste app plugin market-indicators (agent half)
130
+ Requires=lamalibre-app-market-indicators.socket
131
+ After=lamalibre-app-market-indicators.socket
128
132
  After=network-online.target
129
133
  Wants=network-online.target
130
134
 
131
135
  [Service]
132
136
  Type=simple
137
+ Sockets=lamalibre-app-market-indicators.socket
133
138
  User=lamaste-app-market-indicators
134
139
  Group=lamaste-app-market-indicators
135
140
  ExecStart=/usr/bin/node /opt/lamalibre/apps/market-indicators/current/package/dist/agentd.mjs
@@ -137,7 +142,7 @@ Environment=LAMASTE_APP_CONFIG=/etc/lamalibre/apps/market-indicators/plugin-conf
137
142
  Environment=LAMASTE_APP_DATA_DIR=/var/lib/lamalibre/apps/market-indicators
138
143
  Environment=NODE_ENV=production
139
144
  WorkingDirectory=/var/lib/lamalibre/apps/market-indicators
140
- Restart=on-failure
145
+ Restart=always
141
146
  RestartSec=5
142
147
  NoNewPrivileges=yes
143
148
  ProtectSystem=strict
@@ -166,6 +171,7 @@ SystemCallFilter=@system-service
166
171
  SystemCallErrorNumber=EPERM
167
172
  CapabilityBoundingSet=
168
173
  UMask=0077
174
+ LimitCORE=0
169
175
  MemoryMax=256M
170
176
  StandardOutput=journal
171
177
  StandardError=journal
@@ -174,31 +180,67 @@ StandardError=journal
174
180
  WantedBy=multi-user.target
175
181
  ```
176
182
 
177
- The unit is written from a fixed template; it holds paths, never a secret.
178
-
179
- | Directive | Why |
180
- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
181
- | `User=` / `Group=` the agent half's account | Plugin code never runs as root or as the agent's user |
182
- | `NoNewPrivileges`, `CapabilityBoundingSet=` (empty), `RestrictSUIDSGID` | No privilege gained through set-id binaries or capabilities; no privileged port, no `chown` |
183
- | `ProtectSystem=strict`, `ReadWritePaths=` the data directory, `ReadOnlyPaths=/run`, `ProtectHome`, `PrivateTmp`, `UMask=0077` | The whole file system read-only but the data directory and a private `/tmp`; `/run` too (strict leaves it writable, and `/run/lock` is world-writable); homes invisible; new files private |
184
- | `PrivateDevices`, `TemporaryFileSystem=/dev/shm`, `PrivateIPC`, `RemoveIPC` | No device nodes, an empty `/dev/shm` it cannot write (PrivateDevices alone binds the machine's, which every user can write; a bundled Node agent half has no use for POSIX shared memory), no shared IPC namespace, its IPC objects removed when it stops |
185
- | `ProtectProc=invisible` | Other users' processes are not visible in `/proc` |
186
- | `ProtectKernelTunables`, `ProtectKernelModules`, `ProtectKernelLogs`, `ProtectControlGroups`, `ProtectClock`, `ProtectHostname` | The kernel's tunables and modules, the cgroup tree, the clock and the hostname cannot be changed; the kernel log cannot be read |
187
- | `RestrictRealtime`, `RestrictNamespaces`, `LockPersonality`, `SystemCallArchitectures=native` | No realtime scheduling, no new namespaces, no personality or foreign-ABI tricks |
188
- | `SystemCallFilter=@system-service`, `SystemCallErrorNumber=EPERM` | Only the system calls services need; a call outside the set fails with `EPERM` instead of killing the process (libuv probes `io_uring` and falls back to its thread pool) |
189
- | `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX` | IP and Unix sockets only. Outbound network stays the application's (an agent half fetches what it must); it binds loopback |
190
- | `MemoryMax=` | From `agent.service.memoryMax`; left out when the manifest sets none |
191
- | `Restart=on-failure`, `WantedBy=multi-user.target` | Restarted 5 s after a crash; started at boot |
183
+ The unit is written from a fixed template; it holds paths, never a secret — and no port: that is in its [socket unit](#the-socket-unit), written once the plugin config is there.
184
+
185
+ | Directive | Why |
186
+ | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
187
+ | `User=` / `Group=` the agent half's account | Plugin code never runs as root or as the agent's user |
188
+ | `Requires=`, `After=`, `Sockets=` its socket unit | The socket listens before the agent half starts, and systemd passes it on every start — at boot, on `start` or `restart`, after a crash, or on a connection while it was stopped — as descriptor 3 (`LISTEN_PID`, `LISTEN_FDS`). Stopping or restarting the socket stops or restarts the agent half |
189
+ | `NoNewPrivileges`, `CapabilityBoundingSet=` (empty), `RestrictSUIDSGID` | No privilege gained through set-id binaries or capabilities; no privileged port, no `chown` |
190
+ | `ProtectSystem=strict`, `ReadWritePaths=` the data directory, `ReadOnlyPaths=/run`, `ProtectHome`, `PrivateTmp`, `UMask=0077` | The whole file system read-only but the data directory and a private `/tmp`; `/run` too (strict leaves it writable, and `/run/lock` is world-writable); homes invisible; new files private |
191
+ | `PrivateDevices`, `TemporaryFileSystem=/dev/shm`, `PrivateIPC`, `RemoveIPC` | No device nodes, an empty `/dev/shm` it cannot write (PrivateDevices alone binds the machine's, which every user can write; a bundled Node agent half has no use for POSIX shared memory), no shared IPC namespace, its IPC objects removed when it stops |
192
+ | `ProtectProc=invisible` | Other users' processes are not visible in `/proc` |
193
+ | `ProtectKernelTunables`, `ProtectKernelModules`, `ProtectKernelLogs`, `ProtectControlGroups`, `ProtectClock`, `ProtectHostname` | The kernel's tunables and modules, the cgroup tree, the clock and the hostname cannot be changed; the kernel log cannot be read |
194
+ | `RestrictRealtime`, `RestrictNamespaces`, `LockPersonality`, `SystemCallArchitectures=native` | No realtime scheduling, no new namespaces, no personality or foreign-ABI tricks |
195
+ | `SystemCallFilter=@system-service`, `SystemCallErrorNumber=EPERM` | Only the system calls services need; a call outside the set fails with `EPERM` instead of killing the process (libuv probes `io_uring` and falls back to its thread pool) |
196
+ | `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX` | IP and Unix sockets only. Outbound network stays the application's (an agent half fetches what it must); it listens on loopback |
197
+ | `LimitCORE=0` | No core dump of a process that holds the installation secret; the hard limit too, so the agent half cannot raise it |
198
+ | `MemoryMax=` | From `agent.service.memoryMax`; left out when the manifest sets none |
199
+ | `Restart=always`, `WantedBy=multi-user.target` | Restarted 5 s after it exits, whatever its exit status; started at boot. A stopped service whose socket still listens would be started by every connection, and five starts within ten seconds trip systemd's start limit — which fails the socket and lets go of the port; restarted on its own, it is never left stopped for a connection to start. Only `systemctl stop` stops it |
192
200
 
193
201
  Not used, on purpose:
194
202
 
195
203
  - **`MemoryDenyWriteExecute`** — V8's JIT writes the machine code it runs, which the setting forbids.
196
204
  - **`ProcSubset=pid`** — Node.js reads `/proc/cpuinfo`, `/proc/stat` and `/proc/meminfo` for `os.cpus()`, `os.totalmem()` and `os.freemem()`, which applications use to size worker pools; hiding them breaks them quietly.
197
- - **`AF_NETLINK`** is not among the address families, so `os.networkInterfaces()` fails. An agent half binds `127.0.0.1` and needs no interface list.
205
+ - **`AF_NETLINK`** is not among the address families, so `os.networkInterfaces()` fails. An agent half listens on `127.0.0.1` and needs no interface list.
198
206
  - **`IPAddressDeny`** — the application's own outbound calls are its business (market data, a mail relay).
207
+ - **`SocketBindAllow=` / `SocketBindDeny=`** — systemd enforces them only when built with its BPF framework, which the systemd of Ubuntu 22.04 and 24.04 and of Debian 12 is not (they are ignored there, silently), and where they are enforced they deny all UDP from Node.js (`dgram` binds port 0 before its first send). The socket unit keeps the port for the agent half on every distribution instead, and its UDP works.
199
208
 
200
209
  The journal has the agent half's output: `journalctl -u lamalibre-app-<route>`.
201
210
 
211
+ ### The socket unit
212
+
213
+ The agent half never binds its port: systemd does, in a socket unit beside the unit, and passes it the listening socket. For plugin `market-indicators` on port 21007:
214
+
215
+ ```ini
216
+ # Managed by lamaste-apphost: the port of the agent half of Lamaste app plugin market-indicators.
217
+ [Unit]
218
+ Description=Lamaste app plugin market-indicators (agent half socket)
219
+
220
+ [Socket]
221
+ ListenStream=127.0.0.1:21007
222
+ Accept=no
223
+ NoDelay=true
224
+ Service=lamalibre-app-market-indicators.service
225
+
226
+ [Install]
227
+ WantedBy=sockets.target
228
+ ```
229
+
230
+ `Accept=no`: one listening socket for one long-running process, not a process per connection. `NoDelay=true` sets `TCP_NODELAY`, as Node.js's own HTTP server does. No `ReusePort=`, so nobody else may bind the port beside it. `sockets.target` wants it, so it listens from early boot; the agent half is still started at boot by `multi-user.target`, not on its first connection.
231
+
232
+ **Why.** While systemd holds the socket the port stays bound — while the agent half restarts, is updated, crashes or is stopped — so no other local process (another agent half, the agent's user, any account on the machine) can take it in between: there is no window to squat it and answer in its place, nor to hold it and keep the agent half from starting. It works the same on every distribution with systemd, and leaves the agent half's own UDP untouched. (What reaches the port is authenticated as well — see [Security Model](../01-concepts/security-model.md#5d-the-app-host-on-agents).)
233
+
234
+ **When it is written.** Root-owned 0644, atomically, from the validated plugin config — so not by `install` when the config has not arrived yet: the socket and the unit are first started together by the `config` that brings it (or by an `install` or `activate` once it is there). After that:
235
+
236
+ - **The same port** (a rotated secret, an update, an activation): the file is left as it is and the socket keeps listening; the unit restarts on it (an update or activation) or not at all (a rotation).
237
+ - **Another port**: the socket moves. The unit is stopped, then the socket — the old port is let go — the file is written, systemd reloaded, the socket started on the new port, and only then the unit. The socket never listens while its file names another port: a daemon-reload would then close its descriptor and leave it active with nothing open. A run cut short leaves the socket stopped or its file not loaded, which the next start puts right. If another process holds the new port, the socket does not start and neither does the unit (`Requires=`), and the helper reports the failure.
238
+ - **The same port in another template** (a newer helper): written and loaded in place — systemd keeps a listening socket across a reload when its address is unchanged — so the port is never let go for it.
239
+
240
+ Enabling uses `systemctl enable --no-reload`; systemd is reloaded only when a unit file changed (also when systemd reports a unit file it has not loaded, `NeedDaemonReload`).
241
+
242
+ **On removal** both units are stopped and disabled and both files deleted — the port is released — along with any drop-in directory of the unit (`lamalibre-app-<route>.service.d/`, an operator's overrides), followed by one daemon-reload. A route installed later inherits none of them.
243
+
202
244
  ## Signed app jobs
203
245
 
204
246
  The helper runs as root on the agent's behalf, so whatever the agent's user could hand it — a service spec naming system packages, a version to activate, a purge — it would otherwise do as root. Approval of what an agent half brings happens on the relay ([approval](lifecycle-hooks.md#approval)); the relay therefore **signs** every app job when it creates it, and the helper refuses anything it did not sign. The agent carries the signature from `agent-config` to the helper and can neither make nor change one.
@@ -281,7 +323,7 @@ The helper reads a pin only from a root-owned file (opened without following lin
281
323
  The relay lists app jobs in `GET /api/tunnels/agent-config` (`agentJobs`, see [Agent-Hosted Plugins](agent-hosted-plugins.md#what-the-agent-does-with-it)). The agent's sync timer runs them one after another after converge, outside the agent lock:
282
324
 
283
325
  - **A job is checked first.** An app job without a signature, with a malformed one, or whose signature does not describe exactly that job (id, route, kind, deadline, payload) is never run and is reported failed.
284
- - **`app-install`** waits for the next sync until the plugin config has arrived (the relay issues it before it lists the job). Then the agent checks the helper — enabled, answering, protocol ≥ 1, the route not another agent's — fetches a registry token from the panel (`GET /api/agents/me/registry-credential`), runs `npm pack <package>@<version> --ignore-scripts` into a fresh 0700 directory under `~/.lamalibre/lamaste/tmp/agent/` with that token in a throw-away userconfig, refuses a tarball over 128 MiB or one whose SHA-512 is not the pinned integrity, and pipes signature, spec and tarball to `lamaste-apphost install`. Under the agent lock it then records the route in `apphost-state.json` and hands the service its plugin config (`lamaste-apphost config`).
326
+ - **`app-install`** waits for the next sync until the plugin config has arrived (the relay issues it before it lists the job). Then the agent checks the helper — enabled, answering, protocol ≥ 2 (a 4.3 helper speaks 1: `sudo lamaste-agent apphost enable` installs the current one), the route not another agent's — fetches a registry token from the panel (`GET /api/agents/me/registry-credential`), runs `npm pack <package>@<version> --ignore-scripts` into a fresh 0700 directory under `~/.lamalibre/lamaste/tmp/agent/` with that token in a throw-away userconfig, refuses a tarball over 128 MiB or one whose SHA-512 is not the pinned integrity, and pipes signature, spec and tarball to `lamaste-apphost install`. Under the agent lock it then records the route in `apphost-state.json` and hands the service its plugin config (`lamaste-apphost config`).
285
327
  - **`app-activate`** and **`app-remove`** hand the signature to `activate` / `remove`. Removing with the helper disabled succeeds (`disable` removed every agent half first) unless it is a purge.
286
328
  - **Failures are reported, never undone here.** The agent cannot sign, so it never removes or switches an agent half on its own: the relay, which recovers from every install it dispatched, sends the signed removal or activation. A result is kept in `app-job-results.json` until the relay took it — the helper runs a signed job once, so a report that failed is made again on the next sync without running the job again.
287
329
  - **Plugin configs.** For a route recorded in `apphost-state.json`, converge hands a fetched config to the service (`lamaste-apphost config`) **before** acknowledging it, so the relay never signs with a secret the service does not hold; a rotation therefore needs no restart. When the record cannot be read nothing is delivered or acknowledged until the sync rebuilds it from the helper's owner records.
@@ -310,22 +352,29 @@ The contract:
310
352
  - **The entry** is a relative `.js`, `.mjs` or `.cjs` path in the package, run as `/usr/bin/node <entry>` — the Node.js of the helper, which `agent.requires.node` is checked against.
311
353
  - **Files are read-only.** Every file is installed root-owned 0644 — no executable bit, so the agent half spawns no program of its own package directly; directories 0755.
312
354
  - **It writes only its data directory**, `LAMASTE_APP_DATA_DIR` (also its working directory, 0700), and its private `/tmp`. Use `node:sqlite` for a database.
313
- - **Its config** is `LAMASTE_APP_CONFIG` (0600): `{ plugin, port, secret, issuedAt, previousSecret?, previousIssuedAt?, fetchedAt, acknowledged }`. Listen on `127.0.0.1:<port>` and verify every `/admin/*` request:
355
+ - **Its config** is `LAMASTE_APP_CONFIG` (0600): `{ plugin, port, secret, issuedAt, previousSecret?, previousIssuedAt?, fetchedAt, acknowledged }`. Verify every `/admin/*` request with it.
356
+ - **Listen on the socket systemd passes — never bind a port yourself.** `agentHalfListenTarget()` answers `{ fd: 3, host: '127.0.0.1' }` under the helper's unit; systemd holds the port, so binding it fails with `EADDRINUSE`. Give it the config's port so the same code also runs as an agent half installed by hand (no socket: it answers `{ host: '127.0.0.1', port }`):
314
357
 
315
358
  ```js
316
- import { createCallerAssertionVerifier } from '@lamalibre/lamaste@4.3.0/agent'; // bundled
359
+ import Fastify from 'fastify';
360
+ import { agentHalfListenTarget, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.4.0/agent'; // bundled
317
361
 
318
362
  const verifier = createCallerAssertionVerifier({
319
363
  configPath: process.env.LAMASTE_APP_CONFIG,
320
364
  plugin: 'market-indicators',
321
365
  });
322
- const port = await verifier.port();
366
+ const target = agentHalfListenTarget({ port: await verifier.port() });
367
+
368
+ const app = Fastify();
369
+ // …routes
370
+ await app.listen(target); // or, with node:http: http.createServer(handler).listen(target)
323
371
  ```
324
372
 
325
- The verifier re-reads the file when a signature does not verify, so a rotated secret needs no restart; a changed port restarts the unit.
373
+ Pass the target to Fastify as it is: with `fd`, its `host` keeps Fastify from opening a second server on the descriptor for `::1` (its default host is `localhost`). Call it once, at start-up: it reads systemd's `LISTEN_PID`, `LISTEN_FDS` and `LISTEN_FDNAMES` and removes them from `process.env`, so no process the agent half starts takes the socket for its own. Any number of sockets but one is refused. The verifier re-reads the config when a signature does not verify, so a rotated secret needs no restart; a changed port moves the socket and restarts the unit on it.
326
374
 
327
375
  - **Lifecycle hooks** are answered at `POST /admin/hooks/<stage>` ([agent hooks](lifecycle-hooks.md#agent-hooks)); the agent's own `install:pre-tunnel` reaches the service on its loopback once it runs.
328
- - **Limits of the sandbox:** no capabilities (no port below 1024), IP and Unix sockets only, no `os.networkInterfaces()`, only its own processes in `/proc`. Its output goes to the journal; `NODE_ENV=production`.
376
+ - **Settings**, when the manifest declares them, are kept with `createSettingsStore` over `LAMASTE_APP_DATA_DIR` — `settings.json` and `secrets.json`, 0600 — and answered at `/admin/settings` with `createSettingsHandler` ([Settings](agent-hosted-plugins.md#in-the-agent-half)); they stay with the retained data and a purge deletes them.
377
+ - **Limits of the sandbox:** no capabilities (no port below 1024), IP and Unix sockets only, no `os.networkInterfaces()`, only its own processes in `/proc`, no core dumps. Its output goes to the journal; `NODE_ENV=production`.
329
378
  - **System packages** (`agent.requires.systemPackages`) are installed with `apt-get` after approval, on Debian and Ubuntu only.
330
379
  - **Name** the plugin by its route: a plugin with an agent half is named exactly its route (its packages keep their names), and the route starts and ends with a letter or digit.
331
380
 
@@ -355,6 +404,6 @@ Removing an agent half without purge — unhosting, uninstalling, a rolled-back
355
404
  - **System packages are never removed** — not on removal, not on purge.
356
405
  - **No other package managers:** system packages are installed on Debian and Ubuntu only; elsewhere a plugin that declares them cannot be hosted.
357
406
  - **macOS agents** cannot host an agent half Lamaste installs; an agent half installed by hand is still hosted the 2a way.
358
- - **Settings and secrets** of an agent half (slice 4) are not delivered yet; the plugin config carries the port and the installation secret only.
407
+ - **No settings in the plugin config:** it carries the port and the installation secret only. A plugin's settings and secrets reach its agent half over the tunnel (`PUT /admin/settings`, secrets sealed to the installation secret), never as a file Lamaste writes on the agent — see [Agent-Hosted Plugins](agent-hosted-plugins.md).
359
408
 
360
409
  See [Config Files](../06-reference/config-files.md#agent-machine-the-app-host) for every file with its owner and mode, and [Security Model](../01-concepts/security-model.md#5d-the-app-host-on-agents) for the trust model.
@@ -1,6 +1,6 @@
1
1
  # Audit Log & Notifications
2
2
 
3
- The panel keeps an **audit log** of administrative decisions — approving a plugin's relay hooks, switching auto-approve, hosting and removing a hosting, installing, updating and uninstalling a plugin — and of what plugins' [lifecycle hooks](lifecycle-hooks.md) report through the `audit` effect. **Notifications** are messages for the relay's administrators: a hook's `notify-admins` effect, an update waiting for approval (with why auto-approve did not cover it), a rule pack updated with Lamaste. Both live in `state.db` and hold metadata only — never a request or response body or a secret.
3
+ The panel keeps an **audit log** of administrative decisions — approving a plugin's relay hooks, switching auto-approve, hosting and removing a hosting, installing, updating and uninstalling a plugin — and of what plugins' [lifecycle hooks](lifecycle-hooks.md) report through the `audit` effect. **Notifications** are messages for the relay's administrators: a hook's `notify-admins` effect, an update waiting for approval (with why auto-approve did not cover it), a plugin's settings changed outside Lamaste, a rule pack updated with Lamaste. Both live in `state.db` and hold metadata only — never a request or response body or a secret.
4
4
 
5
5
  All endpoints are admin only.
6
6
 
@@ -31,16 +31,16 @@ Newest first. Query: `plugin`, `action` (exact, or a prefix ending in `.` such a
31
31
  | `auto-approve` | a plugin's auto-approve |
32
32
  | `lamaste` | the panel itself |
33
33
 
34
- Actions written by Lamaste: `plugin.installed`, `plugin.uninstalled`, `plugin.hosted`, `plugin.unhosted`, `plugin.updated`, `plugin.update.reverted`, `plugin.update.awaiting-approval`, `plugin.update.discarded`, `hooks.approved`, `hooks.auto-approve.enabled|disabled`, `hooks.auto-approve.withheld`, `hooks.rules-updated`, `hooks.effects-dropped`, and for an [agent half Lamaste installs](app-host.md):
34
+ Actions written by Lamaste: `plugin.installed`, `plugin.uninstalled`, `plugin.hosted`, `plugin.unhosted`, `plugin.updated`, `plugin.update.reverted`, `plugin.update.awaiting-approval`, `plugin.update.discarded`, `hooks.approved`, `hooks.auto-approve.enabled|disabled`, `hooks.auto-approve.withheld`, `hooks.rules-updated`, `hooks.effects-dropped`, `plugin.settings.updated` (a change of a plugin's [settings](agent-hosted-plugins.md#settings), by key name only — `changed`, `cleared`, `secretsSet`, `secretsCleared`, the new `revision` — never a value; `confirmedLater: true` when its answer was lost and a later read found it stored), `plugin.settings.changed-outside-lamaste` (actor `lamaste`: a read found the agent half at a revision Lamaste did not write — `changed` and `secretsChanged`, the names of the settings that differ from the relay's cache, `fromRevision`, `revision`, `agentLabel`; never a value; the administrators are warned too), and for an [agent half Lamaste installs](app-host.md):
35
35
 
36
- | Action | When |
37
- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
38
- | `plugin.agent-half.installed` | The agent reported the pinned version installed and its unit active — `agentLabel`, `package`, `version`, `integrity`, `phase`, `retainedDataReused` |
39
- | `plugin.agent-half.activated` | A failed update switched the agent back to the version before — `version`, `from` |
40
- | `plugin.agent-half.removed` | Removed from the agent — `version`, `purge`, `phase` (`uninstall` or `rollback`) |
41
- | `plugin.agent-half.purged` | Retained data purged on the agent — `route`, `package` |
42
- | `plugin.agent-half.retained-data.forgotten` | The relay's record of retained data dropped without touching the agent |
43
- | `hooks.auto-approve.withheld` | Auto-approve did not cover an update because its agent half changes what runs as root — `reasons` |
36
+ | Action | When |
37
+ | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | `plugin.agent-half.installed` | The agent reported the pinned version installed and its unit active — `agentLabel`, `package`, `version`, `integrity`, `phase`, `retainedDataReused` |
39
+ | `plugin.agent-half.activated` | A failed update switched the agent back to the version before — `version`, `from` |
40
+ | `plugin.agent-half.removed` | Removed from the agent — `version`, `purge`, `phase` (`uninstall` or `rollback`) |
41
+ | `plugin.agent-half.purged` | Retained data purged on the agent — `route`, `package` |
42
+ | `plugin.agent-half.retained-data.forgotten` | The relay's record of retained data dropped without touching the agent |
43
+ | `hooks.auto-approve.withheld` | Auto-approve did not cover an update because its agent half changes what runs as root, or it turns a secret setting into a plain one — `reasons` (sentences) and `codes` (`agent-half.added`, `agent-half.package`, `agent-half.system-packages`, `agent-half.entry`, `agent-half.memory-max`, `settings.secret-to-plain`) |
44
44
 
45
45
  Details are capped at 4 KiB (strings at 300 characters, three levels deep). Entries are kept 180 days, at most 50 000.
46
46
 
@@ -369,6 +369,7 @@ curl -s --cert client.p12:password \
369
369
  "apphost": null
370
370
  },
371
371
  "hostFactsAt": "2026-10-16T09:00:00.000Z",
372
+ "lastSeenAt": "2026-10-16T10:04:51.000Z",
372
373
  "pushInstallEnabledUntil": "2026-10-16T10:30:00.000Z",
373
374
  "pushInstallPolicy": "default"
374
375
  },
@@ -384,30 +385,32 @@ curl -s --cert client.p12:password \
384
385
  "expiringSoon": false,
385
386
  "status": "revoked",
386
387
  "hostFacts": null,
387
- "hostFactsAt": null
388
+ "hostFactsAt": null,
389
+ "lastSeenAt": null
388
390
  }
389
391
  ]
390
392
  }
391
393
  ```
392
394
 
393
- | Field | Type | Description |
394
- | ------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
395
- | `label` | `string` | Agent identifier (matches certificate CN `agent:<label>`) |
396
- | `serial` | `string` | Certificate serial number |
397
- | `capabilities` | `string[]` | Granted capabilities |
398
- | `allowedSites` | `string[]` | Site names this agent can access for file operations |
399
- | `enrollmentMethod` | `string` | `"p12"`, `"hardware-bound"`, or `"delegated"` — how the agent certificate was issued |
400
- | `delegatedBy` | `string \| undefined` | For delegated certs: the label of the delegating agent |
401
- | `certType` | `string \| undefined` | `"plugin-agent"` for delegated certs, absent for regular agents |
402
- | `createdAt` | `string` | ISO 8601 creation timestamp |
403
- | `expiresAt` | `string` | ISO 8601 expiry timestamp |
404
- | `revoked` | `boolean` | Whether the certificate has been revoked |
405
- | `expiringSoon` | `boolean` | `true` if the certificate is not revoked and fewer than 30 days remain until expiry |
406
- | `status` | `string` | `"revoked"`, `"expired"`, `"expiring-soon"` (within 30 days) or `"active"`, in that order |
407
- | `hostFacts` | `object \| null` | What the agent last reported about its host (OS, distribution, architecture, Node.js, systemd, its app host helper) — see [Host facts](agent-hosted-plugins.md#host-facts); `null` until it has |
408
- | `hostFactsAt` | `string \| null` | When it reported them |
409
- | `pushInstallEnabledUntil` | `string \| undefined` | End of the agent's last push-install window (open while in the future); absent once it is closed |
410
- | `pushInstallPolicy` | `string \| undefined` | The push-install policy of that window |
395
+ | Field | Type | Description |
396
+ | ------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
397
+ | `label` | `string` | Agent identifier (matches certificate CN `agent:<label>`) |
398
+ | `serial` | `string` | Certificate serial number |
399
+ | `capabilities` | `string[]` | Granted capabilities |
400
+ | `allowedSites` | `string[]` | Site names this agent can access for file operations |
401
+ | `enrollmentMethod` | `string` | `"p12"`, `"hardware-bound"`, or `"delegated"` — how the agent certificate was issued |
402
+ | `delegatedBy` | `string \| undefined` | For delegated certs: the label of the delegating agent |
403
+ | `certType` | `string \| undefined` | `"plugin-agent"` for delegated certs, absent for regular agents |
404
+ | `createdAt` | `string` | ISO 8601 creation timestamp |
405
+ | `expiresAt` | `string` | ISO 8601 expiry timestamp |
406
+ | `revoked` | `boolean` | Whether the certificate has been revoked |
407
+ | `expiringSoon` | `boolean` | `true` if the certificate is not revoked and fewer than 30 days remain until expiry |
408
+ | `status` | `string` | `"revoked"`, `"expired"`, `"expiring-soon"` (within 30 days) or `"active"`, in that order |
409
+ | `hostFacts` | `object \| null` | What the agent last reported about its host (OS, distribution, architecture, Node.js, systemd, its app host helper) — see [Host facts](agent-hosted-plugins.md#host-facts); `null` until it has |
410
+ | `hostFactsAt` | `string \| null` | When it reported them |
411
+ | `lastSeenAt` | `string \| null` | When the agent last contacted the relay — its sync's `GET /api/tunnels/agent-config`, every 30 seconds (written to `state.db` at most once a minute, so a panel restart loses at most that minute). `null` until its first such call, and for a revoked agent. See [When the hosting agent is offline](agent-hosted-plugins.md#when-the-hosting-agent-is-offline) |
412
+ | `pushInstallEnabledUntil` | `string \| undefined` | End of the agent's last push-install window (open while in the future); absent once it is closed |
413
+ | `pushInstallPolicy` | `string \| undefined` | The push-install policy of that window |
411
414
 
412
415
  ---
413
416
 
@@ -70,7 +70,8 @@ Relay hooks are code Lamaste runs on the relay, and an [agent half Lamaste insta
70
70
 
71
71
  - Installing a plugin with relay hooks or an agent half Lamaste installs leaves them **pending**: the plugin can be enabled, but not hosted, and its forwarded API answers `503 { code: "hooks_not_approved" }` until the approval.
72
72
  - **Approving** names the digest the administrator saw; if what the version brings changed meanwhile the panel answers `409` with the current digest.
73
- - **Updating** a plugin whose new version brings another hook set or agent half waits for approval; the installed version stays live. **Auto-approve**, off by default and per plugin, approves such changes automatically — each use is in the audit log — except an agent half change that runs more as root: an agent half where there was none, another agent package, added system packages, another service entry or memory limit ([withheld](agent-hosted-plugins.md#approval)).
73
+ - **Updating** a plugin whose new version brings another hook set or agent half waits for approval; the installed version stays live. **Auto-approve**, off by default and per plugin, approves such changes automatically — each use is in the audit log — except an agent half change that runs more as root (an agent half where there was none, another agent package, added system packages, another service entry or memory limit; [withheld](agent-hosted-plugins.md#approval)).
74
+ - A version that turns a secret setting into a plain one — whose next value the relay would keep in clear — **always waits for an administrator**: settings are outside the digest, so this is a gate of its own, held even when the digest is already approved, when the plugin brings neither relay hooks nor an agent half, and whatever auto-approve says. The pending update says why it waits (`approvalRequired`), and its approval covers both.
74
75
  - Agent hooks are the plugin's own code on its own agent and need no approval. Shared hooks are part of the approved set by name and settings; their rules ride Lamaste releases (see below).
75
76
 
76
77
  Every approval, auto-approve change and auto-approval is recorded in the [audit log](audit-and-notifications.md) with the administrator certificate's serial.
@@ -132,11 +133,27 @@ Every approval, auto-approve change and auto-approval is recorded in the [audit
132
133
  }
133
134
  ```
134
135
 
135
- `failOpen` lists a hook's fail-open settings (`onError`, `onOversize`, `onUnscannable`); the panel highlights them. `relay.digest` is the relay hook set's own digest (what the runner holds); `approval.digest` is the one to approve, covering the relay hooks and `agentHalf` — the agent half Lamaste installs, `null` when there is none or it is installed by hand ([App Host](app-host.md)). `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, autoApproveWithheld }` while an update waits — `autoApproveWithheld` lists why auto-approve did not cover it (`null` otherwise).
136
+ `failOpen` lists a hook's fail-open settings (`onError`, `onOversize`, `onUnscannable`); the panel highlights them. `relay.digest` is the relay hook set's own digest (what the runner holds); `approval.digest` is the one to approve, covering the relay hooks and `agentHalf` — the agent half Lamaste installs, `null` when there is none or it is installed by hand ([App Host](app-host.md)). `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, settings, approvalRequired, autoApproveWithheld }` while an update waits — `digest` is the one to approve it with (`null` when it brings neither relay hooks nor an agent half: it is approved by its version), `settings` lists how the version changes the plugin's [settings schema](agent-hosted-plugins.md#in-the-plugin-list-and-the-update-preview) (`{ added: [{ key, type }], removed: [{ key, type }], retyped: [{ key, from, to }], newRequired: [key], secretToPlain: [key] }`, `null` when neither version declares settings; not part of the digest), `approvalRequired` why it waits for an administrator, and `autoApproveWithheld` why auto-approve, when on, did not cover it, in sentences (`null` otherwise):
137
+
138
+ ```json
139
+ "approvalRequired": [
140
+ { "code": "digest.unapproved", "reason": "its agent half is not what was approved" },
141
+ {
142
+ "code": "settings.secret-to-plain",
143
+ "reason": "it turns secret settings into plain ones (apiKey) — what is typed into them next is kept on the relay and shown in clear"
144
+ }
145
+ ]
146
+ ```
147
+
148
+ `digest.unapproved`: what it brings — relay hooks, an agent half Lamaste installs — is not what was approved (auto-approve may stand in for an administrator on it). `settings.secret-to-plain`: it turns secret settings into plain ones — always an administrator's call, its digest approved or not.
136
149
 
137
150
  ### `POST /api/plugins/:name/hooks/approve`
138
151
 
139
- `{ "digest": "…" }` — the installed set's digest (`200 { ok, hooks }`) or a pending update's (the update is applied: `202 { ok, operation }`). `409` when the set changed since it was shown.
152
+ `{ "digest": "…" }` — the installed set's digest (`200 { ok, hooks }`) or a pending update's (the update is applied: `202 { ok, operation }`). `409` with `currentDigest` when the set changed since it was shown.
153
+
154
+ `{ "digest": "…" | null, "version": "1.2.3" }` — the pending update to that version, named by its version and its digest (`null`, or left out, when it has none: an update that brings neither relay hooks nor an agent half and waits only because it turns a secret setting into a plain one). `409` when another version waits, or its digest changed (`currentDigest`, possibly `null`); `400` when it has a digest and none is given. The panel and `lamaste-server plugins approve-hooks` always name the version of an update they approve.
155
+
156
+ Approving a pending update approves everything it waits for (`approvalRequired`), whether its digest changed or is the one already approved. The version is fetched again and must still have the approved digest and turn no other secret setting plain than those shown — `409` otherwise, and nothing is applied. The approval is recorded once the update starts under it: `hooks.approved` with `for: "update"` and the `codes` it covered.
140
157
 
141
158
  ### `PUT /api/plugins/:name/hooks/auto-approve`
142
159
 
@@ -149,7 +166,7 @@ Every approval, auto-approve change and auto-approval is recorded in the [audit
149
166
  `{ "version"?: "1.2.3" | "latest" }` (default `latest`). For agent-hosted plugins. The panel fetches the version with `npm pack` and reads its manifest and relay module **in memory** — nothing of it is installed yet. Then:
150
167
 
151
168
  - `200 { upToDate: true }` — nothing to do;
152
- - `202 { pendingUpdate }` — what it brings (relay hook set, agent half) differs from what was approved and auto-approve is off or withheld; approve it with the digest shown;
169
+ - `202 { pendingUpdate }` — what it brings (relay hook set, agent half) differs from what was approved and auto-approve is off or withheld, or it turns a secret setting into a plain one (always); `approvalRequired` says which, and the administrators are notified. Approve it with the digest shown, or by its version;
153
170
  - `202 { operation }` — the update runs: the new hook module goes into the runner, the install pipeline runs with `phase: "upgrade"` around installing the package (agent stages through the tunnel), and a failure restores the previous package, row and module.
154
171
 
155
172
  The agent half of a hosted plugin whose agent half Lamaste installs is pinned when the version is staged and installed on the agent **first**, after the agent's pre stage; a failure after that activates the version that ran before ([Hosting with an agent half](agent-hosted-plugins.md#the-pipelines)). That is a push: the hosting agent must be eligible for the new version and push install must allow `update` — checked when the update starts, not while it waits for approval.
@@ -233,13 +250,13 @@ Relay hooks run in `lamalibre-lamaste-hooks@<route>.service`, started by its soc
233
250
 
234
251
  `lamaste-priv hooks install <route> <sha256|none>` (module on stdin, checked against the approved digest) writes the module root-owned to `/opt/lamalibre/lamaste/hooks/<route>/<sha256>.mjs` and enables the socket; `hooks activate` points back at the previous module (an update's revert); `hooks restart` stops a stuck runner; `hooks remove` disables it and deletes its modules. The panel reinstalls an approved runner at start-up if it is missing.
235
252
 
236
- The protocol between the panel and the runner is one length-prefixed JSON frame each way per connection (`@lamalibre/lamaste@4.3.0/hooks`, `protocol.ts`).
253
+ The protocol between the panel and the runner is one length-prefixed JSON frame each way per connection (`@lamalibre/lamaste@4.4.0/hooks`, `protocol.ts`).
237
254
 
238
255
  ## Writing hooks
239
256
 
240
257
  ```js
241
258
  // hooks/relay.mjs — bundled into one file (esbuild), Node built-ins only
242
- import { defineRelayHooks } from '@lamalibre/lamaste@4.3.0/hooks';
259
+ import { defineRelayHooks } from '@lamalibre/lamaste@4.4.0/hooks';
243
260
 
244
261
  export default defineRelayHooks({
245
262
  'install:pre-tunnel': async (input) =>
@@ -262,14 +279,16 @@ export default defineRelayHooks({
262
279
  | `request:post-forward` | `status`, `headers`, `request`, `route`, `body?` | `{ action: 'pass' }` / `{ action: 'replace', body, contentType?, setHeaders? }` / `{ action: 'block', status?, reasonId }` |
263
280
  | `tunnel:down` / `tunnel:up` | `state`, `reason`, `since`, `lastError?` | `{ fallback? }` (down) / `{}` |
264
281
 
282
+ `settings` in a relay hook's input is the plugin's effective non-secret [settings](agent-hosted-plugins.md#settings) — each stored value or its default, from the relay's cache (`{}` while it never read them); an agent hook gets `{}`, since the agent half holds the settings itself. Neither ever gets a secret.
283
+
265
284
  Every decision may carry `effects` (at most 10): `{ type: 'audit', event, data? }`, `{ type: 'notify-admins', message, level? }`, `{ type: 'log', message, level?, data? }` — rate-limited per plugin (60, 6 and 120 a minute). Header edits may not touch `Host`, framing, `Cookie`/`Set-Cookie`, `Authorization`, `X-Forwarded-*`, `X-Lamalibre-*`, `X-SSL-Client-*`, `Remote-*` or `X-Accel-*`.
266
285
 
267
286
  ### Agent hooks
268
287
 
269
- The agent half answers `POST /admin/hooks/<stage>` (body `{ stage, input }`, answer `{ ok: true, output? }` or `{ ok: false, reason }`). `createAgentHookHandler` from `@lamalibre/lamaste@4.3.0/agent` does the checking — the caller assertion must name Lamaste itself (`{ type: 'lamaste' }`), which no forwarded caller can, and the forwarder never reaches `/admin/hooks`:
288
+ The agent half answers `POST /admin/hooks/<stage>` (body `{ stage, input }`, answer `{ ok: true, output? }` or `{ ok: false, reason }`). `createAgentHookHandler` from `@lamalibre/lamaste@4.4.0/agent` does the checking — the caller assertion must name Lamaste itself (`{ type: 'lamaste' }`), which no forwarded caller can, and bind the SHA-256 of the exact body (`wrong_body` otherwise), so it needs the body **as received** (a `Buffer` or a string; a parsed object is refused `400` "The body must be passed as received"); the forwarder never reaches `/admin/hooks*`. Send the `status`, `headers` and `bodyText` it returns, `bodyText` byte for byte: its `X-Lamalibre-Responder` header signs those bytes, and the relay counts a hook answer without a valid one as a failure (`unauthenticated`) — see [Authentication between the relay and the agent half](agent-hosted-plugins.md#authentication-between-the-relay-and-the-agent-half):
270
289
 
271
290
  ```js
272
- import { createAgentHookHandler, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.3.0/agent';
291
+ import { createAgentHookHandler, createCallerAssertionVerifier } from '@lamalibre/lamaste@4.4.0/agent';
273
292
 
274
293
  const hooks = createAgentHookHandler({
275
294
  verifier: createCallerAssertionVerifier({ configPath, plugin: 'fx' }),
@@ -279,8 +298,9 @@ const hooks = createAgentHookHandler({
279
298
  'tunnel:down': () => queueLocally(),
280
299
  },
281
300
  });
282
- // in the agent half's router, for POST /admin/hooks/*:
283
- const { status, body } = await hooks.handle({ method, url, headers, body: rawBody });
301
+ // in the agent half's router, for POST /admin/hooks/* (the body as received):
302
+ const out = await hooks.handle({ method, url, headers, body: rawBody });
303
+ res.writeHead(out.status, out.headers).end(out.bodyText);
284
304
  ```
285
305
 
286
306
  The relay calls agent hooks through the tunnel while it exists. Before the tunnel is granted and after it is removed, the relay lists an **agent job** in `GET /api/tunnels/agent-config` (`agentJobs: [{ id, route, kind: "hook", stage, input, timeoutMs, deadlineAt }]`); the agent's sync calls the agent half on its own loopback with the installation secret it holds and reports `POST /api/agents/me/jobs/:id/result` (`{ ok: true, decision }` or `{ ok: false, error }`; hosting agent only, once). A job whose agent half does not answer yet waits for the next sync, until its deadline (timeout plus 90 s).
@@ -311,7 +331,9 @@ Matching runs on the raw text: a JSON-escaped PEM key is found, and nothing but
311
331
 
312
332
  ```
313
333
  lamaste-server plugins hooks <name> # the relay hook set, its digest and approval
314
- lamaste-server plugins approve-hooks <name> --digest <sha> # approve (or approve a pending update)
334
+ lamaste-server plugins approve-hooks <name> --digest <sha> [--version <v>]
335
+ # approve; a pending update by its version too
336
+ # (--version alone when it has no digest)
315
337
  lamaste-server plugins auto-approve <name> on|off
316
338
  lamaste-server plugins update <name> [--version <v>]
317
339
  lamaste-server plugins discard-update <name>
@@ -255,7 +255,7 @@ The wildcard check probes `test-lamaste-check.<domain>`. Wildcard DNS is optiona
255
255
 
256
256
  ### `POST /api/onboarding/provision`
257
257
 
258
- Starts the full stack provisioning process in the background. This configures and starts Chisel and Authelia, issues certbot certificates, and writes nginx vhosts. The Chisel and Authelia binaries and units are installed by `create-lamaste`, not here: if one is missing, the task fails with the error "Chisel is not installed. Run `npx @lamalibre/create-lamaste@4.3.0` on the server to repair the installation." (likewise for Authelia). Task IDs are unchanged from earlier versions; only the titles changed.
258
+ Starts the full stack provisioning process in the background. This configures and starts Chisel and Authelia, issues certbot certificates, and writes nginx vhosts. The Chisel and Authelia binaries and units are installed by `create-lamaste`, not here: if one is missing, the task fails with the error "Chisel is not installed. Run `npx @lamalibre/create-lamaste@4.4.0` on the server to repair the installation." (likewise for Authelia). Task IDs are unchanged from earlier versions; only the titles changed.
259
259
 
260
260
  Provisioning runs asynchronously. This endpoint returns immediately with a 202 status. Use the WebSocket stream endpoint to follow progress in real time.
261
261