diff --git a/ipkg/CONTROL/control b/ipkg/CONTROL/control index d539668..70a4f84 100644 --- a/ipkg/CONTROL/control +++ b/ipkg/CONTROL/control @@ -1,6 +1,6 @@ Package: ua3f Version: 1.4.0-1 -Depends: luci-compat, ipset, iptables, iptables-mod-tproxy, iptables-mod-extra, iptables-mod-nat-extra, kmod-ipt-conntrack, iptables-mod-ipopt +Depends: luci-compat, ipset, iptables, iptables-mod-tproxy, iptables-mod-extra, iptables-mod-nat-extra, kmod-ipt-conntrack, iptables-mod-ipopt, iptables-mod-nfqueue, iptables-mod-conntrack-extra, kmod-nf-conntrack-netlink Source: /feed/openwrt SourceName: UA3F License: GPL-3.0-only diff --git a/ipkg/CONTROL/control-e b/ipkg/CONTROL/control-e index d539668..70a4f84 100644 --- a/ipkg/CONTROL/control-e +++ b/ipkg/CONTROL/control-e @@ -1,6 +1,6 @@ Package: ua3f Version: 1.4.0-1 -Depends: luci-compat, ipset, iptables, iptables-mod-tproxy, iptables-mod-extra, iptables-mod-nat-extra, kmod-ipt-conntrack, iptables-mod-ipopt +Depends: luci-compat, ipset, iptables, iptables-mod-tproxy, iptables-mod-extra, iptables-mod-nat-extra, kmod-ipt-conntrack, iptables-mod-ipopt, iptables-mod-nfqueue, iptables-mod-conntrack-extra, kmod-nf-conntrack-netlink Source: /feed/openwrt SourceName: UA3F License: GPL-3.0-only diff --git a/openwrt/Makefile b/openwrt/Makefile index 968aa66..331247f 100644 --- a/openwrt/Makefile +++ b/openwrt/Makefile @@ -28,7 +28,7 @@ define Package/ua3f SUBMENU:=Web Servers/Proxies TITLE:=A SOCKS5 Server for User-Agent Rewriting URL:=https://github.com/SunBK201/UA3F - DEPENDS:=$(GO_ARCH_DEPENDS) +luci-compat +ipset +iptables +iptables-mod-tproxy +iptables-mod-extra +iptables-mod-nat-extra +kmod-ipt-conntrack +iptables-mod-ipopt + DEPENDS:=$(GO_ARCH_DEPENDS) +luci-compat +ipset +iptables +iptables-mod-tproxy +iptables-mod-extra +iptables-mod-nat-extra +kmod-ipt-conntrack +iptables-mod-ipopt +iptables-mod-nfqueue +iptables-mod-conntrack-extra +kmod-nf-conntrack-netlink endef define Package/ua3f/description diff --git a/openwrt/files/luci/cbi.lua b/openwrt/files/luci/cbi.lua index e16fe36..3da8c26 100644 --- a/openwrt/files/luci/cbi.lua +++ b/openwrt/files/luci/cbi.lua @@ -37,6 +37,7 @@ server_mode:value("HTTP", "HTTP") server_mode:value("SOCKS5", "SOCKS5") server_mode:value("TPROXY", "TPROXY") server_mode:value("REDIRECT", "REDIRECT") +server_mode:value("NFQUEUE", "NFQUEUE") port = general:taboption("general", Value, "port", translate("Port")) port.placeholder = "1080" diff --git a/openwrt/files/ua3f.init b/openwrt/files/ua3f.init index 94d1860..c317ef8 100755 --- a/openwrt/files/ua3f.init +++ b/openwrt/files/ua3f.init @@ -82,6 +82,10 @@ set_ua3f_group() { UA3F_GID="0" UA3F_GROUP="root" return + elif [ $server_mode = "NFQUEUE" ]; then + UA3F_GID="0" + UA3F_GROUP="root" + return fi add_skip_gids "453" if openclash_running; then @@ -130,12 +134,22 @@ add_skip_gids() { detect_backend() { if opkg_available; then - if opkg list-installed kmod-nft-tproxy | grep -q 'kmod-nft-tproxy'; then - FW_BACKEND="nft" - return 0 - else - FW_BACKEND="ipt" - return 0 + if [ "$SERVER_MODE" = "TPROXY" ]; then + if opkg list-installed kmod-nft-tproxy | grep -q 'kmod-nft-tproxy'; then + FW_BACKEND="nft" + return 0 + else + FW_BACKEND="ipt" + return 0 + fi + elif [ "$SERVER_MODE" = "NFQUEUE" ]; then + if opkg list-installed kmod-nft-queue | grep -q 'kmod-nft-queue'; then + FW_BACKEND="nft" + return 0 + else + FW_BACKEND="ipt" + return 0 + fi fi fi if nft_available; then @@ -174,7 +188,7 @@ cleanup_tproxy_route() { nft_drop_table() { nft delete table ip "$NFT_TABLE" 2>/dev/null nft delete table inet "$UA3F_TTL_TABLE" 2>/dev/null - # nft delete table inet "$NFT_TABLE" 2>/dev/null + nft delete table inet "$NFT_TABLE" 2>/dev/null # nft delete chain inet fw4 "$NFT_TABLE" 2>/dev/null } @@ -389,6 +403,37 @@ cleanup_ipset_ipt() { ipset destroy $UA3F_LANSET 2>/dev/null } +fw_setup_nft_nfqueue() { + nft_reinit_table || { + LOG "Failed to reinitialize nft table" + return 1 + } + + nft add chain ip $NFT_TABLE postrouting '{ type filter hook postrouting priority mangle - 20; }' + nft add rule ip $NFT_TABLE postrouting meta l4proto != tcp counter return + nft add rule ip $NFT_TABLE postrouting ct direction reply counter return + nft add rule ip $NFT_TABLE postrouting ip daddr @$UA3F_LANSET counter return + nft add rule ip $NFT_TABLE postrouting tcp dport {$SKIP_PORTS} return + nft add rule ip $NFT_TABLE postrouting ct mark 201 counter return + nft add rule ip $NFT_TABLE postrouting ct direction original ct state established ip length \> 40 counter queue num 10201 bypass +} + +fw_setup_ipt_nfqueue() { + setup_ipset_ipt || return 1 + + # POSTROUTING + iptables -t mangle -F $UA3F_CHAIN 2>/dev/null + iptables -t mangle -D POSTROUTING -p tcp -j $UA3F_CHAIN 2>/dev/null + iptables -t mangle -X $UA3F_CHAIN 2>/dev/null + iptables -t mangle -N $UA3F_CHAIN + iptables -t mangle -A POSTROUTING -p tcp -j $UA3F_CHAIN + iptables -t mangle -A $UA3F_CHAIN -m conntrack --ctdir REPLY -j RETURN + iptables -t mangle -A $UA3F_CHAIN -m set --match-set $UA3F_LANSET dst -j RETURN + iptables -t mangle -A $UA3F_CHAIN -p tcp -m multiport --dports $SKIP_PORTS -j RETURN + iptables -t mangle -A $UA3F_CHAIN -m connmark --mark 201 -j RETURN + iptables -t mangle -A $UA3F_CHAIN -m conntrack --ctdir ORIGINAL --ctstate ESTABLISHED -m length --length 41:0xffff -j NFQUEUE --queue-num 10201 --queue-bypass +} + set_ttl_nft() { nft drop table inet $UA3F_TTL_TABLE 2>/dev/null nft add table inet $UA3F_TTL_TABLE || return 1 @@ -433,6 +478,10 @@ fw_revert_ipt() { iptables -t nat -D OUTPUT -p tcp -j $UA3F_OUT_CHAIN 2>/dev/null iptables -t nat -F $UA3F_OUT_CHAIN 2>/dev/null iptables -t nat -X $UA3F_OUT_CHAIN 2>/dev/null + # NFQUEUE + iptables -t mangle -D POSTROUTING -p tcp -j $UA3F_CHAIN 2>/dev/null + iptables -t mangle -F $UA3F_CHAIN 2>/dev/null + iptables -t mangle -X $UA3F_CHAIN 2>/dev/null # ipset cleanup_ipset_ipt cleanup_tproxy_route @@ -534,6 +583,19 @@ start_service() { } fi ;; + NFQUEUE) + if [ "$FW_BACKEND" = "nft" ]; then + fw_setup_nft_nfqueue || { + LOG "fw_setup_nft_nfqueue setup failed" + return 1 + } + else + fw_setup_ipt_nfqueue || { + LOG "fw_setup_ipt_nfqueue setup failed" + return 1 + } + fi + ;; *) LOG "Unsupported server_mode: $SERVER_MODE" return 1 diff --git a/src/go.mod b/src/go.mod index f0065c1..28021ad 100644 --- a/src/go.mod +++ b/src/go.mod @@ -4,12 +4,23 @@ go 1.19 require ( github.com/dlclark/regexp2 v1.11.4 + github.com/florianl/go-nfqueue/v2 v2.0.2 + github.com/google/gopacket v1.1.19 github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/mdlayher/netlink v1.7.2 github.com/sirupsen/logrus v1.9.3 golang.org/x/sys v0.30.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) +require ( + github.com/google/go-cmp v0.6.0 // indirect + github.com/josharian/native v1.1.0 // indirect + github.com/mdlayher/socket v0.4.1 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.7.0 // indirect +) + require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect diff --git a/src/go.sum b/src/go.sum index cf8e4c1..96c1a0b 100644 --- a/src/go.sum +++ b/src/go.sum @@ -3,8 +3,20 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE= +github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= @@ -13,9 +25,25 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 35135c7..cdef073 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -11,6 +11,7 @@ const ( ServerModeSocks5 = "SOCKS5" ServerModeTProxy = "TPROXY" ServerModeRedirect = "REDIRECT" + ServerModeNFQueue = "NFQUEUE" ) type Config struct { @@ -38,7 +39,7 @@ func Parse() (*Config, bool) { showVer bool ) - flag.StringVar(&serverMode, "m", ServerModeSocks5, "Server mode: HTTP, SOCKS5, TPROXY, REDIRECT") + flag.StringVar(&serverMode, "m", ServerModeSocks5, "Server mode: HTTP, SOCKS5, TPROXY, REDIRECT, NFQUEUE") flag.StringVar(&bindAddr, "b", "127.0.0.1", "Bind address") flag.IntVar(&port, "p", 1080, "Port") flag.StringVar(&loglevel, "l", "info", "Log level") diff --git a/src/internal/rewrite/packet.go b/src/internal/rewrite/packet.go new file mode 100644 index 0000000..45e7da7 --- /dev/null +++ b/src/internal/rewrite/packet.go @@ -0,0 +1,151 @@ +package rewrite + +import ( + "fmt" + + "github.com/google/gopacket/layers" + "github.com/sunbk201/ua3f/internal/log" + "github.com/sunbk201/ua3f/internal/sniff" + "github.com/sunbk201/ua3f/internal/statistics" +) + +// RewriteResult contains the result of TCP rewriting +type RewriteResult struct { + Modified bool // Whether the packet was modified + NewPayload []byte // New packet data (only if Modified is true) + HasUA bool // Whether User-Agent was found + InCache bool // Whether destination address is in cache + InWhitelist bool // Whether User-Agent was in whitelist +} + +// shouldRewriteUA checks if the given User-Agent should be rewritten +// Returns true if UA should be rewritten (not in whitelist and matches regex pattern) +func (r *Rewriter) shouldRewriteUA(srcAddr, dstAddr string, ua string) bool { + // If no pattern specified, rewrite all non-whitelist UAs + if r.pattern == "" { + return true + } + + // Check regex match + matches, err := r.uaRegex.MatchString(ua) + if err != nil { + log.LogErrorWithAddr(srcAddr, dstAddr, fmt.Sprintf("Error matching User-Agent regex: %v", err)) + return true + } + + return matches +} + +// buildReplacement creates replacement content for User-Agent +// If the original UA should not be rewritten, returns nil +// Otherwise, uses buildUserAgent logic (partial or full replace) and adjusts to length n +func (r *Rewriter) buildReplacement(srcAddr, dstAddr string, originalUA string, n int) []byte { + if n <= 0 { + return nil + } + + // Build the new UA using the same logic as in Rewrite() + newUA := r.buildUserAgent(originalUA) + + log.LogInfoWithAddr(srcAddr, dstAddr, fmt.Sprintf("Rewritten User-Agent: %s", newUA)) + statistics.AddRewriteRecord(&statistics.RewriteRecord{ + Host: dstAddr, + OriginalUA: originalUA, + MockedUA: newUA, + }) + + // Adjust to the exact length needed + if len(newUA) >= n { + return []byte(newUA[:n]) + } + out := make([]byte, n) + copy(out, newUA) + // Pad with spaces if newUA is shorter than needed + for i := len(newUA); i < n; i++ { + out[i] = ' ' + } + + return out +} + +// RewritePacketUserAgent rewrites User-Agent in a raw packet payload +// Returns the rewritten payload and metadata about the operation +func (r *Rewriter) RewritePacketUserAgent(payload []byte, srcAddr, dstAddr string) (newPayload []byte, hasUA, modified, inWhitelist bool) { + // Find all User-Agent positions + positions, unterm := sniff.FindUserAgentInPayload(payload) + + if unterm { + log.LogInfoWithAddr(srcAddr, dstAddr, "Unterminated User-Agent found, not rewriting") + return payload, true, false, false + } + + if len(positions) == 0 { + log.LogDebugWithAddr(srcAddr, dstAddr, "No User-Agent found in payload") + return payload, false, false, false + } + + // Copy payload for in-place modification + out := make([]byte, len(payload)) + copy(out, payload) + + // Replace each User-Agent value + for _, pos := range positions { + valStart, valEnd := pos[0], pos[1] + n := valEnd - valStart + if n <= 0 { + continue + } + + // Extract original UA string + originalUA := string(payload[valStart:valEnd]) + + log.LogInfoWithAddr(srcAddr, dstAddr, fmt.Sprintf("Original User-Agent: %s", originalUA)) + + // Check whitelist first + if r.inWhitelist(originalUA) { + log.LogInfoWithAddr(srcAddr, dstAddr, fmt.Sprintf("User-Agent in whitelist, not rewriting: %s", originalUA)) + statistics.AddPassThroughRecord(&statistics.PassThroughRecord{ + SrcAddr: srcAddr, + DestAddr: dstAddr, + UA: originalUA, + }) + return payload, true, false, true + } + + // Check if should rewrite + if !r.shouldRewriteUA(srcAddr, dstAddr, originalUA) { + statistics.AddPassThroughRecord(&statistics.PassThroughRecord{ + SrcAddr: srcAddr, + DestAddr: dstAddr, + UA: originalUA, + }) + return payload, true, false, false + } + + // Build replacement with regex matching + repl := r.buildReplacement(srcAddr, dstAddr, originalUA, n) + if repl != nil { + copy(out[valStart:valEnd], repl) + modified = true + } + } + + return out, true, modified, false +} + +// RewriteTCP rewrites the TCP packet's User-Agent if applicable +func (r *Rewriter) RewriteTCP(tcp *layers.TCP, srcAddr, dstAddr string) *RewriteResult { + if len(tcp.Payload) == 0 { + log.LogDebugWithAddr(srcAddr, dstAddr, "TCP payload is empty") + return &RewriteResult{Modified: false} + } + + newPayload, hasUA, modified, inWhitelist := r.RewritePacketUserAgent(tcp.Payload, srcAddr, dstAddr) + + return &RewriteResult{ + Modified: modified, + NewPayload: newPayload, + HasUA: hasUA, + InWhitelist: inWhitelist, + } +} diff --git a/src/internal/server/nfqueue/nfqueue.go b/src/internal/server/nfqueue/nfqueue.go new file mode 100644 index 0000000..0813e30 --- /dev/null +++ b/src/internal/server/nfqueue/nfqueue.go @@ -0,0 +1,272 @@ +package nfqueue + +import ( + "context" + "fmt" + "runtime" + "sync" + + nfq "github.com/florianl/go-nfqueue/v2" + "github.com/mdlayher/netlink" + "github.com/sirupsen/logrus" + + "github.com/sunbk201/ua3f/internal/config" + "github.com/sunbk201/ua3f/internal/log" + "github.com/sunbk201/ua3f/internal/rewrite" + "github.com/sunbk201/ua3f/internal/statistics" +) + +type Server struct { + cfg *config.Config + rw *rewrite.Rewriter + nf *nfq.Nfqueue + queueNum uint16 + maxQueueLen uint32 + maxPacketLen uint32 + numWorkers int + workers []chan *nfq.Attribute + wg sync.WaitGroup + SniffMarkRangeLower uint32 + SniffMarkRangeUpper uint32 + HTTPMark uint32 + NotHTTPMark uint32 +} + +func New(cfg *config.Config, rw *rewrite.Rewriter) *Server { + numWorkers := runtime.NumCPU() + if numWorkers < 2 { + numWorkers = 2 + } + + return &Server{ + cfg: cfg, + rw: rw, + queueNum: 10201, + maxQueueLen: 2000, + numWorkers: numWorkers, + SniffMarkRangeLower: 10201, + SniffMarkRangeUpper: 10216, + NotHTTPMark: 201, + HTTPMark: 202, + } +} + +// worker processes packets from its assigned channel +func (s *Server) worker(ctx context.Context, workerID int, aChan <-chan *nfq.Attribute) { + defer s.wg.Done() + + logrus.Debugf("Worker %d started", workerID) + + for { + select { + case <-ctx.Done(): + logrus.Debugf("Worker %d stopping", workerID) + return + case a, ok := <-aChan: + if !ok { + logrus.Debugf("Worker %d channel closed", workerID) + return + } + if s.cfg.DirectForward { + _ = s.nf.SetVerdict(*a.PacketID, nfq.NfAccept) + continue + } else { + s.handlePacket(a) + } + } + } +} + +func (s *Server) computeWorkerIndex(a *nfq.Attribute) int { + var flowID uint32 + if a.Ct != nil { + flowID = ctIDFromCtBytes(*a.Ct) + } else { + // Compute flow hash to determine which worker should handle this packet + flowID = computeFlowHash(*a.Payload) + } + workerIdx := int(flowID % uint32(s.numWorkers)) + return workerIdx +} + +func (s *Server) SendVerdict(packet *IPPacket, result *rewrite.RewriteResult) { + nf := s.nf + id := *packet.a.PacketID + setMark, nextMark := s.getNextMark(packet, result) + + var newPacket []byte + var err error + + if result.Modified { + newPacket, err = serializeIPPacket(packet.NetworkLayer, packet.TCP, result.NewPayload, packet.IsIPv6) + if err != nil { + _ = nf.SetVerdict(id, nfq.NfAccept) + log.LogErrorWithAddr(packet.SrcAddr, packet.DstAddr, fmt.Sprintf("serializeIPPacket failed: %v", err)) + return + } + } + + if !result.Modified { + if setMark { + nf.SetVerdictWithOption(id, nfq.NfAccept, nfq.WithConnMark(nextMark)) + } else { + _ = nf.SetVerdict(id, nfq.NfAccept) + } + } else { + if setMark { + if err := nf.SetVerdictWithOption(id, nfq.NfAccept, nfq.WithAlteredPacket(newPacket), nfq.WithConnMark(nextMark)); err != nil { + _ = nf.SetVerdict(id, nfq.NfAccept) + log.LogErrorWithAddr(packet.SrcAddr, packet.DstAddr, fmt.Sprintf("SetVerdictWithOption failed: %v", err)) + } + } else { + if err := nf.SetVerdictWithOption(id, nfq.NfAccept, nfq.WithAlteredPacket(newPacket)); err != nil { + _ = nf.SetVerdict(id, nfq.NfAccept) + log.LogErrorWithAddr(packet.SrcAddr, packet.DstAddr, fmt.Sprintf("SetVerdictWithOption failed: %v", err)) + } + } + } +} + +// handlePacket processes a single NFQUEUE packet +func (s *Server) handlePacket(a *nfq.Attribute) { + nf := s.nf + if ok, verdict := packetAttributeSanityCheck(a); !ok { + if a.PacketID != nil { + _ = nf.SetVerdict(*a.PacketID, verdict) + } + return + } + packet, err := decodeIPPacket(a) + if err != nil { + _ = nf.SetVerdict(*a.PacketID, nfq.NfAccept) + return + } + if s.rw.Cache.Contains(packet.DstAddr) { + s.SendVerdict(packet, &rewrite.RewriteResult{Modified: false, InCache: true}) + log.LogDebugWithAddr(packet.SrcAddr, packet.DstAddr, "Destination in cache, skipping User-Agent rewrite") + return + } + result := s.rw.RewriteTCP(packet.TCP, packet.SrcAddr, packet.DstAddr) + s.SendVerdict(packet, result) +} + +func (s *Server) getNextMark(packet *IPPacket, result *rewrite.RewriteResult) (setMark bool, mark uint32) { + if packet.a.Mark == nil { + return true, s.SniffMarkRangeLower + } + + mark = *packet.a.Mark + + // should not happen + if mark == s.NotHTTPMark { + return false, 0 + } + + if mark == s.HTTPMark { + return false, 0 + } + + if result.InCache { + return true, s.NotHTTPMark + } + + if result.InWhitelist { + return true, s.NotHTTPMark + } + + if result.Modified { + return true, s.HTTPMark + } + + if mark == 0 { + return true, s.SniffMarkRangeLower + } + + if mark == s.SniffMarkRangeUpper { + s.rw.Cache.Add(packet.DstAddr, struct{}{}) + return true, s.NotHTTPMark + } + + if mark >= s.SniffMarkRangeLower && mark < s.SniffMarkRangeUpper { + return true, mark + 1 + } + + return false, 0 +} + +func (s *Server) Start() (err error) { + logrus.Infof("Starting NFQUEUE mode on queue %d with %d workers...", s.queueNum, s.numWorkers) + + config := nfq.Config{ + NfQueue: s.queueNum, + MaxQueueLen: s.maxQueueLen, + MaxPacketLen: s.maxPacketLen, + Copymode: nfq.NfQnlCopyPacket, + Flags: nfq.NfQaCfgFlagConntrack, + } + + nf, err := nfq.Open(&config) + if err != nil { + return fmt.Errorf("could not open nfqueue socket: %w", err) + } + defer nf.Close() + s.nf = nf + + // Ignore ENOBUFS to prevent queue drop logs + if err := nf.SetOption(netlink.NoENOBUFS, true); err != nil { + return fmt.Errorf("failed to set netlink option: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Initialize worker channels and start worker goroutines + s.workers = make([]chan *nfq.Attribute, s.numWorkers) + for i := 0; i < s.numWorkers; i++ { + s.workers[i] = make(chan *nfq.Attribute, 2000) + s.wg.Add(1) + go s.worker(ctx, i, s.workers[i]) + } + + // Register callback function + err = nf.RegisterWithErrorFunc(ctx, + func(a nfq.Attribute) int { + select { + case s.workers[s.computeWorkerIndex(&a)] <- &a: + default: + // If worker channel is full, accept the packet to avoid blocking + logrus.Warn("Worker channel full, accepting packet without processing") + if a.PacketID != nil { + _ = nf.SetVerdict(*a.PacketID, nfq.NfAccept) + } + } + return 0 + }, + func(e error) int { + logrus.Errorf("Error in nfqueue handler: %v", e) + return 0 + }, + ) + if err != nil { + // Close all worker channels + for i := 0; i < s.numWorkers; i++ { + close(s.workers[i]) + } + s.wg.Wait() + return fmt.Errorf("failed to register nfqueue handler: %w", err) + } + + logrus.Info("NFQUEUE handler registered, listening for packets") + + go statistics.StartRecorder() + + <-ctx.Done() + + // Cleanup: close all worker channels and wait for workers to finish + for i := 0; i < s.numWorkers; i++ { + close(s.workers[i]) + } + s.wg.Wait() + + return nil +} diff --git a/src/internal/server/nfqueue/packet.go b/src/internal/server/nfqueue/packet.go new file mode 100644 index 0000000..6a3d6ae --- /dev/null +++ b/src/internal/server/nfqueue/packet.go @@ -0,0 +1,168 @@ +package nfqueue + +import ( + "encoding/binary" + "fmt" + "hash/fnv" + + nfq "github.com/florianl/go-nfqueue/v2" + "github.com/google/gopacket" + "github.com/google/gopacket/layers" + "github.com/mdlayher/netlink" +) + +type IPPacket struct { + a *nfq.Attribute + NetworkLayer gopacket.NetworkLayer + TCP *layers.TCP + SrcAddr string + DstAddr string + IsIPv6 bool +} + +func packetAttributeSanityCheck(a *nfq.Attribute) (ok bool, verdict int) { + if a.PacketID == nil { + return false, -1 + } + if a.Payload == nil || len(*a.Payload) < 40 { + return false, nfq.NfAccept + } + return true, 0 +} + +// computeFlowHash computes a hash value based on TCP 4-tuple to ensure packets +// from the same TCP stream are handled by the same worker goroutine +func computeFlowHash(pktData []byte) uint32 { + version := (pktData[0] >> 4) & 0xF + + h := fnv.New32a() + + switch version { + case 4: + // IPv4: IP header is at least 20 bytes + if len(pktData) < 20 { + return 0 + } + + // Source IP (bytes 12-15) and Dest IP (bytes 16-19) + h.Write(pktData[12:20]) + + // Check if it's TCP (protocol 6) + protocol := pktData[9] + if protocol == 6 { + // IHL (IP Header Length) is in the lower 4 bits of byte 0 + ihl := (pktData[0] & 0x0F) * 4 + if len(pktData) >= int(ihl)+4 { + // TCP source port and dest port (first 4 bytes of TCP header) + h.Write(pktData[ihl : ihl+4]) + } + } + + case 6: + // IPv6: IP header is at least 40 bytes + if len(pktData) < 40 { + return 0 + } + + // Source IP (bytes 8-23) and Dest IP (bytes 24-39) + h.Write(pktData[8:40]) + + // Check if it's TCP (next header 6) + nextHeader := pktData[6] + if nextHeader == 6 && len(pktData) >= 44 { + // TCP source port and dest port (first 4 bytes of TCP header at offset 40) + h.Write(pktData[40:44]) + } + } + + return h.Sum32() +} + +func ctIDFromCtBytes(ct []byte) uint32 { + ctAttrs, err := netlink.UnmarshalAttributes(ct) + if err != nil { + return 0 + } + for _, attr := range ctAttrs { + if attr.Type == 12 { // CTA_ID + return binary.BigEndian.Uint32(attr.Data) + } + } + return 0 +} + +// decodeIPPacket decodes IP packet and extracts TCP layer +func decodeIPPacket(a *nfq.Attribute) (ipPacket *IPPacket, err error) { + ipPacket = &IPPacket{ + a: a, + } + + var decoded []gopacket.LayerType + var layerType gopacket.LayerType + var ipLayer gopacket.DecodingLayer + + ipPacket.TCP = &layers.TCP{} + + pktData := *a.Payload + version := (pktData[0] >> 4) & 0xF + ipPacket.IsIPv6 = version == 6 + + if ipPacket.IsIPv6 { + ip6 := &layers.IPv6{} + layerType = layers.LayerTypeIPv6 + ipLayer = ip6 + ipPacket.NetworkLayer = ip6 + } else { + ip4 := &layers.IPv4{} + layerType = layers.LayerTypeIPv4 + ipLayer = ip4 + ipPacket.NetworkLayer = ip4 + } + + parser := gopacket.NewDecodingLayerParser(layerType, ipLayer, ipPacket.TCP) + parser.IgnoreUnsupported = true + + if err = parser.DecodeLayers(pktData, &decoded); err != nil { + return + } + + if ipPacket.IsIPv6 { + ip6 := ipPacket.NetworkLayer.(*layers.IPv6) + ipPacket.SrcAddr = fmt.Sprintf("%s:%d", ip6.SrcIP.String(), ipPacket.TCP.SrcPort) + ipPacket.DstAddr = fmt.Sprintf("%s:%d", ip6.DstIP.String(), ipPacket.TCP.DstPort) + } else { + ip4 := ipPacket.NetworkLayer.(*layers.IPv4) + ipPacket.SrcAddr = fmt.Sprintf("%s:%d", ip4.SrcIP.String(), ipPacket.TCP.SrcPort) + ipPacket.DstAddr = fmt.Sprintf("%s:%d", ip4.DstIP.String(), ipPacket.TCP.DstPort) + } + return +} + +// serializeIPPacket serializes IP packet with modified TCP payload +func serializeIPPacket(networkLayer gopacket.NetworkLayer, tcp *layers.TCP, newPayload []byte, isIPv6 bool) ([]byte, error) { + buffer := gopacket.NewSerializeBuffer() + serOpts := gopacket.SerializeOptions{ + FixLengths: true, + ComputeChecksums: true, + } + + tcp.Checksum = 0 + tcp.Payload = nil + tcp.SetNetworkLayerForChecksum(networkLayer) + + var err error + if isIPv6 { + ip6 := networkLayer.(*layers.IPv6) + ip6.NextHeader = layers.IPProtocolTCP + err = gopacket.SerializeLayers(buffer, serOpts, ip6, tcp, gopacket.Payload(newPayload)) + } else { + ip4 := networkLayer.(*layers.IPv4) + ip4.Checksum = 0 + err = gopacket.SerializeLayers(buffer, serOpts, ip4, tcp, gopacket.Payload(newPayload)) + } + + if err != nil { + return nil, err + } + return buffer.Bytes(), nil +} diff --git a/src/internal/server/server.go b/src/internal/server/server.go index 7116c55..ff4c8c6 100644 --- a/src/internal/server/server.go +++ b/src/internal/server/server.go @@ -2,11 +2,11 @@ package server import ( "fmt" - "net" "github.com/sunbk201/ua3f/internal/config" "github.com/sunbk201/ua3f/internal/rewrite" "github.com/sunbk201/ua3f/internal/server/http" + "github.com/sunbk201/ua3f/internal/server/nfqueue" "github.com/sunbk201/ua3f/internal/server/redirect" "github.com/sunbk201/ua3f/internal/server/socks5" "github.com/sunbk201/ua3f/internal/server/tproxy" @@ -14,8 +14,6 @@ import ( type Server interface { Start() error - HandleClient(net.Conn) - ForwardTCP(client, target net.Conn, destAddr string) } func NewServer(cfg *config.Config, rw *rewrite.Rewriter) (Server, error) { @@ -28,6 +26,8 @@ func NewServer(cfg *config.Config, rw *rewrite.Rewriter) (Server, error) { return tproxy.New(cfg, rw), nil case config.ServerModeRedirect: return redirect.New(cfg, rw), nil + case config.ServerModeNFQueue: + return nfqueue.New(cfg, rw), nil default: return nil, fmt.Errorf("unknown server mode: %s", cfg.ServerMode) } diff --git a/src/internal/sniff/packet.go b/src/internal/sniff/packet.go new file mode 100644 index 0000000..96cff42 --- /dev/null +++ b/src/internal/sniff/packet.go @@ -0,0 +1,97 @@ +package sniff + +import ( + "bytes" +) + +var ( + // HTTP User-Agent header tag (case-sensitive search optimized) + uaTag = []byte("\r\nUser-Agent:") +) + +// toLowerASCII converts an ASCII byte to lowercase (only A-Z) +func toLowerASCII(b byte) byte { + if b >= 'A' && b <= 'Z' { + return b + 32 + } + return b +} + +// IndexFoldASCII performs case-insensitive search for needle in haystack (ASCII only) +// Returns the first occurrence index or -1 if not found +func IndexFoldASCII(haystack, needle []byte) int { + if len(needle) == 0 { + return 0 + } + if len(haystack) < len(needle) { + return -1 + } + n0 := toLowerASCII(needle[0]) + limit := len(haystack) - len(needle) + for i := 0; i <= limit; i++ { + if toLowerASCII(haystack[i]) != n0 { + continue + } + match := true + for j := 1; j < len(needle); j++ { + if toLowerASCII(haystack[i+j]) != toLowerASCII(needle[j]) { + match = false + break + } + } + if match { + return i + } + } + return -1 +} + +// FindUserAgentInPayload searches for User-Agent header(s) in raw HTTP payload +// Returns slice of (startPos, endPos) pairs for each User-Agent value found +// Returns empty slice if no User-Agent found, or if any UA is unterminated (missing \r) +func FindUserAgentInPayload(payload []byte) (positions [][2]int, unterminated bool) { + if len(payload) < len(uaTag) { + return nil, false + } + + searchStart := 0 + for { + if len(payload)-searchStart < len(uaTag) { + break + } + + idx := IndexFoldASCII(payload[searchStart:], uaTag) + if idx < 0 { + break + } + + uaKeyPos := searchStart + idx + valStart := uaKeyPos + len(uaTag) + + // Support both "User-Agent:XXX" and "User-Agent: XXX" (with or without space) + if valStart < len(payload) && payload[valStart] == ' ' { + valStart++ + } + if valStart >= len(payload) { + // UA at the end of payload, no \r found + return nil, true + } + + // Find line ending position: look for \r + relEnd := bytes.IndexByte(payload[valStart:], '\r') + if relEnd < 0 { + // No \r found, UA is unterminated + return nil, true + } + valEnd := valStart + relEnd + + if valEnd > valStart { + positions = append(positions, [2]int{valStart, valEnd}) + } + + // Continue searching for more UA headers + searchStart = valEnd + } + + return positions, false +}