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