2026-09-23 09:40:59 +08:00
package agent
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/rand"
2026-09-24 17:06:34 +08:00
"crypto/sha256"
2026-09-23 09:40:59 +08:00
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
2026-09-24 10:15:11 +08:00
2026-09-24 19:03:05 +08:00
"github.com/fonu/fonu/internal/fsutil"
2026-09-24 10:15:11 +08:00
"github.com/fonu/fonu/internal/sysinfo"
2026-09-23 09:40:59 +08:00
)
// AgentVersion 随每次 agent 行为变更手动递增;status/ping 与安装脚本都会回报,用于确认 VPS 上跑的是哪个版本。
// 0.5.0:目录判定改用 nginx -T 自省(dump 完整生效配置,按 "# configuration file" 标记
// 找出真正被加载的文件所在目录,零猜测);include 链递归与 FONU_AGENT_NGINX_CONF_DIR 保留为回退。
// 0.9.0: Nginx 可用性显式探测(status 暴露 nginx_ok/nginx_missing)、部署前预检、一键安装 Nginx 端点。
// 0.9.1: status 补充 nginx_running / nginx_version,供页面展示 Nginx 状态卡片。
2026-09-23 11:22:11 +08:00
// 0.9.2: Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
2026-09-24 10:15:11 +08:00
// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。
2026-09-24 10:49:28 +08:00
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
2026-09-24 17:06:34 +08:00
// 0.12.0: frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
// nginx 配置注释(改为只在 PUT 响应里回一次)。
2026-09-24 19:03:05 +08:00
// 0.12.1:frps 启停失败如实回报(不再无条件写「已启动」);HTTP 服务补 ReadTimeout/IdleTimeout。
const AgentVersion = "0.12.1"
2026-09-23 09:40:59 +08:00
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct {
ListenAddr string
Token string
NginxConfDir string
NginxBinary string
CertsDir string
FrpsConfigPath string
FrpsBinary string
DataDir string
}
// Server 暴露 agent HTTP 服务。
type Server struct {
cfg Config
mu sync . Mutex
2026-09-24 10:15:11 +08:00
// metrics 采样缓存:CPU 占用率需要两次采样,间隔内的重复请求直接复用上次结果
metricsMu sync . Mutex
metricsAt time . Time
metricsSample sysinfo . CPUSample
metricsCached map [ string ] any
2026-09-23 09:40:59 +08:00
// nginx 可用性探测缓存:status 轮询频繁,避免每次都起 nginx -t 进程
nginxStatusMu sync . Mutex
nginxStatusAt time . Time
nginxStatus nginxProbe
}
// nginxProbe Nginx 可用性探测结果。
type nginxProbe struct {
OK bool `json:"ok"` // nginx -t 通过
Missing bool `json:"missing"` // 未找到可执行文件
Running bool `json:"running"` // 有 nginx 进程在运行
Path string `json:"path,omitempty"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// resolveNginxConfDir 预先解析并缓存 vhost 目录:探测结果落盘到 DataDir/nginx_conf_dir,
// 启动时后台执行一次(不阻断监听),路由写入时优先读缓存。
// 返回目录与错误;缓存命中时直接返回。
func ( s * Server ) resolveNginxConfDir () ( string , error ) {
s . mu . Lock ()
cached := strings . TrimSpace ( s . cfg . NginxConfDir )
s . mu . Unlock ()
if cached != "" {
return cached , nil
}
dir , err := detectNginxConfDir ( s . nginxPath ())
if err != nil {
return "" , err
}
s . mu . Lock ()
s . cfg . NginxConfDir = dir
s . mu . Unlock ()
_ = os . MkdirAll ( dir , 0 o755 )
// 结果落盘,下次启动优先复用(避免每次启动都跑 -T)
if s . cfg . DataDir != "" {
_ = os . WriteFile ( filepath . Join ( s . cfg . DataDir , "nginx_conf_dir" ), [] byte ( dir ), 0 o644 )
}
return dir , nil
}
// preloadNginxConfDir 启动后台预解析:成功与否不影响监听,结果进缓存供状态与路由写入使用。
func ( s * Server ) preloadNginxConfDir () {
go func () {
if _ , err := s . resolveNginxConfDir (); err != nil {
log . Printf ( "nginx conf dir 预解析失败(路由写入时会重试): %v" , err )
}
}()
}
var domainPattern = regexp . MustCompile ( `^(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?i:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))+$` )
func NewServer ( cfg Config ) ( * Server , error ) {
if strings . TrimSpace ( cfg . ListenAddr ) == "" {
cfg . ListenAddr = ":7700"
}
if strings . TrimSpace ( cfg . NginxConfDir ) == "" {
// 目录探测失败不阻断启动:agent 照常提供状态接口,路由写入时重试探测(systemd PATH 下可能找不到 nginx,写入前会补全路径)
cfg . NginxConfDir = ""
}
if strings . TrimSpace ( cfg . NginxBinary ) == "" {
cfg . NginxBinary = "nginx"
}
if strings . TrimSpace ( cfg . CertsDir ) == "" {
cfg . CertsDir = "/var/lib/fonu-agent/certs"
}
if strings . TrimSpace ( cfg . FrpsConfigPath ) == "" {
cfg . FrpsConfigPath = "/etc/frp/frps.toml"
}
if strings . TrimSpace ( cfg . FrpsBinary ) == "" {
cfg . FrpsBinary = "/usr/local/bin/frps"
} else if ! filepath . IsAbs ( cfg . FrpsBinary ) {
// 安装器固定把 frps 装到 /usr/local/bin;状态展示与实际安装位置保持一致
cfg . FrpsBinary = "/usr/local/bin/" + filepath . Base ( cfg . FrpsBinary )
}
if strings . TrimSpace ( cfg . DataDir ) == "" {
cfg . DataDir = "/var/lib/fonu-agent"
}
if strings . TrimSpace ( cfg . Token ) == "" {
return nil , errors . New ( "agent token is required" )
}
for _ , dir := range [] string { cfg . NginxConfDir , cfg . CertsDir , cfg . DataDir } {
// NginxConfDir 可能为空(探测延迟到路由写入时重试),跳过而不是报错
if strings . TrimSpace ( dir ) == "" {
continue
}
if err := os . MkdirAll ( dir , 0 o750 ); err != nil {
return nil , fmt . Errorf ( "create agent directory: %w" , err )
}
}
server := & Server { cfg : cfg }
// 启动后台预解析目录(不阻断监听);每次启动都会用最新探测逻辑重新解析并覆盖落盘缓存,
// 历史错误目录的缓存会被自动修正。
server . preloadNginxConfDir ()
return server , nil
}
func ( s * Server ) Handler () http . Handler {
mux := http . NewServeMux ()
mux . HandleFunc ( "GET /api/v1/ping" , s . auth ( s . ping ))
mux . HandleFunc ( "GET /api/v1/status" , s . auth ( s . status ))
mux . HandleFunc ( "GET /api/v1/routes" , s . auth ( s . routes ))
mux . HandleFunc ( "PUT /api/v1/routes" , s . auth ( s . putRoute ))
mux . HandleFunc ( "GET /api/v1/routes/{domain}/conf" , s . auth ( s . routeConf ))
mux . HandleFunc ( "PUT /api/v1/china-cidr" , s . auth ( s . putChinaCIDR ))
mux . HandleFunc ( "POST /api/v1/nginx/install" , s . auth ( s . installNginx ))
mux . HandleFunc ( "DELETE /api/v1/routes/{domain}" , s . auth ( s . deleteRoute ))
mux . HandleFunc ( "PUT /api/v1/frps/config" , s . auth ( s . putFRPSConfig ))
mux . HandleFunc ( "POST /api/v1/frps/install" , s . auth ( s . installFRPS ))
mux . HandleFunc ( "POST /api/v1/frps/action" , s . auth ( s . frpsAction ))
mux . HandleFunc ( "POST /api/v1/certs" , s . auth ( s . putCertificate ))
2026-09-24 10:15:11 +08:00
mux . HandleFunc ( "GET /api/v1/metrics" , s . auth ( s . metrics ))
mux . HandleFunc ( "GET /api/v1/logs/nginx" , s . auth ( s . nginxLogs ))
mux . HandleFunc ( "GET /api/v1/frps/config" , s . auth ( s . frpsConfig ))
2026-09-24 10:49:28 +08:00
mux . HandleFunc ( "GET /api/v1/certs" , s . auth ( s . certs ))
mux . HandleFunc ( "POST /api/v1/nginx/reload" , s . auth ( s . nginxReload ))
2026-09-23 09:40:59 +08:00
return mux
}
func ( s * Server ) ListenAndServe ( ctx context . Context ) error {
2026-09-24 19:03:05 +08:00
server := & http . Server { Addr : s . cfg . ListenAddr , Handler : s . Handler (), ReadHeaderTimeout : 10 * time . Second , ReadTimeout : 60 * time . Second , IdleTimeout : 2 * time . Minute }
2026-09-23 09:40:59 +08:00
go func () {
<- ctx . Done ()
shutdownCtx , cancel := context . WithTimeout ( context . Background (), 5 * time . Second )
defer cancel ()
_ = server . Shutdown ( shutdownCtx )
}()
return server . ListenAndServe ()
}
func ( s * Server ) auth ( next http . HandlerFunc ) http . HandlerFunc {
return func ( w http . ResponseWriter , r * http . Request ) {
if r . Header . Get ( "Authorization" ) != "Bearer " + s . cfg . Token {
writeJSON ( w , http . StatusUnauthorized , map [ string ] string { "error" : "unauthorized" })
return
}
next ( w , r )
}
}
func ( s * Server ) ping ( w http . ResponseWriter , _ * http . Request ) {
writeJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "service" : "fonu-agent" })
}
func ( s * Server ) status ( w http . ResponseWriter , _ * http . Request ) {
ports := map [ string ] bool { "80" : portListening ( 80 ), "443" : portListening ( 443 ), "7000" : portListening ( 7000 ), "8080" : portListening ( 8080 )}
nginxProbe := s . probeNginx ()
frpsActive := exec . Command ( "systemctl" , "is-active" , "--quiet" , "frps" ). Run () == nil
writeJSON ( w , http . StatusOK , map [ string ] any {
"service" : "fonu-agent" ,
"agent_version" : AgentVersion ,
"nginx_binary" : s . cfg . NginxBinary ,
"nginx_conf_dir" : s . cfg . NginxConfDir ,
"nginx_ok" : nginxProbe . OK ,
"nginx_missing" : nginxProbe . Missing ,
"nginx_running" : nginxProbe . Running ,
"nginx_path" : nginxProbe . Path ,
"nginx_version" : nginxProbe . Version ,
"nginx_error" : nginxProbe . Error ,
"frps_binary" : s . cfg . FrpsBinary ,
"frps_config_path" : s . cfg . FrpsConfigPath ,
"ports" : ports ,
"frps_active" : frpsActive ,
"frps_vhost_hint" : "curl -H 'Host: 你的域名' http://127.0.0.1:8080 可测试 frps vhost 是否通" ,
"frps_unit_exec" : strings . TrimSpace ( execCommandOutput ( "systemctl" , "show" , "frps" , "-p" , "ExecStart" , "--value" )),
"frps_enabled" : exec . Command ( "systemctl" , "is-enabled" , "--quiet" , "frps" ). Run () == nil ,
"frps_version" : strings . TrimSpace ( frpsVersion ( s . cfg . FrpsBinary )),
"frps_log_tail" : strings . TrimSpace ( journalTail ( "frps" , 6 )),
"dashboard_addr" : strings . TrimSpace ( dashboardAddr ( s . cfg . FrpsConfigPath )),
"routes" : s . routeNames (),
"checked_at" : time . Now (). UTC (). Format ( time . RFC3339 ),
})
}
type frpsInstallRequest struct {
Version string `json:"version"`
Proxy string `json:"proxy,omitempty"`
}
2026-09-24 17:06:34 +08:00
// frps 安装包只允许从官方 release 下载,或经这几个加速前缀。白名单与 internal/frp/runtime.go 的
// downloadProxyOptions 同源(agent 不依赖应用侧包,这里保留一份副本,改一处需同步另一处)。
var frpsDownloadProxies = map [ string ] struct {}{
"" : {},
"https://gh-proxy.org/" : {},
"https://v4.gh-proxy.org/" : {},
"https://cdn.gh-proxy.org/" : {},
}
const frpsReleaseBaseURL = "https://github.com/fatedier/frp/releases/download/"
2026-09-23 09:40:59 +08:00
// installFRPS 下载指定版本的 frp release、安装 frps 二进制并注册 systemd 服务(幂等,重复调用=升级)。
func ( s * Server ) installFRPS ( w http . ResponseWriter , r * http . Request ) {
var req frpsInstallRequest
if err := json . NewDecoder ( http . MaxBytesReader ( w , r . Body , 4 << 10 )). Decode ( & req ); err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid JSON" })
return
}
version := strings . TrimPrefix ( strings . TrimSpace ( req . Version ), "v" )
if matched , _ := regexp . MatchString ( `^\d+\.\d+\.\d+$` , version ); ! matched {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "version 需形如 0.71.0" })
return
}
arch := map [ string ] string { "amd64" : "amd64" , "arm64" : "arm64" }[ runtime . GOARCH ]
if arch == "" {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "unsupported agent arch: " + runtime . GOARCH })
return
}
2026-09-24 17:06:34 +08:00
proxy := strings . TrimSpace ( req . Proxy )
if _ , ok := frpsDownloadProxies [ proxy ]; ! ok {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "不支持的下载加速代理" })
return
2026-09-23 09:40:59 +08:00
}
2026-09-24 17:06:34 +08:00
asset := fmt . Sprintf ( "frp_%s_linux_%s.tar.gz" , version , arch )
releaseURL := proxy + frpsReleaseBaseURL + "v" + version + "/"
downloadURL := releaseURL + asset
2026-09-23 09:40:59 +08:00
workDir := filepath . Join ( s . cfg . DataDir , "tmp-frps-" + version )
_ = os . RemoveAll ( workDir )
defer os . RemoveAll ( workDir )
if err := os . MkdirAll ( workDir , 0 o750 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
2026-09-24 17:06:34 +08:00
// 先取官方校验文件,再下载安装包并按 SHA-256 校验:避免装上来路不明的 frps 可执行文件
wantSum , err := fetchChecksumFor ( releaseURL + "frp_" + version + "_sha256sums.txt" , asset )
if err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "获取 frps 官方校验信息失败: " + err . Error ()})
return
}
2026-09-23 09:40:59 +08:00
tarball := filepath . Join ( workDir , "frp.tar.gz" )
if err := downloadToFile ( downloadURL , tarball ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "下载 frps 失败: " + err . Error ()})
return
}
2026-09-24 17:06:34 +08:00
gotSum , err := fileSHA256 ( tarball )
if err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "校验 frps 安装包失败: " + err . Error ()})
return
}
if ! strings . EqualFold ( gotSum , wantSum ) {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "frps 安装包 SHA-256 校验失败" })
return
}
2026-09-23 09:40:59 +08:00
installPath := s . cfg . FrpsBinary
if ! filepath . IsAbs ( installPath ) {
installPath = "/usr/local/bin/frps"
}
if err := extractFile ( tarball , "frp_" + version + "_linux_" + arch + "/frps" , installPath , 0 o755 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "解压 frps 失败: " + err . Error ()})
return
}
2026-09-24 17:06:34 +08:00
notes := [] string { "frps 二进制已安装: " + installPath , "安装包 SHA-256 校验通过" }
2026-09-23 09:40:59 +08:00
unitPath := "/etc/systemd/system/frps.service"
writeUnit := false
if _ , err := os . Stat ( unitPath ); errors . Is ( err , os . ErrNotExist ) {
writeUnit = true
notes = append ( notes , "已创建 systemd 单位 frps.service" )
} else if existing , err := os . ReadFile ( unitPath ); err == nil {
// 旧 unit(如 frps-admin 面板遗留)的 ExecStart 可能指向已不存在的二进制;检测到失效路径时覆写接管
for _ , line := range strings . Split ( string ( existing ), "\n" ) {
if execPath , found := strings . CutPrefix ( strings . TrimSpace ( line ), "ExecStart=" ); found {
ref := strings . Fields ( execPath )
if len ( ref ) > 0 {
if _ , statErr := os . Stat ( ref [ 0 ]); statErr != nil {
writeUnit = true
notes = append ( notes , fmt . Sprintf ( "检测到现有 frps.service 的 ExecStart 指向不存在的 %s,已覆写接管" , ref [ 0 ]))
}
}
break
}
}
}
if writeUnit {
unit := fmt . Sprintf ( "[Unit]\nDescription=frps service (managed by fonu-agent)\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=%s -c %s\nRestart=on-failure\nRestartSec=3\n\n[Install]\nWantedBy=multi-user.target\n" , installPath , s . cfg . FrpsConfigPath )
if err := atomicWrite ( unitPath , [] byte ( unit ), 0 o644 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "写入 frps.service 失败: " + err . Error ()})
return
}
_ = exec . Command ( "systemctl" , "daemon-reload" ). Run ()
}
// nohup 手工跑的 frps 让位给 systemd;已在 systemd 下运行则重启以加载新二进制
2026-09-24 19:03:05 +08:00
active := exec . Command ( "systemctl" , "is-active" , "--quiet" , "frps" ). Run () == nil
if ! active && exec . Command ( "pgrep" , "-x" , "frps" ). Run () == nil {
_ = exec . Command ( "pkill" , "-x" , "frps" ). Run ()
time . Sleep ( time . Second )
notes = append ( notes , "已停止 nohup 方式运行的旧 frps 进程" )
2026-09-23 09:40:59 +08:00
}
2026-09-24 19:03:05 +08:00
args := [] string { "restart" , "frps" }
label := "已重启以加载新版本"
if ! active {
args = [] string { "enable" , "--now" , "frps" }
label = "已启动"
}
// 启动失败必须如实回报:之前忽略 err 后无条件写「已启动」,运维会以为服务在跑
if out , err := exec . Command ( "systemctl" , args ... ). CombinedOutput (); err != nil {
writeJSON ( w , http . StatusOK , map [ string ] any {
"ok" : false , "version" : version , "binary" : installPath , "notes" : notes ,
"error" : fmt . Sprintf ( "frps 二进制已安装,但 systemctl %s 失败:%v: %s" , strings . Join ( args , " " ), err , tailText ( string ( out ), 400 )),
})
return
}
notes = append ( notes , "frps.service " + label )
2026-09-23 09:40:59 +08:00
writeJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "version" : version , "binary" : installPath , "notes" : notes })
}
type frpsActionRequest struct {
Action string `json:"action"` // start | stop | restart
}
// frpsAction 对 VPS 本机 frps 服务执行启停(仅白名单动作,经 systemctl)。
func ( s * Server ) frpsAction ( w http . ResponseWriter , r * http . Request ) {
var req frpsActionRequest
if err := json . NewDecoder ( http . MaxBytesReader ( w , r . Body , 1 << 10 )). Decode ( & req ); err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid JSON" })
return
}
var command * exec . Cmd
switch req . Action {
case "start" :
command = exec . Command ( "systemctl" , "start" , "frps" )
case "stop" :
command = exec . Command ( "systemctl" , "stop" , "frps" )
case "restart" :
command = exec . Command ( "systemctl" , "restart" , "frps" )
default :
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "action 需为 start/stop/restart" })
return
}
if output , err := command . CombinedOutput (); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : fmt . Sprintf ( "systemctl %s frps 失败: %s" , req . Action , strings . TrimSpace ( string ( output )))})
return
}
time . Sleep ( 800 * time . Millisecond ) // 给 frps 启动留出窗口,失败时才能在 is-active/journal 里看到
active := exec . Command ( "systemctl" , "is-active" , "--quiet" , "frps" ). Run () == nil
result := map [ string ] any { "ok" : true , "action" : req . Action , "frps_active" : active }
if ! active {
// 启动失败时把 frps 的失败原因带回去,而不是只说“启动成功”
journal , _ := exec . Command ( "journalctl" , "-u" , "frps" , "-n" , "8" , "--no-pager" ). CombinedOutput ()
result [ "ok" ] = false
result [ "error" ] = fmt . Sprintf ( "systemctl %s 已执行但 frps 未进入运行状态,最近日志:\n%s" , req . Action , strings . TrimSpace ( string ( journal )))
}
writeJSON ( w , http . StatusOK , result )
}
func downloadToFile ( url , path string ) error {
client := & http . Client { Timeout : 10 * time . Minute }
resp , err := client . Get ( url )
if err != nil {
return err
}
defer resp . Body . Close ()
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
return fmt . Errorf ( "HTTP %d" , resp . StatusCode )
}
out , err := os . Create ( path )
if err != nil {
return err
}
defer out . Close ()
_ , err = io . Copy ( out , io . LimitReader ( resp . Body , 256 << 20 ))
return err
}
2026-09-24 17:06:34 +08:00
// fetchChecksumFor 取官方 sha256sums.txt 中 asset 对应的校验值(格式:<sha256> <文件名>)。
func fetchChecksumFor ( url , asset string ) ( string , error ) {
client := & http . Client { Timeout : 30 * time . Second }
resp , err := client . Get ( url )
if err != nil {
return "" , err
}
defer resp . Body . Close ()
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
return "" , fmt . Errorf ( "HTTP %d" , resp . StatusCode )
}
data , err := io . ReadAll ( io . LimitReader ( resp . Body , 1 << 20 ))
if err != nil {
return "" , err
}
for _ , line := range strings . Split ( string ( data ), "\n" ) {
fields := strings . Fields ( line )
if len ( fields ) >= 2 && strings . TrimPrefix ( fields [ 1 ], "*" ) == asset {
return fields [ 0 ], nil
}
}
return "" , fmt . Errorf ( "校验文件中未找到 %s" , asset )
}
func fileSHA256 ( path string ) ( string , error ) {
file , err := os . Open ( path )
if err != nil {
return "" , err
}
defer file . Close ()
hash := sha256 . New ()
if _ , err := io . Copy ( hash , file ); err != nil {
return "" , err
}
return hex . EncodeToString ( hash . Sum ( nil )), nil
}
2026-09-23 09:40:59 +08:00
// extractFile 从 tar.gz 中取出 name 对应的单个文件写入 dest。
func extractFile ( tarball , name , dest string , mode os . FileMode ) error {
file , err := os . Open ( tarball )
if err != nil {
return err
}
defer file . Close ()
gz , err := gzip . NewReader ( file )
if err != nil {
return err
}
defer gz . Close ()
reader := tar . NewReader ( gz )
for {
header , err := reader . Next ()
if errors . Is ( err , io . EOF ) {
return fmt . Errorf ( "压缩包内未找到 %s" , name )
}
if err != nil {
return err
}
if strings . TrimSpace ( filepath . Clean ( header . Name )) != name {
continue
}
data , err := io . ReadAll ( io . LimitReader ( reader , 256 << 20 ))
if err != nil {
return err
}
return atomicWrite ( dest , data , mode )
}
}
func execCommandOutput ( name string , args ... string ) string {
output , _ := exec . Command ( name , args ... ). CombinedOutput ()
return string ( output )
}
// dashboardAddr 已废弃:面板地址改由 Fonu 前端从服务端表单的「frps 管理接口」字段拼出,agent 不再解析 frps.toml。
func dashboardAddr ( configPath string ) string {
data , err := os . ReadFile ( configPath )
if err != nil {
return ""
}
var addr , port string
for _ , line := range strings . Split ( string ( data ), "\n" ) {
line = strings . TrimSpace ( line )
if value , found := strings . CutPrefix ( line , "webServer.addr" ); found {
addr = strings . Trim ( strings . TrimSpace ( strings . TrimPrefix ( value , "=" )), "\"" )
} else if value , found := strings . CutPrefix ( line , "webServer.port" ); found {
port = strings . TrimSpace ( strings . TrimPrefix ( value , "=" ))
}
}
if addr == "" || port == "" || port == "0" {
return ""
}
if addr == "0.0.0.0" || addr == "::" {
addr = "127.0.0.1"
}
return fmt . Sprintf ( "http://%s:%s" , addr , port )
}
func frpsVersion ( binary string ) string {
output , err := exec . Command ( binary , "--version" ). CombinedOutput ()
if err != nil {
return ""
}
return string ( output )
}
func journalTail ( unit string , lines int ) string {
output , err := exec . Command ( "journalctl" , "-u" , unit , "-n" , strconv . Itoa ( lines ), "--no-pager" ). CombinedOutput ()
if err != nil {
return ""
}
return string ( output )
}
// nginxBinaryPath 解析实际可用的 nginx 可执行文件:优先传入名称(PATH/绝对路径),
// 否则从运行中的 nginx 主进程反查(/proc/<pid>/exe),零硬编码安装位置。
func nginxBinaryPath ( configured string ) string {
if p := strings . TrimSpace ( configured ); p != "" {
if filepath . IsAbs ( p ) {
if _ , err := os . Stat ( p ); err == nil {
return p
}
} else if _ , err := exec . LookPath ( p ); err == nil {
return p
}
}
// 从运行中的 nginx master 进程反查二进制(不依赖安装位置)
if entries , err := os . ReadDir ( "/proc" ); err == nil {
for _ , entry := range entries {
if ! entry . IsDir () {
continue
}
if data , err := os . ReadFile ( "/proc/" + entry . Name () + "/comm" ); err != nil || strings . TrimSpace ( string ( data )) != "nginx" {
continue
}
if link , err := os . Readlink ( "/proc/" + entry . Name () + "/exe" ); err == nil && strings . Contains ( strings . ToLower ( link ), "nginx" ) {
return link
}
}
}
return ""
}
// detectNginxConfDir 自动发现 Nginx 实际加载完整 server 配置的目录(零硬编码路径):
// 1) nginx -T dump 完整生效配置,收集所有被加载的 .conf 所在目录(Nginx 亲口说的事实);
// 2) 按内容启发排序:目录下已有含 server_name 的 .conf( HTTP vhost 语义)优先,片段目录(location-only)靠后;
// 3) 逐个试探:写入 → nginx -t → 通过即选定并缓存;失败则清理该次写入并换下一个。
// Nginx 自己是唯一裁判,agent 不猜测、不评分、不写死任何安装路径。
func detectNginxConfDir ( binary string ) ( string , error ) {
nginx := nginxBinaryPath ( binary )
if nginx == "" {
return "" , fmt . Errorf ( "未找到可用的 nginx 可执行文件;请先在 VPS 安装 Nginx( apt install nginx / yum install nginx),或用 FONU_AGENT_NGINX_BIN 显式指定路径" )
}
out , err := exec . Command ( nginx , "-T" ). CombinedOutput ()
if err != nil {
return "" , fmt . Errorf ( "nginx -T 执行失败: %w" , err )
}
loadedDirs := map [ string ] bool {}
for _ , line := range strings . Split ( string ( out ), "\n" ) {
line = strings . TrimSpace ( line )
if value , found := strings . CutPrefix ( line , "# configuration file " ); found {
p := strings . TrimSuffix ( strings . TrimSpace ( value ), ":" )
if strings . HasSuffix ( p , ".conf" ) {
loadedDirs [ filepath . Dir ( p )] = true
}
}
}
delete ( loadedDirs , "." )
if len ( loadedDirs ) == 0 {
return "" , fmt . Errorf ( "nginx -T 输出中未发现任何被加载的 .conf 目录" )
}
// 内容启发:目录下已有含 server_name 的 .conf 视为 HTTP vhost 目录,优先尝试。
dirs := make ([] string , 0 , len ( loadedDirs ))
for dir := range loadedDirs {
dirs = append ( dirs , dir )
}
sort . Slice ( dirs , func ( i , j int ) bool {
si , sj := dirHasVhostConf ( dirs [ i ]), dirHasVhostConf ( dirs [ j ])
if si != sj {
return si // vhost 语义目录在前
}
return dirs [ i ] < dirs [ j ]
})
for _ , dir := range dirs {
if err := os . MkdirAll ( dir , 0 o755 ); err != nil {
continue
}
probe := filepath . Join ( dir , "fonu-probe.conf" )
probeBody := "# Managed by fonu-agent. Do not edit.\nserver {\n listen 127.0.0.1:1;\n server_name fonu-probe.invalid;\n}\n"
if err := atomicWrite ( probe , [] byte ( probeBody ), 0 o644 ); err != nil {
continue
}
// 两级验证:-t 仅验证语法,不保证文件被 include 加载(主配置目录等无 include 的目录也能通过)。
// 必须再执行 -T 确认探针文件真的出现在生效配置清单里,才算“会被加载”的目录。
if err := runCommand ( nginx , "-t" ); err == nil {
if tOut , tErr := exec . Command ( nginx , "-T" ). CombinedOutput (); tErr == nil && strings . Contains ( string ( tOut ), probe ) {
_ = os . Remove ( probe )
_ = runCommand ( nginx , "-s" , "reload" ) // 清除 probe 后恢复原状
return dir , nil
}
// -t 通过但 -T 不含探针:该目录不被 include 加载,删除探针换下一个
_ = os . Remove ( probe )
continue
}
// 该目录不接受完整 server 块(片段目录):删除 probe 文件。
// 若 -t 失败信息指向历史残留的 fonu-*.conf,也一并清理(仅限 Agent 生成的文件)。
_ = os . Remove ( probe )
if tOut , tErr := exec . Command ( nginx , "-t" ). CombinedOutput (); tErr != nil {
cleanupLegacyFonuConf ( string ( tOut ))
if err := runCommand ( nginx , "-t" ); err == nil {
continue // 清理残留后配置恢复可用,换下一个目录
}
}
}
return "" , fmt . Errorf ( "在 %d 个被加载目录中均无法放置完整 server 配置(已尝试: %s);可用 FONU_AGENT_NGINX_CONF_DIR 显式指定" , len ( dirs ), strings . Join ( dirs , ", " ))
}
// dirHasVhostConf 判断目录下是否已有含 server_name 的 .conf( HTTP vhost 语义)。
func dirHasVhostConf ( dir string ) bool {
entries , err := os . ReadDir ( dir )
if err != nil {
return false
}
for _ , entry := range entries {
if entry . IsDir () || ! strings . HasSuffix ( entry . Name (), ".conf" ) {
continue
}
if data , err := os . ReadFile ( filepath . Join ( dir , entry . Name ())); err == nil && strings . Contains ( string ( data ), "server_name" ) {
return true
}
}
return false
}
// cleanupLegacyFonuConf 根据 nginx -t 的报错文本清理 Agent 历史生成的坏文件(仅限 fonu- 前缀)。
func cleanupLegacyFonuConf ( nginxTestOutput string ) {
for _ , line := range strings . Split ( nginxTestOutput , "\n" ) {
idx := strings . Index ( line , "fonu-" )
if idx < 0 {
continue
}
start , end := - 1 , - 1
for i := idx ; i >= 0 && line [ i ] != ' ' ; i -- {
start = i
}
for i := idx ; i < len ( line ) && line [ i ] != ' ' && line [ i ] != ':' ; i ++ {
end = i + 1
}
if start < 0 || end <= start {
continue
}
path := line [ start : end ]
if ! strings . HasSuffix ( path , ".conf" ) {
continue
}
if data , err := os . ReadFile ( path ); err == nil && strings . HasPrefix ( string ( data ), "# Managed by fonu-agent." ) {
_ = os . Remove ( path )
}
}
}
func portListening ( port int ) bool {
conn , err := net . DialTimeout ( "tcp" , net . JoinHostPort ( "127.0.0.1" , strconv . Itoa ( port )), 300 * time . Millisecond )
if err != nil {
return false
}
_ = conn . Close ()
return true
}
func ( s * Server ) routes ( w http . ResponseWriter , _ * http . Request ) {
writeJSON ( w , http . StatusOK , s . routeDetails ())
}
// routeDetails 返回每条路由的真实状态:文件存在/语法通过/被 Nginx 实际加载。
func ( s * Server ) routeDetails () [] map [ string ] any {
entries , err := os . ReadDir ( s . cfg . NginxConfDir )
if err != nil {
return [] map [ string ] any {}
}
details := [] map [ string ] any {}
for _ , entry := range entries {
if entry . IsDir () || ! strings . HasPrefix ( entry . Name (), "fonu-" ) || ! strings . HasSuffix ( entry . Name (), ".conf" ) {
continue
}
domain := strings . TrimSuffix ( strings . TrimPrefix ( entry . Name (), "fonu-" ), ".conf" )
path := filepath . Join ( s . cfg . NginxConfDir , entry . Name ())
item := map [ string ] any {
"domain" : domain ,
"file_exists" : true ,
"syntax_ok" : false ,
"loaded" : false ,
}
// 语法与加载判定合并在一次 -T 中:-T 成功且输出含本文件 = 语法通过且被加载
if nginx := s . nginxPath (); nginx != "" {
if tOut , tErr := exec . Command ( nginx , "-T" ). CombinedOutput (); tErr == nil {
item [ "syntax_ok" ] = true
item [ "loaded" ] = strings . Contains ( string ( tOut ), path )
}
}
details = append ( details , item )
}
sort . Slice ( details , func ( i , j int ) bool { return details [ i ][ "domain" ].( string ) < details [ j ][ "domain" ].( string ) })
return details
}
type routeRequest struct {
Domain string `json:"domain"`
Upstream string `json:"upstream"`
TLS bool `json:"tls"`
CertDir string `json:"cert_dir,omitempty"`
WebSocket bool `json:"websocket,omitempty"`
VhostPort int `json:"vhost_port,omitempty"` // 回源自检用:Fonu 传入本次部署的 frps vhost 端口
RedirectTLS bool `json:"redirect_https,omitempty"` // HTTP 80 强制跳转 HTTPS
AllowIPs [] string `json:"allow_ips,omitempty"` // IP 白名单;空则不限制
BasicAuthUser string `json:"basic_auth_user,omitempty"` // Basic Auth 用户名;空则不启用(密码自动生成)
// 第一梯队部署设置(仅作用于公网 Nginx vhost,不写入 frpc.toml)
DenyIPs [] string `json:"deny_ips,omitempty"` // IP 黑名单;与白名单可同时使用
BasicAuthPassword string `json:"basic_auth_password,omitempty"` // Basic Auth 自定义密码;空则首次自动生成
HTTPSDisabled bool `json:"https_disabled,omitempty"` // 显式关闭 HTTPS(证书已推送也不监听 443)
ClientMaxBodySize string `json:"client_max_body_size,omitempty"` // 上传大小限制,如 50m
ProxyReadTimeout string `json:"proxy_read_timeout,omitempty"` // 代理读超时,如 3600s
SecurityHeaders bool `json:"security_headers,omitempty"` // 安全响应头(HSTS 等)
TLS13Only bool `json:"tls13_only,omitempty"` // 仅 TLS 1.3
// 第二梯队部署设置:限流 / 连接数 / 仅中国大陆 IP
RateLimitRate int `json:"rate_limit_rate,omitempty"` // 请求限流:每秒请求数
RateLimitBurst int `json:"rate_limit_burst,omitempty"` // 请求限流:突发允许量
ConnLimitMax int `json:"conn_limit_max,omitempty"` // 每 IP 并发连接数上限
ChinaOnly bool `json:"china_only,omitempty"` // 仅允许中国大陆 IP(需先下发 IP 段)
}
func ( s * Server ) putRoute ( w http . ResponseWriter , r * http . Request ) {
var req routeRequest
if err := json . NewDecoder ( http . MaxBytesReader ( w , r . Body , 32 << 10 )). Decode ( & req ); err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid JSON" })
return
}
if err := validateRoute ( req ); err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : err . Error ()})
return
}
// 部署预检:未装 Nginx 时给出人话提示,而不是后续的 nginx config rejected
if probe := s . probeNginx (); probe . Missing {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "VPS 未安装 Nginx(或不在常见路径):请先安装(apt install nginx / yum install nginx),或在「公网反代」页点「一键安装 Nginx」后再部署" })
return
}
s . mu . Lock ()
defer s . mu . Unlock ()
2026-09-24 17:06:34 +08:00
// Basic Auth 密码留空时首次自动生成:生成的明文只在本次响应里回一次,
// 不再写进配置文件注释(否则「查看配置」会永久暴露密码)。已有凭据时返回空串。
generatedPassword := ""
if user := strings . TrimSpace ( req . BasicAuthUser ); user != "" && strings . TrimSpace ( req . BasicAuthPassword ) == "" {
if generated , err := ensureBasicAuthSecret ( req . Domain , user ); err == nil {
generatedPassword = generated
}
}
2026-09-23 09:40:59 +08:00
if err := s . writeRoute ( req ); err != nil {
2026-09-24 17:06:34 +08:00
if generatedPassword != "" {
// 部署失败就丢弃刚生成的凭据,否则重试时文件已存在、密码再也拿不到
_ = os . Remove ( basicAuthFilePath ( req . Domain ))
}
2026-09-23 09:40:59 +08:00
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
result := map [ string ] any { "ok" : true , "domain" : req . Domain , "websocket" : req . WebSocket }
2026-09-24 17:06:34 +08:00
if generatedPassword != "" {
result [ "basic_auth_user" ] = strings . TrimSpace ( req . BasicAuthUser )
result [ "basic_auth_password" ] = generatedPassword
}
2026-09-23 09:40:59 +08:00
// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
if vhost := req . VhostPort ; vhost > 0 {
code , body := s . probeVhost ( vhost , req . Domain )
result [ "probe_status" ] = code
result [ "probe_ok" ] = code >= 200 && code < 400
result [ "probe_notfound" ] = code == 404
result [ "probe_body_snip" ] = body
}
writeJSON ( w , http . StatusOK , result )
}
// probeVhost 从 VPS 本机模拟 Nginx 回源:返回 HTTP 状态码与响应片段(限长)。
func ( s * Server ) probeVhost ( port int , host string ) ( int , string ) {
client := & http . Client {
Timeout : 5 * time . Second ,
CheckRedirect : func ( req * http . Request , via [] * http . Request ) error {
return http . ErrUseLastResponse
},
}
req , err := http . NewRequest ( http . MethodGet , fmt . Sprintf ( "http://127.0.0.1:%d/" , port ), nil )
if err != nil {
return 0 , err . Error ()
}
req . Host = host
resp , err := client . Do ( req )
if err != nil {
return 0 , err . Error ()
}
defer resp . Body . Close ()
snippet , _ := io . ReadAll ( io . LimitReader ( resp . Body , 200 ))
return resp . StatusCode , strings . TrimSpace ( string ( snippet ))
}
// routeConf 返回指定域名的当前反代配置内容(查看生成的 nginx 配置)。
func ( s * Server ) routeConf ( w http . ResponseWriter , r * http . Request ) {
domain := strings . ToLower ( strings . TrimSpace ( r . PathValue ( "domain" )))
if ! validDomain ( domain ) {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid domain" })
return
}
s . mu . Lock ()
defer s . mu . Unlock ()
data , err := os . ReadFile ( s . routePath ( domain ))
if err != nil {
writeJSON ( w , http . StatusNotFound , map [ string ] string { "error" : "该域名尚未部署反代" })
return
}
writeJSON ( w , http . StatusOK , map [ string ] any { "domain" : domain , "content" : string ( data )})
}
func ( s * Server ) deleteRoute ( w http . ResponseWriter , r * http . Request ) {
domain := strings . ToLower ( strings . TrimSpace ( r . PathValue ( "domain" )))
if ! validDomain ( domain ) {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid domain" })
return
}
s . mu . Lock ()
defer s . mu . Unlock ()
path := s . routePath ( domain )
if err := s . clearLimitState ( domain ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
if err := os . Remove ( path ); err != nil && ! errors . Is ( err , os . ErrNotExist ) {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
if err := s . reloadNginx (); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
writeJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "domain" : domain })
}
type frpsConfigRequest struct {
Content string `json:"content"`
}
func ( s * Server ) putFRPSConfig ( w http . ResponseWriter , r * http . Request ) {
var req frpsConfigRequest
if err := json . NewDecoder ( http . MaxBytesReader ( w , r . Body , 512 << 10 )). Decode ( & req ); err != nil || strings . TrimSpace ( req . Content ) == "" {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "frps config content is required" })
return
}
s . mu . Lock ()
defer s . mu . Unlock ()
backup := s . cfg . FrpsConfigPath + ".bak"
if old , err := os . ReadFile ( s . cfg . FrpsConfigPath ); err == nil {
if err := atomicWrite ( backup , old , 0 o600 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
}
if err := atomicWrite ( s . cfg . FrpsConfigPath , [] byte ( req . Content ), 0 o600 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
writeJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "path" : s . cfg . FrpsConfigPath , "restart_required" : true })
}
type certificateRequest struct {
Domain string `json:"domain"`
Fullchain string `json:"fullchain_pem"`
PrivateKey string `json:"key_pem"`
}
func ( s * Server ) putCertificate ( w http . ResponseWriter , r * http . Request ) {
var req certificateRequest
if err := json . NewDecoder ( http . MaxBytesReader ( w , r . Body , 2 << 20 )). Decode ( & req ); err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid JSON" })
return
}
req . Domain = strings . ToLower ( strings . TrimSpace ( req . Domain ))
if ! validDomain ( req . Domain ) || strings . TrimSpace ( req . Fullchain ) == "" || strings . TrimSpace ( req . PrivateKey ) == "" {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "domain and PEM values are required" })
return
}
s . mu . Lock ()
defer s . mu . Unlock ()
dir := filepath . Join ( s . cfg . CertsDir , req . Domain )
if err := os . MkdirAll ( dir , 0 o700 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
if err := atomicWrite ( filepath . Join ( dir , "fullchain.pem" ), [] byte ( req . Fullchain ), 0 o600 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
if err := atomicWrite ( filepath . Join ( dir , "privkey.pem" ), [] byte ( req . PrivateKey ), 0 o600 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
writeJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "domain" : req . Domain , "directory" : dir })
}
// nginxInstallResult 一键安装 Nginx 的执行结果。
type nginxInstallResult struct {
OK bool `json:"ok"`
Manager string `json:"manager,omitempty"`
Output string `json:"output,omitempty"`
Path string `json:"path,omitempty"`
Error string `json:"error,omitempty"`
}
// installNginx 一键安装 Nginx:识别 apt-get/dnf/yum/apk → 非交互安装 → 启动 → 回写探测结果。
// 需要 agent 以 root 运行(systemd 单元默认如此)。
func ( s * Server ) installNginx ( w http . ResponseWriter , r * http . Request ) {
if os . Geteuid () != 0 {
writeJSON ( w , http . StatusForbidden , map [ string ] string { "error" : "安装 Nginx 需要 root 权限:请以 root 运行 fonu-agent" })
return
}
if probe := s . probeNginx (); probe . OK {
writeJSON ( w , http . StatusOK , nginxInstallResult { OK : true , Path : probe . Path , Output : "Nginx 已可用,无需安装" })
return
}
manager , installArgs := detectPackageManager ()
if manager == "" {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "未识别到支持的包管理器(apt-get/dnf/yum/apk),请在 VPS 上手动安装 Nginx" })
return
}
s . mu . Lock ()
defer s . mu . Unlock ()
ctx , cancel := context . WithTimeout ( r . Context (), 5 * time . Minute )
defer cancel ()
if manager == "apt-get" {
update := exec . CommandContext ( ctx , "apt-get" , "update" , "-y" )
update . Env = append ( os . Environ (), "DEBIAN_FRONTEND=noninteractive" )
_ , _ = update . CombinedOutput () // 索引更新失败不阻断安装尝试
}
install := exec . CommandContext ( ctx , manager , installArgs ... )
install . Env = append ( os . Environ (), "DEBIAN_FRONTEND=noninteractive" )
out , err := install . CombinedOutput ()
if err != nil {
writeJSON ( w , http . StatusBadGateway , nginxInstallResult { Manager : manager , Output : tailText ( string ( out ), 4000 ), Error : err . Error ()})
return
}
// 启动并设置开机自启:systemd 优先,回退 service 命令(两者失败都忽略,最终以探测结果为准)
_ = exec . Command ( "systemctl" , "enable" , "--now" , "nginx" ). Run ()
_ = exec . Command ( "service" , "nginx" , "start" ). Run ()
s . invalidateNginxProbe ()
probe := s . probeNginx ()
result := nginxInstallResult { OK : probe . OK , Manager : manager , Output : tailText ( string ( out ), 4000 ), Path : probe . Path , Error : probe . Error }
if ! probe . OK {
writeJSON ( w , http . StatusBadGateway , result )
return
}
// 安装成功后解析并缓存 vhost 目录,省去后续首次部署的探测
if dir , err := detectNginxConfDir ( s . nginxPath ()); err == nil {
s . cfg . NginxConfDir = dir
_ = os . MkdirAll ( dir , 0 o755 )
if s . cfg . DataDir != "" {
_ = os . WriteFile ( filepath . Join ( s . cfg . DataDir , "nginx_conf_dir" ), [] byte ( dir ), 0 o644 )
}
}
writeJSON ( w , http . StatusOK , result )
}
type chinaCIDRRequest struct {
Content string `json:"content"`
}
// putChinaCIDR 接收 Fonu 下发的中国 IP 段(geo include 格式),写入 geo 变量文件并重载。
func ( s * Server ) putChinaCIDR ( w http . ResponseWriter , r * http . Request ) {
var req chinaCIDRRequest
if err := json . NewDecoder ( http . MaxBytesReader ( w , r . Body , 16 << 20 )). Decode ( & req ); err != nil {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "invalid JSON" })
return
}
content := strings . TrimSpace ( req . Content )
if content == "" {
writeJSON ( w , http . StatusBadRequest , map [ string ] string { "error" : "中国 IP 段内容为空" })
return
}
if probe := s . probeNginx (); probe . Missing {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "VPS 未安装 Nginx:请先安装 Nginx 再同步中国 IP 段" })
return
}
s . mu . Lock ()
defer s . mu . Unlock ()
if strings . TrimSpace ( s . cfg . NginxConfDir ) == "" {
dir , err := detectNginxConfDir ( s . cfg . NginxBinary )
if err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
s . cfg . NginxConfDir = dir
}
if err := atomicWrite ( chinaListPath (), [] byte ( content + "\n" ), 0 o640 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
geo := "# Managed by fonu-agent. Do not edit.\ngeo $fonu_china_client {\n default 0;\n include " + chinaListPath () + ";\n}\n"
if err := atomicWrite ( s . chinaGeoFilePath (), [] byte ( geo ), 0 o640 ); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
if err := s . validateNginx (); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : "nginx config rejected: " + err . Error ()})
return
}
if err := s . reloadNginx (); err != nil {
writeJSON ( w , http . StatusBadGateway , map [ string ] string { "error" : err . Error ()})
return
}
writeJSON ( w , http . StatusOK , map [ string ] any { "ok" : true , "bytes" : len ( content )})
}
func ( s * Server ) writeRoute ( req routeRequest ) error {
// 目录为空时惰性探测(启动时探测失败不阻断,这里重试;systemd 下补 PATH)
if strings . TrimSpace ( s . cfg . NginxConfDir ) == "" {
dir , err := detectNginxConfDir ( s . cfg . NginxBinary )
if err != nil {
return err
}
// putRoute 已持有 s.mu;这里不能再次加锁,否则会发生自锁死。
s . cfg . NginxConfDir = dir
}
certDir := req . CertDir
if certDir == "" {
certDir = filepath . Join ( s . cfg . CertsDir , req . Domain )
}
// 仅中国大陆 IP 依赖已下发的 IP 段(geo 变量在 fonuagent-china.conf 中定义)
if req . ChinaOnly {
if _ , err := os . Stat ( chinaListPath ()); err != nil {
return fmt . Errorf ( "启用「仅中国大陆 IP」前需先同步中国 IP 段(请在公网反代页触发同步后重试)" )
}
}
content := nginxRoute ( req , certDir )
path := s . routePath ( req . Domain )
// 清理历史版本写到其他目录的同域名 Agent 文件:从 nginx -T 的生效清单反向比对,
// 非当前目录但内容带 Agent 标记的 fonu-<domain>.conf 一律删除(不碰宝塔自己的配置)。
if nginx := s . nginxPath (); nginx != "" {
if tOut , tErr := exec . Command ( nginx , "-T" ). CombinedOutput (); tErr == nil {
for _ , line := range strings . Split ( string ( tOut ), "\n" ) {
line = strings . TrimSpace ( line )
if value , found := strings . CutPrefix ( line , "# configuration file " ); found {
p := strings . TrimSuffix ( strings . TrimSpace ( value ), ":" )
base := filepath . Base ( p )
if p != path && base == "fonu-" + req . Domain + ".conf" {
if data , readErr := os . ReadFile ( p ); readErr == nil && strings . HasPrefix ( string ( data ), "# Managed by fonu-agent." ) {
_ = os . Remove ( p )
}
}
}
}
}
}
// 先落 zone 文件再写 vhost: nginx -t 需要 zone 已存在
if err := s . applyLimitState ( req ); err != nil {
return err
}
if err := atomicWrite ( path , [] byte ( content ), 0 o640 ); err != nil {
return err
}
if err := s . validateNginx (); err != nil {
_ = os . Remove ( path )
return fmt . Errorf ( "nginx config rejected: %w" , err )
}
return s . reloadNginx ()
}
func ( s * Server ) routeNames () [] string {
entries , err := os . ReadDir ( s . cfg . NginxConfDir )
if err != nil {
return [] string {}
}
var result [] string
for _ , entry := range entries {
if entry . IsDir () || ! strings . HasPrefix ( entry . Name (), "fonu-" ) || ! strings . HasSuffix ( entry . Name (), ".conf" ) {
continue
}
result = append ( result , strings . TrimSuffix ( strings . TrimPrefix ( entry . Name (), "fonu-" ), ".conf" ))
}
sort . Strings ( result )
return result
}
func ( s * Server ) routePath ( domain string ) string {
return filepath . Join ( s . cfg . NginxConfDir , "fonu-" + domain + ".conf" )
}
func ( s * Server ) validateNginx () error {
return runCommand ( s . nginxPath (), "-t" )
}
func ( s * Server ) reloadNginx () error {
return runCommand ( s . nginxPath (), "-s" , "reload" )
}
// nginxPath 返回可用的 nginx 绝对路径:配置的路径可用则用之;否则探测常见安装位置
// (宝塔的 nginx 不在 systemd PATH 里,直接用名字会 exec 失败)。
func ( s * Server ) nginxPath () string {
if p := strings . TrimSpace ( s . cfg . NginxBinary ); p != "" {
if filepath . IsAbs ( p ) {
if _ , err := os . Stat ( p ); err == nil {
return p
}
} else if _ , err := exec . LookPath ( p ); err == nil {
return p
}
}
for _ , candidate := range [] string { "/www/server/nginx/sbin/nginx" , "/usr/local/nginx/sbin/nginx" , "/usr/sbin/nginx" } {
if _ , err := os . Stat ( candidate ); err == nil {
return candidate
}
}
return s . cfg . NginxBinary
}
// probeNginx 真实探测 Nginx 可用性:解析可执行文件并执行 nginx -t;结果缓存 30 秒。
func ( s * Server ) probeNginx () nginxProbe {
s . nginxStatusMu . Lock ()
defer s . nginxStatusMu . Unlock ()
if ! s . nginxStatusAt . IsZero () && time . Since ( s . nginxStatusAt ) < 30 * time . Second {
return s . nginxStatus
}
probe := nginxProbe {}
path := strings . TrimSpace ( s . nginxPath ())
if path == "" {
probe . Missing = true
probe . Error = "未找到 nginx 可执行文件(已尝试配置值、常见安装路径与运行中进程)"
} else {
probe . Path = path
probe . Version = nginxVersion ( path )
probe . Running = nginxRunning ()
if out , err := exec . Command ( path , "-t" ). CombinedOutput (); err != nil {
probe . Error = strings . TrimSpace ( string ( out ))
} else {
probe . OK = true
}
}
s . nginxStatus = probe
s . nginxStatusAt = time . Now ()
return probe
}
// invalidateNginxProbe 清除探测缓存(安装/升级 Nginx 后强制重新探测)。
func ( s * Server ) invalidateNginxProbe () {
s . nginxStatusMu . Lock ()
s . nginxStatusAt = time . Time {}
s . nginxStatusMu . Unlock ()
}
// nginxRunning 判断是否有 nginx 进程在运行(扫描 /proc,不依赖端口占用)。
func nginxRunning () bool {
entries , err := os . ReadDir ( "/proc" )
if err != nil {
return false
}
for _ , entry := range entries {
if ! entry . IsDir () {
continue
}
if _ , err := strconv . Atoi ( entry . Name ()); err != nil {
continue
}
if data , err := os . ReadFile ( "/proc/" + entry . Name () + "/comm" ); err == nil && strings . TrimSpace ( string ( data )) == "nginx" {
return true
}
}
return false
}
// nginxVersion 返回 nginx 版本字符串(如 nginx/1.24.0);-v 输出走 stderr。
func nginxVersion ( path string ) string {
out , _ := exec . Command ( path , "-v" ). CombinedOutput ()
text := strings . TrimSpace ( string ( out ))
if idx := strings . Index ( text , "nginx version: " ); idx >= 0 {
return strings . TrimSpace ( text [ idx + len ( "nginx version: " ):])
}
return text
}
// detectPackageManager 识别可用的包管理器与非交互安装参数。
func detectPackageManager () ( string , [] string ) {
candidates := [] struct {
bin string
args [] string
}{
{ "apt-get" , [] string { "install" , "-y" , "nginx" }},
{ "dnf" , [] string { "install" , "-y" , "nginx" }},
{ "yum" , [] string { "install" , "-y" , "nginx" }},
{ "apk" , [] string { "add" , "--no-cache" , "nginx" }},
}
for _ , candidate := range candidates {
if path , err := exec . LookPath ( candidate . bin ); err == nil && path != "" {
return candidate . bin , candidate . args
}
}
return "" , nil
}
// tailText 截取文本尾部,避免把整个安装日志塞进响应。
func tailText ( value string , limit int ) string {
value = strings . TrimSpace ( value )
if len ( value ) <= limit {
return value
}
return "…(已截断)" + value [ len ( value ) - limit :]
}
func runCommand ( name string , args ... string ) error {
output , err := exec . Command ( name , args ... ). CombinedOutput ()
if err != nil {
return fmt . Errorf ( "%s: %w: %s" , name , err , strings . TrimSpace ( string ( output )))
}
return nil
}
func validateRoute ( req routeRequest ) error {
if ! validDomain ( strings . ToLower ( strings . TrimSpace ( req . Domain ))) {
return errors . New ( "invalid domain" )
}
if strings . TrimSpace ( req . Upstream ) == "" || strings . ContainsAny ( req . Upstream , " \t\r\n;'\"" ) {
return errors . New ( "invalid upstream" )
}
if req . TLS && strings . TrimSpace ( req . CertDir ) != "" && ! filepath . IsAbs ( req . CertDir ) {
return errors . New ( "certificate directory must be absolute" )
}
return nil
}
func validDomain ( value string ) bool {
if len ( value ) == 0 || len ( value ) > 253 || strings . Contains ( value , ".." ) {
return false
}
return domainPattern . MatchString ( value )
}
func nginxRoute ( req routeRequest , certDir string ) string {
var b strings . Builder
b . WriteString ( "# Managed by fonu-agent. Do not edit.\n" )
// HTTPSDisabled 显式关闭 HTTPS:即使证书已推送也不监听 443(字段缺省 = 沿用现有行为)
tlsEnabled := req . TLS && ! req . HTTPSDisabled
if req . RedirectTLS && tlsEnabled {
// 80 端口只做 301 跳转,正常代理由 443 TLS server 承担
fmt . Fprintf ( & b , "server {\n listen 80;\n server_name %s;\n return 301 https://$host$request_uri;\n}\n\n" , req . Domain )
fmt . Fprintf ( & b , "server {\n listen 443 ssl;\n server_name %s;\n" , req . Domain )
b . WriteString ( sslDirectives ( certDir , req . TLS13Only ))
} else {
fmt . Fprintf ( & b , "server {\n listen 80;\n server_name %s;\n" , req . Domain )
if tlsEnabled {
b . WriteString ( " listen 443 ssl;\n" )
b . WriteString ( sslDirectives ( certDir , req . TLS13Only ))
}
}
// 上传大小与代理读超时:按规则覆盖 Nginx 默认值(大文件上传 / 长轮询 / 流媒体必需)
if size := strings . TrimSpace ( req . ClientMaxBodySize ); size != "" {
fmt . Fprintf ( & b , " client_max_body_size %s;\n" , size )
}
if timeout := strings . TrimSpace ( req . ProxyReadTimeout ); timeout != "" {
fmt . Fprintf ( & b , " proxy_read_timeout %s;\n" , timeout )
}
// 安全响应头:HSTS 仅在 HTTPS 实际启用时输出
if req . SecurityHeaders {
b . WriteString ( " add_header X-Content-Type-Options \"nosniff\" always;\n" )
b . WriteString ( " add_header X-Frame-Options \"SAMEORIGIN\" always;\n" )
b . WriteString ( " add_header Referrer-Policy \"strict-origin-when-cross-origin\" always;\n" )
if tlsEnabled {
b . WriteString ( " add_header Strict-Transport-Security \"max-age=31536000; includeSubDomains\" always;\n" )
}
}
// 限流/连接数的 zone 定义在 fonuagent-limits.conf( http 上下文),此处只声明状态码
if req . RateLimitRate > 0 {
b . WriteString ( " limit_req_status 429;\n" )
}
if req . ConnLimitMax > 0 {
b . WriteString ( " limit_conn_status 503;\n" )
}
if req . WebSocket {
b . WriteString ( " proxy_http_version 1.1;\n" )
b . WriteString ( " proxy_set_header Upgrade $http_upgrade;\n" )
b . WriteString ( " proxy_set_header Connection \"upgrade\";\n" )
}
// IP 访问控制:白名单(allow + deny all)与黑名单(deny)可同时生效;
// Nginx 按出现顺序匹配,因此 allow 在前、deny 在后。
var acl strings . Builder
for _ , ip := range req . AllowIPs {
ip = strings . TrimSpace ( ip )
if ip != "" {
fmt . Fprintf ( & acl , " allow %s;\n" , ip )
}
}
for _ , ip := range req . DenyIPs {
if ip = strings . TrimSpace ( ip ); ip != "" {
fmt . Fprintf ( & acl , " deny %s;\n" , ip )
}
}
if len ( req . AllowIPs ) > 0 {
acl . WriteString ( " deny all;\n" )
}
2026-09-24 17:06:34 +08:00
// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式);
// 自动生成的明文由 putRoute 在响应里回一次,这里只写 auth_basic 指令,不写密码注释
2026-09-23 09:40:59 +08:00
authLine := ""
if user := strings . TrimSpace ( req . BasicAuthUser ); user != "" {
if password := strings . TrimSpace ( req . BasicAuthPassword ); password != "" {
if err := writeBasicAuthSecret ( req . Domain , user , password ); err == nil {
authLine = fmt . Sprintf ( " auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n" , basicAuthFilePath ( req . Domain ))
}
2026-09-24 17:06:34 +08:00
} else if _ , err := ensureBasicAuthSecret ( req . Domain , user ); err == nil {
2026-09-23 09:40:59 +08:00
authLine = fmt . Sprintf ( " auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n" , basicAuthFilePath ( req . Domain ))
}
}
// 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义)
var limitText strings . Builder
if req . RateLimitRate > 0 {
zone := zoneKey ( req . Domain )
if req . RateLimitBurst > 0 {
fmt . Fprintf ( & limitText , " limit_req zone=%s_req burst=%d nodelay;\n" , zone , req . RateLimitBurst )
} else {
fmt . Fprintf ( & limitText , " limit_req zone=%s_req nodelay;\n" , zone )
}
}
if req . ConnLimitMax > 0 {
fmt . Fprintf ( & limitText , " limit_conn %s_conn %d;\n" , zoneKey ( req . Domain ), req . ConnLimitMax )
}
if req . ChinaOnly {
limitText . WriteString ( " if ($fonu_china_client = 0) { return 403; }\n" )
}
aclText := acl . String ()
fmt . Fprintf ( & b , " location / {\n%s%s%s proxy_pass http://%s;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_http_version 1.1;\n }\n}\n" , aclText , authLine , limitText . String (), req . Upstream )
return b . String ()
}
// agentStateDir 存放 Agent 的持久状态(htpasswd、geo IP 段、限流参数)。
const agentStateDir = "/var/lib/fonu-agent"
// zoneKey 把域名转成合法的 nginx zone 名(仅保留小写字母与数字,其余换下划线)。
func zoneKey ( domain string ) string {
var b strings . Builder
b . WriteString ( "fonu_" )
for _ , r := range strings . ToLower ( domain ) {
if ( r >= 'a' && r <= 'z' ) || ( r >= '0' && r <= '9' ) {
b . WriteRune ( r )
} else {
b . WriteByte ( '_' )
}
}
return b . String ()
}
// limitsFilePath 返回 zone 定义文件(放在 vhost 目录,顶层指令即 http 上下文)。
func ( s * Server ) limitsFilePath () string {
return filepath . Join ( s . cfg . NginxConfDir , "fonuagent-limits.conf" )
}
// chinaGeoFilePath 返回 geo 变量定义文件(同样位于 http 上下文)。
func ( s * Server ) chinaGeoFilePath () string {
return filepath . Join ( s . cfg . NginxConfDir , "fonuagent-china.conf" )
}
// chinaListPath 返回中国 IP 段文件(geo include 格式:<cidr> 1;)。
func chinaListPath () string {
return filepath . Join ( agentStateDir , "geo" , "china.conf" )
}
func limitsStatePath () string {
return filepath . Join ( agentStateDir , "route-limits.json" )
}
// routeLimit 单条路由的限流参数。
type routeLimit struct {
Rate int `json:"rate,omitempty"`
Burst int `json:"burst,omitempty"`
Conn int `json:"conn,omitempty"`
}
func readLimitState () map [ string ] routeLimit {
state := map [ string ] routeLimit {}
data , err := os . ReadFile ( limitsStatePath ())
if err != nil {
return state
}
_ = json . Unmarshal ( data , & state )
return state
}
// writeLimitState 落盘限流参数并重生成 zone 定义文件;无限流时删除该文件。
func ( s * Server ) writeLimitState ( state map [ string ] routeLimit ) error {
data , err := json . Marshal ( state )
if err != nil {
return err
}
if err := atomicWrite ( limitsStatePath (), data , 0 o640 ); err != nil {
return err
}
path := s . limitsFilePath ()
if len ( state ) == 0 {
if err := os . Remove ( path ); err != nil && ! errors . Is ( err , os . ErrNotExist ) {
return err
}
return nil
}
var b strings . Builder
b . WriteString ( "# Managed by fonu-agent. Do not edit.\n" )
domains := make ([] string , 0 , len ( state ))
for domain := range state {
domains = append ( domains , domain )
}
sort . Strings ( domains )
for _ , domain := range domains {
item := state [ domain ]
key := zoneKey ( domain )
if item . Rate > 0 {
fmt . Fprintf ( & b , "limit_req_zone $binary_remote_addr zone=%s_req:10m rate=%dr/s;\n" , key , item . Rate )
}
if item . Conn > 0 {
fmt . Fprintf ( & b , "limit_conn_zone $binary_remote_addr zone=%s_conn:10m;\n" , key )
}
}
return atomicWrite ( path , [] byte ( b . String ()), 0 o640 )
}
// applyLimitState 记录本次路由的限流参数并刷新 zone 文件(必须在写 vhost 之前完成)。
func ( s * Server ) applyLimitState ( req routeRequest ) error {
state := readLimitState ()
if req . RateLimitRate > 0 || req . ConnLimitMax > 0 {
state [ req . Domain ] = routeLimit { Rate : req . RateLimitRate , Burst : req . RateLimitBurst , Conn : req . ConnLimitMax }
} else {
delete ( state , req . Domain )
}
return s . writeLimitState ( state )
}
// clearLimitState 删除该域名的限流参数并刷新 zone 文件。
func ( s * Server ) clearLimitState ( domain string ) error {
state := readLimitState ()
if _ , ok := state [ domain ]; ! ok {
return nil
}
delete ( state , domain )
return s . writeLimitState ( state )
}
// sslDirectives 生成证书与 TLS 协议指令;tls13Only 时只允许 TLS 1.3。
func sslDirectives ( certDir string , tls13Only bool ) string {
var b strings . Builder
fmt . Fprintf ( & b , " ssl_certificate %s;\n ssl_certificate_key %s;\n" , filepath . Join ( certDir , "fullchain.pem" ), filepath . Join ( certDir , "privkey.pem" ))
if tls13Only {
b . WriteString ( " ssl_protocols TLSv1.3;\n" )
}
return b . String ()
}
// writeBasicAuthSecret 用规则表单提供的密码写入 htpasswd(自定义密码场景)。
func writeBasicAuthSecret ( domain , user , password string ) error {
2026-09-23 11:22:11 +08:00
hash , err := apr1Crypt ( password )
2026-09-23 09:40:59 +08:00
if err != nil {
2026-09-23 11:22:11 +08:00
return fmt . Errorf ( "生成 htpasswd 失败: %w" , err )
2026-09-23 09:40:59 +08:00
}
2026-09-23 11:22:11 +08:00
return atomicWrite ( basicAuthFilePath ( domain ), [] byte ( user + ":" + hash + "\n" ), 0 o640 )
2026-09-23 09:40:59 +08:00
}
// basicAuthFilePath 返回指定域名的 htpasswd 文件路径(与反代配置同目录管理)。
func basicAuthFilePath ( domain string ) string {
return filepath . Join ( "/var/lib/fonu-agent" , "basicauth" , domain + ".htpasswd" )
}
// ensureBasicAuthSecret 读取已有 Basic Auth 密码;不存在则生成随机密码并写入 htpasswd 文件。
// 返回明文密码(只在生成配置时写进注释,供用户查看一次)。
func ensureBasicAuthSecret ( domain , user string ) ( string , error ) {
path := basicAuthFilePath ( domain )
if data , err := os . ReadFile ( path ); err == nil {
// 已有凭据:格式 user:$apr1$...,无法反解;返回空表示沿用旧密码
if len ( data ) > 0 {
return "" , nil
}
}
password := randomToken ()[: 16 ]
2026-09-23 11:22:11 +08:00
// 纯 Go 生成 apr1 哈希(等价 openssl passwd -apr1,不依赖 VPS 上的外部命令)
hash , err := apr1Crypt ( password )
2026-09-23 09:40:59 +08:00
if err != nil {
2026-09-23 11:22:11 +08:00
return "" , fmt . Errorf ( "生成 htpasswd 失败: %w" , err )
2026-09-23 09:40:59 +08:00
}
if err := os . MkdirAll ( filepath . Dir ( path ), 0 o750 ); err != nil {
return "" , err
}
if err := atomicWrite ( path , [] byte ( user + ":" + hash + "\n" ), 0 o640 ); err != nil {
return "" , err
}
return password , nil
}
func atomicWrite ( path string , data [] byte , mode os . FileMode ) error {
if err := os . MkdirAll ( filepath . Dir ( path ), 0 o750 ); err != nil {
return err
}
2026-09-24 19:03:05 +08:00
return fsutil . WriteFileAtomic ( path , data , mode )
2026-09-23 09:40:59 +08:00
}
func writeJSON ( w http . ResponseWriter , status int , value any ) {
w . Header (). Set ( "Content-Type" , "application/json; charset=utf-8" )
w . WriteHeader ( status )
_ = json . NewEncoder ( w ). Encode ( value )
}
func randomToken () string {
buf := make ([] byte , 32 )
if _ , err := rand . Read ( buf ); err != nil {
return hex . EncodeToString ([] byte ( time . Now (). String ()))
}
return hex . EncodeToString ( buf )
}