背景
NOTE你可以跳过这些废话,直接前往具体实现
这个可爱的小工具是我去年运维的时候vibe写的,今天更新frpc配置的时候挖出来了,正好水一篇博客
至于为什么没有使用素质更高的toml而是ini,是因为我用的那台机器上装的是旧版frpc,而新版也兼容ini,其实就是懒得再写一份
文中名词参考frp官方文档
为什么需要frpm
一般情况下,frpc的使用不会有太大问题,因为一般情况下使用者只会有一台服务器用作frps,同时只有少数几个代理,配置大致如下:
# 云服务器配置[common]server_addr = 1.1.1.1server_port = 7000token = token_to_auth
# 代理1[proxy1]type = tcplocal_ip = 192.168.1.2local_port = 22remote_port = 12345
# 代理2[proxy2]type = tcplocal_ip = 192.168.1.3local_port = 22remote_port = 12346但是假如你同时有多个云服务器做frps,他们的addr、port、token各不相同,同时你还有很多很多代理要接入很多主机,那么你的配置文件会变成云服务器数量个除了云服务器配置部分之外完全相同,而且内容极其繁杂的大坨配置文件
# placeholder[common]server_addr = n.n.n.nserver_port = 7000token = token_n
[proxy1]type = tcplocal_ip = 192.168.1.2local_port = 22remote_port = 12345...很显然,这是非常蠢的,把配置文件拆开是刚需
实现思路
配置文件要拆是很好拆的,无非把云服务器的配置部分拆成一个文件,把代理按照特定的范围比如主机拆分成多个独立文件,问题是怎么拼起来
frpc:搞半天还要自己拼.jpg
环境变量渲染配置
frpc作为用go写的程序,在读配置的时候能使用go自带的配置渲染
同时这个配置渲染使用的变量可以是环境变量,这就给了我们集成的自由度——我们只需把云服务器配置以环境变量的形式导入,在启动服务的时候让frpc自己渲染模版就行了
代理配置原子化与systemd的局限性
既然云服务器的配置都拆出来了,何不把代理的也拆一拆,我个人喜欢拆到主机级
不过这样一来,启动服务的时候就需要两个参数:云服务器配置id和代理配置id,也就是说需要切参数了,因为systemd模版单元只能接受一个参数,于是不得不引入一个包装脚本,原因是显而易见的:ExecStart对多命令的支持几乎是没有,也不鼓励你这么做
于是使用了常见的包装器脚本切参数方案,使用:作为分隔符,他大概像这样工作:
systemctl start frpc@server:proxy# call: wrapper.sh server:proxy# 包装器将 "server:proxy" 以 : 为分隔符切成 server 和 proxy# 随后包装器导入 server 对应的环境变量并使用 proxy 对应的配置文件启动 frpc# frpc 渲染配置并运行满地鸡毛
诶你这个傻呗脚本给我的frpc切成大份了,我本来只需要systemctl start frpc或者systemctl start frpc@server就能启动服务,现在你把含有x个云服务器和y个代理的配置拆成了一共x*y个systemctl命令,多的那一大堆命令你帮我敲吗
手敲x*y个命令肯定是不现实的,所以我们要把systemctl再包一层,做成一个简单方便的统一管理入口
我们一开始切配置的大前提是,这x个云服务器配置和y个代理配置不强相关,那么我们写个脚本遍历我们想要的云服务器配置和代理配置不就行了
于是有frpm:
❯ frpm --helpfrpm - FRP 服务管理工具 v1.0.0
用法: frpm [选项] <命令>
命令: enable 启用并启动服务 disable 禁用并停止服务 start 启动服务 stop 停止服务 reload 重载服务配置 status 显示服务状态(表格视图) help 显示此帮助信息
选项: -s, --server <servername> 包含指定的服务器(可多次使用) -c, --config <configname> 包含指定的配置(可多次使用) -ns, --no-server <servername> 排除指定的服务器(可多次使用) -nc, --no-config <configname> 排除指定的配置(可多次使用) -h, --help 显示帮助信息
示例: frpm enable # 启用所有服务组合 frpm status -s server1 # 只显示server1的状态 frpm stop -c config1 -ns server2 # 停止所有使用config1但不包括server2的服务于是frpc配置管理的最后一块拼图被补齐
顺便一提这个status虽然也是vibe的,但是是我寻思的,两个不强相关的量作为两个轴织出一个阵列真那啥吧

