feat: support record ua mock times and i18n

This commit is contained in:
SunBK201 2025-10-23 01:35:27 +08:00
parent 830aadb22b
commit f4d15229e3
12 changed files with 284 additions and 35 deletions

View File

@ -64,12 +64,16 @@ ipkg_build=ipkg-build.sh
mkdir -p $opkg_template/usr/bin
mkdir -p $opkg_template/usr/lib/lua/luci/controller
mkdir -p $opkg_template/usr/lib/lua/luci/model/cbi
mkdir -p $opkg_template/usr/lib/lua/luci/view/ua3f
mkdir -p $opkg_template/usr/lib/lua/luci/i18n
mkdir -p $opkg_template/etc/init.d
mkdir -p $opkg_template/etc/config
cp openwrt/files/luci/controller.lua $opkg_template/usr/lib/lua/luci/controller/ua3f.lua
cp openwrt/files/luci/cbi.lua $opkg_template/usr/lib/lua/luci/model/cbi/ua3f.lua
cp openwrt/files/luci/statistics.htm $opkg_template/usr/lib/lua/luci/view/ua3f/statistics.htm
cp openwrt/files/ua3f.init $opkg_template/etc/init.d/ua3f
cp openwrt/files/ua3f.uci $opkg_template/etc/config/ua3f
./po2lmo openwrt/po/zh_cn/ua3f.po $opkg_template/usr/lib/lua/luci/i18n/ua3f.zh-cn.lmo
for goarch in "amd64" "arm" "arm64" "mipsle" "mips64" "riscv64" "386" "mipsle-softfloat" "mipsle-hardfloat" "armv7" "armv8"; do
obj_name=$project_name-$release_version-$goarch
mv $dist/bin/$obj_name $opkg_template/usr/bin/ua3f

View File

@ -7,5 +7,5 @@ License: GPL-3.0-only
Section: net
SourceDateEpoch: 1711267200
Architecture: all
Installed-Size: 3686400
Installed-Size: 3696640
Description: Implementation of the next generation of HTTP User-Agent modification methodology.

View File

