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