feat: OpsxAPI v1.0 全协议接口工作台

- 六协议调试:HTTP/GraphQL/WebSocket/SSE/Socket.IO/MQTT
- 集合树/多标签/多环境变量/历史记录,localStorage 持久化
- 导入:OpenAPI 2/3/3.1、Postman、Apifox、Apipost、cURL、HAR、
  Insomnia、Hoppscotch、Markdown(自动识别,21 个单测覆盖)
- 导出:OpenAPI 3.0 JSON/YAML、Postman v2.1、Markdown 文档、
  全量备份;单请求生成 cURL/fetch/Python/Go 代码
- Gin 服务:HTTP 代理、NDJSON 流式代理(SSE 透传)、
  WebSocket 隧道(浏览器自定义握手 Header)
- 内置产品官网 /home 与用户文档站 /docs(SSR,随二进制交付)
- lucide 图标体系 + 品牌标识(Logo/favicon),明暗双主题
- 环境变量:彩色徽标切换器、管理弹窗、{{ 自动补全
This commit is contained in:
logan committed 2026-09-18 00:16:59 +08:00
commit dec66db5aa
182 files changed
+23377

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
package proxy
import (
"fmt"
"net"
"net/url"
"strings"
)
var blockedHosts = map[string]struct{}{
"169.254.169.254": {},
"metadata.google.internal": {},
}
// ValidateURL applies MVP SSRF basics. Week 2 should add configurable CIDR
// deny-lists and DNS-rebinding protection before exposing this beyond localhost.
func ValidateURL(raw string) (*url.URL, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("only http/https schemes are allowed")
}
if u.Hostname() == "" {
return nil, fmt.Errorf("url host is required")
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
if _, blocked := blockedHosts[host]; blocked {
return nil, fmt.Errorf("blocked metadata host")
}
if ip := net.ParseIP(host); ip != nil && ip.Equal(net.ParseIP("169.254.169.254")) {
return nil, fmt.Errorf("blocked metadata address")
}
return u, nil
}
+23
View File
@@ -0,0 +1,23 @@
package proxy
import "testing"
func TestValidateURL(t *testing.T) {
for _, tc := range []struct {
raw string
ok bool
}{
{"https://example.com/a", true},
{"file:///etc/passwd", false},
{"http://169.254.169.254/latest/meta-data", false},
{"http://metadata.google.internal/computeMetadata/v1", false},
} {
_, err := ValidateURL(tc.raw)
if tc.ok && err != nil {
t.Fatalf("%s: %v", tc.raw, err)
}
if !tc.ok && err == nil {
t.Fatalf("expected rejection: %s", tc.raw)
}
}
}
+116
View File
@@ -0,0 +1,116 @@
package proxy
import (
"bytes"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const maxResponseBytes = 10 << 20 // 10 MiB MVP safety cap
type Handler struct {
BaseClient *http.Client
}
func NewHandler() *Handler {
return &Handler{BaseClient: &http.Client{Timeout: 30 * time.Second}}
}
func normalizeMethod(raw string) string {
m := strings.ToUpper(strings.TrimSpace(raw))
switch m {
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch,
http.MethodDelete, http.MethodHead, http.MethodOptions:
return m
default:
return http.MethodGet
}
}
func (h *Handler) ProxyHTTP(c *gin.Context) {
var spec HttpSpec
if err := c.ShouldBindJSON(&spec); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid HttpSpec", "detail": err.Error()})
return
}
method := normalizeMethod(spec.Method)
u, err := ValidateURL(spec.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := u.Query()
for key, value := range spec.Query {
query.Set(key, value)
}
u.RawQuery = query.Encode()
var body io.Reader
if spec.Body != nil && spec.Body.Mode != "none" && method != http.MethodGet && method != http.MethodHead {
body = bytes.NewBufferString(spec.Body.Raw)
}
req, err := http.NewRequestWithContext(c.Request.Context(), method, u.String(), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create request", "detail": err.Error()})
return
}
for key, value := range spec.Headers {
// Hop-by-hop headers are managed by net/http.
if !isHopByHop(key) {
req.Header.Set(key, value)
}
}
if spec.Body != nil && spec.Body.Mode == "json" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
client := h.BaseClient
if spec.TimeoutMS > 0 {
client = &http.Client{Timeout: time.Duration(spec.TimeoutMS) * time.Millisecond}
}
started := time.Now()
res, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed", "detail": err.Error()})
return
}
defer res.Body.Close()
payload, err := io.ReadAll(io.LimitReader(res.Body, maxResponseBytes+1))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "cannot read upstream response", "detail": err.Error()})
return
}
if len(payload) > maxResponseBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream response exceeds 10 MiB limit"})
return
}
headers := make(map[string]string, len(res.Header))
for key, values := range res.Header {
headers[strings.ToLower(key)] = strings.Join(values, ", ")
}
c.JSON(http.StatusOK, HttpResult{
Status: res.StatusCode,
Headers: headers,
Body: string(payload),
TimeMS: time.Since(started).Milliseconds(),
Size: len(payload),
})
}
func isHopByHop(name string) bool {
switch strings.ToLower(name) {
case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length":
return true
default:
return false
}
}
+145
View File
@@ -0,0 +1,145 @@
package proxy
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// ProxyHTTPStream 以 NDJSON 流式转发响应(SSE / chunked / 大响应体)。
// 事件序列:meta → chunk* → end | error
type streamEvent struct {
Type string `json:"type"`
Status int `json:"status,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Data string `json:"data,omitempty"`
Message string `json:"message,omitempty"`
TimeMS int64 `json:"timeMs,omitempty"`
Size int `json:"size,omitempty"`
}
func (h *Handler) ProxyHTTPStream(c *gin.Context) {
var spec HttpSpec
if err := c.ShouldBindJSON(&spec); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid HttpSpec", "detail": err.Error()})
return
}
method := normalizeMethod(spec.Method)
u, err := ValidateURL(spec.URL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
query := u.Query()
for key, value := range spec.Query {
query.Set(key, value)
}
u.RawQuery = query.Encode()
var body io.Reader
if spec.Body != nil && spec.Body.Mode != "none" && method != http.MethodGet && method != http.MethodHead {
body = bytes.NewBufferString(spec.Body.Raw)
}
req, err := http.NewRequestWithContext(c.Request.Context(), method, u.String(), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot create request", "detail": err.Error()})
return
}
for key, value := range spec.Headers {
if !isHopByHop(key) {
req.Header.Set(key, value)
}
}
if spec.Body != nil && spec.Body.Mode == "json" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
client := h.BaseClient
if spec.TimeoutMS > 0 {
client = &http.Client{Timeout: time.Duration(spec.TimeoutMS) * time.Millisecond}
} else {
// 流式场景默认不设整体超时(SSE 长连接),交给客户端断开
client = &http.Client{}
}
started := time.Now()
res, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream request failed", "detail": err.Error()})
return
}
defer res.Body.Close()
headers := make(map[string]string, len(res.Header))
for key, values := range res.Header {
headers[key] = joinNonEmpty(values)
}
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming unsupported"})
return
}
c.Header("Content-Type", "application/x-ndjson")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
enc := json.NewEncoder(c.Writer)
writeEvent := func(ev streamEvent) bool {
if err := enc.Encode(ev); err != nil {
return false
}
flusher.Flush()
return true
}
headers["content-type"] = res.Header.Get("Content-Type")
if !writeEvent(streamEvent{Type: "meta", Status: res.StatusCode, Headers: headers}) {
return
}
reader := bufio.NewReaderSize(res.Body, 32*1024)
buf := make([]byte, 16*1024)
total := 0
for {
n, readErr := reader.Read(buf)
if n > 0 {
total += n
ok := writeEvent(streamEvent{
Type: "chunk",
Data: base64.StdEncoding.EncodeToString(buf[:n]),
Size: n,
})
if !ok {
return
}
}
if readErr != nil {
break
}
if c.Request.Context().Err() != nil {
return
}
}
writeEvent(streamEvent{Type: "end", TimeMS: time.Since(started).Milliseconds(), Size: total})
}
func joinNonEmpty(values []string) string {
out := ""
for i, v := range values {
if i > 0 {
out += ", "
}
out += v
}
return out
}
+23
View File
@@ -0,0 +1,23 @@
package proxy
type BodySpec struct {
Mode string `json:"mode"`
Raw string `json:"raw,omitempty"`
}
type HttpSpec struct {
Method string `json:"method" binding:"required"`
URL string `json:"url" binding:"required"`
Headers map[string]string `json:"headers"`
Query map[string]string `json:"query,omitempty"`
Body *BodySpec `json:"body,omitempty"`
TimeoutMS int `json:"timeoutMs,omitempty"`
}
type HttpResult struct {
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
TimeMS int64 `json:"timeMs"`
Size int `json:"size"`
}
+236
View File
@@ -0,0 +1,236 @@
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)
}