@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.
- package/package.json +2 -2
- package/scripts/bundle-vendor.js +13 -8
- package/src/index.js +4 -0
- package/src/lib/panel-pin.js +41 -0
- package/src/lib/service-config.js +6 -0
- package/vendor/docs/00-introduction/how-it-works.md +2 -2
- package/vendor/docs/00-introduction/quickstart.md +5 -5
- package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
- package/vendor/docs/01-concepts/certificates.md +3 -3
- package/vendor/docs/01-concepts/mtls.md +2 -2
- package/vendor/docs/01-concepts/security-model.md +50 -2
- package/vendor/docs/01-concepts/tickets.md +2 -2
- package/vendor/docs/01-concepts/tunneling.md +4 -4
- package/vendor/docs/02-guides/agent-setup.md +6 -6
- package/vendor/docs/02-guides/certificate-management.md +2 -2
- package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
- package/vendor/docs/02-guides/desktop-app-setup.md +12 -11
- package/vendor/docs/02-guides/installation.md +3 -3
- package/vendor/docs/02-guides/onboarding.md +2 -2
- package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
- package/vendor/docs/03-architecture/installer-flow.md +2 -2
- package/vendor/docs/03-architecture/installer.md +4 -4
- package/vendor/docs/03-architecture/management-flow.md +1 -1
- package/vendor/docs/03-architecture/overview.md +5 -5
- package/vendor/docs/03-architecture/panel-client.md +7 -7
- package/vendor/docs/03-architecture/panel-server.md +2 -2
- package/vendor/docs/03-architecture/state-management.md +2 -2
- package/vendor/docs/03-architecture/system-overview.md +1 -1
- package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
- package/vendor/docs/04-api-reference/app-host.md +87 -38
- package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
- package/vendor/docs/04-api-reference/certificates.md +22 -19
- package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
- package/vendor/docs/04-api-reference/onboarding.md +1 -1
- package/vendor/docs/04-api-reference/overview.md +137 -135
- package/vendor/docs/04-api-reference/registry-and-oidc.md +1 -1
- package/vendor/docs/04-api-reference/tunnels.md +14 -10
- package/vendor/docs/05-operations/backup-and-restore.md +4 -1
- package/vendor/docs/05-operations/uninstalling.md +1 -1
- package/vendor/docs/05-operations/upgrades.md +6 -6
- package/vendor/docs/06-reference/config-files.md +94 -84
- package/vendor/docs/06-reference/glossary.md +3 -3
- package/vendor/docs/06-reference/installer-flags.md +13 -13
- package/vendor/docs/06-reference/ports-and-services.md +1 -1
- package/vendor/docs/06-reference/troubleshooting.md +8 -8
- package/vendor/docs/gatekeeper-design.md +8 -8
- package/vendor/docs/identity-directory-design.md +2 -2
- package/vendor/docs/package.json +1 -1
- package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
- package/vendor/gatekeeper/dist/cli/index.js +0 -0
- package/vendor/gatekeeper/package.json +2 -2
- package/vendor/server/bin/lamaste-server.js +11 -1
- package/vendor/server/package.json +4 -3
- package/vendor/server/src/commands/panel-pin.js +40 -0
- package/vendor/server/src/commands/plugin-settings.js +541 -0
- package/vendor/server/src/commands/plugins.js +94 -27
- package/vendor/server/src/panel-api.js +281 -34
- package/vendor/server/src/panel-identity.js +165 -0
- package/vendor/server/src/plugin-approval.js +78 -0
- package/vendor/server/src/plugin-settings.js +699 -0
- package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
- package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
- package/vendor/server-ui/dist/index.html +2 -2
- package/vendor/serverd/package.json +2 -2
- package/vendor/serverd/src/index.js +9 -0
- package/vendor/serverd/src/lib/agent-presence.js +106 -0
- package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
- package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
- package/vendor/serverd/src/lib/mtls.js +44 -4
- package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
- package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
- package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
- package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
- package/vendor/serverd/src/lib/plugins.js +73 -22
- package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
- package/vendor/serverd/src/routes/management/plugins.js +166 -19
- package/vendor/serverd/src/routes/management/tunnels.js +21 -1
- package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
- package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +0 -84
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Agent-Hosted Plugins
|
|
2
2
|
|
|
3
|
-
An **agent-hosted plugin** is an application that runs on one of your agents — typically a VM — while its admin pages live in the Lamaste panel. Lamaste never runs the plugin's code on the relay. This page covers what Lamaste adds for a plugin whose manifest has an `agent` block: hosting it on an agent, the internal plugin tunnel, the forwarded admin API, the installation secret
|
|
3
|
+
An **agent-hosted plugin** is an application that runs on one of your agents — typically a VM — while its admin pages live in the Lamaste panel. Lamaste never runs the plugin's code on the relay. This page covers what Lamaste adds for a plugin whose manifest has an `agent` block: hosting it on an agent, the internal plugin tunnel, the forwarded admin API, the installation secret, the health monitor and what the panel shows while the hosting agent is offline, the plugin's [settings and secrets](#settings) — and, for a plugin that names its agent package, installing, updating and removing that agent half on the agent from the panel ([Hosting with an agent half](#hosting-with-an-agent-half)). What happens on the agent machine itself — the root helper, the unit, the signed jobs — is in [App Host](app-host.md). The hooks a plugin can run around all of these — and their approval — are in [Lifecycle Hooks](lifecycle-hooks.md). The full design is `plugin-development/design/agent-hosted-app-plugins.md`.
|
|
4
4
|
|
|
5
5
|
## In Plain English
|
|
6
6
|
|
|
7
|
-
The plugin has two halves. The **agent half** is the application: it runs on the hosting agent, listens on the agent's loopback and holds the data. The **panel** serves the plugin's pages and forwards every call under `/api/<route>/` to the agent half, over a tunnel that exists only on the relay's loopback — no hostname, no certificate, no DNS. Each forwarded call carries a signed **caller assertion** naming who the panel admitted, so the agent half trusts the panel and nothing else that can reach the relay
|
|
7
|
+
The plugin has two halves. The **agent half** is the application: it runs on the hosting agent, listens on the agent's loopback and holds the data. The **panel** serves the plugin's pages and forwards every call under `/api/<route>/` to the agent half, over a tunnel that exists only on the relay's loopback — no hostname, no certificate, no DNS. Each forwarded call carries a signed **caller assertion** naming who the panel admitted, and every answer carries the agent half's **responder signature**, so the agent half trusts the panel and the panel trusts the agent half — and nothing else that can reach, or take, a loopback port on either machine ([Authentication between the relay and the agent half](#authentication-between-the-relay-and-the-agent-half)).
|
|
8
8
|
|
|
9
9
|
Hosting a plugin on an agent does three things at once: it creates the internal tunnel (Lamaste picks the port), issues the installation secret, and grants the agent's certificate the capability that lets it — and only it — fetch that secret. The agent's sync timer fetches the secret within about 30 seconds and hands it to the agent half; from then on the panel's pages work. When the manifest names the agent package, hosting also installs the agent half on the agent — the agent downloads exactly the version the relay pinned and its root helper sets it up as a service — once an administrator approved what it brings and the agent's push-install window is open.
|
|
10
10
|
|
|
@@ -39,6 +39,7 @@ Hosting a plugin on an agent does three things at once: it creates the internal
|
|
|
39
39
|
| `agent.service` | Required with `agent.package`, and only with it. `entry`: a relative `.js`, `.mjs` or `.cjs` path inside the package, run as the service; `memoryMax` (optional): the unit's `MemoryMax=`, `[1-9][0-9]{0,5}` followed by `K`, `M` or `G`. |
|
|
40
40
|
| `panel` | Allowed with an `agent` block: the pages are listed in the panel, and the bundle at `/api/<route>/panel.js` comes from the agent half (`/admin/panel.js`). |
|
|
41
41
|
| `hooks` | Lifecycle hooks on the relay and in the agent half — see [Lifecycle Hooks](lifecycle-hooks.md). |
|
|
42
|
+
| `settings` | What an administrator configures — values and secrets the agent half stores. Only with an `agent` block. See [Settings](#settings). |
|
|
42
43
|
|
|
43
44
|
There is no port in the manifest: Lamaste assigns it when the plugin is hosted.
|
|
44
45
|
|
|
@@ -84,11 +85,12 @@ The hosting record (`GET`, and `agentHosting` on the plugin list):
|
|
|
84
85
|
"version": "1.0.0",
|
|
85
86
|
"installedAt": "2026-10-15T10:01:30.000Z"
|
|
86
87
|
},
|
|
87
|
-
"pluginName": "market-indicators"
|
|
88
|
+
"pluginName": "market-indicators",
|
|
89
|
+
"agentLastSeenAt": "2026-10-15T10:04:51.000Z"
|
|
88
90
|
}
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
`phase` is `installing` (the install pipeline runs: secret delivered, tunnel not yet granted), `active`, or `removing` (the uninstall pipeline runs: tunnel gone, secret not yet). The forwarder and the health monitor use only active hostings. `agentHalf` is the version of the agent half Lamaste installed that the agent reported running (`null`: installed by hand, or not yet). `pluginName` is the plugin that owns the hosting: no operation of one plugin reads, changes or removes a hosting another plugin owns (`409`, `code: "hosting_owner_mismatch"`).
|
|
93
|
+
`phase` is `installing` (the install pipeline runs: secret delivered, tunnel not yet granted), `active`, or `removing` (the uninstall pipeline runs: tunnel gone, secret not yet). The forwarder and the health monitor use only active hostings. `agentHalf` is the version of the agent half Lamaste installed that the agent reported running (`null`: installed by hand, or not yet). `pluginName` is the plugin that owns the hosting: no operation of one plugin reads, changes or removes a hosting another plugin owns (`409`, `code: "hosting_owner_mismatch"`). `agentLastSeenAt` is when the hosting agent last contacted the relay (`null` until its first such call) — see [When the hosting agent is offline](#when-the-hosting-agent-is-offline).
|
|
92
94
|
|
|
93
95
|
### `GET /api/plugins/:name/hosting`
|
|
94
96
|
|
|
@@ -131,7 +133,7 @@ Every app job is signed by the relay with its signing key when it is created, an
|
|
|
131
133
|
|
|
132
134
|
### Host facts
|
|
133
135
|
|
|
134
|
-
Each agent reports its host on its sync when the facts change, and once a day: `PUT /api/agents/me/host-facts` (agent certificates only; 10 per minute, 4 KiB).
|
|
136
|
+
Each agent reports its host on its sync when the facts change, when the relay does not hold them, and once a day: `PUT /api/agents/me/host-facts` (agent certificates only; 10 per minute, 4 KiB). What the relay holds it tells the agent in every [`GET /api/tunnels/agent-config`](tunnels.md#get-api-tunnels-agent-config) answer: `hostFactsHash`, the SHA-256 of the facts stored on the agent's record (`hostFactsHash` in `@lamalibre/lamaste@4.4.0`, the same function the agent hashes its own with), `null` when it stores none. When that is not the hash of the agent's facts, the sync reports them whatever it last reported — so an agent enrolled again on its label (a new record, without host facts) or a relay restored from a backup can host again after one sync, not a day later. Behind a relay that does not send the field, the agent reports as before.
|
|
135
137
|
|
|
136
138
|
```json
|
|
137
139
|
{
|
|
@@ -153,7 +155,7 @@ Each agent reports its host on its sync when the facts change, and once a day: `
|
|
|
153
155
|
| `systemd` | systemd is the system manager |
|
|
154
156
|
| `apphost` | The app host helper; `null` when it is not enabled or does not answer. `version`: its protocol; `node`: the Node.js it — and every agent half it installs — runs on; `relayKid`: the id of the relay key pinned for this agent (`null`: none pinned) |
|
|
155
157
|
|
|
156
|
-
The relay keeps them on the agent's record (`hostFacts`, `hostFactsAt`; `GET /api/certs/agent`), re-validated on every read. The panel shows them on the Certificates page and in the host dialog. The agent asks its helper only when the helper file changed or a day passed (sudo logs every run); the pinned key is read on every sync, so a re-pin is reported at once. A panel older than 4.3 answers `404`, which the agent logs as a warning.
|
|
158
|
+
The relay keeps them on the agent's record (`hostFacts`, `hostFactsAt`; `GET /api/certs/agent`), re-validated on every read. The panel shows them on the Certificates page and in the host dialog. The agent asks its helper only when the helper file changed or a day passed (sudo logs every run); the pinned key is read on every sync, so a re-pin is reported at once. A panel older than 4.3 answers `404`, which the agent logs as a warning. `sync.log` records `host facts reported` for a change, and `host facts reported again (the relay held other facts or none)` for facts the relay did not hold.
|
|
157
159
|
|
|
158
160
|
### `GET /api/plugins/:name/hosting/eligibility`
|
|
159
161
|
|
|
@@ -172,7 +174,7 @@ Which enrolled machine agents can host the plugin, and why the others cannot —
|
|
|
172
174
|
}
|
|
173
175
|
```
|
|
174
176
|
|
|
175
|
-
For a plugin with `agent.package`, each agent's host facts are checked: it reported them at all; `agent.requires.os`; `agent.requires.node` against the helper's Node.js; Linux with systemd; the helper enabled at protocol
|
|
177
|
+
For a plugin with `agent.package`, each agent's host facts are checked: it reported them at all; `agent.requires.os`; `agent.requires.node` against the helper's Node.js; Linux with systemd; the helper enabled at protocol 2 or later (4.4; a 4.3 helper speaks 1); the relay's signing key usable and the one the agent's helper has pinned (`relayKid`); declared system packages only on Debian or Ubuntu. For an agent half installed by hand every agent is eligible — nothing is checked. Hosting and updating check it again (`409`, `code: "agent_not_eligible"`, with the `reasons`). Admin only.
|
|
176
178
|
|
|
177
179
|
### Push-install gates
|
|
178
180
|
|
|
@@ -206,7 +208,9 @@ What a version brings that Lamaste runs or installs — relay hooks and the agen
|
|
|
206
208
|
}
|
|
207
209
|
```
|
|
208
210
|
|
|
209
|
-
An update whose agent half differs waits for approval like a changed hook set: `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, autoApproveWithheld }` (`relay` and `agentHalf` are `null` when the version brings none). **Auto-approve** covers a new version and tarball of the same agent half — what every release brings — and is **withheld** when the version brings an agent half where there was none, changes the agent package, adds system packages, or changes the service entry or memory limit: those run as root on the agent and wait for an administrator. `autoApproveWithheld` says why; the audit log records `hooks.auto-approve.withheld` and the administrators are notified.
|
|
211
|
+
An update whose agent half differs waits for approval like a changed hook set: `pendingUpdate` is `{ version, digest, stagedAt, relay, agentHalf, settings, approvalRequired, autoApproveWithheld }` (`relay` and `agentHalf` are `null` when the version brings none, and `digest` when it brings neither; `settings` is how it changes the [settings schema](#in-the-plugin-list-and-the-update-preview), outside the digest). `approvalRequired` says why it waits, never empty: `[{ code, reason }]` with `digest.unapproved` (what it brings is not what was approved) and `settings.secret-to-plain` (below), each with a sentence. **Auto-approve** covers a new version and tarball of the same agent half — what every release brings — and is **withheld** when the version brings an agent half where there was none, changes the agent package, adds system packages, or changes the service entry or memory limit: those run as root on the agent and wait for an administrator. `autoApproveWithheld` says why, in sentences; the audit log records `hooks.auto-approve.withheld` with the sentences (`reasons`) and a stable code for each (`codes`: `agent-half.added`, `agent-half.package`, `agent-half.system-packages`, `agent-half.entry`, `agent-half.memory-max`, `settings.secret-to-plain`), and the administrators are notified.
|
|
212
|
+
|
|
213
|
+
**A secret turned plain always waits for an administrator.** Settings stay outside the digest, but an update that turns a secret setting into a plain one (`secretToPlain`) waits for approval — whatever is typed into the field next would be kept on the relay and shown in clear, which a release must not decide alone — even when its digest is already approved, when the plugin brings neither relay hooks nor an agent half Lamaste installs, and whether or not auto-approve is on (when it is, `autoApproveWithheld` and `hooks.auto-approve.withheld` name it as well). It is the same pending update, shown and approved the same way, and one approval covers everything it waits for: `POST /api/plugins/:name/hooks/approve` with the digest it shows, or with `{ digest, version }` — `{ version }` alone when it has no digest ([Approval](lifecycle-hooks.md#post-api-plugins-name-hooks-approve)). Approved, the version is fetched again and must still have that digest and turn no other secret plain than those shown (`409` otherwise). `plugin.update.awaiting-approval` and the update's `hooks.approved` carry the codes of what it waited for (`codes`), and the administrators are notified with the reasons. Any other update that needs no approval — no relay hooks and an agent half installed by hand, or a version whose digest is already approved — runs at once, whatever else it does to the settings.
|
|
210
214
|
|
|
211
215
|
### Retained data
|
|
212
216
|
|
|
@@ -333,17 +337,30 @@ What the agent's host offers — see [Host facts](#host-facts).
|
|
|
333
337
|
|
|
334
338
|
`lamaste-agent sync` (the 30-second timer) fetches a plugin's config when it does not hold the secret issued at `secretIssuedAt`, writes `~/.lamalibre/lamaste/agents/<label>/app-plugins/<route>.json` (0600, atomic), acknowledges it, and removes the file of a plugin the relay no longer lists. For an agent half Lamaste installed it first writes the service's own copy, `/etc/lamalibre/apps/<route>/plugin-config.json`, and acknowledges only once both are written. `sync.log` records `plugin config fetched: <route>` and `plugin config delivered to its service: <route>`.
|
|
335
339
|
|
|
336
|
-
The agent half reads
|
|
340
|
+
The agent half reads its plugin config — `LAMASTE_APP_CONFIG` for an agent half Lamaste installs, the file above for one installed by hand (`LAMALIBRE_LAMASTE_APP_PLUGIN_CONFIG` by convention) — and verifies every request on `/admin/*` with `createCallerAssertionVerifier` from `@lamalibre/lamaste@4.4.0/agent`, which re-reads the file when a signature does not verify, so a rotation needs no restart. It listens where `agentHalfListenTarget` says: for an agent half Lamaste installs, systemd holds the port in a socket unit and passes the listening socket (descriptor 3), so the port stays bound while the agent half restarts or is updated and no other local process can take it in between — such an agent half must listen this way, and one that binds its port itself fails with `EADDRINUSE`; for one installed by hand, the config's port on `127.0.0.1` ([App Host](app-host.md#writing-an-agent-half)). Every answer it gives a verified request carries the responder signature ([below](#authentication-between-the-relay-and-the-agent-half)):
|
|
337
341
|
|
|
338
342
|
```js
|
|
339
|
-
import {
|
|
343
|
+
import {
|
|
344
|
+
agentHalfListenTarget,
|
|
345
|
+
createCallerAssertionVerifier,
|
|
346
|
+
createHealthHandler,
|
|
347
|
+
responderHeaderFor,
|
|
348
|
+
} from '@lamalibre/lamaste@4.4.0/agent';
|
|
340
349
|
|
|
341
350
|
const verifier = createCallerAssertionVerifier({ configPath, plugin: 'market-indicators' });
|
|
342
|
-
const
|
|
343
|
-
//
|
|
351
|
+
const health = createHealthHandler({ verifier, check: () => db.isOpen() });
|
|
352
|
+
// systemd's socket (fd 3) for an agent half Lamaste installs, else 127.0.0.1:<port>:
|
|
353
|
+
await app.listen(agentHalfListenTarget({ port: await verifier.port() }));
|
|
354
|
+
|
|
355
|
+
// GET /admin/health — what the relay's health monitor probes:
|
|
356
|
+
const out = await health.handle({ method, url, headers, body: rawBody });
|
|
357
|
+
res.writeHead(out.status, out.headers).end(out.bodyText);
|
|
358
|
+
|
|
359
|
+
// each of the plugin's own /admin routes:
|
|
344
360
|
const result = await verifier.verify(req.headers, req.method, req.url);
|
|
345
|
-
if (!result.ok) return reply.code(401).send({ error: result.reason });
|
|
361
|
+
if (!result.ok) return reply.code(401).send({ error: result.reason }); // no responder header
|
|
346
362
|
// result.payload.caller is { type: 'admin' }, { type: 'agent', label } or { type: 'lamaste' }
|
|
363
|
+
reply.headers(responderHeaderFor(result, 200)).code(200).send(page);
|
|
347
364
|
```
|
|
348
365
|
|
|
349
366
|
## The forwarded admin API
|
|
@@ -351,18 +368,353 @@ if (!result.ok) return reply.code(401).send({ error: result.reason });
|
|
|
351
368
|
Once the plugin is enabled and hosted, the panel answers `/api/<route>` and `/api/<route>/*`:
|
|
352
369
|
|
|
353
370
|
- **Authentication** is the panel's: an administrator certificate, or an agent certificate holding one of the plugin's declared capabilities. `plugin:<route>:host` does not admit calls — hosting the application is not using it. `403` otherwise.
|
|
354
|
-
- **Mapping:** `/api/<route>/<rest>?query` → `http://127.0.0.1:<port>/admin/<rest>?query` on the relay's loopback, the relay end of the internal tunnel. Only `/admin/*` of the agent half is reachable; its `/app/*` (the user surface) is not, and neither is `/admin
|
|
371
|
+
- **Mapping:** `/api/<route>/<rest>?query` → `http://127.0.0.1:<port>/admin/<rest>?query` on the relay's loopback, the relay end of the internal tunnel. Only `/admin/*` of the agent half is reachable; its `/app/*` (the user surface) is not, and neither is anything Lamaste keeps for itself: a path whose segment after `/admin` — percent-decoded until it no longer changes (at most three rounds; one that still decodes, or does not decode, counts as reserved), lower-cased and stripped of everything but `a-z` and `0-9` — **starts with** `hooks` (Lamaste's lifecycle hook calls) or `settings` (where Lamaste writes the plugin's [settings](#between-the-relay-and-the-agent-half)). `/api/<route>/Settings`, `/api/<route>/%2573ettings`, `/api/<route>/settings.json` and `/api/<route>/hooks-v2/x` all answer `404` and reach nothing, whatever the agent half's router would make of them — so an agent half's own routes cannot be named so. A path segment with an encoded `/` or `\` is refused (`400`).
|
|
355
372
|
- **Request hooks:** a plugin's approved `request:pre-forward` / `request:post-forward` hooks run on the routes they name; a plugin whose relay hooks are not approved is refused (`503`, `code: "hooks_not_approved"`). See [Lifecycle Hooks](lifecycle-hooks.md#request).
|
|
356
|
-
- **Caller assertion:** `X-Lamalibre-Caller: <base64url(payload)>.<base64url(hmac)>` — payload `{ v: 1, plugin, caller, method, path, iat, nonce }`, HMAC-SHA256 with the installation secret, valid 60 seconds (5 seconds of clock skew), nonces refused on a second sight.
|
|
373
|
+
- **Caller assertion:** `X-Lamalibre-Caller: <base64url(payload)>.<base64url(hmac)>` — payload `{ v: 1, plugin, caller, method, path, iat, nonce }`, HMAC-SHA256 with the installation secret, valid 60 seconds (5 seconds of clock skew), nonces refused on a second sight. A forwarded request streams, so its assertion carries no body hash ([below](#authentication-between-the-relay-and-the-agent-half)).
|
|
374
|
+
- **The answer must authenticate itself:** before the client sees a status, a header or a byte, the forwarder checks the answer's `X-Lamalibre-Responder` (the `-` form, over the request's nonce and the answer's status). An answer without a valid one — from whatever took the port while the hosting agent was away, or from an agent half built against a core older than 4.4 — is `502` with `reason: "app"` (or the `tunnel:down` fallback while the plugin is down), so nothing but the agent half can serve the plugin's pages or API.
|
|
357
375
|
- **Streaming:** request and response bodies are piped, never buffered; `text/event-stream` responses pass through unbuffered (nginx is told `X-Accel-Buffering: no`). The tunnel's body limit (`maxBodySizeMb`, default 10) is enforced: a larger `Content-Length` is `413` before anything is sent, and a chunked body that grows past it is cut off.
|
|
358
376
|
- **Timeouts:** `504` when the agent half has not sent its headers after 30 seconds; a response body (not an event stream) that stays silent for five minutes is cut off.
|
|
359
|
-
- **Headers:** the caller's `Cookie` and `Authorization`, nginx's `X-SSL-Client-*`, anything under `X-Lamalibre-*` or `Remote-*`, and hop-by-hop headers never reach the agent half
|
|
360
|
-
- **
|
|
377
|
+
- **Headers:** the caller's `Cookie` and `Authorization`, nginx's `X-SSL-Client-*`, anything under `X-Lamalibre-*` or `Remote-*`, and hop-by-hop headers never reach the agent half. From the application, `Set-Cookie`, `Service-Worker-Allowed` and `Clear-Site-Data` (an answer must not widen a service worker's scope over the panel or wipe the panel's storage) and anything under `X-Lamalibre-*` (the responder signature is the relay's to check) never reach the client.
|
|
378
|
+
- **No service workers:** a request that fetches a service worker's script (`Service-Worker: script`, any value) is refused with `403` before it enters the tunnel — no plugin installs a service worker on the panel's origin. A plugin's admin bundle runs in the panel's own document; that is a boundary of its own until plugin UIs are isolated in sandboxed frames ([Security Model](../01-concepts/security-model.md), 5e).
|
|
379
|
+
- **Failures:** `502` with `reason: "tunnel"` when nothing listens on the port (the agent is not connected), `502` with `reason: "app"` when the connection drops before the headers or the answer does not authenticate itself (the tunnel is up, the agent half is not — or something else holds the port), `503` while the plugin is disabled or not yet hosted. While the plugin is down, its `tunnel:down` hook's fallback answers instead of the 502.
|
|
361
380
|
- **Logging:** one line per request — plugin, caller, method, path without its query, status, duration. Never a body.
|
|
362
381
|
|
|
382
|
+
## Authentication between the relay and the agent half
|
|
383
|
+
|
|
384
|
+
The internal tunnel ends on loopback ports: on the relay, the port chisel listens on while the hosting agent is connected; on the agent, the port the agent half listens on. Every process on either machine can reach them — and **take** them: on the relay while the agent is offline, on the agent while an agent half installed by hand restarts (for one Lamaste installs, systemd holds the port in a socket unit, so it stays bound across restarts and updates — [App Host](app-host.md)). So neither end trusts the network position, and **anything that takes a tunnel's loopback port gets nothing and can serve nothing**. All three mechanisms below are keyed with the hosting's installation secret, which only the relay's panel, the hosting agent's account and the agent half's own account hold.
|
|
385
|
+
|
|
386
|
+
**Requests carry a caller assertion bound to their body.** Lamaste's own calls with a body — settings changes, hook calls — carry `bodySha256` in the assertion: the SHA-256 of the exact body bytes, 64 lower-case hex characters. `createSettingsHandler` (`PUT`) and `createAgentHookHandler` require it and compare it with the bytes received; an assertion lifted onto another body is refused (`401`, reason `wrong_body`) without burning its nonce, so the true request still verifies. The handlers therefore need the body **exactly as received** — a `Buffer` (or `Uint8Array`) or a string; an object a framework already parsed is refused with `400` "The body must be passed as received". With Fastify, register a `parseAs: 'buffer'` content-type parser for these routes; with `node:http`, collect the chunks. Forwarded requests stream, so theirs carries no hash. Verifiers built before the field existed ignore it.
|
|
387
|
+
|
|
388
|
+
**Answers carry a responder signature.** Every answer an agent half gives to a request whose assertion verified carries
|
|
389
|
+
|
|
390
|
+
```
|
|
391
|
+
X-Lamalibre-Responder: base64url(HMAC-SHA256(secret,
|
|
392
|
+
"lamaste.responder.v1\n" + nonce + "\n" + status + "\n" + (bodySha256 | "-")))
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
keyed with the secret that verified the request (the previous one during a rotation), over the request's assertion nonce, the decimal HTTP status and — for the buffered answers to Lamaste's own calls — the SHA-256 of the exact answer body in lower-case hex; `-` for answers to forwarded calls, which stream. The relay checks it in constant time before it believes or passes on anything:
|
|
396
|
+
|
|
397
|
+
| Who checks | Form | Without a valid signature |
|
|
398
|
+
| ---------------------------------------------------- | ---- | -------------------------------------------------------------------------- |
|
|
399
|
+
| `callAgentHalf` (settings reads and changes) | body | failure `unauthenticated`; a settings change answers `502 unauthenticated` |
|
|
400
|
+
| `callAgentHalfHook` (agent hooks through the tunnel) | body | failure `unauthenticated`, a failed hook |
|
|
401
|
+
| The health monitor (`GET /admin/health`) | body | a failure with reason `app`, however the port answered |
|
|
402
|
+
| The forwarder (every forwarded `/admin/*` answer) | `-` | `502` with `reason: "app"` before anything reaches the client |
|
|
403
|
+
|
|
404
|
+
**Every `/admin/*` response of an agent half to a verified request must carry the header**, and core's helpers produce it:
|
|
405
|
+
|
|
406
|
+
- `createSettingsHandler`, `createAgentHookHandler` and `createHealthHandler` return `{ status, body, bodyText, headers }`: send `status`, `headers` (`content-type` and the responder header) and **`bodyText`, byte for byte** — the signature covers those bytes; `body` is the parsed answer, for inspection, and re-serializing it would break the signature;
|
|
407
|
+
- **`createHealthHandler({ verifier, check? })`** answers `GET /admin/health`: `200 { ok: true }`, or `503 { ok: false }` when `check` returns false, throws or rejects (its message is not passed on); any verified caller may ask;
|
|
408
|
+
- **`responderHeaderFor(verified, status)`** gives the header (the `-` form) for the agent half's own routes — its pages, `panel.js`, its API — to merge into every answer to a verified forwarded request, with the status it sends.
|
|
409
|
+
|
|
410
|
+
Answers given before or instead of a verification — a `401` for a bad assertion, a `404` or `405` no handler matched, the `400` for a parsed body — carry no responder header: there is no verified request to bind it to, and the relay treats them as not the agent half's.
|
|
411
|
+
|
|
412
|
+
**Secret settings travel sealed** to the one request that carries them ([Between the relay and the agent half](#between-the-relay-and-the-agent-half)).
|
|
413
|
+
|
|
414
|
+
**Agent halves built against `@lamalibre/lamaste@4.4.0` 4.3 must be rebuilt** against this version: an agent half that does not sign its answers is down (`app`) to the health monitor, its pages and API answer `502`, and its settings and hooks fail as `unauthenticated`; one that expects secrets in clear refuses sealed ones.
|
|
415
|
+
|
|
363
416
|
## Health
|
|
364
417
|
|
|
365
|
-
Every 10 seconds, for every active hosting of an enabled plugin, the panel connects to the relay-loopback port and then calls `GET /admin/health` on the agent half with a caller assertion naming Lamaste itself (`{ "type": "lamaste" }`). A refused connect means the **tunnel** is down; a failed health call means the tunnel is up and the **application** is down. The state changes to `down` after three consecutive failures of the same reason and to `up` after two consecutive successes; each transition is persisted (`health` on the hosting record, with `reason`, `since`, `lastError`) and runs the plugin's `tunnel:down` / `tunnel:up` relay hook. The forwarder still attempts every request; the state decides whether a failure is answered with the hook's fallback.
|
|
418
|
+
Every 10 seconds, for every active hosting of an enabled plugin, the panel connects to the relay-loopback port and then calls `GET /admin/health` on the agent half with a caller assertion naming Lamaste itself (`{ "type": "lamaste" }`). Only a `2xx` answer authenticated with the responder signature (`createHealthHandler`) counts: whatever squats the port without the installation secret is not the plugin's application, however it answers. A refused connect means the **tunnel** is down; a failed or unauthenticated health call means the tunnel is up and the **application** is down. The state changes to `down` after three consecutive failures of the same reason and to `up` after two consecutive successes; each transition is persisted (`health` on the hosting record, with `reason`, `since`, `lastError`) and runs the plugin's `tunnel:down` / `tunnel:up` relay hook. The forwarder still attempts every request; the state decides whether a failure is answered with the hook's fallback.
|
|
419
|
+
|
|
420
|
+
## When the hosting agent is offline
|
|
421
|
+
|
|
422
|
+
A hosting that is `down` is one of two outages. Reason **`tunnel`**: nothing listens on the internal tunnel's port on the relay — the hosting agent is offline, or its chisel client is. Reason **`app`**: the tunnel answers and the agent half does not. For the first, what an administrator wants to know is how long the agent has been gone, which the health monitor's `since` cannot say (it is when the monitor noticed), so the relay keeps each agent's **last contact**.
|
|
423
|
+
|
|
424
|
+
Every call an agent certificate makes to `GET /api/tunnels/agent-config` — the agent's sync, every 30 seconds — records that agent; an administrator naming an agent with `?agent=` does not. The time is kept in memory and written to `state.db` (`agent_presence`) at most once a minute per agent, never through the `agents` table, which the registry rewrites whole; a restart of the panel loses at most that minute. Revoking the agent's certificate forgets it, so a later enrollment under the label does not inherit it. It is reported as:
|
|
425
|
+
|
|
426
|
+
- `lastSeenAt` on each entry of `GET /api/certs/agent` — `null` until the agent's first such call, and for a revoked agent;
|
|
427
|
+
- `agentLastSeenAt` on a hosting — `GET /api/plugins/:name/hosting`, `agentHosting` on `GET /api/plugins` — and on `hosting` of the [settings view](#get-api-plugins-name-settings).
|
|
428
|
+
|
|
429
|
+
What the panel shows while a hosting is down:
|
|
430
|
+
|
|
431
|
+
- **The plugin's pages** check the hosting before they fetch the bundle — which comes from the agent half, so it could only fail, after the forwarder's 30-second timeout. They show "Hosting agent vm1 is offline since 14:05:12 (12m ago) — last sync 14:04:40 (13m ago)" for `tunnel` or "The agent half is not responding since 14:05:12 (12m ago)" for `app`, read the plugin list again every 10 seconds and open the pages once the hosting is up. A plugin that is not hosted, or whose hosting is being set up or removed, says so in the same place. Pages already open stay open, with the outage shown above them. A server plugin loads as before, and so does any plugin when the plugin list cannot be read.
|
|
432
|
+
- **The sidebar** marks the plugin's pages, with the same sentence.
|
|
433
|
+
- **The plugin's card** on the Plugins page shows the outage with its health, and its Settings section is read-only from the relay's cache, with the same reason ([Settings](#in-the-panel)).
|
|
434
|
+
|
|
435
|
+
`lamaste-server plugins settings <name>` prints the same sentence, and `plugins configure` refuses with it.
|
|
436
|
+
|
|
437
|
+
## Settings
|
|
438
|
+
|
|
439
|
+
A plugin with an agent half may declare **settings**: what an administrator configures for it — an API key, a schedule, a region. The **agent half is the system of record**: it stores the values in its own data directory and answers `GET` and `PUT /admin/settings` for Lamaste alone. The panel shows them as a form, checks a change and carries it to the agent half through the internal tunnel; it keeps a cache of the non-secret values and of _when_ each secret was set — never a secret's value. A secret exists on the relay in clear only in the memory of the request that carries it, and crosses the tunnel sealed to that request, so not even whatever takes the tunnel's port can read it ([Security Model](../01-concepts/security-model.md), 5e).
|
|
440
|
+
|
|
441
|
+
Settings are not part of the [approval digest](#approval): they declare data, not code, and nothing they change runs with more privilege — but an update that turns a secret setting into a plain one waits for an administrator's approval all the same ([Approval](#approval)). Because the agent half holds them, they can be changed only while its hosting is up; while it is offline the panel shows them from its cache, read-only.
|
|
442
|
+
|
|
443
|
+
### The settings block
|
|
444
|
+
|
|
445
|
+
```json
|
|
446
|
+
"settings": {
|
|
447
|
+
"schemaVersion": 1,
|
|
448
|
+
"fields": [
|
|
449
|
+
{ "key": "apiKey", "label": "API key", "type": "secret", "required": true },
|
|
450
|
+
{
|
|
451
|
+
"key": "schedule",
|
|
452
|
+
"label": "Daily sync time",
|
|
453
|
+
"type": "string",
|
|
454
|
+
"pattern": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
|
|
455
|
+
"default": "06:00"
|
|
456
|
+
},
|
|
457
|
+
{ "key": "retries", "label": "Retries", "type": "integer", "min": 0, "max": 10, "default": 3 },
|
|
458
|
+
{
|
|
459
|
+
"key": "region",
|
|
460
|
+
"label": "Region",
|
|
461
|
+
"type": "enum",
|
|
462
|
+
"options": [
|
|
463
|
+
{ "value": "eu", "label": "Europe" },
|
|
464
|
+
{ "value": "us", "label": "United States" }
|
|
465
|
+
]
|
|
466
|
+
},
|
|
467
|
+
{ "key": "verbose", "label": "Verbose logging", "type": "boolean", "default": false }
|
|
468
|
+
]
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Only in a manifest with an `agent` block — the agent half stores the values (`400`, "settings needs an agent block"). Both the block and its fields are strict: an unknown property is refused. Text an administrator reads — `label`, `description`, an option's `value` and `label`, a string `default` and `pattern` — refuses C0 and C1 control characters and the bidirectional marks, embeddings, overrides and isolates (U+061C, U+200E/F, U+202A–U+202E, U+2066–U+2069), which could make a field read as something it is not ("Contains control characters").
|
|
473
|
+
|
|
474
|
+
| Property | Meaning |
|
|
475
|
+
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
476
|
+
| `schemaVersion` | An integer of at least 1, bumped by the plugin when its fields change. Every change the relay sends names the installed manifest's version, and an agent half running another one refuses it (`409`, `schema_mismatch`) — during an update, until both sides run the same version. |
|
|
477
|
+
| `fields` | 1–50 fields; keys are unique. |
|
|
478
|
+
| `key` | A lower-case letter, then letters and digits, at most 64 characters (`^[a-z][A-Za-z0-9]{0,63}$`). |
|
|
479
|
+
| `label` | 1–100 characters. `description` (optional): at most 500. |
|
|
480
|
+
| `type` | `string`, `secret` (a non-empty string the panel never shows), `integer` (a safe integer), `number` (finite), `boolean` or `enum`. |
|
|
481
|
+
| `required` | A required secret that is not set, or a required non-secret field with neither a stored value nor a default, leaves the plugin **needing configuration**. |
|
|
482
|
+
| `default` | Never on a secret. Must itself be a valid value of the field: its type, length, range and option are checked when the manifest is validated, its `pattern` by the agent half when it reads its own manifest (`settingsFromManifest`) — the relay never runs a manifest's pattern, not even against its default. |
|
|
483
|
+
| `maxLength` | `string` and `secret` only: 1–4096; 1024 when not set. |
|
|
484
|
+
| `min`, `max` | `integer` and `number` only; `min` not greater than `max`. |
|
|
485
|
+
| `pattern` | `string` only, at most 256 characters; must compile as `new RegExp(pattern, 'u')`. Tested **unanchored** — anchor it with `^` and `$` when it means the whole value. |
|
|
486
|
+
| `options` | `enum` only, and required there: 1–100 `{ value, label }` (a value of 1–200 characters, unique; a label of 1–100). |
|
|
487
|
+
|
|
488
|
+
**Who tests `pattern`.** The form, in the administrator's browser, and the agent half — never the panel process, and never `lamaste-server`, which runs as root on the relay: a manifest's regular expression must not be able to stall the panel or run as root. The relay validates manifests at install, at update and at every start, so the manifest schema only compiles a pattern (linear in its length) and never runs it — a catastrophic pattern with a default it does not match is checked instantly. The panel checks everything else — type, required, length, range, option — so a value that only the pattern refuses comes back from the agent half (`422`). **Validation messages never contain the value**: a secret typed into the wrong field must not come back in an error, a log line or a response. That is why settings values are checked by hand and not with zod's enum or literal schemas, whose messages repeat what they received.
|
|
489
|
+
|
|
490
|
+
### Reading and changing settings
|
|
491
|
+
|
|
492
|
+
Admin only.
|
|
493
|
+
|
|
494
|
+
#### `GET /api/plugins/:name/settings`
|
|
495
|
+
|
|
496
|
+
```json
|
|
497
|
+
{
|
|
498
|
+
"schemaVersion": 1,
|
|
499
|
+
"fields": [{ "key": "apiKey", "label": "API key", "type": "secret", "required": true }, "…"],
|
|
500
|
+
"values": {
|
|
501
|
+
"apiKey": { "set": true, "setAt": "2026-10-16T10:00:00.000Z" },
|
|
502
|
+
"schedule": "06:00",
|
|
503
|
+
"retries": 5,
|
|
504
|
+
"verbose": false
|
|
505
|
+
},
|
|
506
|
+
"configured": true,
|
|
507
|
+
"missing": [],
|
|
508
|
+
"revision": 3,
|
|
509
|
+
"source": "live",
|
|
510
|
+
"syncedAt": "2026-10-16T10:05:00.000Z",
|
|
511
|
+
"hosting": {
|
|
512
|
+
"agentLabel": "vm1",
|
|
513
|
+
"phase": "active",
|
|
514
|
+
"state": "up",
|
|
515
|
+
"reason": null,
|
|
516
|
+
"since": "2026-10-16T09:00:00.000Z",
|
|
517
|
+
"agentLastSeenAt": "2026-10-16T10:04:51.000Z"
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
| Field | Meaning |
|
|
523
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
524
|
+
| `fields` | The installed manifest's fields, as declared (`pattern` included, for the form) |
|
|
525
|
+
| `values` | Each non-secret field's effective value — the stored one, else its default; absent when it has neither (`region` above). Each secret as `{ "set": true, "setAt" }` or `{ "set": false }` — never a value |
|
|
526
|
+
| `configured`, `missing` | Whether a required field lacks a value, and which, in schema order — as the agent half reported them |
|
|
527
|
+
| `revision` | The agent half's revision (+1 per change), which a change names; `null` while the relay never read the settings |
|
|
528
|
+
| `source` | `live`: read from the agent half for this answer (the hosting is `active` and its health is not `down`; 5 s). `cache`: the relay's last read, at `syncedAt` — the agent half is offline or did not answer. `none`: never read — the defaults, `revision` `null` |
|
|
529
|
+
| `hosting` | `null` when the plugin is not hosted; else the hosting agent, the hosting's `phase`, the health monitor's `state`, `reason` and `since`, and when the agent last contacted the relay ([offline](#when-the-hosting-agent-is-offline)) |
|
|
530
|
+
|
|
531
|
+
`404` when the plugin is not installed, and with `code: "no_settings"` when it declares no settings.
|
|
532
|
+
|
|
533
|
+
#### `PUT /api/plugins/:name/settings`
|
|
534
|
+
|
|
535
|
+
```json
|
|
536
|
+
{
|
|
537
|
+
"revision": 3,
|
|
538
|
+
"values": { "retries": 5, "region": null },
|
|
539
|
+
"secrets": { "apiKey": "…" }
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
A partial change: only the keys it names change. `revision` is the revision the administrator read. A secret goes in `secrets`, every other field in `values` (each in the wrong one is refused per key), and the body holds nothing else. `null` puts a non-secret field back to its default — or unset, when it has none — and clears a secret; both are refused for a field that must have a value. At most 2 MiB (`413`); 30 requests a minute. The change is writable while the hosting is `active` and its health is not `down` — `unknown`, right after hosting, included: an answer that does not authenticate itself as the agent half's is refused, so nothing else on the tunnel's port can take a change.
|
|
544
|
+
|
|
545
|
+
The panel checks the change against the installed schema — without `pattern` — and sends it to the agent half (`PUT /admin/settings`, 15 s), each secret sealed to the call, and the agent half's authenticated answer decides: on success the panel caches the agent half's new state, records the change and answers `200` with the settings as `GET` returns them (`source: "live"`). A change that changes nothing stores nothing and answers the settings as they are. Changes and reads of one plugin's settings run one at a time, so a slow read cannot put back what a change replaced.
|
|
546
|
+
|
|
547
|
+
| Status | `code` | When |
|
|
548
|
+
| ------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
549
|
+
| 400 | — | The panel refused it: `errors`, a message per field key (`_` for the body as a whole, and for names the schema does not know — counted, `"2 unknown settings"`, never named) |
|
|
550
|
+
| 404 | `no_settings` | The plugin declares no settings (without a code: it is not installed) |
|
|
551
|
+
| 409 | `operation_running` | An operation of the plugin runs (`operationId`) — an update may be changing the schema |
|
|
552
|
+
| 409 | `revision_mismatch` | The settings changed since they were read; `revision` is the current one. Read them again and make the change on them |
|
|
553
|
+
| 409 | `schema_mismatch` | The agent half runs another version of the settings schema — let the plugin's update finish |
|
|
554
|
+
| 409 | `conflict` | The agent half refused it as conflicting, for another reason |
|
|
555
|
+
| 422 | — | The agent half refused it: `errors` per field — its `pattern`, its own schema, or a sealed secret that did not open ("Could not be decrypted") |
|
|
556
|
+
| 502 | `settings_unsupported` | The agent half does not answer `/admin/settings` (`404`) — it predates settings; update the plugin |
|
|
557
|
+
| 502 | `refused` | Nothing answers on the internal tunnel's port |
|
|
558
|
+
| 502 | `unauthenticated` | The answer did not carry a valid responder signature: something else may hold the tunnel's port, or the agent half was built against an older `@lamalibre/lamaste@4.4.0` and must be rebuilt. The change may or may not have been stored — read the settings again |
|
|
559
|
+
| 502 | `invalid`, `other` | The agent half answered too large a body or in another shape (reload the settings to see what it stored), or could not be reached |
|
|
560
|
+
| 502 | `agent_half_error` | The agent half answered another status (`status`) |
|
|
561
|
+
| 503 | `hosting_offline` | The plugin is not hosted, its hosting is not `active`, or its health is `down` — with `hosting` as in `GET` (`null` when not hosted) |
|
|
562
|
+
| 504 | `timeout` | No answer within 15 s — the change may or may not have been stored; read the settings again |
|
|
563
|
+
|
|
564
|
+
Once the plugin and its settings block are found, the checks run in this order: the body (`400`), a running operation (`409`), the hosting (`503`), then the agent half. A refusal answers `{ error, code?, errors?, revision?, operationId?, hosting?, status? }`, built from what Lamaste found and never from the request body. The agent half's per-field messages are passed on only under the keys of the installed schema (or `_`), and only when they are messages Lamaste's own validation produces — the fixed ones (`Is required`, `Does not match the required format`, …) and the templated ones (`Must be at most 64 characters`, `Must be at least 0`, `2 unknown settings`), matched exactly; anything else reads `Invalid value` (`normalizeSettingsErrorMessage` in `@lamalibre/lamaste@4.4.0`). The agent half is Lamaste code that never repeats a value, but the relay does not answer an administrator with text it cannot vouch for.
|
|
565
|
+
|
|
566
|
+
A stored change is recorded in the [audit log](audit-and-notifications.md) as `plugin.settings.updated` with key names only — `changed`, `cleared`, `secretsSet`, `secretsCleared` — the new `revision` and the certificate serial. A change whose answer was lost (`504 timeout`, `502 unauthenticated` or `invalid`) is remembered by its key names; when a later read finds the agent half at exactly its revision, changed in those keys only, it is recorded then, with `confirmedLater: true`.
|
|
567
|
+
|
|
568
|
+
**Changes made outside Lamaste.** Only Lamaste writes settings — the forwarder reserves `/admin/settings*` — so every revision of the agent half's should be one Lamaste wrote. The relay remembers the last one it wrote or adopted; a read that finds any other revision — higher (a change by whatever holds the installation secret on the agent) or lower (files put back from a backup) — records `plugin.settings.changed-outside-lamaste` in the audit log with the names of the settings that differ from its cache (`changed`, `secretsChanged`), `fromRevision`, `revision` and the agent, notifies the administrators with a warning, and then adopts the revision, so each such change is reported once. Never a value.
|
|
569
|
+
|
|
570
|
+
#### In the plugin list and the update preview
|
|
571
|
+
|
|
572
|
+
`GET /api/plugins`, `GET /api/plugins/:name` and the install response carry, for a plugin that declares settings, `settings: { configured, missing, syncedAt }` in place of its schema — no values — from the relay's cache, or from the schema alone while the relay never read the settings (`syncedAt: null`). The Plugins page shows **Needs configuration** from it.
|
|
573
|
+
|
|
574
|
+
An update's preview — `pendingUpdate.settings`, in `GET /api/plugins/:name/hooks` and the update response — says how the version changes the schema, `null` when neither version declares settings:
|
|
575
|
+
|
|
576
|
+
```json
|
|
577
|
+
{
|
|
578
|
+
"added": [{ "key": "endpoint", "type": "string" }],
|
|
579
|
+
"removed": [{ "key": "verbose", "type": "boolean" }],
|
|
580
|
+
"retyped": [{ "key": "token", "from": "secret", "to": "string" }],
|
|
581
|
+
"newRequired": ["endpoint"],
|
|
582
|
+
"secretToPlain": ["token"]
|
|
583
|
+
}
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
`retyped` are fields whose type changes; their values are dropped on both sides, never converted. `newRequired` are fields required without a default in the new version that were not before (absent, optional or with a default), and required fields that are retyped. `secretToPlain` are fields that are secrets now and plain values after the update: their secrets are dropped, never moved into plain storage — but whatever is typed into them next is stored on the relay and shown in clear, so **such an update always waits for an administrator's approval** (`settings.secret-to-plain` in `approvalRequired`, see [Approval](#approval)), its digest approved or not and auto-approve or not. The panel and `lamaste-server plugins hooks|update` show the diff next to what the update asks to approve and why it waits — the panel and the CLI warn about `secretToPlain` — but it is not part of the digest. After the update the plugin needs configuration until the newly required fields are filled.
|
|
587
|
+
|
|
588
|
+
### Between the relay and the agent half
|
|
589
|
+
|
|
590
|
+
The relay calls the agent half with `callAgentHalf` (`@lamalibre/lamaste@4.4.0`, also exported from `@lamalibre/lamaste@4.4.0/server`): one JSON request to `http://127.0.0.1:<port>/admin/…` — the relay end of the internal tunnel — with a caller assertion naming Lamaste itself (`{ "type": "lamaste" }`) that binds the body's SHA-256, answered with whatever status the agent half gives once the answer carries a valid responder signature over that status and its exact body ([Authentication](#authentication-between-the-relay-and-the-agent-half)). It calls only paths under `/admin/` without `.` or `..` segments, with `GET`, `PUT`, `POST` or `DELETE`, and throws before sending anything otherwise; it sends a body as `application/json` — `body` may be a function of the assertion's nonce, chosen before anything is signed, which is how a change seals its secrets to the one call — bounds the time and the response size, and resolves `{ ok: true, status, body }` (the parsed JSON, or `null`) or `{ ok: false, code, error }` — `refused` (nothing listens), `timeout`, `invalid` (a response over the size limit), `unauthenticated` (no valid responder signature: not the agent half, or one built against an older core) or `other` — with an error that never contains the request body. The hook call `callAgentHalfHook` is built on the same exchange.
|
|
591
|
+
|
|
592
|
+
| Call | Body | Answer |
|
|
593
|
+
| ----------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
594
|
+
| `GET /admin/settings` | — | `200` the state |
|
|
595
|
+
| `PUT /admin/settings` | `{ schemaVersion, baseRevision, values, secrets }`, at most 2 MiB | `200` the new state; `409 { error, code: "schema_mismatch" }`; `409 { error, code: "revision_mismatch", revision }`; `422 { error: "Invalid settings", errors }` (a sealed secret that does not open: "Could not be decrypted"); `400` a body that is not JSON or not of that shape, or one passed parsed; `401` an assertion that does not verify or binds another body (`wrong_body`); `413` too large |
|
|
596
|
+
| `POST /admin/settings/reload` | — | `200` the state, read again from the files |
|
|
597
|
+
|
|
598
|
+
The state, which every answer of the relay's reads and changes is built from:
|
|
599
|
+
|
|
600
|
+
```json
|
|
601
|
+
{
|
|
602
|
+
"schemaVersion": 1,
|
|
603
|
+
"revision": 3,
|
|
604
|
+
"values": { "retries": 5 },
|
|
605
|
+
"secrets": { "apiKey": { "setAt": "2026-10-16T10:00:00.000Z" } },
|
|
606
|
+
"configured": true,
|
|
607
|
+
"missing": []
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
`values` are the explicitly stored non-secret values — no defaults; `secrets` names the secrets that are set, and when — never a value; `setAt` must be an ISO 8601 UTC timestamp as `Date.prototype.toISOString` writes it (`2026-10-16T10:00:00.000Z`), so not even that field can carry free text. In a change, `schemaVersion` is the relay's installed manifest's, `baseRevision` the revision the administrator edited, `values` the non-secret change the panel validated and `secrets` each secret **sealed** (`null` clears). Bodies and responses are 2 MiB at most — the largest valid change, 50 fields of 4096 characters that JSON escapes to six bytes apiece, is about 1.23 MB. The relay parses every state with `parseAgentHalfSettingsState`, which builds a fresh object of the contract's fields: nothing else the agent half sent — a secret's value in particular — survives into the cache.
|
|
612
|
+
|
|
613
|
+
**Sealed secrets.** Each secret of a change crosses the tunnel as `{ "v": 1, "iv": "<base64url, 12 bytes>", "ct": "<base64url ciphertext and 16-byte tag>" }` — AES-256-GCM under a key derived from the installation secret (HKDF-SHA256 over its 32 bytes, salt `lamaste/settings-secrets/v1`, info the plugin's route), a random IV per value, and as additional data the JSON array `[plugin, nonce, key, baseRevision]`: the plugin, the call's assertion nonce, the field and the revision the change was made on. `encryptSettingsSecret` and `decryptSettingsSecret` in `@lamalibre/lamaste@4.4.0` are the two halves; the relay seals with the secret it signs the call with, and the agent half opens with the secret that verified it (`decryptSettingsSecret` on the verify result — the secret never leaves the verifier). A sealed secret opens for no other plugin, call, field or revision; one that does not open is a `422` for its key, and an error never repeats it. Whatever takes the tunnel's port receives no secret in clear — the non-secret values of a change, which the relay stores in clear anyway, are not sealed.
|
|
614
|
+
|
|
615
|
+
Only Lamaste: a caller assertion that does not verify is `401`, one naming an administrator or an agent `403`; another path under `/admin/settings` is `404`, a method a path does not take `405`. The **forwarder reserves `/admin/settings`** and everything under it as it reserves `/admin/hooks/*`: `/api/<route>/settings`, `/api/<route>/Settings` and `/api/<route>/%73ettings/x` all answer `404` and reach nothing. No forwarded caller — an administrator, or an agent holding one of the plugin's capabilities — reads or changes the settings behind the panel's validation, and the relay's cache cannot drift from the agent half. An agent half's own admin routes cannot live there.
|
|
616
|
+
|
|
617
|
+
`POST /admin/settings/reload` is for a restore that puts older files in place while the agent half runs; Lamaste does not call it today, and since the forwarder reserves it nothing else can. An agent half that is restarted after a restore reads its files when it starts.
|
|
618
|
+
|
|
619
|
+
### In the agent half
|
|
620
|
+
|
|
621
|
+
`@lamalibre/lamaste@4.4.0/agent` holds the store and the handler, so that no plugin reimplements how a secret is stored:
|
|
622
|
+
|
|
623
|
+
- **`settingsFromManifest(manifest)`** — the `settings` block of the agent half's own copy of its manifest (bundled with it), validated — here each string default is also tested against its field's `pattern`, which the relay never runs; throws `ManifestValidationError` when it is missing or invalid. The agent half checks values against this schema, not the relay's.
|
|
624
|
+
- **`createSettingsStore({ dataDir, settings })`** — the store over two files in `dataDir`, which must exist: for an agent half Lamaste installs, `LAMASTE_APP_DATA_DIR`, the only directory its unit may write ([App Host](app-host.md)). One store per process. `load()` reads and reconciles the files and must run before anything else; then `state()` is what `GET` answers, `values()` the values the plugin runs with (defaults overlaid by the stored values, never a secret), `secret(key)` a secret's value for the agent half's own use (`undefined` when it is not set), `configured()`, `update(change)`, `reload()`, and `onChange(listener)` — called with the new state after every update and reload, returning its unsubscribe function; a listener that throws does not fail the update, which is stored.
|
|
625
|
+
- **`createSettingsHandler({ verifier, store })`** — answers `/admin/settings` as above. Framework-agnostic, like `createAgentHookHandler`: pass `{ method, url, headers, body }` — the body **as received** (a `Buffer` or a string; a parsed object is refused `400` on `PUT`, since the assertion binds the bytes) — and send the `status`, `headers` and `bodyText` it returns, `bodyText` byte for byte (the responder signature covers it). It opens each sealed secret with the verified request and hands the store the plaintext. No answer carries a secret's value; a store that fails is `500` without its message.
|
|
626
|
+
|
|
627
|
+
| File | Contents |
|
|
628
|
+
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
629
|
+
| `<dataDir>/settings.json` | `{ v: 1, schemaVersion, revision, values: { <key>: { type, value } } }` — each non-secret value with the type of the field it was stored for; written on every change |
|
|
630
|
+
| `<dataDir>/secrets.json` | `{ v: 1, secrets: { <key>: { value, setAt } } }` — written only when a secret changes |
|
|
631
|
+
|
|
632
|
+
Both are 0600, owned by the account the agent half runs as, and written atomically: an exclusive temp file in the same directory (`wx` — never opened through an existing file or link), `fsync`, rename, `fsync` of the directory. A symbolic link — or anything but a regular file — where either belongs is refused, on read (checked, then opened with `O_NOFOLLOW`) and on write. A missing file is empty; a malformed one, or one over 4 MiB, is an error that names the file and never repeats its contents. A secret never goes into `settings.json`.
|
|
633
|
+
|
|
634
|
+
**Reconcile.** `load()` and `reload()` reconcile the stored values with the agent half's own schema. A key the schema no longer knows is dropped; so is a value recorded for another type than its field has now — a **retyped value is dropped, never converted**: a secret whose field became a string leaves `secrets.json` and never lands in `settings.json`, and a string whose field became a secret leaves `settings.json` — and so is a value that no longer passes its field's checks, pattern included. A file is rewritten when something was dropped from it, and `settings.json` also when it records another `schemaVersion` than the agent half's — after an update that dropped only a secret, or nothing, it names the new schema too; the revision stays.
|
|
635
|
+
|
|
636
|
+
**A change** is serialized with every other. A `schemaVersion` other than the agent half's is `409 schema_mismatch`, a `baseRevision` other than the current revision `409 revision_mismatch` with the current `revision`, and a value its checks refuse — pattern included — `422` per field. Otherwise it is applied (`null` clears): `secrets.json` first, when a secret changed, each new secret with `setAt` now; then `settings.json` with the revision plus one; then the listeners.
|
|
637
|
+
|
|
638
|
+
```js
|
|
639
|
+
import {
|
|
640
|
+
createCallerAssertionVerifier,
|
|
641
|
+
createSettingsHandler,
|
|
642
|
+
createSettingsStore,
|
|
643
|
+
settingsFromManifest,
|
|
644
|
+
} from '@lamalibre/lamaste@4.4.0/agent';
|
|
645
|
+
import manifest from '../lamaste-plugin.json' with { type: 'json' }; // bundled into the agent half
|
|
646
|
+
|
|
647
|
+
const verifier = createCallerAssertionVerifier({
|
|
648
|
+
configPath: process.env.LAMASTE_APP_CONFIG,
|
|
649
|
+
plugin: 'market-indicators',
|
|
650
|
+
});
|
|
651
|
+
const settings = createSettingsStore({
|
|
652
|
+
dataDir: process.env.LAMASTE_APP_DATA_DIR,
|
|
653
|
+
settings: settingsFromManifest(manifest),
|
|
654
|
+
});
|
|
655
|
+
await settings.load();
|
|
656
|
+
settings.onChange(() => scheduler.reschedule(settings.values()));
|
|
657
|
+
const settingsHandler = createSettingsHandler({ verifier, store: settings });
|
|
658
|
+
|
|
659
|
+
// in the agent half's router, for /admin/settings and /admin/settings/* (the body as received):
|
|
660
|
+
const out = await settingsHandler.handle({ method, url, headers, body: rawBody });
|
|
661
|
+
res.writeHead(out.status, out.headers).end(out.bodyText);
|
|
662
|
+
|
|
663
|
+
// where the plugin uses them:
|
|
664
|
+
const { schedule, retries } = settings.values();
|
|
665
|
+
const apiKey = settings.secret('apiKey'); // undefined until an administrator sets it
|
|
666
|
+
if (!settings.configured()) pauseSync(); // required settings missing
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
### The relay's cache
|
|
670
|
+
|
|
671
|
+
`state.db` holds one row per plugin in `plugin_settings` (migration `0012`): the agent half's schema version and revision, the non-secret values it stored — for the keys the installed schema knows, and only values that still suit their field — `{ key: setAt }` for the secrets it holds, whether it is configured and what is missing, when the relay read them (`synced_at`), and its provenance: the revision Lamaste last wrote or adopted (`lamaste_revision`) and the key names of a change whose answer was lost (`unconfirmed_change_json`). There is no column for a secret's value. The installed manifest's `settings` block itself is kept on the plugin's row (`plugins.settings`).
|
|
672
|
+
|
|
673
|
+
The relay reads the agent half's settings after a hosting succeeds, after an update or its revert, whenever the health monitor sees the plugin come up, **once when the panel starts** (for every hosting that is active and not down, one plugin after another — the monitor resumes from the persisted health, so a plugin that stayed up sees no transition, and a change made while the panel was down would otherwise go unseen), on every live read, and from the answer to every change. A read that fails — including an answer without a valid responder signature — is logged with metadata only and keeps the row. When an update or its revert changes the installed settings schema, the row is **pruned in the transaction that writes the plugin's row**: a value or a secret stays only while its field keeps its type — a value whose field became a secret goes, and a secret whose field became a plain value is no longer reported set — and whether the plugin is configured is worked out against the new schema until the agent half reports its own. The row is deleted when the plugin's hosting is removed — a later hosting reads the settings afresh, from files the agent kept or from none — when a version without settings is installed over it, and when the plugin is uninstalled, with its registry row.
|
|
674
|
+
|
|
675
|
+
Hook inputs read the cache: a **relay hook**'s `settings` is the plugin's effective non-secret values — each stored value or its default, `{}` while the relay never read them — and an **agent hook** gets `{}`, since the agent half holds the settings itself ([Lifecycle Hooks](lifecycle-hooks.md#writing-hooks)). Neither ever gets a secret.
|
|
676
|
+
|
|
677
|
+
### Hosting, retained data and updates
|
|
678
|
+
|
|
679
|
+
- **Hosting takes no settings.** The agent half starts without them — or with the files it kept on that agent — and reports `configured: false` until an administrator fills the required fields. Secrets would otherwise wait in the relay's memory through an operation that can take minutes. Once the hosting is active the relay reads the settings, and the plugin's card shows whether it needs configuration.
|
|
680
|
+
- **Retained data.** The files are in the agent half's data directory, so for an agent half Lamaste installs they stay with its [retained data](#retained-data) when it is removed without purge, and come back when the same package is hosted on that agent again; the relay reads them then. A **purge** deletes them with the data directory.
|
|
681
|
+
- **Updates.** A version may add, remove or retype fields and bump `schemaVersion`. The new agent half reconciles the files with its schema when it starts: values of fields it no longer has are dropped, and a retyped field's value is dropped — on the relay too, whose cache keeps only values that suit the installed schema — never converted. While the two sides run different schema versions a change is refused (`409 schema_mismatch`), and while the update runs as an operation it is refused anyway (`409 operation_running`); the relay reads the settings again once the update finished or was reverted. Fields the new version newly requires leave the plugin needing configuration.
|
|
682
|
+
|
|
683
|
+
### In the panel
|
|
684
|
+
|
|
685
|
+
A plugin that declares settings gets a **Settings** section on its card on the Plugins page: a form generated from its fields — a text input for a string (its length and `pattern` checked in the browser before anything is sent), a number input with its range, a checkbox, a select of an enum's options; a secret as a masked input that is never prefilled, says when it was set, and offers **Replace** and — for an optional secret — **Clear**. A secret's input is a text input masked with `-webkit-text-security: disc` (Chrome, Edge and Safari; Firefox since 114), not a password input: browsers offer to save what is typed into a password input in their password managers whatever its `autocomplete` says (`new-password` only stops autofill), and a plugin's secret does not belong there. It keeps no form history (`autocomplete="off"`, no name), cannot be copied out, and in a browser that cannot mask it the form says the value shows as it is typed. A secret's value never enters the page's state or its query cache: the form reads the input once when it submits and empties it. A plain string field whose key or label looks like a credential (`key`, `token`, `secret`, `password`, `passwd`, `credential`) says "Stored on the relay and shown in clear — use a secret field for credentials". Only the fields that changed are sent; the panel's and the agent half's messages appear under their fields, a stale revision reloads the settings, and a timeout or an answer that did not authenticate itself reloads them to show what was stored. While the hosting is not active or is down, the form is read-only from the cache and says why ("Hosting agent vm1 is offline since … — last sync …"), and reads the settings again every 10 seconds until it can open. The card shows **Needs configuration** while required settings are missing; the host dialog lists the fields a plugin declares, with their types; an update's approval says why it waits — a secret turned plain in red — and lists how it changes the settings — fields with their types, retyped ones `from → to`, newly required ones, a warning for secrets that become plain values — and what auto-approve did not cover beyond that.
|
|
686
|
+
|
|
687
|
+
The desktop app's server mode shows the same section, through `AdminClient` `getPluginSettings(name)` and `updatePluginSettings(name, body)` — Tauri commands `admin_get_plugin_settings` and `admin_update_plugin_settings`, which hand the body to curl on stdin, never in its arguments.
|
|
688
|
+
|
|
689
|
+
### From the CLI
|
|
690
|
+
|
|
691
|
+
```
|
|
692
|
+
lamaste-server plugins settings <name> [--json]
|
|
693
|
+
lamaste-server plugins configure <name> [--set key=value]... [--clear key]... [--secrets-stdin]
|
|
694
|
+
lamaste-server plugins configure <name> # in a terminal: asked field by field
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
`settings` prints each field — its key, type and value (a secret only as `set <when>` or `not set`), its label, whether it is required, its range, options and default — then whether the plugin is configured or what is missing, where the values come from (the agent half, the relay's copy and when it was synced, or the defaults when it never was), and the hosting in the panel's words. With `--json` it prints the `GET` answer as one line.
|
|
698
|
+
|
|
699
|
+
`configure` with flags makes one change from one read: it reads the settings — the schema and the revision — builds the change and sends it with that revision.
|
|
700
|
+
|
|
701
|
+
| Flag | Meaning |
|
|
702
|
+
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
703
|
+
| `--set key=value` (or `--set=key=value`) | A non-secret field, the value parsed by the field's type: an integer, a number, `true`/`yes`/`on` or `false`/`no`/`off`, one of an enum's option values, or the text as given. An empty value is refused — use `--clear`. **Refused for a secret**: a command line is readable by other local users |
|
|
704
|
+
| `--clear key` (or `--clear=key`) | Puts a non-secret field back to its default (or unset) and clears a secret; refused for a field that must have a value |
|
|
705
|
+
| `--secrets-stdin` | Reads one JSON object from stdin — `{ "apiKey": "…", "oldToken": null }`: secret keys only, `null` clears — at most 2 MiB. Refused when stdin is a terminal: pipe it in or redirect it from a file |
|
|
706
|
+
|
|
707
|
+
```bash
|
|
708
|
+
lamaste-server plugins configure market-indicators --set retries=5 --clear region --secrets-stdin < secrets.json
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
Each key may be named once across the flags. The change is checked against the schema before anything is sent — without `pattern`, which the agent half tests — and a refused one prints a message per field and sends nothing. Messages name keys, never a value; an argument that could be a secret typed in the wrong place is never repeated, and names the schema does not know — on the command line or on stdin — are counted (`2 unknown settings`), never named, as the panel counts them. A secret given with `--set` is refused and, since it is now in the shell history and in sudo's log, the refusal says to rotate it. Everything a manifest, an agent half or the panel supplies — labels, descriptions, options, `setAt`, messages — is printed without control or bidirectional formatting characters.
|
|
712
|
+
|
|
713
|
+
Without flags, in a terminal (stdin and stdout both), `configure` asks for each field in turn: a secret is kept, replaced — typed into a masked prompt, never shown — or cleared; a boolean is a yes or no; an enum a choice among its options (or back to its default); a string or a number a text prefilled with the current value, empty putting it back to its default. It then lists what changes — a non-secret field with its old and new value, a secret only as set, replaced or cleared — and asks before saving. Without flags it refuses to run with `--json` or without a terminal.
|
|
714
|
+
|
|
715
|
+
Before sending, `configure` refuses what the panel would: the settings of a plugin that is not hosted, whose hosting is being set up or removed, or whose hosting is down (`hosting_offline`, with since when and the agent's last sync), or that the relay has never read (`settings_not_read`). A refusal of the panel is explained: each field's message; a stale revision (nothing was saved — run it again); a running operation (`plugins operations <name>`); a timeout or an answer that did not authenticate itself (`plugins settings <name>` shows what the agent half stored; for the latter, something else may hold the tunnel's port or the agent half must be rebuilt); an agent half that predates settings (`plugins update <name>`).
|
|
716
|
+
|
|
717
|
+
Both commands exit `0` when they did what was asked — for `configure`, saved the change, found nothing to change, or the administrator declined to save — and `1` for a usage error, a refusal, or a prompt cancelled with Ctrl-C (nothing is saved). With `--json`, `configure` prints `{ "event": "complete", "name", "changed", "cleared", "secretsSet", "secretsCleared", "settings" }` — key names, and the settings as `GET` answers them — and every failure of either command `{ "event": "error", "message", "recoverable": false, "code"?, "errors"?, "revision"?, "operationId"?, "hosting"? }`.
|
|
366
718
|
|
|
367
719
|
## Internal tunnels
|
|
368
720
|
|
|
@@ -381,27 +733,35 @@ lamaste-server plugins retained-data [<name>] # what agents keep of
|
|
|
381
733
|
lamaste-server plugins purge-data <name> --agent <label> # delete it on the agent (follows the operation)
|
|
382
734
|
lamaste-server plugins forget-data <name> --agent <label> # drop the relay's record only
|
|
383
735
|
lamaste-server plugins hooks <name> # what needs approval: relay hooks, the agent half
|
|
736
|
+
lamaste-server plugins approve-hooks <name> --digest <sha> [--version <v>]
|
|
737
|
+
# approve it; a pending update by its version too
|
|
738
|
+
lamaste-server plugins settings <name> # its settings (secrets only as set), the hosting
|
|
739
|
+
lamaste-server plugins configure <name> [--set key=value]... [--clear key]... [--secrets-stdin]
|
|
384
740
|
lamaste-server push-install status|on|off # the switch, policies, every agent's window
|
|
385
741
|
lamaste-server push-install open <agent> [--minutes <n>] [--policy <id>]
|
|
386
742
|
lamaste-server push-install close <agent>
|
|
387
743
|
lamaste-server push-install default-policy <id>
|
|
388
744
|
```
|
|
389
745
|
|
|
390
|
-
`lamaste-server plugins list` shows where each hosted plugin runs, its health and the agent half version. `host` and `unhost` follow the operation step by step and exit non-zero unless it succeeded. A refusal is explained: `agent_not_eligible` lists the reasons (and points at `eligibility`), `push_install_refused` says which gates a push passes — `lamaste-server` calls the panel from `127.0.0.1`, so the agent's policy must allow that address. `--purge` names the data directory it deletes before it starts. `retained-data` lists every agent's records — marking those of uninstalled plugins — or one plugin's, with the data directory and account of each; `purge-data` says which directory and account it deletes, follows the operation and exits non-zero unless it succeeded; `forget-data` says that nothing on the agent changed and how to remove the data there. `hooks` prints the approval digest, the relay hook set and the agent half (package and version, integrity, entry and memory limit, the account and unit it runs as, its data directory, its system packages), and a pending update's. `push-install status` lists the switch, the default policy, each policy's actions, packages and addresses, and each enrolled machine agent's window (`open until …` with its policy, or `closed`); `open` checks the minutes against the bounds the panel reports (default 30).
|
|
746
|
+
`lamaste-server plugins list` shows where each hosted plugin runs, its health and the agent half version. `host` and `unhost` follow the operation step by step and exit non-zero unless it succeeded. A refusal is explained: `agent_not_eligible` lists the reasons (and points at `eligibility`), `push_install_refused` says which gates a push passes — `lamaste-server` calls the panel from `127.0.0.1`, so the agent's policy must allow that address. `--purge` names the data directory it deletes before it starts. `retained-data` lists every agent's records — marking those of uninstalled plugins — or one plugin's, with the data directory and account of each; `purge-data` says which directory and account it deletes, follows the operation and exits non-zero unless it succeeded; `forget-data` says that nothing on the agent changed and how to remove the data there. `hooks` prints the approval digest, the relay hook set and the agent half (package and version, integrity, entry and memory limit, the account and unit it runs as, its data directory, its system packages), and a pending update's — with why it waits (`approvalRequired`; a secret turned plain in red) and the `approve-hooks` command that approves it, naming its version (`--digest <sha256> --version <v>`, or `--version <v>` alone when it has no digest); `update` prints the same for an update that waits. `settings` and `configure` — interactive in a terminal, a secret only from a masked prompt or stdin, never an argument — are described under [Settings](#from-the-cli). `push-install status` lists the switch, the default policy, each policy's actions, packages and addresses, and each enrolled machine agent's window (`open until …` with its policy, or `closed`); `open` checks the minutes against the bounds the panel reports (default 30).
|
|
391
747
|
|
|
392
748
|
## State
|
|
393
749
|
|
|
394
|
-
| Path | Contents
|
|
395
|
-
| ------------------------------------------------------------------------------- |
|
|
396
|
-
| `/etc/lamalibre/lamaste/plugin-hostings.json` | Hosting records with their secrets (0600, atomic, one per plugin), owner and agent half version
|
|
397
|
-
| `/etc/lamalibre/lamaste/tunnels.json` | The internal tunnel, like every tunnel
|
|
398
|
-
| `state.db` `plugins.route`, `plugins.agent_half_pin` | The plugin's unique route; the pinned agent half `{ package, version, integrity }`
|
|
399
|
-
| `state.db` `agents.host_facts`, `host_facts_at` | What each agent reported about its host
|
|
400
|
-
| `state.db` `agents.push_install_enabled_until`, `push_install_policy` | Each agent's push-install window and policy
|
|
401
|
-
| `state.db` `agent_jobs.payload`, `agent_jobs.signature` | App job parameters and the relay's signature over them
|
|
402
|
-
| `state.db` `retained_agent_data` | Data agents keep of removed agent halves
|
|
403
|
-
|
|
|
404
|
-
|
|
|
405
|
-
|
|
|
406
|
-
|
|
407
|
-
|
|
750
|
+
| Path | Contents |
|
|
751
|
+
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
752
|
+
| `/etc/lamalibre/lamaste/plugin-hostings.json` | Hosting records with their secrets (0600, atomic, one per plugin), owner and agent half version |
|
|
753
|
+
| `/etc/lamalibre/lamaste/tunnels.json` | The internal tunnel, like every tunnel |
|
|
754
|
+
| `state.db` `plugins.route`, `plugins.agent_half_pin` | The plugin's unique route; the pinned agent half `{ package, version, integrity }` |
|
|
755
|
+
| `state.db` `agents.host_facts`, `host_facts_at` | What each agent reported about its host |
|
|
756
|
+
| `state.db` `agents.push_install_enabled_until`, `push_install_policy` | Each agent's push-install window and policy |
|
|
757
|
+
| `state.db` `agent_jobs.payload`, `agent_jobs.signature` | App job parameters and the relay's signature over them |
|
|
758
|
+
| `state.db` `retained_agent_data` | Data agents keep of removed agent halves |
|
|
759
|
+
| `state.db` `plugins.settings` | The installed manifest's settings block — the schema the panel checks a change against |
|
|
760
|
+
| `state.db` `plugin_settings` | The cache of each plugin's settings: non-secret values, `{ key: setAt }` for secrets, status, provenance |
|
|
761
|
+
| `state.db` `agent_presence` | When each agent last contacted the relay (written at most once a minute per agent) |
|
|
762
|
+
| `/etc/lamalibre/lamaste/push-install-config.json`, `push-install-sessions.json` | The switch and policies; the push log (last 500) |
|
|
763
|
+
| `<agent>/agents/<label>/app-plugins/<route>.json` | The agent's copy of the port and secret (0600) |
|
|
764
|
+
| `/etc/lamalibre/apps/<route>/plugin-config.json` (agent) | The service's copy, for an agent half Lamaste installed (0600, its account) |
|
|
765
|
+
| `<data directory>/settings.json`, `secrets.json` (agent) | The plugin's settings and secrets, kept by the agent half (0600, its account) |
|
|
766
|
+
|
|
767
|
+
Installation secrets leave the relay only through `GET /api/agents/me/plugin-config/:route`, over TLS, to the hosting agent's certificate. Settings secrets never rest on the relay: they cross it in the memory of one request, and the tunnel sealed, to the agent half. Files on the agent: [App Host](app-host.md#what-it-creates).
|