diff --git a/README.md b/README.md index d129ac6..5083b31 100644 --- a/README.md +++ b/README.md @@ -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 `: 服务模式,支持 SOCKS5、TPROXY、REDIRECT,默认 SOCKS5 +- `-m `: 服务模式,支持 HTTP、SOCKS5、TPROXY、REDIRECT,默认 SOCKS5 - `-b `: 自定义绑定监听地址,默认 127.0.0.1 - `-p `: 端口号,默认 1080 - `-l `: 日志等级,默认 info,可选:debug,默认日志位置:`/var/log/ua3f.log` diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 8a48ce4..4317b4a 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -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() diff --git a/src/internal/rewrite/rewriter.go b/src/internal/rewrite/rewriter.go index f8ed9be..e365061 100644 --- a/src/internal/rewrite/rewriter.go +++ b/src/internal/rewrite/rewriter.go @@ -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, - }) } } diff --git a/src/internal/server/http/http.go b/src/internal/server/http/http.go new file mode 100644 index 0000000..a6b8851 --- /dev/null +++ b/src/internal/server/http/http.go @@ -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) +} diff --git a/src/internal/server/server.go b/src/internal/server/server.go index 8f4ae5b..7116c55 100644 --- a/src/internal/server/server.go +++ b/src/internal/server/server.go @@ -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: