@supacloud/admin 0.1.12 → 0.3.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/README.md +32 -0
- package/dist/index.js +760 -114
- package/package.json +1 -1
- package/dist/sshcrypto-8h5xcwbw.node +0 -0
package/README.md
CHANGED
|
@@ -8,9 +8,20 @@ Typical environment variables:
|
|
|
8
8
|
|
|
9
9
|
- `SUPACLOUD_HOST`
|
|
10
10
|
- `SUPACLOUD_SSH_KEY` or `SUPACLOUD_SSH_PASS`
|
|
11
|
+
- `SUPACLOUD_SSH_HOST_FINGERPRINT` — required for SSH actions, in OpenSSH `SHA256:<base64>` form
|
|
11
12
|
- `SUPACLOUD_API_URL`
|
|
12
13
|
- `SUPACLOUD_API_TOKEN`
|
|
13
14
|
|
|
15
|
+
SSH host keys are fail-closed: setting `SUPACLOUD_HOST` and credentials is not
|
|
16
|
+
enough to enable SSH actions. Obtain the fingerprint through a trusted channel,
|
|
17
|
+
compare it out of band, then set it explicitly. For example, the discovery
|
|
18
|
+
command below is useful only after independently authenticating its result:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
ssh-keyscan -p 22 server.example.com | ssh-keygen -lf -
|
|
22
|
+
export SUPACLOUD_SSH_HOST_FINGERPRINT='SHA256:...'
|
|
23
|
+
```
|
|
24
|
+
|
|
14
25
|
Examples:
|
|
15
26
|
|
|
16
27
|
```bash
|
|
@@ -30,3 +41,24 @@ Project commands owned by this CLI:
|
|
|
30
41
|
- `project restore`
|
|
31
42
|
- `project restart`
|
|
32
43
|
- `project update_settings`
|
|
44
|
+
|
|
45
|
+
Gateway / Caddy commands (config is injected via the Caddy JSON Admin API; requires admin privileges):
|
|
46
|
+
|
|
47
|
+
- `gateway routes` — list custom gateway routes
|
|
48
|
+
- `gateway upsert_route` — create or replace a route
|
|
49
|
+
- `gateway update_route` — replace a route by id
|
|
50
|
+
- `gateway delete_route` — remove a route by id
|
|
51
|
+
- `gateway config` — update rate-limit tier, CORS origins, or JWT settings
|
|
52
|
+
- `gateway get_certificate` / `gateway update_certificate`
|
|
53
|
+
- `gateway issue_certificate` — issue or renew with lego
|
|
54
|
+
- `gateway deploy_certificate` — deploy an existing PEM cert/key pair
|
|
55
|
+
- `gateway rebuild` — rebuild all tenant gateway configs (`--clean` for a full rebuild)
|
|
56
|
+
- `gateway custom_hostname` / `gateway set_custom_hostname` / `gateway delete_custom_hostname` / `gateway verify_custom_hostname`
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
supacloud-admin gateway routes --ref abc123
|
|
60
|
+
supacloud-admin gateway upsert_route --ref abc123 --route_id webhook \
|
|
61
|
+
--hosts "api.example.com" --paths "/webhook/*" --upstream 10.0.0.5:8080
|
|
62
|
+
supacloud-admin gateway config --ref abc123 --rate_limit_tier pro
|
|
63
|
+
supacloud-admin gateway rebuild --ref abc123 --clean
|
|
64
|
+
```
|
package/dist/index.js
CHANGED
|
@@ -4757,11 +4757,6 @@ var require_utils = __commonJS((exports, module) => {
|
|
|
4757
4757
|
};
|
|
4758
4758
|
});
|
|
4759
4759
|
|
|
4760
|
-
// node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
|
|
4761
|
-
var require_sshcrypto = __commonJS((exports, module) => {
|
|
4762
|
-
module.exports = __require("./sshcrypto-8h5xcwbw.node");
|
|
4763
|
-
});
|
|
4764
|
-
|
|
4765
4760
|
// node_modules/ssh2/lib/protocol/crypto/poly1305.js
|
|
4766
4761
|
var require_poly1305 = __commonJS((exports, module) => {
|
|
4767
4762
|
var __dirname = "/home/runner/work/supacloud/supacloud/packages/admin/node_modules/ssh2/lib/protocol/crypto", __filename = "/home/runner/work/supacloud/supacloud/packages/admin/node_modules/ssh2/lib/protocol/crypto/poly1305.js";
|
|
@@ -5248,7 +5243,7 @@ var require_crypto = __commonJS((exports, module) => {
|
|
|
5248
5243
|
var ChaChaPolyDecipher;
|
|
5249
5244
|
var GenericDecipher;
|
|
5250
5245
|
try {
|
|
5251
|
-
binding =
|
|
5246
|
+
binding = (()=>{throw new Error("Cannot require module "+"./crypto/build/Release/sshcrypto.node");})();
|
|
5252
5247
|
({
|
|
5253
5248
|
AESGCMCipher,
|
|
5254
5249
|
ChaChaPolyCipher,
|
|
@@ -33123,6 +33118,10 @@ function date4(params) {
|
|
|
33123
33118
|
|
|
33124
33119
|
// node_modules/zod/v4/classic/external.js
|
|
33125
33120
|
config(en_default());
|
|
33121
|
+
// src/index.ts
|
|
33122
|
+
import { resolve as resolve2 } from "node:path";
|
|
33123
|
+
import { pathToFileURL } from "node:url";
|
|
33124
|
+
|
|
33126
33125
|
// src/shared/cli.ts
|
|
33127
33126
|
async function runCli(cliTools, args, options = {}) {
|
|
33128
33127
|
const commandName = options.commandName || "supacloud-admin";
|
|
@@ -33382,6 +33381,7 @@ function resolveSupaCloudContext(env = process.env, cwd = process.cwd()) {
|
|
|
33382
33381
|
sshPort: parseInt(env.SUPACLOUD_SSH_PORT ?? "22", 10),
|
|
33383
33382
|
sshKey: env.SUPACLOUD_SSH_KEY ?? resolve(homedir(), ".ssh", "id_rsa"),
|
|
33384
33383
|
sshPass: env.SUPACLOUD_SSH_PASS ?? "",
|
|
33384
|
+
sshHostFingerprint: env.SUPACLOUD_SSH_HOST_FINGERPRINT ?? "",
|
|
33385
33385
|
apiUrl,
|
|
33386
33386
|
apiToken: env.SUPACLOUD_API_TOKEN ?? inferredToken.value,
|
|
33387
33387
|
projectRef,
|
|
@@ -33525,6 +33525,74 @@ class HttpTransport {
|
|
|
33525
33525
|
|
|
33526
33526
|
// src/shared/transports/ssh.ts
|
|
33527
33527
|
var import_ssh2 = __toESM(require_lib3(), 1);
|
|
33528
|
+
import { timingSafeEqual } from "node:crypto";
|
|
33529
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
33530
|
+
var DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
33531
|
+
var MAX_CONFIGURABLE_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
33532
|
+
function normalizeSshHostFingerprint(value) {
|
|
33533
|
+
const trimmed = value.trim();
|
|
33534
|
+
const match = trimmed.match(/^SHA256:([A-Za-z0-9+/]{43}=?)$/);
|
|
33535
|
+
if (!match) {
|
|
33536
|
+
throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must use OpenSSH SHA256:<base64> format");
|
|
33537
|
+
}
|
|
33538
|
+
const decoded = Buffer.from(match[1], "base64");
|
|
33539
|
+
if (decoded.length !== 32) {
|
|
33540
|
+
throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must contain a 32-byte SHA256 digest");
|
|
33541
|
+
}
|
|
33542
|
+
return `SHA256:${match[1].replace(/=+$/, "")}`;
|
|
33543
|
+
}
|
|
33544
|
+
function createHostVerifier(fingerprint) {
|
|
33545
|
+
const expected = Buffer.from(normalizeSshHostFingerprint(fingerprint).slice("SHA256:".length), "base64");
|
|
33546
|
+
return (actualHash) => {
|
|
33547
|
+
if (!/^[a-f0-9]{64}$/i.test(actualHash))
|
|
33548
|
+
return false;
|
|
33549
|
+
const actual = Buffer.from(actualHash, "hex");
|
|
33550
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
33551
|
+
};
|
|
33552
|
+
}
|
|
33553
|
+
function normalizeMaxOutputBytes(value) {
|
|
33554
|
+
const resolved = value ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
33555
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > MAX_CONFIGURABLE_OUTPUT_BYTES) {
|
|
33556
|
+
throw new Error(`maxOutputBytes must be an integer between 1 and ${MAX_CONFIGURABLE_OUTPUT_BYTES}`);
|
|
33557
|
+
}
|
|
33558
|
+
return resolved;
|
|
33559
|
+
}
|
|
33560
|
+
|
|
33561
|
+
class BoundedOutputCollector {
|
|
33562
|
+
limit;
|
|
33563
|
+
storage;
|
|
33564
|
+
bytes = 0;
|
|
33565
|
+
truncated = false;
|
|
33566
|
+
constructor(limit) {
|
|
33567
|
+
this.limit = limit;
|
|
33568
|
+
this.storage = Buffer.allocUnsafe(limit);
|
|
33569
|
+
}
|
|
33570
|
+
append(data) {
|
|
33571
|
+
if (this.truncated)
|
|
33572
|
+
return;
|
|
33573
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
33574
|
+
const remaining = this.limit - this.bytes;
|
|
33575
|
+
const copied = Math.min(buffer.length, Math.max(0, remaining));
|
|
33576
|
+
if (copied > 0)
|
|
33577
|
+
buffer.copy(this.storage, this.bytes, 0, copied);
|
|
33578
|
+
this.bytes += copied;
|
|
33579
|
+
this.truncated = buffer.length > copied;
|
|
33580
|
+
}
|
|
33581
|
+
finalize() {
|
|
33582
|
+
let output = this.storage.subarray(0, this.bytes).toString("utf8");
|
|
33583
|
+
if (this.truncated) {
|
|
33584
|
+
const lastNewline = output.lastIndexOf(`
|
|
33585
|
+
`);
|
|
33586
|
+
output = lastNewline >= 0 ? output.slice(0, lastNewline + 1) : "";
|
|
33587
|
+
}
|
|
33588
|
+
const redacted = redactSshOutput(output);
|
|
33589
|
+
if (!this.truncated)
|
|
33590
|
+
return redacted;
|
|
33591
|
+
return `${redacted}${redacted && !redacted.endsWith(`
|
|
33592
|
+
`) ? `
|
|
33593
|
+
` : ""}[TRUNCATED: output exceeded ${this.limit}-byte limit]`;
|
|
33594
|
+
}
|
|
33595
|
+
}
|
|
33528
33596
|
var BLOCKED_COMMANDS = [
|
|
33529
33597
|
"rm -rf /",
|
|
33530
33598
|
"mkfs",
|
|
@@ -33542,20 +33610,29 @@ var BLOCKED_COMMANDS = [
|
|
|
33542
33610
|
"crontab -r",
|
|
33543
33611
|
"chmod -R 777 /"
|
|
33544
33612
|
];
|
|
33613
|
+
function redactSshCommand(command) {
|
|
33614
|
+
return command.replace(/(\b[A-Z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL)[A-Z0-9_]*=)(?:"[^"]*"|'[^']*'|[^\s;]+)/gi, "$1[REDACTED]").replace(/(Authorization\s*[:=]\s*)(['"]?)Bearer\s+[^'"\s;]+\2/gi, "$1$2Bearer [REDACTED]$2").replace(/(\b--(?:password|token|secret|api-key)\s+)(\S+)/gi, "$1[REDACTED]").replace(/(postgres(?:ql)?:\/\/[^:\s/]+:)[^@\s]+@/gi, "$1[REDACTED]@");
|
|
33615
|
+
}
|
|
33616
|
+
function redactSshOutput(output) {
|
|
33617
|
+
const redactedLines = output.replace(/^(\s*(?:export\s+)?[A-Z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)[A-Z0-9_]*\s*=\s*).*$/gim, "$1[REDACTED]");
|
|
33618
|
+
const redactedStructuredFields = redactedLines.replace(/((?:["']?(?:password|pass|secret|token|key|credential|db_uri|database_url|dsn)["']?)\s*:\s*)(?:"[^"]*"|'[^']*'|[^,}\]\r\n]+)/gi, "$1[REDACTED]");
|
|
33619
|
+
return redactSshCommand(redactedStructuredFields);
|
|
33620
|
+
}
|
|
33545
33621
|
function isCommandBlocked(command) {
|
|
33546
33622
|
const normalized = command.trim().toLowerCase();
|
|
33547
33623
|
return BLOCKED_COMMANDS.some((blocked) => normalized.includes(blocked));
|
|
33548
33624
|
}
|
|
33549
33625
|
var auditLog = [];
|
|
33550
33626
|
function auditCommand(command, host, blocked) {
|
|
33551
|
-
const
|
|
33627
|
+
const safeCommand = redactSshCommand(command);
|
|
33628
|
+
const entry = { timestamp: new Date().toISOString(), command: safeCommand, host, blocked };
|
|
33552
33629
|
auditLog.push(entry);
|
|
33553
33630
|
if (auditLog.length > 1000)
|
|
33554
33631
|
auditLog.shift();
|
|
33555
33632
|
if (blocked) {
|
|
33556
|
-
console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${
|
|
33633
|
+
console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${safeCommand}`);
|
|
33557
33634
|
} else {
|
|
33558
|
-
console.log(`[SSH-AUDIT] Executing on ${host}: ${
|
|
33635
|
+
console.log(`[SSH-AUDIT] Executing on ${host}: ${safeCommand.substring(0, 200)}`);
|
|
33559
33636
|
}
|
|
33560
33637
|
}
|
|
33561
33638
|
class SshConnectionPool {
|
|
@@ -33563,11 +33640,13 @@ class SshConnectionPool {
|
|
|
33563
33640
|
maxSize = 3;
|
|
33564
33641
|
config;
|
|
33565
33642
|
creating = 0;
|
|
33566
|
-
|
|
33643
|
+
clientFactory;
|
|
33644
|
+
constructor(config2, clientFactory) {
|
|
33567
33645
|
this.config = config2;
|
|
33646
|
+
this.clientFactory = clientFactory;
|
|
33568
33647
|
}
|
|
33569
33648
|
async createConnection() {
|
|
33570
|
-
const conn =
|
|
33649
|
+
const conn = this.clientFactory();
|
|
33571
33650
|
return new Promise((resolve2, reject) => {
|
|
33572
33651
|
const timeout = setTimeout(() => {
|
|
33573
33652
|
conn.end();
|
|
@@ -33583,8 +33662,10 @@ class SshConnectionPool {
|
|
|
33583
33662
|
host: this.config.host,
|
|
33584
33663
|
port: this.config.port,
|
|
33585
33664
|
username: this.config.username,
|
|
33586
|
-
...this.config.privateKeyPath ? { privateKey:
|
|
33665
|
+
...this.config.privateKeyPath ? { privateKey: readFileSync2(this.config.privateKeyPath) } : {},
|
|
33587
33666
|
...this.config.password ? { password: this.config.password } : {},
|
|
33667
|
+
hostHash: "sha256",
|
|
33668
|
+
hostVerifier: createHostVerifier(this.config.hostFingerprint),
|
|
33588
33669
|
readyTimeout: 15000,
|
|
33589
33670
|
keepaliveInterval: 30000
|
|
33590
33671
|
});
|
|
@@ -33627,17 +33708,22 @@ class SshConnectionPool {
|
|
|
33627
33708
|
class SshTransport {
|
|
33628
33709
|
config;
|
|
33629
33710
|
pool;
|
|
33630
|
-
constructor(config2) {
|
|
33631
|
-
this.config =
|
|
33632
|
-
|
|
33711
|
+
constructor(config2, options = {}) {
|
|
33712
|
+
this.config = {
|
|
33713
|
+
...config2,
|
|
33714
|
+
hostFingerprint: normalizeSshHostFingerprint(config2.hostFingerprint || ""),
|
|
33715
|
+
maxOutputBytes: normalizeMaxOutputBytes(config2.maxOutputBytes)
|
|
33716
|
+
};
|
|
33717
|
+
this.pool = new SshConnectionPool(this.config, options.clientFactory ?? (() => new import_ssh2.Client));
|
|
33633
33718
|
}
|
|
33634
33719
|
async exec(command, timeoutMs = 300000) {
|
|
33635
33720
|
if (isCommandBlocked(command)) {
|
|
33636
33721
|
auditCommand(command, this.config.host, true);
|
|
33722
|
+
const safeCommand = redactSshCommand(command);
|
|
33637
33723
|
return {
|
|
33638
33724
|
success: false,
|
|
33639
33725
|
stdout: "",
|
|
33640
|
-
stderr: `Command blocked by security policy: "${
|
|
33726
|
+
stderr: `Command blocked by security policy: "${safeCommand.substring(0, 100)}". ` + `Destructive or system-altering commands are not allowed via supacloud-admin.`,
|
|
33641
33727
|
code: 126
|
|
33642
33728
|
};
|
|
33643
33729
|
}
|
|
@@ -33645,8 +33731,9 @@ class SshTransport {
|
|
|
33645
33731
|
const conn = await this.pool.acquire();
|
|
33646
33732
|
try {
|
|
33647
33733
|
return await new Promise((resolve2, reject) => {
|
|
33648
|
-
|
|
33649
|
-
|
|
33734
|
+
const outputLimit = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
33735
|
+
const stdout = new BoundedOutputCollector(outputLimit);
|
|
33736
|
+
const stderr = new BoundedOutputCollector(outputLimit);
|
|
33650
33737
|
const timer = setTimeout(() => {
|
|
33651
33738
|
conn.end();
|
|
33652
33739
|
reject(new Error(`SSH command timed out after ${timeoutMs}ms`));
|
|
@@ -33658,11 +33745,18 @@ class SshTransport {
|
|
|
33658
33745
|
}
|
|
33659
33746
|
stream.on("close", (code) => {
|
|
33660
33747
|
clearTimeout(timer);
|
|
33661
|
-
resolve2({
|
|
33748
|
+
resolve2({
|
|
33749
|
+
success: code === 0,
|
|
33750
|
+
stdout: stdout.finalize(),
|
|
33751
|
+
stderr: stderr.finalize(),
|
|
33752
|
+
code,
|
|
33753
|
+
stdoutTruncated: stdout.truncated,
|
|
33754
|
+
stderrTruncated: stderr.truncated
|
|
33755
|
+
});
|
|
33662
33756
|
}).on("data", (data) => {
|
|
33663
|
-
stdout
|
|
33757
|
+
stdout.append(data);
|
|
33664
33758
|
}).stderr.on("data", (data) => {
|
|
33665
|
-
stderr
|
|
33759
|
+
stderr.append(data);
|
|
33666
33760
|
});
|
|
33667
33761
|
});
|
|
33668
33762
|
});
|
|
@@ -33688,6 +33782,29 @@ class SshTransport {
|
|
|
33688
33782
|
this.pool.release(conn);
|
|
33689
33783
|
}
|
|
33690
33784
|
}
|
|
33785
|
+
async uploadText(remotePath, content, mode = 384) {
|
|
33786
|
+
auditCommand(`upload ${remotePath} (${Buffer.byteLength(content)} bytes; content redacted)`, this.config.host, false);
|
|
33787
|
+
const conn = await this.pool.acquire();
|
|
33788
|
+
try {
|
|
33789
|
+
await new Promise((resolve2, reject) => {
|
|
33790
|
+
conn.sftp((err, sftp) => {
|
|
33791
|
+
if (err)
|
|
33792
|
+
return reject(err);
|
|
33793
|
+
sftp.writeFile(remotePath, content, { mode }, (writeError) => {
|
|
33794
|
+
if (writeError)
|
|
33795
|
+
return reject(writeError);
|
|
33796
|
+
sftp.chmod(remotePath, mode, (chmodError) => {
|
|
33797
|
+
if (chmodError)
|
|
33798
|
+
return reject(chmodError);
|
|
33799
|
+
resolve2();
|
|
33800
|
+
});
|
|
33801
|
+
});
|
|
33802
|
+
});
|
|
33803
|
+
});
|
|
33804
|
+
} finally {
|
|
33805
|
+
this.pool.release(conn);
|
|
33806
|
+
}
|
|
33807
|
+
}
|
|
33691
33808
|
async ping() {
|
|
33692
33809
|
const result = await this.exec("echo pong", 1e4).catch(() => null);
|
|
33693
33810
|
return result?.stdout.trim() === "pong";
|
|
@@ -33698,32 +33815,40 @@ class SshTransport {
|
|
|
33698
33815
|
}
|
|
33699
33816
|
|
|
33700
33817
|
// src/shared/tools/ssh-tools.ts
|
|
33818
|
+
import { randomUUID } from "node:crypto";
|
|
33701
33819
|
var SAFE_CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
|
|
33702
33820
|
var SAFE_PROJECT_REF = /^[a-z0-9-]{1,20}$/;
|
|
33703
33821
|
var SAFE_RELEASE_TAG = /^[a-zA-Z0-9._-]{1,80}$/;
|
|
33704
33822
|
var SAFE_TIMEOUT_SECONDS = 300;
|
|
33705
|
-
var
|
|
33706
|
-
|
|
33707
|
-
|
|
33708
|
-
|
|
33709
|
-
|
|
33710
|
-
|
|
33711
|
-
|
|
33712
|
-
|
|
33713
|
-
|
|
33714
|
-
|
|
33715
|
-
|
|
33716
|
-
|
|
33717
|
-
|
|
33718
|
-
|
|
33719
|
-
|
|
33720
|
-
|
|
33721
|
-
|
|
33722
|
-
|
|
33723
|
-
|
|
33724
|
-
|
|
33725
|
-
|
|
33726
|
-
|
|
33823
|
+
var SAFE_HOSTNAME = /^(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(?:\.(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?))*$/;
|
|
33824
|
+
var SAFE_SYSTEMD_UNIT = /^[a-zA-Z0-9][a-zA-Z0-9_.@:-]{0,127}$/;
|
|
33825
|
+
var SAFE_DB_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/;
|
|
33826
|
+
function hostnameSchema(fieldName) {
|
|
33827
|
+
return exports_external.string().trim().min(1).max(253).refine((value) => SAFE_HOSTNAME.test(value), { message: `Invalid ${fieldName}` }).transform((value) => value.toLowerCase());
|
|
33828
|
+
}
|
|
33829
|
+
function secretSchema(fieldName) {
|
|
33830
|
+
return exports_external.string().min(12, `${fieldName} must contain at least 12 characters`).max(256, `${fieldName} must contain at most 256 characters`).refine((value) => value === value.trim() && !/[\u0000-\u001f\u007f]/.test(value), {
|
|
33831
|
+
message: `Invalid ${fieldName}`
|
|
33832
|
+
});
|
|
33833
|
+
}
|
|
33834
|
+
function quoteEnvValue(value) {
|
|
33835
|
+
return `'${value.split("'").join("'\\''")}'`;
|
|
33836
|
+
}
|
|
33837
|
+
var REMOTE_ENV_REDACTION_AWK = `awk -F= 'BEGIN { IGNORECASE=1 } /^[A-Za-z_][A-Za-z0-9_]*=/ { key=$1; if (key ~ /(PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)/) print key "=[REDACTED]"; else print; next } { print }'`;
|
|
33838
|
+
function redactTenantConfig(value) {
|
|
33839
|
+
const redactedLines = value.split(/\r?\n/).map((line) => {
|
|
33840
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
33841
|
+
if (!match)
|
|
33842
|
+
return line;
|
|
33843
|
+
const key = match[1];
|
|
33844
|
+
if (!/(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)/i.test(key)) {
|
|
33845
|
+
return line;
|
|
33846
|
+
}
|
|
33847
|
+
return `${key}=[REDACTED]`;
|
|
33848
|
+
}).join(`
|
|
33849
|
+
`);
|
|
33850
|
+
return redactedLines.replace(/\b([A-Za-z_][A-Za-z0-9_]*(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)[A-Za-z0-9_]*)=(?:"[^"]*"|'[^']*'|\S+)/gi, "$1=[REDACTED]").replace(/\b(postgres(?:ql)?:\/\/[^:\s/@]+:)[^@\s]+@/gi, "$1[REDACTED]@");
|
|
33851
|
+
}
|
|
33727
33852
|
function assertSafeProjectRef(value, fieldName) {
|
|
33728
33853
|
if (!SAFE_PROJECT_REF.test(value)) {
|
|
33729
33854
|
throw new Error(`Invalid ${fieldName}`);
|
|
@@ -33744,21 +33869,21 @@ function assertSafeReleaseTag(value) {
|
|
|
33744
33869
|
}
|
|
33745
33870
|
function assertSafeGithubProxy(value) {
|
|
33746
33871
|
const trimmed = value.trim();
|
|
33747
|
-
if (/[\s\n\r;&|`$<>]/.test(trimmed)) {
|
|
33872
|
+
if (/[\s\n\r;&|`$<>{}\[\]()*!?\\'\"]/.test(trimmed)) {
|
|
33748
33873
|
throw new Error("Invalid github_proxy");
|
|
33749
33874
|
}
|
|
33750
33875
|
if (trimmed.toLowerCase() === "direct" || trimmed.toLowerCase() === "none") {
|
|
33751
33876
|
return trimmed;
|
|
33752
33877
|
}
|
|
33753
33878
|
const parsed = new URL(trimmed);
|
|
33754
|
-
if (
|
|
33755
|
-
throw new Error("Invalid github_proxy protocol");
|
|
33879
|
+
if (parsed.protocol !== "https:") {
|
|
33880
|
+
throw new Error("Invalid github_proxy protocol: HTTPS is required");
|
|
33881
|
+
}
|
|
33882
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
33883
|
+
throw new Error("Invalid github_proxy: credentials, query strings, and fragments are not allowed");
|
|
33756
33884
|
}
|
|
33757
33885
|
return parsed.toString();
|
|
33758
33886
|
}
|
|
33759
|
-
function deriveStudioDomain(publicDomain) {
|
|
33760
|
-
return `studio.${publicDomain.trim().replace(/^(?:api|studio)\./i, "")}`;
|
|
33761
|
-
}
|
|
33762
33887
|
function getExecTimeoutMs(timeoutSeconds) {
|
|
33763
33888
|
const seconds = timeoutSeconds || 60;
|
|
33764
33889
|
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > SAFE_TIMEOUT_SECONDS) {
|
|
@@ -33771,13 +33896,113 @@ function assertSafeExecCommand(command) {
|
|
|
33771
33896
|
if (!trimmed) {
|
|
33772
33897
|
throw new Error("'command' required");
|
|
33773
33898
|
}
|
|
33774
|
-
|
|
33775
|
-
throw new Error("
|
|
33899
|
+
const reject = () => {
|
|
33900
|
+
throw new Error("Command is outside the allowed read-only diagnostic grammar");
|
|
33901
|
+
};
|
|
33902
|
+
if (!/^[\x20-\x7e]+$/.test(trimmed) || /[\n\r;&|`$<>\\'\"()[\]{}*?!~#]/.test(trimmed))
|
|
33903
|
+
reject();
|
|
33904
|
+
const tokens = trimmed.split(/\s+/);
|
|
33905
|
+
const commandName = tokens[0];
|
|
33906
|
+
if (commandName === "systemctl") {
|
|
33907
|
+
const action = tokens[1];
|
|
33908
|
+
if (["list-units", "list-unit-files"].includes(action)) {
|
|
33909
|
+
if (tokens.length === 2 || tokens.length === 3 && tokens[2] === "--no-pager")
|
|
33910
|
+
return trimmed;
|
|
33911
|
+
reject();
|
|
33912
|
+
}
|
|
33913
|
+
if (["status", "is-active", "is-enabled"].includes(action) && SAFE_SYSTEMD_UNIT.test(tokens[2] || "")) {
|
|
33914
|
+
if (tokens.length === 3 || tokens.length === 4 && tokens[3] === "--no-pager")
|
|
33915
|
+
return trimmed;
|
|
33916
|
+
}
|
|
33917
|
+
reject();
|
|
33918
|
+
}
|
|
33919
|
+
if (commandName === "journalctl") {
|
|
33920
|
+
let unit = "";
|
|
33921
|
+
let tailCount = "";
|
|
33922
|
+
let noPager = false;
|
|
33923
|
+
for (let index = 1;index < tokens.length; index += 1) {
|
|
33924
|
+
const token = tokens[index];
|
|
33925
|
+
if (token === "-u" && !unit) {
|
|
33926
|
+
unit = tokens[++index] || "";
|
|
33927
|
+
if (!SAFE_SYSTEMD_UNIT.test(unit))
|
|
33928
|
+
reject();
|
|
33929
|
+
} else if (token === "-n" && !tailCount) {
|
|
33930
|
+
tailCount = tokens[++index] || "";
|
|
33931
|
+
const count = Number(tailCount);
|
|
33932
|
+
if (!/^\d+$/.test(tailCount) || count < 1 || count > 1000)
|
|
33933
|
+
reject();
|
|
33934
|
+
} else if (token === "--no-pager" && !noPager) {
|
|
33935
|
+
noPager = true;
|
|
33936
|
+
} else {
|
|
33937
|
+
reject();
|
|
33938
|
+
}
|
|
33939
|
+
}
|
|
33940
|
+
if (unit && tailCount && noPager)
|
|
33941
|
+
return trimmed;
|
|
33942
|
+
reject();
|
|
33943
|
+
}
|
|
33944
|
+
if (commandName === "docker" || commandName === "podman") {
|
|
33945
|
+
const action = tokens[1];
|
|
33946
|
+
if (action === "ps") {
|
|
33947
|
+
const flags = tokens.slice(2);
|
|
33948
|
+
if (flags.every((flag, index) => ["-a", "--no-trunc"].includes(flag) && flags.indexOf(flag) === index)) {
|
|
33949
|
+
return trimmed;
|
|
33950
|
+
}
|
|
33951
|
+
reject();
|
|
33952
|
+
}
|
|
33953
|
+
if (action === "logs") {
|
|
33954
|
+
let index = 2;
|
|
33955
|
+
if (tokens[index] !== "--tail")
|
|
33956
|
+
reject();
|
|
33957
|
+
const countToken = tokens[index + 1] || "";
|
|
33958
|
+
const count = Number(countToken);
|
|
33959
|
+
if (!/^\d+$/.test(countToken) || count < 1 || count > 1000)
|
|
33960
|
+
reject();
|
|
33961
|
+
index += 2;
|
|
33962
|
+
if (index === tokens.length - 1 && SAFE_CONTAINER_NAME.test(tokens[index] || ""))
|
|
33963
|
+
return trimmed;
|
|
33964
|
+
reject();
|
|
33965
|
+
}
|
|
33966
|
+
reject();
|
|
33776
33967
|
}
|
|
33777
|
-
if (
|
|
33778
|
-
|
|
33968
|
+
if (commandName === "ps" && trimmed === "ps -eo pid,user,comm")
|
|
33969
|
+
return trimmed;
|
|
33970
|
+
if (commandName === "ss" && ["ss -s", "ss -tlnp", "ss -lntp"].includes(trimmed))
|
|
33971
|
+
return trimmed;
|
|
33972
|
+
if (commandName === "df" && (trimmed === "df -h" || /^df -h (?:\/|\/var|\/tmp)$/.test(trimmed)))
|
|
33973
|
+
return trimmed;
|
|
33974
|
+
if (commandName === "free" && ["free", "free -h", "free -m"].includes(trimmed))
|
|
33975
|
+
return trimmed;
|
|
33976
|
+
if (commandName === "uname" && ["uname", "uname -a", "uname -r", "uname -m"].includes(trimmed))
|
|
33977
|
+
return trimmed;
|
|
33978
|
+
if (trimmed === "cat /etc/os-release")
|
|
33979
|
+
return trimmed;
|
|
33980
|
+
if (commandName === "hostname" && ["hostname", "hostname -f"].includes(trimmed))
|
|
33981
|
+
return trimmed;
|
|
33982
|
+
if (commandName === "pg_isready") {
|
|
33983
|
+
const seen = new Set;
|
|
33984
|
+
for (let index = 1;index < tokens.length; index += 2) {
|
|
33985
|
+
const option = tokens[index];
|
|
33986
|
+
const optionValue = tokens[index + 1];
|
|
33987
|
+
if (!optionValue || seen.has(option))
|
|
33988
|
+
reject();
|
|
33989
|
+
seen.add(option);
|
|
33990
|
+
if (option === "-h" && !["localhost", "127.0.0.1", "::1"].includes(optionValue))
|
|
33991
|
+
reject();
|
|
33992
|
+
else if (option === "-p") {
|
|
33993
|
+
const port = Number(optionValue);
|
|
33994
|
+
if (!/^\d+$/.test(optionValue) || port < 1 || port > 65535)
|
|
33995
|
+
reject();
|
|
33996
|
+
} else if (["-U", "-d"].includes(option)) {
|
|
33997
|
+
if (!SAFE_DB_IDENTIFIER.test(optionValue))
|
|
33998
|
+
reject();
|
|
33999
|
+
} else if (option !== "-h") {
|
|
34000
|
+
reject();
|
|
34001
|
+
}
|
|
34002
|
+
}
|
|
34003
|
+
return trimmed;
|
|
33779
34004
|
}
|
|
33780
|
-
return
|
|
34005
|
+
return reject();
|
|
33781
34006
|
}
|
|
33782
34007
|
function registerSshTools(server, ssh) {
|
|
33783
34008
|
server.tool("ssh", `Server management via SSH. Available before & after SupaCloud installation.
|
|
@@ -33799,14 +34024,14 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
|
|
|
33799
34024
|
]).describe("Action to perform"),
|
|
33800
34025
|
command: exports_external.string().optional().describe("[exec] Restricted shell command to execute"),
|
|
33801
34026
|
timeout_seconds: exports_external.number().optional().describe("[exec] Timeout in seconds (default: 60)"),
|
|
33802
|
-
public_domain:
|
|
33803
|
-
studio_domain:
|
|
33804
|
-
postgres_password:
|
|
33805
|
-
dashboard_password:
|
|
34027
|
+
public_domain: hostnameSchema("public_domain").optional().describe("[install] API domain, e.g. api.example.com"),
|
|
34028
|
+
studio_domain: hostnameSchema("studio_domain").optional().describe("[install] Studio domain"),
|
|
34029
|
+
postgres_password: secretSchema("postgres_password").optional().describe("[install] DB password (auto-generated if empty)"),
|
|
34030
|
+
dashboard_password: secretSchema("dashboard_password").optional().describe("[install] Console password"),
|
|
33806
34031
|
edge_runtime: exports_external.enum(["bun"]).optional().describe("[install] Runtime (default: bun)"),
|
|
33807
|
-
storage_type: exports_external.enum(["juicefs", "
|
|
34032
|
+
storage_type: exports_external.enum(["juicefs", "minio"]).optional().describe("[install] Storage backend configurable through Admin"),
|
|
33808
34033
|
version: exports_external.string().optional().describe("[upgrade] Specific version"),
|
|
33809
|
-
github_proxy: exports_external.string().optional().describe("[upgrade] GitHub proxy prefix,
|
|
34034
|
+
github_proxy: exports_external.string().optional().describe("[install/upgrade] Explicit GitHub proxy prefix, or direct/none"),
|
|
33810
34035
|
focus: exports_external.enum(["all", "containers", "database", "network", "disk", "logs"]).optional().describe("[troubleshoot] Focus area"),
|
|
33811
34036
|
container: exports_external.string().optional().describe("[container_logs] Container name"),
|
|
33812
34037
|
lines: exports_external.number().optional().describe("[container_logs] Number of log lines (default: 100)"),
|
|
@@ -33827,15 +34052,11 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
|
|
|
33827
34052
|
}
|
|
33828
34053
|
case "setup": {
|
|
33829
34054
|
const baseTools = await ssh.exec("if ! command -v git &>/dev/null; then " + " if command -v dnf &>/dev/null; then dnf install -y git; " + " elif command -v yum &>/dev/null; then yum install -y git; " + " elif command -v apt-get &>/dev/null; then apt-get update && apt-get install -y git; fi; " + "fi; " + "if command -v dnf &>/dev/null; then dnf install -y compat-openssl11 libatomic 2>/dev/null; " + "elif command -v yum &>/dev/null; then yum install -y compat-openssl11 libatomic 2>/dev/null; fi; " + "ldconfig 2>/dev/null; git --version; openssl version");
|
|
33830
|
-
const
|
|
33831
|
-
|
|
33832
|
-
await ssh.exec("IP=$(hostname -I | cut -d' ' -f1) && ssh-keyscan -H localhost 127.0.0.1 $IP >> ~/.ssh/known_hosts 2>/dev/null && chmod 600 ~/.ssh/known_hosts");
|
|
33833
|
-
const verify = await ssh.exec("ssh -o StrictHostKeyChecking=no root@localhost 'echo SSH_SELF_OK'");
|
|
33834
|
-
const ok = verify.stdout.includes("SSH_SELF_OK");
|
|
34055
|
+
const verify = await ssh.exec("echo SSH_SESSION_OK");
|
|
34056
|
+
const ok = verify.success && verify.stdout.includes("SSH_SESSION_OK");
|
|
33835
34057
|
text = [
|
|
33836
|
-
ok ? "✅ SSH
|
|
34058
|
+
ok ? "✅ SSH session verified" : "❌ SSH session verification failed",
|
|
33837
34059
|
`Tools: ${baseTools.stdout.trim()}`,
|
|
33838
|
-
`SSH: exit ${sshSetup.code}`,
|
|
33839
34060
|
`Verify: ${verify.stdout.trim() || verify.stderr.trim()}`
|
|
33840
34061
|
].join(`
|
|
33841
34062
|
`);
|
|
@@ -33844,40 +34065,93 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
|
|
|
33844
34065
|
case "install": {
|
|
33845
34066
|
if (!args.public_domain)
|
|
33846
34067
|
throw new Error("'public_domain' required");
|
|
33847
|
-
const
|
|
34068
|
+
const installId = randomUUID();
|
|
34069
|
+
const DIR = "/opt/supacloud";
|
|
34070
|
+
const LOG = `/var/log/supacloud/install-${installId}.log`;
|
|
34071
|
+
const STATUS = `/var/log/supacloud/install-${installId}.status`;
|
|
34072
|
+
const CONFIG = "/etc/supabase/install.env";
|
|
34073
|
+
const INPUT = `/etc/supabase/.install-input-${installId}.env`;
|
|
34074
|
+
const BOOTSTRAP = `/opt/.supacloud-bootstrap-${installId}`;
|
|
33848
34075
|
const REPO = "https://github.com/zuohuadong/supacloud.git";
|
|
34076
|
+
const configuredProxy = args.github_proxy ? assertSafeGithubProxy(args.github_proxy) : "direct";
|
|
34077
|
+
const proxyDisabled = ["direct", "none"].includes(configuredProxy.toLowerCase());
|
|
34078
|
+
const proxyPrefix = proxyDisabled ? "" : configuredProxy.endsWith("/") ? configuredProxy : `${configuredProxy}/`;
|
|
34079
|
+
const bootstrapClone = `git clone --depth 1 --branch main ${quoteEnvValue(REPO)} ${quoteEnvValue(BOOTSTRAP)}`;
|
|
34080
|
+
const bootstrapDeps = await ssh.exec(`set -e; ` + `if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then ` + `if command -v apt-get >/dev/null 2>&1; then ` + `apt-get update; DEBIAN_FRONTEND=noninteractive apt-get install -y git curl ca-certificates; ` + `elif command -v dnf >/dev/null 2>&1; then ` + `dnf install -y git curl ca-certificates; ` + `elif command -v yum >/dev/null 2>&1; then ` + `yum install -y git curl ca-certificates; ` + `else echo 'No supported package manager can install git and curl' >&2; exit 127; fi; ` + `fi; ` + `command -v git >/dev/null 2>&1; command -v curl >/dev/null 2>&1; ` + `echo BOOTSTRAP_DEPS_OK`, 180000);
|
|
34081
|
+
if (!bootstrapDeps.success || !bootstrapDeps.stdout.includes("BOOTSTRAP_DEPS_OK")) {
|
|
34082
|
+
text = `❌ Bootstrap dependency preparation failed
|
|
34083
|
+
${bootstrapDeps.stderr.slice(-500)}`;
|
|
34084
|
+
break;
|
|
34085
|
+
}
|
|
33849
34086
|
const osCheck = await ssh.exec("cat /etc/os-release | grep -E 'NAME|VERSION_ID' | head -4");
|
|
33850
|
-
const clone2 = await ssh.exec(`
|
|
33851
|
-
if (!clone2.stdout.includes("
|
|
33852
|
-
|
|
34087
|
+
const clone2 = await ssh.exec(`set -e; umask 077; rm -rf ${quoteEnvValue(BOOTSTRAP)}; ` + `${bootstrapClone}; ` + `git -C ${quoteEnvValue(BOOTSTRAP)} remote set-url origin ${quoteEnvValue(REPO)}; ` + `test "$(git -C ${quoteEnvValue(BOOTSTRAP)} remote get-url origin)" = ${quoteEnvValue(REPO)}; ` + `test "$(git -C ${quoteEnvValue(BOOTSTRAP)} symbolic-ref --short HEAD)" = main; ` + `test -z "$(git -C ${quoteEnvValue(BOOTSTRAP)} status --porcelain --untracked-files=no)"; ` + `git -C ${quoteEnvValue(BOOTSTRAP)} ls-files --error-unmatch setup.sh scripts/lib/install_config.sh scripts/lib/release_assets.sh >/dev/null; ` + `test -f ${quoteEnvValue(`${BOOTSTRAP}/setup.sh`)}; echo BOOTSTRAP_OK`, 120000);
|
|
34088
|
+
if (!clone2.stdout.includes("BOOTSTRAP_OK")) {
|
|
34089
|
+
await ssh.exec(`rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
|
|
34090
|
+
text = `❌ Trusted bootstrap clone failed
|
|
33853
34091
|
${clone2.stderr.slice(-500)}`;
|
|
33854
34092
|
break;
|
|
33855
34093
|
}
|
|
34094
|
+
const prepareProtectedPaths = await ssh.exec(`umask 077; install -d -m 700 /etc/supabase /var/log/supacloud; ` + `: > ${quoteEnvValue(LOG)}; : > ${quoteEnvValue(STATUS)}; ` + `chmod 600 ${quoteEnvValue(LOG)} ${quoteEnvValue(STATUS)}`);
|
|
34095
|
+
if (!prepareProtectedPaths.success) {
|
|
34096
|
+
await ssh.exec(`rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
|
|
34097
|
+
text = `❌ Unable to prepare protected install input and log paths
|
|
34098
|
+
${prepareProtectedPaths.stderr.slice(-500)}`;
|
|
34099
|
+
break;
|
|
34100
|
+
}
|
|
33856
34101
|
const envLines = [
|
|
33857
|
-
`SUPABASE_PUBLIC_DOMAIN=${args.public_domain}`,
|
|
33858
|
-
|
|
33859
|
-
`EDGE_RUNTIME=${args.edge_runtime
|
|
33860
|
-
`S3_STORAGE_TYPE=${args.storage_type
|
|
33861
|
-
args.postgres_password ? `POSTGRES_PASSWORD=${args.postgres_password}` : "",
|
|
33862
|
-
args.dashboard_password ? `DASHBOARD_PASSWORD=${args.dashboard_password}` : ""
|
|
34102
|
+
`SUPABASE_PUBLIC_DOMAIN=${quoteEnvValue(args.public_domain)}`,
|
|
34103
|
+
args.studio_domain ? `SUPABASE_STUDIO_DOMAIN=${quoteEnvValue(args.studio_domain)}` : "",
|
|
34104
|
+
args.edge_runtime ? `EDGE_RUNTIME=${quoteEnvValue(args.edge_runtime)}` : "",
|
|
34105
|
+
args.storage_type ? `S3_STORAGE_TYPE=${quoteEnvValue(args.storage_type)}` : "",
|
|
34106
|
+
args.postgres_password ? `POSTGRES_PASSWORD=${quoteEnvValue(args.postgres_password)}` : "",
|
|
34107
|
+
args.dashboard_password ? `DASHBOARD_PASSWORD=${quoteEnvValue(args.dashboard_password)}` : ""
|
|
33863
34108
|
].filter(Boolean).join(`
|
|
33864
34109
|
`);
|
|
33865
|
-
|
|
33866
|
-
|
|
33867
|
-
|
|
33868
|
-
|
|
33869
|
-
|
|
34110
|
+
try {
|
|
34111
|
+
await ssh.uploadText(INPUT, `${envLines}
|
|
34112
|
+
`, 384);
|
|
34113
|
+
} catch (error51) {
|
|
34114
|
+
await ssh.exec(`rm -f ${quoteEnvValue(INPUT)}; rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
|
|
34115
|
+
throw error51;
|
|
34116
|
+
}
|
|
34117
|
+
const setupEnv = [
|
|
34118
|
+
`SUPACLOUD_INSTALL_DIR=${quoteEnvValue(DIR)}`,
|
|
34119
|
+
"SUPACLOUD_SETUP_ARTIFACT_MODE=release",
|
|
34120
|
+
"SUPACLOUD_FORCE_VERIFIED_RELEASE_ASSETS=true",
|
|
34121
|
+
`SUPACLOUD_SETUP_INPUT_FILE=${quoteEnvValue(INPUT)}`,
|
|
34122
|
+
`SUPACLOUD_INSTALL_CONFIG_FILE=${quoteEnvValue(CONFIG)}`,
|
|
34123
|
+
proxyPrefix ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(configuredProxy)}` : ""
|
|
34124
|
+
].filter(Boolean).join(" ");
|
|
34125
|
+
const statusNext = `${STATUS}.next`;
|
|
34126
|
+
const backgroundScript = [
|
|
34127
|
+
"set +e",
|
|
34128
|
+
`trap "rm -f ${quoteEnvValue(INPUT)}; rm -rf ${quoteEnvValue(BOOTSTRAP)}" EXIT`,
|
|
34129
|
+
`printf 'RUNNING\\n' > ${quoteEnvValue(statusNext)}`,
|
|
34130
|
+
`chmod 600 ${quoteEnvValue(statusNext)}`,
|
|
34131
|
+
`mv -f ${quoteEnvValue(statusNext)} ${quoteEnvValue(STATUS)}`,
|
|
34132
|
+
`env ${setupEnv} bash ${quoteEnvValue(`${BOOTSTRAP}/setup.sh`)}`,
|
|
34133
|
+
"INSTALL_CODE=$?",
|
|
34134
|
+
`if [ "$INSTALL_CODE" -eq 0 ]; then printf 'SUCCEEDED\\n' > ${quoteEnvValue(statusNext)}; ` + `else printf 'FAILED:%s\\n' "$INSTALL_CODE" > ${quoteEnvValue(statusNext)}; fi`,
|
|
34135
|
+
`chmod 600 ${quoteEnvValue(statusNext)}`,
|
|
34136
|
+
`mv -f ${quoteEnvValue(statusNext)} ${quoteEnvValue(STATUS)}`,
|
|
34137
|
+
'exit "$INSTALL_CODE"'
|
|
34138
|
+
].join("; ");
|
|
34139
|
+
const result = await ssh.exec(`umask 077; nohup bash -c ${quoteEnvValue(backgroundScript)} > ${quoteEnvValue(LOG)} 2>&1 </dev/null & ` + `INSTALL_PID=$!; sleep 5; ` + `INSTALL_STATE=$(sed -n '1p' ${quoteEnvValue(STATUS)} 2>/dev/null || true); ` + `case "$INSTALL_STATE" in ` + `RUNNING) if kill -0 "$INSTALL_PID" 2>/dev/null; then echo "INSTALL_STARTED pid=$INSTALL_PID"; ` + `else wait "$INSTALL_PID" 2>/dev/null; INSTALL_CODE=$?; ` + `echo "INSTALL_FAILED code=$INSTALL_CODE state=$INSTALL_STATE"; exit 1; fi ;; ` + `SUCCEEDED) echo "INSTALL_COMPLETED pid=$INSTALL_PID" ;; ` + `FAILED:*) INSTALL_CODE=$(printf '%s' "$INSTALL_STATE" | cut -d: -f2); ` + `echo "INSTALL_FAILED code=$INSTALL_CODE"; exit 1 ;; ` + `*) echo "INSTALL_FAILED code=unknown state=$INSTALL_STATE"; exit 1 ;; esac`, 30000);
|
|
34140
|
+
const installAccepted = result.stdout.includes("INSTALL_STARTED") || result.stdout.includes("INSTALL_COMPLETED");
|
|
34141
|
+
text = installAccepted ? `✅ Installation started
|
|
33870
34142
|
OS: ${osCheck.stdout.trim()}
|
|
33871
34143
|
${result.stdout.trim()}
|
|
33872
34144
|
Log: ${LOG}
|
|
34145
|
+
Status: ${STATUS}
|
|
33873
34146
|
⏱ ~15-30 min` : `❌ Start failed
|
|
34147
|
+
${result.stdout.slice(-500)}
|
|
33874
34148
|
${result.stderr.slice(-500)}`;
|
|
33875
34149
|
break;
|
|
33876
34150
|
}
|
|
33877
34151
|
case "upgrade": {
|
|
33878
34152
|
const envParts = [
|
|
33879
34153
|
args.version ? `SUPACLOUD_UPGRADE_TAG=${assertSafeReleaseTag(args.version)}` : "",
|
|
33880
|
-
args.github_proxy ? `SUPACLOUD_GITHUB_PROXY=${assertSafeGithubProxy(args.github_proxy)}` : ""
|
|
34154
|
+
args.github_proxy ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(assertSafeGithubProxy(args.github_proxy))}` : ""
|
|
33881
34155
|
].filter(Boolean);
|
|
33882
34156
|
const envPrefix = envParts.length > 0 ? `${envParts.join(" ")} ` : "";
|
|
33883
34157
|
const cmd = "if [ ! -x /usr/local/bin/supacloud ]; then " + "echo 'SupaCloud binary not found at /usr/local/bin/supacloud; run ssh install first.' >&2; exit 127; " + `fi; ${envPrefix}/usr/local/bin/supacloud upgrade --yes`;
|
|
@@ -33894,7 +34168,7 @@ ${r.stderr.slice(-500)}`;
|
|
|
33894
34168
|
"echo '=== Disk ===' && df -h /",
|
|
33895
34169
|
"echo '=== Docker ===' && (docker ps --format 'table {{.Names}}\t{{.Status}}' 2>/dev/null || echo 'Not found')",
|
|
33896
34170
|
"echo '=== PostgreSQL ===' && (pg_isready 2>/dev/null && echo 'Running' || echo 'Not detected')",
|
|
33897
|
-
"echo '=== Management API ===' && (curl -sf http://localhost:9090/
|
|
34171
|
+
"echo '=== Management API ===' && (curl -sf http://localhost:9090/health > /dev/null && echo 'Running' || echo 'Not running')"
|
|
33898
34172
|
];
|
|
33899
34173
|
const r = await ssh.exec(cmds.join(" && "));
|
|
33900
34174
|
text = r.stdout || r.stderr;
|
|
@@ -33929,7 +34203,7 @@ ${r.stderr.slice(-500)}`;
|
|
|
33929
34203
|
if (f === "all" || f === "network")
|
|
33930
34204
|
checks3.push("echo '══════ Ports ══════'", "ss -tlnp | grep -E ':(80|443|5432|8000|9090|3000) ' 2>/dev/null || echo 'N/A'");
|
|
33931
34205
|
if (f === "all" || f === "logs")
|
|
33932
|
-
checks3.push("echo '══════ Install Log ══════'",
|
|
34206
|
+
checks3.push("echo '══════ Install Log ══════'", `(latest_log=$(find /var/log/supacloud -maxdepth 1 -type f -name 'install-*.log' -printf '%T@ %p\\n' 2>/dev/null | sort -nr | head -1 | cut -d' ' -f2-); [ -n "$latest_log" ] && tail -50 "$latest_log" || echo 'Not found')`);
|
|
33933
34207
|
if (f === "all" || f === "disk")
|
|
33934
34208
|
checks3.push("echo '══════ Large Dirs ══════'", "du -sh /var/lib/postgresql /var/lib/docker 2>/dev/null | sort -rh | head -10");
|
|
33935
34209
|
const r = await ssh.exec(checks3.join(`
|
|
@@ -33981,26 +34255,40 @@ ${r.stdout}`;
|
|
|
33981
34255
|
if (!args.project_ref)
|
|
33982
34256
|
throw new Error("'project_ref' required");
|
|
33983
34257
|
const projectRef = assertSafeProjectRef(args.project_ref, "project_ref");
|
|
33984
|
-
const r = await ssh.exec(
|
|
33985
|
-
|
|
33986
|
-
|
|
33987
|
-
|
|
34258
|
+
const r = await ssh.exec([
|
|
34259
|
+
"set -eu",
|
|
34260
|
+
"found=0",
|
|
34261
|
+
`for file in /etc/supabase/tenants/${projectRef}.env /etc/supabase/tenants/${projectRef}_gotrue.env; do`,
|
|
34262
|
+
' [ -f "$file" ] || continue',
|
|
34263
|
+
" found=1",
|
|
34264
|
+
` printf '
|
|
34265
|
+
# %s
|
|
34266
|
+
' "$(basename "$file")"`,
|
|
34267
|
+
` ${REMOTE_ENV_REDACTION_AWK} "$file"`,
|
|
34268
|
+
"done",
|
|
34269
|
+
`[ "$found" -eq 1 ] || { echo 'Tenant config not found' >&2; exit 1; }`
|
|
34270
|
+
].join(`
|
|
34271
|
+
`), 1e4);
|
|
34272
|
+
const output = redactTenantConfig(r.stdout || r.stderr);
|
|
34273
|
+
text = r.success ? `\uD83D\uDCC4 ${projectRef} tenant config (sensitive values redacted):
|
|
34274
|
+
${output}` : `❌ Unable to inspect ${projectRef}:
|
|
34275
|
+
${output}`;
|
|
33988
34276
|
break;
|
|
33989
34277
|
}
|
|
33990
34278
|
case "tenant_diagnose": {
|
|
33991
34279
|
const checks3 = [
|
|
33992
34280
|
"echo '══════ Multi-tenant Diagnostic ══════'",
|
|
33993
|
-
"ps
|
|
34281
|
+
"ps -eo pid=,user=,comm= | grep -E 'postgrest|gotrue' | grep -v grep || echo 'No processes'",
|
|
33994
34282
|
"systemctl list-units 'supacloud-pgrst@*' 'supacloud-gotrue@*' --no-pager 2>/dev/null || echo 'N/A'",
|
|
33995
34283
|
"ls -l /etc/supabase/tenants/*.env 2>/dev/null || echo 'No config'"
|
|
33996
34284
|
];
|
|
33997
34285
|
if (args.project_ref) {
|
|
33998
34286
|
const projectRef = assertSafeProjectRef(args.project_ref, "project_ref");
|
|
33999
|
-
checks3.push(`systemctl status supacloud-pgrst@${projectRef} --no-pager 2>/dev/null || echo 'Not found'`, `
|
|
34287
|
+
checks3.push(`systemctl status supacloud-pgrst@${projectRef} --no-pager 2>/dev/null || echo 'Not found'`, `for file in /etc/supabase/tenants/${projectRef}.env /etc/supabase/tenants/${projectRef}_gotrue.env; do [ -f "$file" ] && ${REMOTE_ENV_REDACTION_AWK} "$file"; done`);
|
|
34000
34288
|
}
|
|
34001
34289
|
const r = await ssh.exec(checks3.join(`
|
|
34002
34290
|
`), 30000);
|
|
34003
|
-
text = r.stdout || r.stderr;
|
|
34291
|
+
text = redactTenantConfig(r.stdout || r.stderr);
|
|
34004
34292
|
break;
|
|
34005
34293
|
}
|
|
34006
34294
|
case "tenant_migrate": {
|
|
@@ -34008,21 +34296,31 @@ ${r.stdout || r.stderr}`;
|
|
|
34008
34296
|
throw new Error("'source_ref' and 'target_ref' required");
|
|
34009
34297
|
const sourceRef = assertSafeProjectRef(args.source_ref, "source_ref");
|
|
34010
34298
|
const targetRef = assertSafeProjectRef(args.target_ref, "target_ref");
|
|
34299
|
+
if (sourceRef === targetRef)
|
|
34300
|
+
throw new Error("source_ref and target_ref must be different");
|
|
34011
34301
|
const s = args.schemas || "public,auth,storage";
|
|
34012
34302
|
if (!/^[a-z_,\s]+$/.test(s))
|
|
34013
34303
|
throw new Error("Invalid schemas");
|
|
34014
|
-
const
|
|
34304
|
+
const schemas3 = s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
34305
|
+
if (schemas3.length === 0)
|
|
34306
|
+
throw new Error("At least one schema is required");
|
|
34307
|
+
const schemaArgs = schemas3.map((schema) => `-n ${schema}`).join(" ");
|
|
34015
34308
|
const df = args.data_only ? "--data-only" : "";
|
|
34016
34309
|
const cmd = [
|
|
34310
|
+
"set -euo pipefail",
|
|
34311
|
+
"umask 077",
|
|
34312
|
+
'tmp_dir="$(mktemp -d /tmp/supacloud-migrate.XXXXXX)"',
|
|
34313
|
+
'dump_file="$tmp_dir/tenant.dump"',
|
|
34314
|
+
`trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM`,
|
|
34017
34315
|
`echo 'Migrating: supa_${sourceRef} → supa_${targetRef}'`,
|
|
34018
|
-
`pg_dump -h localhost -U postgres -d supa_${sourceRef} ${schemaArgs} ${df} -Fc -f
|
|
34019
|
-
`pg_restore -h localhost -U postgres -d supa_${targetRef} --no-owner --no-acl
|
|
34020
|
-
|
|
34316
|
+
`pg_dump -h localhost -U postgres -d supa_${sourceRef} ${schemaArgs} ${df} -Fc -f "$dump_file"`,
|
|
34317
|
+
`pg_restore -h localhost -U postgres -d supa_${targetRef} --no-owner --no-acl --exit-on-error "$dump_file"`,
|
|
34318
|
+
"echo 'Migration complete'"
|
|
34021
34319
|
].join(`
|
|
34022
34320
|
`);
|
|
34023
34321
|
const r = await ssh.exec(cmd, 600000);
|
|
34024
34322
|
text = r.success ? `✅ Migration done
|
|
34025
|
-
${r.stdout}` : `❌
|
|
34323
|
+
${r.stdout}` : `❌ Migration failed (exit ${r.code})
|
|
34026
34324
|
${r.stdout}
|
|
34027
34325
|
${r.stderr.slice(-1000)}`;
|
|
34028
34326
|
break;
|
|
@@ -34370,6 +34668,300 @@ function registerAdminProjectCliTools(server, http) {
|
|
|
34370
34668
|
});
|
|
34371
34669
|
}
|
|
34372
34670
|
|
|
34671
|
+
// src/shared/tools/gateway-tools.ts
|
|
34672
|
+
var stringArray = exports_external.preprocess((value) => {
|
|
34673
|
+
if (value === undefined || value === null)
|
|
34674
|
+
return;
|
|
34675
|
+
if (Array.isArray(value))
|
|
34676
|
+
return value;
|
|
34677
|
+
const text = String(value).trim();
|
|
34678
|
+
if (!text)
|
|
34679
|
+
return [];
|
|
34680
|
+
if (text.startsWith("[")) {
|
|
34681
|
+
try {
|
|
34682
|
+
return JSON.parse(text);
|
|
34683
|
+
} catch {
|
|
34684
|
+
return [text];
|
|
34685
|
+
}
|
|
34686
|
+
}
|
|
34687
|
+
return text.split(",").map((item) => item.trim()).filter(Boolean);
|
|
34688
|
+
}, exports_external.array(exports_external.string()).optional());
|
|
34689
|
+
var headersRecord = exports_external.preprocess((value) => {
|
|
34690
|
+
if (value === undefined || value === null)
|
|
34691
|
+
return;
|
|
34692
|
+
if (typeof value === "object" && !Array.isArray(value))
|
|
34693
|
+
return value;
|
|
34694
|
+
const text = String(value).trim();
|
|
34695
|
+
if (!text)
|
|
34696
|
+
return;
|
|
34697
|
+
try {
|
|
34698
|
+
const parsed = JSON.parse(text);
|
|
34699
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
34700
|
+
return parsed;
|
|
34701
|
+
} catch {}
|
|
34702
|
+
const out = {};
|
|
34703
|
+
for (const part of text.split(",")) {
|
|
34704
|
+
const idx = part.indexOf(":");
|
|
34705
|
+
if (idx > 0)
|
|
34706
|
+
out[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
|
|
34707
|
+
}
|
|
34708
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
34709
|
+
}, exports_external.record(exports_external.string(), exports_external.string()).optional());
|
|
34710
|
+
var ok2 = (res) => res.ok ? JSON.stringify(res.data, null, 2) : `❌ Failed (${res.status}): ${JSON.stringify(res.data)}`;
|
|
34711
|
+
var simple2 = (res, msg) => res.ok ? `✅ ${msg}` : `❌ Failed (${res.status}): ${JSON.stringify(res.data)}`;
|
|
34712
|
+
function registerGatewayTools(server, http, options = {}) {
|
|
34713
|
+
const { projectRef } = options;
|
|
34714
|
+
server.tool("gateway", `Gateway / Caddy 配置(通过 JSON Admin API 注入)。要求 admin 权限。
|
|
34715
|
+
Actions: routes, upsert_route, update_route, delete_route, config, get_certificate, update_certificate, issue_certificate, deploy_certificate, rebuild, custom_hostname, set_custom_hostname, delete_custom_hostname, verify_custom_hostname`, {
|
|
34716
|
+
action: exports_external.enum([
|
|
34717
|
+
"routes",
|
|
34718
|
+
"upsert_route",
|
|
34719
|
+
"update_route",
|
|
34720
|
+
"delete_route",
|
|
34721
|
+
"config",
|
|
34722
|
+
"get_certificate",
|
|
34723
|
+
"update_certificate",
|
|
34724
|
+
"issue_certificate",
|
|
34725
|
+
"deploy_certificate",
|
|
34726
|
+
"rebuild",
|
|
34727
|
+
"custom_hostname",
|
|
34728
|
+
"set_custom_hostname",
|
|
34729
|
+
"delete_custom_hostname",
|
|
34730
|
+
"verify_custom_hostname"
|
|
34731
|
+
]).describe("Action"),
|
|
34732
|
+
ref: exports_external.string().optional().describe(projectRef ? "可选:覆盖自动关联的项目 ref" : "项目 ref"),
|
|
34733
|
+
route_id: exports_external.string().optional().describe("[upsert_route/update_route/delete_route] 路由 ID(字母/数字/_/-,1-64)"),
|
|
34734
|
+
hosts: stringArray.describe("[upsert_route/update_route] 主机名列表,逗号分隔或 JSON 数组(1-20)"),
|
|
34735
|
+
paths: stringArray.describe("[upsert_route/update_route] 路径列表,逗号分隔或 JSON 数组(1-20)"),
|
|
34736
|
+
upstream: exports_external.string().optional().describe("[upsert_route/update_route] 反代上游 host:port 或 http(s)://host[:port]"),
|
|
34737
|
+
upstream_tls_insecure_skip_verify: exports_external.boolean().optional().describe("[upsert_route/update_route] 上游 TLS 跳过校验"),
|
|
34738
|
+
static_root: exports_external.string().optional().describe("[upsert_route/update_route] 静态站点根目录(与 upstream 二选一)"),
|
|
34739
|
+
rewrite_uri: exports_external.string().optional().describe("[upsert_route/update_route] 重写 URI(以 / 开头)"),
|
|
34740
|
+
strip_prefix: exports_external.string().optional().describe("[upsert_route/update_route] 去除前缀"),
|
|
34741
|
+
headers: headersRecord.describe("[upsert_route/update_route] 自定义请求头,JSON 或 K:V,K2:V2"),
|
|
34742
|
+
cors: stringArray.describe("[upsert_route/update_route] 额外 CORS 源,逗号分隔"),
|
|
34743
|
+
priority: exports_external.number().optional().describe("[upsert_route/update_route] 路由优先级"),
|
|
34744
|
+
enabled: exports_external.boolean().optional().describe("[upsert_route/update_route] 是否启用"),
|
|
34745
|
+
rate_limit_tier: exports_external.enum(["free", "pro", "enterprise"]).optional().describe("[config] 限流档位"),
|
|
34746
|
+
cors_origins: exports_external.string().optional().describe("[config] CORS 源(逗号分隔)"),
|
|
34747
|
+
jwt_enabled: exports_external.boolean().optional().describe("[config] 是否启用 JWT"),
|
|
34748
|
+
jwt_secret: exports_external.string().optional().describe("[config] JWT 密钥"),
|
|
34749
|
+
cert_mode: exports_external.enum(["lego", "manual"]).optional().describe("[update_certificate] 证书模式"),
|
|
34750
|
+
challenge: exports_external.enum(["dns-01", "http-01"]).optional().describe("[update_certificate/issue_certificate] ACME challenge"),
|
|
34751
|
+
email: exports_external.string().optional().describe("[update_certificate/issue_certificate] ACME 邮箱"),
|
|
34752
|
+
dns_provider: exports_external.string().optional().describe("[update_certificate/issue_certificate] DNS 提供商"),
|
|
34753
|
+
dns_env: stringArray.describe("[update_certificate/issue_certificate] DNS 环境变量 KEY=VALUE 列表"),
|
|
34754
|
+
domains: stringArray.describe("[update_certificate/issue_certificate/deploy_certificate] 域名列表"),
|
|
34755
|
+
auto_renew: exports_external.boolean().optional().describe("[update_certificate/issue_certificate] 自动续期"),
|
|
34756
|
+
renew: exports_external.boolean().optional().describe("[issue_certificate] 仅续期已有证书"),
|
|
34757
|
+
cert: exports_external.string().optional().describe("[deploy_certificate] PEM 证书内容"),
|
|
34758
|
+
key: exports_external.string().optional().describe("[deploy_certificate] PEM 私钥内容"),
|
|
34759
|
+
clean: exports_external.boolean().optional().describe("[rebuild] 清理后全量重建"),
|
|
34760
|
+
custom_hostname: exports_external.string().optional().describe("[set_custom_hostname] 自定义域名")
|
|
34761
|
+
}, async (args) => {
|
|
34762
|
+
const resolveRef3 = (override) => {
|
|
34763
|
+
const ref2 = projectRef || override;
|
|
34764
|
+
if (!ref2)
|
|
34765
|
+
throw new Error("'ref' is required for this action");
|
|
34766
|
+
return ref2;
|
|
34767
|
+
};
|
|
34768
|
+
const need = (field, value) => {
|
|
34769
|
+
if (value === undefined || value === null || value === "")
|
|
34770
|
+
throw new Error(`'${field}' is required for '${args.action}'`);
|
|
34771
|
+
};
|
|
34772
|
+
const {
|
|
34773
|
+
action,
|
|
34774
|
+
ref,
|
|
34775
|
+
route_id,
|
|
34776
|
+
hosts,
|
|
34777
|
+
paths,
|
|
34778
|
+
upstream,
|
|
34779
|
+
upstream_tls_insecure_skip_verify,
|
|
34780
|
+
static_root,
|
|
34781
|
+
rewrite_uri,
|
|
34782
|
+
strip_prefix,
|
|
34783
|
+
headers,
|
|
34784
|
+
cors,
|
|
34785
|
+
priority,
|
|
34786
|
+
enabled,
|
|
34787
|
+
rate_limit_tier,
|
|
34788
|
+
cors_origins,
|
|
34789
|
+
jwt_enabled,
|
|
34790
|
+
jwt_secret,
|
|
34791
|
+
cert_mode,
|
|
34792
|
+
challenge,
|
|
34793
|
+
email: email3,
|
|
34794
|
+
dns_provider,
|
|
34795
|
+
dns_env,
|
|
34796
|
+
domains,
|
|
34797
|
+
auto_renew,
|
|
34798
|
+
renew,
|
|
34799
|
+
cert,
|
|
34800
|
+
key,
|
|
34801
|
+
clean,
|
|
34802
|
+
custom_hostname
|
|
34803
|
+
} = args;
|
|
34804
|
+
const projectRefValue = resolveRef3(ref);
|
|
34805
|
+
let text;
|
|
34806
|
+
switch (action) {
|
|
34807
|
+
case "routes":
|
|
34808
|
+
text = ok2(await http.get(`/v1/projects/${projectRefValue}/gateway/routes`));
|
|
34809
|
+
break;
|
|
34810
|
+
case "upsert_route": {
|
|
34811
|
+
need("route_id", route_id);
|
|
34812
|
+
need("hosts", hosts);
|
|
34813
|
+
need("paths", paths);
|
|
34814
|
+
const body = {
|
|
34815
|
+
id: route_id,
|
|
34816
|
+
hosts,
|
|
34817
|
+
path: paths.length === 1 ? paths[0] : paths
|
|
34818
|
+
};
|
|
34819
|
+
if (upstream !== undefined)
|
|
34820
|
+
body.upstream = upstream;
|
|
34821
|
+
if (upstream_tls_insecure_skip_verify !== undefined)
|
|
34822
|
+
body.upstream_tls_insecure_skip_verify = upstream_tls_insecure_skip_verify;
|
|
34823
|
+
if (static_root !== undefined)
|
|
34824
|
+
body.static_root = static_root;
|
|
34825
|
+
if (rewrite_uri !== undefined)
|
|
34826
|
+
body.rewrite_uri = rewrite_uri;
|
|
34827
|
+
if (strip_prefix !== undefined)
|
|
34828
|
+
body.strip_prefix = strip_prefix;
|
|
34829
|
+
if (headers !== undefined)
|
|
34830
|
+
body.headers = headers;
|
|
34831
|
+
if (cors !== undefined)
|
|
34832
|
+
body.cors = cors;
|
|
34833
|
+
if (priority !== undefined)
|
|
34834
|
+
body.priority = priority;
|
|
34835
|
+
if (enabled !== undefined)
|
|
34836
|
+
body.enabled = enabled;
|
|
34837
|
+
text = ok2(await http.post(`/v1/projects/${projectRefValue}/gateway/routes`, body));
|
|
34838
|
+
break;
|
|
34839
|
+
}
|
|
34840
|
+
case "update_route": {
|
|
34841
|
+
need("route_id", route_id);
|
|
34842
|
+
need("hosts", hosts);
|
|
34843
|
+
need("paths", paths);
|
|
34844
|
+
const body = {
|
|
34845
|
+
hosts,
|
|
34846
|
+
path: paths.length === 1 ? paths[0] : paths
|
|
34847
|
+
};
|
|
34848
|
+
if (upstream !== undefined)
|
|
34849
|
+
body.upstream = upstream;
|
|
34850
|
+
if (upstream_tls_insecure_skip_verify !== undefined)
|
|
34851
|
+
body.upstream_tls_insecure_skip_verify = upstream_tls_insecure_skip_verify;
|
|
34852
|
+
if (static_root !== undefined)
|
|
34853
|
+
body.static_root = static_root;
|
|
34854
|
+
if (rewrite_uri !== undefined)
|
|
34855
|
+
body.rewrite_uri = rewrite_uri;
|
|
34856
|
+
if (strip_prefix !== undefined)
|
|
34857
|
+
body.strip_prefix = strip_prefix;
|
|
34858
|
+
if (headers !== undefined)
|
|
34859
|
+
body.headers = headers;
|
|
34860
|
+
if (cors !== undefined)
|
|
34861
|
+
body.cors = cors;
|
|
34862
|
+
if (priority !== undefined)
|
|
34863
|
+
body.priority = priority;
|
|
34864
|
+
if (enabled !== undefined)
|
|
34865
|
+
body.enabled = enabled;
|
|
34866
|
+
text = ok2(await http.put(`/v1/projects/${projectRefValue}/gateway/routes/${route_id}`, body));
|
|
34867
|
+
break;
|
|
34868
|
+
}
|
|
34869
|
+
case "delete_route": {
|
|
34870
|
+
need("route_id", route_id);
|
|
34871
|
+
text = ok2(await http.delete(`/v1/projects/${projectRefValue}/gateway/routes/${route_id}`));
|
|
34872
|
+
break;
|
|
34873
|
+
}
|
|
34874
|
+
case "config": {
|
|
34875
|
+
const body = {};
|
|
34876
|
+
if (rate_limit_tier !== undefined)
|
|
34877
|
+
body.rate_limit_tier = rate_limit_tier;
|
|
34878
|
+
if (cors_origins !== undefined)
|
|
34879
|
+
body.cors_origins = cors_origins;
|
|
34880
|
+
if (jwt_enabled !== undefined)
|
|
34881
|
+
body.jwt_enabled = jwt_enabled;
|
|
34882
|
+
if (jwt_secret !== undefined)
|
|
34883
|
+
body.jwt_secret = jwt_secret;
|
|
34884
|
+
if (Object.keys(body).length === 0)
|
|
34885
|
+
throw new Error("At least one of rate_limit_tier, cors_origins, jwt_enabled, jwt_secret is required");
|
|
34886
|
+
text = ok2(await http.post(`/v1/projects/${projectRefValue}/gateway/config`, body));
|
|
34887
|
+
break;
|
|
34888
|
+
}
|
|
34889
|
+
case "get_certificate":
|
|
34890
|
+
text = ok2(await http.get(`/v1/projects/${projectRefValue}/gateway/certificate`));
|
|
34891
|
+
break;
|
|
34892
|
+
case "update_certificate": {
|
|
34893
|
+
const body = {};
|
|
34894
|
+
if (cert_mode !== undefined)
|
|
34895
|
+
body.mode = cert_mode;
|
|
34896
|
+
if (challenge !== undefined)
|
|
34897
|
+
body.challenge = challenge;
|
|
34898
|
+
if (email3 !== undefined)
|
|
34899
|
+
body.email = email3;
|
|
34900
|
+
if (dns_provider !== undefined)
|
|
34901
|
+
body.dns_provider = dns_provider;
|
|
34902
|
+
if (dns_env !== undefined)
|
|
34903
|
+
body.dns_env = dns_env;
|
|
34904
|
+
if (domains !== undefined)
|
|
34905
|
+
body.domains = domains;
|
|
34906
|
+
if (auto_renew !== undefined)
|
|
34907
|
+
body.auto_renew = auto_renew;
|
|
34908
|
+
text = ok2(await http.put(`/v1/projects/${projectRefValue}/gateway/certificate`, body));
|
|
34909
|
+
break;
|
|
34910
|
+
}
|
|
34911
|
+
case "issue_certificate": {
|
|
34912
|
+
const body = {};
|
|
34913
|
+
if (challenge !== undefined)
|
|
34914
|
+
body.challenge = challenge;
|
|
34915
|
+
if (email3 !== undefined)
|
|
34916
|
+
body.email = email3;
|
|
34917
|
+
if (dns_provider !== undefined)
|
|
34918
|
+
body.dns_provider = dns_provider;
|
|
34919
|
+
if (dns_env !== undefined)
|
|
34920
|
+
body.dns_env = dns_env;
|
|
34921
|
+
if (domains !== undefined)
|
|
34922
|
+
body.domains = domains;
|
|
34923
|
+
if (auto_renew !== undefined)
|
|
34924
|
+
body.auto_renew = auto_renew;
|
|
34925
|
+
if (renew !== undefined)
|
|
34926
|
+
body.renew = renew;
|
|
34927
|
+
text = ok2(await http.post(`/v1/projects/${projectRefValue}/gateway/certificate/issue`, body));
|
|
34928
|
+
break;
|
|
34929
|
+
}
|
|
34930
|
+
case "deploy_certificate": {
|
|
34931
|
+
need("cert", cert);
|
|
34932
|
+
need("key", key);
|
|
34933
|
+
const body = { cert, key };
|
|
34934
|
+
if (domains !== undefined)
|
|
34935
|
+
body.domains = domains;
|
|
34936
|
+
text = ok2(await http.post(`/v1/projects/${projectRefValue}/gateway/certificate/deploy`, body));
|
|
34937
|
+
break;
|
|
34938
|
+
}
|
|
34939
|
+
case "rebuild": {
|
|
34940
|
+
const query = clean ? "?clean=true" : "";
|
|
34941
|
+
text = ok2(await http.post(`/v1/projects/${projectRefValue}/gateway/rebuild-all${query}`));
|
|
34942
|
+
break;
|
|
34943
|
+
}
|
|
34944
|
+
case "custom_hostname":
|
|
34945
|
+
text = ok2(await http.get(`/v1/projects/${projectRefValue}/custom-hostname`));
|
|
34946
|
+
break;
|
|
34947
|
+
case "set_custom_hostname": {
|
|
34948
|
+
need("custom_hostname", custom_hostname);
|
|
34949
|
+
text = simple2(await http.post(`/v1/projects/${projectRefValue}/custom-hostname`, { custom_hostname }), `Custom hostname ${custom_hostname} requested`);
|
|
34950
|
+
break;
|
|
34951
|
+
}
|
|
34952
|
+
case "delete_custom_hostname":
|
|
34953
|
+
text = simple2(await http.delete(`/v1/projects/${projectRefValue}/custom-hostname`), "Custom hostname removed");
|
|
34954
|
+
break;
|
|
34955
|
+
case "verify_custom_hostname":
|
|
34956
|
+
text = ok2(await http.post(`/v1/projects/${projectRefValue}/custom-hostname/verify`));
|
|
34957
|
+
break;
|
|
34958
|
+
default:
|
|
34959
|
+
text = `❌ Unknown action: ${action}`;
|
|
34960
|
+
}
|
|
34961
|
+
return { content: [{ type: "text", text }] };
|
|
34962
|
+
});
|
|
34963
|
+
}
|
|
34964
|
+
|
|
34373
34965
|
// src/index.ts
|
|
34374
34966
|
var adminProjectActionSchema = exports_external.enum([
|
|
34375
34967
|
"list",
|
|
@@ -34442,6 +35034,7 @@ EXPECTED CONTEXT
|
|
|
34442
35034
|
Platform commands typically rely on:
|
|
34443
35035
|
SUPACLOUD_HOST
|
|
34444
35036
|
SUPACLOUD_SSH_KEY / SUPACLOUD_SSH_PASS
|
|
35037
|
+
SUPACLOUD_SSH_HOST_FINGERPRINT=SHA256:...
|
|
34445
35038
|
SUPACLOUD_API_URL
|
|
34446
35039
|
SUPACLOUD_API_TOKEN
|
|
34447
35040
|
|
|
@@ -34456,10 +35049,13 @@ EXAMPLES
|
|
|
34456
35049
|
supacloud-admin project create --name my-app
|
|
34457
35050
|
supacloud-admin project list
|
|
34458
35051
|
supacloud-admin platform metrics
|
|
35052
|
+
supacloud-admin gateway routes --ref abc123
|
|
35053
|
+
supacloud-admin gateway upsert_route --ref abc123 --route_id webhook --hosts "api.example.com" --paths "/webhook/*" --upstream 10.0.0.5:8080
|
|
35054
|
+
supacloud-admin gateway config --ref abc123 --rate_limit_tier pro
|
|
35055
|
+
supacloud-admin gateway rebuild --ref abc123 --clean
|
|
34459
35056
|
`);
|
|
34460
35057
|
}
|
|
34461
|
-
function createAdminTools() {
|
|
34462
|
-
const context = resolveSupaCloudContext();
|
|
35058
|
+
function createAdminTools(context = resolveSupaCloudContext()) {
|
|
34463
35059
|
const tools = {
|
|
34464
35060
|
status: {
|
|
34465
35061
|
schema: {},
|
|
@@ -34473,6 +35069,7 @@ function createAdminTools() {
|
|
|
34473
35069
|
apiUrl: context.apiUrl || null,
|
|
34474
35070
|
hasApiToken: Boolean(context.apiToken),
|
|
34475
35071
|
hasSshKey: Boolean(context.sshKey),
|
|
35072
|
+
hasSshHostFingerprint: Boolean(context.sshHostFingerprint),
|
|
34476
35073
|
source: context.source
|
|
34477
35074
|
}, null, 2)
|
|
34478
35075
|
}
|
|
@@ -34509,22 +35106,62 @@ function createAdminTools() {
|
|
|
34509
35106
|
content: [
|
|
34510
35107
|
{
|
|
34511
35108
|
type: "text",
|
|
34512
|
-
text: "⚠️ SSH commands require SUPACLOUD_HOST
|
|
35109
|
+
text: "⚠️ SSH commands require SUPACLOUD_HOST, SSH credentials, and SUPACLOUD_SSH_HOST_FINGERPRINT."
|
|
35110
|
+
}
|
|
35111
|
+
]
|
|
35112
|
+
})
|
|
35113
|
+
};
|
|
35114
|
+
tools.gateway = {
|
|
35115
|
+
schema: { action: exports_external.string() },
|
|
35116
|
+
callback: async () => ({
|
|
35117
|
+
content: [
|
|
35118
|
+
{
|
|
35119
|
+
type: "text",
|
|
35120
|
+
text: "⚠️ Gateway / Caddy commands require SUPACLOUD_API_URL and SUPACLOUD_API_TOKEN (admin privileges)."
|
|
34513
35121
|
}
|
|
34514
35122
|
]
|
|
34515
35123
|
})
|
|
34516
35124
|
};
|
|
34517
35125
|
};
|
|
34518
35126
|
registerAdminHelp();
|
|
34519
|
-
if (context.host) {
|
|
34520
|
-
|
|
34521
|
-
|
|
34522
|
-
|
|
34523
|
-
|
|
34524
|
-
|
|
34525
|
-
|
|
34526
|
-
|
|
34527
|
-
|
|
35127
|
+
if (context.host && context.sshHostFingerprint) {
|
|
35128
|
+
try {
|
|
35129
|
+
const ssh = new SshTransport({
|
|
35130
|
+
host: context.host,
|
|
35131
|
+
port: context.sshPort,
|
|
35132
|
+
username: context.sshUser,
|
|
35133
|
+
privateKeyPath: context.sshKey || undefined,
|
|
35134
|
+
password: context.sshPass || undefined,
|
|
35135
|
+
hostFingerprint: context.sshHostFingerprint
|
|
35136
|
+
});
|
|
35137
|
+
Object.assign(tools, captureTools((server) => registerSshTools(server, ssh)));
|
|
35138
|
+
} catch (error51) {
|
|
35139
|
+
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
35140
|
+
tools.ssh = {
|
|
35141
|
+
schema: { action: sshActionSchema },
|
|
35142
|
+
callback: async () => ({
|
|
35143
|
+
content: [{
|
|
35144
|
+
type: "text",
|
|
35145
|
+
text: `⚠️ SSH host fingerprint is invalid; SSH actions remain disabled. ${message}`
|
|
35146
|
+
}]
|
|
35147
|
+
})
|
|
35148
|
+
};
|
|
35149
|
+
}
|
|
35150
|
+
} else if (context.host) {
|
|
35151
|
+
tools.ssh = {
|
|
35152
|
+
schema: { action: sshActionSchema },
|
|
35153
|
+
callback: async () => ({
|
|
35154
|
+
content: [{
|
|
35155
|
+
type: "text",
|
|
35156
|
+
text: [
|
|
35157
|
+
"⚠️ SSH actions are disabled because host-key verification is not configured.",
|
|
35158
|
+
"Set SUPACLOUD_SSH_HOST_FINGERPRINT to the server's OpenSSH SHA256 fingerprint.",
|
|
35159
|
+
`Verify it out-of-band first, for example: ssh-keyscan -p ${context.sshPort} ${context.host} | ssh-keygen -lf -`
|
|
35160
|
+
].join(`
|
|
35161
|
+
`)
|
|
35162
|
+
}]
|
|
35163
|
+
})
|
|
35164
|
+
};
|
|
34528
35165
|
}
|
|
34529
35166
|
if (context.apiUrl && context.apiToken) {
|
|
34530
35167
|
const http = new HttpTransport({
|
|
@@ -34532,6 +35169,7 @@ function createAdminTools() {
|
|
|
34532
35169
|
token: context.apiToken
|
|
34533
35170
|
});
|
|
34534
35171
|
Object.assign(tools, captureTools((server) => registerAdminProjectCliTools(server, http)));
|
|
35172
|
+
Object.assign(tools, captureTools((server) => registerGatewayTools(server, http)));
|
|
34535
35173
|
const advancedTools = captureTools((server) => registerAdvancedTools(server, http));
|
|
34536
35174
|
if (advancedTools.platform) {
|
|
34537
35175
|
tools.platform = advancedTools.platform;
|
|
@@ -34548,7 +35186,7 @@ function createAdminTools() {
|
|
|
34548
35186
|
"⚠️ No admin context configured.",
|
|
34549
35187
|
"",
|
|
34550
35188
|
"Provide one or both of:",
|
|
34551
|
-
" SUPACLOUD_HOST + SSH credentials",
|
|
35189
|
+
" SUPACLOUD_HOST + SSH credentials + SUPACLOUD_SSH_HOST_FINGERPRINT",
|
|
34552
35190
|
" SUPACLOUD_API_URL + SUPACLOUD_API_TOKEN",
|
|
34553
35191
|
"",
|
|
34554
35192
|
"This CLI is intended for server installation, diagnostics, and",
|
|
@@ -34584,7 +35222,15 @@ async function main() {
|
|
|
34584
35222
|
}
|
|
34585
35223
|
await runCli(cliTools, args, { commandName: "supacloud-admin" });
|
|
34586
35224
|
}
|
|
34587
|
-
|
|
34588
|
-
|
|
34589
|
-
|
|
34590
|
-
|
|
35225
|
+
function isDirectRun() {
|
|
35226
|
+
return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve2(process.argv[1])).href;
|
|
35227
|
+
}
|
|
35228
|
+
if (isDirectRun()) {
|
|
35229
|
+
main().catch((error51) => {
|
|
35230
|
+
console.error("supacloud-admin failed:", error51);
|
|
35231
|
+
process.exit(1);
|
|
35232
|
+
});
|
|
35233
|
+
}
|
|
35234
|
+
export {
|
|
35235
|
+
createAdminTools
|
|
35236
|
+
};
|
package/package.json
CHANGED
|
Binary file
|