mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-08-02 23:49:34 +08:00
254 lines
8.4 KiB
JavaScript
254 lines
8.4 KiB
JavaScript
/**
|
|
* Router connection setup: writes .env and installs an SSH key on the router
|
|
* so ut-sync works without any manual ssh configuration.
|
|
*
|
|
* Usage: pnpm setup:router [ip]
|
|
* ip router address; omit for the interactive wizard, which asks for
|
|
* every .env value (router IP, dev-server host/port) with the
|
|
* current .env entries as defaults
|
|
*
|
|
* (The script is named `setup:router` because plain `pnpm setup` resolves to
|
|
* pnpm's own built-in setup command, never a package.json script.)
|
|
*/
|
|
|
|
import { execSync, spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { connect } from "node:net";
|
|
import { homedir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { createInterface } from "node:readline/promises";
|
|
|
|
const SSH_OPTS = [
|
|
"-o",
|
|
"StrictHostKeyChecking=no",
|
|
"-o",
|
|
"UserKnownHostsFile=/dev/null",
|
|
];
|
|
|
|
// Must match the ut-sync plugin in vite.config.ts — the final push below is a
|
|
// real template sync, so setup only succeeds if `pnpm dev` sync will too.
|
|
const UT_TEMPLATE_DIR = resolve(
|
|
process.cwd(),
|
|
"../ucode/template/themes/aurora",
|
|
);
|
|
const UT_REMOTE_DIR = "/usr/share/ucode/luci/template/themes/aurora";
|
|
|
|
function canAuth(host) {
|
|
return (
|
|
spawnSync(
|
|
"ssh",
|
|
[
|
|
...SSH_OPTS,
|
|
"-o",
|
|
"BatchMode=yes",
|
|
"-o",
|
|
"ConnectTimeout=5",
|
|
host,
|
|
"echo ok",
|
|
],
|
|
{ stdio: "ignore" },
|
|
).status === 0
|
|
);
|
|
}
|
|
|
|
function canReachSsh(hostname, timeout = 2000) {
|
|
return new Promise((done) => {
|
|
const sock = connect({ host: hostname, port: 22, timeout });
|
|
const finish = (ok) => {
|
|
sock.destroy();
|
|
done(ok);
|
|
};
|
|
sock.once("connect", () => finish(true));
|
|
sock.once("timeout", () => finish(false));
|
|
sock.once("error", () => finish(false));
|
|
});
|
|
}
|
|
|
|
console.log("Aurora router setup — passwordless SSH + .env for `pnpm dev`\n");
|
|
|
|
const args = process.argv.slice(2);
|
|
const argIp = args.find((a) => !a.startsWith("-"));
|
|
|
|
// Every variable the wizard manages (the full .env surface, see
|
|
// .env.example); any other lines in .env pass through untouched.
|
|
const ENV_VARS = [
|
|
{ key: "VITE_OPENWRT_HOST", label: "Router IP", fallback: "192.168.1.1" },
|
|
{ key: "VITE_DEV_HOST", label: "Dev server host", fallback: "127.0.0.1" },
|
|
{ key: "VITE_DEV_PORT", label: "Dev server port", fallback: "5173" },
|
|
];
|
|
|
|
const envPath = resolve(process.cwd(), ".env");
|
|
const envLines = existsSync(envPath)
|
|
? readFileSync(envPath, "utf-8").split("\n")
|
|
: [];
|
|
const savedValue = (key) =>
|
|
envLines
|
|
.find((l) => l.startsWith(`${key}=`))
|
|
?.slice(key.length + 1)
|
|
.trim();
|
|
const values = Object.fromEntries(
|
|
ENV_VARS.map(({ key, fallback }) => [key, savedValue(key) || fallback]),
|
|
);
|
|
|
|
if (argIp) {
|
|
// Arg mode is non-interactive: take the IP from argv and keep the saved
|
|
// (or default) dev-server values.
|
|
values.VITE_OPENWRT_HOST = argIp;
|
|
} else {
|
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
// Answers come through the async iterator — unlike rl.question, it buffers
|
|
// lines, so piped input (`printf 'ip\nhost\nport\n' | …`) is not dropped
|
|
// between questions, and EOF just leaves the remaining defaults in place.
|
|
const lines = rl[Symbol.asyncIterator]();
|
|
for (const { key, label } of ENV_VARS) {
|
|
rl.setPrompt(`${label} [${values[key]}]: `);
|
|
rl.prompt();
|
|
const { value, done } = await lines.next();
|
|
if (done) {
|
|
console.log();
|
|
break;
|
|
}
|
|
const answer = value.trim();
|
|
if (answer) values[key] = answer;
|
|
}
|
|
rl.close();
|
|
}
|
|
|
|
if (!/^\d+$/.test(values.VITE_DEV_PORT)) {
|
|
console.log(
|
|
` Ignoring invalid port "${values.VITE_DEV_PORT}" — using 5173.`,
|
|
);
|
|
values.VITE_DEV_PORT = "5173";
|
|
}
|
|
|
|
const ip = values.VITE_OPENWRT_HOST;
|
|
|
|
// Accept the same forms vite.config.ts does (bare IP, host:port, http:// URL);
|
|
// ssh always targets the bare hostname — port/key overrides live in ~/.ssh/config.
|
|
const hostname = new URL(/^https?:\/\//.test(ip) ? ip : `http://${ip}`)
|
|
.hostname;
|
|
const host = `root@${hostname}`;
|
|
|
|
if (!(await canReachSsh(hostname))) {
|
|
console.error(`✗ Cannot reach ${hostname}:22 (2s timeout).`);
|
|
console.error(
|
|
" Check that the device is powered on, on the same network, and has SSH enabled.",
|
|
);
|
|
console.error(
|
|
" (If it listens on a non-standard port, add a Host block to ~/.ssh/config.)",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const sshDir = join(homedir(), ".ssh");
|
|
|
|
let pubKeyPath = ["id_ed25519.pub", "id_rsa.pub", "id_ecdsa.pub"]
|
|
.map((f) => join(sshDir, f))
|
|
.find((f) => existsSync(f));
|
|
|
|
if (!pubKeyPath) {
|
|
console.log("No SSH key found — generating ~/.ssh/id_ed25519 ...");
|
|
const keygen = spawnSync(
|
|
"ssh-keygen",
|
|
["-t", "ed25519", "-N", "", "-f", join(sshDir, "id_ed25519")],
|
|
{ stdio: "inherit" },
|
|
);
|
|
if (keygen.status !== 0) {
|
|
console.error("✗ ssh-keygen failed.");
|
|
process.exit(1);
|
|
}
|
|
pubKeyPath = join(sshDir, "id_ed25519.pub");
|
|
}
|
|
|
|
if (canAuth(host)) {
|
|
console.log(
|
|
`✓ Passwordless SSH to ${host} already works (checked: ssh echo).`,
|
|
);
|
|
} else {
|
|
console.log(
|
|
`Installing ${pubKeyPath} on the router (enter the router password once):`,
|
|
);
|
|
const pubKey = readFileSync(pubKeyPath, "utf-8").trim();
|
|
// stderr is piped so failures can be classified below; ssh's password prompt
|
|
// goes straight to the tty, so interactive login still works.
|
|
const install = spawnSync(
|
|
"ssh",
|
|
[
|
|
...SSH_OPTS,
|
|
"-o",
|
|
"ConnectTimeout=5",
|
|
host,
|
|
`mkdir -p /etc/dropbear && touch /etc/dropbear/authorized_keys && (grep -qxF '${pubKey}' /etc/dropbear/authorized_keys || echo '${pubKey}' >> /etc/dropbear/authorized_keys) && chmod 600 /etc/dropbear/authorized_keys`,
|
|
],
|
|
{ stdio: ["inherit", "inherit", "pipe"], encoding: "utf-8" },
|
|
);
|
|
if (install.status !== 0) {
|
|
const stderr = (install.stderr || "").trim();
|
|
if (/Permission denied|Authentication failed/i.test(stderr)) {
|
|
console.error(
|
|
"✗ Authentication failed — likely a wrong password. Re-run pnpm setup:router.",
|
|
);
|
|
console.error(
|
|
" (If the device runs openssh instead of dropbear, keys belong in /root/.ssh/authorized_keys.)",
|
|
);
|
|
} else if (
|
|
/timed out|No route to host|Connection refused|Could not resolve/i.test(
|
|
stderr,
|
|
)
|
|
) {
|
|
console.error(`✗ ${hostname} did not respond as an SSH host.`);
|
|
console.error(
|
|
" Check the IP and that SSH is enabled on the device. (A VPN/proxy in TUN mode can also intercept the connection.)",
|
|
);
|
|
} else {
|
|
console.error("✗ Key install failed:");
|
|
}
|
|
if (stderr) console.error(` ${stderr.replace(/\n/g, "\n ")}`);
|
|
process.exit(1);
|
|
}
|
|
if (!canAuth(host)) {
|
|
console.error("✗ Key installed, but passwordless SSH still fails.");
|
|
console.error(
|
|
" Check the dropbear config on the device (PubkeyAuth) and /etc/dropbear/authorized_keys.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log("✓ Passwordless SSH configured.");
|
|
}
|
|
|
|
// End-to-end check: push the templates through the exact pipeline ut-sync uses.
|
|
// `ssh echo ok` alone can pass while the real tar-over-ssh sync would not.
|
|
const started = Date.now();
|
|
try {
|
|
execSync(
|
|
`tar -C "${UT_TEMPLATE_DIR}" -cf - . | ssh ${SSH_OPTS.join(" ")} -o BatchMode=yes -o ConnectTimeout=5 "${host}" "mkdir -p '${UT_REMOTE_DIR}' && tar -xf - -C '${UT_REMOTE_DIR}'"`,
|
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
);
|
|
console.log(
|
|
`✓ Templates pushed to ${host}:${UT_REMOTE_DIR} (${Date.now() - started}ms) — dev sync verified.`,
|
|
);
|
|
} catch (err) {
|
|
console.error(
|
|
"✗ Template push failed — `pnpm dev` template sync will not work:",
|
|
);
|
|
console.error(` ${String(err?.stderr || err?.message || err).trim()}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Update managed keys in place so surrounding comments keep their meaning,
|
|
// then append any the file does not have yet; other lines pass through.
|
|
const out = envLines.map((l) => {
|
|
const managed = ENV_VARS.find(({ key }) => l.startsWith(`${key}=`));
|
|
return managed ? `${managed.key}=${values[managed.key]}` : l;
|
|
});
|
|
while (out.length && !out[out.length - 1]) out.pop();
|
|
for (const { key } of ENV_VARS) {
|
|
if (!out.some((l) => l.startsWith(`${key}=`)))
|
|
out.push(`${key}=${values[key]}`);
|
|
}
|
|
writeFileSync(envPath, out.join("\n") + "\n");
|
|
console.log(
|
|
`✓ Wrote .env (${ENV_VARS.map(({ key }) => `${key}=${values[key]}`).join(", ")})`,
|
|
);
|
|
console.log("Done — run `pnpm dev`.");
|