feat: add connection statistics tracking

This commit is contained in:
SunBK201 2025-11-06 15:29:02 +08:00
parent a392e6272a
commit a44f772c99
8 changed files with 250 additions and 14 deletions

1
.gitignore vendored
View File

@ -25,3 +25,4 @@ bin/
dist/
ipkg/etc/
ipkg/usr/
test/

View File

@ -23,6 +23,18 @@ if pass_stats_file then
pass_stats_file:close()
end
local conn_stats = {}
local conn_stats_file = io.open("/var/log/ua3f/conn_stats", "r")
if conn_stats_file then
for line in conn_stats_file:lines() do
local protocol, srcAddr, destAddr, duration = line:match("^(%S+)%s(%S+)%s(%S+)%s(.+)$")
if protocol and srcAddr and destAddr and duration then
table.insert(conn_stats, {protocol = protocol, srcAddr = srcAddr, destAddr = destAddr, duration = duration})
end
end
conn_stats_file:close()
end
local function rowstyle(i)
return (i % 2 == 0) and "cbi-rowstyle-2" or "cbi-rowstyle-1"
end
@ -30,13 +42,13 @@ end
<h3><%:Statistics%></h3>
<div class="cbi-section-descr"><%:User-Agent Rewrite Statistics%></div>
<div class="cbi-section-descr" style="font-weight:bold;"><%:User-Agent Rewrite Statistics%></div>
<table id="rewrite-stats-table" class="table cbi-section-table">
<tr class="tr table-titles">
<th class="th"><%:Host%></th>
<th class="th"><%:Rewrite Count%></th>
<th class="th"><%:Original User-Agent%></th>
<th class="th"><%:Modified User-Agent%></th>
<th class="th" data-sortable-row="true"><%:Host%></th>
<th class="th" data-sortable-row="true"><%:Rewrite Count%></th>
<th class="th" data-sortable-row="true"><%:Original User-Agent%></th>
<th class="th" data-sortable-row="true"><%:Modified User-Agent%></th>
</tr>
<% for i, item in ipairs(rewrite_stats) do %>
@ -49,13 +61,13 @@ end
<% end %>
</table>
<div class="cbi-section-descr"><%:User-Agent Pass-Through Statistics%></div>
<div class="cbi-section-descr" style="font-weight:bold;"><%:User-Agent Pass-Through Statistics%></div>
<table id="pass-stats-table" class="table cbi-section-table">
<tr class="tr table-titles">
<th class="th"><%:User-Agent%></th>
<th class="th"><%:Pass-Through Count%></th>
<th class="th"><%:Last Source Address%></th>
<th class="th"><%:Last Destination Address%></th>
<th class="th" data-sortable-row="true"><%:User-Agent%></th>
<th class="th" data-sortable-row="true"><%:Pass-Through Count%></th>
<th class="th" data-sortable-row="true"><%:Last Source Address%></th>
<th class="th" data-sortable-row="true"><%:Last Destination Address%></th>
</tr>
<% for i, item in ipairs(pass_stats) do %>
@ -68,6 +80,30 @@ end
<% end %>
</table>
<div class="cbi-section-descr" style="font-weight:bold;"><%:Connection Statistics%></div>
<table id="conn-stats-table" class="table cbi-section-table">
<tr>
<td colspan="4" style="padding:5px 0;">
<%:Total Connections%>: <%= #conn_stats %>
</td>
</tr>
<tr class="tr table-titles">
<th class="th" data-sortable-row="true"><%:Protocol%></th>
<th class="th" data-sortable-row="true"><%:Source Address%></th>
<th class="th" data-sortable-row="true"><%:Destination Address%></th>
<th class="th" data-sortable-row="true"><%:Duration%></th>
</tr>
<% for i, item in ipairs(conn_stats) do %>
<tr class="tr <%= rowstyle(i) %>">
<td class="td" data-title="<%:Protocol%>"><span><%= item.protocol %></span></td>
<td class="td" data-title="<%:Source Address%>"><span><%= item.srcAddr %></span></td>
<td class="td" data-title="<%:Destination Address%>"><span><%= item.destAddr %></span></td>
<td class="td" data-title="<%:Duration%>"><span><%= item.duration %></span></td>
</tr>
<% end %>
</table>
<script type="text/javascript">
async function updateStats() {
@ -79,6 +115,7 @@ end
const doc = parser.parseFromString(text, "text/html");
const newRewriteTable = doc.querySelector("#rewrite-stats-table");
const newPassTable = doc.querySelector("#pass-stats-table");
const newConnTable = doc.querySelector("#conn-stats-table");
const newLog = doc.querySelector("#cbid\\.ua3f\\.main\\.log");
if (newRewriteTable) {
@ -87,6 +124,9 @@ end
if (newPassTable) {
document.querySelector("#pass-stats-table").innerHTML = newPassTable.innerHTML;
}
if (newConnTable) {
document.querySelector("#conn-stats-table").innerHTML = newConnTable.innerHTML;
}
if (newLog) {
document.querySelector("#cbid\\.ua3f\\.main\\.log").value = newLog.value;
}
@ -140,4 +180,25 @@ end
#pass-stats-table td:nth-child(4) {
width: 30%;
}
#conn-stats-table th:nth-child(1),
#conn-stats-table td:nth-child(1) {
width: 15%;
}
#conn-stats-table th:nth-child(2),
#conn-stats-table td:nth-child(2) {
width: 30%;
}
#conn-stats-table th:nth-child(3),
#conn-stats-table td:nth-child(3) {
width: 40%;
}
#conn-stats-table th:nth-child(4),
#conn-stats-table td:nth-child(4) {
width: 15%;
text-align: center;
}
</style>

