- 自动备份:每日一份(按最新备份是否在 24h 内判定,重启不重复)、保留份数可配、列表/下载/恢复/删除,恢复前先校验归档;顺手修 writeArchive 逐个检查 tar/gzip/文件 Close()(审查报告 §二) - 健康持续化:只存状态变更(up/down/unknown)并推导可用率与不可用区间;公开状态页默认关闭、访问码存 SHA-256、按 IP 限流、只暴露域名与可用率 - 隧道判定修复:RouteHealthCandidate 新增 tunnel_known,frps 管理接口不可用时不再把「判不出来」当成「不通」(此前会误报「隧道连续不通」);汇总与徽标同样区分「无法判定」 - 指标历史:metrics_samples 纵向点表(本机 CPU/内存/磁盘 + 每规则上下行,30 天保留、查询按桶平均、不做降采样表);仪表盘趋势卡支持 1h/24h/7d - 仪表盘布局:运行状态并入顶部统计区(运行时长补进「服务」卡);性能并入系统资源那一行,整行 4 列 - 测试:backup/metrics/frp/api/app 新增用例;gofmt、go build/vet/test、vue-tsc、vite build 全绿 - agent 无改动,AgentVersion 保持 0.12.2
This commit is contained in:
@@ -7,14 +7,16 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/fonu/fonu/internal/backup"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
type BackupHandler struct {
|
||||
svc *backup.Service
|
||||
svc *backup.Service
|
||||
settings *settings.Store
|
||||
}
|
||||
|
||||
func NewBackupHandler(svc *backup.Service) *BackupHandler {
|
||||
return &BackupHandler{svc: svc}
|
||||
func NewBackupHandler(svc *backup.Service, settingsStore *settings.Store) *BackupHandler {
|
||||
return &BackupHandler{svc: svc, settings: settingsStore}
|
||||
}
|
||||
|
||||
func (h *BackupHandler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -72,3 +74,94 @@ func (h *BackupHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
||||
_ = os.Remove(tmpPath)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
||||
}
|
||||
|
||||
// ListArchives 列出留在磁盘上的备份(自动备份 + 手动「立即备份」)。
|
||||
// 与「立即下载」不同:这些文件长期留在 <DataDir>/backups 下。
|
||||
func (h *BackupHandler) ListArchives(w http.ResponseWriter, r *http.Request) {
|
||||
archives, err := h.svc.List()
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取备份列表失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"archives": archives,
|
||||
"keep": backup.KeepCount(r.Context(), h.settings),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateArchive 立即备份一份并保留在服务器上,顺带按保留份数剪枝
|
||||
func (h *BackupHandler) CreateArchive(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := h.svc.Export(r.Context())
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "创建备份失败", err)
|
||||
return
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
// 写失败的备份不能留在列表里冒充可用备份
|
||||
if _, err := h.svc.Verify(r.Context(), path); err != nil {
|
||||
_ = h.svc.Remove(name)
|
||||
writeError(r, w, http.StatusInternalServerError, "备份校验失败:"+err.Error(), err)
|
||||
return
|
||||
}
|
||||
removed, err := h.svc.Prune(backup.KeepCount(r.Context(), h.settings))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "备份已创建,但清理旧备份失败:"+err.Error(), err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "removed": removed})
|
||||
}
|
||||
|
||||
// DownloadArchive 下载指定备份;用 ServeContent 支持断点续传
|
||||
func (h *BackupHandler) DownloadArchive(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := h.svc.Path(r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusNotFound, "备份不存在")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取备份失败", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+filepath.Base(path))
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), file)
|
||||
}
|
||||
|
||||
// RestoreArchive 从指定备份恢复。恢复会覆盖数据库与证书,前端必须二次确认
|
||||
func (h *BackupHandler) RestoreArchive(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := h.svc.Path(r.PathValue("name"))
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// 恢复前先校验:半个包不能拿去覆盖现有数据
|
||||
if _, err := h.svc.Verify(r.Context(), path); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, "该备份不可用:"+err.Error(), err)
|
||||
return
|
||||
}
|
||||
if err := h.svc.Restore(r.Context(), path); err != nil {
|
||||
writeError(r, w, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "备份已恢复,建议重启 Fonu"})
|
||||
}
|
||||
|
||||
// DeleteArchive 删除指定备份
|
||||
func (h *BackupHandler) DeleteArchive(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.svc.Remove(r.PathValue("name")); err != nil {
|
||||
if err.Error() == "备份不存在" {
|
||||
writeError(r, w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(r, w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
+17
-15
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/fonu/fonu/internal/ddns"
|
||||
"github.com/fonu/fonu/internal/discovery"
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/metrics"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
@@ -19,19 +20,20 @@ import (
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
Config config.Config
|
||||
Logger *slog.Logger
|
||||
Auth *auth.Service
|
||||
Proxy *service.ProxyService
|
||||
DDNS *ddns.Service
|
||||
ACME *acme.Service
|
||||
Settings *settings.Store
|
||||
ChinaCIDR *chinacidr.Service
|
||||
Backup *backup.Service
|
||||
Discovery *discovery.Service
|
||||
FRPMulti *frp.Service
|
||||
Traffic *traffic.Collector
|
||||
Notify *notify.Service
|
||||
StaticFS fs.FS
|
||||
StartedAt string
|
||||
Config config.Config
|
||||
Logger *slog.Logger
|
||||
Auth *auth.Service
|
||||
Proxy *service.ProxyService
|
||||
DDNS *ddns.Service
|
||||
ACME *acme.Service
|
||||
Settings *settings.Store
|
||||
ChinaCIDR *chinacidr.Service
|
||||
Backup *backup.Service
|
||||
Metrics *metrics.Store
|
||||
Discovery *discovery.Service
|
||||
FRPMulti *frp.Service
|
||||
Traffic *traffic.Collector
|
||||
Notify *notify.Service
|
||||
StaticFS fs.FS
|
||||
StartedAt string
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
)
|
||||
@@ -439,6 +440,23 @@ func (h *FRPMultiHandler) AgentRouteConf(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"proxy_id": proxyID, "domain": domain, "content": content})
|
||||
}
|
||||
|
||||
// RouteUptime 返回每条反代规则在最近窗口内的可用率与不可用区间。
|
||||
// 数据来自后台巡检落库的「状态变更」序列,不做现场探测。
|
||||
func (h *FRPMultiHandler) RouteUptime(w http.ResponseWriter, r *http.Request) {
|
||||
hours := 24
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("hours")); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 24*7 {
|
||||
hours = parsed
|
||||
}
|
||||
}
|
||||
stats, err := h.svc.RouteUptime(r.Context(), time.Duration(hours)*time.Hour)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取可用率失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"hours": hours, "items": stats})
|
||||
}
|
||||
|
||||
// AgentRouteVersions 返回规则首个域名在 VPS 上的当前 vhost 与历史版本,供差异对比与回滚。
|
||||
func (h *FRPMultiHandler) AgentRouteVersions(w http.ResponseWriter, r *http.Request) {
|
||||
serverID, _, domain, status, msg := h.agentRouteTarget(r, r.PathValue("id"), r.PathValue("proxyID"))
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/metrics"
|
||||
)
|
||||
|
||||
type MetricsHandler struct {
|
||||
store *metrics.Store
|
||||
}
|
||||
|
||||
func NewMetricsHandler(store *metrics.Store) *MetricsHandler {
|
||||
return &MetricsHandler{store: store}
|
||||
}
|
||||
|
||||
// History 返回指标历史:窗口内的序列已按桶取平均,前端拿到就能直接画。
|
||||
// hours 最多 30 天(=保留期),buckets 最多 metrics.MaxBuckets。
|
||||
func (h *MetricsHandler) History(w http.ResponseWriter, r *http.Request) {
|
||||
hours := 24
|
||||
if raw := r.URL.Query().Get("hours"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 24*30 {
|
||||
hours = parsed
|
||||
}
|
||||
}
|
||||
buckets := 48
|
||||
if raw := r.URL.Query().Get("buckets"); raw != "" {
|
||||
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
|
||||
buckets = parsed
|
||||
}
|
||||
}
|
||||
if buckets > metrics.MaxBuckets {
|
||||
buckets = metrics.MaxBuckets
|
||||
}
|
||||
series, err := h.store.Query(r.Context(), time.Duration(hours)*time.Hour, buckets)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取指标历史失败", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"hours": hours,
|
||||
"buckets": buckets,
|
||||
"series": series,
|
||||
})
|
||||
}
|
||||
+41
-28
@@ -13,25 +13,29 @@ import (
|
||||
func NewRouter(deps Deps) http.Handler {
|
||||
logsHandler := NewLogsHandler(deps.Config)
|
||||
r := &Router{
|
||||
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
|
||||
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
|
||||
nginxHandler: NewNginxHandler(deps.Proxy),
|
||||
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.StartedAt, deps.Config.NginxPIDFile),
|
||||
ddnsHandler: NewDDNSHandler(deps.DDNS),
|
||||
certHandler: NewCertHandler(deps.ACME),
|
||||
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
|
||||
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
|
||||
logsHandler: logsHandler,
|
||||
backupHandler: NewBackupHandler(deps.Backup),
|
||||
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
|
||||
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti),
|
||||
authSvc: deps.Auth,
|
||||
staticFS: deps.StaticFS,
|
||||
authHandler: NewAuthHandler(deps.Auth, deps.Notify),
|
||||
proxyHandler: NewProxyHandler(deps.Config, deps.Proxy, logsHandler, deps.Traffic),
|
||||
nginxHandler: NewNginxHandler(deps.Proxy),
|
||||
statusHandler: NewStatusHandler(deps.Config, deps.Proxy, deps.DDNS, deps.ACME, deps.StartedAt, deps.Config.NginxPIDFile),
|
||||
ddnsHandler: NewDDNSHandler(deps.DDNS),
|
||||
certHandler: NewCertHandler(deps.ACME),
|
||||
settingsHandler: NewSettingsHandler(deps.Settings, deps.Proxy, deps.Notify),
|
||||
chinaCIDRHandler: NewChinaCIDRHandler(deps.ChinaCIDR),
|
||||
logsHandler: logsHandler,
|
||||
backupHandler: NewBackupHandler(deps.Backup, deps.Settings),
|
||||
statusPageHandler: NewStatusPageHandler(deps.Settings, deps.FRPMulti),
|
||||
metricsHandler: NewMetricsHandler(deps.Metrics),
|
||||
discoveryHandler: NewDiscoveryHandler(deps.Discovery),
|
||||
frpmultiHandler: NewFRPMultiHandler(deps.FRPMulti),
|
||||
authSvc: deps.Auth,
|
||||
staticFS: deps.StaticFS,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /api/version", Version(deps.Config))
|
||||
// 唯一一个匿名只读接口:默认关闭、可设访问码、按 IP 限流
|
||||
mux.HandleFunc("GET /api/status-page", r.statusPageHandler.Get)
|
||||
mux.HandleFunc("GET /api/auth/status", r.authHandler.Status)
|
||||
mux.HandleFunc("POST /api/auth/login", r.authHandler.Login)
|
||||
mux.HandleFunc("POST /api/auth/logout", r.authHandler.Logout)
|
||||
@@ -88,9 +92,15 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("GET /api/logs/access", r.logsHandler.Access)
|
||||
protect("GET /api/logs/error", r.logsHandler.Error)
|
||||
protect("GET /api/logs/system", r.logsHandler.System)
|
||||
protect("GET /api/metrics/history", r.metricsHandler.History)
|
||||
protect("GET /api/logs/stream", r.logsHandler.Stream)
|
||||
protect("GET /api/backup/export", r.backupHandler.Export)
|
||||
protect("POST /api/backup/restore", r.backupHandler.Restore)
|
||||
protect("GET /api/backup/archives", r.backupHandler.ListArchives)
|
||||
protect("POST /api/backup/archives", r.backupHandler.CreateArchive)
|
||||
protect("GET /api/backup/archives/{name}", r.backupHandler.DownloadArchive)
|
||||
protect("POST /api/backup/archives/{name}/restore", r.backupHandler.RestoreArchive)
|
||||
protect("DELETE /api/backup/archives/{name}", r.backupHandler.DeleteArchive)
|
||||
protect("GET /api/discovery/scan", r.discoveryHandler.Scan)
|
||||
if deps.FRPMulti != nil {
|
||||
frpHandler := r.frpmultiHandler
|
||||
@@ -122,6 +132,7 @@ func NewRouter(deps Deps) http.Handler {
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes", frpHandler.AgentRoutes)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/health", frpHandler.AgentRouteHealth)
|
||||
protect("GET /api/frpmulti/routes/summary", frpHandler.AgentRouteHealthSummary)
|
||||
protect("GET /api/frpmulti/routes/uptime", frpHandler.RouteUptime)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/routes/drift", frpHandler.AgentRouteDrift)
|
||||
protect("POST /api/frpmulti/servers/{id}/agent/routes/redeploy", frpHandler.AgentRedeployRoutes)
|
||||
protect("GET /api/frpmulti/servers/{id}/agent/metrics", frpHandler.AgentMetrics)
|
||||
@@ -165,20 +176,22 @@ func NewRouter(deps Deps) http.Handler {
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
authHandler *AuthHandler
|
||||
proxyHandler *ProxyHandler
|
||||
nginxHandler *NginxHandler
|
||||
statusHandler *StatusHandler
|
||||
ddnsHandler *DDNSHandler
|
||||
certHandler *CertHandler
|
||||
settingsHandler *SettingsHandler
|
||||
chinaCIDRHandler *ChinaCIDRHandler
|
||||
logsHandler *LogsHandler
|
||||
backupHandler *BackupHandler
|
||||
discoveryHandler *DiscoveryHandler
|
||||
frpmultiHandler *FRPMultiHandler
|
||||
authSvc *auth.Service
|
||||
staticFS fs.FS
|
||||
authHandler *AuthHandler
|
||||
proxyHandler *ProxyHandler
|
||||
nginxHandler *NginxHandler
|
||||
statusHandler *StatusHandler
|
||||
ddnsHandler *DDNSHandler
|
||||
certHandler *CertHandler
|
||||
settingsHandler *SettingsHandler
|
||||
chinaCIDRHandler *ChinaCIDRHandler
|
||||
logsHandler *LogsHandler
|
||||
backupHandler *BackupHandler
|
||||
statusPageHandler *StatusPageHandler
|
||||
metricsHandler *MetricsHandler
|
||||
discoveryHandler *DiscoveryHandler
|
||||
frpmultiHandler *FRPMultiHandler
|
||||
authSvc *auth.Service
|
||||
staticFS fs.FS
|
||||
}
|
||||
|
||||
func (r *Router) spaHandler() http.Handler {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/service"
|
||||
@@ -29,6 +30,11 @@ func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
notify.SanitizeSettings(values)
|
||||
// 状态页访问码只回掩码:既不下发明文也不下发哈希
|
||||
if values[settings.KeyStatusPageCodeHash] != "" {
|
||||
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
|
||||
}
|
||||
delete(values, settings.KeyStatusPageCodeHash)
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
|
||||
@@ -80,6 +86,13 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
settings.KeyNotifyFrpsFailThreshold: true,
|
||||
settings.KeyNotifyAgentFailThreshold: true,
|
||||
settings.KeyNotifyDriftCooldownHours: true,
|
||||
settings.KeyBackupAutoEnabled: true,
|
||||
settings.KeyRouteHealthEnabled: true,
|
||||
settings.KeyStatusPageEnabled: true,
|
||||
settings.KeyMetricsHistoryEnabled: true,
|
||||
settings.KeyStatusPageCode: true,
|
||||
settings.KeyBackupKeepCount: true,
|
||||
settings.KeyNotifyOnBackupFailure: true,
|
||||
settings.KeyTrustedProxy: true,
|
||||
settings.KeyGlobalIPBlacklist: true,
|
||||
settings.KeyGlobalIPWhitelist: true,
|
||||
@@ -98,6 +111,19 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
filtered[key] = value
|
||||
}
|
||||
// 状态页访问码走「掩码往返」:客户端提交明文,这里落 SHA-256;
|
||||
// 空串 = 清除访问码,掩码值 = 保持不变。哈希字段不接受客户端直接写入。
|
||||
if code, ok := filtered[settings.KeyStatusPageCode]; ok {
|
||||
delete(filtered, settings.KeyStatusPageCode)
|
||||
trimmed := strings.TrimSpace(code)
|
||||
switch {
|
||||
case trimmed == MaskedStatusPageCode:
|
||||
case trimmed == "":
|
||||
filtered[settings.KeyStatusPageCodeHash] = ""
|
||||
default:
|
||||
filtered[settings.KeyStatusPageCodeHash] = hashStatusPageCode(trimmed)
|
||||
}
|
||||
}
|
||||
if h.notify != nil && h.notify.Store() != nil {
|
||||
if in, ok := notify.ParseSaveInputFromMap(filtered); ok {
|
||||
if err := h.notify.Store().Save(r.Context(), in); err != nil {
|
||||
@@ -127,6 +153,11 @@ func (h *SettingsHandler) Put(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
notify.SanitizeSettings(values)
|
||||
// 状态页访问码只回掩码:既不下发明文也不下发哈希
|
||||
if values[settings.KeyStatusPageCodeHash] != "" {
|
||||
values[settings.KeyStatusPageCode] = MaskedStatusPageCode
|
||||
}
|
||||
delete(values, settings.KeyStatusPageCodeHash)
|
||||
writeJSON(w, http.StatusOK, values)
|
||||
}
|
||||
|
||||
@@ -163,6 +194,7 @@ func isNotifySettingKey(key string) bool {
|
||||
settings.KeyNotifyFrpsFailThreshold,
|
||||
settings.KeyNotifyAgentFailThreshold,
|
||||
settings.KeyNotifyDriftCooldownHours,
|
||||
settings.KeyNotifyOnBackupFailure,
|
||||
settings.KeyNotifyWebhookURL:
|
||||
return true
|
||||
default:
|
||||
@@ -199,6 +231,13 @@ func (h *SettingsHandler) validateSetting(key, value string) error {
|
||||
return fmt.Errorf("统计窗口需在 10–3600 秒之间")
|
||||
}
|
||||
}
|
||||
case settings.KeyBackupKeepCount:
|
||||
if value != "" {
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n < 1 || n > 60 {
|
||||
return fmt.Errorf("备份保留份数需在 1–60 之间")
|
||||
}
|
||||
}
|
||||
case settings.KeyNotifyLoginFailureThreshold:
|
||||
if value != "" {
|
||||
n, err := strconv.Atoi(value)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fonu/fonu/internal/frp"
|
||||
"github.com/fonu/fonu/internal/notify"
|
||||
"github.com/fonu/fonu/internal/settings"
|
||||
)
|
||||
|
||||
// statusPageWindow / statusPageLimit 匿名访问的限流窗口与配额:
|
||||
// 状态页会被扫描器盯上,这里比登录接口更宽松但仍有限度。
|
||||
const (
|
||||
statusPageWindow = time.Minute
|
||||
statusPageLimit = 60
|
||||
)
|
||||
|
||||
// StatusPageHandler 提供**匿名只读**状态页数据,默认关闭。
|
||||
//
|
||||
// 刻意只暴露域名与可用率:不带内网地址、端口、证书路径、服务端地址——
|
||||
// 状态页是要挂到公网上的东西,泄露拓扑就等于把内网结构摊开给别人看。
|
||||
type StatusPageHandler struct {
|
||||
settings *settings.Store
|
||||
frp *frp.Service
|
||||
limiter *statusPageLimiter
|
||||
}
|
||||
|
||||
func NewStatusPageHandler(settingsStore *settings.Store, frpSvc *frp.Service) *StatusPageHandler {
|
||||
return &StatusPageHandler{
|
||||
settings: settingsStore,
|
||||
frp: frpSvc,
|
||||
limiter: newStatusPageLimiter(),
|
||||
}
|
||||
}
|
||||
|
||||
type statusPageRoute struct {
|
||||
Domain string `json:"domain"`
|
||||
State string `json:"state"`
|
||||
Uptime float64 `json:"uptime_pct"`
|
||||
DownMinutes int `json:"down_minutes"`
|
||||
UnknownMinutes int `json:"unknown_minutes"`
|
||||
}
|
||||
|
||||
type statusPagePayload struct {
|
||||
Title string `json:"title"`
|
||||
CodeRequired bool `json:"code_required"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Up int `json:"up"`
|
||||
Down int `json:"down"`
|
||||
Unknown int `json:"unknown"`
|
||||
Routes []statusPageRoute `json:"routes"`
|
||||
}
|
||||
|
||||
// Get 是唯一的匿名入口:关闭时一律 404(与「不存在」不可区分),
|
||||
// 设了访问码时校验 ?code=,校验失败也回 404。
|
||||
func (h *StatusPageHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
if h == nil || h.settings == nil {
|
||||
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||
return
|
||||
}
|
||||
if !h.limiter.allow(clientIPForLimit(r)) {
|
||||
writeError(r, w, http.StatusTooManyRequests, "请求过于频繁")
|
||||
return
|
||||
}
|
||||
|
||||
enabled, _ := h.settings.GetBool(r.Context(), settings.KeyStatusPageEnabled)
|
||||
if !enabled || h.frp == nil {
|
||||
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||
return
|
||||
}
|
||||
codeHash, err := h.settings.Get(r.Context(), settings.KeyStatusPageCodeHash)
|
||||
if err != nil {
|
||||
codeHash = ""
|
||||
}
|
||||
codeRequired := strings.TrimSpace(codeHash) != ""
|
||||
if codeRequired && hashStatusPageCode(r.URL.Query().Get("code")) != codeHash {
|
||||
writeError(r, w, http.StatusNotFound, "状态页未开启")
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.frp.RouteUptime(r.Context(), 24*time.Hour)
|
||||
if err != nil {
|
||||
writeError(r, w, http.StatusInternalServerError, "读取状态失败", err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := statusPagePayload{
|
||||
Title: "Fonu 服务状态",
|
||||
CodeRequired: codeRequired,
|
||||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Routes: make([]statusPageRoute, 0, len(stats)),
|
||||
}
|
||||
for _, stat := range stats {
|
||||
switch stat.State {
|
||||
case frp.RouteHealthUp:
|
||||
payload.Up++
|
||||
case frp.RouteHealthDown:
|
||||
payload.Down++
|
||||
default:
|
||||
payload.Unknown++
|
||||
}
|
||||
payload.Routes = append(payload.Routes, statusPageRoute{
|
||||
Domain: stat.Domain,
|
||||
State: stat.State,
|
||||
Uptime: stat.Uptime,
|
||||
DownMinutes: stat.DownMinutes,
|
||||
UnknownMinutes: stat.UnknownMinutes,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func hashStatusPageCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(code)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// clientIPForLimit 取限流用的客户端 IP
|
||||
func clientIPForLimit(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// statusPageLimiter 按 IP 的滑动窗口限流;只给匿名状态页用
|
||||
type statusPageLimiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newStatusPageLimiter() *statusPageLimiter {
|
||||
return &statusPageLimiter{hits: map[string][]time.Time{}, now: time.Now}
|
||||
}
|
||||
|
||||
func (l *statusPageLimiter) allow(ip string) bool {
|
||||
now := l.now()
|
||||
cutoff := now.Add(-statusPageWindow)
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
kept := make([]time.Time, 0, len(l.hits[ip])+1)
|
||||
for _, at := range l.hits[ip] {
|
||||
if at.After(cutoff) {
|
||||
kept = append(kept, at)
|
||||
}
|
||||
}
|
||||
if len(kept) >= statusPageLimit {
|
||||
l.hits[ip] = kept
|
||||
return false
|
||||
}
|
||||
l.hits[ip] = append(kept, now)
|
||||
// 顺手清理长期不活跃的 IP,避免 map 无限增长
|
||||
if len(l.hits) > 1024 {
|
||||
for key, times := range l.hits {
|
||||
if len(times) == 0 {
|
||||
delete(l.hits, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MaskedStatusPageCode 供设置接口回显用(与通知模块的掩码约定一致)
|
||||
const MaskedStatusPageCode = notify.MaskedSecret
|
||||
@@ -0,0 +1,43 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStatusPageLimiterBlocksAfterLimit(t *testing.T) {
|
||||
limiter := newStatusPageLimiter()
|
||||
now := time.Date(2026, 9, 25, 10, 0, 0, 0, time.UTC)
|
||||
limiter.now = func() time.Time { return now }
|
||||
|
||||
for i := 0; i < statusPageLimit; i++ {
|
||||
if !limiter.allow("1.2.3.4") {
|
||||
t.Fatalf("第 %d 次请求应放行", i+1)
|
||||
}
|
||||
}
|
||||
if limiter.allow("1.2.3.4") {
|
||||
t.Fatal("超过配额应拒绝")
|
||||
}
|
||||
// 限流按 IP 计:别的访客不该被牵连
|
||||
if !limiter.allow("5.6.7.8") {
|
||||
t.Fatal("其它 IP 不应被牵连")
|
||||
}
|
||||
// 窗口滑过后恢复
|
||||
now = now.Add(statusPageWindow + time.Second)
|
||||
if !limiter.allow("1.2.3.4") {
|
||||
t.Fatal("窗口过后应恢复放行")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashStatusPageCodeHidesPlaintext(t *testing.T) {
|
||||
hash := hashStatusPageCode("my-secret")
|
||||
if hash == "my-secret" || len(hash) != 64 {
|
||||
t.Fatalf("访问码应以 SHA-256 存储,实际 %q", hash)
|
||||
}
|
||||
if hashStatusPageCode(" my-secret ") != hash {
|
||||
t.Fatal("前后空白裁剪后应得到同一哈希")
|
||||
}
|
||||
if hashStatusPageCode("other") == hash {
|
||||
t.Fatal("不同访问码不应得到同一哈希")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user