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:
commit
dec66db5aa
182 files changed
+23377
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user