feat: add rule system

This commit is contained in:
SunBK201 2025-11-09 17:36:58 +08:00
parent 5dfa30cf34
commit 9b2ca0223d
21 changed files with 1441 additions and 67 deletions

58
clash/rules.json Normal file
View 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
View File

@ -0,0 +1 @@
/etc/config/ua3f

View File

@ -1,5 +1,5 @@
#!/bin/sh #!/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 /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 >/dev/null 2>&1
rm -rf /usr/lib/lua/luci/model/cbi/ua3f.lua >/dev/null 2>&1 rm -rf /usr/lib/lua/luci/model/cbi/ua3f.lua >/dev/null 2>&1

View File

@ -1,4 +1,6 @@
#!/bin/sh #!/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 [ -s ${IPKG_INSTROOT}/lib/functions.sh ] || exit 0
. ${IPKG_INSTROOT}/lib/functions.sh . ${IPKG_INSTROOT}/lib/functions.sh
default_prerm $0 $@ default_prerm $0 $@

View File

@ -4,6 +4,8 @@ function index()
entry({ "admin", "services", "ua3f" }, cbi("ua3f"), _("UA3F"), 1) entry({ "admin", "services", "ua3f" }, cbi("ua3f"), _("UA3F"), 1)
entry({ "admin", "services", "ua3f", "download_log" }, call("action_download_log")).leaf = true 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", "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 end
local fs = require("nixio.fs") local fs = require("nixio.fs")
@ -71,3 +73,111 @@ function clear_log()
http.write("Log file not found") http.write("Log file not found")
end end
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

View File

@ -22,6 +22,7 @@ function create_sections(map)
-- General Section with tabs -- General Section with tabs
sections.general = map:section(NamedSection, "main", "ua3f", translate("General")) sections.general = map:section(NamedSection, "main", "ua3f", translate("General"))
sections.general:tab("general", translate("Settings")) sections.general:tab("general", translate("Settings"))
sections.general:tab("rewrite", translate("Rewrite Rules"))
sections.general:tab("stats", translate("Statistics")) sections.general:tab("stats", translate("Statistics"))
sections.general:tab("log", translate("Log")) sections.general:tab("log", translate("Log"))
sections.general:tab("others", translate("Others Settings")) sections.general:tab("others", translate("Others Settings"))
@ -33,6 +34,7 @@ local sections = create_sections(ua3f)
fields.add_status_fields(sections.status) fields.add_status_fields(sections.status)
fields.add_general_fields(sections.general) fields.add_general_fields(sections.general)
fields.add_rewrite_fields(sections.general)
fields.add_stats_fields(sections.general) fields.add_stats_fields(sections.general)
fields.add_log_fields(sections.general) fields.add_log_fields(sections.general)
fields.add_others_fields(sections.general) fields.add_others_fields(sections.general)

View File

