update:
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
142
README.md
Normal file
142
README.md
Normal 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
3
bin/server-config.js
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require("../src/cli").main(process.argv.slice(2));
|
||||
17
package.json
Normal file
17
package.json
Normal 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
631
src/cli.js
Normal 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
39
test/frp-config.test.js
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user