@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
|
@@ -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
|
|
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
|
|
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
|
-
* (
|
|
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(
|
|
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
|
-
/**
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
823
|
-
const
|
|
824
|
-
if (
|
|
825
|
-
|
|
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(
|
|
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 {
|
|
10
|
-
import
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
'
|
|
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', '-
|
|
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}',
|
|
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
|
|
77
|
-
*
|
|
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
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* the
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
-
|
|
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
|
}
|