View File

@ -109,3 +109,21 @@ msgstr "固定 TTL"
msgid "Set the TTL 64 for packets"
msgstr "固定数据包的 TTL"
msgid "Connection Statistics"
msgstr "连接实时统计"
msgid "Protocol"
msgstr "嗅探协议"
msgid "Source Address"
msgstr "源地址"
msgid "Destination Address"
msgstr "目标地址"
msgid "Duration"
msgstr "持续时间"
msgid "Total Connections"
msgstr "连接总数"

View File

@ -160,6 +160,11 @@ func (r *Rewriter) Process(dst net.Conn, src net.Conn, destAddr string, srcAddr
if strings.HasSuffix(destAddr, "443") && sniff.SniffTLSClientHello(reader) {
r.Cache.Add(destAddr, struct{}{})
log.LogInfoWithAddr(srcAddr, destAddr, "tls client hello detected, added to cache")
statistics.AddConnection(&statistics.ConnectionRecord{
Protocol: sniff.HTTPS,
SrcAddr: srcAddr,
DestAddr: destAddr,
})
return
}
@ -172,14 +177,34 @@ func (r *Rewriter) Process(dst net.Conn, src net.Conn, destAddr string, srcAddr
if !isHTTP {
r.Cache.Add(destAddr, struct{}{})
log.LogInfoWithAddr(srcAddr, destAddr, "sniff first request is not http, added to cache, switching to raw proxy")
if sniff.SniffTLSClientHello(reader) {
statistics.AddConnection(&statistics.ConnectionRecord{
Protocol: sniff.TLS,
SrcAddr: srcAddr,
DestAddr: destAddr,
})
}
return
}
statistics.AddConnection(&statistics.ConnectionRecord{
Protocol: sniff.HTTP,
SrcAddr: srcAddr,
DestAddr: destAddr,
})
var req *http.Request
for {
if isHTTP, err = sniff.SniffHTTPFast(reader); err != nil {
err = fmt.Errorf("sniff.SniffHTTPFast: %w", err)
statistics.AddConnection(
&statistics.ConnectionRecord{
Protocol: sniff.TCP,
SrcAddr: srcAddr,
DestAddr: destAddr,
},
)
return
}
if !isHTTP {
@ -199,6 +224,11 @@ func (r *Rewriter) Process(dst net.Conn, src net.Conn, destAddr string, srcAddr
}
if req.Header.Get("Upgrade") == "websocket" && req.Header.Get("Connection") == "Upgrade" {
log.LogInfoWithAddr(srcAddr, destAddr, "websocket upgrade detected, switching to raw proxy")
statistics.AddConnection(&statistics.ConnectionRecord{
Protocol: sniff.WebSocket,
SrcAddr: srcAddr,
DestAddr: destAddr,
})
return
}
}

View File

@ -4,10 +4,13 @@ import (
"fmt"
"io"
"net"
"time"
"github.com/sirupsen/logrus"
"github.com/sunbk201/ua3f/internal/log"
"github.com/sunbk201/ua3f/internal/rewrite"
"github.com/sunbk201/ua3f/internal/sniff"
"github.com/sunbk201/ua3f/internal/statistics"
)
var one = make([]byte, 1)
@ -43,6 +46,8 @@ func CopyHalf(dst, src net.Conn) {
// ProxyHalf runs the rewriter proxy on src->dst and then half-closes both sides.
func ProxyHalf(dst, src net.Conn, rw *rewrite.Rewriter, destAddr string) {
srcAddr := src.RemoteAddr().String()
defer func() {
if tc, ok := dst.(*net.TCPConn); ok {
_ = tc.CloseWrite()
@ -55,11 +60,18 @@ func ProxyHalf(dst, src net.Conn, rw *rewrite.Rewriter, destAddr string) {
_ = src.Close()
}
log.LogDebugWithAddr(src.RemoteAddr().String(), destAddr, "Connections half-closed")
defer statistics.RemoveConnection(srcAddr, destAddr)
}()
// Fast path: known pass-through
srcAddr := src.RemoteAddr().String()
statistics.AddConnection(&statistics.ConnectionRecord{
Protocol: sniff.TCP,
SrcAddr: srcAddr,
DestAddr: destAddr,
StartTime: time.Now(),
})
if rw.Cache.Contains(destAddr) {
// Fast path: known pass-through
log.LogInfoWithAddr(srcAddr, destAddr, fmt.Sprintf("destination (%s) in cache, passing through", destAddr))
io.CopyBuffer(dst, src, one)
return

View File

@ -7,6 +7,17 @@ import (
"io"
)
// Protocol sniffed protocol types
type Protocol string
const (
TCP Protocol = "TCP"
HTTP Protocol = "HTTP"
HTTPS Protocol = "HTTPS"
TLS Protocol = "TLS"
WebSocket Protocol = "WebSocket"
)
var ErrPeekTimeout = errors.New("peek timeout")
// peekLineSlice reads a line from bufio.Reader without consuming it.

View File

@ -0,0 +1,77 @@
package statistics
import (
"fmt"
"os"
"sort"
"time"
"github.com/sirupsen/logrus"
"github.com/sunbk201/ua3f/internal/sniff"
)
const connStatsFile = "/var/log/ua3f/conn_stats"
type ConnectionRecord struct {
Protocol sniff.Protocol
SrcAddr string
DestAddr string
StartTime time.Time
}
type ConnectionAction struct {
Action Action
Key string
Record ConnectionRecord
}
var connectionRecords = make(map[string]*ConnectionRecord)
// AddConnection adds or updates a connection record
func AddConnection(record *ConnectionRecord) {
select {
case connectionActionChan <- ConnectionAction{
Action: Add,
Key: fmt.Sprintf("%s-%s", record.SrcAddr, record.DestAddr),
Record: *record,
}:
default:
}
}
// RemoveConnection removes a connection record
func RemoveConnection(srcAddr, destAddr string) {
select {
case connectionActionChan <- ConnectionAction{
Action: Remove,
Key: fmt.Sprintf("%s-%s", srcAddr, destAddr),
}:
default:
}
}
func dumpConnectionRecords() {
f, err := os.Create(connStatsFile)
if err != nil {
logrus.Errorf("create conn stats file error: %v", err)
return
}
defer f.Close()
var statList []ConnectionRecord
for _, record := range connectionRecords {
statList = append(statList, *record)
}
// Sort by start time (newest first)
sort.SliceStable(statList, func(i, j int) bool {
return statList[i].StartTime.After(statList[j].StartTime)
})
for _, record := range statList {
duration := time.Since(record.StartTime)
line := fmt.Sprintf("%s %s %s %s\n",
record.Protocol, record.SrcAddr, record.DestAddr, duration.Round(time.Second))
f.WriteString(line)
}
}

View File

@ -8,6 +8,15 @@ import (
var (
rewriteRecordChan = make(chan RewriteRecord, 2000)
passThroughRecordChan = make(chan PassThroughRecord, 2000)
connectionActionChan = make(chan ConnectionAction, 2000)
)
// Actions for recording connection statistics
type Action int
const (
Add Action = iota
Remove
)
func StartRecorder() {
@ -45,9 +54,26 @@ func StartRecorder() {
Count: 1,
}
}
case action := <-connectionActionChan:
switch action.Action {
case Add:
if r, exists := connectionRecords[action.Key]; exists {
r.Protocol = action.Record.Protocol
} else {
connectionRecords[action.Key] = &ConnectionRecord{
Protocol: action.Record.Protocol,
SrcAddr: action.Record.SrcAddr,
DestAddr: action.Record.DestAddr,
StartTime: action.Record.StartTime,
}
}
case Remove:
delete(connectionRecords, action.Key)
}
case <-ticker.C:
dumpRewriteRecords()
dumpPassThroughRecords()
dumpConnectionRecords()
}
}
}