feat: support nfqueue mode

This commit is contained in:
SunBK201 2025-11-07 22:08:34 +08:00
parent 43c01ba545
commit f5a4238c73
13 changed files with 805 additions and 14 deletions

View File

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

View File

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

View File

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

View File

@ -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"

View File

@ -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,6 +134,7 @@ add_skip_gids() {
detect_backend() {
if opkg_available; then
if [ "$SERVER_MODE" = "TPROXY" ]; then
if opkg list-installed kmod-nft-tproxy | grep -q 'kmod-nft-tproxy'; then
FW_BACKEND="nft"
return 0
@ -137,6 +142,15 @@ detect_backend() {
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
FW_BACKEND="nft"
@ -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

View File

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

View File

@ -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=

View File

@ -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")

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}