@lamalibre/create-lamaste 4.2.0 → 4.3.1

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 (128) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +13 -8
  3. package/scripts/lamaste-priv +205 -2
  4. package/src/index.js +8 -4
  5. package/src/lib/node-version.js +114 -0
  6. package/src/lib/panel-pin.js +41 -0
  7. package/src/lib/relay-services.js +15 -1
  8. package/src/lib/service-config.js +113 -0
  9. package/src/tasks/gatekeeper.js +8 -3
  10. package/src/tasks/node.js +34 -75
  11. package/src/tasks/redeploy.js +36 -0
  12. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  13. package/vendor/docs/00-introduction/quickstart.md +5 -5
  14. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  15. package/vendor/docs/01-concepts/authentication.md +6 -3
  16. package/vendor/docs/01-concepts/authorization.md +29 -17
  17. package/vendor/docs/01-concepts/certificates.md +3 -3
  18. package/vendor/docs/01-concepts/mtls.md +2 -2
  19. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  20. package/vendor/docs/01-concepts/security-model.md +110 -35
  21. package/vendor/docs/01-concepts/tickets.md +2 -2
  22. package/vendor/docs/01-concepts/tunneling.md +4 -4
  23. package/vendor/docs/02-guides/agent-setup.md +34 -16
  24. package/vendor/docs/02-guides/certificate-management.md +2 -2
  25. package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
  26. package/vendor/docs/02-guides/desktop-app-setup.md +11 -10
  27. package/vendor/docs/02-guides/installation.md +3 -3
  28. package/vendor/docs/02-guides/onboarding.md +2 -2
  29. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  30. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  31. package/vendor/docs/03-architecture/installer.md +4 -4
  32. package/vendor/docs/03-architecture/management-flow.md +1 -1
  33. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  34. package/vendor/docs/03-architecture/overview.md +8 -8
  35. package/vendor/docs/03-architecture/panel-client.md +6 -6
  36. package/vendor/docs/03-architecture/panel-server.md +2 -2
  37. package/vendor/docs/03-architecture/state-management.md +2 -2
  38. package/vendor/docs/03-architecture/system-overview.md +1 -1
  39. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  40. package/vendor/docs/04-api-reference/app-host.md +360 -0
  41. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  42. package/vendor/docs/04-api-reference/certificates.md +95 -38
  43. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  44. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  45. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  46. package/vendor/docs/04-api-reference/overview.md +137 -111
  47. package/vendor/docs/04-api-reference/registry-and-oidc.md +8 -4
  48. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  49. package/vendor/docs/05-operations/backup-and-restore.md +3 -1
  50. package/vendor/docs/05-operations/uninstalling.md +1 -1
  51. package/vendor/docs/05-operations/upgrades.md +25 -20
  52. package/vendor/docs/06-reference/config-files.md +195 -97
  53. package/vendor/docs/06-reference/glossary.md +3 -3
  54. package/vendor/docs/06-reference/installer-flags.md +13 -13
  55. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  56. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  57. package/vendor/docs/_index.json +20 -0
  58. package/vendor/docs/gatekeeper-design.md +22 -19
  59. package/vendor/docs/identity-directory-design.md +57 -56
  60. package/vendor/docs/package.json +1 -1
  61. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  62. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  63. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  64. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  65. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  66. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/index.js +47 -8
  68. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  69. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  70. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  71. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  72. package/vendor/gatekeeper/package.json +4 -3
  73. package/vendor/server/bin/lamaste-server.js +25 -1
  74. package/vendor/server/package.json +2 -2
  75. package/vendor/server/src/commands/audit.js +60 -0
  76. package/vendor/server/src/commands/panel-pin.js +40 -0
  77. package/vendor/server/src/commands/plugins.js +963 -12
  78. package/vendor/server/src/commands/push-install.js +317 -0
  79. package/vendor/server/src/commands/registry-credential.js +2 -1
  80. package/vendor/server/src/commands/uninstall.js +28 -1
  81. package/vendor/server/src/ndjson.js +4 -2
  82. package/vendor/server/src/panel-api.js +277 -34
  83. package/vendor/server/src/panel-identity.js +165 -0
  84. package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +84 -0
  85. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  86. package/vendor/server-ui/dist/index.html +2 -2
  87. package/vendor/serverd/package.json +2 -2
  88. package/vendor/serverd/src/index.js +56 -4
  89. package/vendor/serverd/src/lib/actor.js +24 -0
  90. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  91. package/vendor/serverd/src/lib/app-error.js +10 -0
  92. package/vendor/serverd/src/lib/audit.js +145 -0
  93. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  94. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  95. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  96. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  97. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  98. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  99. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  100. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  101. package/vendor/serverd/src/lib/mtls.js +89 -8
  102. package/vendor/serverd/src/lib/nginx.js +298 -44
  103. package/vendor/serverd/src/lib/notifications.js +107 -0
  104. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  105. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  106. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  107. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  108. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  109. package/vendor/serverd/src/lib/plugins.js +298 -68
  110. package/vendor/serverd/src/lib/push-install.js +88 -2
  111. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  112. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  113. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  114. package/vendor/serverd/src/lib/retained-data.js +99 -0
  115. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  116. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  117. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  118. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  119. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  120. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  121. package/vendor/serverd/src/routes/management/agents.js +262 -1
  122. package/vendor/serverd/src/routes/management/audit.js +81 -0
  123. package/vendor/serverd/src/routes/management/certs.js +1 -15
  124. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  125. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  126. package/vendor/serverd/src/routes/management.js +4 -0
  127. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  128. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Calling one relay hook: the runner round trip, validation of the
