fix(security): P0 修复——备份恢复路径与权限、frps 安装白名单与 SHA-256、SSH 主机密钥 TOFU、三处凭据掩码,另修状态徽标与仪表盘文案(v1.2.7)

This commit is contained in:
2026-09-24 17:06:34 +08:00
parent 0fce234ef3
commit 6abedbbe42
21 changed files with 651 additions and 65 deletions
+94 -12
View File
@@ -5,6 +5,7 @@ import (
"compress/gzip"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
@@ -35,7 +36,9 @@ import (
// 0.9.2:Basic Auth 哈希改为纯 Go apr1(去掉对 VPS openssl 的依赖);安装脚本 token 改用 /dev/urandom。
// 0.10.0:新增 /api/v1/metrics 主机资源采集、/api/v1/logs/nginx 日志读取、/api/v1/frps/config 配置回读。
// 0.11.0:新增 /api/v1/certs 证书清单与 /api/v1/nginx/reload 显式重载(回传校验/重载失败原因)。
const AgentVersion = "0.11.0"
// 0.12.0:frps 安装包的 proxy 参数改白名单 + 下载后校验 SHA-256;Basic Auth 自动生成的密码不再写进
// nginx 配置注释(改为只在 PUT 响应里回一次)。
const AgentVersion = "0.12.0"
// Config 是 fonu-agent 的运行配置。agent 只在 VPS 本地管理自己的 Nginx 片段、证书和 frps 配置。
type Config struct {
@@ -237,6 +240,17 @@ type frpsInstallRequest struct {
Proxy string `json:"proxy,omitempty"`
}
// 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/"
// installFRPS 下载指定版本的 frp release、安装 frps 二进制并注册 systemd 服务(幂等,重复调用=升级)。
func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
var req frpsInstallRequest
@@ -254,10 +268,14 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported agent arch: " + runtime.GOARCH})
return
}
downloadURL := fmt.Sprintf("https://github.com/fatedier/frp/releases/download/v%s/frp_%s_linux_%s.tar.gz", version, version, arch)
if proxy := strings.TrimSpace(req.Proxy); proxy != "" {
downloadURL = strings.TrimRight(proxy, "/") + "/" + downloadURL
proxy := strings.TrimSpace(req.Proxy)
if _, ok := frpsDownloadProxies[proxy]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "不支持的下载加速代理"})
return
}
asset := fmt.Sprintf("frp_%s_linux_%s.tar.gz", version, arch)
releaseURL := proxy + frpsReleaseBaseURL + "v" + version + "/"
downloadURL := releaseURL + asset
workDir := filepath.Join(s.cfg.DataDir, "tmp-frps-"+version)
_ = os.RemoveAll(workDir)
defer os.RemoveAll(workDir)
@@ -265,11 +283,26 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
// 先取官方校验文件,再下载安装包并按 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
}
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
}
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
}
installPath := s.cfg.FrpsBinary
if !filepath.IsAbs(installPath) {
installPath = "/usr/local/bin/frps"
@@ -278,7 +311,7 @@ func (s *Server) installFRPS(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "解压 frps 失败: " + err.Error()})
return
}
notes := []string{"frps 二进制已安装: " + installPath}
notes := []string{"frps 二进制已安装: " + installPath, "安装包 SHA-256 校验通过"}
unitPath := "/etc/systemd/system/frps.service"
writeUnit := false
if _, err := os.Stat(unitPath); errors.Is(err, os.ErrNotExist) {
@@ -381,6 +414,43 @@ func downloadToFile(url, path string) error {
return err
}
// 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
}
// extractFile 从 tar.gz 中取出 name 对应的单个文件写入 dest。
func extractFile(tarball, name, dest string, mode os.FileMode) error {
file, err := os.Open(tarball)
@@ -692,11 +762,27 @@ func (s *Server) putRoute(w http.ResponseWriter, r *http.Request) {
}
s.mu.Lock()
defer s.mu.Unlock()
// 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
}
}
if err := s.writeRoute(req); err != nil {
if generatedPassword != "" {
// 部署失败就丢弃刚生成的凭据,否则重试时文件已存在、密码再也拿不到
_ = os.Remove(basicAuthFilePath(req.Domain))
}
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
result := map[string]any{"ok": true, "domain": req.Domain, "websocket": req.WebSocket}
if generatedPassword != "" {
result["basic_auth_user"] = strings.TrimSpace(req.BasicAuthUser)
result["basic_auth_password"] = generatedPassword
}
// 部署后回源自检:模拟 Nginx 的回源请求,区分三种状态:
// 2xx/3xx = 隧道通且服务响应;404 = 反代/frps 正常但无此域名的隧道注册;0/5xx = 回源失败或内网服务异常
if vhost := req.VhostPort; vhost > 0 {
@@ -1229,22 +1315,18 @@ func nginxRoute(req routeRequest, certDir string) string {
if len(req.AllowIPs) > 0 {
acl.WriteString(" deny all;\n")
}
// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式)
// Basic Auth:密码优先用规则表单值(自定义),留空则首次自动生成并复用(htpasswd 格式);
// 自动生成的明文由 putRoute 在响应里回一次,这里只写 auth_basic 指令,不写密码注释
authLine := ""
var authNote string
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))
}
} else if generated, err := ensureBasicAuthSecret(req.Domain, user); err == nil {
} else if _, err := ensureBasicAuthSecret(req.Domain, user); err == nil {
authLine = fmt.Sprintf(" auth_basic \"Fonu Protected\";\n auth_basic_user_file %s;\n", basicAuthFilePath(req.Domain))
if generated != "" {
authNote = fmt.Sprintf("# Basic Auth: user=%s password=%s\n", user, generated)
}
}
}
b.WriteString(authNote)
// 限流 / 连接数 / 仅中国大陆 IP 的 location 级指令(zone 与 geo 变量在 http 上下文定义)
var limitText strings.Builder
if req.RateLimitRate > 0 {