@lamalibre/create-lamaste 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +2 -2
  2. package/scripts/bundle-vendor.js +13 -8
  3. package/src/index.js +4 -0
  4. package/src/lib/panel-pin.js +41 -0
  5. package/src/lib/service-config.js +6 -0
  6. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  7. package/vendor/docs/00-introduction/quickstart.md +5 -5
  8. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  9. package/vendor/docs/01-concepts/certificates.md +3 -3
  10. package/vendor/docs/01-concepts/mtls.md +2 -2
  11. package/vendor/docs/01-concepts/security-model.md +50 -2
  12. package/vendor/docs/01-concepts/tickets.md +2 -2
  13. package/vendor/docs/01-concepts/tunneling.md +4 -4
  14. package/vendor/docs/02-guides/agent-setup.md +6 -6
  15. package/vendor/docs/02-guides/certificate-management.md +2 -2
  16. package/vendor/docs/02-guides/cloud-provisioning.md +4 -4
  17. package/vendor/docs/02-guides/desktop-app-setup.md +12 -11
  18. package/vendor/docs/02-guides/installation.md +3 -3
  19. package/vendor/docs/02-guides/onboarding.md +2 -2
  20. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  21. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  22. package/vendor/docs/03-architecture/installer.md +4 -4
  23. package/vendor/docs/03-architecture/management-flow.md +1 -1
  24. package/vendor/docs/03-architecture/overview.md +5 -5
  25. package/vendor/docs/03-architecture/panel-client.md +7 -7
  26. package/vendor/docs/03-architecture/panel-server.md +2 -2
  27. package/vendor/docs/03-architecture/state-management.md +2 -2
  28. package/vendor/docs/03-architecture/system-overview.md +1 -1
  29. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
  30. package/vendor/docs/04-api-reference/app-host.md +87 -38
  31. package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
  32. package/vendor/docs/04-api-reference/certificates.md +22 -19
  33. package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
  34. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  35. package/vendor/docs/04-api-reference/overview.md +137 -135
  36. package/vendor/docs/04-api-reference/registry-and-oidc.md +1 -1
  37. package/vendor/docs/04-api-reference/tunnels.md +14 -10
  38. package/vendor/docs/05-operations/backup-and-restore.md +4 -1
  39. package/vendor/docs/05-operations/uninstalling.md +1 -1
  40. package/vendor/docs/05-operations/upgrades.md +6 -6
  41. package/vendor/docs/06-reference/config-files.md +94 -84
  42. package/vendor/docs/06-reference/glossary.md +3 -3
  43. package/vendor/docs/06-reference/installer-flags.md +13 -13
  44. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  45. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  46. package/vendor/docs/gatekeeper-design.md +8 -8
  47. package/vendor/docs/identity-directory-design.md +2 -2
  48. package/vendor/docs/package.json +1 -1
  49. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  50. package/vendor/gatekeeper/dist/cli/index.js +0 -0
  51. package/vendor/gatekeeper/package.json +2 -2
  52. package/vendor/server/bin/lamaste-server.js +11 -1
  53. package/vendor/server/package.json +4 -3
  54. package/vendor/server/src/commands/panel-pin.js +40 -0
  55. package/vendor/server/src/commands/plugin-settings.js +541 -0
  56. package/vendor/server/src/commands/plugins.js +94 -27
  57. package/vendor/server/src/panel-api.js +281 -34
  58. package/vendor/server/src/panel-identity.js +165 -0
  59. package/vendor/server/src/plugin-approval.js +78 -0
  60. package/vendor/server/src/plugin-settings.js +699 -0
  61. package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
  62. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
  63. package/vendor/server-ui/dist/index.html +2 -2
  64. package/vendor/serverd/package.json +2 -2
  65. package/vendor/serverd/src/index.js +9 -0
  66. package/vendor/serverd/src/lib/agent-presence.js +106 -0
  67. package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
  68. package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
  69. package/vendor/serverd/src/lib/mtls.js +44 -4
  70. package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
  71. package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
  72. package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
  73. package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
  74. package/vendor/serverd/src/lib/plugins.js +73 -22
  75. package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
  76. package/vendor/serverd/src/routes/management/plugins.js +166 -19
  77. package/vendor/serverd/src/routes/management/tunnels.js +21 -1
  78. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
  79. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +0 -84
