mirror of
https://github.com/SunBK201/UA3F.git
synced 2025-12-16 16:57:08 +00:00
feat: add rule system
This commit is contained in:
parent
5dfa30cf34
commit
9b2ca0223d
58
clash/rules.json
Normal file
58
clash/rules.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
1
ipkg/CONTROL/conffiles
Normal file
1
ipkg/CONTROL/conffiles
Normal file
@ -0,0 +1 @@
|
||||
/etc/config/ua3f
|
||||
@ -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
|
||||
|
||||
@ -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 $@
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
653
openwrt/files/luci/view/ua3f/rules.htm
Normal file
653
openwrt/files/luci/view/ua3f/rules.htm
Normal file
@ -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 "[]"
|
||||
%>
|
||||
|
||||
<style>
|
||||
.rule-actions button { margin: 0 2px; }
|
||||
.empty-message { text-align: center; padding: 20px; font-style: italic; }
|
||||
|
||||
.cbi-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.cbi-modal-dialog {
|
||||
background: var(--background-color-high, #fff);
|
||||
color: var(--text-color-primary, #000);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cbi-modal-dialog-header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid var(--border-color-medium, #ddd);
|
||||
}
|
||||
|
||||
.cbi-modal-dialog-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cbi-modal-dialog-body {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Rewrite Rules Section -->
|
||||
<div class="cbi-section-descr" style="font-weight:bold; margin-bottom: 10px;"><%:Rewrite Rules%></div>
|
||||
<div class="cbi-section-descr" style="color: #999; font-size: 90%; margin-bottom: 10px;">
|
||||
<%:Note: Rewrite rules only take effect in rule mode. NFQUEUE mode is temporarily unavailable.%>
|
||||
</div>
|
||||
|
||||
<table id="rewrite-rules-table" class="table cbi-section-table">
|
||||
<tr class="tr table-titles">
|
||||
<th class="th" style="width: 80px; text-align: center"><%:Enabled%></th>
|
||||
<th class="th" style="width: 50px"><%:Index%></th>
|
||||
<th class="th" style="width: 120px"><%:Rule Type%></th>
|
||||
<th class="th"><%:Match Value%></th>
|
||||
<th class="th" style="width: 120px"><%:Rewrite Action%></th>
|
||||
<th class="th"><%:Rewrite Value%></th>
|
||||
<th class="th" style="width: 280px"><%:Actions%></th>
|
||||
</tr>
|
||||
<tbody id="rulesTableBody"></tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin: 10px 0; padding: 5px 0;">
|
||||
<input type="button" class="cbi-button cbi-button-add" onclick="rewriteRules.openAddDialog()" value="<%:Add Rule%>" />
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
window.rewriteRules = {
|
||||
rules: <%=rules_data%>,
|
||||
|
||||
init: function() {
|
||||
this.ensureFinalRule();
|
||||
this.renderTable();
|
||||
},
|
||||
|
||||
ensureFinalRule: function() {
|
||||
// Check if FINAL rule exists
|
||||
var hasFinalRule = this.rules.some(function(rule) {
|
||||
return rule.type === 'FINAL';
|
||||
});
|
||||
|
||||
// If no FINAL rule exists, add it
|
||||
if (!hasFinalRule) {
|
||||
this.rules.push({
|
||||
type: 'FINAL',
|
||||
match_value: '',
|
||||
action: 'DIRECT',
|
||||
rewrite_value: '',
|
||||
description: 'Default fallback rule',
|
||||
enabled: true
|
||||
});
|
||||
} else {
|
||||
// Ensure FINAL rule is at the bottom
|
||||
var finalRuleIndex = -1;
|
||||
for (var i = 0; i < this.rules.length; i++) {
|
||||
if (this.rules[i].type === 'FINAL') {
|
||||
finalRuleIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (finalRuleIndex !== -1 && finalRuleIndex !== this.rules.length - 1) {
|
||||
var finalRule = this.rules.splice(finalRuleIndex, 1)[0];
|
||||
this.rules.push(finalRule);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
isFinalRule: function(index) {
|
||||
return this.rules[index] && this.rules[index].type === 'FINAL';
|
||||
},
|
||||
|
||||
renderTable: function() {
|
||||
var tbody = document.getElementById('rulesTableBody');
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (this.rules.length === 0) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'tr';
|
||||
var td = document.createElement('td');
|
||||
td.className = 'td empty-message';
|
||||
td.colSpan = 7;
|
||||
td.textContent = '<%:No rules configured. Click「Add Rule」to create one%>';
|
||||
tr.appendChild(td);
|
||||
tbody.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < this.rules.length; i++) {
|
||||
tbody.appendChild(this.createRuleRow(this.rules[i], i));
|
||||
}
|
||||
},
|
||||
|
||||
createRuleRow: function(rule, index) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'tr cbi-section-table-row';
|
||||
var isFinal = this.isFinalRule(index);
|
||||
|
||||
// Enabled column
|
||||
var td1 = document.createElement('td');
|
||||
td1.className = 'td';
|
||||
td1.style.textAlign = 'center';
|
||||
var enabledCheckbox = document.createElement('input');
|
||||
enabledCheckbox.type = 'checkbox';
|
||||
enabledCheckbox.className = 'cbi-input-checkbox';
|
||||
enabledCheckbox.checked = rule.enabled;
|
||||
// FINAL rule must always be enabled
|
||||
if (isFinal) {
|
||||
enabledCheckbox.disabled = true;
|
||||
enabledCheckbox.checked = true;
|
||||
} else {
|
||||
enabledCheckbox.onchange = function() { rewriteRules.toggleRuleEnabled(index, this.checked); };
|
||||
}
|
||||
td1.appendChild(enabledCheckbox);
|
||||
tr.appendChild(td1);
|
||||
|
||||
// Index column
|
||||
var td2 = document.createElement('td');
|
||||
td2.className = 'td';
|
||||
td2.textContent = index + 1;
|
||||
tr.appendChild(td2);
|
||||
|
||||
// Rule Type column
|
||||
var td3 = document.createElement('td');
|
||||
td3.className = 'td';
|
||||
td3.textContent = this.getRuleTypeLabel(rule.type);
|
||||
tr.appendChild(td3);
|
||||
|
||||
// Match Value column
|
||||
var td4 = document.createElement('td');
|
||||
td4.className = 'td';
|
||||
td4.style.maxWidth = '200px';
|
||||
td4.style.overflow = 'hidden';
|
||||
td4.style.textOverflow = 'ellipsis';
|
||||
td4.style.whiteSpace = 'nowrap';
|
||||
var span4 = document.createElement('span');
|
||||
span4.textContent = isFinal ? '-' : (rule.match_value || '');
|
||||
span4.title = isFinal ? '' : (rule.match_value || '');
|
||||
td4.appendChild(span4);
|
||||
tr.appendChild(td4);
|
||||
|
||||
// Rewrite Action column
|
||||
var td5 = document.createElement('td');
|
||||
td5.className = 'td';
|
||||
td5.textContent = this.getActionLabel(rule.action);
|
||||
tr.appendChild(td5);
|
||||
|
||||
// Rewrite Value column
|
||||
var td6 = document.createElement('td');
|
||||
td6.className = 'td';
|
||||
td6.style.maxWidth = '200px';
|
||||
td6.style.overflow = 'hidden';
|
||||
td6.style.textOverflow = 'ellipsis';
|
||||
td6.style.whiteSpace = 'nowrap';
|
||||
var span6 = document.createElement('span');
|
||||
span6.textContent = rule.rewrite_value || '-';
|
||||
span6.title = rule.rewrite_value || '';
|
||||
td6.appendChild(span6);
|
||||
tr.appendChild(td6);
|
||||
|
||||
// Actions column
|
||||
var td7 = document.createElement('td');
|
||||
td7.className = 'td rule-actions';
|
||||
|
||||
var editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'cbi-button cbi-button-edit';
|
||||
editBtn.textContent = '<%:Edit%>';
|
||||
editBtn.onclick = function() { rewriteRules.editRule(index); };
|
||||
td7.appendChild(editBtn);
|
||||
|
||||
// Only show move and delete buttons for non-FINAL rules
|
||||
if (!isFinal) {
|
||||
td7.appendChild(document.createTextNode(' '));
|
||||
|
||||
var upBtn = document.createElement('button');
|
||||
upBtn.type = 'button';
|
||||
upBtn.className = 'cbi-button cbi-button-neutral';
|
||||
upBtn.textContent = '<%:Move Up%>';
|
||||
upBtn.disabled = index === 0;
|
||||
upBtn.onclick = function() { rewriteRules.moveRuleUp(index); };
|
||||
td7.appendChild(upBtn);
|
||||
|
||||
td7.appendChild(document.createTextNode(' '));
|
||||
|
||||
var downBtn = document.createElement('button');
|
||||
downBtn.type = 'button';
|
||||
downBtn.className = 'cbi-button cbi-button-neutral';
|
||||
downBtn.textContent = '<%:Move Down%>';
|
||||
// Can't move down if it's the second-to-last (because last is FINAL)
|
||||
downBtn.disabled = index >= this.rules.length - 2;
|
||||
downBtn.onclick = function() { rewriteRules.moveRuleDown(index); };
|
||||
td7.appendChild(downBtn);
|
||||
|
||||
td7.appendChild(document.createTextNode(' '));
|
||||
|
||||
var delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'cbi-button cbi-button-remove';
|
||||
delBtn.textContent = '<%:Delete%>';
|
||||
delBtn.onclick = function() { rewriteRules.deleteRule(index); };
|
||||
td7.appendChild(delBtn);
|
||||
}
|
||||
|
||||
tr.appendChild(td7);
|
||||
|
||||
return tr;
|
||||
},
|
||||
|
||||
getRuleTypeLabel: function(type) {
|
||||
var labels = {
|
||||
'KEYWORD': '<%:KEYWORD%>',
|
||||
'REGEX': '<%:REGEX%>',
|
||||
'IP-CIDR': '<%:IP-CIDR%>',
|
||||
'SRC-IP': '<%:SRC-IP%>',
|
||||
'DEST-PORT': '<%:DEST-PORT%>',
|
||||
'FINAL': '<%:FINAL%>'
|
||||
};
|
||||
return labels[type] || type;
|
||||
},
|
||||
|
||||
getActionLabel: function(action) {
|
||||
var labels = {
|
||||
'REPLACE': '<%:REPLACE%>',
|
||||
'REPLACE-PART': '<%:REPLACE-PART%>',
|
||||
'DELETE': '<%:DELETE%>',
|
||||
'DIRECT': '<%:DIRECT%>',
|
||||
'DROP': '<%:DROP%>'
|
||||
};
|
||||
return labels[action] || action;
|
||||
},
|
||||
|
||||
openAddDialog: function() {
|
||||
this.showRuleDialog(null, -1);
|
||||
},
|
||||
|
||||
editRule: function(index) {
|
||||
this.showRuleDialog(this.rules[index], index);
|
||||
},
|
||||
|
||||
showRuleDialog: function(rule, index) {
|
||||
var self = this;
|
||||
var isEdit = rule !== null;
|
||||
var isFinal = isEdit && rule.type === 'FINAL';
|
||||
|
||||
// Create modal structure
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'cbi-modal';
|
||||
|
||||
var dialog = document.createElement('div');
|
||||
dialog.className = 'cbi-modal-dialog';
|
||||
|
||||
// Header
|
||||
var header = document.createElement('div');
|
||||
header.className = 'cbi-modal-dialog-header';
|
||||
var h3 = document.createElement('h3');
|
||||
h3.textContent = isFinal ? '<%:Edit FINAL Rule%>' : (isEdit ? '<%:Edit Rewrite Rule%>' : '<%:Add Rewrite Rule%>');
|
||||
header.appendChild(h3);
|
||||
dialog.appendChild(header);
|
||||
|
||||
// Body
|
||||
var body = document.createElement('div');
|
||||
body.className = 'cbi-modal-dialog-body';
|
||||
|
||||
var section = document.createElement('div');
|
||||
section.className = 'cbi-section';
|
||||
|
||||
// Rule Type (hidden for FINAL rule)
|
||||
if (!isFinal) {
|
||||
var typeValue = document.createElement('div');
|
||||
typeValue.className = 'cbi-value';
|
||||
var typeLabel = document.createElement('label');
|
||||
typeLabel.className = 'cbi-value-title';
|
||||
typeLabel.textContent = '<%:Rule Type%>';
|
||||
typeValue.appendChild(typeLabel);
|
||||
var typeField = document.createElement('div');
|
||||
typeField.className = 'cbi-value-field';
|
||||
var typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'cbi-input-select';
|
||||
typeSelect.id = 'modal_rule_type';
|
||||
var types = [
|
||||
['KEYWORD', '<%:KEYWORD%>'],
|
||||
['REGEX', '<%:REGEX%>'],
|
||||
['IP-CIDR', '<%:IP-CIDR%>'],
|
||||
['SRC-IP', '<%:SRC-IP%>'],
|
||||
['DEST-PORT', '<%:DEST-PORT%>']
|
||||
];
|
||||
types.forEach(function(t) {
|
||||
var option = document.createElement('option');
|
||||
option.value = t[0];
|
||||
option.textContent = t[1];
|
||||
if (rule && rule.type === t[0]) option.selected = true;
|
||||
typeSelect.appendChild(option);
|
||||
});
|
||||
typeSelect.onchange = function() { rewriteRules.updateMatchValuePlaceholder(); };
|
||||
typeField.appendChild(typeSelect);
|
||||
typeValue.appendChild(typeField);
|
||||
section.appendChild(typeValue);
|
||||
}
|
||||
|
||||
// Match Value (hidden for FINAL rule)
|
||||
if (!isFinal) {
|
||||
var matchValue = document.createElement('div');
|
||||
matchValue.className = 'cbi-value';
|
||||
var matchLabel = document.createElement('label');
|
||||
matchLabel.className = 'cbi-value-title';
|
||||
matchLabel.textContent = '<%:Match Value%>';
|
||||
matchValue.appendChild(matchLabel);
|
||||
var matchField = document.createElement('div');
|
||||
matchField.className = 'cbi-value-field';
|
||||
var matchInput = document.createElement('input');
|
||||
matchInput.type = 'text';
|
||||
matchInput.className = 'cbi-input-text';
|
||||
matchInput.id = 'modal_match_value';
|
||||
matchInput.placeholder = '<%:Enter match value%>';
|
||||
if (rule) matchInput.value = rule.match_value || '';
|
||||
matchField.appendChild(matchInput);
|
||||
matchValue.appendChild(matchField);
|
||||
section.appendChild(matchValue);
|
||||
}
|
||||
|
||||
// Rewrite Action
|
||||
var actionValue = document.createElement('div');
|
||||
actionValue.className = 'cbi-value';
|
||||
var actionLabel = document.createElement('label');
|
||||
actionLabel.className = 'cbi-value-title';
|
||||
actionLabel.textContent = '<%:Rewrite Action%>';
|
||||
actionValue.appendChild(actionLabel);
|
||||
var actionField = document.createElement('div');
|
||||
actionField.className = 'cbi-value-field';
|
||||
var actionSelect = document.createElement('select');
|
||||
actionSelect.className = 'cbi-input-select';
|
||||
actionSelect.id = 'modal_action';
|
||||
var actions = isFinal ? [
|
||||
['DELETE', '<%:DELETE%>'],
|
||||
['REPLACE', '<%:REPLACE%>'],
|
||||
['DIRECT', '<%:DIRECT%>']
|
||||
] : [
|
||||
['DELETE', '<%:DELETE%>'],
|
||||
['REPLACE', '<%:REPLACE%>'],
|
||||
['REPLACE-PART', '<%:REPLACE-PART%>'],
|
||||
['DIRECT', '<%:DIRECT%>'],
|
||||
['DROP', '<%:DROP%>']
|
||||
];
|
||||
actions.forEach(function(s) {
|
||||
var option = document.createElement('option');
|
||||
option.value = s[0];
|
||||
option.textContent = s[1];
|
||||
if (rule && rule.action === s[0]) option.selected = true;
|
||||
actionSelect.appendChild(option);
|
||||
});
|
||||
actionSelect.onchange = function() { rewriteRules.updateRewriteValueVisibility(); };
|
||||
actionField.appendChild(actionSelect);
|
||||
actionValue.appendChild(actionField);
|
||||
section.appendChild(actionValue);
|
||||
|
||||
// Rewrite Value
|
||||
var rewriteValue = document.createElement('div');
|
||||
rewriteValue.className = 'cbi-value';
|
||||
rewriteValue.id = 'rewrite_value_container';
|
||||
var rewriteLabel = document.createElement('label');
|
||||
rewriteLabel.className = 'cbi-value-title';
|
||||
rewriteLabel.textContent = '<%:Rewrite Value%>';
|
||||
rewriteValue.appendChild(rewriteLabel);
|
||||
var rewriteField = document.createElement('div');
|
||||
rewriteField.className = 'cbi-value-field';
|
||||
var rewriteInput = document.createElement('input');
|
||||
rewriteInput.type = 'text';
|
||||
rewriteInput.className = 'cbi-input-text';
|
||||
rewriteInput.id = 'modal_rewrite_value';
|
||||
rewriteInput.placeholder = '<%:Enter rewrite value%>';
|
||||
if (rule) rewriteInput.value = rule.rewrite_value || '';
|
||||
rewriteField.appendChild(rewriteInput);
|
||||
rewriteValue.appendChild(rewriteField);
|
||||
section.appendChild(rewriteValue);
|
||||
|
||||
// Description
|
||||
var descValue = document.createElement('div');
|
||||
descValue.className = 'cbi-value';
|
||||
var descLabel = document.createElement('label');
|
||||
descLabel.className = 'cbi-value-title';
|
||||
descLabel.textContent = '<%:Description%> (<%:Optional%>)';
|
||||
descValue.appendChild(descLabel);
|
||||
var descField = document.createElement('div');
|
||||
descField.className = 'cbi-value-field';
|
||||
var descInput = document.createElement('input');
|
||||
descInput.type = 'text';
|
||||
descInput.className = 'cbi-input-text';
|
||||
descInput.id = 'modal_rule_description';
|
||||
descInput.placeholder = '<%:Enter rule description%>';
|
||||
if (rule && rule.description) descInput.value = rule.description;
|
||||
descField.appendChild(descInput);
|
||||
descValue.appendChild(descField);
|
||||
section.appendChild(descValue);
|
||||
|
||||
body.appendChild(section);
|
||||
|
||||
// Buttons
|
||||
var btnDiv = document.createElement('div');
|
||||
btnDiv.className = 'right';
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'cbi-button cbi-button-neutral';
|
||||
cancelBtn.textContent = '<%:Cancel%>';
|
||||
cancelBtn.onclick = function() { self.closeDialog(); };
|
||||
btnDiv.appendChild(cancelBtn);
|
||||
btnDiv.appendChild(document.createTextNode(' '));
|
||||
var saveBtn = document.createElement('button');
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.className = 'cbi-button cbi-button-positive';
|
||||
saveBtn.textContent = '<%:Save%>';
|
||||
saveBtn.onclick = function() { self.saveFromDialog(index); };
|
||||
btnDiv.appendChild(saveBtn);
|
||||
body.appendChild(btnDiv);
|
||||
|
||||
dialog.appendChild(body);
|
||||
modal.appendChild(dialog);
|
||||
|
||||
modal.onclick = function(e) {
|
||||
if (e.target === modal) self.closeDialog();
|
||||
};
|
||||
|
||||
document.body.appendChild(modal);
|
||||
this.currentModal = modal;
|
||||
|
||||
// Initialize visibility
|
||||
this.updateRewriteValueVisibility();
|
||||
this.updateMatchValuePlaceholder();
|
||||
},
|
||||
|
||||
updateMatchValuePlaceholder: function() {
|
||||
var typeSelect = document.getElementById('modal_rule_type');
|
||||
var matchInput = document.getElementById('modal_match_value');
|
||||
if (!typeSelect || !matchInput) return;
|
||||
|
||||
var placeholders = {
|
||||
'KEYWORD': '<%:Mac%>',
|
||||
'REGEX': '<%:Mac.*Chrome%>',
|
||||
'IP-CIDR': '<%:10.0.0.0/8%>',
|
||||
'SRC-IP': '<%:192.168.1.100%>',
|
||||
'DEST-PORT': '<%:443%>'
|
||||
};
|
||||
matchInput.placeholder = placeholders[typeSelect.value] || '<%:Enter match value%>';
|
||||
},
|
||||
|
||||
updateRewriteValueVisibility: function() {
|
||||
var actionSelect = document.getElementById('modal_action');
|
||||
var rewriteValueContainer = document.getElementById('rewrite_value_container');
|
||||
if (!actionSelect || !rewriteValueContainer) return;
|
||||
|
||||
var action = actionSelect.value;
|
||||
// Show rewrite value only for REPLACE and REPLACE-PART actions
|
||||
if (action === 'REPLACE' || action === 'REPLACE-PART') {
|
||||
rewriteValueContainer.style.display = '';
|
||||
} else {
|
||||
rewriteValueContainer.style.display = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
closeDialog: function() {
|
||||
if (this.currentModal) {
|
||||
document.body.removeChild(this.currentModal);
|
||||
this.currentModal = null;
|
||||
}
|
||||
},
|
||||
|
||||
saveFromDialog: function(index) {
|
||||
var isFinal = index >= 0 && this.rules[index].type === 'FINAL';
|
||||
|
||||
var newRule;
|
||||
if (isFinal) {
|
||||
// For FINAL rule, only allow changing action and rewrite_value
|
||||
newRule = {
|
||||
type: 'FINAL',
|
||||
match_value: '',
|
||||
action: document.getElementById('modal_action').value,
|
||||
rewrite_value: document.getElementById('modal_rewrite_value').value,
|
||||
description: document.getElementById('modal_rule_description').value,
|
||||
enabled: this.rules[index].enabled
|
||||
};
|
||||
} else {
|
||||
newRule = {
|
||||
type: document.getElementById('modal_rule_type').value,
|
||||
match_value: document.getElementById('modal_match_value').value,
|
||||
action: document.getElementById('modal_action').value,
|
||||
rewrite_value: document.getElementById('modal_rewrite_value').value,
|
||||
description: document.getElementById('modal_rule_description').value,
|
||||
enabled: index >= 0 ? this.rules[index].enabled : true
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!newRule.match_value) {
|
||||
alert('<%:Match value is required%>');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rewrite value for REPLACE and REPLACE-PART actions
|
||||
if ((newRule.action === 'REPLACE' || newRule.action === 'REPLACE-PART') && !newRule.rewrite_value) {
|
||||
alert('<%:Rewrite value is required for REPLACE and REPLACE-PART actions%>');
|
||||
return;
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
this.rules[index] = newRule;
|
||||
} else {
|
||||
// Insert before FINAL rule
|
||||
this.rules.splice(this.rules.length - 1, 0, newRule);
|
||||
}
|
||||
|
||||
this.closeDialog();
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '<%=luci.dispatcher.build_url("admin/services/ua3f/save_rules")%>');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
rewriteRules.renderTable();
|
||||
} else {
|
||||
alert('<%:Failed to save rule%>');
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify({ rules: this.rules }));
|
||||
},
|
||||
|
||||
deleteRule: function(index) {
|
||||
// Prevent deleting FINAL rule
|
||||
if (this.isFinalRule(index)) {
|
||||
alert('<%:FINAL rule cannot be deleted%>');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('<%:Are you sure you want to delete this rule?%>')) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.rules.splice(index, 1);
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '<%=luci.dispatcher.build_url("admin/services/ua3f/save_rules")%>');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
rewriteRules.renderTable();
|
||||
} else {
|
||||
alert('<%:Failed to delete rule%>');
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify({ rules: this.rules }));
|
||||
},
|
||||
|
||||
moveRuleUp: function(index) {
|
||||
if (index > 0 && !this.isFinalRule(index)) {
|
||||
var temp = this.rules[index];
|
||||
this.rules[index] = this.rules[index - 1];
|
||||
this.rules[index - 1] = temp;
|
||||
this.saveAndRender();
|
||||
}
|
||||
},
|
||||
|
||||
moveRuleDown: function(index) {
|
||||
// Can't move down if it's FINAL or if next is FINAL
|
||||
if (index < this.rules.length - 2 && !this.isFinalRule(index)) {
|
||||
var temp = this.rules[index];
|
||||
this.rules[index] = this.rules[index + 1];
|
||||
this.rules[index + 1] = temp;
|
||||
this.saveAndRender();
|
||||
}
|
||||
},
|
||||
|
||||
saveAndRender: function() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '<%=luci.dispatcher.build_url("admin/services/ua3f/save_rules")%>');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200) {
|
||||
rewriteRules.renderTable();
|
||||
} else {
|
||||
alert('<%:Failed to save rule%>');
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify({ rules: this.rules }));
|
||||
},
|
||||
|
||||
toggleRuleEnabled: function(index, enabled) {
|
||||
this.rules[index].enabled = enabled;
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '<%=luci.dispatcher.build_url("admin/services/ua3f/save_rules")%>');
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.onload = function() {
|
||||
if (xhr.status !== 200) {
|
||||
alert('<%:Failed to save rule%>');
|
||||
// Revert checkbox state on failure
|
||||
rewriteRules.rules[index].enabled = !enabled;
|
||||
rewriteRules.renderTable();
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify({ rules: this.rules }));
|
||||
}
|
||||
};
|
||||
|
||||
rewriteRules.init();
|
||||
})();
|
||||
</script>
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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'
|
||||
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"}]'
|
||||
@ -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 "连接总数"
|
||||
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 模式暂不可用。"
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
246
src/internal/rule/rule.go
Normal file
246
src/internal/rule/rule.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user