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