@@ -27,8 +27,9 @@
27
27
  * hooks <name> The plugin's lifecycle hooks and what needs approval: the relay
28
28
  * hook set (stages, routes, fail-open hooks, effects), the agent
29
29
  * half Lamaste installs, the approval digest; agent hooks
30
- * approve-hooks <name> --digest <sha256>
31
- * Approve with the approval digest `hooks` shows (or a pending update's)
30
+ * approve-hooks <name> --digest <sha256> [--version <v>]
31
+ * Approve with the approval digest `hooks` shows; a pending update with
32
+ * its version too (`--version` alone when it has no digest)
32
33
  * auto-approve <name> on|off
33
34
  * Approve what this plugin's later versions bring automatically (audited)
34
35
  * update <name> [--version <v>]
@@ -45,6 +46,12 @@
45
46
  * followed to its end
46
47
  * forget-data <name> --agent <label>
47
48
  * Drop only the relay's record of it; the data stays on the agent
49
+ * settings <name> An agent-hosted plugin's settings: each field's value (a secret
50
+ * only as set or not), whether it still needs configuration,
51
+ * where the values come from, the hosting
52
+ * configure <name> [--set key=value]... [--clear key]... [--secrets-stdin]
53
+ * Change them (kept by its agent half); in a terminal without
54
+ * flags, field by field — a secret never on the command line
48
55
  *
49
56
  * Every subcommand goes through the running panel (`/api/plugins/*`, admin
50
57
  * certificate): the panel owns the plugin registry (SQLite `state.db`), runs
@@ -61,6 +68,14 @@ import { AGENT_LABEL_REGEX } from '@lamalibre/lamaste';
61
68
  import { PANEL_SERVICE } from '../config.js';
62
69
  import { panelRequest } from '../panel-api.js';
63
70
  import { emit, emitStep, emitError, emitComplete, emitLog } from '../ndjson.js';
71
+ import { configureCmd, settingsCmd } from './plugin-settings.js';
72
+ import { settingsDiffLines, terminalText } from '../plugin-settings.js';
73
+ import {
74
+ APPROVE_HOOKS_USAGE,
75
+ approveHooksCommand,
76
+ parseApproveArgs,
77
+ pendingUpdateReasons,
78
+ } from '../plugin-approval.js';
64
79
 
65
80
  /** npm runs inside the panel for install and uninstall (its own limit is 120 s). */
66
81
  const NPM_CALL_TIMEOUT_MS = 180_000;
@@ -115,6 +130,10 @@ export async function runPlugins(args, { json }) {
115
130
  return purgeDataCmd(args.slice(1), { json });
116
131
  case 'forget-data':
117
132
  return forgetDataCmd(args.slice(1), { json });
133
+ case 'settings':
134
+ return settingsCmd(args.slice(1), { json });
135
+ case 'configure':
136
+ return configureCmd(args.slice(1), { json });
118
137
  default:
119
138
  printPluginUsage();
120
139
  process.exit(sub ? 1 : 0);
@@ -148,8 +167,9 @@ ${b('Subcommands:')}
148
167
  ${c('rotate-hosting-secret <name>')}
149
168
  New installation secret (the agent fetches it on its next sync)
150
169
  ${c('hooks <name>')} Lifecycle hooks and what needs approval: relay hooks, agent half
151
- ${c('approve-hooks <name> --digest <sha256>')}
152
- Approve with the approval digest "hooks" showed (or a pending update's)
170
+ ${c('approve-hooks <name> --digest <sha256> [--version <v>]')}
171
+ Approve with the approval digest "hooks" showed; a pending update
172
+ with its version too (--version alone when it has no digest)
153
173
  ${c('auto-approve <name> on|off')}
154
174
  Approve what this plugin's later versions bring automatically
155
175
  ${c('update <name> [--version <v>]')}
@@ -164,6 +184,13 @@ ${b('Subcommands:')}
164
184
  Delete it on the agent — its data directory and account (a push)
165
185
  ${c('forget-data <name> --agent <label>')}
166
186
  Drop only the relay's record; the data stays on the agent
187
+ ${c('settings <name>')} Settings of an agent-hosted plugin: values (secrets only as set or
188
+ not), what is missing, where they come from, the hosting
189
+ ${c('configure <name> [--set key=value]... [--clear key]... [--secrets-stdin]')}
190
+ Change them: --set parses by the field's type (never a secret),
191
+ --clear puts a field back to its default or clears a secret,
192
+ --secrets-stdin reads {"key": "value" | null} from stdin;
193
+ in a terminal without flags, asked field by field
167
194
  `);
168
195
  }
169
196
 
@@ -732,11 +759,14 @@ function printAgentHalf(agentHalf, title) {
732
759
 
733
760
  /**
734
761
  * Print what a version brings that needs approval — the approval digest
735
- * (the one value `approve-hooks` takes), its relay hook set and the agent
736
- * half Lamaste installs.
762
+ * (what `approve-hooks` takes; none for an update that brings neither
763
+ * relay hooks nor an agent half Lamaste installs), its relay hook set and
764
+ * the agent half Lamaste installs.
737
765
  */
738
766
  function printApprovalContents({ digest, relay, agentHalf }) {
739
- console.log(` approval digest: ${chalk.cyan(digest)}`);
767
+ console.log(
768
+ ` approval digest: ${digest ? chalk.cyan(digest) : chalk.dim('none — no relay hooks, no agent half Lamaste installs')}`,
769
+ );
740
770
  if (relay) {
741
771
  console.log('');
742
772
  printRelayHookSet(relay, 'Relay hooks — run in a sandbox on the relay');
@@ -747,9 +777,43 @@ function printApprovalContents({ digest, relay, agentHalf }) {
747
777
  }
748
778
  }
749
779
 
750
- /** The command that approves `digest` for plugin `name`. */
751
- function approveCommand(name, digest) {
752
- return chalk.cyan(`lamaste-server plugins approve-hooks ${name} --digest ${digest}`);
780
+ /**
781
+ * Print an update that waits for approval under `title`: why it waits
782
+ * (`approvalRequired` — a secret turned plain in red), what it brings
783
+ * (its digest, relay hook set and agent half), how it changes the settings
784
+ * schema (outside the digest) — fields added and removed with their types,
785
+ * retyped ones, newly required ones, and a warning for secrets that become
786
+ * plain values — what auto-approve could not cover beyond the reasons, and
787
+ * the command that approves it.
788
+ */
789
+ function printPendingUpdate(name, update, title) {
790
+ console.log('');
791
+ console.log(chalk.bold(` ${title}`));
792
+ const { required, notCovered } = pendingUpdateReasons(update);
793
+ for (const line of required) {
794
+ console.log(line.warning ? chalk.red(` - ${line.text}`) : ` - ${line.text}`);
795
+ }
796
+ console.log('');
797
+ printApprovalContents(update);
798
+ const lines = settingsDiffLines(update.settings);
799
+ if (lines.length > 0) {
800
+ console.log('');
801
+ console.log(chalk.bold(' Settings — what this version changes (not part of the digest)'));
802
+ for (const line of lines) {
803
+ console.log(` ${line.warning ? chalk.red(line.text) : line.text}`);
804
+ }
805
+ }
806
+ if (notCovered.length > 0) {
807
+ console.log('');
808
+ console.log(chalk.bold(' Auto-approve does not cover it'));
809
+ for (const reason of notCovered) console.log(chalk.yellow(` ${reason}`));
810
+ }
811
+ console.log(`\n Approve and update with: ${approveCommand(name, update)}`);
812
+ }
813
+
814
+ /** The command that approves the installed version (`{ digest }`) or a pending update (`{ digest, version }`). */
815
+ function approveCommand(name, target) {
816
+ return chalk.cyan(approveHooksCommand(name, target));
753
817
  }
754
818
 
755
819
  /**
@@ -794,7 +858,7 @@ async function hooksCmd(name, { json }) {
794
858
  `\n auto-approve for later versions: ${hooks.autoApprove ? chalk.yellow('on') : 'off'}`,
795
859
  );
796
860
  if (approval.state !== 'approved') {
797
- console.log(`\n Approve with: ${approveCommand(name, approval.digest)}`);
861
+ console.log(`\n Approve with: ${approveCommand(name, { digest: approval.digest })}`);
798
862
  }
799
863
  }
800
864
  if (hooks.agent) {
@@ -805,10 +869,11 @@ async function hooksCmd(name, { json }) {
805
869
  }
806
870
  if (hooks.pendingUpdate) {
807
871
  const update = hooks.pendingUpdate;
808
- console.log('');
809
- console.log(chalk.bold(` Update to ${update.version} — waits for approval`));
810
- printApprovalContents(update);
811
- console.log(`\n Approve and update with: ${approveCommand(name, update.digest)}`);
872
+ printPendingUpdate(
873
+ name,
874
+ update,
875
+ `Update to ${terminalText(update.version)} — waits for your approval:`,
876
+ );
812
877
  }
813
878
  console.log('');
814
879
  }
@@ -819,18 +884,16 @@ async function hooksCmd(name, { json }) {
819
884
  * @param {{ json: boolean }} options
820
885
  */
821
886
  async function approveHooksCmd(name, rest, { json }) {
822
- const idx = rest.indexOf('--digest');
823
- const digest = idx !== -1 ? rest[idx + 1] : undefined;
824
- if (!name || !digest || !/^[a-f0-9]{64}$/.test(digest)) {
825
- fail(
826
- 'Usage: lamaste-server plugins approve-hooks <name> --digest <sha256> (the approval digest `hooks <name>` shows)',
827
- json,
828
- );
829
- }
887
+ if (!name) fail(APPROVE_HOOKS_USAGE, json);
888
+ const args = parseApproveArgs(rest);
889
+ if (args.error) fail(args.error, json);
890
+ const { digest, version } = args;
830
891
  let result;
831
892
  try {
893
+ // A version names the pending update; its digest, when it has one, goes with it.
832
894
  result = await panelRequest('POST', `/api/plugins/${encodeURIComponent(name)}/hooks/approve`, {
833
895
  digest,
896
+ ...(version ? { version } : {}),
834
897
  });
835
898
  } catch (err) {
836
899
  // Approving a pending update starts it: its agent half is a push.
@@ -899,10 +962,12 @@ async function updateCmd(name, rest, { json }) {
899
962
  return;
900
963
  }
901
964
  const update = result.pendingUpdate;
965
+ printPendingUpdate(
966
+ name,
967
+ update,
968
+ `${name} ${terminalText(update.version)} waits for your approval:`,
969
+ );
902
970
  console.log('');
903
- console.log(chalk.bold(` ${name} ${update.version} waits for approval`));
904
- printApprovalContents(update);
905
- console.log(`\n Approve and update with: ${approveCommand(name, update.digest)}\n`);
906
971
  return;
907
972
  }
908
973
  if (!json) console.log(`\n Updating ${chalk.cyan(name)}`);
@@ -1111,7 +1176,9 @@ async function installPluginCmd(packageName, { json }) {
1111
1176
  agentHalf: result.hooks.agentHalf,
1112
1177
  });
1113
1178
  if (approval.state === 'pending') {
1114
- console.log(`\n Approve with: ${approveCommand(plugin.name, approval.digest)}`);
1179
+ console.log(
1180
+ `\n Approve with: ${approveCommand(plugin.name, { digest: approval.digest })}`,
1181
+ );
1115
1182
  }
1116
1183
  console.log('');
1117
1184
  }
@@ -4,35 +4,158 @@
4
4
  * Used by tunnel/site commands that need the full dependency stack
5
5
  * (nginx, certbot, chisel) which lives in the running panel server.
6
6
  * Authenticates via the admin mTLS client certificate.
7
+ *
8
+ * The panel is verified before anything is sent: the CLI talks to nginx's IP
9
+ * vhost on loopback only when it should be listening, and checks it presents
10
+ * the panel's own certificate, read from disk (see `panel-identity.js`) —
11
+ * port 9292 is one any local user could listen on when nginx does not.
12
+ *
13
+ * Nothing secret is ever an argument of curl: other local users can read a
14
+ * process's command line (`ps`, `/proc/<pid>/cmdline`). The P12 password is
15
+ * in a private `-K` config file, and a request body — which can carry a
16
+ * credential, such as the token of `registry-credential set --token-stdin`
17
+ * — reaches curl on stdin.
7
18
  */
8
19
 
9
- import { readFile, writeFile, unlink } from 'node:fs/promises';
10
- import crypto from 'node:crypto';
20
+ import { rmSync } from 'node:fs';
21
+ import { mkdtemp, open, readFile, rm } from 'node:fs/promises';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
11
24
  import { execa } from 'execa';
12
- import { PKI_DIR } from './config.js';
25
+ import { SETTINGS_KEY_RE } from '@lamalibre/lamaste';
26
+ import { PKI_DIR, readConfig } from './config.js';
27
+ import { PANEL_LOOPBACK, assertPanelListener, readPanelIdentity } from './panel-identity.js';
28
+
29
+ /**
30
+ * TLS 1.2 cipher suites the CLI offers: forward-secret ones only. With RSA
31
+ * key exchange a client sends its certificate before the server has proved
32
+ * it holds the key of the certificate it presented — and the panel's
33
+ * certificate is public — so a listener on the panel's port could collect
34
+ * the admin client certificate from the handshake. With ECDHE (and with
35
+ * TLS 1.3) the server signs first, and an impostor fails before the client
36
+ * presents anything. nginx's `HIGH` list offers these suites.
37
+ */
38
+ const FORWARD_SECRET_CIPHERS = 'ECDHE+AESGCM:ECDHE+CHACHA20';
13
39
 
14
- const PANEL_URL = 'https://127.0.0.1:9292';
40
+ /** The signals that remove a request's private curl config before the CLI dies. */
41
+ const CLEANUP_SIGNALS = ['SIGINT', 'SIGTERM'];
15
42
 
16
43
  /**
17
- * The curl argument vector for one panel request. Pure, exported for tests.
44
+ * One curl config directive, `name = "value"`, with backslashes and double
45
+ * quotes escaped for curl's quoted values. A value holding a line break or
46
+ * NUL is refused: curl reads its config line by line, so a line break would
47
+ * start a directive of the value's choosing. The error never repeats the
48
+ * value. Pure, exported for tests.
49
+ *
50
+ * @param {string} name
51
+ * @param {string} value
52
+ * @returns {string}
53
+ */
54
+ export function curlConfigLine(name, value) {
55
+ if (/[\r\n\0]/.test(value)) {
56
+ throw new Error(`The curl config value of "${name}" contains a line break or NUL`);
57
+ }
58
+ return `${name} = "${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n`;
59
+ }
60
+
61
+ /**
62
+ * Run `fn` with the path of a curl config file holding `content`. The file
63
+ * is created exclusively (`wx`, 0600) in a fresh private directory
64
+ * (`mkdtemp`, 0700): the CLI runs as root and the shared temporary
65
+ * directory belongs to every local user, none of whom may read the file or
66
+ * put a file or a link at its path first. The directory is removed on every
67
+ * path — also when the CLI is interrupted (Ctrl-C) or terminated while the
68
+ * request runs: SIGINT and SIGTERM handlers, installed for exactly that long,
69
+ * remove it and then let the signal end the process as it would have
70
+ * (status 130 / 143 to the shell). The CLI has no other use for them.
71
+ *
72
+ * @template T
73
+ * @param {string} content
74
+ * @param {(configPath: string) => Promise<T>} fn
75
+ * @returns {Promise<T>}
76
+ */
77
+ export async function withCurlConfig(content, fn) {
78
+ /** @type {string | undefined} */
79
+ let dir;
80
+ const onSignal = (signal) => {
81
+ if (dir !== undefined) {
82
+ try {
83
+ rmSync(dir, { recursive: true, force: true });
84
+ } catch {
85
+ // Nothing more can be done on the way out.
86
+ }
87
+ }
88
+ for (const sig of CLEANUP_SIGNALS) process.off(sig, onSignal);
89
+ // Without listeners the signal's default action applies again: re-raised,
90
+ // it ends the process. (A listener still installed — execa's, killing
91
+ // curl — re-raises it in turn once it is the last one.)
92
+ process.kill(process.pid, signal);
93
+ };
94
+ for (const sig of CLEANUP_SIGNALS) process.on(sig, onSignal);
95
+ try {
96
+ dir = await mkdtemp(join(tmpdir(), 'lamalibre-lamaste-curl-'));
97
+ const configPath = join(dir, 'curl.conf');
98
+ const fh = await open(configPath, 'wx', 0o600);
99
+ try {
100
+ await fh.writeFile(content, 'utf-8');
101
+ } finally {
102
+ await fh.close();
103
+ }
104
+ return await fn(configPath);
105
+ } finally {
106
+ for (const sig of CLEANUP_SIGNALS) process.off(sig, onSignal);
107
+ if (dir !== undefined) await rm(dir, { recursive: true, force: true });
108
+ }
109
+ }
110
+
111
+ /**
112
+ * The curl invocation for one panel request: its argument vector, and the
113
+ * JSON body curl reads on stdin (`--data-binary @-`; `undefined` without a
114
+ * body). The body is never an argument. Pure, exported for tests.
115
+ *
116
+ * The panel is verified as `identity` says (`readPanelIdentity`): curl
117
+ * trusts nothing but its certificate (`--cacert`), checks it is issued for
118
+ * the address in the request's URL, and connects to that address on
119
+ * loopback (`--connect-to`) — the certificate names the server's public
120
+ * address, and the CLI never leaves the machine. curl reads no `.curlrc`
121
+ * (`-q`, which must come first), which could switch verification off, and
122
+ * uses no proxy. Never `-k`.
18
123
  *
19
124
  * The JSON content type and the body travel together: a `Content-Type:
20
125
  * application/json` header on a body-less DELETE made Fastify answer 400
21
126
  * ("Body cannot be empty when content-type is set"), so `registry-credential
22
127
  * clear`, `tunnels delete` and `sites delete` never reached their routes.
23
128
  *
24
- * @param {{ method: string, path: string, body?: Record<string, unknown> | null, p12Path: string, configPath: string }} request
25
- * @returns {string[]}
129
+ * @param {{ identity: { certPath: string, address: string, port: number }, method: string, path: string, body?: Record<string, unknown> | null, p12Path: string, configPath: string }} request
130
+ * @returns {{ args: string[], input: string | undefined }}
26
131
  */
27
- export function buildPanelCurlArgs({ method, path, body = null, p12Path, configPath }) {
132
+ export function buildPanelCurlRequest({
133
+ identity,
134
+ method,
135
+ path,
136
+ body = null,
137
+ p12Path,
138
+ configPath,
139
+ }) {
140
+ const { certPath, address, port } = identity;
28
141
  const args = [
142
+ '-q', // No .curlrc — it must be the first argument
29
143
  '-K',
30
144
  configPath,
31
145
  '--cert-type',
32
146
  'P12',
33
147
  '--cert',
34
148
  p12Path,
35
- '-k', // Server uses self-signed cert on localhost
149
+ '--cacert',
150
+ certPath,
151
+ '--connect-to',
152
+ `${address}:${port}:${PANEL_LOOPBACK}:${port}`,
153
+ '--ciphers',
154
+ FORWARD_SECRET_CIPHERS,
155
+ '--proto',
156
+ '=https',
157
+ '--noproxy',
158
+ '*',
36
159
  '-s', // Silent
37
160
  '-S', // Show errors
38
161
  '-X',
@@ -40,13 +163,66 @@ export function buildPanelCurlArgs({ method, path, body = null, p12Path, configP
40
163
  '-H',
41
164
  'Accept: application/json',
42
165
  ];
166
+ let input;
43
167
  if (body !== null && body !== undefined) {
44
- args.push('-H', 'Content-Type: application/json', '-d', JSON.stringify(body));
168
+ args.push('-H', 'Content-Type: application/json', '--data-binary', '@-');
169
+ input = JSON.stringify(body);
45
170
  }
46
171
  // Append the HTTP status on its own line so an error response is not
47
172
  // mistaken for a result — the panel answers errors with a JSON body.
48
- args.push('-w', '\n%{http_code}', `${PANEL_URL}${path}`);
49
- return args;
173
+ args.push('-w', '\n%{http_code}', `https://${address}:${port}${path}`);
174
+ return { args, input };
175
+ }
176
+
177
+ /**
178
+ * The error for a curl run that did not complete, saying what went wrong in
179
+ * the panel's terms: curl's own last line, never its command line. Pure,
180
+ * exported for tests.
181
+ *
182
+ * @param {{ exitCode?: number, stderr?: string, timedOut?: boolean, code?: string }} failure
183
+ * @param {{ certPath: string, port: number }} identity
184
+ * @param {{ p12Path: string, timeoutMs: number }} request
185
+ * @returns {Error}
186
+ */
187
+ export function curlFailure(failure, identity, { p12Path, timeoutMs }) {
188
+ const reason =
189
+ String(failure.stderr ?? '')
190
+ .split('\n')
191
+ .map((line) => line.trim())
192
+ .filter(Boolean)
193
+ .pop() ?? '';
194
+ const listener = `${PANEL_LOOPBACK}:${identity.port}`;
195
+ let message;
196
+ if (failure.timedOut) {
197
+ message = `The panel did not answer within ${Math.round(timeoutMs / 1000)} s`;
198
+ } else if (failure.code === 'ENOENT') {
199
+ message = 'curl is not installed';
200
+ } else {
201
+ switch (failure.exitCode) {
202
+ case 7:
203
+ message = `Nothing listens on ${listener} — is nginx running? (systemctl status nginx)`;
204
+ break;
205
+ // The TLS handshake failed: certificate not trusted, issued for another
206
+ // address, another key — or no suite in common with an impostor.
207
+ case 35:
208
+ case 51:
209
+ case 60:
210
+ case 90:
211
+ message =
212
+ `The listener on ${listener} did not prove it is the panel: it must present ` +
213
+ `${identity.certPath}. Nothing was sent to it. If the certificate was regenerated, ` +
214
+ `reload nginx (systemctl reload nginx).`;
215
+ break;
216
+ case 58:
217
+ message = `curl could not use the admin client certificate ${p12Path}`;
218
+ break;
219
+ default:
220
+ message = `curl failed${failure.exitCode === undefined ? '' : ` (exit ${failure.exitCode})`}`;
221
+ }
222
+ }
223
+ const err = new Error(reason ? `${message} (${reason})` : message);
224
+ if (typeof failure.exitCode === 'number') err.curlExitCode = failure.exitCode;
225
+ return err;
50
226
  }
51
227
 
52
228
  /**
@@ -69,12 +245,31 @@ function detailsText(details) {
69
245
  return [...new Set(found)].join('; ');
70
246
  }
71
247
 
248
+ /**
249
+ * The per-field messages of a refused settings change (`errors`: a setting
250
+ * key, or `_` for the change as a whole, to a message), or null when the
251
+ * panel sent none. Only string messages under a key a setting can have are
252
+ * kept — the panel's and the agent half's messages never contain a value.
253
+ *
254
+ * @param {unknown} errors
255
+ * @returns {Record<string, string> | null}
256
+ */
257
+ function settingsErrors(errors) {
258
+ if (errors === null || typeof errors !== 'object' || Array.isArray(errors)) return null;
259
+ const kept = Object.entries(errors).filter(
260
+ ([key, message]) => (key === '_' || SETTINGS_KEY_RE.test(key)) && typeof message === 'string',
261
+ );
262
+ return kept.length > 0 ? Object.fromEntries(kept) : null;
263
+ }
264
+
72
265
  /**
73
266
  * Split curl's output (`<payload>\n<status>`) and turn a non-2xx status
74
267
  * into an error carrying the panel's `error` (and `details`) in its message,
75
268
  * the status as `statusCode`, and what a caller acts on beyond the message
76
- * when the panel sent it: `code` (e.g. `agent_not_eligible`), `reasons` and
77
- * a running operation's `operationId`. Pure, exported for tests.
269
+ * when the panel sent it: `code` (e.g. `agent_not_eligible`), `reasons`, a
270
+ * running operation's `operationId`, and for a refused settings change its
271
+ * per-field `errors`, the current `revision` (`revision_mismatch`) and the
272
+ * `hosting` (`hosting_offline`). Pure, exported for tests.
78
273
  *
79
274
  * @param {string} stdout
80
275
  * @param {string} method
@@ -107,20 +302,75 @@ export function parsePanelResponse(stdout, method, path) {
107
302
  err.reasons = parsed.reasons.filter((r) => typeof r === 'string');
108
303
  }
109
304
  if (typeof parsed.operationId === 'string') err.operationId = parsed.operationId;
305
+ const errors = settingsErrors(parsed.errors);
306
+ if (errors) err.errors = errors;
307
+ if (Number.isSafeInteger(parsed.revision) && parsed.revision >= 0) {
308
+ err.revision = parsed.revision;
309
+ }
310
+ if (
311
+ parsed.hosting === null ||
312
+ (typeof parsed.hosting === 'object' && !Array.isArray(parsed.hosting))
313
+ ) {
314
+ err.hosting = parsed.hosting;
315
+ }
110
316
  throw err;
111
317
  }
112
318
 
113
319
  return parsed;
114
320
  }
115
321
 
322
+ /**
323
+ * Send one request to the panel with curl: the panel verified as
324
+ * `identity` says, the client certificate, its password in a private `-K`
325
+ * config file, and the body on stdin. Resolves and rejects as
326
+ * `panelRequest` does. `identity` is `readPanelIdentity()`'s (tests
327
+ * describe a local server).
328
+ *
329
+ * @param {{ identity: { certPath: string, address: string, port: number }, method: string, path: string, body?: Record<string, unknown> | null, p12Path: string, p12Password: string, timeoutMs?: number }} request
330
+ * @returns {Promise<Record<string, unknown>>}
331
+ */
332
+ export async function curlPanelRequest({
333
+ identity,
334
+ method,
335
+ path,
336
+ body = null,
337
+ p12Path,
338
+ p12Password,
339
+ timeoutMs = 30000,
340
+ }) {
341
+ return withCurlConfig(curlConfigLine('pass', p12Password), async (configPath) => {
342
+ const { args, input } = buildPanelCurlRequest({
343
+ identity,
344
+ method,
345
+ path,
346
+ body,
347
+ p12Path,
348
+ configPath,
349
+ });
350
+ const result = await execa('curl', args, {
351
+ timeout: timeoutMs,
352
+ reject: false,
353
+ ...(input === undefined ? {} : { input }),
354
+ });
355
+ if (result.failed) {
356
+ throw curlFailure(result, identity, { p12Path, timeoutMs });
357
+ }
358
+ return parsePanelResponse(result.stdout, method, path);
359
+ });
360
+ }
361
+
116
362
  /**
117
363
  * Make an authenticated API request to the local panel server.
118
364
  *
119
- * Uses curl with the admin client certificate (mTLS) and a temporary
120
- * config file for the P12 password (never in process args). Resolves with
121
- * the JSON body; rejects with an Error (`statusCode`, the panel's `error`
122
- * in the message, a refusal's `code`, `reasons` and `operationId`) for any
123
- * non-2xx answer.
365
+ * Refuses before connecting when nginx does not listen on the panel's port
366
+ * (panel 2FA on, IP vhost off), and verifies the listener against the
367
+ * panel's certificate (see the top of this file). Uses curl with the admin
368
+ * client certificate (mTLS); neither the P12 password nor the body is ever
369
+ * in curl's arguments. Resolves with the JSON body; rejects with an Error
370
+ * (`statusCode`, the panel's `error` in the message, a refusal's `code`,
371
+ * `reasons` and `operationId`, a refused settings change's `errors`,
372
+ * `revision` and `hosting`) for any non-2xx answer, and with one that says
373
+ * why when the request could not be made.
124
374
  *
125
375
  * @param {string} method
126
376
  * @param {string} path e.g. '/api/tunnels'
@@ -130,6 +380,17 @@ export function parsePanelResponse(stdout, method, path) {
130
380
  * @returns {Promise<Record<string, unknown>>}
131
381
  */
132
382
  export async function panelRequest(method, path, body = null, { timeoutMs = 30000 } = {}) {
383
+ let config;
384
+ try {
385
+ config = await readConfig();
386
+ } catch {
387
+ throw new Error(
388
+ 'Cannot read panel.json. Ensure the server is properly installed and /etc/lamalibre/lamaste/panel.json exists.',
389
+ );
390
+ }
391
+ await assertPanelListener(config);
392
+ const identity = await readPanelIdentity();
393
+
133
394
  const p12Path = `${PKI_DIR}/client.p12`;
134
395
 
135
396
  // Read p12 password from the stored file
@@ -142,19 +403,5 @@ export async function panelRequest(method, path, body = null, { timeoutMs = 3000
142
403
  );
143
404
  }
144
405
 
145
- // Write password to a temporary config file (never in process args)
146
- const tmpConfigPath = `/tmp/lamalibre-lamaste-curl-${crypto.randomBytes(8).toString('hex')}`;
147
- try {
148
- await writeFile(tmpConfigPath, `--pass "${p12Password}"\n`, { mode: 0o600 });
149
-
150
- const { stdout } = await execa(
151
- 'curl',
152
- buildPanelCurlArgs({ method, path, body, p12Path, configPath: tmpConfigPath }),
153
- { timeout: timeoutMs },
154
- );
155
-
156
- return parsePanelResponse(stdout, method, path);
157
- } finally {
158
- await unlink(tmpConfigPath).catch(() => {});
159
- }
406
+ return curlPanelRequest({ identity, method, path, body, p12Path, p12Password, timeoutMs });
160
407
  }