diff --git a/clash/rules.json b/clash/rules.json
new file mode 100644
index 0000000..8677e8e
--- /dev/null
+++ b/clash/rules.json
@@ -0,0 +1,58 @@
+[
+ {
+ "enabled": false,
+ "type": "DEST-PORT",
+ "action": "DIRECT",
+ "match_value": "443",
+ "description": "",
+ "rewrite_value": ""
+ },
+ {
+ "enabled": true,
+ "type": "KEYWORD",
+ "action": "DIRECT",
+ "match_value": "MicroMessenger Client",
+ "description": "",
+ "rewrite_value": ""
+ },
+ {
+ "enabled": true,
+ "type": "KEYWORD",
+ "action": "DIRECT",
+ "match_value": "Bilibili Freedoooooom\/MarkII",
+ "description": "",
+ "rewrite_value": ""
+ },
+ {
+ "enabled": true,
+ "type": "KEYWORD",
+ "action": "DIRECT",
+ "match_value": "Valve\/Steam HTTP Client 1.0",
+ "description": "",
+ "rewrite_value": ""
+ },
+ {
+ "enabled": true,
+ "type": "KEYWORD",
+ "action": "REPLACE",
+ "match_value": "Mac",
+ "description": "",
+ "rewrite_value": "FFF"
+ },
+ {
+ "enabled": true,
+ "type": "REGEX",
+ "action": "REPLACE",
+ "match_value": "(Apple|iPhone|iPad|Macintosh|Mac OS X|Mac|Darwin|Microsoft|Windows|Linux|Android|OpenHarmony|HUAWEI|OPPO|Vivo|XiaoMi|Mobile|Dalvik)",
+ "rewrite_value": "FFF",
+ "description": ""
+ },
+ {
+ "enabled": true,
+ "type": "FINAL",
+ "action": "REPLACE",
+ "match_value": "",
+ "description": "Default Fallback Rule",
+ "rewrite_value": "FFF"
+ }
+]
\ No newline at end of file
diff --git a/ipkg/CONTROL/conffiles b/ipkg/CONTROL/conffiles
new file mode 100644
index 0000000..a07daf4
--- /dev/null
+++ b/ipkg/CONTROL/conffiles
@@ -0,0 +1 @@
+/etc/config/ua3f
diff --git a/ipkg/CONTROL/postrm b/ipkg/CONTROL/postrm
index e106963..b61450e 100755
--- a/ipkg/CONTROL/postrm
+++ b/ipkg/CONTROL/postrm
@@ -1,5 +1,5 @@
#!/bin/sh
-rm -rf /etc/config/ua3f >/dev/null 2>&1
+# rm -rf /etc/config/ua3f >/dev/null 2>&1
rm -rf /var/log/ua3f >/dev/null 2>&1
rm -rf /usr/lib/lua/luci/model/cbi/ua3f >/dev/null 2>&1
rm -rf /usr/lib/lua/luci/model/cbi/ua3f.lua >/dev/null 2>&1
diff --git a/ipkg/CONTROL/prerm b/ipkg/CONTROL/prerm
index 12d06ec..50220d1 100755
--- a/ipkg/CONTROL/prerm
+++ b/ipkg/CONTROL/prerm
@@ -1,4 +1,6 @@
#!/bin/sh
+uci set ua3f.enabled.enabled='0' > /dev/null 2>&1
+uci commit ua3f > /dev/null 2>&1
[ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh
default_prerm $0 $@
diff --git a/openwrt/files/luci/controller/ua3f.lua b/openwrt/files/luci/controller/ua3f.lua
index 7cd8602..b91a7ea 100644
--- a/openwrt/files/luci/controller/ua3f.lua
+++ b/openwrt/files/luci/controller/ua3f.lua
@@ -4,6 +4,8 @@ function index()
entry({ "admin", "services", "ua3f" }, cbi("ua3f"), _("UA3F"), 1)
entry({ "admin", "services", "ua3f", "download_log" }, call("action_download_log")).leaf = true
entry({ "admin", "services", "ua3f", "clear_log" }, call("clear_log")).leaf = true
+ entry({ "admin", "services", "ua3f", "get_rules" }, call("get_rules")).leaf = true
+ entry({ "admin", "services", "ua3f", "save_rules" }, call("save_rules")).leaf = true
end
local fs = require("nixio.fs")
@@ -71,3 +73,111 @@ function clear_log()
http.write("Log file not found")
end
end
+
+function get_rules()
+ local http = require("luci.http")
+ local uci = require("luci.model.uci").cursor()
+ local json = require("luci.jsonc")
+
+ http.prepare_content("application/json")
+
+ local rules = {}
+ local rules_data = uci:get("ua3f", "main", "rewrite_rules")
+
+ if rules_data then
+ -- Parse the rules from UCI config
+ -- Rules are stored as JSON string in UCI
+ local success, parsed_rules = pcall(json.parse, rules_data)
+ if success and parsed_rules then
+ rules = parsed_rules
+ end
+ end
+
+ http.write(json.stringify({
+ success = true,
+ rules = rules
+ }))
+end
+
+function save_rules()
+ local http = require("luci.http")
+ local uci = require("luci.model.uci").cursor()
+ local json = require("luci.jsonc")
+
+ http.prepare_content("application/json")
+
+ -- Read POST data
+ local content_length = tonumber(http.getenv("CONTENT_LENGTH"))
+ if not content_length or content_length == 0 then
+ http.write(json.stringify({
+ success = false,
+ error = "No data provided"
+ }))
+ return
+ end
+
+ local post_data = http.content()
+ if not post_data then
+ http.write(json.stringify({
+ success = false,
+ error = "Failed to read request data"
+ }))
+ return
+ end
+
+ -- Parse JSON data
+ local success, data = pcall(json.parse, post_data)
+ if not success or not data or not data.rules then
+ http.write(json.stringify({
+ success = false,
+ error = "Invalid JSON data"
+ }))
+ return
+ end
+
+ -- Ensure FINAL rule exists and is at the end
+ local has_final = false
+ local final_rule_index = nil
+ for i, rule in ipairs(data.rules) do
+ if rule.type == "FINAL" then
+ has_final = true
+ final_rule_index = i
+ break
+ end
+ end
+
+ -- If FINAL rule exists but not at the end, move it
+ if has_final and final_rule_index ~= #data.rules then
+ local final_rule = table.remove(data.rules, final_rule_index)
+ table.insert(data.rules, final_rule)
+ end
+
+ -- If no FINAL rule, add one
+ if not has_final then
+ table.insert(data.rules, {
+ type = "FINAL",
+ match_value = "",
+ action = "DIRECT",
+ rewrite_value = "",
+ description = "Default fallback rule",
+ enabled = true
+ })
+ else
+ -- Ensure FINAL rule is always enabled
+ for i, rule in ipairs(data.rules) do
+ if rule.type == "FINAL" then
+ rule.enabled = true
+ break
+ end
+ end
+ end
+
+ -- Save rules to UCI
+ local rules_json = json.stringify(data.rules)
+ uci:set("ua3f", "main", "rewrite_rules", rules_json)
+
+ http.write(json.stringify({
+ success = true,
+ message = "Rules saved successfully"
+ }))
+end
diff --git a/openwrt/files/luci/model/cbi/ua3f.lua b/openwrt/files/luci/model/cbi/ua3f.lua
index a53f9fe..d34204b 100644
--- a/openwrt/files/luci/model/cbi/ua3f.lua
+++ b/openwrt/files/luci/model/cbi/ua3f.lua
@@ -22,6 +22,7 @@ function create_sections(map)
-- General Section with tabs
sections.general = map:section(NamedSection, "main", "ua3f", translate("General"))
sections.general:tab("general", translate("Settings"))
+ sections.general:tab("rewrite", translate("Rewrite Rules"))
sections.general:tab("stats", translate("Statistics"))
sections.general:tab("log", translate("Log"))
sections.general:tab("others", translate("Others Settings"))
@@ -33,6 +34,7 @@ local sections = create_sections(ua3f)
fields.add_status_fields(sections.status)
fields.add_general_fields(sections.general)
+fields.add_rewrite_fields(sections.general)
fields.add_stats_fields(sections.general)
fields.add_log_fields(sections.general)
fields.add_others_fields(sections.general)
diff --git a/openwrt/files/luci/model/cbi/ua3f/fields.lua b/openwrt/files/luci/model/cbi/ua3f/fields.lua
index 9832094..65270f8 100644
--- a/openwrt/files/luci/model/cbi/ua3f/fields.lua
+++ b/openwrt/files/luci/model/cbi/ua3f/fields.lua
@@ -41,15 +41,22 @@ function M.add_general_fields(section)
server_mode:value("TPROXY", "TPROXY")
server_mode:value("REDIRECT", "REDIRECT")
server_mode:value("NFQUEUE", "NFQUEUE")
-
- -- Port
- local port = section:taboption("general", Value, "port", translate("Port"))
- port.placeholder = "1080"
+ server_mode.default = "SOCKS5"
-- Bind Address
local bind = section:taboption("general", Value, "bind", translate("Bind Address"))
bind:value("127.0.0.1")
bind:value("0.0.0.0")
+ bind:depends("server_mode", "HTTP")
+ bind:depends("server_mode", "SOCKS5")
+
+ -- Port
+ local port = section:taboption("general", Value, "port", translate("Port"))
+ port.placeholder = "1080"
+ port:depends("server_mode", "HTTP")
+ port:depends("server_mode", "SOCKS5")
+ port:depends("server_mode", "TPROXY")
+ port:depends("server_mode", "REDIRECT")
-- Log Level
local log_level = section:taboption("general", ListValue, "log_level", translate("Log Level"))
@@ -62,14 +69,27 @@ function M.add_general_fields(section)
log_level.description = translate(
"Sets the logging level. Do not keep the log level set to debug/info/warn for an extended period of time.")
- -- User-Agent
+ -- Rewrite Mode
+ local rewrite_mode = section:taboption("general", ListValue, "rewrite_mode", translate("Rewrite Mode"))
+ rewrite_mode:value("DIRECT", translate("Direct Forward"))
+ rewrite_mode:value("GLOBAL", translate("Global Rewrite"))
+ rewrite_mode:value("RULES", translate("Rule Based"))
+ rewrite_mode.default = "GLOBAL"
+ rewrite_mode.description = translate(
+ "Direct Forward: No rewriting. Global Rewrite: Rewrite all User-Agents to the specified value. Rule Based: Use rewrite rules to determine behavior.")
+
+ -- User-Agent (for Global Rewrite)
local ua = section:taboption("general", Value, "ua", translate("User-Agent"))
ua.placeholder = "FFF"
ua.description = translate("User-Agent after rewrite")
+ ua:depends("rewrite_mode", "GLOBAL")
+ ua:depends("server_mode", "NFQUEUE")
-- User-Agent Regex
- local uaRegexPattern = section:taboption("general", Value, "ua_regex", translate("User-Agent Regex"))
- uaRegexPattern.description = translate("Regular expression pattern for matching User-Agent")
+ local regex = section:taboption("general", Value, "ua_regex", translate("User-Agent Regex"))
+ regex.description = translate("Regular expression pattern for matching User-Agent")
+ regex:depends("rewrite_mode", "GLOBAL")
+ regex:depends("server_mode", "NFQUEUE")
-- Partial Replace
local partialReplace = section:taboption("general", Flag, "partial_replace", translate("Partial Replace"))
@@ -77,12 +97,14 @@ function M.add_general_fields(section)
translate(
"Replace only the matched part of the User-Agent, only works when User-Agent Regex is not empty")
partialReplace.default = "0"
+ partialReplace:depends("rewrite_mode", "GLOBAL")
+ partialReplace:depends("server_mode", "NFQUEUE")
+end
- -- Direct Forward
- local directForward = section:taboption("general", Flag, "direct_forward", translate("Direct Forward"))
- directForward.description =
- translate("Directly forward packets without rewriting")
- directForward.default = "0"
+-- Rewrite Rules Tab Fields
+function M.add_rewrite_fields(section)
+ local rules = section:taboption("rewrite", DummyValue, "")
+ rules.template = "ua3f/rules"
end
-- Statistics Tab Fields
diff --git a/openwrt/files/luci/view/ua3f/rules.htm b/openwrt/files/luci/view/ua3f/rules.htm
new file mode 100644
index 0000000..b690aa9
--- /dev/null
+++ b/openwrt/files/luci/view/ua3f/rules.htm
@@ -0,0 +1,653 @@
+<%
+local uci = require("luci.model.uci").cursor()
+local json = require("luci.jsonc")
+
+local rules_data = uci:get("ua3f", "main", "rewrite_rules") or "[]"
+%>
+
+
+
+
+
<%:Rewrite Rules%>
+
+ <%:Note: Rewrite rules only take effect in rule mode. NFQUEUE mode is temporarily unavailable.%>
+
+
+
+
+ | <%:Enabled%> |
+ <%:Index%> |
+ <%:Rule Type%> |
+ <%:Match Value%> |
+ <%:Rewrite Action%> |
+ <%:Rewrite Value%> |
+ <%:Actions%> |
+
+
+
+
+
+
+
+
+
diff --git a/openwrt/files/ua3f.init b/openwrt/files/ua3f.init
index c317ef8..74da9f1 100755
--- a/openwrt/files/ua3f.init
+++ b/openwrt/files/ua3f.init
@@ -505,6 +505,7 @@ start_service() {
LOG "Starting $NAME service..."
local port bind ua log_level ua_regex partial_replace set_ttl direct_forward
+ local rewrite_mode rewrite_rules
config_get server_mode "main" "server_mode" "SOCKS5"
config_get port "main" "port" "1080"
config_get bind "main" "bind" "127.0.0.1"
@@ -514,6 +515,8 @@ start_service() {
config_get log_level "main" "log_level" "info"
config_get_bool set_ttl "main" "set_ttl" 0
config_get_bool direct_forward "main" "direct_forward" 0
+ config_get rewrite_mode "main" "rewrite_mode" "GLOBAL"
+ config_get rewrite_rules "main" "rewrite_rules" ""
SERVER_MODE="$(echo "$server_mode" | tr '[:lower:]' '[:upper:]')"
SERVER_MODE="$server_mode"
@@ -645,6 +648,8 @@ start_service() {
procd_append_param command -f "$ua"
procd_append_param command -r "$ua_regex"
procd_append_param command -l "$log_level"
+ procd_append_param command -x "$rewrite_mode"
+ procd_append_param command -z "$rewrite_rules"
[ "$partial_replace" = "1" ] && procd_append_param command -s
[ "$direct_forward" = "1" ] && procd_append_param command -d
diff --git a/openwrt/files/ua3f.uci b/openwrt/files/ua3f.uci
index 1593060..996459b 100644
--- a/openwrt/files/ua3f.uci
+++ b/openwrt/files/ua3f.uci
@@ -5,10 +5,12 @@ config 'ua3f' 'main'
option server_mode 'SOCKS5'
option port '1080'
option bind '0.0.0.0'
+ option rewrite_mode 'GLOBAL'
option ua 'FFF'
option ua_regex '(Apple|iPhone|iPad|Macintosh|Mac OS X|Mac|Darwin|Microsoft|Windows|Linux|Android|OpenHarmony|HUAWEI|OPPO|Vivo|XiaoMi|Mobile|Dalvik)'
option partial_replace '0'
option direct_forward '0'
option log_level 'error'
option log_lines '1000'
- option set_ttl '0'
\ No newline at end of file
+ option set_ttl '0'
+ option rewrite_rules '[{"enabled":false,"type":"DEST-PORT","action":"DIRECT","match_value":"443","description":"","rewrite_value":""},{"enabled":true,"type":"KEYWORD","action":"DIRECT","match_value":"MicroMessenger Client","description":"","rewrite_value":""},{"enabled":true,"type":"KEYWORD","action":"DIRECT","match_value":"Bilibili Freedoooooom\/MarkII","description":"","rewrite_value":""},{"enabled":true,"type":"KEYWORD","action":"DIRECT","match_value":"Valve\/Steam HTTP Client 1.0","description":"","rewrite_value":""},{"enabled":true,"type":"KEYWORD","action":"REPLACE","match_value":"Mac","description":"","rewrite_value":"FFF"},{"enabled":true,"type":"REGEX","action":"REPLACE","match_value":"(Apple|iPhone|iPad|Macintosh|Mac OS X|Mac|Darwin|Microsoft|Windows|Linux|Android|OpenHarmony|HUAWEI|OPPO|Vivo|XiaoMi|Mobile|Dalvik)","rewrite_value":"FFF","description":""},{"enabled":true,"type":"FINAL","action":"REPLACE","match_value":"","description":"Default Fallback Rule","rewrite_value":"FFF"}]'
\ No newline at end of file
diff --git a/openwrt/po/zh_cn/ua3f.po b/openwrt/po/zh_cn/ua3f.po
index a4a740f..825c349 100644
--- a/openwrt/po/zh_cn/ua3f.po
+++ b/openwrt/po/zh_cn/ua3f.po
@@ -2,6 +2,9 @@
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
+msgid ""
+msgstr ""
+
msgid "Status"
msgstr "状态"
@@ -18,11 +21,14 @@ msgid "Stopped"
msgstr "未运行"
msgid "Settings"
-msgstr "设置"
+msgstr "通用设置"
msgid "Statistics"
msgstr "统计信息"
+msgid "Rewrite Rules"
+msgstr "重写规则"
+
msgid "Log"
msgstr "运行日志"
@@ -38,6 +44,21 @@ msgstr "绑定地址"
msgid "Log Level"
msgstr "日志级别"
+msgid "Rewrite Mode"
+msgstr "重写策略"
+
+msgid "Direct Forward"
+msgstr "直接转发"
+
+msgid "Global Rewrite"
+msgstr "全局重写"
+
+msgid "Rule Based"
+msgstr "规则判定"
+
+msgid "Direct Forward: No rewriting. Global Rewrite: Rewrite all User-Agents to the specified value. Rule Based: Use rewrite rules to determine behavior."
+msgstr "直接转发:不进行任何重写。全局重写:将所有 User-Agent 重写为指定值。规则判定:根据重写规则判定行为。"
+
msgid "User-Agent"
msgstr "User-Agent"
@@ -126,4 +147,106 @@ msgid "Duration"
msgstr "持续时间"
msgid "Total Connections"
-msgstr "连接总数"
\ No newline at end of file
+msgstr "连接总数"
+
+msgid "Add Rule"
+msgstr "添加规则"
+
+msgid "Index"
+msgstr "序号"
+
+msgid "Rule Type"
+msgstr "规则类型"
+
+msgid "Match Value"
+msgstr "匹配值"
+
+msgid "Rewrite Action"
+msgstr "重写策略"
+
+msgid "Rewrite Value"
+msgstr "重写值"
+
+msgid "Pattern"
+msgstr "匹配模式"
+
+msgid "Replacement"
+msgstr "替换内容"
+
+msgid "Actions"
+msgstr "操作"
+
+msgid "No rules configured. Click「Add Rule」to create one"
+msgstr "暂无配置规则,点击「添加规则」创建新规则"
+
+msgid "Add Rewrite Rule"
+msgstr "添加重写规则"
+
+msgid "Edit Rewrite Rule"
+msgstr "编辑重写规则"
+
+msgid "Enter match value"
+msgstr "输入匹配值"
+
+msgid "Enter rewrite value"
+msgstr "输入重写值"
+
+msgid "Description"
+msgstr "描述"
+
+msgid "Optional"
+msgstr "可选"
+
+msgid "Enter rule description"
+msgstr "输入规则描述"
+
+msgid "Cancel"
+msgstr "取消"
+
+msgid "Save"
+msgstr "保存"
+
+msgid "Edit"
+msgstr "编辑"
+
+msgid "Move Up"
+msgstr "上移"
+
+msgid "Move Down"
+msgstr "下移"
+
+msgid "Delete"
+msgstr "删除"
+
+msgid "Are you sure you want to delete this rule?"
+msgstr "确定要删除此规则吗?"
+
+msgid "Failed to delete rule"
+msgstr "删除规则失败"
+
+msgid "Failed to move rule"
+msgstr "移动规则失败"
+
+msgid "Failed to save rule"
+msgstr "保存规则失败"
+
+msgid "Match value is required"
+msgstr "匹配值为必填项"
+
+msgid "Rewrite value is required for REPLACE and REPLACE-PART strategies"
+msgstr "REPLACE 和 REPLACE-PART 策略需要填写重写值"
+
+msgid "Pattern and Replacement are required"
+msgstr "匹配模式和替换内容为必填项"
+
+msgid "Rule saved successfully"
+msgstr "规则保存成功"
+
+msgid "Rule deleted successfully"
+msgstr "规则删除成功"
+
+msgid "Confirm"
+msgstr "确认"
+
+msgid "Note: Rewrite rules only take effect in rule mode. NFQUEUE mode is temporarily unavailable."
+msgstr "注意:重写规则仅在规则判定模式下生效。NFQUEUE 模式暂不可用。"
\ No newline at end of file
diff --git a/src/internal/config/config.go b/src/internal/config/config.go
index cdef073..786bcd7 100644
--- a/src/internal/config/config.go
+++ b/src/internal/config/config.go
@@ -3,65 +3,78 @@ package config
import (
"flag"
"fmt"
- "strings"
)
+type ServerMode string
+
const (
- ServerModeHTTP = "HTTP"
- ServerModeSocks5 = "SOCKS5"
- ServerModeTProxy = "TPROXY"
- ServerModeRedirect = "REDIRECT"
- ServerModeNFQueue = "NFQUEUE"
+ ServerModeHTTP ServerMode = "HTTP"
+ ServerModeSocks5 ServerMode = "SOCKS5"
+ ServerModeTProxy ServerMode = "TPROXY"
+ ServerModeRedirect ServerMode = "REDIRECT"
+ ServerModeNFQueue ServerMode = "NFQUEUE"
+)
+
+type RewriteMode string
+
+const (
+ RewriteModeGlobal RewriteMode = "GLOBAL"
+ RewriteModeDirect RewriteMode = "DIRECT"
+ RewriteModeRules RewriteMode = "RULES"
)
type Config struct {
- ServerMode string
+ ServerMode ServerMode
BindAddr string
Port int
ListenAddr string
LogLevel string
+ RewriteMode RewriteMode
+ Rules string
PayloadUA string
UARegex string
PartialReplace bool
- DirectForward bool
}
func Parse() (*Config, bool) {
var (
- serverMode string
- bindAddr string
- port int
- loglevel string
- payloadUA string
- uaRegx string
- partial bool
- directForward bool
- showVer bool
+ serverMode string
+ bindAddr string
+ port int
+ loglevel string
+ payloadUA string
+ uaRegx string
+ partial bool
+ rewriteMode string
+ rules string
+ showVer bool
)
- flag.StringVar(&serverMode, "m", ServerModeSocks5, "Server mode: HTTP, SOCKS5, TPROXY, REDIRECT, NFQUEUE")
+ flag.StringVar(&serverMode, "m", string(ServerModeSocks5), "Server mode: HTTP, SOCKS5, TPROXY, REDIRECT, NFQUEUE")
flag.StringVar(&bindAddr, "b", "127.0.0.1", "Bind address")
flag.IntVar(&port, "p", 1080, "Port")
flag.StringVar(&loglevel, "l", "info", "Log level")
flag.StringVar(&payloadUA, "f", "FFF", "User-Agent")
flag.StringVar(&uaRegx, "r", "", "User-Agent regex")
flag.BoolVar(&partial, "s", false, "Enable regex partial replace")
- flag.BoolVar(&directForward, "d", false, "Pure Forwarding (no User-Agent rewriting)")
+ flag.StringVar(&rewriteMode, "x", string(RewriteModeGlobal), "Rewrite mode: GLOBAL, DIRECT, RULES")
+ flag.StringVar(&rules, "z", "", "Rules JSON string")
flag.BoolVar(&showVer, "v", false, "Show version")
flag.Parse()
cfg := &Config{
- ServerMode: strings.ToUpper(serverMode),
+ ServerMode: ServerMode(serverMode),
BindAddr: bindAddr,
Port: port,
ListenAddr: fmt.Sprintf("%s:%d", bindAddr, port),
LogLevel: loglevel,
PayloadUA: payloadUA,
UARegex: uaRegx,
- DirectForward: directForward,
PartialReplace: partial,
+ RewriteMode: RewriteMode(rewriteMode),
+ Rules: rules,
}
- if serverMode == ServerModeRedirect {
+ if cfg.ServerMode == ServerModeRedirect || cfg.ServerMode == ServerModeTProxy {
cfg.BindAddr = "0.0.0.0"
cfg.ListenAddr = fmt.Sprintf("0.0.0.0:%d", port)
}
diff --git a/src/internal/log/log.go b/src/internal/log/log.go
index a7e8114..6f6500c 100644
--- a/src/internal/log/log.go
+++ b/src/internal/log/log.go
@@ -72,10 +72,11 @@ func LogHeader(version string, cfg *config.Config) {
logrus.Info("UA3F v" + version)
logrus.Info("Server Mode: " + cfg.ServerMode)
logrus.Infof("Listen on %s", cfg.ListenAddr)
+ logrus.Infof("Rewrite Mode: %s", cfg.RewriteMode)
+ logrus.Infof("Rewrite Rules: %s", cfg.Rules)
logrus.Infof("User-Agent: %s", cfg.PayloadUA)
logrus.Infof("User-Agent Regex: '%s'", cfg.UARegex)
logrus.Infof("Partial Replace: %v", cfg.PartialReplace)
- logrus.Infof("Direct Forward: %v", cfg.DirectForward)
logrus.Infof("Log level: %s", cfg.LogLevel)
}
diff --git a/src/internal/rewrite/rewriter.go b/src/internal/rewrite/rewriter.go
index e59ffe8..2a46088 100644
--- a/src/internal/rewrite/rewriter.go
+++ b/src/internal/rewrite/rewriter.go
@@ -15,6 +15,7 @@ import (
"github.com/sunbk201/ua3f/internal/config"
"github.com/sunbk201/ua3f/internal/log"
+ "github.com/sunbk201/ua3f/internal/rule"
"github.com/sunbk201/ua3f/internal/sniff"
"github.com/sunbk201/ua3f/internal/statistics"
)
@@ -26,10 +27,25 @@ type Rewriter struct {
payloadUA string
pattern string
partialReplace bool
+ rewriteMode config.RewriteMode
- uaRegex *regexp2.Regexp
- whitelist []string
- Cache *expirable.LRU[string, struct{}]
+ uaRegex *regexp2.Regexp
+ ruleEngine *rule.Engine
+ whitelist []string
+ Cache *expirable.LRU[string, struct{}]
+}
+
+// RewriteDecision 重写决策结果
+type RewriteDecision struct {
+ Action rule.Action
+ MatchedRule *rule.Rule
+}
+
+// ShouldRewrite 判断是否需要重写
+func (d *RewriteDecision) ShouldRewrite() bool {
+ return d.Action == rule.ActionReplace ||
+ d.Action == rule.ActionReplacePart ||
+ d.Action == rule.ActionDelete
}
// New constructs a Rewriter from config. Compiles regex and allocates cache.
@@ -41,11 +57,23 @@ func New(cfg *config.Config) (*Rewriter, error) {
return nil, err
}
+ // 创建规则引擎
+ var ruleEngine *rule.Engine
+ if cfg.RewriteMode == config.RewriteModeRules {
+ ruleEngine, err = rule.NewEngine(cfg.Rules)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create rule engine: %w", err)
+ }
+ logrus.Info("Rule engine initialized")
+ }
+
return &Rewriter{
payloadUA: cfg.PayloadUA,
pattern: cfg.UARegex,
partialReplace: cfg.PartialReplace,
+ rewriteMode: cfg.RewriteMode,
uaRegex: uaRegex,
+ ruleEngine: ruleEngine,
Cache: expirable.NewLRU[string, struct{}](1024, nil, 30*time.Minute),
whitelist: []string{
"MicroMessenger Client",
@@ -66,6 +94,11 @@ func (r *Rewriter) inWhitelist(ua string) bool {
return false
}
+// GetRuleEngine 获取规则引擎
+func (r *Rewriter) GetRuleEngine() *rule.Engine {
+ return r.ruleEngine
+}
+
// buildUserAgent returns either a partial replacement (regex) or full overwrite.
func (r *Rewriter) buildUserAgent(originUA string) string {
if r.partialReplace && r.uaRegex != nil && r.pattern != "" {
@@ -79,13 +112,74 @@ func (r *Rewriter) buildUserAgent(originUA string) string {
return r.payloadUA
}
-func (r *Rewriter) ShouldRewrite(req *http.Request, srcAddr, destAddr string) bool {
+func (r *Rewriter) EvaluateRewriteDecision(req *http.Request, srcAddr, destAddr string) *RewriteDecision {
originalUA := req.Header.Get("User-Agent")
log.LogInfoWithAddr(srcAddr, destAddr, fmt.Sprintf("original User-Agent: (%s)", originalUA))
if originalUA == "" {
req.Header.Set("User-Agent", "")
}
+ // 「直接转发」模式:不进行任何重写
+ if r.rewriteMode == config.RewriteModeDirect {
+ log.LogDebugWithAddr(srcAddr, destAddr, "Direct forward mode, skip rewriting")
+ statistics.AddPassThroughRecord(&statistics.PassThroughRecord{
+ SrcAddr: srcAddr,
+ DestAddr: destAddr,
+ UA: originalUA,
+ })
+ return &RewriteDecision{
+ Action: rule.ActionDirect,
+ }
+ }
+
+ // 「规则判定」模式:使用规则引擎(只匹配一次)
+ if r.rewriteMode == config.RewriteModeRules && r.ruleEngine != nil {
+ matchedRule := r.ruleEngine.MatchWithRule(req, srcAddr, destAddr)
+
+ // 没有匹配到任何规则,默认直接转发
+ if matchedRule == nil {
+ log.LogDebugWithAddr(srcAddr, destAddr, "No rule matched, direct forward")
+ statistics.AddPassThroughRecord(&statistics.PassThroughRecord{
+ SrcAddr: srcAddr,
+ DestAddr: destAddr,
+ UA: originalUA,
+ })
+ return &RewriteDecision{
+ Action: rule.ActionDirect,
+ }
+ }
+
+ // DROP 动作:丢弃请求
+ if matchedRule.Action == rule.ActionDrop {
+ log.LogInfoWithAddr(srcAddr, destAddr, "Rule matched: DROP action, request will be dropped")
+ return &RewriteDecision{
+ Action: matchedRule.Action,
+ MatchedRule: matchedRule,
+ }
+ }
+
+ // DIRECT 动作:直接转发
+ if matchedRule.Action == rule.ActionDirect {
+ log.LogDebugWithAddr(srcAddr, destAddr, "Rule matched: DIRECT action, skip rewriting")
+ statistics.AddPassThroughRecord(&statistics.PassThroughRecord{
+ SrcAddr: srcAddr,
+ DestAddr: destAddr,
+ UA: originalUA,
+ })
+ return &RewriteDecision{
+ Action: matchedRule.Action,
+ MatchedRule: matchedRule,
+ }
+ }
+
+ // REPLACE、REPLACE-PART、DELETE 动作:需要重写
+ return &RewriteDecision{
+ Action: matchedRule.Action,
+ MatchedRule: matchedRule,
+ }
+ }
+
+ // 「全局重写」模式:使用原有逻辑
var err error
matches := false
isWhitelist := r.inWhitelist(originalUA)
@@ -117,13 +211,29 @@ func (r *Rewriter) ShouldRewrite(req *http.Request, srcAddr, destAddr string) bo
DestAddr: destAddr,
UA: originalUA,
})
+ return &RewriteDecision{
+ Action: rule.ActionDirect,
+ }
+ }
+ return &RewriteDecision{
+ Action: rule.ActionReplace,
}
- return hit
}
-func (r *Rewriter) Rewrite(req *http.Request, srcAddr string, destAddr string) *http.Request {
+func (r *Rewriter) Rewrite(req *http.Request, srcAddr string, destAddr string, decision *RewriteDecision) *http.Request {
originalUA := req.Header.Get("User-Agent")
- rewritedUA := r.buildUserAgent(originalUA)
+ rewriteValue := decision.MatchedRule.RewriteValue
+ action := decision.Action
+ var rewritedUA string
+
+ // 「规则判定」模式:根据规则动作决定如何重写
+ if r.rewriteMode == config.RewriteModeRules && r.ruleEngine != nil {
+ rewritedUA = r.ruleEngine.ApplyAction(action, rewriteValue, originalUA, decision.MatchedRule)
+ } else {
+ // 「全局重写」模式:使用原有逻辑
+ rewritedUA = r.buildUserAgent(originalUA)
+ }
+
req.Header.Set("User-Agent", rewritedUA)
log.LogInfoWithAddr(srcAddr, destAddr, fmt.Sprintf("Rewrite User-Agent from (%s) to (%s)", originalUA, rewritedUA))
@@ -217,9 +327,19 @@ func (r *Rewriter) Process(dst net.Conn, src net.Conn, destAddr string, srcAddr
err = fmt.Errorf("http.ReadRequest: %w", err)
return
}
- if r.ShouldRewrite(req, srcAddr, destAddr) {
- req = r.Rewrite(req, srcAddr, destAddr)
+
+ // 获取重写决策(只匹配一次规则)
+ decision := r.EvaluateRewriteDecision(req, srcAddr, destAddr)
+ // 处理 DROP 动作
+ if decision.Action == rule.ActionDrop {
+ log.LogInfoWithAddr(srcAddr, destAddr, "Request dropped by rule")
+ continue
}
+ // 如果需要重写,执行重写操作
+ if decision.ShouldRewrite() {
+ req = r.Rewrite(req, srcAddr, destAddr, decision)
+ }
+
if err = r.Forward(dst, req); err != nil {
err = fmt.Errorf("r.Forward: %w", err)
return
diff --git a/src/internal/rule/rule.go b/src/internal/rule/rule.go
new file mode 100644
index 0000000..252aeaa
--- /dev/null
+++ b/src/internal/rule/rule.go
@@ -0,0 +1,246 @@
+package rule
+
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/dlclark/regexp2"
+ "github.com/sirupsen/logrus"
+)
+
+// RuleType 规则类型
+type RuleType string
+
+const (
+ RuleTypeKeyword RuleType = "KEYWORD" // 关键字匹配
+ RuleTypeRegex RuleType = "REGEX" // 正则表达式匹配
+ RuleTypeIPCIDR RuleType = "IP-CIDR" // IP地址段匹配
+ RuleTypeSrcIP RuleType = "SRC-IP" // 源IP地址匹配
+ RuleTypeDestPort RuleType = "DEST-PORT" // 目标端口匹配
+ RuleTypeFinal RuleType = "FINAL" // 兜底规则
+)
+
+// Action 重写策略
+type Action string
+
+const (
+ ActionReplace Action = "REPLACE" // 替换整个 User-Agent
+ ActionReplacePart Action = "REPLACE-PART" // 部分替换
+ ActionDelete Action = "DELETE" // 删除 User-Agent
+ ActionDirect Action = "DIRECT" // 直接转发
+ ActionDrop Action = "DROP" // 丢弃请求
+)
+
+// Rule 重写规则
+type Rule struct {
+ Enabled bool `json:"enabled"` // 是否启用
+ Type RuleType `json:"type"` // 规则类型
+ Action Action `json:"action"` // 重写策略
+ MatchValue string `json:"match_value"` // 匹配值
+ RewriteValue string `json:"rewrite_value"` // 重写值
+ Description string `json:"description"` // 描述
+
+ // 编译后的正则表达式(仅用于 REGEX 类型)
+ regex *regexp2.Regexp
+ // 解析后的 IP 网络(仅用于 IP-CIDR 和 SRC-IP 类型)
+ ipNet *net.IPNet
+}
+
+// Engine 规则引擎
+type Engine struct {
+ rules []*Rule
+}
+
+// NewEngine 创建规则引擎
+func NewEngine(rulesJSON string) (*Engine, error) {
+ if rulesJSON == "" {
+ return &Engine{rules: []*Rule{}}, nil
+ }
+
+ var rules []*Rule
+ if err := json.Unmarshal([]byte(rulesJSON), &rules); err != nil {
+ return nil, fmt.Errorf("failed to parse rules JSON: %w", err)
+ }
+
+ // 编译正则表达式和解析 IP 网络
+ for _, rule := range rules {
+ if !rule.Enabled {
+ continue
+ }
+
+ switch rule.Type {
+ case RuleTypeRegex:
+ if rule.MatchValue != "" {
+ pattern := "(?i)" + rule.MatchValue
+ regex, err := regexp2.Compile(pattern, regexp2.None)
+ if err != nil {
+ logrus.Warnf("Failed to compile regex for rule: %s, error: %v", rule.Description, err)
+ rule.Enabled = false
+ continue
+ }
+ rule.regex = regex
+ }
+ case RuleTypeIPCIDR, RuleTypeSrcIP:
+ if rule.MatchValue != "" {
+ if !strings.Contains(rule.MatchValue, "/") {
+ rule.MatchValue += "/32"
+ }
+ _, ipNet, err := net.ParseCIDR(rule.MatchValue)
+ if err != nil {
+ logrus.Warnf("Failed to parse CIDR for rule: %s, error: %v", rule.Description, err)
+ rule.Enabled = false
+ continue
+ }
+ rule.ipNet = ipNet
+ }
+ }
+ }
+
+ return &Engine{rules: rules}, nil
+}
+
+// MatchWithRule 匹配规则并返回匹配的规则
+func (e *Engine) MatchWithRule(req *http.Request, srcAddr, destAddr string) *Rule {
+ for _, rule := range e.rules {
+ if !rule.Enabled {
+ continue
+ }
+
+ matched := false
+ var err error
+
+ switch rule.Type {
+ case RuleTypeKeyword:
+ matched = e.matchKeyword(req, rule)
+ case RuleTypeRegex:
+ matched, err = e.matchRegex(req, rule)
+ if err != nil {
+ logrus.Warnf("Regex match error: %v", err)
+ }
+ case RuleTypeIPCIDR:
+ matched = e.matchIPCIDR(destAddr, rule)
+ case RuleTypeSrcIP:
+ matched = e.matchSrcIP(srcAddr, rule)
+ case RuleTypeDestPort:
+ matched = e.matchDestPort(destAddr, rule)
+ case RuleTypeFinal:
+ matched = true
+ }
+
+ if matched {
+ logrus.Debugf("Rule matched: %s (type: %s, action: %s)", rule.Description, rule.Type, rule.Action)
+ return rule
+ }
+ }
+ return nil
+}
+
+// matchKeyword 关键字匹配
+func (e *Engine) matchKeyword(req *http.Request, rule *Rule) bool {
+ ua := req.Header.Get("User-Agent")
+ return strings.Contains(strings.ToLower(ua), strings.ToLower(rule.MatchValue))
+}
+
+// matchRegex 正则表达式匹配
+func (e *Engine) matchRegex(req *http.Request, rule *Rule) (bool, error) {
+ if rule.regex == nil {
+ return false, nil
+ }
+ ua := req.Header.Get("User-Agent")
+ return rule.regex.MatchString(ua)
+}
+
+// matchIPCIDR 目标IP地址段匹配
+func (e *Engine) matchIPCIDR(destAddr string, rule *Rule) bool {
+ if rule.ipNet == nil {
+ return false
+ }
+ host, _, err := net.SplitHostPort(destAddr)
+ if err != nil {
+ host = destAddr
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return false
+ }
+ return rule.ipNet.Contains(ip)
+}
+
+// matchSrcIP 源IP地址匹配
+func (e *Engine) matchSrcIP(srcAddr string, rule *Rule) bool {
+ if rule.ipNet == nil {
+ return false
+ }
+ host, _, err := net.SplitHostPort(srcAddr)
+ if err != nil {
+ host = srcAddr
+ }
+ ip := net.ParseIP(host)
+ if ip == nil {
+ return false
+ }
+ return rule.ipNet.Contains(ip)
+}
+
+// matchDestPort 目标端口匹配
+func (e *Engine) matchDestPort(destAddr string, rule *Rule) bool {
+ _, portStr, err := net.SplitHostPort(destAddr)
+ if err != nil {
+ return false
+ }
+ port, err := strconv.Atoi(portStr)
+ if err != nil {
+ return false
+ }
+ matchPort, err := strconv.Atoi(rule.MatchValue)
+ if err != nil {
+ return false
+ }
+ return port == matchPort
+}
+
+// ApplyAction 应用规则动作
+func (e *Engine) ApplyAction(action Action, rewriteValue string, originalUA string, rule *Rule) string {
+ switch action {
+ case ActionReplace:
+ return rewriteValue
+ case ActionReplacePart:
+ if rule != nil && rule.Type == RuleTypeRegex && rule.regex != nil {
+ newUA, err := rule.regex.Replace(originalUA, rewriteValue, -1, -1)
+ if err != nil {
+ logrus.Errorf("Failed to apply REPLACE-PART: %v, using full replacement", err)
+ return rewriteValue
+ }
+ return newUA
+ }
+ // 如果不是正则规则,使用简单字符串替换
+ return strings.ReplaceAll(originalUA, rule.MatchValue, rewriteValue)
+ case ActionDelete:
+ return ""
+ case ActionDirect:
+ return originalUA
+ case ActionDrop:
+ return originalUA // DROP 由上层处理
+ default:
+ return originalUA
+ }
+}
+
+// HasRules 是否有启用的规则
+func (e *Engine) HasRules() bool {
+ for _, rule := range e.rules {
+ if rule.Enabled {
+ return true
+ }
+ }
+ return false
+}
+
+// GetRules 获取所有规则
+func (e *Engine) GetRules() []*Rule {
+ return e.rules
+}
diff --git a/src/internal/server/http/http.go b/src/internal/server/http/http.go
index da1b43c..0b462aa 100644
--- a/src/internal/server/http/http.go
+++ b/src/internal/server/http/http.go
@@ -9,7 +9,9 @@ import (
"github.com/sirupsen/logrus"
"github.com/sunbk201/ua3f/internal/config"
+ "github.com/sunbk201/ua3f/internal/log"
"github.com/sunbk201/ua3f/internal/rewrite"
+ "github.com/sunbk201/ua3f/internal/rule"
"github.com/sunbk201/ua3f/internal/server/utils"
)
@@ -58,18 +60,10 @@ func (s *Server) handleHTTP(w http.ResponseWriter, req *http.Request) {
}
defer target.Close()
- if s.cfg.DirectForward {
- err = req.Write(target)
- if err != nil {
- http.Error(w, err.Error(), http.StatusServiceUnavailable)
- return
- }
- } else {
- err = s.rewriteAndForward(target, req, req.Host, req.RemoteAddr)
- if err != nil {
- http.Error(w, err.Error(), http.StatusServiceUnavailable)
- return
- }
+ err = s.rewriteAndForward(target, req, req.Host, req.RemoteAddr)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusServiceUnavailable)
+ return
}
resp, err := http.ReadResponse(bufio.NewReader(target), req)
if err != nil {
@@ -111,9 +105,21 @@ func (s *Server) handleTunneling(w http.ResponseWriter, req *http.Request) {
func (s *Server) rewriteAndForward(target net.Conn, req *http.Request, dstAddr, srcAddr string) (err error) {
rw := s.rw
- if rw.ShouldRewrite(req, srcAddr, dstAddr) {
- req = rw.Rewrite(req, srcAddr, dstAddr)
+
+ // 获取重写决策(只匹配一次规则)
+ decision := rw.EvaluateRewriteDecision(req, srcAddr, dstAddr)
+
+ // Handle DROP action
+ if decision.Action == rule.ActionDrop {
+ log.LogInfoWithAddr(srcAddr, dstAddr, "Request dropped by rule")
+ return fmt.Errorf("request dropped by rule")
}
+
+ // 如果需要重写,执行重写操作
+ if decision.ShouldRewrite() {
+ req = rw.Rewrite(req, srcAddr, dstAddr, decision)
+ }
+
if err = rw.Forward(target, req); err != nil {
err = fmt.Errorf("r.forward: %w", err)
return
@@ -131,7 +137,7 @@ func (s *Server) ForwardTCP(client, target net.Conn, destAddr string) {
// Server -> Client (raw)
go utils.CopyHalf(client, target)
- if s.cfg.DirectForward {
+ if s.cfg.RewriteMode == "direct" {
// Client -> Server (raw)
go utils.CopyHalf(target, client)
return
diff --git a/src/internal/server/nfqueue/nfqueue.go b/src/internal/server/nfqueue/nfqueue.go
index 98ea677..33ec498 100644
--- a/src/internal/server/nfqueue/nfqueue.go
+++ b/src/internal/server/nfqueue/nfqueue.go
@@ -67,7 +67,7 @@ func (s *Server) worker(ctx context.Context, workerID int, aChan <-chan *nfq.Att
logrus.Debugf("Worker %d channel closed", workerID)
return
}
- if s.cfg.DirectForward {
+ if s.cfg.RewriteMode == config.RewriteModeDirect {
_ = s.nf.SetVerdict(*a.PacketID, nfq.NfAccept)
continue
} else {
diff --git a/src/internal/server/redirect/redirect_linux.go b/src/internal/server/redirect/redirect_linux.go
index 2695591..b57e86b 100644
--- a/src/internal/server/redirect/redirect_linux.go
+++ b/src/internal/server/redirect/redirect_linux.go
@@ -77,7 +77,7 @@ func (s *Server) ForwardTCP(client, target net.Conn, destAddr string) {
// Server -> Client (raw)
go utils.CopyHalf(client, target)
- if s.cfg.DirectForward {
+ if s.cfg.RewriteMode == config.RewriteModeDirect {
// Client -> Server (raw)
go utils.CopyHalf(target, client)
return
diff --git a/src/internal/server/server.go b/src/internal/server/server.go
index 9c55708..de3c427 100644
--- a/src/internal/server/server.go
+++ b/src/internal/server/server.go
@@ -12,6 +12,16 @@ import (
"github.com/sunbk201/ua3f/internal/server/tproxy"
)
+type ServerMode string
+
+const (
+ ServerModeHTTP ServerMode = "HTTP"
+ ServerModeSocks5 ServerMode = "SOCKS5"
+ ServerModeTProxy ServerMode = "TPROXY"
+ ServerModeRedirect ServerMode = "REDIRECT"
+ ServerModeNFQueue ServerMode = "NFQUEUE"
+)
+
type Server interface {
Start() error
}
diff --git a/src/internal/server/socks5/socks5.go b/src/internal/server/socks5/socks5.go
index 4556198..bcda97b 100644
--- a/src/internal/server/socks5/socks5.go
+++ b/src/internal/server/socks5/socks5.go
@@ -217,7 +217,7 @@ func (s *Server) ForwardTCP(client, target net.Conn, destAddr string) {
// Server -> Client (raw)
go utils.CopyHalf(client, target)
- if s.cfg.DirectForward {
+ if s.cfg.RewriteMode == config.RewriteModeDirect {
// Client -> Server (raw)
go utils.CopyHalf(target, client)
return
diff --git a/src/internal/server/tproxy/tproxy_linux.go b/src/internal/server/tproxy/tproxy_linux.go
index d9dc883..4e2009b 100644
--- a/src/internal/server/tproxy/tproxy_linux.go
+++ b/src/internal/server/tproxy/tproxy_linux.go
@@ -97,7 +97,7 @@ func (s *Server) ForwardTCP(client, target net.Conn, destAddr string) {
// Server -> Client (raw)
go utils.CopyHalf(client, target)
- if s.cfg.DirectForward {
+ if s.cfg.RewriteMode == config.RewriteModeDirect {
// Client -> Server (raw)
go utils.CopyHalf(target, client)
return