删除域名转发模块与 forwardplugin;IP 规则资源改为下拉选择并修复拉黑字段映射;请求 IP 展示隧道/域名标签;新增 Wormhole 品牌 Logo 与 favicon。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": "缓存容量 MB,0 表示禁用", "example": 50 },
|
||||
"ttl_sec": { "type": "integer", "minimum": 1, "description": "TTL 秒,max_mb>0 时默认 300", "example": 300 }
|
||||
}
|
||||
},
|
||||
"ForwardPluginTypes": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/ForwardPluginTypeMeta" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"ForwardPluginTypeMeta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"label": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"config_schema": { "type": "object", "additionalProperties": true },
|
||||
"default_config": { "type": "object", "additionalProperties": true }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
@@ -180,7 +34,7 @@ const openAPISpec = `{
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
},
|
||||
"Forbidden": {
|
||||
"description": "权限不足(需管理员)",
|
||||
"description": "权限不足",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
},
|
||||
"NotFound": {
|
||||
@@ -238,214 +92,6 @@ const openAPISpec = `{
|
||||
"delete": { "summary": "删除域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } }
|
||||
},
|
||||
"/hosts/{id}/pause": { "post": { "summary": "暂停域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } },
|
||||
"/hosts/{id}/resume": { "post": { "summary": "恢复域名", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }], "responses": { "200": { "description": "OK" } } } },
|
||||
"/domain-forwards": {
|
||||
"get": {
|
||||
"summary": "分页查询域名转发",
|
||||
"description": "需管理员权限",
|
||||
"parameters": [
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "page_size", "in": "query", "schema": { "type": "integer", "default": 20 } },
|
||||
{ "name": "q", "in": "query", "schema": { "type": "string" }, "description": "搜索源域名、目标 URL" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/PagedDomainForwards" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": { "$ref": "#/components/responses/Forbidden" }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "创建域名转发",
|
||||
"description": "需管理员权限。proxy 模式下可配置 plugins 插件链。",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/DomainForwardInput" },
|
||||
"examples": {
|
||||
"redirect": {
|
||||
"summary": "302 跳转",
|
||||
"value": {
|
||||
"source_host": "old.example.com",
|
||||
"source_location": "/",
|
||||
"mode": "redirect",
|
||||
"target_url": "https://new.example.com",
|
||||
"redirect_mode": "preserve",
|
||||
"redirect_code": 302,
|
||||
"status": true
|
||||
}
|
||||
},
|
||||
"proxy_with_plugins": {
|
||||
"summary": "反代 + 插件链 + HTTP 代理出站",
|
||||
"value": {
|
||||
"source_host": "proxy.example.com",
|
||||
"source_location": "/",
|
||||
"source_scheme": "all",
|
||||
"mode": "proxy",
|
||||
"target_url": "https://backend.internal",
|
||||
"proxy_type": "http_proxy",
|
||||
"http_proxy_addr": "127.0.0.1:8080",
|
||||
"http_proxy_user": "user",
|
||||
"http_proxy_pass": "secret",
|
||||
"preserve_host": false,
|
||||
"plugins": [
|
||||
{
|
||||
"type": "response_cache",
|
||||
"enabled": true,
|
||||
"config": { "max_mb": 50, "ttl_sec": 300 }
|
||||
},
|
||||
{
|
||||
"type": "sub_filter",
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"filters": [{ "from": "old.internal", "to": "proxy.example.com" }],
|
||||
"types": ["text/html"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "inject_js",
|
||||
"enabled": true,
|
||||
"config": { "script": "<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
@@ -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) {
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-80
@@ -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()
|
||||
|
||||
@@ -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, "; ")
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -68,7 +68,6 @@ func (s *Store) migrate() error {
|
||||
&APIKey{},
|
||||
&ResourceVisitor{},
|
||||
&VisitorAccessLog{},
|
||||
&DomainForward{},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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}',
|
||||
|
||||
@@ -60,13 +60,11 @@ export default {
|
||||
nav: {
|
||||
groups: {
|
||||
overview: '概览',
|
||||
forward: '转发',
|
||||
tunnel: '穿透',
|
||||
security: '安全',
|
||||
system: '系统',
|
||||
},
|
||||
dashboard: '仪表盘',
|
||||
domainForwards: '域名转发',
|
||||
clients: '客户端',
|
||||
tunnels: '隧道',
|
||||
hosts: '域名',
|
||||
@@ -80,7 +78,6 @@ export default {
|
||||
clients: '客户端管理',
|
||||
tunnels: '隧道管理',
|
||||
hosts: '域名解析',
|
||||
domainForwards: '域名转发',
|
||||
ipRules: 'IP 规则',
|
||||
requestIPs: '请求 IP 监控',
|
||||
users: '访客用户',
|
||||
@@ -161,45 +158,6 @@ export default {
|
||||
visitorAuth: '访客登录',
|
||||
confirmDelete: '确认删除该域名?',
|
||||
},
|
||||
domainForwards: {
|
||||
title: '域名转发',
|
||||
description: '将源域名跳转或反代到目标 URL,无需 Agent(与内网穿透独立)',
|
||||
add: '新增转发',
|
||||
edit: '编辑转发',
|
||||
searchPlaceholder: '搜索源域名、目标',
|
||||
sourceHost: '源域名',
|
||||
sourceLocation: '路径前缀',
|
||||
scheme: '协议',
|
||||
schemeAll: '全部',
|
||||
forwardMode: '转发模式',
|
||||
redirect: '302/301 跳转',
|
||||
proxy: '反向代理',
|
||||
targetUrl: '目标 URL',
|
||||
redirectMode: '跳转策略',
|
||||
preservePath: '保留路径与参数',
|
||||
fixedTarget: '固定目标地址',
|
||||
statusCode: '状态码',
|
||||
outboundProxy: '出站代理',
|
||||
httpDirect: 'HTTP 直连',
|
||||
httpProxy: 'HTTP 代理',
|
||||
socks5: 'SOCKS5 代理',
|
||||
httpProxyAddr: 'HTTP 代理地址',
|
||||
socks5Addr: 'SOCKS5 地址',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
preserveHost: '保留 Host',
|
||||
preserveHostHint: '开启后将源域名作为 Host 头发给上游',
|
||||
pluginChain: '插件链',
|
||||
pluginChainHint: '按顺序处理响应;仅对普通 HTTP 生效,WebSocket 不走插件',
|
||||
plugins: '插件',
|
||||
proxyCol: '代理',
|
||||
direct: '直连',
|
||||
httpProxyShort: 'HTTP代理',
|
||||
modeProxy: '反代',
|
||||
modeRedirect: '跳转',
|
||||
pluginDisabled: '未启用',
|
||||
confirmDelete: '确认删除该转发规则?',
|
||||
},
|
||||
ipRules: {
|
||||
title: 'IP 规则',
|
||||
description: '白名单 / 黑名单 / CIDR 访问控制',
|
||||
@@ -214,7 +172,11 @@ export default {
|
||||
global: '全局',
|
||||
tunnel: '隧道',
|
||||
host: '域名',
|
||||
resource: '关联资源',
|
||||
resourceId: '资源ID',
|
||||
selectResource: '请选择资源',
|
||||
noResource: '暂无可用资源,请先创建',
|
||||
resourceRequired: '请选择关联资源',
|
||||
confirmDelete: '确认删除该规则?',
|
||||
},
|
||||
requestIPs: {
|
||||
@@ -316,21 +278,6 @@ export default {
|
||||
importDone: '导入完成:新增 {created},更新 {updated},跳过 {skipped},失败 {failed}',
|
||||
parseFailed: '解析文件失败',
|
||||
},
|
||||
forwardPlugin: {
|
||||
addPlugin: '添加插件',
|
||||
moveUp: '上移',
|
||||
moveDown: '下移',
|
||||
find: '查找',
|
||||
replaceWith: '替换为',
|
||||
addRule: '添加替换规则',
|
||||
mimeTypes: 'MIME 类型',
|
||||
removeShort: '删',
|
||||
mbHint: 'MB(0 表示禁用缓存)',
|
||||
ttlSec: '秒 TTL',
|
||||
scriptPlaceholder: '<script>...</script>',
|
||||
fromDomain: '原 Domain,如 .old.com',
|
||||
toDomain: '新 Domain,如 .new.com',
|
||||
},
|
||||
clientSelect: {
|
||||
placeholder: '选择客户端',
|
||||
label: '{name} (#{id}) · {status}',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user