PortRelay:把内网服务临时、可控地开放给同事
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上; 通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。 - portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页) - portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计) - mac-native:SwiftUI 菜单栏 App
This commit is contained in:
commit
86e159b3f0
67 files changed
+13888
No files matched your search
+647
@@ -0,0 +1,647 @@
|
||||
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})
|
||||
}
|
||||
Reference in new issue
Block a user