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/bin
mkdir -p $opkg_template/usr/lib/lua/luci/controller 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/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/init.d
mkdir -p $opkg_template/etc/config 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/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/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.init $opkg_template/etc/init.d/ua3f
cp openwrt/files/ua3f.uci $opkg_template/etc/config/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 for goarch in "amd64" "arm" "arm64" "mipsle" "mips64" "riscv64" "386" "mipsle-softfloat" "mipsle-hardfloat" "armv7" "armv8"; do
obj_name=$project_name-$release_version-$goarch obj_name=$project_name-$release_version-$goarch
mv $dist/bin/$obj_name $opkg_template/usr/bin/ua3f mv $dist/bin/$obj_name $opkg_template/usr/bin/ua3f

View File

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

View File

@ -37,6 +37,7 @@ endef
define Build/Prepare define Build/Prepare
$(CP) ../src/* $(PKG_BUILD_DIR) $(CP) ../src/* $(PKG_BUILD_DIR)
po2lmo ./po/zh_cn/ua3f.po $(PKG_BUILD_DIR)/ua3f.zh-cn.lmo
endef endef
define Package/ua3f/conffiles 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_CONF) ./files/luci/cbi.lua $(1)/usr/lib/lua/luci/model/cbi/ua3f.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller/ $(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller/
$(INSTALL_CONF) ./files/luci/controller.lua $(1)/usr/lib/lua/luci/controller/ua3f.lua $(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 endef

View File

@ -9,60 +9,63 @@ ua3f = Map("ua3f",
]] ]]
) )
enable = ua3f:section(NamedSection, "enabled", "ua3f", "Status") status = ua3f:section(NamedSection, "enabled", "ua3f", translate("Status"))
main = ua3f:section(NamedSection, "main", "ua3f", "Settings") general = ua3f:section(NamedSection, "main", "ua3f", translate("General"))
enable:option(Flag, "enabled", "Enabled") status:option(Flag, "enabled", translate("Enabled"))
status = enable:option(DummyValue, "status", "Status")
status.rawhtml = true running = status:option(DummyValue, "running", translate("Status"))
status.cfgvalue = function(self, section) running.rawhtml = true
running.cfgvalue = function(self, section)
local pid = luci.sys.exec("pidof ua3f") local pid = luci.sys.exec("pidof ua3f")
if pid == "" then 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 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
end end
main:tab("general", "General Settings") general:tab("general", translate("Settings"))
main:tab("log", "Log") 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" 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("127.0.0.1")
bind:value("0.0.0.0") 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("debug")
log_level:value("info") log_level:value("info")
log_level:value("warn") log_level:value("warn")
log_level:value("error") log_level:value("error")
log_level:value("fatal") log_level:value("fatal")
log_level:value("panic") 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.readonly = true
log.cfgvalue = function(self, section) log.cfgvalue = function(self, section)
return luci.sys.exec("cat /var/log/ua3f/ua3f.log") return luci.sys.exec("cat /var/log/ua3f/ua3f.log")
end end
log.rows = 30 log.rows = 30
ua = main:taboption("general", Value, "ua", "User-Agent") stats = general:taboption("stats", DummyValue, "")
ua.placeholder = "FFF" stats.template = "ua3f/statistics"
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
return ua3f return ua3f

View File

@ -1,5 +1,5 @@
module("luci.controller.ua3f", package.seeall) module("luci.controller.ua3f", package.seeall)
function index() function index()
entry({"admin", "services", "ua3f"}, cbi("ua3f"), "UA3F", 1) entry({"admin", "services", "ua3f"}, cbi("ua3f"), _("UA3F"), 1)
end 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" "gopkg.in/natefinch/lumberjack.v2"
) )
const log_file = "/var/log/ua3f/ua3f.log"
type uctFormatter struct { type uctFormatter struct {
} }
@ -27,7 +29,6 @@ func (formatter *uctFormatter) Format(entry *logrus.Entry) ([]byte, error) {
} }
func SetLogConf(level string) { func SetLogConf(level string) {
log_file := "/var/log/ua3f/ua3f.log"
writer1 := &bytes.Buffer{} writer1 := &bytes.Buffer{}
writer2 := os.Stdout writer2 := os.Stdout
writer3 := &lumberjack.Logger{ 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/dlclark/regexp2"
"github.com/hashicorp/golang-lru/v2/expirable" "github.com/hashicorp/golang-lru/v2/expirable"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/sunbk201/ua3f/log" "github.com/sunbk201/ua3f/internal/log"
"github.com/sunbk201/ua3f/internal/statistics"
) )
var ( var (
@ -79,6 +80,9 @@ func main() {
logrus.Fatal("Invalid User-Agent Regex Pattern: ", err) logrus.Fatal("Invalid User-Agent Regex Pattern: ", err)
return return
} }
go statistics.StartStatWorker()
for { for {
client, err := server.Accept() client, err := server.Accept()
if err != nil { if err != nil {
@ -468,11 +472,17 @@ func transfer(dst net.Conn, src net.Conn, destAddrPort string) {
return return
} }
logrus.Debug(fmt.Sprintf("[%s][%s] Hit User-Agent: %s", destAddrPort, src.(*net.TCPConn).RemoteAddr().String(), uaStr)) 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) err = request.Write(dst)
if err != nil { if err != nil {
logrus.Error(fmt.Sprintf("[%s][%s] write error after replace user-agent: %s", destAddrPort, src.(*net.TCPConn).RemoteAddr().String(), err.Error())) logrus.Error(fmt.Sprintf("[%s][%s] write error after replace user-agent: %s", destAddrPort, src.(*net.TCPConn).RemoteAddr().String(), err.Error()))
break break
} }
statistics.AddStat(&statistics.StatRecord{
Host: destAddrPort,
OriginUA: uaStr,
MockedUA: mockedUA,
})
} }
} }