@supacloud/admin 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/index.js +450 -109
- package/dist/{sshcrypto-8h5xcwbw.node → sshcrypto-8m50vnmb.node} +0 -0
- package/package.json +2 -2
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
|
package/dist/index.js
CHANGED
|
@@ -4759,7 +4759,7 @@ var require_utils = __commonJS((exports, module) => {
|
|
|
4759
4759
|
|
|
4760
4760
|
// node_modules/ssh2/lib/protocol/crypto/build/Release/sshcrypto.node
|
|
4761
4761
|
var require_sshcrypto = __commonJS((exports, module) => {
|
|
4762
|
-
module.exports = __require("./sshcrypto-
|
|
4762
|
+
module.exports = __require("./sshcrypto-8m50vnmb.node");
|
|
4763
4763
|
});
|
|
4764
4764
|
|
|
4765
4765
|
// node_modules/ssh2/lib/protocol/crypto/poly1305.js
|
|
@@ -33123,6 +33123,10 @@ function date4(params) {
|
|
|
33123
33123
|
|
|
33124
33124
|
// node_modules/zod/v4/classic/external.js
|
|
33125
33125
|
config(en_default());
|
|
33126
|
+
// src/index.ts
|
|
33127
|
+
import { resolve as resolve2 } from "node:path";
|
|
33128
|
+
import { pathToFileURL } from "node:url";
|
|
33129
|
+
|
|
33126
33130
|
// src/shared/cli.ts
|
|
33127
33131
|
async function runCli(cliTools, args, options = {}) {
|
|
33128
33132
|
const commandName = options.commandName || "supacloud-admin";
|
|
@@ -33382,6 +33386,7 @@ function resolveSupaCloudContext(env = process.env, cwd = process.cwd()) {
|
|
|
33382
33386
|
sshPort: parseInt(env.SUPACLOUD_SSH_PORT ?? "22", 10),
|
|
33383
33387
|
sshKey: env.SUPACLOUD_SSH_KEY ?? resolve(homedir(), ".ssh", "id_rsa"),
|
|
33384
33388
|
sshPass: env.SUPACLOUD_SSH_PASS ?? "",
|
|
33389
|
+
sshHostFingerprint: env.SUPACLOUD_SSH_HOST_FINGERPRINT ?? "",
|
|
33385
33390
|
apiUrl,
|
|
33386
33391
|
apiToken: env.SUPACLOUD_API_TOKEN ?? inferredToken.value,
|
|
33387
33392
|
projectRef,
|
|
@@ -33525,6 +33530,74 @@ class HttpTransport {
|
|
|
33525
33530
|
|
|
33526
33531
|
// src/shared/transports/ssh.ts
|
|
33527
33532
|
var import_ssh2 = __toESM(require_lib3(), 1);
|
|
33533
|
+
import { timingSafeEqual } from "node:crypto";
|
|
33534
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
33535
|
+
var DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
33536
|
+
var MAX_CONFIGURABLE_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
33537
|
+
function normalizeSshHostFingerprint(value) {
|
|
33538
|
+
const trimmed = value.trim();
|
|
33539
|
+
const match = trimmed.match(/^SHA256:([A-Za-z0-9+/]{43}=?)$/);
|
|
33540
|
+
if (!match) {
|
|
33541
|
+
throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must use OpenSSH SHA256:<base64> format");
|
|
33542
|
+
}
|
|
33543
|
+
const decoded = Buffer.from(match[1], "base64");
|
|
33544
|
+
if (decoded.length !== 32) {
|
|
33545
|
+
throw new Error("SUPACLOUD_SSH_HOST_FINGERPRINT must contain a 32-byte SHA256 digest");
|
|
33546
|
+
}
|
|
33547
|
+
return `SHA256:${match[1].replace(/=+$/, "")}`;
|
|
33548
|
+
}
|
|
33549
|
+
function createHostVerifier(fingerprint) {
|
|
33550
|
+
const expected = Buffer.from(normalizeSshHostFingerprint(fingerprint).slice("SHA256:".length), "base64");
|
|
33551
|
+
return (actualHash) => {
|
|
33552
|
+
if (!/^[a-f0-9]{64}$/i.test(actualHash))
|
|
33553
|
+
return false;
|
|
33554
|
+
const actual = Buffer.from(actualHash, "hex");
|
|
33555
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
33556
|
+
};
|
|
33557
|
+
}
|
|
33558
|
+
function normalizeMaxOutputBytes(value) {
|
|
33559
|
+
const resolved = value ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
33560
|
+
if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > MAX_CONFIGURABLE_OUTPUT_BYTES) {
|
|
33561
|
+
throw new Error(`maxOutputBytes must be an integer between 1 and ${MAX_CONFIGURABLE_OUTPUT_BYTES}`);
|
|
33562
|
+
}
|
|
33563
|
+
return resolved;
|
|
33564
|
+
}
|
|
33565
|
+
|
|
33566
|
+
class BoundedOutputCollector {
|
|
33567
|
+
limit;
|
|
33568
|
+
storage;
|
|
33569
|
+
bytes = 0;
|
|
33570
|
+
truncated = false;
|
|
33571
|
+
constructor(limit) {
|
|
33572
|
+
this.limit = limit;
|
|
33573
|
+
this.storage = Buffer.allocUnsafe(limit);
|
|
33574
|
+
}
|
|
33575
|
+
append(data) {
|
|
33576
|
+
if (this.truncated)
|
|
33577
|
+
return;
|
|
33578
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
33579
|
+
const remaining = this.limit - this.bytes;
|
|
33580
|
+
const copied = Math.min(buffer.length, Math.max(0, remaining));
|
|
33581
|
+
if (copied > 0)
|
|
33582
|
+
buffer.copy(this.storage, this.bytes, 0, copied);
|
|
33583
|
+
this.bytes += copied;
|
|
33584
|
+
this.truncated = buffer.length > copied;
|
|
33585
|
+
}
|
|
33586
|
+
finalize() {
|
|
33587
|
+
let output = this.storage.subarray(0, this.bytes).toString("utf8");
|
|
33588
|
+
if (this.truncated) {
|
|
33589
|
+
const lastNewline = output.lastIndexOf(`
|
|
33590
|
+
`);
|
|
33591
|
+
output = lastNewline >= 0 ? output.slice(0, lastNewline + 1) : "";
|
|
33592
|
+
}
|
|
33593
|
+
const redacted = redactSshOutput(output);
|
|
33594
|
+
if (!this.truncated)
|
|
33595
|
+
return redacted;
|
|
33596
|
+
return `${redacted}${redacted && !redacted.endsWith(`
|
|
33597
|
+
`) ? `
|
|
33598
|
+
` : ""}[TRUNCATED: output exceeded ${this.limit}-byte limit]`;
|
|
33599
|
+
}
|
|
33600
|
+
}
|
|
33528
33601
|
var BLOCKED_COMMANDS = [
|
|
33529
33602
|
"rm -rf /",
|
|
33530
33603
|
"mkfs",
|
|
@@ -33542,20 +33615,29 @@ var BLOCKED_COMMANDS = [
|
|
|
33542
33615
|
"crontab -r",
|
|
33543
33616
|
"chmod -R 777 /"
|
|
33544
33617
|
];
|
|
33618
|
+
function redactSshCommand(command) {
|
|
33619
|
+
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]@");
|
|
33620
|
+
}
|
|
33621
|
+
function redactSshOutput(output) {
|
|
33622
|
+
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]");
|
|
33623
|
+
const redactedStructuredFields = redactedLines.replace(/((?:["']?(?:password|pass|secret|token|key|credential|db_uri|database_url|dsn)["']?)\s*:\s*)(?:"[^"]*"|'[^']*'|[^,}\]\r\n]+)/gi, "$1[REDACTED]");
|
|
33624
|
+
return redactSshCommand(redactedStructuredFields);
|
|
33625
|
+
}
|
|
33545
33626
|
function isCommandBlocked(command) {
|
|
33546
33627
|
const normalized = command.trim().toLowerCase();
|
|
33547
33628
|
return BLOCKED_COMMANDS.some((blocked) => normalized.includes(blocked));
|
|
33548
33629
|
}
|
|
33549
33630
|
var auditLog = [];
|
|
33550
33631
|
function auditCommand(command, host, blocked) {
|
|
33551
|
-
const
|
|
33632
|
+
const safeCommand = redactSshCommand(command);
|
|
33633
|
+
const entry = { timestamp: new Date().toISOString(), command: safeCommand, host, blocked };
|
|
33552
33634
|
auditLog.push(entry);
|
|
33553
33635
|
if (auditLog.length > 1000)
|
|
33554
33636
|
auditLog.shift();
|
|
33555
33637
|
if (blocked) {
|
|
33556
|
-
console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${
|
|
33638
|
+
console.error(`[SSH-AUDIT] BLOCKED command on ${host}: ${safeCommand}`);
|
|
33557
33639
|
} else {
|
|
33558
|
-
console.log(`[SSH-AUDIT] Executing on ${host}: ${
|
|
33640
|
+
console.log(`[SSH-AUDIT] Executing on ${host}: ${safeCommand.substring(0, 200)}`);
|
|
33559
33641
|
}
|
|
33560
33642
|
}
|
|
33561
33643
|
class SshConnectionPool {
|
|
@@ -33563,11 +33645,13 @@ class SshConnectionPool {
|
|
|
33563
33645
|
maxSize = 3;
|
|
33564
33646
|
config;
|
|
33565
33647
|
creating = 0;
|
|
33566
|
-
|
|
33648
|
+
clientFactory;
|
|
33649
|
+
constructor(config2, clientFactory) {
|
|
33567
33650
|
this.config = config2;
|
|
33651
|
+
this.clientFactory = clientFactory;
|
|
33568
33652
|
}
|
|
33569
33653
|
async createConnection() {
|
|
33570
|
-
const conn =
|
|
33654
|
+
const conn = this.clientFactory();
|
|
33571
33655
|
return new Promise((resolve2, reject) => {
|
|
33572
33656
|
const timeout = setTimeout(() => {
|
|
33573
33657
|
conn.end();
|
|
@@ -33583,8 +33667,10 @@ class SshConnectionPool {
|
|
|
33583
33667
|
host: this.config.host,
|
|
33584
33668
|
port: this.config.port,
|
|
33585
33669
|
username: this.config.username,
|
|
33586
|
-
...this.config.privateKeyPath ? { privateKey:
|
|
33670
|
+
...this.config.privateKeyPath ? { privateKey: readFileSync2(this.config.privateKeyPath) } : {},
|
|
33587
33671
|
...this.config.password ? { password: this.config.password } : {},
|
|
33672
|
+
hostHash: "sha256",
|
|
33673
|
+
hostVerifier: createHostVerifier(this.config.hostFingerprint),
|
|
33588
33674
|
readyTimeout: 15000,
|
|
33589
33675
|
keepaliveInterval: 30000
|
|
33590
33676
|
});
|
|
@@ -33627,17 +33713,22 @@ class SshConnectionPool {
|
|
|
33627
33713
|
class SshTransport {
|
|
33628
33714
|
config;
|
|
33629
33715
|
pool;
|
|
33630
|
-
constructor(config2) {
|
|
33631
|
-
this.config =
|
|
33632
|
-
|
|
33716
|
+
constructor(config2, options = {}) {
|
|
33717
|
+
this.config = {
|
|
33718
|
+
...config2,
|
|
33719
|
+
hostFingerprint: normalizeSshHostFingerprint(config2.hostFingerprint || ""),
|
|
33720
|
+
maxOutputBytes: normalizeMaxOutputBytes(config2.maxOutputBytes)
|
|
33721
|
+
};
|
|
33722
|
+
this.pool = new SshConnectionPool(this.config, options.clientFactory ?? (() => new import_ssh2.Client));
|
|
33633
33723
|
}
|
|
33634
33724
|
async exec(command, timeoutMs = 300000) {
|
|
33635
33725
|
if (isCommandBlocked(command)) {
|
|
33636
33726
|
auditCommand(command, this.config.host, true);
|
|
33727
|
+
const safeCommand = redactSshCommand(command);
|
|
33637
33728
|
return {
|
|
33638
33729
|
success: false,
|
|
33639
33730
|
stdout: "",
|
|
33640
|
-
stderr: `Command blocked by security policy: "${
|
|
33731
|
+
stderr: `Command blocked by security policy: "${safeCommand.substring(0, 100)}". ` + `Destructive or system-altering commands are not allowed via supacloud-admin.`,
|
|
33641
33732
|
code: 126
|
|
33642
33733
|
};
|
|
33643
33734
|
}
|
|
@@ -33645,8 +33736,9 @@ class SshTransport {
|
|
|
33645
33736
|
const conn = await this.pool.acquire();
|
|
33646
33737
|
try {
|
|
33647
33738
|
return await new Promise((resolve2, reject) => {
|
|
33648
|
-
|
|
33649
|
-
|
|
33739
|
+
const outputLimit = this.config.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
33740
|
+
const stdout = new BoundedOutputCollector(outputLimit);
|
|
33741
|
+
const stderr = new BoundedOutputCollector(outputLimit);
|
|
33650
33742
|
const timer = setTimeout(() => {
|
|
33651
33743
|
conn.end();
|
|
33652
33744
|
reject(new Error(`SSH command timed out after ${timeoutMs}ms`));
|
|
@@ -33658,11 +33750,18 @@ class SshTransport {
|
|
|
33658
33750
|
}
|
|
33659
33751
|
stream.on("close", (code) => {
|
|
33660
33752
|
clearTimeout(timer);
|
|
33661
|
-
resolve2({
|
|
33753
|
+
resolve2({
|
|
33754
|
+
success: code === 0,
|
|
33755
|
+
stdout: stdout.finalize(),
|
|
33756
|
+
stderr: stderr.finalize(),
|
|
33757
|
+
code,
|
|
33758
|
+
stdoutTruncated: stdout.truncated,
|
|
33759
|
+
stderrTruncated: stderr.truncated
|
|
33760
|
+
});
|
|
33662
33761
|
}).on("data", (data) => {
|
|
33663
|
-
stdout
|
|
33762
|
+
stdout.append(data);
|
|
33664
33763
|
}).stderr.on("data", (data) => {
|
|
33665
|
-
stderr
|
|
33764
|
+
stderr.append(data);
|
|
33666
33765
|
});
|
|
33667
33766
|
});
|
|
33668
33767
|
});
|
|
@@ -33688,6 +33787,29 @@ class SshTransport {
|
|
|
33688
33787
|
this.pool.release(conn);
|
|
33689
33788
|
}
|
|
33690
33789
|
}
|
|
33790
|
+
async uploadText(remotePath, content, mode = 384) {
|
|
33791
|
+
auditCommand(`upload ${remotePath} (${Buffer.byteLength(content)} bytes; content redacted)`, this.config.host, false);
|
|
33792
|
+
const conn = await this.pool.acquire();
|
|
33793
|
+
try {
|
|
33794
|
+
await new Promise((resolve2, reject) => {
|
|
33795
|
+
conn.sftp((err, sftp) => {
|
|
33796
|
+
if (err)
|
|
33797
|
+
return reject(err);
|
|
33798
|
+
sftp.writeFile(remotePath, content, { mode }, (writeError) => {
|
|
33799
|
+
if (writeError)
|
|
33800
|
+
return reject(writeError);
|
|
33801
|
+
sftp.chmod(remotePath, mode, (chmodError) => {
|
|
33802
|
+
if (chmodError)
|
|
33803
|
+
return reject(chmodError);
|
|
33804
|
+
resolve2();
|
|
33805
|
+
});
|
|
33806
|
+
});
|
|
33807
|
+
});
|
|
33808
|
+
});
|
|
33809
|
+
} finally {
|
|
33810
|
+
this.pool.release(conn);
|
|
33811
|
+
}
|
|
33812
|
+
}
|
|
33691
33813
|
async ping() {
|
|
33692
33814
|
const result = await this.exec("echo pong", 1e4).catch(() => null);
|
|
33693
33815
|
return result?.stdout.trim() === "pong";
|
|
@@ -33698,32 +33820,40 @@ class SshTransport {
|
|
|
33698
33820
|
}
|
|
33699
33821
|
|
|
33700
33822
|
// src/shared/tools/ssh-tools.ts
|
|
33823
|
+
import { randomUUID } from "node:crypto";
|
|
33701
33824
|
var SAFE_CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
|
|
33702
33825
|
var SAFE_PROJECT_REF = /^[a-z0-9-]{1,20}$/;
|
|
33703
33826
|
var SAFE_RELEASE_TAG = /^[a-zA-Z0-9._-]{1,80}$/;
|
|
33704
33827
|
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
|
-
|
|
33828
|
+
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])?))*$/;
|
|
33829
|
+
var SAFE_SYSTEMD_UNIT = /^[a-zA-Z0-9][a-zA-Z0-9_.@:-]{0,127}$/;
|
|
33830
|
+
var SAFE_DB_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/;
|
|
33831
|
+
function hostnameSchema(fieldName) {
|
|
33832
|
+
return exports_external.string().trim().min(1).max(253).refine((value) => SAFE_HOSTNAME.test(value), { message: `Invalid ${fieldName}` }).transform((value) => value.toLowerCase());
|
|
33833
|
+
}
|
|
33834
|
+
function secretSchema(fieldName) {
|
|
33835
|
+
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), {
|
|
33836
|
+
message: `Invalid ${fieldName}`
|
|
33837
|
+
});
|
|
33838
|
+
}
|
|
33839
|
+
function quoteEnvValue(value) {
|
|
33840
|
+
return `'${value.split("'").join("'\\''")}'`;
|
|
33841
|
+
}
|
|
33842
|
+
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 }'`;
|
|
33843
|
+
function redactTenantConfig(value) {
|
|
33844
|
+
const redactedLines = value.split(/\r?\n/).map((line) => {
|
|
33845
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
33846
|
+
if (!match)
|
|
33847
|
+
return line;
|
|
33848
|
+
const key = match[1];
|
|
33849
|
+
if (!/(?:PASSWORD|PASS|SECRET|TOKEN|KEY|CREDENTIAL|DB_URI|DATABASE_URL|DSN)/i.test(key)) {
|
|
33850
|
+
return line;
|
|
33851
|
+
}
|
|
33852
|
+
return `${key}=[REDACTED]`;
|
|
33853
|
+
}).join(`
|
|
33854
|
+
`);
|
|
33855
|
+
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]@");
|
|
33856
|
+
}
|
|
33727
33857
|
function assertSafeProjectRef(value, fieldName) {
|
|
33728
33858
|
if (!SAFE_PROJECT_REF.test(value)) {
|
|
33729
33859
|
throw new Error(`Invalid ${fieldName}`);
|
|
@@ -33744,21 +33874,21 @@ function assertSafeReleaseTag(value) {
|
|
|
33744
33874
|
}
|
|
33745
33875
|
function assertSafeGithubProxy(value) {
|
|
33746
33876
|
const trimmed = value.trim();
|
|
33747
|
-
if (/[\s\n\r;&|`$<>]/.test(trimmed)) {
|
|
33877
|
+
if (/[\s\n\r;&|`$<>{}\[\]()*!?\\'\"]/.test(trimmed)) {
|
|
33748
33878
|
throw new Error("Invalid github_proxy");
|
|
33749
33879
|
}
|
|
33750
33880
|
if (trimmed.toLowerCase() === "direct" || trimmed.toLowerCase() === "none") {
|
|
33751
33881
|
return trimmed;
|
|
33752
33882
|
}
|
|
33753
33883
|
const parsed = new URL(trimmed);
|
|
33754
|
-
if (
|
|
33755
|
-
throw new Error("Invalid github_proxy protocol");
|
|
33884
|
+
if (parsed.protocol !== "https:") {
|
|
33885
|
+
throw new Error("Invalid github_proxy protocol: HTTPS is required");
|
|
33886
|
+
}
|
|
33887
|
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
|
33888
|
+
throw new Error("Invalid github_proxy: credentials, query strings, and fragments are not allowed");
|
|
33756
33889
|
}
|
|
33757
33890
|
return parsed.toString();
|
|
33758
33891
|
}
|
|
33759
|
-
function deriveStudioDomain(publicDomain) {
|
|
33760
|
-
return `studio.${publicDomain.trim().replace(/^(?:api|studio)\./i, "")}`;
|
|
33761
|
-
}
|
|
33762
33892
|
function getExecTimeoutMs(timeoutSeconds) {
|
|
33763
33893
|
const seconds = timeoutSeconds || 60;
|
|
33764
33894
|
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > SAFE_TIMEOUT_SECONDS) {
|
|
@@ -33771,13 +33901,113 @@ function assertSafeExecCommand(command) {
|
|
|
33771
33901
|
if (!trimmed) {
|
|
33772
33902
|
throw new Error("'command' required");
|
|
33773
33903
|
}
|
|
33774
|
-
|
|
33775
|
-
throw new Error("
|
|
33904
|
+
const reject = () => {
|
|
33905
|
+
throw new Error("Command is outside the allowed read-only diagnostic grammar");
|
|
33906
|
+
};
|
|
33907
|
+
if (!/^[\x20-\x7e]+$/.test(trimmed) || /[\n\r;&|`$<>\\'\"()[\]{}*?!~#]/.test(trimmed))
|
|
33908
|
+
reject();
|
|
33909
|
+
const tokens = trimmed.split(/\s+/);
|
|
33910
|
+
const commandName = tokens[0];
|
|
33911
|
+
if (commandName === "systemctl") {
|
|
33912
|
+
const action = tokens[1];
|
|
33913
|
+
if (["list-units", "list-unit-files"].includes(action)) {
|
|
33914
|
+
if (tokens.length === 2 || tokens.length === 3 && tokens[2] === "--no-pager")
|
|
33915
|
+
return trimmed;
|
|
33916
|
+
reject();
|
|
33917
|
+
}
|
|
33918
|
+
if (["status", "is-active", "is-enabled"].includes(action) && SAFE_SYSTEMD_UNIT.test(tokens[2] || "")) {
|
|
33919
|
+
if (tokens.length === 3 || tokens.length === 4 && tokens[3] === "--no-pager")
|
|
33920
|
+
return trimmed;
|
|
33921
|
+
}
|
|
33922
|
+
reject();
|
|
33923
|
+
}
|
|
33924
|
+
if (commandName === "journalctl") {
|
|
33925
|
+
let unit = "";
|
|
33926
|
+
let tailCount = "";
|
|
33927
|
+
let noPager = false;
|
|
33928
|
+
for (let index = 1;index < tokens.length; index += 1) {
|
|
33929
|
+
const token = tokens[index];
|
|
33930
|
+
if (token === "-u" && !unit) {
|
|
33931
|
+
unit = tokens[++index] || "";
|
|
33932
|
+
if (!SAFE_SYSTEMD_UNIT.test(unit))
|
|
33933
|
+
reject();
|
|
33934
|
+
} else if (token === "-n" && !tailCount) {
|
|
33935
|
+
tailCount = tokens[++index] || "";
|
|
33936
|
+
const count = Number(tailCount);
|
|
33937
|
+
if (!/^\d+$/.test(tailCount) || count < 1 || count > 1000)
|
|
33938
|
+
reject();
|
|
33939
|
+
} else if (token === "--no-pager" && !noPager) {
|
|
33940
|
+
noPager = true;
|
|
33941
|
+
} else {
|
|
33942
|
+
reject();
|
|
33943
|
+
}
|
|
33944
|
+
}
|
|
33945
|
+
if (unit && tailCount && noPager)
|
|
33946
|
+
return trimmed;
|
|
33947
|
+
reject();
|
|
33948
|
+
}
|
|
33949
|
+
if (commandName === "docker" || commandName === "podman") {
|
|
33950
|
+
const action = tokens[1];
|
|
33951
|
+
if (action === "ps") {
|
|
33952
|
+
const flags = tokens.slice(2);
|
|
33953
|
+
if (flags.every((flag, index) => ["-a", "--no-trunc"].includes(flag) && flags.indexOf(flag) === index)) {
|
|
33954
|
+
return trimmed;
|
|
33955
|
+
}
|
|
33956
|
+
reject();
|
|
33957
|
+
}
|
|
33958
|
+
if (action === "logs") {
|
|
33959
|
+
let index = 2;
|
|
33960
|
+
if (tokens[index] !== "--tail")
|
|
33961
|
+
reject();
|
|
33962
|
+
const countToken = tokens[index + 1] || "";
|
|
33963
|
+
const count = Number(countToken);
|
|
33964
|
+
if (!/^\d+$/.test(countToken) || count < 1 || count > 1000)
|
|
33965
|
+
reject();
|
|
33966
|
+
index += 2;
|
|
33967
|
+
if (index === tokens.length - 1 && SAFE_CONTAINER_NAME.test(tokens[index] || ""))
|
|
33968
|
+
return trimmed;
|
|
33969
|
+
reject();
|
|
33970
|
+
}
|
|
33971
|
+
reject();
|
|
33776
33972
|
}
|
|
33777
|
-
if (
|
|
33778
|
-
|
|
33973
|
+
if (commandName === "ps" && trimmed === "ps -eo pid,user,comm")
|
|
33974
|
+
return trimmed;
|
|
33975
|
+
if (commandName === "ss" && ["ss -s", "ss -tlnp", "ss -lntp"].includes(trimmed))
|
|
33976
|
+
return trimmed;
|
|
33977
|
+
if (commandName === "df" && (trimmed === "df -h" || /^df -h (?:\/|\/var|\/tmp)$/.test(trimmed)))
|
|
33978
|
+
return trimmed;
|
|
33979
|
+
if (commandName === "free" && ["free", "free -h", "free -m"].includes(trimmed))
|
|
33980
|
+
return trimmed;
|
|
33981
|
+
if (commandName === "uname" && ["uname", "uname -a", "uname -r", "uname -m"].includes(trimmed))
|
|
33982
|
+
return trimmed;
|
|
33983
|
+
if (trimmed === "cat /etc/os-release")
|
|
33984
|
+
return trimmed;
|
|
33985
|
+
if (commandName === "hostname" && ["hostname", "hostname -f"].includes(trimmed))
|
|
33986
|
+
return trimmed;
|
|
33987
|
+
if (commandName === "pg_isready") {
|
|
33988
|
+
const seen = new Set;
|
|
33989
|
+
for (let index = 1;index < tokens.length; index += 2) {
|
|
33990
|
+
const option = tokens[index];
|
|
33991
|
+
const optionValue = tokens[index + 1];
|
|
33992
|
+
if (!optionValue || seen.has(option))
|
|
33993
|
+
reject();
|
|
33994
|
+
seen.add(option);
|
|
33995
|
+
if (option === "-h" && !["localhost", "127.0.0.1", "::1"].includes(optionValue))
|
|
33996
|
+
reject();
|
|
33997
|
+
else if (option === "-p") {
|
|
33998
|
+
const port = Number(optionValue);
|
|
33999
|
+
if (!/^\d+$/.test(optionValue) || port < 1 || port > 65535)
|
|
34000
|
+
reject();
|
|
34001
|
+
} else if (["-U", "-d"].includes(option)) {
|
|
34002
|
+
if (!SAFE_DB_IDENTIFIER.test(optionValue))
|
|
34003
|
+
reject();
|
|
34004
|
+
} else if (option !== "-h") {
|
|
34005
|
+
reject();
|
|
34006
|
+
}
|
|
34007
|
+
}
|
|
34008
|
+
return trimmed;
|
|
33779
34009
|
}
|
|
33780
|
-
return
|
|
34010
|
+
return reject();
|
|
33781
34011
|
}
|
|
33782
34012
|
function registerSshTools(server, ssh) {
|
|
33783
34013
|
server.tool("ssh", `Server management via SSH. Available before & after SupaCloud installation.
|
|
@@ -33799,14 +34029,14 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
|
|
|
33799
34029
|
]).describe("Action to perform"),
|
|
33800
34030
|
command: exports_external.string().optional().describe("[exec] Restricted shell command to execute"),
|
|
33801
34031
|
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:
|
|
34032
|
+
public_domain: hostnameSchema("public_domain").optional().describe("[install] API domain, e.g. api.example.com"),
|
|
34033
|
+
studio_domain: hostnameSchema("studio_domain").optional().describe("[install] Studio domain"),
|
|
34034
|
+
postgres_password: secretSchema("postgres_password").optional().describe("[install] DB password (auto-generated if empty)"),
|
|
34035
|
+
dashboard_password: secretSchema("dashboard_password").optional().describe("[install] Console password"),
|
|
33806
34036
|
edge_runtime: exports_external.enum(["bun"]).optional().describe("[install] Runtime (default: bun)"),
|
|
33807
|
-
storage_type: exports_external.enum(["juicefs", "
|
|
34037
|
+
storage_type: exports_external.enum(["juicefs", "minio"]).optional().describe("[install] Storage backend configurable through Admin"),
|
|
33808
34038
|
version: exports_external.string().optional().describe("[upgrade] Specific version"),
|
|
33809
|
-
github_proxy: exports_external.string().optional().describe("[upgrade] GitHub proxy prefix,
|
|
34039
|
+
github_proxy: exports_external.string().optional().describe("[install/upgrade] Explicit GitHub proxy prefix, or direct/none"),
|
|
33810
34040
|
focus: exports_external.enum(["all", "containers", "database", "network", "disk", "logs"]).optional().describe("[troubleshoot] Focus area"),
|
|
33811
34041
|
container: exports_external.string().optional().describe("[container_logs] Container name"),
|
|
33812
34042
|
lines: exports_external.number().optional().describe("[container_logs] Number of log lines (default: 100)"),
|
|
@@ -33827,15 +34057,11 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
|
|
|
33827
34057
|
}
|
|
33828
34058
|
case "setup": {
|
|
33829
34059
|
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");
|
|
34060
|
+
const verify = await ssh.exec("echo SSH_SESSION_OK");
|
|
34061
|
+
const ok = verify.success && verify.stdout.includes("SSH_SESSION_OK");
|
|
33835
34062
|
text = [
|
|
33836
|
-
ok ? "✅ SSH
|
|
34063
|
+
ok ? "✅ SSH session verified" : "❌ SSH session verification failed",
|
|
33837
34064
|
`Tools: ${baseTools.stdout.trim()}`,
|
|
33838
|
-
`SSH: exit ${sshSetup.code}`,
|
|
33839
34065
|
`Verify: ${verify.stdout.trim() || verify.stderr.trim()}`
|
|
33840
34066
|
].join(`
|
|
33841
34067
|
`);
|
|
@@ -33844,40 +34070,93 @@ Actions: ping, setup, install, upgrade, diagnose, exec, troubleshoot, container_
|
|
|
33844
34070
|
case "install": {
|
|
33845
34071
|
if (!args.public_domain)
|
|
33846
34072
|
throw new Error("'public_domain' required");
|
|
33847
|
-
const
|
|
34073
|
+
const installId = randomUUID();
|
|
34074
|
+
const DIR = "/opt/supacloud";
|
|
34075
|
+
const LOG = `/var/log/supacloud/install-${installId}.log`;
|
|
34076
|
+
const STATUS = `/var/log/supacloud/install-${installId}.status`;
|
|
34077
|
+
const CONFIG = "/etc/supabase/install.env";
|
|
34078
|
+
const INPUT = `/etc/supabase/.install-input-${installId}.env`;
|
|
34079
|
+
const BOOTSTRAP = `/opt/.supacloud-bootstrap-${installId}`;
|
|
33848
34080
|
const REPO = "https://github.com/zuohuadong/supacloud.git";
|
|
34081
|
+
const configuredProxy = args.github_proxy ? assertSafeGithubProxy(args.github_proxy) : "direct";
|
|
34082
|
+
const proxyDisabled = ["direct", "none"].includes(configuredProxy.toLowerCase());
|
|
34083
|
+
const proxyPrefix = proxyDisabled ? "" : configuredProxy.endsWith("/") ? configuredProxy : `${configuredProxy}/`;
|
|
34084
|
+
const bootstrapClone = `git clone --depth 1 --branch main ${quoteEnvValue(REPO)} ${quoteEnvValue(BOOTSTRAP)}`;
|
|
34085
|
+
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);
|
|
34086
|
+
if (!bootstrapDeps.success || !bootstrapDeps.stdout.includes("BOOTSTRAP_DEPS_OK")) {
|
|
34087
|
+
text = `❌ Bootstrap dependency preparation failed
|
|
34088
|
+
${bootstrapDeps.stderr.slice(-500)}`;
|
|
34089
|
+
break;
|
|
34090
|
+
}
|
|
33849
34091
|
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
|
-
|
|
34092
|
+
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);
|
|
34093
|
+
if (!clone2.stdout.includes("BOOTSTRAP_OK")) {
|
|
34094
|
+
await ssh.exec(`rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
|
|
34095
|
+
text = `❌ Trusted bootstrap clone failed
|
|
33853
34096
|
${clone2.stderr.slice(-500)}`;
|
|
33854
34097
|
break;
|
|
33855
34098
|
}
|
|
34099
|
+
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)}`);
|
|
34100
|
+
if (!prepareProtectedPaths.success) {
|
|
34101
|
+
await ssh.exec(`rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
|
|
34102
|
+
text = `❌ Unable to prepare protected install input and log paths
|
|
34103
|
+
${prepareProtectedPaths.stderr.slice(-500)}`;
|
|
34104
|
+
break;
|
|
34105
|
+
}
|
|
33856
34106
|
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}` : ""
|
|
34107
|
+
`SUPABASE_PUBLIC_DOMAIN=${quoteEnvValue(args.public_domain)}`,
|
|
34108
|
+
args.studio_domain ? `SUPABASE_STUDIO_DOMAIN=${quoteEnvValue(args.studio_domain)}` : "",
|
|
34109
|
+
args.edge_runtime ? `EDGE_RUNTIME=${quoteEnvValue(args.edge_runtime)}` : "",
|
|
34110
|
+
args.storage_type ? `S3_STORAGE_TYPE=${quoteEnvValue(args.storage_type)}` : "",
|
|
34111
|
+
args.postgres_password ? `POSTGRES_PASSWORD=${quoteEnvValue(args.postgres_password)}` : "",
|
|
34112
|
+
args.dashboard_password ? `DASHBOARD_PASSWORD=${quoteEnvValue(args.dashboard_password)}` : ""
|
|
33863
34113
|
].filter(Boolean).join(`
|
|
33864
34114
|
`);
|
|
33865
|
-
|
|
33866
|
-
|
|
33867
|
-
|
|
33868
|
-
|
|
33869
|
-
|
|
34115
|
+
try {
|
|
34116
|
+
await ssh.uploadText(INPUT, `${envLines}
|
|
34117
|
+
`, 384);
|
|
34118
|
+
} catch (error51) {
|
|
34119
|
+
await ssh.exec(`rm -f ${quoteEnvValue(INPUT)}; rm -rf ${quoteEnvValue(BOOTSTRAP)}`);
|
|
34120
|
+
throw error51;
|
|
34121
|
+
}
|
|
34122
|
+
const setupEnv = [
|
|
34123
|
+
`SUPACLOUD_INSTALL_DIR=${quoteEnvValue(DIR)}`,
|
|
34124
|
+
"SUPACLOUD_SETUP_ARTIFACT_MODE=release",
|
|
34125
|
+
"SUPACLOUD_FORCE_VERIFIED_RELEASE_ASSETS=true",
|
|
34126
|
+
`SUPACLOUD_SETUP_INPUT_FILE=${quoteEnvValue(INPUT)}`,
|
|
34127
|
+
`SUPACLOUD_INSTALL_CONFIG_FILE=${quoteEnvValue(CONFIG)}`,
|
|
34128
|
+
proxyPrefix ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(configuredProxy)}` : ""
|
|
34129
|
+
].filter(Boolean).join(" ");
|
|
34130
|
+
const statusNext = `${STATUS}.next`;
|
|
34131
|
+
const backgroundScript = [
|
|
34132
|
+
"set +e",
|
|
34133
|
+
`trap "rm -f ${quoteEnvValue(INPUT)}; rm -rf ${quoteEnvValue(BOOTSTRAP)}" EXIT`,
|
|
34134
|
+
`printf 'RUNNING\\n' > ${quoteEnvValue(statusNext)}`,
|
|
34135
|
+
`chmod 600 ${quoteEnvValue(statusNext)}`,
|
|
34136
|
+
`mv -f ${quoteEnvValue(statusNext)} ${quoteEnvValue(STATUS)}`,
|
|
34137
|
+
`env ${setupEnv} bash ${quoteEnvValue(`${BOOTSTRAP}/setup.sh`)}`,
|
|
34138
|
+
"INSTALL_CODE=$?",
|
|
34139
|
+
`if [ "$INSTALL_CODE" -eq 0 ]; then printf 'SUCCEEDED\\n' > ${quoteEnvValue(statusNext)}; ` + `else printf 'FAILED:%s\\n' "$INSTALL_CODE" > ${quoteEnvValue(statusNext)}; fi`,
|
|
34140
|
+
`chmod 600 ${quoteEnvValue(statusNext)}`,
|
|
34141
|
+
`mv -f ${quoteEnvValue(statusNext)} ${quoteEnvValue(STATUS)}`,
|
|
34142
|
+
'exit "$INSTALL_CODE"'
|
|
34143
|
+
].join("; ");
|
|
34144
|
+
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);
|
|
34145
|
+
const installAccepted = result.stdout.includes("INSTALL_STARTED") || result.stdout.includes("INSTALL_COMPLETED");
|
|
34146
|
+
text = installAccepted ? `✅ Installation started
|
|
33870
34147
|
OS: ${osCheck.stdout.trim()}
|
|
33871
34148
|
${result.stdout.trim()}
|
|
33872
34149
|
Log: ${LOG}
|
|
34150
|
+
Status: ${STATUS}
|
|
33873
34151
|
⏱ ~15-30 min` : `❌ Start failed
|
|
34152
|
+
${result.stdout.slice(-500)}
|
|
33874
34153
|
${result.stderr.slice(-500)}`;
|
|
33875
34154
|
break;
|
|
33876
34155
|
}
|
|
33877
34156
|
case "upgrade": {
|
|
33878
34157
|
const envParts = [
|
|
33879
34158
|
args.version ? `SUPACLOUD_UPGRADE_TAG=${assertSafeReleaseTag(args.version)}` : "",
|
|
33880
|
-
args.github_proxy ? `SUPACLOUD_GITHUB_PROXY=${assertSafeGithubProxy(args.github_proxy)}` : ""
|
|
34159
|
+
args.github_proxy ? `SUPACLOUD_GITHUB_PROXY=${quoteEnvValue(assertSafeGithubProxy(args.github_proxy))}` : ""
|
|
33881
34160
|
].filter(Boolean);
|
|
33882
34161
|
const envPrefix = envParts.length > 0 ? `${envParts.join(" ")} ` : "";
|
|
33883
34162
|
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 +34173,7 @@ ${r.stderr.slice(-500)}`;
|
|
|
33894
34173
|
"echo '=== Disk ===' && df -h /",
|
|
33895
34174
|
"echo '=== Docker ===' && (docker ps --format 'table {{.Names}}\t{{.Status}}' 2>/dev/null || echo 'Not found')",
|
|
33896
34175
|
"echo '=== PostgreSQL ===' && (pg_isready 2>/dev/null && echo 'Running' || echo 'Not detected')",
|
|
33897
|
-
"echo '=== Management API ===' && (curl -sf http://localhost:9090/
|
|
34176
|
+
"echo '=== Management API ===' && (curl -sf http://localhost:9090/health > /dev/null && echo 'Running' || echo 'Not running')"
|
|
33898
34177
|
];
|
|
33899
34178
|
const r = await ssh.exec(cmds.join(" && "));
|
|
33900
34179
|
text = r.stdout || r.stderr;
|
|
@@ -33929,7 +34208,7 @@ ${r.stderr.slice(-500)}`;
|
|
|
33929
34208
|
if (f === "all" || f === "network")
|
|
33930
34209
|
checks3.push("echo '══════ Ports ══════'", "ss -tlnp | grep -E ':(80|443|5432|8000|9090|3000) ' 2>/dev/null || echo 'N/A'");
|
|
33931
34210
|
if (f === "all" || f === "logs")
|
|
33932
|
-
checks3.push("echo '══════ Install Log ══════'",
|
|
34211
|
+
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
34212
|
if (f === "all" || f === "disk")
|
|
33934
34213
|
checks3.push("echo '══════ Large Dirs ══════'", "du -sh /var/lib/postgresql /var/lib/docker 2>/dev/null | sort -rh | head -10");
|
|
33935
34214
|
const r = await ssh.exec(checks3.join(`
|
|
@@ -33981,26 +34260,40 @@ ${r.stdout}`;
|
|
|
33981
34260
|
if (!args.project_ref)
|
|
33982
34261
|
throw new Error("'project_ref' required");
|
|
33983
34262
|
const projectRef = assertSafeProjectRef(args.project_ref, "project_ref");
|
|
33984
|
-
const r = await ssh.exec(
|
|
33985
|
-
|
|
33986
|
-
|
|
33987
|
-
|
|
34263
|
+
const r = await ssh.exec([
|
|
34264
|
+
"set -eu",
|
|
34265
|
+
"found=0",
|
|
34266
|
+
`for file in /etc/supabase/tenants/${projectRef}.env /etc/supabase/tenants/${projectRef}_gotrue.env; do`,
|
|
34267
|
+
' [ -f "$file" ] || continue',
|
|
34268
|
+
" found=1",
|
|
34269
|
+
` printf '
|
|
34270
|
+
# %s
|
|
34271
|
+
' "$(basename "$file")"`,
|
|
34272
|
+
` ${REMOTE_ENV_REDACTION_AWK} "$file"`,
|
|
34273
|
+
"done",
|
|
34274
|
+
`[ "$found" -eq 1 ] || { echo 'Tenant config not found' >&2; exit 1; }`
|
|
34275
|
+
].join(`
|
|
34276
|
+
`), 1e4);
|
|
34277
|
+
const output = redactTenantConfig(r.stdout || r.stderr);
|
|
34278
|
+
text = r.success ? `\uD83D\uDCC4 ${projectRef} tenant config (sensitive values redacted):
|
|
34279
|
+
${output}` : `❌ Unable to inspect ${projectRef}:
|
|
34280
|
+
${output}`;
|
|
33988
34281
|
break;
|
|
33989
34282
|
}
|
|
33990
34283
|
case "tenant_diagnose": {
|
|
33991
34284
|
const checks3 = [
|
|
33992
34285
|
"echo '══════ Multi-tenant Diagnostic ══════'",
|
|
33993
|
-
"ps
|
|
34286
|
+
"ps -eo pid=,user=,comm= | grep -E 'postgrest|gotrue' | grep -v grep || echo 'No processes'",
|
|
33994
34287
|
"systemctl list-units 'supacloud-pgrst@*' 'supacloud-gotrue@*' --no-pager 2>/dev/null || echo 'N/A'",
|
|
33995
34288
|
"ls -l /etc/supabase/tenants/*.env 2>/dev/null || echo 'No config'"
|
|
33996
34289
|
];
|
|
33997
34290
|
if (args.project_ref) {
|
|
33998
34291
|
const projectRef = assertSafeProjectRef(args.project_ref, "project_ref");
|
|
33999
|
-
checks3.push(`systemctl status supacloud-pgrst@${projectRef} --no-pager 2>/dev/null || echo 'Not found'`, `
|
|
34292
|
+
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
34293
|
}
|
|
34001
34294
|
const r = await ssh.exec(checks3.join(`
|
|
34002
34295
|
`), 30000);
|
|
34003
|
-
text = r.stdout || r.stderr;
|
|
34296
|
+
text = redactTenantConfig(r.stdout || r.stderr);
|
|
34004
34297
|
break;
|
|
34005
34298
|
}
|
|
34006
34299
|
case "tenant_migrate": {
|
|
@@ -34008,21 +34301,31 @@ ${r.stdout || r.stderr}`;
|
|
|
34008
34301
|
throw new Error("'source_ref' and 'target_ref' required");
|
|
34009
34302
|
const sourceRef = assertSafeProjectRef(args.source_ref, "source_ref");
|
|
34010
34303
|
const targetRef = assertSafeProjectRef(args.target_ref, "target_ref");
|
|
34304
|
+
if (sourceRef === targetRef)
|
|
34305
|
+
throw new Error("source_ref and target_ref must be different");
|
|
34011
34306
|
const s = args.schemas || "public,auth,storage";
|
|
34012
34307
|
if (!/^[a-z_,\s]+$/.test(s))
|
|
34013
34308
|
throw new Error("Invalid schemas");
|
|
34014
|
-
const
|
|
34309
|
+
const schemas3 = s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
34310
|
+
if (schemas3.length === 0)
|
|
34311
|
+
throw new Error("At least one schema is required");
|
|
34312
|
+
const schemaArgs = schemas3.map((schema) => `-n ${schema}`).join(" ");
|
|
34015
34313
|
const df = args.data_only ? "--data-only" : "";
|
|
34016
34314
|
const cmd = [
|
|
34315
|
+
"set -euo pipefail",
|
|
34316
|
+
"umask 077",
|
|
34317
|
+
'tmp_dir="$(mktemp -d /tmp/supacloud-migrate.XXXXXX)"',
|
|
34318
|
+
'dump_file="$tmp_dir/tenant.dump"',
|
|
34319
|
+
`trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM`,
|
|
34017
34320
|
`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
|
-
|
|
34321
|
+
`pg_dump -h localhost -U postgres -d supa_${sourceRef} ${schemaArgs} ${df} -Fc -f "$dump_file"`,
|
|
34322
|
+
`pg_restore -h localhost -U postgres -d supa_${targetRef} --no-owner --no-acl --exit-on-error "$dump_file"`,
|
|
34323
|
+
"echo 'Migration complete'"
|
|
34021
34324
|
].join(`
|
|
34022
34325
|
`);
|
|
34023
34326
|
const r = await ssh.exec(cmd, 600000);
|
|
34024
34327
|
text = r.success ? `✅ Migration done
|
|
34025
|
-
${r.stdout}` : `❌
|
|
34328
|
+
${r.stdout}` : `❌ Migration failed (exit ${r.code})
|
|
34026
34329
|
${r.stdout}
|
|
34027
34330
|
${r.stderr.slice(-1000)}`;
|
|
34028
34331
|
break;
|
|
@@ -34736,6 +35039,7 @@ EXPECTED CONTEXT
|
|
|
34736
35039
|
Platform commands typically rely on:
|
|
34737
35040
|
SUPACLOUD_HOST
|
|
34738
35041
|
SUPACLOUD_SSH_KEY / SUPACLOUD_SSH_PASS
|
|
35042
|
+
SUPACLOUD_SSH_HOST_FINGERPRINT=SHA256:...
|
|
34739
35043
|
SUPACLOUD_API_URL
|
|
34740
35044
|
SUPACLOUD_API_TOKEN
|
|
34741
35045
|
|
|
@@ -34756,8 +35060,7 @@ EXAMPLES
|
|
|
34756
35060
|
supacloud-admin gateway rebuild --ref abc123 --clean
|
|
34757
35061
|
`);
|
|
34758
35062
|
}
|
|
34759
|
-
function createAdminTools() {
|
|
34760
|
-
const context = resolveSupaCloudContext();
|
|
35063
|
+
function createAdminTools(context = resolveSupaCloudContext()) {
|
|
34761
35064
|
const tools = {
|
|
34762
35065
|
status: {
|
|
34763
35066
|
schema: {},
|
|
@@ -34771,6 +35074,7 @@ function createAdminTools() {
|
|
|
34771
35074
|
apiUrl: context.apiUrl || null,
|
|
34772
35075
|
hasApiToken: Boolean(context.apiToken),
|
|
34773
35076
|
hasSshKey: Boolean(context.sshKey),
|
|
35077
|
+
hasSshHostFingerprint: Boolean(context.sshHostFingerprint),
|
|
34774
35078
|
source: context.source
|
|
34775
35079
|
}, null, 2)
|
|
34776
35080
|
}
|
|
@@ -34807,7 +35111,7 @@ function createAdminTools() {
|
|
|
34807
35111
|
content: [
|
|
34808
35112
|
{
|
|
34809
35113
|
type: "text",
|
|
34810
|
-
text: "⚠️ SSH commands require SUPACLOUD_HOST
|
|
35114
|
+
text: "⚠️ SSH commands require SUPACLOUD_HOST, SSH credentials, and SUPACLOUD_SSH_HOST_FINGERPRINT."
|
|
34811
35115
|
}
|
|
34812
35116
|
]
|
|
34813
35117
|
})
|
|
@@ -34825,15 +35129,44 @@ function createAdminTools() {
|
|
|
34825
35129
|
};
|
|
34826
35130
|
};
|
|
34827
35131
|
registerAdminHelp();
|
|
34828
|
-
if (context.host) {
|
|
34829
|
-
|
|
34830
|
-
|
|
34831
|
-
|
|
34832
|
-
|
|
34833
|
-
|
|
34834
|
-
|
|
34835
|
-
|
|
34836
|
-
|
|
35132
|
+
if (context.host && context.sshHostFingerprint) {
|
|
35133
|
+
try {
|
|
35134
|
+
const ssh = new SshTransport({
|
|
35135
|
+
host: context.host,
|
|
35136
|
+
port: context.sshPort,
|
|
35137
|
+
username: context.sshUser,
|
|
35138
|
+
privateKeyPath: context.sshKey || undefined,
|
|
35139
|
+
password: context.sshPass || undefined,
|
|
35140
|
+
hostFingerprint: context.sshHostFingerprint
|
|
35141
|
+
});
|
|
35142
|
+
Object.assign(tools, captureTools((server) => registerSshTools(server, ssh)));
|
|
35143
|
+
} catch (error51) {
|
|
35144
|
+
const message = error51 instanceof Error ? error51.message : String(error51);
|
|
35145
|
+
tools.ssh = {
|
|
35146
|
+
schema: { action: sshActionSchema },
|
|
35147
|
+
callback: async () => ({
|
|
35148
|
+
content: [{
|
|
35149
|
+
type: "text",
|
|
35150
|
+
text: `⚠️ SSH host fingerprint is invalid; SSH actions remain disabled. ${message}`
|
|
35151
|
+
}]
|
|
35152
|
+
})
|
|
35153
|
+
};
|
|
35154
|
+
}
|
|
35155
|
+
} else if (context.host) {
|
|
35156
|
+
tools.ssh = {
|
|
35157
|
+
schema: { action: sshActionSchema },
|
|
35158
|
+
callback: async () => ({
|
|
35159
|
+
content: [{
|
|
35160
|
+
type: "text",
|
|
35161
|
+
text: [
|
|
35162
|
+
"⚠️ SSH actions are disabled because host-key verification is not configured.",
|
|
35163
|
+
"Set SUPACLOUD_SSH_HOST_FINGERPRINT to the server's OpenSSH SHA256 fingerprint.",
|
|
35164
|
+
`Verify it out-of-band first, for example: ssh-keyscan -p ${context.sshPort} ${context.host} | ssh-keygen -lf -`
|
|
35165
|
+
].join(`
|
|
35166
|
+
`)
|
|
35167
|
+
}]
|
|
35168
|
+
})
|
|
35169
|
+
};
|
|
34837
35170
|
}
|
|
34838
35171
|
if (context.apiUrl && context.apiToken) {
|
|
34839
35172
|
const http = new HttpTransport({
|
|
@@ -34858,7 +35191,7 @@ function createAdminTools() {
|
|
|
34858
35191
|
"⚠️ No admin context configured.",
|
|
34859
35192
|
"",
|
|
34860
35193
|
"Provide one or both of:",
|
|
34861
|
-
" SUPACLOUD_HOST + SSH credentials",
|
|
35194
|
+
" SUPACLOUD_HOST + SSH credentials + SUPACLOUD_SSH_HOST_FINGERPRINT",
|
|
34862
35195
|
" SUPACLOUD_API_URL + SUPACLOUD_API_TOKEN",
|
|
34863
35196
|
"",
|
|
34864
35197
|
"This CLI is intended for server installation, diagnostics, and",
|
|
@@ -34894,7 +35227,15 @@ async function main() {
|
|
|
34894
35227
|
}
|
|
34895
35228
|
await runCli(cliTools, args, { commandName: "supacloud-admin" });
|
|
34896
35229
|
}
|
|
34897
|
-
|
|
34898
|
-
|
|
34899
|
-
|
|
34900
|
-
|
|
35230
|
+
function isDirectRun() {
|
|
35231
|
+
return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve2(process.argv[1])).href;
|
|
35232
|
+
}
|
|
35233
|
+
if (isDirectRun()) {
|
|
35234
|
+
main().catch((error51) => {
|
|
35235
|
+
console.error("supacloud-admin failed:", error51);
|
|
35236
|
+
process.exit(1);
|
|
35237
|
+
});
|
|
35238
|
+
}
|
|
35239
|
+
export {
|
|
35240
|
+
createAdminTools
|
|
35241
|
+
};
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supacloud/admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Platform administration CLI for SupaCloud operators",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -36,6 +36,6 @@
|
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/bun": "^1.3.14",
|
|
38
38
|
"@types/ssh2": "^1.15.5",
|
|
39
|
-
"typescript": "^
|
|
39
|
+
"typescript": "^7.0.2"
|
|
40
40
|
}
|
|
41
41
|
}
|