fix(security): P0 修复——备份恢复路径与权限、frps 安装白名单与 SHA-256、SSH 主机密钥 TOFU、三处凭据掩码,另修状态徽标与仪表盘文案(v1.2.7)
This commit is contained in:
+94
-12
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user