From de93193279398fbc5d4199966839008320c94fb4 Mon Sep 17 00:00:00 2001 From: hanruo <552455797@qq.com> Date: Wed, 15 Apr 2026 13:58:22 +0800 Subject: [PATCH] update: --- .codex | 0 .gitignore | 3 + README.md | 142 +++++++++ bin/server-config.js | 3 + package.json | 17 ++ src/cli.js | 631 ++++++++++++++++++++++++++++++++++++++++ test/frp-config.test.js | 39 +++ 7 files changed, 835 insertions(+) create mode 100644 .codex create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/server-config.js create mode 100644 package.json create mode 100644 src/cli.js create mode 100644 test/frp-config.test.js diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1a4fa8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +npm-debug.log* +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..1697978 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# server-config-cli + +一个用于初始化服务器环境的 Node.js CLI,覆盖 zsh、openssh-server 和 frp client 配置。frp 支持通过命令追加端口穿透配置。 + +## 安装和本地使用 + +```bash +npm link +server-config --help +``` + +也可以不 link,直接在项目目录执行: + +```bash +node bin/server-config.js --help +``` + +建议先用 `--dry-run` 看要执行的命令: + +```bash +server-config zsh install --dry-run +server-config ssh install --dry-run +server-config frp install --token "$FRP_TOKEN" --dry-run +``` + +## zsh + +```bash +server-config zsh install +``` + +会执行: + +- `apt update && apt upgrade` +- 安装 `zsh git curl` +- 切换当前用户 shell 到 `/bin/zsh` +- 安装 oh-my-zsh +- 安装 `zsh-autosuggestions` 和 `zsh-syntax-highlighting` +- 更新 `~/.zshrc` 的插件列表为 `git zsh-autosuggestions zsh-syntax-highlighting` + +安装完成后重新登录 shell,或手动执行: + +```bash +source ~/.zshrc +``` + +## ssh + +```bash +server-config ssh install +``` + +会安装 `openssh-server`,并执行 `systemctl enable --now ssh`。 + +## frp + +不要把 frp token 写死到仓库。使用环境变量或命令参数传入: + +```bash +export FRP_TOKEN="your-token" +server-config frp install --token "$FRP_TOKEN" +``` + +默认配置: + +- `server_addr = "81.70.134.9"` +- `server_port = 15443` +- `tls_enable = false` +- `tcp_mux = true` +- `log_file = "/var/log/frpc.log"` +- `log_level = "info"` +- `log_max_days = 7` +- frp 版本:`0.58.1` +- 安装目录:`/opt/frp/frp_0.58.1_linux_amd64` +- systemd 服务:`frpc` + +只初始化配置文件: + +```bash +server-config frp init --token "$FRP_TOKEN" +``` + +如果配置文件已经存在,默认不会覆盖。需要覆盖时加: + +```bash +server-config frp init --token "$FRP_TOKEN" --force +``` + +### 增加端口穿透 + +SSH 示例,本机 ssh 监听 `22`,远端暴露 `17227`: + +```bash +server-config frp add ssh --local-port 22 --remote-port 17227 --restart +``` + +MySQL 示例: + +```bash +server-config frp add mysql --local-port 3306 --remote-port 33061 --restart +``` + +如果本地服务不在 `127.0.0.1`,可以指定: + +```bash +server-config frp add web --local-ip 0.0.0.0 --local-port 8080 --remote-port 18080 --restart +``` + +查看当前代理: + +```bash +server-config frp list +``` + +删除代理: + +```bash +server-config frp remove mysql --restart +``` + +重启 frpc: + +```bash +server-config frp restart +``` + +### 自定义路径 + +```bash +server-config frp install \ + --token "$FRP_TOKEN" \ + --install-dir /home/scyk/frp_0.58.1_linux_amd64 \ + --config /home/scyk/frp_0.58.1_linux_amd64/frpc.toml +``` + +## 一键初始化 + +```bash +server-config bootstrap --token "$FRP_TOKEN" +``` + +会依次执行 zsh、ssh 和 frp 安装。 diff --git a/bin/server-config.js b/bin/server-config.js new file mode 100755 index 0000000..d025d15 --- /dev/null +++ b/bin/server-config.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require("../src/cli").main(process.argv.slice(2)); diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e87a67 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "server-config-cli", + "version": "0.1.0", + "description": "Node.js CLI for bootstrapping server shell, ssh and frp client configuration.", + "bin": { + "server-config": "bin/server-config.js", + "scc": "bin/server-config.js" + }, + "scripts": { + "check": "node --check bin/server-config.js && node --check src/cli.js && node --check test/frp-config.test.js", + "test": "node --test" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT" +} diff --git a/src/cli.js b/src/cli.js new file mode 100644 index 0000000..6d7864c --- /dev/null +++ b/src/cli.js @@ -0,0 +1,631 @@ +"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"; + +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", "-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)"', + ]); + + 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); + + if (fs.existsSync(configPath) && !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 config = renderFrpConfig({ + server_addr: stringFlag(flags, "server-addr", DEFAULT_FRP_SERVER_ADDR), + server_port: numberFlag(flags, "server-port", DEFAULT_FRP_SERVER_PORT), + token, + tls_enable: booleanFlag(flags, "tls-enable", false), + tcp_mux: booleanFlag(flags, "tcp-mux", true), + log_file: stringFlag(flags, "log-file", "/var/log/frpc.log"), + log_level: stringFlag(flags, "log-level", "info"), + log_max_days: numberFlag(flags, "log-max-days", 7), + }, []); + + 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 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(line); + continue; + } + + const keyValue = parseTomlKeyValue(line); + if (keyValue) { + current.values[keyValue.key] = keyValue.value; + } + } + + return { globals: trimTrailingBlankLines(globals), sections }; +} + +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 `[${name}] +type = ${formatTomlValue(values.type || "tcp")} +local_ip = ${formatTomlValue(values.local_ip || "127.0.0.1")} +local_port = ${formatTomlValue(values.local_port)} +remote_port = ${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, +}; diff --git a/test/frp-config.test.js b/test/frp-config.test.js new file mode 100644 index 0000000..be04608 --- /dev/null +++ b/test/frp-config.test.js @@ -0,0 +1,39 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { parseFrpConfig, renderParsedFrpConfig } = require("../src/cli"); + +test("parses and renders frp proxy sections", () => { + const parsed = parseFrpConfig(`server_addr = "81.70.134.9" +server_port = 15443 +token = "secret" + +[ssh] +type = "tcp" +local_ip = "127.0.0.1" +local_port = 22 +remote_port = 17227 +`); + + assert.equal(parsed.sections.get("ssh").values.local_port, 22); + assert.equal(parsed.sections.get("ssh").values.remote_port, 17227); + + parsed.sections.set("mysql", { + name: "mysql", + values: { + type: "tcp", + local_ip: "127.0.0.1", + local_port: 3306, + remote_port: 33061, + }, + }); + + const rendered = renderParsedFrpConfig(parsed); + + assert.match(rendered, /\[ssh]/); + assert.match(rendered, /local_port = 22/); + assert.match(rendered, /\[mysql]/); + assert.match(rendered, /remote_port = 33061/); +});