feat: support http mode

This commit is contained in:
SunBK201 2025-11-03 18:25:50 +08:00
parent 29840b64fb
commit 2c8763a918
5 changed files with 199 additions and 64 deletions

View File

@ -1,10 +1,10 @@
# UA3F
UA3F 是下一代 HTTP User-Agent 重写工具,作为一个 SOCKS5/TPROXY/REDIRECT 服务部署在路由器等设备进行透明 User-Agent 重写。
UA3F 是下一代 HTTP User-Agent 重写工具,作为一个 HTTP、SOCKS5、TPROXY、REDIRECT 代理服务对 HTTP 进行 User-Agent 透明重写。
## 特性
- 支持多种服务模式SOCKS5、TPROXY、REDIRECT
- 支持多种服务模式:HTTP、SOCKS5、TPROXY、REDIRECT
- 支持正则表达式规则匹配重写 User-Agent
- 自定义重写 User-Agent 内容
- LRU 高速缓存非 HTTP 流量,加速非 HTTP 流量转发
@ -56,7 +56,7 @@ UA3F 支持 LuCI Web 页面,可以打开 Services -> UA3F 进行相关配置
> [!NOTE]
> 设置说明:
>
> - Server Mode (服务模式): 支持 `SOCKS5`、`TPROXY`、`REDIRECT` 三种模式,默认 `SOCKS5`
> - Server Mode (服务模式): 支持 `HTTP`、`SOCKS5`、`TPROXY`、`REDIRECT`,默认 `SOCKS5`
> - Port (监听端口): 默认 `1080`
> - Bind Address (绑定地址): 默认 `0.0.0.0`
> - Log Level (日志等级): 默认 `error`, 如果需要调试排查错误可以设置为 `debug`
@ -87,7 +87,7 @@ sudo -u shellcrash /usr/bin/ua3f
相关命令行启动参数:
- `-m <mode>`: 服务模式,支持 SOCKS5、TPROXY、REDIRECT默认 SOCKS5
- `-m <mode>`: 服务模式,支持 HTTP、SOCKS5、TPROXY、REDIRECT默认 SOCKS5
- `-b <bind addr>`: 自定义绑定监听地址,默认 127.0.0.1
- `-p <port>`: 端口号,默认 1080
- `-l <log level>`: 日志等级,默认 info可选debug默认日志位置`/var/log/ua3f.log`

View File