@ -37,6 +37,7 @@ endef
define Build/Prepare
$(CP) ../src/* $(PKG_BUILD_DIR)
po2lmo ./po/zh_cn/ua3f.po $(PKG_BUILD_DIR)/ua3f.zh-cn.lmo
endef
define Package/ua3f/conffiles
@ -57,6 +58,10 @@ define Package/ua3f/install
$(INSTALL_CONF) ./files/luci/cbi.lua $(1)/usr/lib/lua/luci/model/cbi/ua3f.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller/
$(INSTALL_CONF) ./files/luci/controller.lua $(1)/usr/lib/lua/luci/controller/ua3f.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/ua3f/
$(INSTALL_CONF) ./files/luci/statistics.htm $(1)/usr/lib/lua/luci/view/ua3f/statistics.htm
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n/
$(INSTALL_DATA) $(PKG_BUILD_DIR)/ua3f.zh-cn.lmo $(1)/usr/lib/lua/luci/i18n/ua3f.zh-cn.lmo
endef

View File

@ -9,60 +9,63 @@ ua3f = Map("ua3f",
]]
)
enable = ua3f:section(NamedSection, "enabled", "ua3f", "Status")
main = ua3f:section(NamedSection, "main", "ua3f", "Settings")
status = ua3f:section(NamedSection, "enabled", "ua3f", translate("Status"))
general = ua3f:section(NamedSection, "main", "ua3f", translate("General"))
enable:option(Flag, "enabled", "Enabled")
status = enable:option(DummyValue, "status", "Status")
status.rawhtml = true
status.cfgvalue = function(self, section)
status:option(Flag, "enabled", translate("Enabled"))
running = status:option(DummyValue, "running", translate("Status"))
running.rawhtml = true
running.cfgvalue = function(self, section)
local pid = luci.sys.exec("pidof ua3f")
if pid == "" then
return "<span style='color:red'>" .. "Stopped" .. "</span>"
return "<input disabled type='button' style='opacity: 1;' class='btn cbi-button cbi-button-reset' value='" ..
translate("Stop") .. "'/>"
else
return "<span style='color:green'>" .. "Running" .. "</span>"
return "<input disabled type='button' style='opacity: 1;' class='btn cbi-button cbi-button-add' value='" ..
translate("Running") .. "'/>"
end
end
main:tab("general", "General Settings")
main:tab("log", "Log")
general:tab("general", translate("Settings"))
general:tab("stats", translate("Statistics"))
general:tab("log", translate("Log"))
port = main:taboption("general", Value, "port", "Port")
port = general:taboption("general", Value, "port", translate("Port"))
port.placeholder = "1080"
bind = main:taboption("general", Value, "bind", "Bind Address")
bind = general:taboption("general", Value, "bind", translate("Bind Address"))
bind:value("127.0.0.1")
bind:value("0.0.0.0")
log_level = main:taboption("general", ListValue, "log_level", "Log Level")
log_level = general:taboption("general", ListValue, "log_level", translate("Log Level"))
log_level:value("debug")
log_level:value("info")
log_level:value("warn")
log_level:value("error")
log_level:value("fatal")
log_level:value("panic")
log = main:taboption("log", TextValue, "")
ua = general:taboption("general", Value, "ua", translate("User-Agent"))
ua.placeholder = "FFF"
uaRegexPattern = general:taboption("general", Value, "ua_regex", translate("User-Agent Regex Pattern"))
uaRegexPattern.placeholder = "(iPhone|iPad|Android|Macintosh|Windows|Linux|Apple|Mac OS X|Mobile)"
uaRegexPattern.description = translate("Regular expression pattern for matching User-Agent")
partialRepalce = general:taboption("general", Flag, "partial_replace", translate("Partial Replace"))
partialRepalce.description =
translate("Replace only the matched part of the User-Agent, only works when User-Agent Regex Pattern is not empty")
partialRepalce.default = "0"
log = general:taboption("log", TextValue, "")
log.readonly = true
log.cfgvalue = function(self, section)
return luci.sys.exec("cat /var/log/ua3f/ua3f.log")
end
log.rows = 30
ua = main:taboption("general", Value, "ua", "User-Agent")
ua.placeholder = "FFF"
uaRegexPattern = main:taboption("general", Value, "ua_regex", "User-Agent Regex Pattern")
uaRegexPattern.placeholder = "(iPhone|iPad|Android|Macintosh|Windows|Linux|Apple|Mac OS X|Mobile)"
uaRegexPattern.description = "Regular expression pattern for matching User-Agent"
partialRepalce = main:taboption("general", Flag, "partial_replace", "Partial Replace")
partialRepalce.description =
"Replace only the matched part of the User-Agent, only works when User-Agent Regex Pattern is not empty"
partialRepalce.default = "0"
local apply = luci.http.formvalue("cbi.apply")
if apply then
io.popen("/etc/init.d/ua3f restart")
end
stats = general:taboption("stats", DummyValue, "")
stats.template = "ua3f/statistics"
return ua3f

View File

@ -1,5 +1,5 @@
module("luci.controller.ua3f", package.seeall)
function index()
entry({"admin", "services", "ua3f"}, cbi("ua3f"), "UA3F", 1)
entry({"admin", "services", "ua3f"}, cbi("ua3f"), _("UA3F"), 1)
end

View File

@ -0,0 +1,82 @@
<%
local stats = {}
local file = io.open("/var/log/ua3f/stats", "r")
if file then
for line in file:lines() do
local host, count, origin_ua, mocked_ua = line:match("^(%S+)%s+(%d+)%s+(.-)SEQSEQ(.-)%s*$")
if host and count then
table.insert(stats, {host = host, count = count, origin_ua = origin_ua, mocked_ua = mocked_ua})
end
end
file:close()
end
local function rowstyle(i)
return (i % 2 == 0) and "cbi-rowstyle-2" or "cbi-rowstyle-1"
end
%>
<h3><%:Statistics%></h3>
<div class="cbi-section-descr"><%:User-Agent Mock Statistics%></div>
<table id="stats-table" class="table cbi-section-table">
<tr class="tr table-titles">
<th class="th"><%:Host%></th>
<th class="th"><%:Modified Count%></th>
<th class="th"><%:Original User-Agent%></th>
<th class="th"><%:Mocked User-Agent%></th>
</tr>
<% for i, item in ipairs(stats) do %>
<tr class="tr <%= rowstyle(i) %>">
<td class="td" data-title="<%:Host%>"><span><%= item.host %></span></td>
<td class="td" data-title="<%:Modified Count%>"><%= item.count %></td>
<td class="td" data-title="<%:Original User-Agent%>"><span><%= item.origin_ua %></span></td>
<td class="td" data-title="<%:Mocked User-Agent%>"><span><%= item.mocked_ua %></span></td>
</tr>
<% end %>
</table>
<script type="text/javascript">
async function updateStats() {
try {
const response = await fetch(window.location.href, {cache: "no-store"});
const text = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, "text/html");
const newTable = doc.querySelector("#stats-table");
if (newTable) {
document.querySelector("#stats-table").innerHTML = newTable.innerHTML;
}
} catch (err) {
console.error("update stats error:", err);
}
}
setInterval(updateStats, 5000);
</script>
<style>
#stats-table th:nth-child(1),
#stats-table td:nth-child(1) {
width: 20%;
}
#stats-table th:nth-child(2),
#stats-table td:nth-child(2) {
width: 10%;
text-align: center;
}
#stats-table th:nth-child(3),
#stats-table td:nth-child(3) {
width: 35%;
}
#stats-table th:nth-child(4),
#stats-table td:nth-child(4) {
width: 35%;
}
</style>

66
openwrt/po/zh_cn/ua3f.po Normal file
View File

@ -0,0 +1,66 @@
"Language: zh_CN\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
msgid "Status"
msgstr "状态"
msgid "General"
msgstr "常规"
msgid "Enabled"
msgstr "启用"
msgid "Running"
msgstr "运行中"
msgid "Stopped"
msgstr "未运行"
msgid "Settings"
msgstr "设置"
msgid "Statistics"
msgstr "统计信息"
msgid "Log"
msgstr "运行日志"
msgid "Port"
msgstr "端口"
msgid "Bind Address"
msgstr "绑定地址"
msgid "Log Level"
msgstr "日志级别"
msgid "User-Agent"
msgstr "User-Agent"
msgid "User-Agent Regex Pattern"
msgstr "User-Agent 正则表达式"
msgid "Regular expression pattern for matching User-Agent"
msgstr "用于匹配 User-Agent只有匹配成功的 User-Agent 才会被修改,为空则匹配所有 User-Agent"
msgid "Partial Replace"
msgstr "部分替换"
msgid "Replace only the matched part of the User-Agent, only works when User-Agent Regex Pattern is not empty"
msgstr "仅替换 User-Agent 正则匹配的部分,仅在 User-Agent 正则表达式非空时有效"
msgid "User-Agent Mock Statistics"
msgstr "User-Agent 修改次数实时统计"
msgid "Host"
msgstr "地址"
msgid "Modified Count"
msgstr "修改次数"
msgid "Original User-Agent"
msgstr "原始 User-Agent"
msgid "Mocked User-Agent"
msgstr "修改后 User-Agent"

BIN
po2lmo Executable file

Binary file not shown.

View File

@ -11,6 +11,8 @@ import (
"gopkg.in/natefinch/lumberjack.v2"
)
const log_file = "/var/log/ua3f/ua3f.log"
type uctFormatter struct {
}
@ -27,7 +29,6 @@ func (formatter *uctFormatter) Format(entry *logrus.Entry) ([]byte, error) {
}
func SetLogConf(level string) {
log_file := "/var/log/ua3f/ua3f.log"
writer1 := &bytes.Buffer{}
writer2 := os.Stdout
writer3 := &lumberjack.Logger{

View File

@ -0,0 +1,78 @@
package statistics
import (
"fmt"
"os"
"sort"
"time"
"github.com/sirupsen/logrus"
)
type StatRecord struct {
Host string
Count int
OriginUA string
MockedUA string
}
var (
statChan = make(chan StatRecord, 3000)
stats = make(map[string]*StatRecord)
)
const statsFile = "/var/log/ua3f/stats"
func StartStatWorker() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case dest := <-statChan:
if record, exists := stats[dest.Host]; exists {
record.Count++
record.OriginUA = dest.OriginUA
record.MockedUA = dest.MockedUA
} else {
stats[dest.Host] = &StatRecord{
Host: dest.Host,
Count: 1,
OriginUA: dest.OriginUA,
MockedUA: dest.MockedUA,
}
}
case <-ticker.C:
dumpStatsToFile()
}
}
}
func AddStat(dest *StatRecord) {
select {
case statChan <- *dest:
default:
}
}
func dumpStatsToFile() {
f, err := os.Create(statsFile)
if err != nil {
logrus.Errorf("create stats file error: %v", err)
return
}
defer f.Close()
var statList []StatRecord
for _, record := range stats {
statList = append(statList, *record)
}
sort.SliceStable(statList, func(i, j int) bool {
return statList[i].Count > statList[j].Count
})
for _, record := range statList {
line := fmt.Sprintf("%s %d %sSEQSEQ%s\n", record.Host, record.Count, record.OriginUA, record.MockedUA)
f.WriteString(line)
}
}

View File

@ -15,7 +15,8 @@ import (
"github.com/dlclark/regexp2"
"github.com/hashicorp/golang-lru/v2/expirable"
"github.com/sirupsen/logrus"
"github.com/sunbk201/ua3f/log"
"github.com/sunbk201/ua3f/internal/log"
"github.com/sunbk201/ua3f/internal/statistics"
)
var (
@ -79,6 +80,9 @@ func main() {
logrus.Fatal("Invalid User-Agent Regex Pattern: ", err)
return
}
go statistics.StartStatWorker()
for {
client, err := server.Accept()
if err != nil {
@ -468,11 +472,17 @@ func transfer(dst net.Conn, src net.Conn, destAddrPort string) {
return
}
logrus.Debug(fmt.Sprintf("[%s][%s] Hit User-Agent: %s", destAddrPort, src.(*net.TCPConn).RemoteAddr().String(), uaStr))
request.Header.Set("User-Agent", buildNewUA(uaStr, payload, uaRegexp, enablePartialReplace))
mockedUA := buildNewUA(uaStr, payload, uaRegexp, enablePartialReplace)
request.Header.Set("User-Agent", mockedUA)
err = request.Write(dst)
if err != nil {
logrus.Error(fmt.Sprintf("[%s][%s] write error after replace user-agent: %s", destAddrPort, src.(*net.TCPConn).RemoteAddr().String(), err.Error()))
break
}
statistics.AddStat(&statistics.StatRecord{
Host: destAddrPort,
OriginUA: uaStr,
MockedUA: mockedUA,
})
}
}