具体实现
这部分纯纯把代码贴上来水字数的,复制拿去用就行
配置部分
代理配置
头部是公式化的渲染模版
[common]server_addr = {{ .Envs.FRP_SERVER_ADDR }}server_port = {{ .Envs.FRP_SERVER_PORT }}token = {{ .Envs.FRP_TOKEN }}
[proxy-n]type = tcplocal_ip = 192.168.1.nlocal_port = 22remote_port = 12345云服务器配置
写成环境变量方便导入
export FRP_SERVER_ADDR="n.server.com"export FRP_SERVER_PORT="7000"export FRP_TOKEN="token_n"服务部分
systemd单元
[Unit]Description=FRP client for instance %iAfter=network.target
[Service]Type=simpleUser=rootExecStart=/usr/local/bin/frpc-wrapper.sh %i startExecReload=/usr/local/bin/frpc-wrapper.sh %i reloadRestart=on-failureRestartSec=5s
[Install]WantedBy=multi-user.target包装脚本
在这里渲染配置
#!/bin/bashset -euo pipefail
i="$1"action="${2:-start}"
log() { echo "[wrapper] $*"}
if [[ "$i" != *:* ]]; then log "Error: instance name must be in format servername:confname" exit 1fi
servername="${i%%:*}"confname="${i#*:}"
log "resolved servername:$servername confname:$confname"
envfile="/etc/frp/servers/${servername}.env"conffile="/etc/frp/${confname}.frpc.ini"
if [ ! -f "$envfile" ]; then log "Error: environment file $envfile not found" exit 1fiif [ ! -f "$conffile" ]; then log "Error: config file $conffile not found" exit 1fi
log "now sourcing $envfile"source "$envfile"
log "now starting frpc with config $conffile"case "$action" in start) exec /usr/sbin/frpc -c "$conffile" ;; reload) exec /usr/sbin/frpc reload -c "$conffile" ;; *) log "Error: unknown action $action" exit 1 ;;esac便捷管理脚本
其实这个才是frpm来的)),不过没有其余部分这玩意也没用,所以无所谓了
#!/bin/bash
# frpm - FRP 服务管理工具VERSION="1.0.0"
# 启用扩展模式匹配和nullglobshopt -s extglob nullglob
# 全局变量declare -a SERVER_NAMES CONFIG_NAMESdeclare -a SELECTED_SERVERS SELECTED_CONFIGSdeclare -a EXCLUDED_SERVERS EXCLUDED_CONFIGS
# 显示帮助信息show_help() { cat << EOFfrpm - FRP 服务管理工具 v${VERSION}
用法: frpm [选项] <命令>
命令: enable 启用并启动服务 disable 禁用并停止服务 start 启动服务 stop 停止服务 reload 重载服务配置 status 显示服务状态(表格视图) help 显示此帮助信息
选项: -s, --server <servername> 包含指定的服务器(可多次使用) -c, --config <configname> 包含指定的配置(可多次使用) -ns, --no-server <servername> 排除指定的服务器(可多次使用) -nc, --no-config <configname> 排除指定的配置(可多次使用) -h, --help 显示帮助信息
示例: frpm enable # 启用所有服务组合 frpm status -s server1 # 只显示server1的状态 frpm stop -c config1 -ns server2 # 停止所有使用config1但不包括server2的服务EOF}
# 显示错误信息并退出die() { echo "错误: $1" >&2 exit 1}
# 从文件系统读取可用的服务器和配置read_available_items() { # 读取服务器列表 SERVER_NAMES=() for server_file in /etc/frp/servers/*.env; do servername=$(basename "$server_file" .env) SERVER_NAMES+=("$servername") done
# 读取配置列表 CONFIG_NAMES=() for config_file in /etc/frp/*.frpc.ini; do confname=$(basename "$config_file" .frpc.ini) CONFIG_NAMES+=("$confname") done
if [ ${#SERVER_NAMES[@]} -eq 0 ]; then die "在 /etc/frp/servers/ 目录下未找到任何 .env 文件" fi
if [ ${#CONFIG_NAMES[@]} -eq 0 ]; then die "在 /etc/frp/ 目录下未找到任何 .frpc.ini 文件" fi}
# 应用筛选条件apply_filters() { SELECTED_SERVERS=("${SERVER_NAMES[@]}") SELECTED_CONFIGS=("${CONFIG_NAMES[@]}")
# 应用包含筛选 if [ ${#INCLUDE_SERVERS[@]} -gt 0 ]; then SELECTED_SERVERS=() for server in "${SERVER_NAMES[@]}"; do for included in "${INCLUDE_SERVERS[@]}"; do if [[ "$server" == "$included" ]]; then SELECTED_SERVERS+=("$server") break fi done done fi
if [ ${#INCLUDE_CONFIGS[@]} -gt 0 ]; then SELECTED_CONFIGS=() for config in "${CONFIG_NAMES[@]}"; do for included in "${INCLUDE_CONFIGS[@]}"; do if [[ "$config" == "$included" ]]; then SELECTED_CONFIGS+=("$config") break fi done done fi
# 应用排除筛选 if [ ${#EXCLUDE_SERVERS[@]} -gt 0 ]; then TEMP_SERVERS=() for server in "${SELECTED_SERVERS[@]}"; do excluded=false for excluded_server in "${EXCLUDE_SERVERS[@]}"; do if [[ "$server" == "$excluded_server" ]]; then excluded=true break fi done if ! $excluded; then TEMP_SERVERS+=("$server") fi done SELECTED_SERVERS=("${TEMP_SERVERS[@]}") fi
if [ ${#EXCLUDE_CONFIGS[@]} -gt 0 ]; then TEMP_CONFIGS=() for config in "${SELECTED_CONFIGS[@]}"; do excluded=false for excluded_config in "${EXCLUDE_CONFIGS[@]}"; do if [[ "$config" == "$excluded_config" ]]; then excluded=true break fi done if ! $excluded; then TEMP_CONFIGS+=("$config") fi done SELECTED_CONFIGS=("${TEMP_CONFIGS[@]}") fi
if [ ${#SELECTED_SERVERS[@]} -eq 0 ]; then die "筛选后没有可用的服务器" fi
if [ ${#SELECTED_CONFIGS[@]} -eq 0 ]; then die "筛选后没有可用的配置" fi}
# 执行systemctl命令execute_systemctl() { local command="$1" local service_name local -a failed_services=()
for server in "${SELECTED_SERVERS[@]}"; do for config in "${SELECTED_CONFIGS[@]}"; do service_name="frpc-new@${server}:${config}" echo "执行: systemctl $command $service_name"
if ! systemctl "$command" "$service_name"; then failed_services+=("$service_name") fi done done
if [ ${#failed_services[@]} -gt 0 ]; then echo "警告: 以下服务执行失败:" >&2 printf ' %s\n' "${failed_services[@]}" >&2 return 1 fi
return 0}
# 显示服务状态表格show_status_table() { # 收集所有服务状态 declare -A status_map local max_server_len=0 local max_config_len=0
# 确定最大长度用于表格格式化 for server in "${SELECTED_SERVERS[@]}"; do if [ ${#server} -gt $max_server_len ]; then max_server_len=${#server} fi done
for config in "${SELECTED_CONFIGS[@]}"; do if [ ${#config} -gt $max_config_len ]; then max_config_len=${#config} fi done
# 获取所有服务的状态 for server in "${SELECTED_SERVERS[@]}"; do for config in "${SELECTED_CONFIGS[@]}"; do service_name="frpc-new@${server}:${config}" status=$(systemctl is-active "$service_name" 2>/dev/null) status="${status:-unknown}" status_map["${server}:${config}"]=$status done done
# 打印表头 printf "%${max_config_len}s" "" for server in "${SELECTED_SERVERS[@]}"; do printf " %-${max_server_len}s" "$server" done echo
# 打印表格内容 for config in "${SELECTED_CONFIGS[@]}"; do printf "%-${max_config_len}s" "$config" for server in "${SELECTED_SERVERS[@]}"; do status=${status_map["${server}:${config}"]} # 根据状态着色 case "$status" in active) color="\033[32m" ;; # 绿色 inactive) color="\033[31m" ;; # 红色 activating) color="\033[33m" ;; # 黄色 deactivating) color="\033[33m" ;; # 黄色 failed) color="\033[31m" ;; # 红色 *) color="\033[90m" ;; # 灰色 esac printf " ${color}%-${max_server_len}s\033[0m" "$status" done echo done
# 图例 echo echo "图例:" echo -e " \033[32mactive\033[0m - 服务正在运行" echo -e " \033[31minactive\033[0m - 服务未运行" echo -e " \033[33mactivating/deactivating\033[0m - 服务正在启动/停止" echo -e " \033[31mfailed\033[0m - 服务启动失败" echo -e " \033[90munknown\033[0m - 服务状态未知或服务不存在"}
# 主函数main() { local command="" declare -a INCLUDE_SERVERS INCLUDE_CONFIGS declare -a EXCLUDE_SERVERS EXCLUDE_CONFIGS
# 解析命令行参数 while [[ $# -gt 0 ]]; do case "$1" in enable|disable|start|stop|reload|status|help) command="$1" shift ;; -s|--server) if [ -z "$2" ]; then die "选项 $1 需要一个参数" fi INCLUDE_SERVERS+=("$2") shift 2 ;; -c|--config) if [ -z "$2" ]; then die "选项 $1 需要一个参数" fi INCLUDE_CONFIGS+=("$2") shift 2 ;; -ns|--no-server) if [ -z "$2" ]; then die "选项 $1 需要一个参数" fi EXCLUDE_SERVERS+=("$2") shift 2 ;; -nc|--no-config) if [ -z "$2" ]; then die "选项 $1 需要一个参数" fi EXCLUDE_CONFIGS+=("$2") shift 2 ;; -h|--help) show_help exit 0 ;; -*) die "未知选项: $1" ;; *) die "未知参数: $1" ;; esac done
# 如果没有指定命令,显示帮助 if [ -z "$command" ]; then show_help exit 1 fi
# 处理help命令 if [ "$command" = "help" ]; then show_help exit 0 fi
# 读取可用项并应用筛选 read_available_items apply_filters
# 执行请求的命令 case "$command" in enable|disable|start|stop|reload) execute_systemctl "$command" ;; status) show_status_table ;; *) die "未知命令: $command" ;; esac}
# 运行主函数main "$@"