diff --git a/internal/api/domain_forwards.go b/internal/api/domain_forwards.go deleted file mode 100644 index b906261..0000000 --- a/internal/api/domain_forwards.go +++ /dev/null @@ -1,144 +0,0 @@ -package api - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/wormhole/wormhole/internal/auth" - "github.com/wormhole/wormhole/internal/forwardplugin" - "github.com/wormhole/wormhole/internal/store" -) - -func (s *Server) listDomainForwards(c *gin.Context) { - if !auth.IsAdmin(getClaims(c).Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) - return - } - p := pageParams(c) - items, total, err := s.store.ListDomainForwardsPaged(p) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"items": items, "total": total}) -} - -func (s *Server) createDomainForward(c *gin.Context) { - if !auth.IsAdmin(getClaims(c).Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) - return - } - var req store.DomainForward - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) - return - } - if err := store.ValidateDomainForward(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := s.store.RouteConflict(req.SourceHost, req.SourceLocation, 0, 0); err != nil { - c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) - return - } - if err := s.store.CreateDomainForward(&req); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - s.cache.UpsertDomainForward(&req) - c.JSON(http.StatusOK, req) -} - -func (s *Server) updateDomainForward(c *gin.Context) { - if !auth.IsAdmin(getClaims(c).Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) - return - } - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - existing, err := s.store.GetDomainForwardByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - var req store.DomainForward - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) - return - } - req.ID = existing.ID - if req.Socks5Pass == "" { - req.Socks5Pass = existing.Socks5Pass - } - if req.HTTPProxyPass == "" { - req.HTTPProxyPass = existing.HTTPProxyPass - } - if err := store.ValidateDomainForward(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := s.store.RouteConflict(req.SourceHost, req.SourceLocation, uint(id), 0); err != nil { - c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) - return - } - if err := s.store.UpdateDomainForward(&req); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - forwardplugin.InvalidateForwardCache(uint(id)) - s.cache.UpsertDomainForward(&req) - c.JSON(http.StatusOK, req) -} - -func (s *Server) deleteDomainForward(c *gin.Context) { - if !auth.IsAdmin(getClaims(c).Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) - return - } - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - if _, err := s.store.GetDomainForwardByID(uint(id)); err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - if err := s.store.DeleteDomainForward(uint(id)); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - forwardplugin.InvalidateForwardCache(uint(id)) - s.cache.DeleteDomainForward(uint(id)) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -func (s *Server) pauseDomainForward(c *gin.Context) { - if !auth.IsAdmin(getClaims(c).Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) - return - } - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - existing, err := s.store.GetDomainForwardByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - existing.Status = false - _ = s.store.UpdateDomainForward(existing) - s.cache.UpsertDomainForward(existing) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} - -func (s *Server) resumeDomainForward(c *gin.Context) { - if !auth.IsAdmin(getClaims(c).Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) - return - } - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - existing, err := s.store.GetDomainForwardByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - existing.Status = true - _ = s.store.UpdateDomainForward(existing) - s.cache.UpsertDomainForward(existing) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} diff --git a/internal/api/extra_handlers.go b/internal/api/extra_handlers.go index c33c526..b597de7 100644 --- a/internal/api/extra_handlers.go +++ b/internal/api/extra_handlers.go @@ -46,7 +46,7 @@ func (s *Server) dashboardRankings(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"items": items, "total": total}) + c.JSON(http.StatusOK, gin.H{"items": requestIPViews(s.store, items), "total": total}) default: c.JSON(http.StatusBadRequest, gin.H{"error": "invalid type"}) } diff --git a/internal/api/forward_plugins.go b/internal/api/forward_plugins.go deleted file mode 100644 index f01f16b..0000000 --- a/internal/api/forward_plugins.go +++ /dev/null @@ -1,17 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "github.com/wormhole/wormhole/internal/auth" - "github.com/wormhole/wormhole/internal/forwardplugin" -) - -func (s *Server) listForwardPluginTypes(c *gin.Context) { - if !auth.IsAdmin(getClaims(c).Role) { - c.JSON(http.StatusForbidden, gin.H{"error": "admin only"}) - return - } - c.JSON(http.StatusOK, gin.H{"items": forwardplugin.ListPluginTypes()}) -} diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 8d7d9a3..4beec95 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -6,7 +6,7 @@ const openAPISpec = `{ "info": { "title": "Wormhole API", "version": "1.0.0", - "description": "使用 X-API-Key 请求头或 Bearer JWT 访问。Base URL: http://host:8529/api。域名转发接口需管理员权限。" + "description": "使用 X-API-Key 请求头或 Bearer JWT 访问。Base URL: http://host:8529/api。" }, "servers": [{ "url": "/api" }], "components": { @@ -26,152 +26,6 @@ const openAPISpec = `{ "properties": { "ok": { "type": "boolean", "example": true } } - }, - "PagedDomainForwards": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { "$ref": "#/components/schemas/DomainForward" } - }, - "total": { "type": "integer", "description": "总条数" } - } - }, - "DomainForward": { - "type": "object", - "description": "域名转发规则(服务端直连,无需 Agent)", - "properties": { - "id": { "type": "integer", "readOnly": true }, - "source_host": { "type": "string", "description": "源域名,支持通配如 *.example.com", "example": "old.example.com" }, - "source_location": { "type": "string", "default": "/", "description": "路径前缀", "example": "/" }, - "source_scheme": { "type": "string", "enum": ["all", "http", "https"], "default": "all" }, - "mode": { "type": "string", "enum": ["redirect", "proxy"], "description": "redirect=跳转,proxy=反向代理" }, - "target_url": { "type": "string", "format": "uri", "description": "目标 URL,需含协议与主机", "example": "https://new.example.com" }, - "redirect_mode": { "type": "string", "enum": ["preserve", "fixed"], "default": "preserve", "description": "跳转模式:保留路径或固定地址" }, - "redirect_code": { "type": "integer", "enum": [301, 302], "default": 302 }, - "proxy_type": { - "type": "string", - "enum": ["http", "http_proxy", "socks5"], - "default": "http", - "description": "http=直连,http_proxy=经 HTTP 代理出站,socks5=经 SOCKS5 出站" - }, - "http_proxy_addr": { "type": "string", "description": "HTTP 代理地址,proxy_type=http_proxy 时必填", "example": "127.0.0.1:8080" }, - "http_proxy_user": { "type": "string", "description": "HTTP 代理用户名(可选)" }, - "socks5_addr": { "type": "string", "description": "SOCKS5 地址,proxy_type=socks5 时必填", "example": "127.0.0.1:1080" }, - "socks5_user": { "type": "string", "description": "SOCKS5 用户名(可选)" }, - "preserve_host": { "type": "boolean", "default": false, "description": "反代时是否将源域名作为 Host 发给上游" }, - "plugins": { - "type": "array", - "description": "反代插件链,按数组顺序执行;redirect 模式忽略", - "items": { "$ref": "#/components/schemas/ForwardPluginEntry" } - }, - "status": { "type": "boolean", "default": true }, - "created_at": { "type": "string", "format": "date-time", "readOnly": true }, - "updated_at": { "type": "string", "format": "date-time", "readOnly": true } - }, - "required": ["source_host", "mode", "target_url"] - }, - "DomainForwardInput": { - "allOf": [ - { "$ref": "#/components/schemas/DomainForward" }, - { - "type": "object", - "properties": { - "http_proxy_pass": { "type": "string", "writeOnly": true, "description": "HTTP 代理密码;更新时留空表示不修改" }, - "socks5_pass": { "type": "string", "writeOnly": true, "description": "SOCKS5 密码;更新时留空表示不修改" } - } - } - ] - }, - "ForwardPluginEntry": { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string", - "enum": ["sub_filter", "inject_js", "cookie_domain", "response_cache"], - "description": "插件类型" - }, - "enabled": { "type": "boolean", "default": true }, - "config": { - "description": "插件配置,结构随 type 变化", - "oneOf": [ - { "$ref": "#/components/schemas/SubFilterPluginConfig" }, - { "$ref": "#/components/schemas/InjectJSPluginConfig" }, - { "$ref": "#/components/schemas/CookieDomainPluginConfig" }, - { "$ref": "#/components/schemas/ResponseCachePluginConfig" } - ] - } - } - }, - "SubFilterPluginConfig": { - "type": "object", - "description": "type=sub_filter:按 MIME 对响应体做字符串替换", - "properties": { - "filters": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": ["from"], - "properties": { - "from": { "type": "string" }, - "to": { "type": "string" } - } - } - }, - "types": { - "type": "array", - "items": { "type": "string" }, - "default": ["text/html"], - "example": ["text/html", "text/css"] - } - }, - "required": ["filters"] - }, - "InjectJSPluginConfig": { - "type": "object", - "description": "type=inject_js:在 HTML 前注入脚本", - "properties": { - "script": { "type": "string", "example": "" } - }, - "required": ["script"] - }, - "CookieDomainPluginConfig": { - "type": "object", - "description": "type=cookie_domain:改写 Set-Cookie 的 Domain", - "properties": { - "from": { "type": "string", "example": ".old.com" }, - "to": { "type": "string", "example": ".new.com" } - }, - "required": ["from", "to"] - }, - "ResponseCachePluginConfig": { - "type": "object", - "description": "type=response_cache:缓存上游原始 GET 响应(不含 Set-Cookie)", - "properties": { - "max_mb": { "type": "integer", "minimum": 0, "description": "缓存容量 MB,0 表示禁用", "example": 50 }, - "ttl_sec": { "type": "integer", "minimum": 1, "description": "TTL 秒,max_mb>0 时默认 300", "example": 300 } - } - }, - "ForwardPluginTypes": { - "type": "object", - "properties": { - "items": { - "type": "array", - "items": { "$ref": "#/components/schemas/ForwardPluginTypeMeta" } - } - } - }, - "ForwardPluginTypeMeta": { - "type": "object", - "properties": { - "type": { "type": "string" }, - "label": { "type": "string" }, - "description": { "type": "string" }, - "config_schema": { "type": "object", "additionalProperties": true }, - "default_config": { "type": "object", "additionalProperties": true } - } } }, "responses": { @@ -180,7 +34,7 @@ const openAPISpec = `{ "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "Forbidden": { - "description": "权限不足(需管理员)", + "description": "权限不足", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "NotFound": { @@ -238,214 +92,6 @@ const openAPISpec = `{ "delete": { "summary": "删除域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } }, "/hosts/{id}/pause": { "post": { "summary": "暂停域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } }, - "/hosts/{id}/resume": { "post": { "summary": "恢复域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } }, - "/domain-forwards": { - "get": { - "summary": "分页查询域名转发", - "description": "需管理员权限", - "parameters": [ - { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, - { "name": "page_size", "in": "query", "schema": { "type": "integer", "default": 20 } }, - { "name": "q", "in": "query", "schema": { "type": "string" }, "description": "搜索源域名、目标 URL" } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/PagedDomainForwards" } - } - } - }, - "403": { "$ref": "#/components/responses/Forbidden" } - } - }, - "post": { - "summary": "创建域名转发", - "description": "需管理员权限。proxy 模式下可配置 plugins 插件链。", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/DomainForwardInput" }, - "examples": { - "redirect": { - "summary": "302 跳转", - "value": { - "source_host": "old.example.com", - "source_location": "/", - "mode": "redirect", - "target_url": "https://new.example.com", - "redirect_mode": "preserve", - "redirect_code": 302, - "status": true - } - }, - "proxy_with_plugins": { - "summary": "反代 + 插件链 + HTTP 代理出站", - "value": { - "source_host": "proxy.example.com", - "source_location": "/", - "source_scheme": "all", - "mode": "proxy", - "target_url": "https://backend.internal", - "proxy_type": "http_proxy", - "http_proxy_addr": "127.0.0.1:8080", - "http_proxy_user": "user", - "http_proxy_pass": "secret", - "preserve_host": false, - "plugins": [ - { - "type": "response_cache", - "enabled": true, - "config": { "max_mb": 50, "ttl_sec": 300 } - }, - { - "type": "sub_filter", - "enabled": true, - "config": { - "filters": [{ "from": "old.internal", "to": "proxy.example.com" }], - "types": ["text/html"] - } - }, - { - "type": "inject_js", - "enabled": true, - "config": { "script": "" } - }, - { - "type": "cookie_domain", - "enabled": true, - "config": { "from": ".internal", "to": ".example.com" } - } - ], - "status": true - } - } - } - } - } - }, - "responses": { - "200": { - "description": "创建成功", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/DomainForward" } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "409": { "$ref": "#/components/responses/Conflict" } - } - } - }, - "/domain-forwards/{id}": { - "put": { - "summary": "更新域名转发", - "description": "需管理员权限。http_proxy_pass / socks5_pass 留空时不修改原密码。", - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/DomainForwardInput" } - } - } - }, - "responses": { - "200": { - "description": "更新成功", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/DomainForward" } - } - } - }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "409": { "$ref": "#/components/responses/Conflict" } - } - }, - "delete": { - "summary": "删除域名转发", - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } - ], - "responses": { - "200": { - "description": "删除成功", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Ok" } - } - } - }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" } - } - } - }, - "/domain-forwards/{id}/pause": { - "post": { - "summary": "暂停域名转发", - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Ok" } - } - } - }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" } - } - } - }, - "/domain-forwards/{id}/resume": { - "post": { - "summary": "恢复域名转发", - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Ok" } - } - } - }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" } - } - } - }, - "/forward-plugin-types": { - "get": { - "summary": "获取域名转发插件类型元数据", - "description": "返回可用插件的 type、label、config_schema、default_config,供前端或 API 客户端动态构建配置。", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/ForwardPluginTypes" } - } - } - }, - "403": { "$ref": "#/components/responses/Forbidden" } - } - } - } + "/hosts/{id}/resume": { "post": { "summary": "恢复域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } } } }` diff --git a/internal/api/server.go b/internal/api/server.go index 33759f5..35d1f6e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -99,14 +99,6 @@ func (s *Server) Router() *gin.Engine { authGroup.GET("/hosts/export", s.exportHosts) authGroup.POST("/hosts/import", s.importHosts) - authGroup.GET("/domain-forwards", s.listDomainForwards) - authGroup.POST("/domain-forwards", s.createDomainForward) - authGroup.PUT("/domain-forwards/:id", s.updateDomainForward) - authGroup.DELETE("/domain-forwards/:id", s.deleteDomainForward) - authGroup.POST("/domain-forwards/:id/pause", s.pauseDomainForward) - authGroup.POST("/domain-forwards/:id/resume", s.resumeDomainForward) - authGroup.GET("/forward-plugin-types", s.listForwardPluginTypes) - authGroup.GET("/security/ip-rules", s.listIPRules) authGroup.POST("/security/ip-rules", s.createIPRule) authGroup.DELETE("/security/ip-rules/:id", s.deleteIPRule) @@ -233,6 +225,9 @@ func (s *Server) dashboard(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if tops, ok := data["top_ips"].([]store.RequestIP); ok { + data["top_ips"] = requestIPViews(s.store, tops) + } c.JSON(http.StatusOK, data) } @@ -554,7 +549,7 @@ func (s *Server) createHost(c *gin.Context) { if req.VisitorTTLSeconds <= 0 { req.VisitorTTLSeconds = 7200 } - if err := s.store.RouteConflict(req.Host.Host, req.Location, 0, 0); err != nil { + if err := s.store.HostRouteConflict(req.Host.Host, req.Location, 0); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } @@ -597,7 +592,7 @@ func (s *Server) updateHost(c *gin.Context) { if req.VisitorTTLSeconds <= 0 { req.VisitorTTLSeconds = 7200 } - if err := s.store.RouteConflict(req.Host.Host, req.Location, 0, uint(id)); err != nil { + if err := s.store.HostRouteConflict(req.Host.Host, req.Location, uint(id)); err != nil { c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) return } @@ -685,7 +680,7 @@ func (s *Server) listRequestIPs(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"items": items, "total": total}) + c.JSON(http.StatusOK, gin.H{"items": requestIPViews(s.store, items), "total": total}) return } items, total, err := s.store.ListRequestIPs(resourceType, uint(resourceID), limit, offset) @@ -693,24 +688,29 @@ func (s *Server) listRequestIPs(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"items": items, "total": total}) + c.JSON(http.StatusOK, gin.H{"items": requestIPViews(s.store, items), "total": total}) } func (s *Server) blockIP(c *gin.Context) { var req struct { - IP string `json:"ip"` - Scope string `json:"scope"` - ResourceID uint `json:"resource_id"` - Remark string `json:"remark"` + IP string `json:"ip"` + Scope string `json:"scope"` + ResourceType string `json:"resource_type"` + ResourceID uint `json:"resource_id"` + Remark string `json:"remark"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) return } - if req.Scope == "" { - req.Scope = "global" + scope := req.Scope + if scope == "" { + scope = req.ResourceType } - if err := s.security.BlockIP(req.IP, req.Scope, req.ResourceID, req.Remark); err != nil { + if scope == "" { + scope = "global" + } + if err := s.security.BlockIP(req.IP, scope, req.ResourceID, req.Remark); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -807,32 +807,6 @@ func (s *Server) listVisitorAccessLogs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) } -func visitorResourceLabel(st *store.Store, resourceType string, resourceID uint) string { - switch resourceType { - case "host": - h, err := st.GetHostByID(resourceID) - if err != nil { - return "" - } - label := h.Host - if h.Location != "" && h.Location != "/" { - label += h.Location - } - return label - case "tunnel": - t, err := st.GetTunnelByID(resourceID) - if err != nil { - return "" - } - if t.Name != "" { - return t.Name + " :" + strconv.Itoa(t.ListenPort) - } - return ":" + strconv.Itoa(t.ListenPort) - default: - return "" - } -} - func (s *Server) createUser(c *gin.Context) { claims := getClaims(c) if !auth.IsAdmin(claims.Role) { diff --git a/internal/api/visitor_helpers.go b/internal/api/visitor_helpers.go index 0ed4799..ca0346a 100644 --- a/internal/api/visitor_helpers.go +++ b/internal/api/visitor_helpers.go @@ -3,6 +3,8 @@ package api import ( "fmt" "net/http" + "strconv" + "time" "github.com/gin-gonic/gin" "github.com/wormhole/wormhole/internal/store" @@ -87,3 +89,59 @@ func (s *Server) validateVisitorIDs(ids []uint) error { func writeVisitorSaveError(c *gin.Context, err error) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } + +type requestIPView struct { + ID uint `json:"id"` + ResourceType string `json:"resource_type"` + ResourceID uint `json:"resource_id"` + ResourceLabel string `json:"resource_label"` + IP string `json:"ip"` + HitCount int64 `json:"hit_count"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +func requestIPViews(st *store.Store, items []store.RequestIP) []requestIPView { + out := make([]requestIPView, 0, len(items)) + for _, item := range items { + label := visitorResourceLabel(st, item.ResourceType, item.ResourceID) + if label == "" && item.ResourceID > 0 { + label = fmt.Sprintf("#%d", item.ResourceID) + } + out = append(out, requestIPView{ + ID: item.ID, + ResourceType: item.ResourceType, + ResourceID: item.ResourceID, + ResourceLabel: label, + IP: item.IP, + HitCount: item.HitCount, + LastSeenAt: item.LastSeenAt, + }) + } + return out +} + +func visitorResourceLabel(st *store.Store, resourceType string, resourceID uint) string { + switch resourceType { + case "host": + h, err := st.GetHostByID(resourceID) + if err != nil { + return "" + } + label := h.Host + if h.Location != "" && h.Location != "/" { + label += h.Location + } + return label + case "tunnel": + t, err := st.GetTunnelByID(resourceID) + if err != nil { + return "" + } + if t.Name != "" { + return t.Name + " :" + strconv.Itoa(t.ListenPort) + } + return ":" + strconv.Itoa(t.ListenPort) + default: + return "" + } +} diff --git a/internal/cache/cache.go b/internal/cache/cache.go index 014273a..55ba8de 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -40,8 +40,6 @@ type Manager struct { tunnelsByPort map[string]*store.Tunnel hosts map[uint]*store.Host hostIndex []hostEntry - forwards map[uint]*store.DomainForward - forwardIndex []forwardEntry onlineAgents map[uint]*OnlineAgent @@ -56,10 +54,6 @@ type hostEntry struct { host *store.Host } -type forwardEntry struct { - forward *store.DomainForward -} - func NewManager() *Manager { return &Manager{ clients: make(map[uint]*store.Client), @@ -67,7 +61,6 @@ func NewManager() *Manager { tunnels: make(map[uint]*store.Tunnel), tunnelsByPort: make(map[string]*store.Tunnel), hosts: make(map[uint]*store.Host), - forwards: make(map[uint]*store.DomainForward), onlineAgents: make(map[uint]*OnlineAgent), authPolicies: make(map[string]*store.AuthPolicy), visitorBindings: make(map[string][]uint), @@ -100,10 +93,6 @@ func (m *Manager) ReloadAll(st *store.Store) error { if err != nil { return err } - forwards, err := st.ListDomainForwards() - if err != nil { - return err - } m.mu.Lock() defer m.mu.Unlock() @@ -133,14 +122,6 @@ func (m *Manager) ReloadAll(st *store.Store) error { m.hostIndex = append(m.hostIndex, hostEntry{host: &h}) } - m.forwards = make(map[uint]*store.DomainForward) - m.forwardIndex = nil - for i := range forwards { - f := forwards[i] - m.forwards[f.ID] = &f - m.forwardIndex = append(m.forwardIndex, forwardEntry{forward: &f}) - } - m.ipRules = compileRules(rules) m.authPolicies = make(map[string]*store.AuthPolicy) @@ -318,40 +299,6 @@ func (m *Manager) MatchHost(hostHeader, path, scheme string) *store.Host { return best } -func (m *Manager) MatchDomainForward(hostHeader, path, scheme string) *store.DomainForward { - m.mu.RLock() - defer m.mu.RUnlock() - - hostHeader = strings.ToLower(strings.Split(hostHeader, ":")[0]) - var best *store.DomainForward - bestLen := -1 - - for _, entry := range m.forwardIndex { - f := entry.forward - if !f.Status { - continue - } - if f.SourceScheme != "all" && f.SourceScheme != scheme { - continue - } - if !matchHostPattern(f.SourceHost, hostHeader) { - continue - } - loc := f.SourceLocation - if loc == "" { - loc = "/" - } - if !strings.HasPrefix(path, loc) { - continue - } - if len(loc) > bestLen { - best = f - bestLen = len(loc) - } - } - return best -} - func matchHostPattern(pattern, host string) bool { pattern = strings.ToLower(pattern) if pattern == host { @@ -557,33 +504,6 @@ func (m *Manager) DeleteHost(id uint) { } } -func (m *Manager) UpsertDomainForward(f *store.DomainForward) { - m.mu.Lock() - defer m.mu.Unlock() - m.forwards[f.ID] = f - m.forwardIndex = nil - for _, fwd := range m.forwards { - m.forwardIndex = append(m.forwardIndex, forwardEntry{forward: fwd}) - } -} - -func (m *Manager) DeleteDomainForward(id uint) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.forwards, id) - m.forwardIndex = nil - for _, fwd := range m.forwards { - m.forwardIndex = append(m.forwardIndex, forwardEntry{forward: fwd}) - } -} - -func (m *Manager) GetDomainForward(id uint) (*store.DomainForward, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - f, ok := m.forwards[id] - return f, ok -} - func (m *Manager) ListEnabledTunnels() []*store.Tunnel { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/forwardplugin/cookie_domain.go b/internal/forwardplugin/cookie_domain.go deleted file mode 100644 index 37340ca..0000000 --- a/internal/forwardplugin/cookie_domain.go +++ /dev/null @@ -1,86 +0,0 @@ -package forwardplugin - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" -) - -func init() { - Register("cookie_domain", newCookieDomainPlugin) -} - -type cookieDomainConfig struct { - From string `json:"from"` - To string `json:"to"` -} - -type cookieDomainPlugin struct { - from string - to string -} - -func newCookieDomainPlugin(raw json.RawMessage) (Plugin, error) { - var cfg cookieDomainConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, fmt.Errorf("cookie_domain 配置无效") - } - return &cookieDomainPlugin{from: cfg.From, to: cfg.To}, nil -} - -func (p *cookieDomainPlugin) Type() string { return "cookie_domain" } - -func (p *cookieDomainPlugin) ValidateConfig(raw json.RawMessage) error { - var cfg cookieDomainConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return fmt.Errorf("cookie_domain 配置无效") - } - if strings.TrimSpace(cfg.From) == "" { - return fmt.Errorf("cookie_domain.from 不能为空") - } - if strings.TrimSpace(cfg.To) == "" { - return fmt.Errorf("cookie_domain.to 不能为空") - } - return nil -} - -func (p *cookieDomainPlugin) NeedsBody() bool { return false } - -func (p *cookieDomainPlugin) HandleRequest(_ *Context, _ *http.Request) (bool, error) { - return false, nil -} - -func (p *cookieDomainPlugin) HandleResponse(_ *Context, resp *BufferedResponse) error { - cookies := resp.Header.Values("Set-Cookie") - if len(cookies) == 0 { - return nil - } - resp.Header.Del("Set-Cookie") - for _, c := range cookies { - resp.Header.Add("Set-Cookie", rewriteCookieDomain(c, p.from, p.to)) - } - return nil -} - -func rewriteCookieDomain(cookie, from, to string) string { - parts := strings.Split(cookie, ";") - for i, part := range parts { - trimmed := strings.TrimSpace(part) - kv := strings.SplitN(trimmed, "=", 2) - if len(kv) != 2 { - continue - } - if strings.EqualFold(kv[0], "domain") { - val := strings.TrimSpace(kv[1]) - if val == from || strings.TrimPrefix(val, ".") == strings.TrimPrefix(from, ".") { - parts[i] = "Domain=" + to - } else { - parts[i] = trimmed - } - } else { - parts[i] = trimmed - } - } - return strings.Join(parts, "; ") -} diff --git a/internal/forwardplugin/metadata.go b/internal/forwardplugin/metadata.go deleted file mode 100644 index 897147f..0000000 --- a/internal/forwardplugin/metadata.go +++ /dev/null @@ -1,64 +0,0 @@ -package forwardplugin - -type PluginTypeMeta struct { - Type string `json:"type"` - Label string `json:"label"` - Description string `json:"description"` - ConfigSchema interface{} `json:"config_schema"` - DefaultConfig interface{} `json:"default_config"` -} - -func ListPluginTypes() []PluginTypeMeta { - return []PluginTypeMeta{ - { - Type: "sub_filter", - Label: "内容替换", - Description: "按 MIME 类型对响应体做字符串替换", - ConfigSchema: map[string]interface{}{ - "filters": []map[string]string{{"from": "", "to": ""}}, - "types": []string{"text/html"}, - }, - DefaultConfig: map[string]interface{}{ - "filters": []map[string]string{{"from": "", "to": ""}}, - "types": []string{"text/html"}, - }, - }, - { - Type: "inject_js", - Label: "JS 注入", - Description: "在 HTML 前注入脚本", - ConfigSchema: map[string]interface{}{ - "script": "", - }, - DefaultConfig: map[string]interface{}{ - "script": "", - }, - }, - { - Type: "cookie_domain", - Label: "Cookie 域改写", - Description: "改写 Set-Cookie 的 Domain 属性", - ConfigSchema: map[string]interface{}{ - "from": "", - "to": "", - }, - DefaultConfig: map[string]interface{}{ - "from": ".example.com", - "to": ".proxy.com", - }, - }, - { - Type: "response_cache", - Label: "GET 响应缓存", - Description: "缓存上游原始 GET 响应(不含 Set-Cookie)", - ConfigSchema: map[string]interface{}{ - "max_mb": 0, - "ttl_sec": 300, - }, - DefaultConfig: map[string]interface{}{ - "max_mb": 50, - "ttl_sec": 300, - }, - }, - } -} diff --git a/internal/forwardplugin/plugin.go b/internal/forwardplugin/plugin.go deleted file mode 100644 index feafd9e..0000000 --- a/internal/forwardplugin/plugin.go +++ /dev/null @@ -1,118 +0,0 @@ -package forwardplugin - -import ( - "encoding/json" - "net/http" - "sync" -) - -type PluginEntry struct { - Type string `json:"type"` - Enabled bool `json:"enabled"` - Config json.RawMessage `json:"config"` -} - -type Plugin interface { - Type() string - ValidateConfig(raw json.RawMessage) error - NeedsBody() bool - HandleRequest(ctx *Context, req *http.Request) (shortCircuit bool, err error) - HandleResponse(ctx *Context, resp *BufferedResponse) error -} - -type PluginFactory func(config json.RawMessage) (Plugin, error) - -type RawStorer interface { - StoreRaw(ctx *Context, resp *BufferedResponse) -} - -var ( - registryMu sync.RWMutex - registry = map[string]PluginFactory{} -) - -func Register(typ string, factory PluginFactory) { - registryMu.Lock() - defer registryMu.Unlock() - registry[typ] = factory -} - -func getFactory(typ string) (PluginFactory, bool) { - registryMu.RLock() - defer registryMu.RUnlock() - f, ok := registry[typ] - return f, ok -} - -type Context struct { - ForwardID uint - ClientReq *http.Request - CachedResp *BufferedResponse -} - -type BufferedResponse struct { - StatusCode int - Header http.Header - Body []byte - FromCache bool -} - -func (r *BufferedResponse) Clone() *BufferedResponse { - if r == nil { - return nil - } - h := r.Header.Clone() - return &BufferedResponse{ - StatusCode: r.StatusCode, - Header: h, - Body: append([]byte(nil), r.Body...), - FromCache: r.FromCache, - } -} - -type Chain struct { - plugins []Plugin -} - -func (c *Chain) Empty() bool { - return c == nil || len(c.plugins) == 0 -} - -func (c *Chain) NeedsBody() bool { - for _, p := range c.plugins { - if p.NeedsBody() { - return true - } - } - return false -} - -func (c *Chain) HandleRequest(ctx *Context, req *http.Request) (bool, error) { - for _, p := range c.plugins { - short, err := p.HandleRequest(ctx, req) - if err != nil { - return false, err - } - if short { - return true, nil - } - } - return false, nil -} - -func (c *Chain) StoreRawResponses(ctx *Context, resp *BufferedResponse) { - for _, p := range c.plugins { - if st, ok := p.(RawStorer); ok { - st.StoreRaw(ctx, resp) - } - } -} - -func (c *Chain) HandleResponse(ctx *Context, resp *BufferedResponse) error { - for _, p := range c.plugins { - if err := p.HandleResponse(ctx, resp); err != nil { - return err - } - } - return nil -} diff --git a/internal/forwardplugin/plugin_inject.go b/internal/forwardplugin/plugin_inject.go deleted file mode 100644 index 96e37bb..0000000 --- a/internal/forwardplugin/plugin_inject.go +++ /dev/null @@ -1,64 +0,0 @@ -package forwardplugin - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" -) - -func init() { - Register("inject_js", newInjectJSPlugin) -} - -type injectJSConfig struct { - Script string `json:"script"` -} - -type injectJSPlugin struct { - script string -} - -func newInjectJSPlugin(raw json.RawMessage) (Plugin, error) { - var cfg injectJSConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, fmt.Errorf("inject_js 配置无效") - } - return &injectJSPlugin{script: cfg.Script}, nil -} - -func (p *injectJSPlugin) Type() string { return "inject_js" } - -func (p *injectJSPlugin) ValidateConfig(raw json.RawMessage) error { - var cfg injectJSConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return fmt.Errorf("inject_js 配置无效") - } - if strings.TrimSpace(cfg.Script) == "" { - return fmt.Errorf("inject_js.script 不能为空") - } - return nil -} - -func (p *injectJSPlugin) NeedsBody() bool { return true } - -func (p *injectJSPlugin) HandleRequest(_ *Context, _ *http.Request) (bool, error) { - return false, nil -} - -func (p *injectJSPlugin) HandleResponse(_ *Context, resp *BufferedResponse) error { - ct := strings.ToLower(resp.Header.Get("Content-Type")) - if !strings.Contains(ct, "text/html") { - return nil - } - body := string(resp.Body) - lower := strings.ToLower(body) - idx := strings.LastIndex(lower, "") - if idx >= 0 { - body = body[:idx] + p.script + body[idx:] - } else { - body += p.script - } - resp.Body = []byte(body) - return nil -} diff --git a/internal/forwardplugin/plugin_test.go b/internal/forwardplugin/plugin_test.go deleted file mode 100644 index 59b0811..0000000 --- a/internal/forwardplugin/plugin_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package forwardplugin - -import ( - "encoding/json" - "testing" -) - -func TestValidateAndNormalizePlugins(t *testing.T) { - entries := []PluginEntry{ - { - Type: "sub_filter", - Enabled: true, - Config: json.RawMessage(`{"filters":[{"from":"a","to":"b"}]}`), - }, - { - Type: "response_cache", - Enabled: true, - Config: json.RawMessage(`{"max_mb":10,"ttl_sec":0}`), - }, - } - out, err := ValidateAndNormalizePlugins(entries) - if err != nil { - t.Fatal(err) - } - if len(out) != 2 { - t.Fatalf("expected 2 plugins, got %d", len(out)) - } - var cacheCfg responseCacheConfig - if err := json.Unmarshal(out[1].Config, &cacheCfg); err != nil { - t.Fatal(err) - } - if cacheCfg.TTLSec != 300 { - t.Fatalf("expected default ttl 300, got %d", cacheCfg.TTLSec) - } -} - -func TestSubFilterReplace(t *testing.T) { - p, err := newSubFilterPlugin(json.RawMessage(`{"filters":[{"from":"old","to":"new"}],"types":["text/html"]}`)) - if err != nil { - t.Fatal(err) - } - plugin := p.(*subFilterPlugin) - resp := &BufferedResponse{ - StatusCode: 200, - Header: make(map[string][]string), - Body: []byte("old page"), - } - resp.Header.Set("Content-Type", "text/html; charset=utf-8") - if err := plugin.HandleResponse(nil, resp); err != nil { - t.Fatal(err) - } - if string(resp.Body) != "new page" { - t.Fatalf("unexpected body: %s", resp.Body) - } -} - -func TestInjectJS(t *testing.T) { - p, err := newInjectJSPlugin(json.RawMessage(`{"script":""}`)) - if err != nil { - t.Fatal(err) - } - plugin := p.(*injectJSPlugin) - resp := &BufferedResponse{ - Header: make(map[string][]string), - Body: []byte("hi"), - } - resp.Header.Set("Content-Type", "text/html") - if err := plugin.HandleResponse(nil, resp); err != nil { - t.Fatal(err) - } - if want := "hi"; string(resp.Body) != want { - t.Fatalf("got %q want %q", resp.Body, want) - } -} - -func TestCookieDomainRewrite(t *testing.T) { - p, err := newCookieDomainPlugin(json.RawMessage(`{"from":".old.com","to":".new.com"}`)) - if err != nil { - t.Fatal(err) - } - plugin := p.(*cookieDomainPlugin) - resp := &BufferedResponse{ - Header: make(map[string][]string), - } - resp.Header.Add("Set-Cookie", "sid=1; Domain=.old.com; Path=/") - if err := plugin.HandleResponse(nil, resp); err != nil { - t.Fatal(err) - } - got := resp.Header.Get("Set-Cookie") - if got != "sid=1; Domain=.new.com; Path=/" { - t.Fatalf("unexpected cookie: %s", got) - } -} diff --git a/internal/forwardplugin/response_cache.go b/internal/forwardplugin/response_cache.go deleted file mode 100644 index 9ff5c46..0000000 --- a/internal/forwardplugin/response_cache.go +++ /dev/null @@ -1,228 +0,0 @@ -package forwardplugin - -import ( - "container/list" - "encoding/json" - "fmt" - "net/http" - "sync" - "time" -) - -func init() { - Register("response_cache", newResponseCachePlugin) -} - -type responseCacheConfig struct { - MaxMB int `json:"max_mb"` - TTLSec int `json:"ttl_sec"` -} - -type responseCachePlugin struct { - forwardID uint - maxBytes int64 - ttl time.Duration - store *responseCacheStore -} - -func newResponseCachePlugin(raw json.RawMessage) (Plugin, error) { - var cfg responseCacheConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, fmt.Errorf("response_cache 配置无效") - } - return &responseCachePlugin{ - maxBytes: int64(cfg.MaxMB) * 1024 * 1024, - ttl: time.Duration(cfg.TTLSec) * time.Second, - }, nil -} - -func (p *responseCachePlugin) bindForwardID(id uint) { - p.forwardID = id - if p.maxBytes <= 0 { - return - } - p.store = globalResponseCaches.get(id, p.maxBytes, p.ttl) -} - -func (p *responseCachePlugin) Type() string { return "response_cache" } - -func (p *responseCachePlugin) ValidateConfig(raw json.RawMessage) error { - var cfg responseCacheConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return fmt.Errorf("response_cache 配置无效") - } - if cfg.MaxMB < 0 { - return fmt.Errorf("response_cache.max_mb 不能为负数") - } - if cfg.MaxMB > 0 && cfg.TTLSec <= 0 { - return nil - } - if cfg.TTLSec < 0 { - return fmt.Errorf("response_cache.ttl_sec 不能为负数") - } - return nil -} - -func (p *responseCachePlugin) NormalizeConfig(raw json.RawMessage) (json.RawMessage, error) { - var cfg responseCacheConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, err - } - if cfg.MaxMB > 0 && cfg.TTLSec <= 0 { - cfg.TTLSec = 300 - } - return json.Marshal(cfg) -} - -func (p *responseCachePlugin) NeedsBody() bool { return false } - -func (p *responseCachePlugin) HandleRequest(ctx *Context, req *http.Request) (bool, error) { - if p.store == nil || req.Method != http.MethodGet { - return false, nil - } - key := cacheKey(req) - if hit := p.store.get(key); hit != nil { - ctx.CachedResp = hit.Clone() - ctx.CachedResp.FromCache = true - return true, nil - } - return false, nil -} - -func (p *responseCachePlugin) HandleResponse(_ *Context, _ *BufferedResponse) error { - return nil -} - -func (p *responseCachePlugin) StoreRaw(ctx *Context, resp *BufferedResponse) { - if p.store == nil || ctx.ClientReq.Method != http.MethodGet || resp.FromCache { - return - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return - } - if len(resp.Header.Values("Set-Cookie")) > 0 { - return - } - p.store.put(cacheKey(ctx.ClientReq), resp.Clone()) -} - -func cacheKey(req *http.Request) string { - if req.URL.RawQuery != "" { - return req.URL.Path + "?" + req.URL.RawQuery - } - return req.URL.Path -} - -type cacheEntry struct { - key string - resp *BufferedResponse - expires time.Time - size int64 -} - -type responseCacheStore struct { - mu sync.Mutex - maxBytes int64 - ttl time.Duration - usedBytes int64 - items map[string]*list.Element - order *list.List -} - -func newResponseCacheStore(maxBytes int64, ttl time.Duration) *responseCacheStore { - return &responseCacheStore{ - maxBytes: maxBytes, - ttl: ttl, - items: make(map[string]*list.Element), - order: list.New(), - } -} - -func (s *responseCacheStore) get(key string) *BufferedResponse { - s.mu.Lock() - defer s.mu.Unlock() - el, ok := s.items[key] - if !ok { - return nil - } - ent := el.Value.(*cacheEntry) - if time.Now().After(ent.expires) { - s.removeElement(el) - return nil - } - s.order.MoveToFront(el) - return ent.resp -} - -func (s *responseCacheStore) put(key string, resp *BufferedResponse) { - size := int64(len(resp.Body)) - for k, v := range resp.Header { - for _, vv := range v { - size += int64(len(k) + len(vv)) - } - } - s.mu.Lock() - defer s.mu.Unlock() - if el, ok := s.items[key]; ok { - old := el.Value.(*cacheEntry) - s.usedBytes -= old.size - s.order.Remove(el) - delete(s.items, key) - } - for s.usedBytes+size > s.maxBytes && s.order.Len() > 0 { - s.removeElement(s.order.Back()) - } - ent := &cacheEntry{ - key: key, - resp: resp, - expires: time.Now().Add(s.ttl), - size: size, - } - el := s.order.PushFront(ent) - s.items[key] = el - s.usedBytes += size -} - -func (s *responseCacheStore) removeElement(el *list.Element) { - ent := el.Value.(*cacheEntry) - delete(s.items, ent.key) - s.order.Remove(el) - s.usedBytes -= ent.size -} - -type responseCacheManager struct { - mu sync.Mutex - caches map[uint]*responseCacheStore -} - -var globalResponseCaches = &responseCacheManager{caches: make(map[uint]*responseCacheStore)} - -func (m *responseCacheManager) get(forwardID uint, maxBytes int64, ttl time.Duration) *responseCacheStore { - m.mu.Lock() - defer m.mu.Unlock() - if c, ok := m.caches[forwardID]; ok { - c.maxBytes = maxBytes - c.ttl = ttl - return c - } - c := newResponseCacheStore(maxBytes, ttl) - m.caches[forwardID] = c - return c -} - -func BindForwardID(chain *Chain, forwardID uint) { - if chain == nil { - return - } - for _, p := range chain.plugins { - if rc, ok := p.(*responseCachePlugin); ok { - rc.bindForwardID(forwardID) - } - } -} - -func InvalidateForwardCache(forwardID uint) { - globalResponseCaches.mu.Lock() - defer globalResponseCaches.mu.Unlock() - delete(globalResponseCaches.caches, forwardID) -} diff --git a/internal/forwardplugin/sub_filter.go b/internal/forwardplugin/sub_filter.go deleted file mode 100644 index 961be3b..0000000 --- a/internal/forwardplugin/sub_filter.go +++ /dev/null @@ -1,117 +0,0 @@ -package forwardplugin - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" -) - -func init() { - Register("sub_filter", newSubFilterPlugin) -} - -type subFilterConfig struct { - Filters []subFilterRule `json:"filters"` - Types []string `json:"types"` -} - -type subFilterRule struct { - From string `json:"from"` - To string `json:"to"` -} - -type subFilterPlugin struct { - filters []subFilterRule - types []string -} - -func newSubFilterPlugin(raw json.RawMessage) (Plugin, error) { - var cfg subFilterConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, fmt.Errorf("sub_filter 配置无效") - } - return &subFilterPlugin{filters: cfg.Filters, types: cfg.Types}, nil -} - -func (p *subFilterPlugin) Type() string { return "sub_filter" } - -func (p *subFilterPlugin) ValidateConfig(raw json.RawMessage) error { - cfg, err := parseSubFilterConfig(raw) - if err != nil { - return err - } - if len(cfg.Filters) == 0 { - return fmt.Errorf("sub_filter.filters 不能为空") - } - for i, f := range cfg.Filters { - if f.From == "" { - return fmt.Errorf("sub_filter.filters[%d].from 不能为空", i) - } - } - return nil -} - -func (p *subFilterPlugin) NormalizeConfig(raw json.RawMessage) (json.RawMessage, error) { - cfg, err := parseSubFilterConfig(raw) - if err != nil { - return nil, err - } - if len(cfg.Types) == 0 { - cfg.Types = []string{"text/html"} - } - return json.Marshal(cfg) -} - -func parseSubFilterConfig(raw json.RawMessage) (*subFilterConfig, error) { - var cfg subFilterConfig - if err := json.Unmarshal(raw, &cfg); err != nil { - return nil, fmt.Errorf("sub_filter 配置无效") - } - types := make([]string, 0, len(cfg.Types)) - for _, t := range cfg.Types { - t = strings.TrimSpace(strings.ToLower(t)) - if t != "" { - types = append(types, t) - } - } - if len(types) == 0 { - types = []string{"text/html"} - } - cfg.Types = types - return &cfg, nil -} - -func (p *subFilterPlugin) NeedsBody() bool { return true } - -func (p *subFilterPlugin) HandleRequest(_ *Context, _ *http.Request) (bool, error) { - return false, nil -} - -func (p *subFilterPlugin) HandleResponse(_ *Context, resp *BufferedResponse) error { - if !p.matchesContentType(resp.Header.Get("Content-Type")) { - return nil - } - body := string(resp.Body) - for _, rule := range p.filters { - body = strings.ReplaceAll(body, rule.From, rule.To) - } - resp.Body = []byte(body) - return nil -} - -func (p *subFilterPlugin) matchesContentType(ct string) bool { - ct = strings.TrimSpace(strings.ToLower(ct)) - if ct == "" { - return false - } - if i := strings.Index(ct, ";"); i >= 0 { - ct = strings.TrimSpace(ct[:i]) - } - for _, t := range p.types { - if ct == t { - return true - } - } - return false -} diff --git a/internal/forwardplugin/validate.go b/internal/forwardplugin/validate.go deleted file mode 100644 index d519b90..0000000 --- a/internal/forwardplugin/validate.go +++ /dev/null @@ -1,87 +0,0 @@ -package forwardplugin - -import ( - "encoding/json" - "fmt" - "strings" -) - -func ValidateAndNormalizePlugins(entries []PluginEntry) ([]PluginEntry, error) { - if len(entries) == 0 { - return nil, nil - } - out := make([]PluginEntry, 0, len(entries)) - for i, e := range entries { - typ := strings.TrimSpace(e.Type) - if typ == "" { - return nil, fmt.Errorf("plugins[%d].type 不能为空", i) - } - factory, ok := getFactory(typ) - if !ok { - return nil, fmt.Errorf("未知插件类型: %s", typ) - } - cfg := e.Config - if len(cfg) == 0 { - cfg = json.RawMessage("{}") - } - p, err := factory(cfg) - if err != nil { - return nil, fmt.Errorf("plugins[%d]: %w", i, err) - } - if err := p.ValidateConfig(cfg); err != nil { - return nil, fmt.Errorf("plugins[%d]: %w", i, err) - } - normalized, err := normalizeConfig(cfg, p) - if err != nil { - return nil, fmt.Errorf("plugins[%d]: %w", i, err) - } - out = append(out, PluginEntry{ - Type: typ, - Enabled: e.Enabled, - Config: normalized, - }) - } - return out, nil -} - -func normalizeConfig(raw json.RawMessage, p Plugin) (json.RawMessage, error) { - if norm, ok := p.(ConfigNormalizer); ok { - return norm.NormalizeConfig(raw) - } - return raw, nil -} - -type ConfigNormalizer interface { - NormalizeConfig(raw json.RawMessage) (json.RawMessage, error) -} - -func BuildChain(entries []PluginEntry) (*Chain, error) { - plugins := make([]Plugin, 0, len(entries)) - for _, e := range entries { - if !e.Enabled { - continue - } - factory, ok := getFactory(e.Type) - if !ok { - return nil, fmt.Errorf("未知插件类型: %s", e.Type) - } - cfg := e.Config - if len(cfg) == 0 { - cfg = json.RawMessage("{}") - } - p, err := factory(cfg) - if err != nil { - return nil, err - } - plugins = append(plugins, p) - } - return &Chain{plugins: plugins}, nil -} - -func PluginsJSON(entries []PluginEntry) string { - if len(entries) == 0 { - return "[]" - } - b, _ := json.Marshal(entries) - return string(b) -} diff --git a/internal/proxy/domain_forward.go b/internal/proxy/domain_forward.go deleted file mode 100644 index 44ed811..0000000 --- a/internal/proxy/domain_forward.go +++ /dev/null @@ -1,217 +0,0 @@ -package proxy - -import ( - "context" - "crypto/tls" - "fmt" - "io" - "net" - "net/http" - "net/http/httputil" - "net/url" - "strings" - - "github.com/wormhole/wormhole/internal/store" - "golang.org/x/net/proxy" -) - -func (m *Manager) handleDomainForward(w http.ResponseWriter, r *http.Request, df *store.DomainForward) { - switch df.Mode { - case "redirect": - m.handleDomainRedirect(w, r, df) - case "proxy": - m.handleDomainProxy(w, r, df) - default: - http.Error(w, "bad forward mode", http.StatusBadGateway) - } -} - -func (m *Manager) handleDomainRedirect(w http.ResponseWriter, r *http.Request, df *store.DomainForward) { - target, err := buildRedirectURL(df, r) - if err != nil { - http.Error(w, "bad redirect target", http.StatusBadGateway) - return - } - code := df.RedirectCode - if code != 301 && code != 302 { - code = 302 - } - http.Redirect(w, r, target, code) -} - -func buildRedirectURL(df *store.DomainForward, r *http.Request) (string, error) { - targetBase, err := url.Parse(df.TargetURL) - if err != nil { - return "", err - } - if df.RedirectMode == "fixed" { - return targetBase.String(), nil - } - loc := df.SourceLocation - if loc == "" { - loc = "/" - } - suffix := strings.TrimPrefix(r.URL.Path, loc) - if suffix == "" { - suffix = "/" - } - if !strings.HasPrefix(suffix, "/") { - suffix = "/" + suffix - } - out := *targetBase - if strings.HasSuffix(targetBase.Path, "/") && suffix == "/" { - // keep target path as-is - } else if targetBase.Path == "" || targetBase.Path == "/" { - out.Path = suffix - } else { - out.Path = strings.TrimSuffix(targetBase.Path, "/") + suffix - } - out.RawQuery = r.URL.RawQuery - return out.String(), nil -} - -func (m *Manager) handleDomainProxy(w http.ResponseWriter, r *http.Request, df *store.DomainForward) { - targetBase, err := url.Parse(df.TargetURL) - if err != nil { - http.Error(w, "bad target", http.StatusBadGateway) - return - } - - transport, err := m.domainForwardTransport(df) - if err != nil { - http.Error(w, "proxy transport error", http.StatusBadGateway) - return - } - - proxy := httputil.NewSingleHostReverseProxy(targetBase) - proxy.Transport = transport - loc := df.SourceLocation - if loc == "" { - loc = "/" - } - originalDirector := proxy.Director - proxy.Director = func(req *http.Request) { - originalDirector(req) - suffix := strings.TrimPrefix(r.URL.Path, loc) - if suffix == "" { - suffix = "/" - } - if !strings.HasPrefix(suffix, "/") { - suffix = "/" + suffix - } - if targetBase.Path == "" || targetBase.Path == "/" { - req.URL.Path = suffix - } else { - req.URL.Path = strings.TrimSuffix(targetBase.Path, "/") + suffix - } - req.URL.RawQuery = r.URL.RawQuery - req.URL.Scheme = targetBase.Scheme - req.URL.Host = targetBase.Host - if df.PreserveHost { - req.Host = r.Host - } else { - req.Host = targetBase.Host - } - } - proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, e error) { - http.Error(rw, "proxy error", http.StatusBadGateway) - } - if isWebSocketUpgrade(r) { - proxy.ServeHTTP(w, r) - return - } - - if hasEnabledPlugins(df.Plugins) { - upstreamReq, err := buildDomainForwardUpstreamRequest(r, df, targetBase, loc) - if err != nil { - http.Error(w, "bad target", http.StatusBadGateway) - return - } - if err := runForwardPluginPipeline(w, r, df, upstreamReq, transport); err != nil { - http.Error(w, "proxy error", http.StatusBadGateway) - } - return - } - - rec := &responseRecorder{ResponseWriter: w, status: 200} - proxy.ServeHTTP(rec, r) -} - -func buildDomainForwardUpstreamRequest(r *http.Request, df *store.DomainForward, targetBase *url.URL, loc string) (*http.Request, error) { - suffix := strings.TrimPrefix(r.URL.Path, loc) - if suffix == "" { - suffix = "/" - } - if !strings.HasPrefix(suffix, "/") { - suffix = "/" + suffix - } - path := suffix - if targetBase.Path != "" && targetBase.Path != "/" { - path = strings.TrimSuffix(targetBase.Path, "/") + suffix - } - upstreamURL := *targetBase - upstreamURL.Path = path - upstreamURL.RawQuery = r.URL.RawQuery - - var body io.ReadCloser - if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead { - body = r.Body - } - outReq, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL.String(), body) - if err != nil { - return nil, err - } - outReq.Header = r.Header.Clone() - if df.PreserveHost { - outReq.Host = r.Host - } else { - outReq.Host = targetBase.Host - } - return outReq, nil -} - -func (m *Manager) domainForwardTransport(df *store.DomainForward) (*http.Transport, error) { - t := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - if df.ProxyType == "http_proxy" { - addr := strings.TrimSpace(df.HTTPProxyAddr) - if addr == "" { - return nil, fmt.Errorf("http proxy addr required") - } - if !strings.Contains(addr, "://") { - addr = "http://" + addr - } - proxyURL, err := url.Parse(addr) - if err != nil || proxyURL.Host == "" { - return nil, fmt.Errorf("invalid http proxy addr") - } - if df.HTTPProxyUser != "" { - proxyURL.User = url.UserPassword(df.HTTPProxyUser, df.HTTPProxyPass) - } - t.Proxy = http.ProxyURL(proxyURL) - return t, nil - } - if df.ProxyType != "socks5" { - return t, nil - } - addr := strings.TrimSpace(df.Socks5Addr) - if addr == "" { - return nil, fmt.Errorf("socks5 addr required") - } - var auth *proxy.Auth - if df.Socks5User != "" { - auth = &proxy.Auth{User: df.Socks5User, Password: df.Socks5Pass} - } - dialer, err := proxy.SOCKS5("tcp", addr, auth, proxy.Direct) - if err != nil { - return nil, err - } - t.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { - if cd, ok := dialer.(proxy.ContextDialer); ok { - return cd.DialContext(ctx, network, address) - } - return dialer.Dial(network, address) - } - return t, nil -} diff --git a/internal/proxy/forward_pipeline.go b/internal/proxy/forward_pipeline.go deleted file mode 100644 index 5530ef8..0000000 --- a/internal/proxy/forward_pipeline.go +++ /dev/null @@ -1,115 +0,0 @@ -package proxy - -import ( - "bytes" - "io" - "net/http" - - "github.com/wormhole/wormhole/internal/forwardplugin" - "github.com/wormhole/wormhole/internal/store" -) - -func pluginEntriesFromStore(plugins store.ForwardPlugins) []forwardplugin.PluginEntry { - if len(plugins) == 0 { - return nil - } - out := make([]forwardplugin.PluginEntry, len(plugins)) - for i, p := range plugins { - out[i] = forwardplugin.PluginEntry{ - Type: p.Type, - Enabled: p.Enabled, - Config: p.Config, - } - } - return out -} - -func runForwardPluginPipeline( - w http.ResponseWriter, - clientReq *http.Request, - df *store.DomainForward, - upstreamReq *http.Request, - transport http.RoundTripper, -) error { - chain, err := forwardplugin.BuildChain(pluginEntriesFromStore(df.Plugins)) - if err != nil { - return err - } - - forwardplugin.BindForwardID(chain, df.ID) - - ctx := &forwardplugin.Context{ - ForwardID: df.ID, - ClientReq: clientReq, - } - - outReq := upstreamReq.Clone(clientReq.Context()) - if chain.NeedsBody() { - outReq.Header.Del("Accept-Encoding") - } - - short, err := chain.HandleRequest(ctx, outReq) - if err != nil { - return err - } - - var resp *forwardplugin.BufferedResponse - if short && ctx.CachedResp != nil { - resp = ctx.CachedResp.Clone() - } else { - resp, err = fetchUpstreamResponse(outReq, transport, chain.NeedsBody()) - if err != nil { - return err - } - chain.StoreRawResponses(ctx, resp) - } - - if err := chain.HandleResponse(ctx, resp); err != nil { - return err - } - writeBufferedResponse(w, resp) - return nil -} - -func fetchUpstreamResponse(req *http.Request, transport http.RoundTripper, _ bool) (*forwardplugin.BufferedResponse, error) { - client := &http.Client{Transport: transport} - upstreamResp, err := client.Do(req) - if err != nil { - return nil, err - } - defer upstreamResp.Body.Close() - - body, err := io.ReadAll(upstreamResp.Body) - if err != nil { - return nil, err - } - return &forwardplugin.BufferedResponse{ - StatusCode: upstreamResp.StatusCode, - Header: upstreamResp.Header.Clone(), - Body: body, - }, nil -} - -func writeBufferedResponse(w http.ResponseWriter, resp *forwardplugin.BufferedResponse) { - for k, vals := range resp.Header { - for _, v := range vals { - w.Header().Add(k, v) - } - } - if resp.StatusCode == 0 { - resp.StatusCode = http.StatusOK - } - w.WriteHeader(resp.StatusCode) - if len(resp.Body) > 0 { - _, _ = io.Copy(w, bytes.NewReader(resp.Body)) - } -} - -func hasEnabledPlugins(plugins store.ForwardPlugins) bool { - for _, p := range plugins { - if p.Enabled { - return true - } - } - return false -} diff --git a/internal/proxy/manager.go b/internal/proxy/manager.go index 9fd1d08..1608ac5 100644 --- a/internal/proxy/manager.go +++ b/internal/proxy/manager.go @@ -191,14 +191,6 @@ func itoa(n int) string { func (m *Manager) httpHandler(scheme string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { directIP := auth.DirectRemoteIP(r) - if fwd := m.cache.MatchDomainForward(r.Host, r.URL.Path, scheme); fwd != nil { - if !m.security.Allow(directIP, 0, "global", 0) { - http.Error(w, "forbidden", http.StatusForbidden) - return - } - m.handleDomainForward(w, r, fwd) - return - } host := m.cache.MatchHost(r.Host, r.URL.Path, scheme) if host == nil { diff --git a/internal/store/domain_forward.go b/internal/store/domain_forward.go deleted file mode 100644 index 515e54a..0000000 --- a/internal/store/domain_forward.go +++ /dev/null @@ -1,176 +0,0 @@ -package store - -import ( - "fmt" - "net/url" - "strings" - - "github.com/wormhole/wormhole/internal/forwardplugin" -) - -func normalizeDomainForwardRoute(host, location string) (string, string) { - host = strings.TrimSpace(strings.ToLower(host)) - location = strings.TrimSpace(location) - if location == "" { - location = "/" - } - if !strings.HasPrefix(location, "/") { - location = "/" + location - } - return host, location -} - -func (s *Store) ListDomainForwards() ([]DomainForward, error) { - var items []DomainForward - err := s.db.Order("id asc").Find(&items).Error - return items, err -} - -func (s *Store) GetDomainForwardByID(id uint) (*DomainForward, error) { - var df DomainForward - if err := s.db.First(&df, id).Error; err != nil { - return nil, err - } - return &df, nil -} - -func (s *Store) CreateDomainForward(df *DomainForward) error { - df.SourceHost, df.SourceLocation = normalizeDomainForwardRoute(df.SourceHost, df.SourceLocation) - return s.db.Create(df).Error -} - -func (s *Store) UpdateDomainForward(df *DomainForward) error { - df.SourceHost, df.SourceLocation = normalizeDomainForwardRoute(df.SourceHost, df.SourceLocation) - return s.db.Save(df).Error -} - -func (s *Store) DeleteDomainForward(id uint) error { - return s.db.Delete(&DomainForward{}, id).Error -} - -func (s *Store) DomainForwardRouteInUse(host, location string, excludeID uint) (bool, error) { - host, location = normalizeDomainForwardRoute(host, location) - q := s.db.Model(&DomainForward{}).Where("source_host = ? AND source_location = ?", host, location) - if excludeID > 0 { - q = q.Where("id <> ?", excludeID) - } - var count int64 - if err := q.Count(&count).Error; err != nil { - return false, err - } - return count > 0, nil -} - -// RouteConflict checks domain forward vs penetration host on same host+location. -func (s *Store) RouteConflict(host, location string, excludeForwardID, excludeHostID uint) error { - host, location = normalizeDomainForwardRoute(host, location) - inUse, err := s.DomainForwardRouteInUse(host, location, excludeForwardID) - if err != nil { - return err - } - if inUse { - return fmt.Errorf("域名与路径前缀已被其他转发规则占用") - } - hostInUse, err := s.HostRouteInUse(host, location, excludeHostID) - if err != nil { - return err - } - if hostInUse { - return fmt.Errorf("域名与路径前缀已被穿透域名占用") - } - return nil -} - -func ValidateDomainForward(df *DomainForward) error { - if strings.TrimSpace(df.SourceHost) == "" { - return fmt.Errorf("源域名不能为空") - } - target := strings.TrimSpace(df.TargetURL) - if target == "" { - return fmt.Errorf("目标 URL 不能为空") - } - u, err := url.Parse(target) - if err != nil || u.Scheme == "" || u.Host == "" { - return fmt.Errorf("目标 URL 格式无效,需包含协议和主机,如 https://example.com") - } - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("目标 URL 仅支持 http/https") - } - df.TargetURL = u.String() - - switch df.Mode { - case "redirect", "proxy": - default: - return fmt.Errorf("模式无效") - } - if df.Mode == "redirect" { - if df.RedirectMode == "" { - df.RedirectMode = "preserve" - } - if df.RedirectMode != "preserve" && df.RedirectMode != "fixed" { - return fmt.Errorf("跳转策略无效") - } - if df.RedirectCode == 0 { - df.RedirectCode = 302 - } - if df.RedirectCode != 301 && df.RedirectCode != 302 { - return fmt.Errorf("跳转状态码仅支持 301 或 302") - } - } - if df.Mode == "proxy" { - if df.ProxyType == "" { - df.ProxyType = "http" - } - if df.ProxyType != "http" && df.ProxyType != "socks5" && df.ProxyType != "http_proxy" { - return fmt.Errorf("代理类型无效") - } - if df.ProxyType == "socks5" && strings.TrimSpace(df.Socks5Addr) == "" { - return fmt.Errorf("SOCKS5 代理地址不能为空") - } - if df.ProxyType == "http_proxy" && strings.TrimSpace(df.HTTPProxyAddr) == "" { - return fmt.Errorf("HTTP 代理地址不能为空") - } - normalized, err := forwardplugin.ValidateAndNormalizePlugins(toPluginEntries(df.Plugins)) - if err != nil { - return err - } - df.Plugins = fromPluginEntries(normalized) - } - if df.SourceScheme == "" { - df.SourceScheme = "all" - } - if df.SourceLocation == "" { - df.SourceLocation = "/" - } - return nil -} - -func toPluginEntries(plugins ForwardPlugins) []forwardplugin.PluginEntry { - if len(plugins) == 0 { - return nil - } - out := make([]forwardplugin.PluginEntry, len(plugins)) - for i, p := range plugins { - out[i] = forwardplugin.PluginEntry{ - Type: p.Type, - Enabled: p.Enabled, - Config: p.Config, - } - } - return out -} - -func fromPluginEntries(entries []forwardplugin.PluginEntry) ForwardPlugins { - if len(entries) == 0 { - return nil - } - out := make(ForwardPlugins, len(entries)) - for i, e := range entries { - out[i] = ForwardPluginEntry{ - Type: e.Type, - Enabled: e.Enabled, - Config: e.Config, - } - } - return out -} diff --git a/internal/store/models.go b/internal/store/models.go index 7242237..2e756fc 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -1,10 +1,6 @@ package store import ( - "database/sql/driver" - "encoding/json" - "fmt" - "strings" "time" ) @@ -163,74 +159,3 @@ type VisitorAccessLog struct { UserAgent string `gorm:"size:256" json:"user_agent"` CreatedAt time.Time `gorm:"index:idx_va_user_time" json:"created_at"` } - -// DomainForward maps a public source domain to another URL (redirect or reverse proxy). -// Unlike Host (penetration), this is handled entirely on the server without Agent. -type DomainForward struct { - ID uint `gorm:"primaryKey" json:"id"` - SourceHost string `gorm:"size:256;not null;uniqueIndex:idx_df_route" json:"source_host"` - SourceLocation string `gorm:"size:256;default:/;uniqueIndex:idx_df_route" json:"source_location"` - SourceScheme string `gorm:"size:16;default:all" json:"source_scheme"` // http, https, all - Mode string `gorm:"size:16;not null" json:"mode"` // redirect, proxy - TargetURL string `gorm:"size:1024;not null" json:"target_url"` - RedirectMode string `gorm:"size:16;default:preserve" json:"redirect_mode"` // preserve, fixed - RedirectCode int `gorm:"default:302" json:"redirect_code"` - ProxyType string `gorm:"size:16;default:http" json:"proxy_type"` // http(direct), socks5, http_proxy - Socks5Addr string `gorm:"size:256" json:"socks5_addr"` - Socks5User string `gorm:"size:128" json:"socks5_user"` - Socks5Pass string `gorm:"size:128" json:"-"` - HTTPProxyAddr string `gorm:"size:512" json:"http_proxy_addr"` - HTTPProxyUser string `gorm:"size:128" json:"http_proxy_user"` - HTTPProxyPass string `gorm:"size:128" json:"-"` - PreserveHost bool `gorm:"default:false" json:"preserve_host"` - Plugins ForwardPlugins `gorm:"type:text" json:"plugins"` - Status bool `gorm:"not null;default:true" json:"status"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ForwardPluginEntry struct { - Type string `json:"type"` - Enabled bool `json:"enabled"` - Config json.RawMessage `json:"config"` -} - -type ForwardPlugins []ForwardPluginEntry - -func (p ForwardPlugins) Value() (driver.Value, error) { - if len(p) == 0 { - return "[]", nil - } - b, err := json.Marshal([]ForwardPluginEntry(p)) - if err != nil { - return nil, err - } - return string(b), nil -} - -func (p *ForwardPlugins) Scan(value interface{}) error { - if value == nil { - *p = nil - return nil - } - var raw string - switch v := value.(type) { - case string: - raw = v - case []byte: - raw = string(v) - default: - return fmt.Errorf("unsupported plugins value type %T", value) - } - raw = strings.TrimSpace(raw) - if raw == "" || raw == "null" { - *p = nil - return nil - } - var items []ForwardPluginEntry - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return err - } - *p = items - return nil -} diff --git a/internal/store/query.go b/internal/store/query.go index f1b3d9d..084d86c 100644 --- a/internal/store/query.go +++ b/internal/store/query.go @@ -78,29 +78,6 @@ func applyHostSearch(q *gorm.DB, keyword string) *gorm.DB { return q.Where("host LIKE ? OR target_addr LIKE ? OR location LIKE ?", like, like, like) } -func applyDomainForwardSearch(q *gorm.DB, keyword string) *gorm.DB { - if keyword == "" { - return q - } - like := "%" + keyword + "%" - return q.Where( - "source_host LIKE ? OR target_url LIKE ? OR source_location LIKE ?", - like, like, like, - ) -} - -func (s *Store) ListDomainForwardsPaged(p PageParams) ([]DomainForward, int64, error) { - var items []DomainForward - var total int64 - q := applyDomainForwardSearch(s.db.Model(&DomainForward{}), p.Q) - if err := q.Count(&total).Error; err != nil { - return nil, 0, err - } - offset, limit := NormalizePage(p) - err := q.Order("id asc").Offset(offset).Limit(limit).Find(&items).Error - return items, total, err -} - func (s *Store) ListTunnelsPaged(clientID, ownerID uint, isAdmin bool, p PageParams) ([]Tunnel, int64, error) { var tunnels []Tunnel var total int64 @@ -114,6 +91,20 @@ func (s *Store) ListTunnelsPaged(clientID, ownerID uint, isAdmin bool, p PagePar return tunnels, total, err } +func (s *Store) HostRouteConflict(host, location string, excludeHostID uint) error { + if location == "" { + location = "/" + } + inUse, err := s.HostRouteInUse(host, location, excludeHostID) + if err != nil { + return err + } + if inUse { + return fmt.Errorf("域名与路径前缀已被穿透域名占用") + } + return nil +} + func (s *Store) ListHostsPaged(clientID, ownerID uint, isAdmin bool, p PageParams) ([]Host, int64, error) { var hosts []Host var total int64 diff --git a/internal/store/store.go b/internal/store/store.go index afda738..1cdf550 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -68,7 +68,6 @@ func (s *Store) migrate() error { &APIKey{}, &ResourceVisitor{}, &VisitorAccessLog{}, - &DomainForward{}, ) } diff --git a/web/index.html b/web/index.html index cc9a22c..82e05f8 100644 --- a/web/index.html +++ b/web/index.html @@ -3,6 +3,7 @@ + Wormhole diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..b079d51 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web/src/components/AppLogo.vue b/web/src/components/AppLogo.vue index 67b55c7..487a6ab 100644 --- a/web/src/components/AppLogo.vue +++ b/web/src/components/AppLogo.vue @@ -1,8 +1,28 @@ diff --git a/web/src/components/ForwardPluginEditor.vue b/web/src/components/ForwardPluginEditor.vue deleted file mode 100644 index 701db7d..0000000 --- a/web/src/components/ForwardPluginEditor.vue +++ /dev/null @@ -1,166 +0,0 @@ - - - - - diff --git a/web/src/components/RankDialog.vue b/web/src/components/RankDialog.vue index fc6f730..307f896 100644 --- a/web/src/components/RankDialog.vue +++ b/web/src/components/RankDialog.vue @@ -17,9 +17,8 @@
diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index 4cf8fc8..d10263e 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -55,7 +55,6 @@ import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { Odometer, - Link, Connection, Share, Monitor, @@ -75,7 +74,6 @@ const topbarKeys = { '/clients': 'topbar.clients', '/tunnels': 'topbar.tunnels', '/hosts': 'topbar.hosts', - '/domain-forwards': 'topbar.domainForwards', '/security/ip-rules': 'topbar.ipRules', '/security/request-ips': 'topbar.requestIPs', '/users': 'topbar.users', @@ -99,11 +97,6 @@ const navGroups = computed(() => [ visible: true, items: [{ path: '/dashboard', labelKey: 'nav.dashboard', icon: Odometer }], }, - { - labelKey: 'nav.groups.forward', - visible: isAdmin.value, - items: [{ path: '/domain-forwards', labelKey: 'nav.domainForwards', icon: Link }], - }, { labelKey: 'nav.groups.tunnel', visible: true, diff --git a/web/src/locales/en.js b/web/src/locales/en.js index 926fa0b..51c5da6 100644 --- a/web/src/locales/en.js +++ b/web/src/locales/en.js @@ -60,13 +60,11 @@ export default { nav: { groups: { overview: 'Overview', - forward: 'Forward', tunnel: 'Tunnel', security: 'Security', system: 'System', }, dashboard: 'Dashboard', - domainForwards: 'Domain forward', clients: 'Clients', tunnels: 'Tunnels', hosts: 'Hosts', @@ -80,7 +78,6 @@ export default { clients: 'Client management', tunnels: 'Tunnel management', hosts: 'Host routing', - domainForwards: 'Domain forward', ipRules: 'IP rules', requestIPs: 'Request IP monitor', users: 'Visitor users', @@ -161,45 +158,6 @@ export default { visitorAuth: 'Visitor auth', confirmDelete: 'Delete this host?', }, - domainForwards: { - title: 'Domain forward', - description: 'Redirect or reverse-proxy source domains to target URLs without Agent', - add: 'Add forward', - edit: 'Edit forward', - searchPlaceholder: 'Search source host, target', - sourceHost: 'Source host', - sourceLocation: 'Path prefix', - scheme: 'Scheme', - schemeAll: 'All', - forwardMode: 'Forward mode', - redirect: '302/301 redirect', - proxy: 'Reverse proxy', - targetUrl: 'Target URL', - redirectMode: 'Redirect strategy', - preservePath: 'Preserve path and query', - fixedTarget: 'Fixed target URL', - statusCode: 'Status code', - outboundProxy: 'Outbound proxy', - httpDirect: 'HTTP direct', - httpProxy: 'HTTP proxy', - socks5: 'SOCKS5 proxy', - httpProxyAddr: 'HTTP proxy address', - socks5Addr: 'SOCKS5 address', - username: 'Username', - password: 'Password', - preserveHost: 'Preserve Host', - preserveHostHint: 'Send source host as Host header to upstream when enabled', - pluginChain: 'Plugin chain', - pluginChainHint: 'Process responses in order; HTTP only, WebSocket bypasses plugins', - plugins: 'Plugins', - proxyCol: 'Proxy', - direct: 'Direct', - httpProxyShort: 'HTTP proxy', - modeProxy: 'Proxy', - modeRedirect: 'Redirect', - pluginDisabled: 'Disabled', - confirmDelete: 'Delete this forward rule?', - }, ipRules: { title: 'IP rules', description: 'Allow / deny lists and CIDR access control', @@ -214,7 +172,11 @@ export default { global: 'Global', tunnel: 'Tunnel', host: 'Host', + resource: 'Resource', resourceId: 'Resource ID', + selectResource: 'Select a resource', + noResource: 'No resources available. Create one first.', + resourceRequired: 'Please select a resource', confirmDelete: 'Delete this rule?', }, requestIPs: { @@ -316,21 +278,6 @@ export default { importDone: 'Import done: created {created}, updated {updated}, skipped {skipped}, failed {failed}', parseFailed: 'Failed to parse file', }, - forwardPlugin: { - addPlugin: 'Add plugin', - moveUp: 'Move up', - moveDown: 'Move down', - find: 'Find', - replaceWith: 'Replace with', - addRule: 'Add replace rule', - mimeTypes: 'MIME types', - removeShort: 'Del', - mbHint: 'MB (0 disables cache)', - ttlSec: 'seconds TTL', - scriptPlaceholder: '', - fromDomain: 'From domain, e.g. .old.com', - toDomain: 'To domain, e.g. .new.com', - }, clientSelect: { placeholder: 'Select client', label: '{name} (#{id}) · {status}', diff --git a/web/src/locales/zh-CN.js b/web/src/locales/zh-CN.js index 5b2cbd6..0537eb1 100644 --- a/web/src/locales/zh-CN.js +++ b/web/src/locales/zh-CN.js @@ -60,13 +60,11 @@ export default { nav: { groups: { overview: '概览', - forward: '转发', tunnel: '穿透', security: '安全', system: '系统', }, dashboard: '仪表盘', - domainForwards: '域名转发', clients: '客户端', tunnels: '隧道', hosts: '域名', @@ -80,7 +78,6 @@ export default { clients: '客户端管理', tunnels: '隧道管理', hosts: '域名解析', - domainForwards: '域名转发', ipRules: 'IP 规则', requestIPs: '请求 IP 监控', users: '访客用户', @@ -161,45 +158,6 @@ export default { visitorAuth: '访客登录', confirmDelete: '确认删除该域名?', }, - domainForwards: { - title: '域名转发', - description: '将源域名跳转或反代到目标 URL,无需 Agent(与内网穿透独立)', - add: '新增转发', - edit: '编辑转发', - searchPlaceholder: '搜索源域名、目标', - sourceHost: '源域名', - sourceLocation: '路径前缀', - scheme: '协议', - schemeAll: '全部', - forwardMode: '转发模式', - redirect: '302/301 跳转', - proxy: '反向代理', - targetUrl: '目标 URL', - redirectMode: '跳转策略', - preservePath: '保留路径与参数', - fixedTarget: '固定目标地址', - statusCode: '状态码', - outboundProxy: '出站代理', - httpDirect: 'HTTP 直连', - httpProxy: 'HTTP 代理', - socks5: 'SOCKS5 代理', - httpProxyAddr: 'HTTP 代理地址', - socks5Addr: 'SOCKS5 地址', - username: '用户名', - password: '密码', - preserveHost: '保留 Host', - preserveHostHint: '开启后将源域名作为 Host 头发给上游', - pluginChain: '插件链', - pluginChainHint: '按顺序处理响应;仅对普通 HTTP 生效,WebSocket 不走插件', - plugins: '插件', - proxyCol: '代理', - direct: '直连', - httpProxyShort: 'HTTP代理', - modeProxy: '反代', - modeRedirect: '跳转', - pluginDisabled: '未启用', - confirmDelete: '确认删除该转发规则?', - }, ipRules: { title: 'IP 规则', description: '白名单 / 黑名单 / CIDR 访问控制', @@ -214,7 +172,11 @@ export default { global: '全局', tunnel: '隧道', host: '域名', + resource: '关联资源', resourceId: '资源ID', + selectResource: '请选择资源', + noResource: '暂无可用资源,请先创建', + resourceRequired: '请选择关联资源', confirmDelete: '确认删除该规则?', }, requestIPs: { @@ -316,21 +278,6 @@ export default { importDone: '导入完成:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}', parseFailed: '解析文件失败', }, - forwardPlugin: { - addPlugin: '添加插件', - moveUp: '上移', - moveDown: '下移', - find: '查找', - replaceWith: '替换为', - addRule: '添加替换规则', - mimeTypes: 'MIME 类型', - removeShort: '删', - mbHint: 'MB(0 表示禁用缓存)', - ttlSec: '秒 TTL', - scriptPlaceholder: '', - fromDomain: '原 Domain,如 .old.com', - toDomain: '新 Domain,如 .new.com', - }, clientSelect: { placeholder: '选择客户端', label: '{name} (#{id}) · {status}', diff --git a/web/src/router/index.js b/web/src/router/index.js index 7f67b7d..d9d321c 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -5,7 +5,6 @@ import Dashboard from '@/views/Dashboard.vue' import Clients from '@/views/Clients.vue' import Tunnels from '@/views/Tunnels.vue' import Hosts from '@/views/Hosts.vue' -import DomainForwards from '@/views/DomainForwards.vue' import IPRules from '@/views/IPRules.vue' import RequestIPs from '@/views/RequestIPs.vue' import Users from '@/views/Users.vue' @@ -22,7 +21,6 @@ const routes = [ { path: 'clients', component: Clients }, { path: 'tunnels', component: Tunnels }, { path: 'hosts', component: Hosts }, - { path: 'domain-forwards', component: DomainForwards }, { path: 'security/ip-rules', component: IPRules }, { path: 'security/request-ips', component: RequestIPs }, { path: 'users', component: Users }, diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue index 924b4d5..c68de5d 100644 --- a/web/src/views/Dashboard.vue +++ b/web/src/views/Dashboard.vue @@ -74,7 +74,7 @@ - + diff --git a/web/src/views/DomainForwards.vue b/web/src/views/DomainForwards.vue deleted file mode 100644 index a8ebb0f..0000000 --- a/web/src/views/DomainForwards.vue +++ /dev/null @@ -1,305 +0,0 @@ - - - diff --git a/web/src/views/IPRules.vue b/web/src/views/IPRules.vue index a6dc7c2..51a6b5f 100644 --- a/web/src/views/IPRules.vue +++ b/web/src/views/IPRules.vue @@ -11,16 +11,17 @@ - - - - + + + + + + +