package client import ( "encoding/json" "fmt" "io" "net" "net/http" "net/url" "strings" "time" "github.com/DeepFal/PortRelay/core/protocol" ) const maxBodyBytes = 64 * 1024 // APIServer provides REST and SSE endpoints for the dashboard type APIServer struct { cm *ConfigManager agent *Agent fs http.FileSystem // OnShutdown 由 POST /api/shutdown 触发(菜单栏“退出”时彻底结束后台引擎) OnShutdown func() } // NewAPIServer creates an API server func NewAPIServer(cm *ConfigManager, agent *Agent, fs http.FileSystem) *APIServer { return &APIServer{ cm: cm, agent: agent, fs: fs, } } // Handler returns the http handler with routing func (s *APIServer) Handler() http.Handler { mux := http.NewServeMux() // API endpoints mux.HandleFunc("/api/status", s.handleStatus) mux.HandleFunc("/api/tunnels", s.handleTunnels) mux.HandleFunc("/api/tunnels/", s.handleTunnelItem) mux.HandleFunc("/api/probe", s.handleProbe) mux.HandleFunc("/api/recommend-port", s.handleRecommendPort) mux.HandleFunc("/api/connections", s.handleConnections) mux.HandleFunc("/api/connections/kill", s.handleKillConn) mux.HandleFunc("/api/history", s.handleClientHistory) mux.HandleFunc("/api/reconnect", s.handleReconnect) mux.HandleFunc("/api/settings", s.handleSettings) mux.HandleFunc("/api/stream", s.handleSSE) mux.HandleFunc("/api/shutdown", s.handleShutdown) // Static web files if s.fs != nil { fileServer := http.FileServer(s.fs) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api") { http.NotFound(w, r) return } fileServer.ServeHTTP(w, r) }) } return localOnlyMiddleware(mux) } func isLoopbackHost(hostport string) bool { host := hostport if h, _, err := net.SplitHostPort(hostport); err == nil { host = h } host = strings.Trim(host, "[]") if strings.EqualFold(host, "localhost") { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } // localOnlyMiddleware 只接受本机面板与原生 App 的请求: // - Host 必须是回环地址(防 DNS rebinding) // - 带 Origin 的请求必须同源(防任意网页通过浏览器跨站调用本地 API) func localOnlyMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !isLoopbackHost(r.Host) { http.Error(w, "forbidden host", http.StatusForbidden) return } if origin := r.Header.Get("Origin"); origin != "" { u, err := url.Parse(origin) if err != nil || !strings.EqualFold(u.Host, r.Host) { http.Error(w, "cross-origin request rejected", http.StatusForbidden) return } } if r.Method != http.MethodGet && r.Method != http.MethodHead { r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) } next.ServeHTTP(w, r) }) } func (s *APIServer) statusPayload() map[string]any { cfg := s.cm.Get() return map[string]any{ "connected": s.agent.IsConnected(), "server_addr": cfg.ServerAddr, "public_host": cfg.PublicHostOrDefault(), "last_error": s.agent.LastError(), "invite_base": s.inviteBase(cfg), "total_rate_history": s.agent.RateSeries(""), "port_range": fmt.Sprintf("%d - %d", PortMin, PortMax), "timestamp": time.Now(), } } func (s *APIServer) handleStatus(w http.ResponseWriter, r *http.Request) { jsonResponse(w, http.StatusOK, s.statusPayload()) } // TunnelWithMetrics joins config with current metrics type TunnelWithMetrics struct { protocol.TunnelConfig Metrics protocol.TunnelMetrics `json:"metrics"` Activity *TunnelActivity `json:"activity,omitempty"` // EffectiveIdleCloseMinutes 生效的闲置自动关闭时长(0 表示不自动关闭) EffectiveIdleCloseMinutes int `json:"effective_idle_close_minutes"` // RateHistory 近 10 分钟流量曲线(仅开启中的通道) RateHistory *RateSeries `json:"rate_history,omitempty"` } // tunnelsWithMetrics merges config, live metrics and registration errors into one consistent view. // status: disabled | offline (未连接服务器) | error (服务器拒绝注册) | syncing (等待指标) | online func (s *APIServer) tunnelsWithMetrics(metrics protocol.MetricsMessage) []TunnelWithMetrics { cfg := s.cm.Get() connected := s.agent.IsConnected() metricsMap := make(map[string]protocol.TunnelMetrics) for _, m := range metrics.Tunnels { metricsMap[m.TunnelID] = m } result := make([]TunnelWithMetrics, 0, len(cfg.Tunnels)) for _, t := range cfg.Tunnels { tm := protocol.TunnelMetrics{TunnelID: t.ID, ListenPort: t.ListenPort} switch { case !t.Enabled: tm.Status = "disabled" case !connected: tm.Status = "offline" case s.agent.TunnelError(t.ID) != "": tm.Status = "error" tm.Error = s.agent.TunnelError(t.ID) default: if m, ok := metricsMap[t.ID]; ok { tm = m } else { tm.Status = "syncing" } } item := TunnelWithMetrics{ TunnelConfig: t, Metrics: tm, Activity: s.agent.Activity(t), EffectiveIdleCloseMinutes: IdleCloseMinutes(t), } if t.Enabled { series := s.agent.RateSeries(t.ID) item.RateHistory = &series } result = append(result, item) } return result } func (s *APIServer) handleTunnels(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: jsonResponse(w, http.StatusOK, s.tunnelsWithMetrics(s.agent.GetLatestMetrics())) case http.MethodPost: var t protocol.TunnelConfig if err := json.NewDecoder(r.Body).Decode(&t); err != nil { jsonError(w, http.StatusBadRequest, "Invalid JSON payload") return } created, err := s.cm.AddTunnel(t) if err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } s.agent.SyncTunnel(created, false) jsonResponse(w, http.StatusCreated, created) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (s *APIServer) handleTunnelItem(w http.ResponseWriter, r *http.Request) { parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") if len(parts) < 3 || parts[2] == "" { jsonError(w, http.StatusBadRequest, "Missing tunnel ID") return } tunnelID := parts[2] if len(parts) == 4 && parts[3] == "toggle" && r.Method == http.MethodPost { s.handleToggle(w, r, tunnelID) return } // POST /api/tunnels/{id}/leases/revoke {"ip": "..."};ip 为空表示撤销该通道全部临时放通 if len(parts) == 5 && parts[3] == "leases" && parts[4] == "revoke" && r.Method == http.MethodPost { var req struct { IP string `json:"ip"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { jsonError(w, http.StatusBadRequest, "Invalid JSON") return } if _, ok := s.cm.FindTunnel(tunnelID); !ok { jsonError(w, http.StatusNotFound, "通道未找到") return } if err := s.agent.RevokeLease(tunnelID, strings.TrimSpace(req.IP)); err != nil { jsonError(w, http.StatusConflict, err.Error()) return } jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"}) return } // 邀请链接:POST 新建;DELETE /invites/{code} 作废;GET /invites/{code}/share 获取分享文案 if len(parts) >= 4 && parts[3] == "invites" { s.handleInvites(w, r, tunnelID, parts[4:]) return } // POST /api/tunnels/{id}/requests/{rid} {"approve": true|false} if len(parts) == 5 && parts[3] == "requests" && r.Method == http.MethodPost { var req struct { Approve bool `json:"approve"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, "Invalid JSON") return } if err := s.agent.DecideRequest(tunnelID, parts[4], req.Approve); err != nil { jsonError(w, http.StatusConflict, err.Error()) return } jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"}) return } if len(parts) == 4 && parts[3] == "refresh-auth-code" && r.Method == http.MethodPost { t, err := s.cm.RefreshAuthCode(tunnelID) if err != nil { jsonError(w, http.StatusNotFound, err.Error()) return } s.agent.SyncTunnel(t, true) jsonResponse(w, http.StatusOK, t) return } if len(parts) != 3 { http.NotFound(w, r) return } switch r.Method { case http.MethodPut: body, err := io.ReadAll(r.Body) if err != nil { jsonError(w, http.StatusBadRequest, "Invalid body") return } updated, _, err := s.cm.PatchTunnel(tunnelID, body) if err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } s.agent.SyncTunnel(updated, true) jsonResponse(w, http.StatusOK, updated) case http.MethodDelete: if err := s.cm.DeleteTunnel(tunnelID); err != nil { jsonError(w, http.StatusNotFound, err.Error()) return } s.agent.ConsumeSessionBytes(tunnelID) s.agent.RemoveTunnel(tunnelID) jsonResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) default: w.WriteHeader(http.StatusMethodNotAllowed) } } // handleToggle 支持 {"enabled": true|false} 指定目标状态(幂等,防止重复点击来回翻转);不带 body 时取反 func (s *APIServer) handleToggle(w http.ResponseWriter, r *http.Request, tunnelID string) { var req struct { Enabled *bool `json:"enabled"` } if r.Body != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { jsonError(w, http.StatusBadRequest, "Invalid JSON") return } } current, err := s.cm.CurrentEnabled(tunnelID) if err != nil { jsonError(w, http.StatusNotFound, err.Error()) return } want := !current if req.Enabled != nil { want = *req.Enabled } var sessIn, sessOut int64 if !want && current { s.agent.FlushUsage(tunnelID) } if want != current { // 关闭时把本次会话流量并入历史;开启时清掉可能残留的旧指标 sessIn, sessOut = s.agent.ConsumeSessionBytes(tunnelID) } t, changed, err := s.cm.SetTunnelEnabled(tunnelID, want, sessIn, sessOut) if err != nil { jsonError(w, http.StatusNotFound, err.Error()) return } if changed { s.agent.dropUsage(tunnelID) s.agent.SyncTunnel(t, false) } jsonResponse(w, http.StatusOK, t) } // inviteBase is the URL prefix of invite links: http://<对外访问地址>:<控制端口>/i/ func (s *APIServer) inviteBase(cfg Config) string { port := "33000" if _, p, err := net.SplitHostPort(cfg.ServerAddr); err == nil { port = p } host := cfg.PublicHostOrDefault() if host == "" { return "" } return "http://" + net.JoinHostPort(host, port) + "/i/" } // shareText is the ready-to-send message (e.g. for WeChat) for an invite func shareText(cfg Config, t protocol.TunnelConfig, inv protocol.Invite, link string) string { var b strings.Builder b.WriteString("【PortRelay 访问邀请】") if inv.Note != "" { b.WriteString("(给 " + inv.Note + ")") } b.WriteString("\n通道:" + t.Name) b.WriteString(fmt.Sprintf("\n访问地址:%s:%d", cfg.PublicHostOrDefault(), t.ListenPort)) action := "填写姓名后即可访问" if inv.RequireApproval { action = "填写姓名提交申请,管理员批准后即可访问" } b.WriteString(fmt.Sprintf("\n请在 %s 前用浏览器打开下面的链接,%s(访问有效期:%s):\n%s", inv.ExpiresAt.Local().Format("01-02 15:04"), action, protocol.DescribeLeaseDuration(inv.LeaseDuration), link)) if inv.MaxUses == 1 { b.WriteString("\n此链接仅限 1 台设备使用,请勿转发。") } else if inv.MaxUses > 1 { b.WriteString(fmt.Sprintf("\n此链接最多 %d 台设备使用,请勿转发。", inv.MaxUses)) } return b.String() } func (s *APIServer) handleInvites(w http.ResponseWriter, r *http.Request, tunnelID string, rest []string) { t, ok := s.cm.FindTunnel(tunnelID) if !ok { jsonError(w, http.StatusNotFound, "通道未找到") return } cfg := s.cm.Get() switch { case len(rest) == 0 && r.Method == http.MethodPost: var req struct { Note string `json:"note"` ValidMinutes int `json:"valid_minutes"` MaxUses *int `json:"max_uses"` LeaseDuration string `json:"lease_duration"` // 授权后可访问时长,默认 2 小时 RequireApproval bool `json:"require_approval"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { jsonError(w, http.StatusBadRequest, "Invalid JSON") return } // 默认:24 小时内有效、限 1 台设备 if req.ValidMinutes == 0 { req.ValidMinutes = 24 * 60 } maxUses := 1 if req.MaxUses != nil { maxUses = *req.MaxUses } if !protocol.ValidLeaseDuration(req.LeaseDuration) { jsonError(w, http.StatusBadRequest, "无效的授权时长") return } if req.LeaseDuration == "" { req.LeaseDuration = protocol.LeaseDuration2h } if !t.Enabled { jsonError(w, http.StatusConflict, "通道未开启,请先开启通道再生成邀请链接") return } inv, err := s.agent.CreateInvite(tunnelID, strings.TrimSpace(req.Note), req.ValidMinutes, maxUses, req.LeaseDuration, req.RequireApproval) if err != nil { jsonError(w, http.StatusConflict, err.Error()) return } link := s.inviteBase(cfg) + inv.Code jsonResponse(w, http.StatusCreated, map[string]any{ "invite": inv, "link": link, "share_text": shareText(cfg, t, inv, link), }) case len(rest) == 1 && r.Method == http.MethodDelete: if err := s.agent.RevokeInvite(tunnelID, rest[0]); err != nil { jsonError(w, http.StatusConflict, err.Error()) return } jsonResponse(w, http.StatusOK, map[string]string{"status": "revoked"}) case len(rest) == 2 && rest[1] == "share" && r.Method == http.MethodGet: for _, m := range s.agent.GetLatestMetrics().Tunnels { if m.TunnelID != tunnelID { continue } for _, inv := range m.Invites { if inv.Code == rest[0] { link := s.inviteBase(cfg) + inv.Code jsonResponse(w, http.StatusOK, map[string]any{"invite": inv, "link": link, "share_text": shareText(cfg, t, inv, link)}) return } } } jsonError(w, http.StatusNotFound, "邀请链接不存在或已过期") default: http.NotFound(w, r) } } func (s *APIServer) handleReconnect(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } // mode=check:仅在会话不健康时重连(系统唤醒时使用) force := r.URL.Query().Get("mode") != "check" go s.agent.Reconnect(force) jsonResponse(w, http.StatusOK, map[string]string{"status": "reconnecting"}) } func (s *APIServer) handleProbe(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { Target string `json:"target"` } _ = json.NewDecoder(r.Body).Decode(&req) req.Target = strings.TrimSpace(req.Target) if req.Target == "" { jsonError(w, http.StatusBadRequest, "target is required") return } res := ProbeTarget(req.Target, 2*time.Second) jsonResponse(w, http.StatusOK, map[string]any{ "target": res.Target, "reachable": res.Reachable, "rtt": res.FormatRTT(), "error": res.Error, }) } func (s *APIServer) handleRecommendPort(w http.ResponseWriter, r *http.Request) { port := s.cm.RecommendPort() if port == 0 { jsonError(w, http.StatusConflict, "端口池已无空闲端口") return } jsonResponse(w, http.StatusOK, map[string]int{"recommended_port": port}) } // handleConnections returns live client connections (optionally for one tunnel) and recent security events func (s *APIServer) handleConnections(w http.ResponseWriter, r *http.Request) { metrics := s.agent.GetLatestMetrics() tunnelID := r.URL.Query().Get("tunnel_id") conns := make([]protocol.ActiveConnInfo, 0, len(metrics.ActiveConns)) for _, c := range metrics.ActiveConns { if tunnelID == "" || c.TunnelID == tunnelID { conns = append(conns, c) } } events := metrics.SecurityEvents if events == nil { events = []protocol.SecurityEvent{} } jsonResponse(w, http.StatusOK, map[string]any{ "active_conns": conns, "security_events": events, }) } func (s *APIServer) handleKillConn(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { ConnID string `json:"conn_id"` } _ = json.NewDecoder(r.Body).Decode(&req) if req.ConnID != "" { s.agent.KillConn(req.ConnID) } jsonResponse(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *APIServer) handleClientHistory(w http.ResponseWriter, r *http.Request) { tunnelID := r.URL.Query().Get("tunnel_id") jsonResponse(w, http.StatusOK, s.cm.GetClientHistory(tunnelID)) } func (s *APIServer) handleSettings(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: cfg := s.cm.Get() jsonResponse(w, http.StatusOK, map[string]string{ "server_addr": cfg.ServerAddr, "token": cfg.Token, "public_host": cfg.PublicHostOrDefault(), }) case http.MethodPost: var req struct { ServerAddr string `json:"server_addr"` Token string `json:"token"` PublicHost *string `json:"public_host"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, http.StatusBadRequest, "Invalid JSON") return } publicHost := s.cm.Get().PublicHost if req.PublicHost != nil { publicHost = *req.PublicHost } changed, err := s.cm.UpdateServer(req.ServerAddr, req.Token, publicHost) if err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } // 新地址/令牌必须重连才会生效;只改对外访问地址时不打断连接 if changed { go s.agent.Reconnect(true) } jsonResponse(w, http.StatusOK, map[string]string{"status": "updated"}) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (s *APIServer) handleSSE(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "Streaming unsupported", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") send := func() bool { metrics := s.agent.GetLatestMetrics() payload := s.statusPayload() payload["metrics"] = metrics payload["tunnels"] = s.tunnelsWithMetrics(metrics) data, _ := json.Marshal(payload) if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { return false } flusher.Flush() return true } if !send() { return } ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-r.Context().Done(): return case <-ticker.C: if !send() { return } } } } func (s *APIServer) handleShutdown(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } if s.OnShutdown == nil { jsonError(w, http.StatusNotImplemented, "shutdown not supported") return } jsonResponse(w, http.StatusOK, map[string]string{"status": "shutting down"}) if f, ok := w.(http.Flusher); ok { f.Flush() } go s.OnShutdown() } func jsonResponse(w http.ResponseWriter, status int, data any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(data) } func jsonError(w http.ResponseWriter, status int, msg string) { jsonResponse(w, status, map[string]string{"error": msg}) }