Files
PortRelay/client/api.go
T
DeepFal 86e159b3f0 PortRelay:把内网服务临时、可控地开放给同事
边缘网关 + Agent 的端口中继:Agent 主动连出到网关,把内网目标挂到网关端口上;
通过邀请链接按设备授权,可选审批,首次连接起计时,到期、闲置自动收回。

- portrelay-server:边缘网关(端口池、白名单与授权租约、邀请与授权网页)
- portrelay-agent:Agent(yamux 隧道、本地 API 与网页面板、流量统计)
- mac-native:SwiftUI 菜单栏 App
2026-10-13 16:33:17 +08:00

648 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}