feat: 移除域名转发并优化 IP 规则、请求 IP 与品牌图标
CI / docker (push) Has been cancelled

删除域名转发模块与 forwardplugin;IP 规则资源改为下拉选择并修复拉黑字段映射;请求 IP 展示隧道/域名标签;新增 Wormhole 品牌 Logo 与 favicon。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-23 16:54:01 +08:00
parent 26e3475b24
commit aa600b6acb
35 changed files with 292 additions and 2741 deletions
-144
View File
@@ -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})
}
+1 -1
View File
@@ -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"})
}
-17
View File
@@ -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()})
}
+3 -357
View File
@@ -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 </body> 前注入脚本",
"properties": {
"script": { "type": "string", "example": "<script></script>" }
},
"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": "缓存容量 MB0 表示禁用", "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": "<script>console.log(1)</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" } } } }
}
}`
+19 -45
View File
@@ -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) {
+58
View File
@@ -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 ""
}
}
-80
View File
@@ -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()
-86
View File
@@ -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, "; ")
}
-64
View File
@@ -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 </body> 前注入脚本",
ConfigSchema: map[string]interface{}{
"script": "",
},
DefaultConfig: map[string]interface{}{
"script": "<script></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,
},
},
}
}
-118
View File
@@ -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
}
-64
View File
@@ -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, "</body>")
if idx >= 0 {
body = body[:idx] + p.script + body[idx:]
} else {
body += p.script
}
resp.Body = []byte(body)
return nil
}
-93
View File
@@ -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("<html>old page</html>"),
}
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) != "<html>new page</html>" {
t.Fatalf("unexpected body: %s", resp.Body)
}
}
func TestInjectJS(t *testing.T) {
p, err := newInjectJSPlugin(json.RawMessage(`{"script":"<script>x</script>"}`))
if err != nil {
t.Fatal(err)
}
plugin := p.(*injectJSPlugin)
resp := &BufferedResponse{
Header: make(map[string][]string),
Body: []byte("<html><body>hi</body></html>"),
}
resp.Header.Set("Content-Type", "text/html")
if err := plugin.HandleResponse(nil, resp); err != nil {
t.Fatal(err)
}
if want := "<html><body>hi<script>x</script></body></html>"; 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)
}
}
-228
View File
@@ -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)
}
-117
View File
@@ -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
}
-87
View File
@@ -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)
}
-217
View File
@@ -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
}
-115
View File
@@ -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
}
-8
View File
@@ -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 {
-176
View File
@@ -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
}
-75
View File
@@ -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
}
+14 -23
View File
@@ -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
-1
View File
@@ -68,7 +68,6 @@ func (s *Store) migrate() error {
&APIKey{},
&ResourceVisitor{},
&VisitorAccessLog{},
&DomainForward{},
)
}
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Wormhole</title>
</head>
<body>
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#0f7a62"/>
<g transform="translate(4 4)" stroke="#fafafa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<ellipse cx="7.5" cy="12" rx="3.25" ry="5"/>
<ellipse cx="16.5" cy="12" rx="3.25" ry="5"/>
<path d="M10.25 8.25C11.35 9.35 12.65 9.35 13.75 8.25"/>
<path d="M10.25 15.75C11.35 14.65 12.65 14.65 13.75 15.75"/>
<circle cx="12" cy="12" r="1.5" fill="#fafafa" stroke="none"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 548 B

+24 -4
View File
@@ -1,8 +1,28 @@
<template>
<svg :width="size" :height="size" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<!-- Wormhole: twin portals linked by a tunnel (outlined, currentColor) -->
<ellipse cx="7.5" cy="12" rx="3.25" ry="5" stroke="currentColor" stroke-width="2" />
<ellipse cx="16.5" cy="12" rx="3.25" ry="5" stroke="currentColor" stroke-width="2" />
<path
d="M10.25 8.25C11.35 9.35 12.65 9.35 13.75 8.25"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M10.25 15.75C11.35 14.65 12.65 14.65 13.75 15.75"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
</svg>
</template>
-166
View File
@@ -1,166 +0,0 @@
<template>
<div class="plugin-editor">
<div v-for="(plugin, index) in modelValue" :key="index" class="plugin-item">
<div class="plugin-head">
<el-tag size="small">{{ labelOf(plugin.type) }}</el-tag>
<span class="plugin-type">{{ plugin.type }}</span>
<div class="plugin-actions">
<el-switch v-model="plugin.enabled" size="small" />
<el-button size="small" link :disabled="index === 0" @click="move(index, -1)">{{ t('forwardPlugin.moveUp') }}</el-button>
<el-button size="small" link :disabled="index === modelValue.length - 1" @click="move(index, 1)">{{ t('forwardPlugin.moveDown') }}</el-button>
<el-button size="small" link type="danger" @click="remove(index)">{{ t('common.delete') }}</el-button>
</div>
</div>
<template v-if="plugin.type === 'sub_filter'">
<div v-for="(rule, ri) in plugin.config.filters" :key="ri" class="filter-row">
<el-input v-model="rule.from" :placeholder="t('forwardPlugin.find')" />
<el-input v-model="rule.to" :placeholder="t('forwardPlugin.replaceWith')" />
<el-button link type="danger" @click="removeFilter(plugin, ri)">{{ t('forwardPlugin.removeShort') }}</el-button>
</div>
<el-button size="small" @click="addFilter(plugin)">{{ t('forwardPlugin.addRule') }}</el-button>
<el-form-item :label="t('forwardPlugin.mimeTypes')" label-width="90px" class="plugin-mime">
<el-select v-model="plugin.config.types" multiple filterable allow-create default-first-option class="w-full">
<el-option label="text/html" value="text/html" />
<el-option label="text/css" value="text/css" />
<el-option label="application/javascript" value="application/javascript" />
</el-select>
</el-form-item>
</template>
<template v-else-if="plugin.type === 'inject_js'">
<el-input v-model="plugin.config.script" type="textarea" :rows="4" :placeholder="t('forwardPlugin.scriptPlaceholder')" />
</template>
<template v-else-if="plugin.type === 'cookie_domain'">
<div class="pair-row">
<el-input v-model="plugin.config.from" :placeholder="t('forwardPlugin.fromDomain')" />
<el-input v-model="plugin.config.to" :placeholder="t('forwardPlugin.toDomain')" />
</div>
</template>
<template v-else-if="plugin.type === 'response_cache'">
<div class="pair-row">
<el-input-number v-model="plugin.config.max_mb" :min="0" :max="1024" />
<span class="hint-inline">{{ t('forwardPlugin.mbHint') }}</span>
</div>
<div class="pair-row">
<el-input-number v-model="plugin.config.ttl_sec" :min="1" :max="86400" />
<span class="hint-inline">{{ t('forwardPlugin.ttlSec') }}</span>
</div>
</template>
</div>
<el-dropdown trigger="click" @command="addPlugin">
<el-button type="primary" plain size="small">{{ t('forwardPlugin.addPlugin') }}</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="t in types" :key="t.type" :command="t.type">{{ t.label }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
modelValue: { type: Array, default: () => [] },
types: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:modelValue'])
const typeMap = computed(() => {
const m = {}
for (const t of props.types) m[t.type] = t
return m
})
function labelOf(type) {
return typeMap.value[type]?.label || type
}
function defaultConfig(type) {
const meta = typeMap.value[type]
if (!meta?.default_config) return {}
return JSON.parse(JSON.stringify(meta.default_config))
}
function addPlugin(type) {
const next = [...props.modelValue, { type, enabled: true, config: defaultConfig(type) }]
emit('update:modelValue', next)
}
function remove(index) {
const next = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', next)
}
function move(index, delta) {
const next = [...props.modelValue]
const target = index + delta
if (target < 0 || target >= next.length) return
;[next[index], next[target]] = [next[target], next[index]]
emit('update:modelValue', next)
}
function addFilter(plugin) {
if (!plugin.config.filters) plugin.config.filters = []
plugin.config.filters.push({ from: '', to: '' })
}
function removeFilter(plugin, index) {
plugin.config.filters.splice(index, 1)
}
</script>
<style scoped>
.plugin-editor {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.plugin-item {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-3);
}
.plugin-head {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.plugin-type {
color: var(--muted-foreground);
font-size: 0.75rem;
}
.plugin-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-1);
}
.plugin-mime {
margin-top: var(--space-2);
}
.filter-row,
.pair-row {
display: flex;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
}
.pair-row + .pair-row {
margin-top: var(--space-2);
}
.hint-inline {
font-size: 0.75rem;
color: var(--muted-foreground);
white-space: nowrap;
}
</style>
+1 -2
View File
@@ -17,9 +17,8 @@
</template>
<template v-else>
<el-table-column prop="ip" :label="t('dashboard.visitorIp')" min-width="120" />
<el-table-column prop="direct_ip" :label="t('dashboard.directIp')" min-width="120" />
<el-table-column prop="resource_label" :label="t('common.resource')" min-width="160" show-overflow-tooltip />
<el-table-column prop="hit_count" :label="t('dashboard.hitCount')" width="80" />
<el-table-column prop="resource_type" :label="t('common.resource')" width="80" />
</template>
</el-table>
<div class="card-pager">
-7
View File
@@ -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,
+4 -57
View File
@@ -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: '<script>...</script>',
fromDomain: 'From domain, e.g. .old.com',
toDomain: 'To domain, e.g. .new.com',
},
clientSelect: {
placeholder: 'Select client',
label: '{name} (#{id}) · {status}',
+4 -57
View File
@@ -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: 'MB0 表示禁用缓存)',
ttlSec: '秒 TTL',
scriptPlaceholder: '<script>...</script>',
fromDomain: '原 Domain,如 .old.com',
toDomain: '新 Domain,如 .new.com',
},
clientSelect: {
placeholder: '选择客户端',
label: '{name} (#{id}) · {status}',
-2
View File
@@ -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 },
+1 -1
View File
@@ -74,7 +74,7 @@
</template>
<el-table :data="data.top_ips || []" size="small">
<el-table-column prop="ip" :label="t('dashboard.visitorIp')" />
<el-table-column prop="direct_ip" :label="t('dashboard.directIp')" width="110" />
<el-table-column prop="resource_label" :label="t('common.resource')" min-width="140" show-overflow-tooltip />
<el-table-column prop="hit_count" :label="t('dashboard.hitCount')" width="72" />
</el-table>
</AppCard>
-305
View File
@@ -1,305 +0,0 @@
<template>
<div class="page">
<PageHeader :title="t('domainForwards.title')" :description="t('domainForwards.description')">
<template #actions>
<el-button @click="load">{{ t('common.refresh') }}</el-button>
<el-button type="primary" @click="openDialog()">{{ t('domainForwards.add') }}</el-button>
</template>
</PageHeader>
<AppCard>
<div class="card-toolbar">
<el-input v-model="query" :placeholder="t('domainForwards.searchPlaceholder')" clearable class="toolbar-input--wide" @keyup.enter="search" @clear="search" />
<el-button @click="search">{{ t('common.search') }}</el-button>
</div>
<el-table :data="list" v-loading="loading" size="small">
<el-table-column prop="id" :label="t('common.id')" width="60" />
<el-table-column prop="source_host" :label="t('domainForwards.sourceHost')" min-width="140" />
<el-table-column prop="source_location" :label="t('common.path')" width="100" />
<el-table-column :label="t('common.mode')" width="88">
<template #default="{ row }">
<el-tag size="small" :type="row.mode === 'proxy' ? 'primary' : 'warning'">
{{ row.mode === 'proxy' ? t('domainForwards.modeProxy') : t('domainForwards.modeRedirect') }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_url" :label="t('common.target')" min-width="180" show-overflow-tooltip />
<el-table-column :label="t('domainForwards.proxyCol')" width="88">
<template #default="{ row }">
<span v-if="row.mode === 'proxy'">
{{ row.proxy_type === 'socks5' ? 'SOCKS5' : row.proxy_type === 'http_proxy' ? t('domainForwards.httpProxyShort') : t('domainForwards.direct') }}
</span>
<span v-else>{{ t('common.dash') }}</span>
</template>
</el-table-column>
<el-table-column :label="t('domainForwards.plugins')" min-width="120">
<template #default="{ row }">
<span v-if="row.mode === 'proxy'">{{ pluginSummary(row.plugins) }}</span>
<span v-else>{{ t('common.dash') }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag :type="row.status ? 'success' : 'info'" size="small">{{ row.status ? t('common.enabled') : t('common.paused') }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="220" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status" size="small" link @click="pause(row)">{{ t('common.pause') }}</el-button>
<el-button v-else size="small" link type="primary" @click="resume(row)">{{ t('common.resume') }}</el-button>
<el-button size="small" link type="primary" @click="openDialog(row)">{{ t('common.edit') }}</el-button>
<el-button size="small" link type="danger" @click="remove(row)">{{ t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="card-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@current-change="load"
@size-change="search"
/>
</div>
</AppCard>
<el-dialog v-model="dialogVisible" :title="form.id ? t('domainForwards.edit') : t('domainForwards.add')" width="640px" destroy-on-close>
<el-form :model="form" label-width="110px">
<el-form-item :label="t('domainForwards.sourceHost')" required>
<el-input v-model="form.source_host" placeholder="old.example.com 或 *.example.com" />
</el-form-item>
<el-form-item :label="t('domainForwards.sourceLocation')">
<el-input v-model="form.source_location" placeholder="/" />
</el-form-item>
<el-form-item :label="t('domainForwards.scheme')">
<el-select v-model="form.source_scheme">
<el-option :label="t('domainForwards.schemeAll')" value="all" />
<el-option label="HTTP" value="http" />
<el-option label="HTTPS" value="https" />
</el-select>
</el-form-item>
<el-form-item :label="t('domainForwards.forwardMode')" required>
<el-radio-group v-model="form.mode">
<el-radio value="redirect">{{ t('domainForwards.redirect') }}</el-radio>
<el-radio value="proxy">{{ t('domainForwards.proxy') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('domainForwards.targetUrl')" required>
<el-input v-model="form.target_url" placeholder="https://new.example.com" />
</el-form-item>
<template v-if="form.mode === 'redirect'">
<el-form-item :label="t('domainForwards.redirectMode')">
<el-radio-group v-model="form.redirect_mode">
<el-radio value="preserve">{{ t('domainForwards.preservePath') }}</el-radio>
<el-radio value="fixed">{{ t('domainForwards.fixedTarget') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('domainForwards.statusCode')">
<el-radio-group v-model="form.redirect_code">
<el-radio :value="302">302</el-radio>
<el-radio :value="301">301</el-radio>
</el-radio-group>
</el-form-item>
</template>
<template v-if="form.mode === 'proxy'">
<el-form-item :label="t('domainForwards.outboundProxy')">
<el-radio-group v-model="form.proxy_type">
<el-radio value="http">{{ t('domainForwards.httpDirect') }}</el-radio>
<el-radio value="http_proxy">{{ t('domainForwards.httpProxy') }}</el-radio>
<el-radio value="socks5">{{ t('domainForwards.socks5') }}</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="form.proxy_type === 'http_proxy'">
<el-form-item :label="t('domainForwards.httpProxyAddr')" required>
<el-input v-model="form.http_proxy_addr" placeholder="127.0.0.1:8080 或 http://127.0.0.1:8080" />
</el-form-item>
<el-form-item :label="t('domainForwards.username')">
<el-input v-model="form.http_proxy_user" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('domainForwards.password')">
<el-input v-model="form.http_proxy_pass" type="password" show-password :placeholder="form.id ? t('users.passwordPlaceholderEdit') : t('common.optional')" />
</el-form-item>
</template>
<template v-if="form.proxy_type === 'socks5'">
<el-form-item :label="t('domainForwards.socks5Addr')" required>
<el-input v-model="form.socks5_addr" placeholder="127.0.0.1:1080" />
</el-form-item>
<el-form-item :label="t('domainForwards.username')">
<el-input v-model="form.socks5_user" :placeholder="t('common.optional')" />
</el-form-item>
<el-form-item :label="t('domainForwards.password')">
<el-input v-model="form.socks5_pass" type="password" show-password :placeholder="form.id ? t('users.passwordPlaceholderEdit') : t('common.optional')" />
</el-form-item>
</template>
<el-form-item :label="t('domainForwards.preserveHost')">
<el-switch v-model="form.preserve_host" />
<div class="form-hint">{{ t('domainForwards.preserveHostHint') }}</div>
</el-form-item>
<el-form-item :label="t('domainForwards.pluginChain')">
<ForwardPluginEditor v-model="form.plugins" :types="pluginTypes" />
<div class="form-hint">{{ t('domainForwards.pluginChainHint') }}</div>
</el-form-item>
</template>
<el-form-item :label="t('common.enabled')">
<el-switch v-model="form.status" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" @click="save">{{ t('common.save') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
import AppCard from '@/components/AppCard.vue'
import ForwardPluginEditor from '@/components/ForwardPluginEditor.vue'
import api from '@/api'
const { t } = useI18n()
const pluginTypes = ref([])
const list = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const query = ref('')
const defaultForm = () => ({
id: null,
source_host: '',
source_location: '/',
source_scheme: 'all',
mode: 'redirect',
target_url: '',
redirect_mode: 'preserve',
redirect_code: 302,
proxy_type: 'http',
http_proxy_addr: '',
http_proxy_user: '',
http_proxy_pass: '',
socks5_addr: '',
socks5_user: '',
socks5_pass: '',
preserve_host: false,
plugins: [],
status: true,
})
const form = reactive(defaultForm())
function pluginSummary(plugins) {
if (!Array.isArray(plugins) || plugins.length === 0) return t('common.dash')
const enabled = plugins.filter((p) => p.enabled !== false)
if (enabled.length === 0) return t('domainForwards.pluginDisabled')
const labels = enabled.map((p) => p.type)
if (labels.length <= 2) return labels.join(', ')
return `${labels[0]} +${labels.length - 1}`
}
function normalizePlugins(plugins) {
if (!Array.isArray(plugins)) return []
return plugins.map((p) => ({
type: p.type,
enabled: p.enabled !== false,
config: p.config || {},
}))
}
async function loadPluginTypes() {
try {
const { data } = await api.get('/forward-plugin-types')
pluginTypes.value = data.items || []
} catch {
pluginTypes.value = []
}
}
async function load() {
loading.value = true
try {
const { data } = await api.get('/domain-forwards', {
params: { page: page.value, page_size: pageSize.value, q: query.value || undefined },
})
list.value = data.items || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
function search() {
page.value = 1
load()
}
function openDialog(row) {
if (row) {
Object.assign(form, {
...row,
socks5_pass: '',
http_proxy_pass: '',
redirect_code: row.redirect_code || 302,
redirect_mode: row.redirect_mode || 'preserve',
proxy_type: row.proxy_type || 'http',
plugins: normalizePlugins(row.plugins),
})
} else {
Object.assign(form, defaultForm())
}
dialogVisible.value = true
}
async function save() {
try {
const payload = { ...form, plugins: normalizePlugins(form.plugins) }
if (form.id && !payload.socks5_pass) {
delete payload.socks5_pass
}
if (form.id && !payload.http_proxy_pass) {
delete payload.http_proxy_pass
}
if (form.id) await api.put(`/domain-forwards/${form.id}`, payload)
else await api.post('/domain-forwards', payload)
ElMessage.success(t('message.saveSuccess'))
dialogVisible.value = false
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('message.saveFailed'))
}
}
async function pause(row) {
await api.post(`/domain-forwards/${row.id}/pause`)
load()
}
async function resume(row) {
await api.post(`/domain-forwards/${row.id}/resume`)
load()
}
async function remove(row) {
await ElMessageBox.confirm(t('domainForwards.confirmDelete'), t('common.hint'), { type: 'warning' })
await api.delete(`/domain-forwards/${row.id}`)
ElMessage.success(t('message.deleteSuccess'))
load()
}
onMounted(() => {
loadPluginTypes()
load()
})
</script>
+146 -20
View File
@@ -11,16 +11,17 @@
<el-table :data="list" v-loading="loading" size="small">
<el-table-column prop="id" :label="t('common.id')" width="60" />
<el-table-column prop="pattern" :label="t('ipRules.pattern')" min-width="160" />
<el-table-column prop="rule_type" :label="t('ipRules.ruleType')" width="100" />
<el-table-column prop="resource_type" :label="t('ipRules.resourceType')" width="100" />
<el-table-column prop="resource_id" :label="t('ipRules.resourceId')" width="80" />
<el-table-column :label="t('common.enabled')" width="80">
<el-table-column :label="t('ipRules.ruleType')" width="100">
<template #default="{ row }">
<el-tag :type="row.status ? 'success' : 'info'" size="small">
{{ row.status ? t('common.yes') : t('common.no') }}
</el-tag>
<el-tag :type="ruleTypeTag(row)" size="small">{{ ruleTypeLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('ipRules.resourceType')" width="100">
<template #default="{ row }">{{ resourceTypeLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('ipRules.resource')" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ resourceLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openDialog(row)">{{ t('common.edit') }}</el-button>
@@ -45,14 +46,29 @@
</el-select>
</el-form-item>
<el-form-item :label="t('ipRules.resourceType')">
<el-select v-model="form.resource_type">
<el-select v-model="form.resource_type" class="w-full">
<el-option :label="t('ipRules.global')" value="global" />
<el-option :label="t('ipRules.tunnel')" value="tunnel" />
<el-option :label="t('ipRules.host')" value="host" />
</el-select>
</el-form-item>
<el-form-item :label="t('ipRules.resourceId')"><el-input-number v-model="form.resource_id" :min="0" class="w-full" /></el-form-item>
<el-form-item :label="t('common.enabled')"><el-switch v-model="form.status" /></el-form-item>
<el-form-item v-if="form.resource_type !== 'global'" :label="t('ipRules.resource')" required>
<el-select
v-model="form.resource_id"
filterable
class="w-full"
:placeholder="t('ipRules.selectResource')"
:loading="resourcesLoading"
:no-data-text="t('ipRules.noResource')"
>
<el-option
v-for="item in resourceOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
@@ -63,7 +79,7 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import PageHeader from '@/components/PageHeader.vue'
@@ -72,45 +88,155 @@ import api from '@/api'
const { t } = useI18n()
const list = ref([])
const tunnels = ref([])
const hosts = ref([])
const loading = ref(false)
const resourcesLoading = ref(false)
const dialogVisible = ref(false)
const form = reactive({
id: null,
pattern: '',
rule_type: 'deny',
resource_type: 'global',
resource_id: 0,
status: true,
resource_id: null,
})
const resourceOptions = computed(() => {
if (form.resource_type === 'tunnel') {
return tunnels.value.map((item) => ({
id: item.id,
label: `${item.name} (#${item.id}) · :${item.listen_port}`,
}))
}
if (form.resource_type === 'host') {
return hosts.value.map((item) => ({
id: item.id,
label: `${item.host} (#${item.id})`,
}))
}
return []
})
watch(
() => form.resource_type,
(type) => {
if (type === 'global') {
form.resource_id = null
return
}
const options = resourceOptions.value
if (!options.some((item) => item.id === form.resource_id)) {
form.resource_id = options[0]?.id ?? null
}
},
)
function ruleScope(row) {
return row.scope || row.resource_type || 'global'
}
function ruleTypeLabel(row) {
const type = row.type || row.rule_type
if (type === 'allow') return t('ipRules.allow')
if (type === 'deny') return t('ipRules.deny')
return type || '—'
}
function ruleTypeTag(row) {
const type = row.type || row.rule_type
if (type === 'allow') return 'success'
if (type === 'deny') return 'danger'
return 'info'
}
function resourceTypeLabel(row) {
const scope = ruleScope(row)
if (scope === 'global') return t('ipRules.global')
if (scope === 'tunnel') return t('ipRules.tunnel')
if (scope === 'host') return t('ipRules.host')
return scope
}
function resourceLabel(row) {
const scope = ruleScope(row)
const id = row.resource_id
if (scope === 'global' || !id) return '—'
if (scope === 'tunnel') {
const item = tunnels.value.find((tunnel) => tunnel.id === id)
return item ? `${item.name} (#${id})` : `#${id}`
}
if (scope === 'host') {
const item = hosts.value.find((host) => host.id === id)
return item ? `${item.host} (#${id})` : `#${id}`
}
return `#${id}`
}
function rowToForm(row) {
return {
id: row.id,
pattern: row.pattern,
rule_type: row.type || row.rule_type || 'deny',
resource_type: ruleScope(row),
resource_id: row.resource_id || null,
}
}
function formToPayload() {
return {
pattern: form.pattern,
scope: form.resource_type,
type: form.rule_type,
resource_id: form.resource_type === 'global' ? 0 : form.resource_id,
}
}
async function loadResources() {
resourcesLoading.value = true
try {
const [tunnelsRes, hostsRes] = await Promise.all([
api.get('/tunnels', { params: { page: 1, page_size: 1000 } }),
api.get('/hosts', { params: { page: 1, page_size: 1000 } }),
])
tunnels.value = tunnelsRes.data.items || []
hosts.value = hostsRes.data.items || []
} finally {
resourcesLoading.value = false
}
}
async function load() {
loading.value = true
try {
const { data } = await api.get('/security/ip-rules')
list.value = data
const [rulesRes] = await Promise.all([api.get('/security/ip-rules'), loadResources()])
list.value = rulesRes.data
} finally {
loading.value = false
}
}
function openDialog(row) {
if (row) Object.assign(form, { ...row })
if (row) Object.assign(form, rowToForm(row))
else {
Object.assign(form, {
id: null,
pattern: '',
rule_type: 'deny',
resource_type: 'global',
resource_id: 0,
status: true,
resource_id: null,
})
}
dialogVisible.value = true
}
async function save() {
if (form.id) await api.put(`/security/ip-rules/${form.id}`, form)
else await api.post('/security/ip-rules', form)
if (form.resource_type !== 'global' && !form.resource_id) {
ElMessage.warning(t('ipRules.resourceRequired'))
return
}
const payload = formToPayload()
if (form.id) await api.put(`/security/ip-rules/${form.id}`, payload)
else await api.post('/security/ip-rules', payload)
ElMessage.success(t('message.saveSuccess'))
dialogVisible.value = false
load()
+6 -4
View File
@@ -13,9 +13,7 @@
</div>
<el-table :data="list" v-loading="loading" size="small">
<el-table-column prop="ip" :label="t('dashboard.visitorIp')" min-width="140" />
<el-table-column prop="direct_ip" :label="t('dashboard.directIp')" min-width="140" />
<el-table-column prop="resource_type" :label="t('requestIPs.resourceType')" width="90" />
<el-table-column prop="resource_id" :label="t('requestIPs.resourceId')" width="80" />
<el-table-column prop="resource_label" :label="t('common.resource')" min-width="160" show-overflow-tooltip />
<el-table-column prop="hit_count" :label="t('requestIPs.hitCount')" width="100" />
<el-table-column :label="t('requestIPs.lastSeen')" min-width="160">
<template #default="{ row }">{{ row.last_seen_at }}</template>
@@ -75,7 +73,11 @@ function search() {
async function block(row) {
await ElMessageBox.confirm(t('requestIPs.confirmBlock'), t('common.hint'), { type: 'warning' })
await api.post('/security/block-ip', { ip: row.ip })
await api.post('/security/block-ip', {
ip: row.ip,
scope: row.resource_type,
resource_id: row.resource_id,
})
ElMessage.success(t('message.blockSuccess'))
load()
}