This commit is contained in:
2026-04-15 13:58:22 +08:00
commit de93193279
7 changed files with 835 additions and 0 deletions

0
.codex Normal file
View File

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
npm-debug.log*
.DS_Store

142
README.md Normal file
View File

@@ -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 安装。

3
bin/server-config.js Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
require("../src/cli").main(process.argv.slice(2));

17
package.json Normal file
View File

@@ -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"
}

631
src/cli.js Normal file
View File

@@ -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 <value> 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 <frp-token> [--dry-run]
server-config frp install --token <frp-token> [options]
server-config frp init --token <frp-token> [options]
server-config frp add <name> --local-port <port> --remote-port <port> [options]
server-config frp remove <name> [--restart]
server-config frp list [--config <path>]
server-config frp restart [--service-name frpc]
Common options:
--dry-run Print commands and file changes without executing them.
frp options:
--version <version> Default: ${DEFAULT_FRP_VERSION}
--arch <arch> Default: ${DEFAULT_FRP_ARCH}
--install-dir <path> Default: /opt/frp/frp_<version>_linux_<arch>
--config <path> Default: <install-dir>/frpc.toml
--service-name <name> Default: ${DEFAULT_SERVICE_NAME}
--server-addr <ip> Default: ${DEFAULT_FRP_SERVER_ADDR}
--server-port <port> Default: ${DEFAULT_FRP_SERVER_PORT}
--token <token> Or set FRP_TOKEN.
--tls-enable <true|false> Default: false
--tcp-mux <true|false> 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,
};

39
test/frp-config.test.js Normal file
View File

@@ -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/);
});