3
+ * decision, the effects it asks for, and the runner restart a timeout calls
4
+ * for. Every pipeline (install, uninstall, request, tunnel state) calls
5
+ * relay hooks through {@link runRelayHook}.
6
+ *
7
+ * What is logged is metadata: plugin, stage, outcome, duration. A failing
8
+ * request-stage hook's message is never logged — it is plugin text and may
9
+ * quote the request or response it saw.
10
+ */
11
+
12
+ import { SHARED_HOOK_EFFECTS } from '@lamalibre/lamaste/hooks';
13
+ import {
14
+ callRelayHook,
15
+ decisionEffects,
16
+ hookRunnerSocketPath,
17
+ validateHookDecision,
18
+ } from '@lamalibre/lamaste/server';
19
+ import { performHookEffects } from './hook-effects.js';
20
+ import { hookSocketDir, restartRelayHookRunner } from './plugin-hooks.js';
21
+
22
+ function isRequestStage(stage) {
23
+ return stage === 'request:pre-forward' || stage === 'request:post-forward';
24
+ }
25
+
26
+ /**
27
+ * @param {{
28
+ * plugin: string,
29
+ * stage: import('@lamalibre/lamaste/hooks').HookStage,
30
+ * call: import('@lamalibre/lamaste/hooks').HookCallTarget,
31
+ * input: Record<string, unknown>,
32
+ * body?: { bytes: Buffer, contentType: string | null } | null,
33
+ * timeoutMs: number,
34
+ * effects: ReadonlyArray<string>,
35
+ * logger?: import('pino').Logger,
36
+ * }} params
37
+ * `effects` — the effect types the plugin declared (for its own hooks;
38
+ * a shared hook uses Lamaste's list).
39
+ * @returns {Promise<
40
+ * | { ok: true, decision: object, body: Buffer | null, bodyIsText: boolean, durationMs: number }
41
+ * | { ok: false, kind: string, message: string, durationMs: number }
42
+ * >}
43
+ */
44
+ export async function runRelayHook({
45
+ plugin,
46
+ stage,
47
+ call,
48
+ input,
49
+ body,
50
+ timeoutMs,
51
+ effects,
52
+ logger,
53
+ }) {
54
+ const outcome = await callRelayHook(hookRunnerSocketPath(hookSocketDir(), plugin), {
55
+ plugin,
56
+ stage,
57
+ call,
58
+ input,
59
+ body: body ?? null,
60
+ timeoutMs,
61
+ });
62
+ const requestStage = isRequestStage(stage);
63
+ if (!outcome.ok) {
64
+ if (outcome.kind === 'timeout') void restartRelayHookRunner(plugin, logger);
65
+ logger?.warn(
66
+ {
67
+ plugin,
68
+ stage,
69
+ hook: call.kind === 'shared' ? call.name : 'plugin',
70
+ kind: outcome.kind,
71
+ durationMs: outcome.durationMs,
72
+ ...(requestStage ? {} : { error: outcome.message }),
73
+ },
74
+ 'Relay hook failed',
75
+ );
76
+ return {
77
+ ok: false,
78
+ kind: outcome.kind,
79
+ message: requestStage ? '' : outcome.message,
80
+ durationMs: outcome.durationMs,
81
+ };
82
+ }
83
+ const validated = validateHookDecision(stage, outcome.decision);
84
+ if (!validated.ok) {
85
+ logger?.warn(
86
+ { plugin, stage, error: validated.error },
87
+ 'Relay hook returned an invalid decision',
88
+ );
89
+ return { ok: false, kind: 'invalid', message: validated.error, durationMs: outcome.durationMs };
90
+ }
91
+ const allowed = new Set(call.kind === 'shared' ? SHARED_HOOK_EFFECTS[call.name] : effects);
92
+ await performHookEffects(plugin, stage, decisionEffects(validated.decision), allowed, logger);
93
+ return {
94
+ ok: true,
95
+ decision: validated.decision,
96
+ body: outcome.body,
97
+ bodyIsText: outcome.bodyIsText,
98
+ durationMs: outcome.durationMs,
99
+ };
100
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The relay's assertion signing key (`<stateDir>/relay-signing-key.json`,
3
+ * 0600). The panel creates it at startup and signs agent-panel requests
4
+ * with it, and every app job it gives an agent's root helper
5
+ * (`agent-jobs.js`); Gatekeeper reads the same file to sign for plugin
6
+ * tunnels. Agents fetch the public half from `GET /api/agents/me/relay-keys`.
7
+ * See `relay-assertion.ts` and `app-job-signature.ts` in the core library.
8
+ */
9
+
10
+ import path from 'node:path';
11
+ import { createRelaySigner, relayVerifyKeyOf, RELAY_SIGNING_KEY_FILE } from '@lamalibre/lamaste';
12
+
13
+ const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
14
+
15
+ export const relaySigner = createRelaySigner(path.join(STATE_DIR, RELAY_SIGNING_KEY_FILE), {
16
+ create: true,
17
+ });
18
+
19
+ /**
20
+ * Create the key if it does not exist yet, so Gatekeeper finds it, and log
21
+ * its id. A key that exists but cannot be used (permissions, content) is an
22
+ * error the operator must fix: nothing is signed with it, so the agent-panel
23
+ * and plugin tunnels are refused until then.
24
+ *
25
+ * @param {{ info(o: object, m?: string): void, error(o: object, m?: string): void }} log
26
+ */
27
+ export async function ensureRelaySigningKey(log) {
28
+ try {
29
+ const key = await relaySigner.current();
30
+ if (key) log.info({ kid: key.kid }, 'Relay assertion signing key ready');
31
+ } catch (err) {
32
+ log.error(
33
+ { err: err instanceof Error ? err.message : String(err) },
34
+ 'Relay assertion signing key unusable — agent panels and plugin tunnels are refused',
35
+ );
36
+ }
37
+ }
38
+
39
+ /** The public keys agents verify relay assertions with (current key only). */
40
+ export async function relayVerifyKeys() {
41
+ const key = await relaySigner.current();
42
+ return key ? [relayVerifyKeyOf(key)] : [];
43
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The panel's request hook runtime for the forwarder
3
+ * (`createHookedForwarder` in @lamalibre/lamaste/server): which request
4
+ * hooks a plugin has, how one runs, and the fallback while it is down.
5
+ *
6
+ * Only an approved relay hook set runs. A plugin whose installed set is not
7
+ * the approved one has its forwarded traffic refused (503) — letting it past
8
+ * hooks that do not run would be a bypass.
9
+ */
10
+
11
+ import { findPlugin } from './plugins.js';
12
+ import { installedRelayHooks, installedVersionApproved } from './plugin-hooks.js';
13
+ import { runRelayHook } from './relay-hooks.js';
14
+ import { getStateDb } from './state-db.js';
15
+
16
+ /** plugin → the fallback its `tunnel:down` hook returned, while it is down. */
17
+ const fallbacks = new Map();
18
+
19
+ /** plugin → declared effect types of its relay hooks (from the last `hooksFor`). */
20
+ const declaredEffects = new Map();
21
+
22
+ /** Load the persisted fallbacks (start-up). */
23
+ export async function loadTunnelFallbacks() {
24
+ const db = await getStateDb();
25
+ fallbacks.clear();
26
+ for (const row of db
27
+ .prepare(
28
+ 'SELECT plugin, tunnel_fallback FROM plugin_hook_sets WHERE tunnel_fallback IS NOT NULL',
29
+ )
30
+ .all()) {
31
+ try {
32
+ fallbacks.set(row.plugin, JSON.parse(row.tunnel_fallback));
33
+ } catch {
34
+ // A malformed row serves no fallback.
35
+ }
36
+ }
37
+ }
38
+
39
+ /** Keep the in-memory fallback in step with the persisted one (tunnel-hooks.js). */
40
+ export function rememberTunnelFallback(plugin, fallback) {
41
+ if (fallback) fallbacks.set(plugin, fallback);
42
+ else fallbacks.delete(plugin);
43
+ }
44
+
45
+ /** @type {import('@lamalibre/lamaste/server').RequestHookRuntime} */
46
+ export const requestHookRuntime = {
47
+ async hooksFor(plugin) {
48
+ const entry = await findPlugin(plugin);
49
+ const relay = entry ? installedRelayHooks(entry) : null;
50
+ if (!relay) return { hooks: [] };
51
+ if (!(await installedVersionApproved(entry))) {
52
+ return {
53
+ refuse: `The relay hooks of plugin "${plugin}" are not approved — an administrator approves them in the panel`,
54
+ };
55
+ }
56
+ declaredEffects.set(plugin, relay.set.effects);
57
+ return { hooks: relay.set.request };
58
+ },
59
+
60
+ async run(plugin, hook, input, body) {
61
+ const result = await runRelayHook({
62
+ plugin,
63
+ stage: `request:${hook.stage}`,
64
+ call: hook.use
65
+ ? { kind: 'shared', name: hook.use, settings: hook.settings }
66
+ : { kind: 'app' },
67
+ input,
68
+ body,
69
+ timeoutMs: hook.timeoutMs,
70
+ effects: declaredEffects.get(plugin) ?? [],
71
+ });
72
+ if (!result.ok) return { ok: false };
73
+ return { ok: true, decision: result.decision, body: result.body };
74
+ },
75
+
76
+ fallbackFor(plugin) {
77
+ return fallbacks.get(plugin) ?? null;
78
+ },
79
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Data an agent half leaves on an agent. Removing an agent half without
3
+ * purge (an unhost, an uninstall, a rolled-back install) keeps its data
4
+ * directory and service account on the agent, both named after the route;
5
+ * the relay keeps a record of it so an administrator can purge it later —
6
+ * after the plugin is gone, too — and so another agent package is never
7
+ * installed over it.
8
+ *
9
+ * One record per agent and route (`retained_agent_data`, migration 0010),
10
+ * the way the data exists on the agent. A record goes when the data is
11
+ * purged, or when the same agent package is installed there again and
12
+ * takes the data back.
13
+ */
14
+
15
+ import { APPHOST_PATHS, appServiceUser } from '@lamalibre/lamaste';
16
+ import { getStateDb } from './state-db.js';
17
+
18
+ /**
19
+ * A record as the API shows it: what the table keeps, and what a purge
20
+ * deletes on the agent — the data directory and the account, both derived
21
+ * from the route — so a panel or CLI confirmation can name them.
22
+ */
23
+ function rowToRecord(row) {
24
+ return {
25
+ agentLabel: row.agent_label,
26
+ route: row.route,
27
+ plugin: row.plugin,
28
+ package: row.package,
29
+ since: row.since,
30
+ operation: row.operation,
31
+ dataDir: APPHOST_PATHS.dataDir(row.route),
32
+ serviceUser: appServiceUser(row.route),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Record that agent `agentLabel` keeps the data of route `route`, owned by
38
+ * agent package `pkg` of plugin `plugin`. A record that exists already keeps
39
+ * its `since` — the data has been there since then.
40
+ *
41
+ * @param {{ agentLabel: string, route: string, plugin: string, package: string, operation?: string | null }} record
42
+ */
43
+ export async function recordRetainedData({ agentLabel, route, plugin, package: pkg, operation }) {
44
+ const db = await getStateDb();
45
+ db.prepare(
46
+ `INSERT INTO retained_agent_data (agent_label, route, plugin, package, since, operation)
47
+ VALUES (?, ?, ?, ?, ?, ?)
48
+ ON CONFLICT(agent_label, route) DO UPDATE SET
49
+ plugin = excluded.plugin,
50
+ package = excluded.package,
51
+ operation = excluded.operation`,
52
+ ).run(agentLabel, route, plugin, pkg, new Date().toISOString(), operation ?? null);
53
+ }
54
+
55
+ /** Drop the record of the data agent `agentLabel` keeps for route `route` (purged, or taken back). */
56
+ export async function forgetRetainedData(agentLabel, route) {
57
+ const db = await getStateDb();
58
+ return Number(
59
+ db
60
+ .prepare('DELETE FROM retained_agent_data WHERE agent_label = ? AND route = ?')
61
+ .run(agentLabel, route).changes,
62
+ );
63
+ }
64
+
65
+ /** The record for agent `agentLabel` and route `route`, or null. */
66
+ export async function findRetainedData(agentLabel, route) {
67
+ const db = await getStateDb();
68
+ const row = db
69
+ .prepare('SELECT * FROM retained_agent_data WHERE agent_label = ? AND route = ?')
70
+ .get(agentLabel, route);
71
+ return row ? rowToRecord(row) : null;
72
+ }
73
+
74
+ /**
75
+ * Records, oldest first — all of them, those of plugin `plugin` (by the name
76
+ * it had; it may be uninstalled), or those on agent `agentLabel`.
77
+ *
78
+ * @param {{ plugin?: string, agentLabel?: string }} [filter]
79
+ */
80
+ export async function listRetainedData({ plugin, agentLabel } = {}) {
81
+ const db = await getStateDb();
82
+ const where = [];
83
+ const args = [];
84
+ if (plugin) {
85
+ where.push('plugin = ?');
86
+ args.push(plugin);
87
+ }
88
+ if (agentLabel) {
89
+ where.push('agent_label = ?');
90
+ args.push(agentLabel);
91
+ }
92
+ return db
93
+ .prepare(
94
+ `SELECT * FROM retained_agent_data${where.length ? ` WHERE ${where.join(' AND ')}` : ''}
95
+ ORDER BY since, agent_label, route`,
96
+ )
97
+ .all(...args)
98
+ .map(rowToRecord);
99
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Which proxy hop the panel trusts for a request's client address — the
3
+ * Fastify `trustProxy` function.
4
+ *
5
+ * The panel listens on 127.0.0.1 only; every request reaches it through the
6
+ * relay's nginx (the panel vhost, the IP-based panel on :9292), which
7
+ * appends the address it accepted the connection from to
8
+ * `X-Forwarded-For` (`proxy_set_header X-Forwarded-For
9
+ * $proxy_add_x_forwarded_for`). The panel trusts exactly that one hop, and
10
+ * only when it comes from loopback: `request.ip` is then the address nginx
11
+ * saw — what push-install IP policies, rate limits and logs see — never an
12
+ * `X-Forwarded-For` entry the client wrote itself (those sit further left),
13
+ * and never a header from a peer that is not local should the panel ever
14
+ * listen elsewhere.
15
+ */
16
+
17
+ import net from 'node:net';
18
+
19
+ /** Whether `address` is a loopback address (IPv4 127/8, `::1`, or IPv4-mapped 127/8). */
20
+ export function isLoopbackAddress(address) {
21
+ if (typeof address !== 'string') return false;
22
+ const v4 = address.startsWith('::ffff:') ? address.slice(7) : address;
23
+ if (net.isIPv4(v4)) return v4.split('.')[0] === '127';
24
+ return net.isIPv6(address) && (address === '::1' || /^(0{1,4}:){7}0{0,3}1$/.test(address));
25
+ }
26
+
27
+ /**
28
+ * Fastify / proxy-addr trust function: `hop` 0 is the connection's own peer.
29
+ * @param {string} address
30
+ * @param {number} hop
31
+ */
32
+ export function trustLocalNginxHop(address, hop) {
33
+ return hop === 0 && isLoopbackAddress(address);
34
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The tunnel-state pipeline on the relay: when the health monitor sees a
3
+ * hosted plugin go down (the tunnel, or the application behind it) or come
4
+ * back, Lamaste runs the plugin's `tunnel:down` / `tunnel:up` relay hook.
5
+ * What `tunnel:down` returns as `fallback` is what the forwarder answers
6
+ * while the plugin is down, instead of a bare 502; `tunnel:up` clears it.
7
+ *
8
+ * `tunnel:up` runs only after a `down` — not when a hosting is first seen
9
+ * up, or after a restart of the panel. The agent side of the pipeline runs
10
+ * on the agent (its sync follows the relay's view of the tunnel).
11
+ *
12
+ * Calls for one plugin are serialised and run beside the monitor, which
13
+ * does not wait for them.
14
+ */
15
+
16
+ import { findPlugin } from './plugins.js';
17
+ import {
18
+ installedRelayHooks,
19
+ installedVersionApproved,
20
+ setTunnelFallback,
21
+ } from './plugin-hooks.js';
22
+ import { runRelayHook } from './relay-hooks.js';
23
+ import { rememberTunnelFallback } from './request-hooks.js';
24
+
25
+ const chains = new Map();
26
+
27
+ /**
28
+ * React to a persisted health transition of `plugin` (`before` → `after`).
29
+ * Never throws; failures are logged.
30
+ */
31
+ export function onHealthTransition(plugin, before, after, logger) {
32
+ const previous = chains.get(plugin) ?? Promise.resolve();
33
+ const next = previous
34
+ .then(() => runTunnelHook(plugin, before, after, logger))
35
+ .catch((err) => {
36
+ logger?.error({ plugin, err: err?.message }, 'Tunnel-state hook pipeline failed');
37
+ });
38
+ chains.set(plugin, next);
39
+ next.finally(() => {
40
+ if (chains.get(plugin) === next) chains.delete(plugin);
41
+ });
42
+ return next;
43
+ }
44
+
45
+ async function runTunnelHook(plugin, before, after, logger) {
46
+ let stage;
47
+ if (after.state === 'down') stage = 'tunnel:down';
48
+ else if (after.state === 'up' && before?.state === 'down') stage = 'tunnel:up';
49
+ else return;
50
+
51
+ const entry = await findPlugin(plugin);
52
+ const relay = entry ? installedRelayHooks(entry) : null;
53
+ const decl = relay?.set.stages.find((s) => s.stage === stage) ?? null;
54
+ if (stage === 'tunnel:up') {
55
+ // Whatever happens next, the plugin is up: no fallback.
56
+ await setTunnelFallback(plugin, null);
57
+ rememberTunnelFallback(plugin, null);
58
+ }
59
+ if (!decl) return;
60
+ if (!(await installedVersionApproved(entry))) return;
61
+
62
+ const result = await runRelayHook({
63
+ plugin,
64
+ stage,
65
+ call: { kind: 'app' },
66
+ input: {
67
+ state: after.state,
68
+ ...(after.reason ? { reason: after.reason } : {}),
69
+ since: after.since,
70
+ ...(after.lastError ? { lastError: after.lastError.slice(0, 300) } : {}),
71
+ },
72
+ timeoutMs: decl.timeoutMs,
73
+ effects: relay.set.effects,
74
+ logger,
75
+ });
76
+ if (!result.ok) return;
77
+ if (stage === 'tunnel:down') {
78
+ const fallback = result.decision.fallback ?? null;
79
+ await setTunnelFallback(plugin, fallback);
80
+ rememberTunnelFallback(plugin, fallback);
81
+ logger?.info(
82
+ { plugin, reason: after.reason, fallback: fallback !== null },
83
+ 'Ran tunnel:down hook',
84
+ );
85
+ } else {
86
+ logger?.info({ plugin }, 'Ran tunnel:up hook');
87
+ }
88
+ }