@ -41,15 +41,22 @@ function M.add_general_fields(section)
server_mode:value("TPROXY", "TPROXY") server_mode:value("TPROXY", "TPROXY")
server_mode:value("REDIRECT", "REDIRECT") server_mode:value("REDIRECT", "REDIRECT")
server_mode:value("NFQUEUE", "NFQUEUE") server_mode:value("NFQUEUE", "NFQUEUE")
server_mode.default = "SOCKS5"
-- Port
local port = section:taboption("general", Value, "port", translate("Port"))
port.placeholder = "1080"
-- Bind Address -- Bind Address
local bind = section:taboption("general", Value, "bind", translate("Bind Address")) local bind = section:taboption("general", Value, "bind", translate("Bind Address"))
bind:value("127.0.0.1") bind:value("127.0.0.1")
bind:value("0.0.0.0") 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 -- Log Level
local log_level = section:taboption("general", ListValue, "log_level", translate("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( 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.") "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")) local ua = section:taboption("general", Value, "ua", translate("User-Agent"))
ua.placeholder = "FFF" ua.placeholder = "FFF"
ua.description = translate("User-Agent after rewrite") ua.description = translate("User-Agent after rewrite")
ua:depends("rewrite_mode", "GLOBAL")
ua:depends("server_mode", "NFQUEUE")
-- User-Agent Regex -- User-Agent Regex
local uaRegexPattern = section:taboption("general", Value, "ua_regex", translate("User-Agent Regex")) local regex = section:taboption("general", Value, "ua_regex", translate("User-Agent Regex"))
uaRegexPattern.description = translate("Regular expression pattern for matching User-Agent") regex.description = translate("Regular expression pattern for matching User-Agent")
regex:depends("rewrite_mode", "GLOBAL")
regex:depends("server_mode", "NFQUEUE")
-- Partial Replace -- Partial Replace
local partialReplace = section:taboption("general", Flag, "partial_replace", translate("Partial Replace")) local partialReplace = section:taboption("general", Flag, "partial_replace", translate("Partial Replace"))
@ -77,12 +97,14 @@ function M.add_general_fields(section)
translate( translate(
"Replace only the matched part of the User-Agent, only works when User-Agent Regex is not empty") "Replace only the matched part of the User-Agent, only works when User-Agent Regex is not empty")
partialReplace.default = "0" partialReplace.default = "0"
partialReplace:depends("rewrite_mode", "GLOBAL")
partialReplace:depends("server_mode", "NFQUEUE")
end
-- Direct Forward -- Rewrite Rules Tab Fields
local directForward = section:taboption("general", Flag, "direct_forward", translate("Direct Forward")) function M.add_rewrite_fields(section)
directForward.description = local rules = section:taboption("rewrite", DummyValue, "")
translate("Directly forward packets without rewriting") rules.template = "ua3f/rules"
directForward.default = "0"
end end
-- Statistics Tab Fields -- Statistics Tab Fields

View 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>

View File

