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

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 {
+4 -3
View File
@@ -42,19 +42,20 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
writeError(r, w, http.StatusBadRequest, "上传文件无效")
return
}
file, header, err := r.FormFile("file")
file, _, err := r.FormFile("file")
if err != nil {
writeError(r, w, http.StatusBadRequest, "请选择备份文件")
return
}
defer file.Close()
tmpPath := filepath.Join(os.TempDir(), header.Filename)
out, err := os.Create(tmpPath)
// 上传文件名完全由客户端控制:不能拼进临时路径(../ 可逃出临时目录),交给 CreateTemp 生成
out, err := os.CreateTemp("", "fonu-restore-*.tar.gz")
if err != nil {
writeError(r, w, http.StatusInternalServerError, "保存上传文件失败")
return
}
tmpPath := out.Name()
if _, err := io.Copy(out, file); err != nil {
out.Close()
_ = os.Remove(tmpPath)
+14 -5
View File
@@ -16,6 +16,12 @@ type Service struct {
dataDir string
}
// 还原时的固定权限:备份内容只有数据库、证书与 nginx 配置,不接受归档自带的权限位
const (
restoreFileMode = 0o640
restoreDirMode = 0o750
)
func New(dataDir string) *Service {
return &Service{dataDir: dataDir}
}
@@ -85,19 +91,22 @@ func (s *Service) Restore(ctx context.Context, archivePath string) error {
continue
}
target := filepath.Join(s.dataDir, header.Name)
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(s.dataDir)) {
return fmt.Errorf("备份条目非法")
// 归档条目必须落在 DataDir 内:前缀比较会被 ../data-other 这类同前缀路径绕过,用 Rel 判定
rel, err := filepath.Rel(s.dataDir, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("备份条目非法: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
if err := os.MkdirAll(target, restoreDirMode); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(target), restoreDirMode); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
// 归档里的权限位不照搬(构造的归档可带 0777 / 执行位):备份内容只有数据库、证书与 nginx 配置
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, restoreFileMode)
if err != nil {
return err
}
+5
View File
@@ -94,6 +94,11 @@ func (c Config) CertsDir() string {
return c.DataDir + "/certs"
}
// SSHKnownHostsPath 是 SSH 主机密钥的 TOFU 记录文件(首次连接记录指纹,之后必须一致)。
func (c Config) SSHKnownHostsPath() string {
return filepath.Join(c.DataDir, "ssh_known_hosts")
}
func (c Config) ErrorsDir() string {
return c.NginxDir() + "/errors"
}
+11 -9
View File
@@ -31,15 +31,17 @@ func (i AgentInstaller) Probe(ctx context.Context) (map[string]any, error) {
"agent_exist": out("test -x /usr/local/bin/fonu-agent && echo yes || echo no") == "yes",
"agent_running": out("systemctl is-active fonu-agent 2>/dev/null || true"),
"agent_version": out("/usr/local/bin/fonu-agent -version 2>/dev/null || echo 未知"),
"agent_token": out("test -f /etc/fonu-agent/agent.env && sed -n 's/^FONU_AGENT_TOKEN=//p' /etc/fonu-agent/agent.env || true"),
"frps_alive": out("systemctl is-active frps 2>/dev/null; pgrep -x frps >/dev/null && echo active || true"),
"frps_version": out("/usr/local/bin/frps --version 2>/dev/null || echo 未安装"),
"frps_config": out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"),
"frps_unit": out("systemctl status frps --no-pager 2>/dev/null | head -3 || echo 无 systemd 单位"),
"frps_log": out("journalctl -u frps -n 5 --no-pager 2>/dev/null || true"),
"port7000": out("ss -tlnp 2>/dev/null | grep ':7000 ' | head -1"),
"port80": out("ss -tlnp 2>/dev/null | grep ':80 ' | head -1"),
"port443": out("ss -tlnp 2>/dev/null | grep ':443 ' | head -1"),
// Token 只回「是否已配置 + 末 4 位」:明文不经过 HTTP 返回前端(库里同一字段也是掩码)
"agent_token_configured": out("test -s /etc/fonu-agent/agent.env && grep -q '^FONU_AGENT_TOKEN=' /etc/fonu-agent/agent.env && echo yes || echo no") == "yes",
"agent_token_hint": out("sed -n 's/^FONU_AGENT_TOKEN=//p' /etc/fonu-agent/agent.env 2>/dev/null | tr -d '\\n' | tail -c 4"),
"frps_alive": out("systemctl is-active frps 2>/dev/null; pgrep -x frps >/dev/null && echo active || true"),
"frps_version": out("/usr/local/bin/frps --version 2>/dev/null || echo 未安装"),
"frps_config": out("test -f /etc/frp/frps.toml && echo /etc/frp/frps.toml || echo 配置文件不存在"),
"frps_unit": out("systemctl status frps --no-pager 2>/dev/null | head -3 || echo 无 systemd 单位"),
"frps_log": out("journalctl -u frps -n 5 --no-pager 2>/dev/null || true"),
"port7000": out("ss -tlnp 2>/dev/null | grep ':7000 ' | head -1"),
"port80": out("ss -tlnp 2>/dev/null | grep ':80 ' | head -1"),
"port443": out("ss -tlnp 2>/dev/null | grep ':443 ' | head -1"),
}, nil
}
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -682,7 +682,7 @@ func (s *Service) sshInstaller(ctx context.Context, serverID int64) (Server, SSH
return Server{}, SSHInstaller{}, err
}
}
installer := SSHInstaller{Host: server.SSHHost, Port: server.SSHPort, User: server.SSHUser}
installer := SSHInstaller{Host: server.SSHHost, Port: server.SSHPort, User: server.SSHUser, KnownHostsPath: s.cfg.SSHKnownHostsPath()}
if server.SSHAuth == "password" {
installer.Password = secret
} else {
+60 -1
View File
@@ -2,14 +2,17 @@ package frp
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// SSHInstaller 通过固定白名单命令在 VPS 上安装/升级 fonu-agent。
@@ -21,6 +24,58 @@ type SSHInstaller struct {
Password string
PrivateKey string
Timeout time.Duration
// KnownHostsPath 是主机密钥记录文件:首次连接按 TOFU 记录指纹,之后必须一致
KnownHostsPath string
}
// hostKeyCallback 用 known_hosts 校验主机密钥:首次见到的主机按 TOFU 记录指纹,
// 已记录但与当前不一致则拒绝连接(避免中间人截获 VPS 的 root 凭据)。
func (i SSHInstaller) hostKeyCallback() (ssh.HostKeyCallback, error) {
path := strings.TrimSpace(i.KnownHostsPath)
if path == "" {
return nil, fmt.Errorf("未配置 SSH 主机密钥记录文件")
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.WriteFile(path, nil, 0o600); err != nil {
return nil, err
}
}
known, err := knownhosts.New(path)
if err != nil {
return nil, fmt.Errorf("读取 SSH 主机密钥记录失败: %w", err)
}
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := known(hostname, remote, key)
if err == nil {
return nil
}
var keyErr *knownhosts.KeyError
if errors.As(err, &keyErr) {
if len(keyErr.Want) == 0 {
return appendKnownHost(path, remote, key)
}
return fmt.Errorf("SSH 主机密钥与首次记录不一致(%s 期望 %s,实际 %s);若确认 VPS 重装过系统,请删除 %s 后重连",
remote.String(), ssh.FingerprintSHA256(keyErr.Want[0].Key), ssh.FingerprintSHA256(key), path)
}
return fmt.Errorf("SSH 主机密钥校验失败: %w", err)
}, nil
}
// appendKnownHost 把首次见到的主机密钥追加进 known_hosts(TOFU:首次信任,之后必须一致)。
func appendKnownHost(path string, remote net.Addr, key ssh.PublicKey) error {
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
line := knownhosts.Line([]string{knownhosts.Normalize(remote.String())}, key)
if _, err := file.WriteString(line + "\n"); err != nil {
return err
}
return nil
}
func (i SSHInstaller) client(ctx context.Context) (*ssh.Client, error) {
@@ -39,7 +94,11 @@ func (i SSHInstaller) client(ctx context.Context) (*ssh.Client, error) {
} else {
return nil, fmt.Errorf("SSH 密码或私钥不能为空")
}
config := &ssh.ClientConfig{User: i.User, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: i.Timeout}
hostKey, err := i.hostKeyCallback()
if err != nil {
return nil, err
}
config := &ssh.ClientConfig{User: i.User, Auth: []ssh.AuthMethod{auth}, HostKeyCallback: hostKey, Timeout: i.Timeout}
if config.Timeout == 0 {
config.Timeout = 15 * time.Second
}
+12 -7
View File
@@ -34,9 +34,9 @@ func (s *Store) LoadRuntime(ctx context.Context) (RuntimeConfig, error) {
}
type runtimeSecrets struct {
smtpPassword string
webhookSecret string
telegramToken string
smtpPassword string
webhookSecret string
telegramToken string
}
func (s *Store) load(ctx context.Context, decrypt bool) (Config, runtimeSecrets, error) {
@@ -401,6 +401,11 @@ func SanitizeSettings(values map[string]string) {
delete(values, settings.KeyNotifySMTPPassword)
delete(values, settings.KeyNotifyWebhookSecret)
delete(values, settings.KeyNotifyTelegramToken)
// ZeroSSL API Key 属证书申请凭据:API 永不回明文,只回掩码。前端以「非空」判断是否已配置,
// 且只在用户重新输入时才提交该字段,因此掩码不会被当成新密钥写回。
if values[settings.KeyZeroSSLAPIKey] != "" {
values[settings.KeyZeroSSLAPIKey] = MaskedSecret
}
}
func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
@@ -415,10 +420,10 @@ func ParseSaveInputFromMap(values map[string]string) (SaveInput, bool) {
}
in := SaveInput{
Type: NotifyType(values[settings.KeyNotifyType]),
SMTPPassword: values[settings.KeyNotifySMTPPassword],
WebhookSecret: values[settings.KeyNotifyWebhookSecret],
TelegramToken: values[settings.KeyNotifyTelegramToken],
Type: NotifyType(values[settings.KeyNotifyType]),
SMTPPassword: values[settings.KeyNotifySMTPPassword],
WebhookSecret: values[settings.KeyNotifyWebhookSecret],
TelegramToken: values[settings.KeyNotifyTelegramToken],
}
if raw := values[settings.KeyNotifyEmailJSON]; raw != "" {
_ = json.Unmarshal([]byte(raw), &in.Email)