边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上; 通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。 - portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页) - portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计) - mac-native:SwiftUI 菜单栏 App
648 lines
19 KiB
Go
648 lines
19 KiB
Go
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})
|
||
}
|