feat: OpsxDB 桌面数据库客户端首版入库

含工作台/连库多引擎字段与 SSH·SSL、结果编辑、Redis 键浏览、
Tauri 桌面与 docs/落地页;排除本地构建产物与 sidecar 二进制。
This commit is contained in:
logan committed 2026-09-19 22:43:45 +08:00
commit f8740d27f7
240 files changed
+21625

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)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
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 (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 := strings.ToUpper(spec.Method)
switch method {
case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported method"})
return
}
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 {
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
}
}
+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"`
}