@ -505,6 +505,7 @@ start_service() {
LOG "Starting $NAME service..." LOG "Starting $NAME service..."
local port bind ua log_level ua_regex partial_replace set_ttl direct_forward 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 server_mode "main" "server_mode" "SOCKS5"
config_get port "main" "port" "1080" config_get port "main" "port" "1080"
config_get bind "main" "bind" "127.0.0.1" 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 log_level "main" "log_level" "info"
config_get_bool set_ttl "main" "set_ttl" 0 config_get_bool set_ttl "main" "set_ttl" 0
config_get_bool direct_forward "main" "direct_forward" 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="$(echo "$server_mode" | tr '[:lower:]' '[:upper:]')"
SERVER_MODE="$server_mode" SERVER_MODE="$server_mode"
@ -645,6 +648,8 @@ start_service() {
procd_append_param command -f "$ua" procd_append_param command -f "$ua"
procd_append_param command -r "$ua_regex" procd_append_param command -r "$ua_regex"
procd_append_param command -l "$log_level" 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 [ "$partial_replace" = "1" ] && procd_append_param command -s
[ "$direct_forward" = "1" ] && procd_append_param command -d [ "$direct_forward" = "1" ] && procd_append_param command -d

View File

@ -5,10 +5,12 @@ config 'ua3f' 'main'
option server_mode 'SOCKS5' option server_mode 'SOCKS5'
option port '1080' option port '1080'
option bind '0.0.0.0' option bind '0.0.0.0'
option rewrite_mode 'GLOBAL'
option ua 'FFF' 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 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 partial_replace '0'
option direct_forward '0' option direct_forward '0'
option log_level 'error' option log_level 'error'
option log_lines '1000' 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"}]'

View File

@ -2,6 +2,9 @@
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
msgid ""
msgstr ""
msgid "Status" msgid "Status"
msgstr "状态" msgstr "状态"
@ -18,11 +21,14 @@ msgid "Stopped"
msgstr "未运行" msgstr "未运行"
msgid "Settings" msgid "Settings"
msgstr "设置" msgstr "通用设置"
msgid "Statistics" msgid "Statistics"
msgstr "统计信息" msgstr "统计信息"
msgid "Rewrite Rules"
msgstr "重写规则"
msgid "Log" msgid "Log"
msgstr "运行日志" msgstr "运行日志"
@ -38,6 +44,21 @@ msgstr "绑定地址"
msgid "Log Level" msgid "Log Level"
msgstr "日志级别" 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" msgid "User-Agent"
msgstr "User-Agent" msgstr "User-Agent"
@ -126,4 +147,106 @@ msgid "Duration"
msgstr "持续时间" msgstr "持续时间"
msgid "Total Connections" 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 模式暂不可用。"

View File

@ -3,65 +3,78 @@ package config
import ( import (
"flag" "flag"
"fmt" "fmt"
"strings"
) )
type ServerMode string
const ( const (
ServerModeHTTP = "HTTP" ServerModeHTTP ServerMode = "HTTP"
ServerModeSocks5 = "SOCKS5" ServerModeSocks5 ServerMode = "SOCKS5"
ServerModeTProxy = "TPROXY" ServerModeTProxy ServerMode = "TPROXY"
ServerModeRedirect = "REDIRECT" ServerModeRedirect ServerMode = "REDIRECT"
ServerModeNFQueue = "NFQUEUE" ServerModeNFQueue ServerMode = "NFQUEUE"
)
type RewriteMode string
const (
RewriteModeGlobal RewriteMode = "GLOBAL"
RewriteModeDirect RewriteMode = "DIRECT"
RewriteModeRules RewriteMode = "RULES"
) )
type Config struct { type Config struct {
ServerMode string ServerMode ServerMode
BindAddr string BindAddr string
Port int Port int
ListenAddr string ListenAddr string
LogLevel string LogLevel string
RewriteMode RewriteMode
Rules string
PayloadUA string PayloadUA string
UARegex string UARegex string
PartialReplace bool PartialReplace bool
DirectForward bool
} }
func Parse() (*Config, bool) { func Parse() (*Config, bool) {
var ( var (
serverMode string serverMode string
bindAddr string bindAddr string
port int port int
loglevel string loglevel string
payloadUA string payloadUA string
uaRegx string uaRegx string
partial bool partial bool
directForward bool rewriteMode string
showVer bool 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.StringVar(&bindAddr, "b", "127.0.0.1", "Bind address")
flag.IntVar(&port, "p", 1080, "Port") flag.IntVar(&port, "p", 1080, "Port")
flag.StringVar(&loglevel, "l", "info", "Log level") flag.StringVar(&loglevel, "l", "info", "Log level")
flag.StringVar(&payloadUA, "f", "FFF", "User-Agent") flag.StringVar(&payloadUA, "f", "FFF", "User-Agent")
flag.StringVar(&uaRegx, "r", "", "User-Agent regex") flag.StringVar(&uaRegx, "r", "", "User-Agent regex")
flag.BoolVar(&partial, "s", false, "Enable regex partial replace") 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.BoolVar(&showVer, "v", false, "Show version")
flag.Parse() flag.Parse()
cfg := &Config{ cfg := &Config{
ServerMode: strings.ToUpper(serverMode), ServerMode: ServerMode(serverMode),
BindAddr: bindAddr, BindAddr: bindAddr,
Port: port, Port: port,
ListenAddr: fmt.Sprintf("%s:%d", bindAddr, port), ListenAddr: fmt.Sprintf("%s:%d", bindAddr, port),
LogLevel: loglevel, LogLevel: loglevel,
PayloadUA: payloadUA, PayloadUA: payloadUA,
UARegex: uaRegx, UARegex: uaRegx,
DirectForward: directForward,
PartialReplace: partial, PartialReplace: partial,
RewriteMode: RewriteMode(rewriteMode),
Rules: rules,
} }
if serverMode == ServerModeRedirect { if cfg.ServerMode == ServerModeRedirect || cfg.ServerMode == ServerModeTProxy {
cfg.BindAddr = "0.0.0.0" cfg.BindAddr = "0.0.0.0"
cfg.ListenAddr = fmt.Sprintf("0.0.0.0:%d", port) cfg.ListenAddr = fmt.Sprintf("0.0.0.0:%d", port)
} }

View File

@ -72,10 +72,11 @@ func LogHeader(version string, cfg *config.Config) {
logrus.Info("UA3F v" + version) logrus.Info("UA3F v" + version)
logrus.Info("Server Mode: " + cfg.ServerMode) logrus.Info("Server Mode: " + cfg.ServerMode)
logrus.Infof("Listen on %s", cfg.ListenAddr) 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: %s", cfg.PayloadUA)
logrus.Infof("User-Agent Regex: '%s'", cfg.UARegex) logrus.Infof("User-Agent Regex: '%s'", cfg.UARegex)
logrus.Infof("Partial Replace: %v", cfg.PartialReplace) logrus.Infof("Partial Replace: %v", cfg.PartialReplace)
logrus.Infof("Direct Forward: %v", cfg.DirectForward)
logrus.Infof("Log level: %s", cfg.LogLevel) logrus.Infof("Log level: %s", cfg.LogLevel)
} }

View File

@ -15,6 +15,7 @@ import (
"github.com/sunbk201/ua3f/internal/config" "github.com/sunbk201/ua3f/internal/config"
"github.com/sunbk201/ua3f/internal/log" "github.com/sunbk201/ua3f/internal/log"
"github.com/sunbk201/ua3f/internal/rule"
"github.com/sunbk201/ua3f/internal/sniff" "github.com/sunbk201/ua3f/internal/sniff"
"github.com/sunbk201/ua3f/internal/statistics" "github.com/sunbk201/ua3f/internal/statistics"
) )
@ -26,10 +27,25 @@ type Rewriter struct {
payloadUA string payloadUA string
pattern string pattern string
partialReplace bool partialReplace bool
rewriteMode config.RewriteMode
uaRegex *regexp2.Regexp uaRegex *regexp2.Regexp
whitelist []string ruleEngine *rule.Engine
Cache *expirable.LRU[string, struct{}] 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. // 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 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{ return &Rewriter{
payloadUA: cfg.PayloadUA, payloadUA: cfg.PayloadUA,
pattern: cfg.UARegex, pattern: cfg.UARegex,
partialReplace: cfg.PartialReplace, partialReplace: cfg.PartialReplace,
rewriteMode: cfg.RewriteMode,
uaRegex: uaRegex, uaRegex: uaRegex,
ruleEngine: ruleEngine,
Cache: expirable.NewLRU[string, struct{}](1024, nil, 30*time.Minute), Cache: expirable.NewLRU[string, struct{}](1024, nil, 30*time.Minute),
whitelist: []string{ whitelist: []string{
"MicroMessenger Client", "MicroMessenger Client",
@ -66,6 +94,11 @@ func (r *Rewriter) inWhitelist(ua string) bool {
return false return false
} }
// GetRuleEngine 获取规则引擎
func (r *Rewriter) GetRuleEngine() *rule.Engine {
return r.ruleEngine
}
// buildUserAgent returns either a partial replacement (regex) or full overwrite. // buildUserAgent returns either a partial replacement (regex) or full overwrite.
func (r *Rewriter) buildUserAgent(originUA string) string { func (r *Rewriter) buildUserAgent(originUA string) string {
if r.partialReplace && r.uaRegex != nil && r.pattern != "" { if r.partialReplace && r.uaRegex != nil && r.pattern != "" {
@ -79,13 +112,74 @@ func (r *Rewriter) buildUserAgent(originUA string) string {
return r.payloadUA 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") originalUA := req.Header.Get("User-Agent")
log.LogInfoWithAddr(srcAddr, destAddr, fmt.Sprintf("original User-Agent: (%s)", originalUA)) log.LogInfoWithAddr(srcAddr, destAddr, fmt.Sprintf("original User-Agent: (%s)", originalUA))
if originalUA == "" { if originalUA == "" {
req.Header.Set("User-Agent", "") 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 var err error
matches := false matches := false
isWhitelist := r.inWhitelist(originalUA) isWhitelist := r.inWhitelist(originalUA)
@ -117,13 +211,29 @@ func (r *Rewriter) ShouldRewrite(req *http.Request, srcAddr, destAddr string) bo
DestAddr: destAddr, DestAddr: destAddr,
UA: originalUA, 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") 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) req.Header.Set("User-Agent", rewritedUA)
log.LogInfoWithAddr(srcAddr, destAddr, fmt.Sprintf("Rewrite User-Agent from (%s) to (%s)", originalUA, 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) err = fmt.Errorf("http.ReadRequest: %w", err)
return 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 { if err = r.Forward(dst, req); err != nil {
err = fmt.Errorf("r.Forward: %w", err) err = fmt.Errorf("r.Forward: %w", err)
return return

246
src/internal/rule/rule.go Normal file
View 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
}

View File

@ -9,7 +9,9 @@ import (
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/sunbk201/ua3f/internal/config" "github.com/sunbk201/ua3f/internal/config"
"github.com/sunbk201/ua3f/internal/log"
"github.com/sunbk201/ua3f/internal/rewrite" "github.com/sunbk201/ua3f/internal/rewrite"
"github.com/sunbk201/ua3f/internal/rule"
"github.com/sunbk201/ua3f/internal/server/utils" "github.com/sunbk201/ua3f/internal/server/utils"
) )
@ -58,18 +60,10 @@ func (s *Server) handleHTTP(w http.ResponseWriter, req *http.Request) {
} }
defer target.Close() defer target.Close()
if s.cfg.DirectForward { err = s.rewriteAndForward(target, req, req.Host, req.RemoteAddr)
err = req.Write(target) if err != nil {
if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable)
http.Error(w, err.Error(), http.StatusServiceUnavailable) return
return
}
} else {
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) resp, err := http.ReadResponse(bufio.NewReader(target), req)
if err != nil { 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) { func (s *Server) rewriteAndForward(target net.Conn, req *http.Request, dstAddr, srcAddr string) (err error) {
rw := s.rw 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 { if err = rw.Forward(target, req); err != nil {
err = fmt.Errorf("r.forward: %w", err) err = fmt.Errorf("r.forward: %w", err)
return return
@ -131,7 +137,7 @@ func (s *Server) ForwardTCP(client, target net.Conn, destAddr string) {
// Server -> Client (raw) // Server -> Client (raw)
go utils.CopyHalf(client, target) go utils.CopyHalf(client, target)
if s.cfg.DirectForward { if s.cfg.RewriteMode == "direct" {
// Client -> Server (raw) // Client -> Server (raw)
go utils.CopyHalf(target, client) go utils.CopyHalf(target, client)
return return

View File

@ -67,7 +67,7 @@ func (s *Server) worker(ctx context.Context, workerID int, aChan <-chan *nfq.Att
logrus.Debugf("Worker %d channel closed", workerID) logrus.Debugf("Worker %d channel closed", workerID)
return return
} }
if s.cfg.DirectForward { if s.cfg.RewriteMode == config.RewriteModeDirect {
_ = s.nf.SetVerdict(*a.PacketID, nfq.NfAccept) _ = s.nf.SetVerdict(*a.PacketID, nfq.NfAccept)
continue continue
} else { } else {

View File

@ -77,7 +77,7 @@ func (s *Server) ForwardTCP(client, target net.Conn, destAddr string) {
// Server -> Client (raw) // Server -> Client (raw)
go utils.CopyHalf(client, target) go utils.CopyHalf(client, target)
if s.cfg.DirectForward { if s.cfg.RewriteMode == config.RewriteModeDirect {
// Client -> Server (raw) // Client -> Server (raw)
go utils.CopyHalf(target, client) go utils.CopyHalf(target, client)
return return

View File

@ -12,6 +12,16 @@ import (
"github.com/sunbk201/ua3f/internal/server/tproxy" "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 { type Server interface {
Start() error Start() error
} }

View File

@ -217,7 +217,7 @@ func (s *Server) ForwardTCP(client, target net.Conn, destAddr string) {
// Server -> Client (raw) // Server -> Client (raw)
go utils.CopyHalf(client, target) go utils.CopyHalf(client, target)
if s.cfg.DirectForward { if s.cfg.RewriteMode == config.RewriteModeDirect {
// Client -> Server (raw) // Client -> Server (raw)
go utils.CopyHalf(target, client) go utils.CopyHalf(target, client)
return return

View File

@ -97,7 +97,7 @@ func (s *Server) ForwardTCP(client, target net.Conn, destAddr string) {
// Server -> Client (raw) // Server -> Client (raw)
go utils.CopyHalf(client, target) go utils.CopyHalf(client, target)
if s.cfg.DirectForward { if s.cfg.RewriteMode == config.RewriteModeDirect {
// Client -> Server (raw) // Client -> Server (raw)
go utils.CopyHalf(target, client) go utils.CopyHalf(target, client)
return return