- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化) - 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转 - 设置→外观 主题顺序调整为 深色/浅色/跟随系统 - release 二进制重编并验证(浏览器实测关于页链接可开文档页) - 附带工作区中未提交的扁平化重构现状(另一会话进行中) - homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
237 lines
5.4 KiB
Go
237 lines
5.4 KiB
Go
package proxy
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/gorilla/websocket"
|
||
)
|
||
|
||
// WSTunnel 让浏览器借道 Gin 连接带自定义 Header 的 WebSocket:
|
||
//
|
||
// POST /v1/proxy/ws/connect {url, headers, protocols} → {sessionId}
|
||
// GET /v1/proxy/ws/attach?sessionId=… (浏览器 WebSocket 双向桥)
|
||
// POST /v1/proxy/ws/close {sessionId}
|
||
type wsTunnelSpec struct {
|
||
URL string `json:"url" binding:"required"`
|
||
Headers map[string]string `json:"headers,omitempty"`
|
||
Protocols []string `json:"protocols,omitempty"`
|
||
}
|
||
|
||
type wsTunnelSession struct {
|
||
id string
|
||
target *websocket.Conn
|
||
attach chan *websocket.Conn
|
||
createdAt time.Time
|
||
}
|
||
|
||
type WSTunnel struct {
|
||
mu sync.Mutex
|
||
sessions map[string]*wsTunnelSession
|
||
upgrader websocket.Upgrader
|
||
}
|
||
|
||
func NewWSTunnel() *WSTunnel {
|
||
t := &WSTunnel{
|
||
sessions: map[string]*wsTunnelSession{},
|
||
upgrader: websocket.Upgrader{
|
||
ReadBufferSize: 32 * 1024,
|
||
WriteBufferSize: 32 * 1024,
|
||
CheckOrigin: func(r *http.Request) bool {
|
||
// 仅本地 UI 使用;Origin 校验交给 CORS 中间件
|
||
return true
|
||
},
|
||
},
|
||
}
|
||
go t.gc()
|
||
return t
|
||
}
|
||
|
||
func (t *WSTunnel) gc() {
|
||
ticker := time.NewTicker(30 * time.Second)
|
||
defer ticker.Stop()
|
||
for range ticker.C {
|
||
t.mu.Lock()
|
||
for id, s := range t.sessions {
|
||
// 未 attach 或空闲超过 10 分钟的会话回收
|
||
if time.Since(s.createdAt) > 10*time.Minute {
|
||
_ = s.target.Close()
|
||
delete(t.sessions, id)
|
||
}
|
||
}
|
||
t.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// ValidateWSURL 校验 ws/wss 目标(http/https 自动换算)
|
||
func ValidateWSURL(raw string) (*url.URL, error) {
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
switch u.Scheme {
|
||
case "ws", "http":
|
||
u.Scheme = "ws"
|
||
case "wss", "https":
|
||
u.Scheme = "wss"
|
||
default:
|
||
return nil, errOnlyWS
|
||
}
|
||
if u.Hostname() == "" {
|
||
return nil, errEmptyHost
|
||
}
|
||
if _, blocked := blockedHosts[strings.ToLower(u.Hostname())]; blocked {
|
||
return nil, errBlockedHost
|
||
}
|
||
return u, nil
|
||
}
|
||
|
||
var (
|
||
errOnlyWS = &wsTunnelError{"only ws/wss schemes are allowed"}
|
||
errEmptyHost = &wsTunnelError{"url host is required"}
|
||
errBlockedHost = &wsTunnelError{"blocked metadata host"}
|
||
)
|
||
|
||
type wsTunnelError struct{ msg string }
|
||
|
||
func (e *wsTunnelError) Error() string { return e.msg }
|
||
|
||
func (t *WSTunnel) Connect(c *gin.Context) {
|
||
var spec wsTunnelSpec
|
||
if err := c.ShouldBindJSON(&spec); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid spec", "detail": err.Error()})
|
||
return
|
||
}
|
||
u, err := ValidateWSURL(spec.URL)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
dialer := websocket.Dialer{HandshakeTimeout: 15 * time.Second}
|
||
dialHeader := http.Header{}
|
||
for key, value := range spec.Headers {
|
||
if !isHopByHop(key) {
|
||
dialHeader.Set(key, value)
|
||
}
|
||
}
|
||
target, _, err := dialer.Dial(u.String(), dialHeader)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "dial failed", "detail": err.Error()})
|
||
return
|
||
}
|
||
|
||
id := randomSessionID()
|
||
session := &wsTunnelSession{id: id, target: target, attach: make(chan *websocket.Conn, 1), createdAt: time.Now()}
|
||
t.mu.Lock()
|
||
t.sessions[id] = session
|
||
t.mu.Unlock()
|
||
|
||
c.JSON(http.StatusOK, gin.H{"sessionId": id})
|
||
|
||
// 30 秒内没有浏览器 attach 就回收
|
||
go func() {
|
||
select {
|
||
case client := <-session.attach:
|
||
t.bridge(client, session)
|
||
case <-time.After(30 * time.Second):
|
||
t.mu.Lock()
|
||
if s, ok := t.sessions[id]; ok {
|
||
_ = s.target.Close()
|
||
delete(t.sessions, id)
|
||
}
|
||
t.mu.Unlock()
|
||
}
|
||
}()
|
||
}
|
||
|
||
func (t *WSTunnel) Attach(c *gin.Context) {
|
||
id := c.Query("sessionId")
|
||
t.mu.Lock()
|
||
session, ok := t.sessions[id]
|
||
t.mu.Unlock()
|
||
if !ok {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||
return
|
||
}
|
||
client, err := t.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||
if err != nil {
|
||
return
|
||
}
|
||
select {
|
||
case session.attach <- client:
|
||
// bridge goroutine 已在 Connect 中启动
|
||
default:
|
||
_ = client.Close()
|
||
}
|
||
}
|
||
|
||
func (t *WSTunnel) Close(c *gin.Context) {
|
||
var body struct {
|
||
SessionID string `json:"sessionId"`
|
||
}
|
||
_ = c.ShouldBindJSON(&body)
|
||
t.mu.Lock()
|
||
session, ok := t.sessions[body.SessionID]
|
||
if ok {
|
||
delete(t.sessions, body.SessionID)
|
||
}
|
||
t.mu.Unlock()
|
||
if ok {
|
||
_ = session.target.Close()
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||
}
|
||
|
||
// bridge 双向转发:浏览器 → 目标;目标 → 浏览器
|
||
func (t *WSTunnel) bridge(client *websocket.Conn, session *wsTunnelSession) {
|
||
defer func() {
|
||
_ = client.Close()
|
||
_ = session.target.Close()
|
||
t.mu.Lock()
|
||
delete(t.sessions, session.id)
|
||
t.mu.Unlock()
|
||
}()
|
||
|
||
done := make(chan struct{})
|
||
// 浏览器 → 目标
|
||
go func() {
|
||
defer close(done)
|
||
for {
|
||
msgType, data, err := client.ReadMessage()
|
||
if err != nil {
|
||
return
|
||
}
|
||
if err := session.target.WriteMessage(msgType, data); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
// 目标 → 浏览器(bridge 协议:控制帧 + 数据帧)
|
||
_ = client.WriteMessage(websocket.TextMessage, []byte(`{"type":"open"}`))
|
||
for {
|
||
msgType, data, err := session.target.ReadMessage()
|
||
if err != nil {
|
||
_ = client.WriteMessage(websocket.TextMessage, []byte(`{"type":"closed","reason":"target closed"}`))
|
||
return
|
||
}
|
||
if err := client.WriteMessage(msgType, data); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func randomSessionID() string {
|
||
b := make([]byte, 16)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return time.Now().Format("20060102150405.000000000")
|
||
}
|
||
return hex.EncodeToString(b)
|
||
}
|