@ -7,6 +7,7 @@ import (
)
const (
ServerModeHTTP = "HTTP"
ServerModeSocks5 = "SOCKS5"
ServerModeTProxy = "TPROXY"
ServerModeRedirect = "REDIRECT"
@ -35,13 +36,13 @@ func Parse() (*Config, bool) {
showVer bool
)
flag.StringVar(&serverMode, "m", ServerModeSocks5, "server mode: SOCKS5, TPROXY, REDIRECT (default: SOCKS5)")
flag.StringVar(&serverMode, "m", ServerModeSocks5, "server mode: HTTP, SOCKS5, TPROXY, REDIRECT (default: SOCKS5)")
flag.StringVar(&bindAddr, "b", "127.0.0.1", "bind address (default: 127.0.0.1)")
flag.IntVar(&port, "p", 1080, "port")
flag.StringVar(&payloadUA, "f", "FFF", "User-Agent")
flag.StringVar(&uaPattern, "r", "", "UA-Pattern")
flag.BoolVar(&partial, "s", false, "Enable Regex Partial Replace")
flag.StringVar(&loglevel, "l", "error", "Log level (default: error)")
flag.StringVar(&loglevel, "l", "info", "Log level (default: info)")
flag.BoolVar(&showVer, "v", false, "show version")
flag.Parse()

View File

@ -83,6 +83,74 @@ func New(cfg *config.Config) (*Rewriter, error) {
}, nil
}
// RewriteAndForward rewrites the User-Agent header if needed and forwards the request.
// Returns pass=true if the request has been forwarded as-is (no rewrite).
func (r *Rewriter) RewriteAndForward(dst net.Conn, req *http.Request, destAddr string, srcAddr string) (pass bool, err error) {
originalUA := req.Header.Get("User-Agent")
// No UA header: pass-through after writing this first request
if originalUA == "" {
r.Cache.Add(destAddr, destAddr)
log.LogDebugWithAddr(srcAddr, destAddr, "Not found User-Agent, Add LRU Relay Cache")
if err = req.Write(dst); err != nil {
err = fmt.Errorf("req.Write: %w", err)
}
pass = true
return
}
log.LogInfoWithAddr(srcAddr, destAddr, fmt.Sprintf("Original User-Agent: %s", originalUA))
isWhitelist := r.inWhitelist(originalUA)
matches := true
if r.pattern != "" {
matches, err = r.uaRegex.MatchString(originalUA)
if err != nil {
log.LogErrorWithAddr(srcAddr, destAddr, fmt.Sprintf("User-Agent Regex Pattern Match Error: %s", err.Error()))
matches = true
}
}
// If UA is whitelisted or does not match target pattern, write once then pass-through.
if isWhitelist || !matches {
if !matches {
log.LogDebugWithAddr(srcAddr, destAddr, fmt.Sprintf("Not Hit User-Agent Regex: %s", originalUA))
}
if isWhitelist {
log.LogInfoWithAddr(srcAddr, destAddr, fmt.Sprintf("Hit User-Agent Whitelist: %s", originalUA))
r.Cache.Add(destAddr, destAddr)
}
statistics.AddPassThroughRecord(&statistics.PassThroughRecord{
Host: destAddr,
UA: originalUA,
})
if err = req.Write(dst); err != nil {
err = fmt.Errorf("req.Write: %w", err)
}
pass = true
return
}
// Rewrite UA and forward the request (including body)
rewritedUA := r.buildNewUA(originalUA)
log.LogInfoWithAddr(srcAddr, destAddr, fmt.Sprintf("Rewrite User-Agent from (%s) to (%s)", originalUA, rewritedUA))
req.Header.Set("User-Agent", rewritedUA)
if err = req.Write(dst); err != nil {
err = fmt.Errorf("req.Write: %w", err)
pass = true
return
}
statistics.AddRewriteRecord(&statistics.RewriteRecord{
Host: destAddr,
OriginalUA: originalUA,
MockedUA: rewritedUA,
})
return false, nil
}
// ProxyHTTPOrRaw reads traffic from src and writes to dst.
// - If target in LRU cache: pass-through (raw).
// - Else if HTTP: rewrite UA (unless whitelisted or pattern not matched).
@ -114,11 +182,12 @@ func (r *Rewriter) ProxyHTTPOrRaw(dst net.Conn, src net.Conn, destAddr string, s
}
var req *http.Request
var pass bool
// HTTP request loop (handles keep-alive)
for {
isHTTP, err = r.isHTTP(reader)
if err != nil {
if isHTTP, err = r.isHTTP(reader); err != nil {
err = fmt.Errorf("isHTTP: %w", err)
return
}
@ -132,67 +201,13 @@ func (r *Rewriter) ProxyHTTPOrRaw(dst net.Conn, src net.Conn, destAddr string, s
}
return
}
req, err = http.ReadRequest(reader)
if err != nil {
if req, err = http.ReadRequest(reader); err != nil {
err = fmt.Errorf("http.ReadRequest: %w", err)
return
}
originalUA := req.Header.Get("User-Agent")
// No UA header: pass-through after writing this first request
if originalUA == "" {
r.Cache.Add(destAddr, destAddr)
log.LogDebugWithAddr(srcAddr, destAddr, "Not found User-Agent, Add LRU Relay Cache")
if err = req.Write(dst); err != nil {
err = fmt.Errorf("req.Write: %w", err)
}
if pass, err = r.RewriteAndForward(dst, req, destAddr, srcAddr); pass {
return
}
isWhitelist := r.inWhitelist(originalUA)
matches := true
if r.pattern != "" {
matches, err = r.uaRegex.MatchString(originalUA)
if err != nil {
log.LogErrorWithAddr(srcAddr, destAddr, fmt.Sprintf("User-Agent Regex Pattern Match Error: %s", err.Error()))
matches = true
}
}
// If UA is whitelisted or does not match target pattern, write once then pass-through.
if isWhitelist || !matches {
if !matches {
log.LogDebugWithAddr(srcAddr, destAddr, fmt.Sprintf("Not Hit User-Agent Regex: %s", originalUA))
}
if isWhitelist {
log.LogDebugWithAddr(srcAddr, destAddr, fmt.Sprintf("Hit User-Agent Whitelist: %s", originalUA))
r.Cache.Add(destAddr, destAddr)
}
statistics.AddPassThroughRecord(&statistics.PassThroughRecord{
Host: destAddr,
UA: originalUA,
})
if err = req.Write(dst); err != nil {
err = fmt.Errorf("req.Write: %w", err)
}
return
}
// Rewrite UA and forward the request (including body)
log.LogDebugWithAddr(srcAddr, destAddr, fmt.Sprintf("Hit User-Agent: %s", originalUA))
mockedUA := r.buildNewUA(originalUA)
req.Header.Set("User-Agent", mockedUA)
if err = req.Write(dst); err != nil {
err = fmt.Errorf("req.Write: %w", err)
return
}
statistics.AddRewriteRecord(&statistics.RewriteRecord{
Host: destAddr,
OriginalUA: originalUA,
MockedUA: mockedUA,
})
}
}

View File

@ -0,0 +1,116 @@
package http
import (
"bufio"
"fmt"
"io"
"net"
"net/http"
"github.com/sirupsen/logrus"
"github.com/sunbk201/ua3f/internal/config"
"github.com/sunbk201/ua3f/internal/rewrite"
"github.com/sunbk201/ua3f/internal/server/utils"
)
type Server struct {
cfg *config.Config
rw *rewrite.Rewriter
}
func New(cfg *config.Config, rw *rewrite.Rewriter) *Server {
return &Server{
cfg: cfg,
rw: rw,
}
}
func (s *Server) Start() (err error) {
server := &http.Server{
Addr: s.cfg.ListenAddr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodConnect {
s.handleTunneling(w, req)
} else {
s.handleHTTP(w, req)
}
}),
}
return server.ListenAndServe()
}
func (s *Server) handleHTTP(w http.ResponseWriter, req *http.Request) {
req.RequestURI = ""
req.URL.Scheme = "http"
req.URL.Host = req.Host
destPort := req.URL.Port()
if destPort == "" {
destPort = "80"
}
destAddr := fmt.Sprintf("%s:%s", req.URL.Hostname(), destPort)
logrus.Infof("HTTP request for %s", destAddr)
target, err := utils.Connect(destAddr)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
defer target.Close()
_, err = s.rw.RewriteAndForward(target, req, req.Host, req.RemoteAddr)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
resp, err := http.ReadResponse(bufio.NewReader(target), req)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
defer resp.Body.Close()
for k, v := range resp.Header {
for _, vv := range v {
w.Header().Add(k, vv)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}
func (s *Server) handleTunneling(w http.ResponseWriter, req *http.Request) {
logrus.Infof("HTTP CONNECT request for %s", req.Host)
destAddr := req.Host
dest, err := utils.Connect(destAddr)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
client, _, err := hijacker.Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
}
client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
s.ForwardTCP(client, dest, destAddr)
}
func (s *Server) HandleClient(client net.Conn) {
}
// ForwardTCP proxies traffic in both directions.
// target->client uses raw copy.
// client->target is processed by the rewriter (or raw if cached).
func (s *Server) ForwardTCP(client, target net.Conn, destAddr string) {
// Server -> Client (raw)
go utils.CopyHalf(client, target)
// Client -> Server (rewriter)
go utils.ProxyHalf(target, client, s.rw, destAddr)
}

View File

@ -6,6 +6,7 @@ import (
"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/redirect"
"github.com/sunbk201/ua3f/internal/server/socks5"
"github.com/sunbk201/ua3f/internal/server/tproxy"
@ -19,6 +20,8 @@ type Server interface {
func NewServer(cfg *config.Config, rw *rewrite.Rewriter) (Server, error) {
switch cfg.ServerMode {
case config.ServerModeHTTP:
return http.New(cfg, rw), nil
case config.ServerModeSocks5:
return socks5.New(cfg, rw), nil
case config.ServerModeTProxy: