"use strict"; const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { spawnSync } = require("node:child_process"); const DEFAULT_FRP_VERSION = "0.58.1"; const DEFAULT_FRP_ARCH = "amd64"; const DEFAULT_FRP_SERVER_ADDR = "81.70.134.9"; const DEFAULT_FRP_SERVER_PORT = 15443; const DEFAULT_SERVICE_NAME = "frpc"; const NVM_INSTALL_VERSION = "v0.40.4"; const NVM_INSTALL_COMMAND = `set -euo pipefail export NVM_DIR="$HOME/.nvm" if [ ! -s "$NVM_DIR/nvm.sh" ]; then if command -v curl >/dev/null 2>&1; then curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_INSTALL_VERSION}/install.sh | bash elif command -v wget >/dev/null 2>&1; then wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_INSTALL_VERSION}/install.sh | bash else echo "curl or wget is required to install nvm" >&2 exit 1 fi fi . "$NVM_DIR/nvm.sh" nvm install --lts nvm alias default 'lts/*' nvm use --lts`; function main(argv) { const parsed = parseArgs(argv); if (parsed.flags.help || parsed.positionals.length === 0) { printHelp(); return; } const [group, action, ...rest] = parsed.positionals; const commandFlags = parseArgs(rest).flags; const flags = { ...parsed.flags, ...commandFlags }; const runner = createRunner(flags); try { if (group === "zsh" && action === "install") { installZsh(runner, flags); return; } if (group === "ssh" && action === "install") { installSsh(runner); return; } if (group === "frp") { handleFrp(action, rest, flags, runner); return; } if (group === "bootstrap") { installZsh(runner, flags); installSsh(runner); installFrp(runner, flags); return; } fail(`Unknown command: ${[group, action].filter(Boolean).join(" ")}`); } catch (error) { fail(error.message); } } function handleFrp(action, argv, rootFlags, runner) { const parsed = parseArgs(argv); const flags = { ...rootFlags, ...parsed.flags }; if (action === "install") { installFrp(runner, flags); return; } if (action === "init") { initFrpConfig(flags, runner); return; } if (action === "add") { const name = parsed.positionals[0]; addFrpProxy(name, flags, runner); return; } if (action === "remove") { const name = parsed.positionals[0]; removeFrpProxy(name, flags, runner); return; } if (action === "list") { listFrpProxies(flags); return; } if (action === "restart") { runner.run("sudo", ["systemctl", "restart", stringFlag(flags, "service-name", DEFAULT_SERVICE_NAME)]); return; } fail(`Unknown frp command: ${action || ""}`.trim()); } function installZsh(runner, flags) { runner.run("sudo", ["apt", "update"]); runner.run("sudo", ["apt", "upgrade", "-y"]); runner.run("sudo", ["apt", "install", "zsh", "git", "curl", "wget", "-y"]); runner.run("chsh", ["-s", "/bin/zsh"]); runner.run("sh", [ "-c", 'RUNZSH=no CHSH=no KEEP_ZSHRC=yes sh -c "$(curl -fsSL https://gitee.com/pocmon/ohmyzsh/raw/master/tools/install.sh)"', ]); runner.run("bash", ["-c", NVM_INSTALL_COMMAND]); const customDir = process.env.ZSH_CUSTOM || path.join(os.homedir(), ".oh-my-zsh", "custom"); installGitPlugin( runner, "https://github.com/zsh-users/zsh-autosuggestions", path.join(customDir, "plugins", "zsh-autosuggestions"), ); installGitPlugin( runner, "https://github.com/zsh-users/zsh-syntax-highlighting.git", path.join(customDir, "plugins", "zsh-syntax-highlighting"), ); updateZshrcPlugins(["git", "zsh-autosuggestions", "zsh-syntax-highlighting"], flags.dryRun); } function installGitPlugin(runner, repo, destination) { if (fs.existsSync(destination)) { runner.run("git", ["-C", destination, "pull", "--ff-only"]); return; } runner.run("git", ["clone", repo, destination]); } function updateZshrcPlugins(plugins, dryRun) { const zshrc = path.join(os.homedir(), ".zshrc"); const pluginLine = `plugins=(${plugins.join(" ")})`; if (dryRun) { console.log(`[dry-run] update ${zshrc}: ${pluginLine}`); return; } const original = fs.existsSync(zshrc) ? fs.readFileSync(zshrc, "utf8") : ""; const next = /^plugins=\([^)]*\)$/m.test(original) ? original.replace(/^plugins=\([^)]*\)$/m, pluginLine) : `${original.trimEnd()}\n${pluginLine}\n`; fs.writeFileSync(zshrc, next); console.log(`Updated ${zshrc}`); } function installSsh(runner) { runner.run("sudo", ["apt", "update"]); runner.run("sudo", ["apt", "install", "openssh-server", "-y"]); runner.run("sudo", ["systemctl", "enable", "--now", "ssh"]); } function installFrp(runner, flags) { const version = stringFlag(flags, "version", DEFAULT_FRP_VERSION); const arch = stringFlag(flags, "arch", DEFAULT_FRP_ARCH); const installDir = getInstallDir(flags, version, arch); const archiveName = `frp_${version}_linux_${arch}.tar.gz`; const extractedDir = `/tmp/frp_${version}_linux_${arch}`; const archivePath = `/tmp/${archiveName}`; const url = `https://github.com/fatedier/frp/releases/download/v${version}/${archiveName}`; runner.run("wget", ["-O", archivePath, url]); runner.run("tar", ["-zxf", archivePath, "-C", "/tmp"]); runner.run("sudo", ["mkdir", "-p", installDir]); runner.run("sudo", ["cp", "-f", path.join(extractedDir, "frpc"), path.join(installDir, "frpc")]); runner.run("sudo", ["chmod", "+x", path.join(installDir, "frpc")]); initFrpConfig({ ...flags, "install-dir": installDir }, runner); writeFrpService(flags, runner, installDir); runner.run("sudo", ["systemctl", "daemon-reload"]); runner.run("sudo", ["systemctl", "enable", "--now", stringFlag(flags, "service-name", DEFAULT_SERVICE_NAME)]); } function initFrpConfig(flags, runner) { const configPath = getConfigPath(flags); const force = Boolean(flags.force); const configExists = fs.existsSync(configPath); if (configExists && !force) { console.log(`${configPath} already exists. Use --force to rewrite it.`); return; } const token = stringFlag(flags, "token", process.env.FRP_TOKEN || ""); if (!token) { throw new Error("frp token is required. Pass --token or set FRP_TOKEN."); } const proxies = configExists && force ? [...parseFrpConfig(fs.readFileSync(configPath, "utf8")).sections.values()].map((section) => ({ name: section.name, ...section.values, })) : []; const config = renderFrpConfig({ serverAddr: stringFlag(flags, "server-addr", DEFAULT_FRP_SERVER_ADDR), serverPort: numberFlag(flags, "server-port", DEFAULT_FRP_SERVER_PORT), "auth.method": "token", "auth.token": token, "transport.tls.enable": booleanFlag(flags, "tls-enable", false), "transport.tcpMux": booleanFlag(flags, "tcp-mux", true), "log.to": stringFlag(flags, "log-file", "/var/log/frpc.log"), "log.level": stringFlag(flags, "log-level", "info"), "log.maxDays": numberFlag(flags, "log-max-days", 7), }, proxies); writeFile(configPath, config, runner); } function writeFrpService(flags, runner, installDir) { const serviceName = stringFlag(flags, "service-name", DEFAULT_SERVICE_NAME); const configPath = getConfigPath({ ...flags, "install-dir": installDir }); const servicePath = `/etc/systemd/system/${serviceName}.service`; const service = `[Unit] Description=frp client After=network.target [Service] Type=simple ExecStart=${path.join(installDir, "frpc")} -c ${configPath} Restart=always RestartSec=5 [Install] WantedBy=multi-user.target `; writeFile(servicePath, service, runner); } function addFrpProxy(name, flags, runner) { if (!name) { throw new Error("frp add requires a proxy name, for example: frp add ssh --local-port 22 --remote-port 17227"); } assertProxyName(name); const configPath = getConfigPath(flags); if (!fs.existsSync(configPath)) { throw new Error(`${configPath} does not exist. Run frp init or frp install first.`); } const localPort = numberFlag(flags, "local-port"); const remotePort = numberFlag(flags, "remote-port"); if (!localPort || !remotePort) { throw new Error("frp add requires --local-port and --remote-port."); } const config = parseFrpConfig(fs.readFileSync(configPath, "utf8")); config.sections.set(name, { name, values: { type: stringFlag(flags, "type", "tcp"), local_ip: stringFlag(flags, "local-ip", "127.0.0.1"), local_port: localPort, remote_port: remotePort, }, }); writeFile(configPath, renderParsedFrpConfig(config), runner); restartServiceIfRequested(flags, runner); } function removeFrpProxy(name, flags, runner) { if (!name) { throw new Error("frp remove requires a proxy name."); } const configPath = getConfigPath(flags); const config = parseFrpConfig(fs.readFileSync(configPath, "utf8")); if (!config.sections.delete(name)) { console.log(`No proxy named ${name} in ${configPath}`); return; } writeFile(configPath, renderParsedFrpConfig(config), runner); restartServiceIfRequested(flags, runner); } function listFrpProxies(flags) { const configPath = getConfigPath(flags); const config = parseFrpConfig(fs.readFileSync(configPath, "utf8")); if (config.sections.size === 0) { console.log("No frp proxies configured."); return; } for (const section of config.sections.values()) { const values = section.values; console.log(`${section.name}: ${values.type || "tcp"} ${values.local_ip || "127.0.0.1"}:${values.local_port || "?"} -> remote:${values.remote_port || "?"}`); } } function restartServiceIfRequested(flags, runner) { if (!flags.restart) { return; } runner.run("sudo", ["systemctl", "restart", stringFlag(flags, "service-name", DEFAULT_SERVICE_NAME)]); } function parseFrpConfig(text) { const globals = []; const sections = new Map(); let current = null; for (const line of text.split(/\r?\n/)) { const proxyMatch = line.match(/^\s*\[\[proxies\]\]\s*$/); if (proxyMatch) { current = { name: "", values: {} }; continue; } const sectionMatch = line.match(/^\s*\[([A-Za-z0-9_.-]+)]\s*$/); if (sectionMatch) { current = { name: sectionMatch[1], values: {} }; sections.set(current.name, current); continue; } if (!current) { globals.push(migrateFrpGlobalLine(line)); continue; } const keyValue = parseTomlKeyValue(line); if (keyValue) { applyProxyConfigValue(current, keyValue, sections); } } return { globals: trimTrailingBlankLines(globals), sections }; } function migrateFrpGlobalLine(line) { const keyMap = { server_addr: "serverAddr", server_port: "serverPort", token: "auth.token", tls_enable: "transport.tls.enable", tcp_mux: "transport.tcpMux", log_file: "log.to", log_level: "log.level", log_max_days: "log.maxDays", }; const match = line.match(/^(\s*)([A-Za-z0-9_.-]+)(\s*=.*)$/); if (!match) { return line; } const [, indent, key, rest] = match; return `${indent}${keyMap[key] || key}${rest}`; } function applyProxyConfigValue(current, keyValue, sections) { const key = normalizeProxyKey(keyValue.key); if (key === "name") { if (current.name) { sections.delete(current.name); } current.name = String(keyValue.value); sections.set(current.name, current); return; } current.values[key] = keyValue.value; } function normalizeProxyKey(key) { const keyMap = { localIP: "local_ip", localPort: "local_port", remotePort: "remote_port", }; return keyMap[key] || key; } function parseTomlKeyValue(line) { const withoutComment = line.replace(/\s+#.*$/, "").trim(); const match = withoutComment.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/); if (!match) { return null; } const [, key, rawValue] = match; if (/^".*"$/.test(rawValue)) { return { key, value: rawValue.slice(1, -1) }; } if (rawValue === "true") { return { key, value: true }; } if (rawValue === "false") { return { key, value: false }; } if (/^\d+$/.test(rawValue)) { return { key, value: Number(rawValue) }; } return { key, value: rawValue }; } function renderParsedFrpConfig(config) { const lines = [...config.globals]; for (const section of config.sections.values()) { if (lines.length && lines[lines.length - 1] !== "") { lines.push(""); } lines.push(renderProxy(section.name, section.values).trimEnd()); } return `${lines.join("\n").trimEnd()}\n`; } function renderFrpConfig(globals, proxies) { const lines = Object.entries(globals).map(([key, value]) => `${key} = ${formatTomlValue(value)}`); for (const proxy of proxies) { lines.push(""); lines.push(renderProxy(proxy.name, proxy)); } return `${lines.join("\n").trimEnd()}\n`; } function renderProxy(name, values) { return `[[proxies]] name = ${formatTomlValue(name)} type = ${formatTomlValue(values.type || "tcp")} localIP = ${formatTomlValue(values.local_ip || "127.0.0.1")} localPort = ${formatTomlValue(values.local_port)} remotePort = ${formatTomlValue(values.remote_port)} `; } function formatTomlValue(value) { if (typeof value === "number" || typeof value === "boolean") { return String(value); } return JSON.stringify(String(value)); } function writeFile(filePath, content, runner) { const dir = path.dirname(filePath); if (runner.dryRun) { console.log(`[dry-run] mkdir -p ${dir}`); console.log(`[dry-run] write ${filePath}`); console.log(content.trimEnd()); return; } if (canWriteDirectly(filePath)) { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, content); console.log(`Wrote ${filePath}`); return; } runner.run("sudo", ["mkdir", "-p", dir]); const result = spawnSync("sudo", ["tee", filePath], { input: content, stdio: ["pipe", "ignore", "inherit"], encoding: "utf8", }); if (result.status !== 0) { throw new Error(`Failed to write ${filePath}`); } console.log(`Wrote ${filePath}`); } function canWriteDirectly(filePath) { if (process.getuid && process.getuid() === 0) { return true; } const dir = path.dirname(filePath); if (fs.existsSync(filePath)) { return isWritable(filePath); } return fs.existsSync(dir) && isWritable(dir); } function isWritable(target) { try { fs.accessSync(target, fs.constants.W_OK); return true; } catch { return false; } } function createRunner(flags) { const dryRun = Boolean(flags.dryRun || flags["dry-run"]); return { dryRun, run(command, args) { if (dryRun) { console.log(`[dry-run] ${formatCommand(command, args)}`); return; } const result = spawnSync(command, args, { stdio: "inherit" }); if (result.error) { throw result.error; } if (result.status !== 0) { throw new Error(`Command failed: ${formatCommand(command, args)}`); } }, }; } function formatCommand(command, args) { return [command, ...args].map(shellQuote).join(" "); } function shellQuote(value) { if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) { return value; } return `'${String(value).replace(/'/g, "'\\''")}'`; } function parseArgs(argv) { const flags = {}; const positionals = []; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--") { positionals.push(...argv.slice(index + 1)); break; } if (arg.startsWith("--")) { const eqIndex = arg.indexOf("="); const rawKey = eqIndex === -1 ? arg.slice(2) : arg.slice(2, eqIndex); const key = rawKey.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); const value = eqIndex === -1 ? argv[index + 1] : arg.slice(eqIndex + 1); if (eqIndex === -1 && value && !value.startsWith("-")) { flags[key] = value; flags[toCamel(key)] = value; index += 1; } else { flags[key] = true; flags[toCamel(key)] = true; } continue; } if (arg === "-h") { flags.help = true; continue; } positionals.push(arg); } return { flags, positionals }; } function toCamel(key) { return key.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); } function getInstallDir(flags, version = DEFAULT_FRP_VERSION, arch = DEFAULT_FRP_ARCH) { return stringFlag(flags, "install-dir", `/opt/frp/frp_${version}_linux_${arch}`); } function getConfigPath(flags) { return stringFlag(flags, "config", path.join(getInstallDir(flags), "frpc.toml")); } function stringFlag(flags, key, fallback = undefined) { const value = flags[key] ?? flags[toCamel(key)] ?? fallback; return value === undefined ? undefined : String(value); } function numberFlag(flags, key, fallback = undefined) { const value = flags[key] ?? flags[toCamel(key)] ?? fallback; if (value === undefined) { return undefined; } const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { throw new Error(`--${key} must be an integer between 1 and 65535.`); } return parsed; } function booleanFlag(flags, key, fallback) { const value = flags[key] ?? flags[toCamel(key)] ?? fallback; if (typeof value === "boolean") { return value; } if (String(value).toLowerCase() === "true") { return true; } if (String(value).toLowerCase() === "false") { return false; } throw new Error(`--${key} must be true or false.`); } function assertProxyName(name) { if (!/^[A-Za-z0-9_.-]+$/.test(name)) { throw new Error("Proxy name may only contain letters, numbers, underscore, dash and dot."); } } function trimTrailingBlankLines(lines) { const next = [...lines]; while (next.length && next[next.length - 1].trim() === "") { next.pop(); } return next; } function printHelp() { console.log(`Usage: server-config zsh install [--dry-run] server-config ssh install [--dry-run] server-config bootstrap --token [--dry-run] server-config frp install --token [options] server-config frp init --token [options] server-config frp add --local-port --remote-port [options] server-config frp remove [--restart] server-config frp list [--config ] server-config frp restart [--service-name frpc] Common options: --dry-run Print commands and file changes without executing them. frp options: --version Default: ${DEFAULT_FRP_VERSION} --arch Default: ${DEFAULT_FRP_ARCH} --install-dir Default: /opt/frp/frp__linux_ --config Default: /frpc.toml --service-name Default: ${DEFAULT_SERVICE_NAME} --server-addr Default: ${DEFAULT_FRP_SERVER_ADDR} --server-port Default: ${DEFAULT_FRP_SERVER_PORT} --token Or set FRP_TOKEN. --tls-enable Default: false --tcp-mux Default: true --restart Restart frpc after add/remove. Examples: server-config frp install --token "$FRP_TOKEN" server-config frp add ssh --local-port 22 --remote-port 17227 --restart server-config frp add mysql --local-port 3306 --remote-port 33061 --restart `); } function fail(message) { console.error(message); process.exitCode = 1; } module.exports = { main, parseArgs, parseFrpConfig, renderParsedFrpConfig, };