diff --git a/.testcoverage.yml b/.testcoverage.yml index 8ca2812..79fdf86 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -2,9 +2,10 @@ profile: cover.out local-prefix: "github.com/ryanbekhen/nanoproxy" threshold: file: 60 - package: 80 + package: 60 total: 80 exclude: paths: - \.pb\.go$ # excludes all protobuf generated files - - nanoproxy\.go$ # excludes the main package \ No newline at end of file + - nanoproxy\.go$ # excludes the main package + - pkg/config/.*\.go$ # excludes all files in the pkg/config package \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f68266a..11a8f51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,5 +2,6 @@ FROM busybox:1.36.1-glibc COPY nanoproxy /usr/bin/nanoproxy EXPOSE 1080 +EXPOSE 8080 ENTRYPOINT ["nanoproxy"] \ No newline at end of file diff --git a/Dockerfile-tor b/Dockerfile-tor index 109f878..72e9512 100644 --- a/Dockerfile-tor +++ b/Dockerfile-tor @@ -14,5 +14,6 @@ RUN mkdir -p /etc/tor && \ RUN mkdir -p /var/lib/tor EXPOSE 1080 +EXPOSE 8080 ENTRYPOINT ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] \ No newline at end of file diff --git a/README.md b/README.md index f9cc2c7..e1b0e7b 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ Note: This code includes modifications from the original go-socks5 project (http Modifications have been made as part of maintenance for NanoProxy. This version is licensed under the MIT license. -NanoProxy is a lightweight SOCKS5 proxy server written in Go. It is designed to be simple, minimalistic, and easy to -use. +NanoProxy is a lightweight proxy server written in Go. It supports both **SOCKS5** and **HTTP Proxy** protocols, making +it flexible for proxying various types of network traffic. NanoProxy is designed to be simple, minimalistic, and easy to +use. It can be run as a standalone service or as a Docker container. > ⚠️ **Notice:** NanoProxy is currently in pre-production stage. While it provides essential proxying capabilities, > please be aware that it is still under active development. Full backward compatibility is not guaranteed until @@ -17,10 +18,10 @@ use. ## Data Flow Through Proxy -NanoProxy acts as a proxy server that forwards network traffic between the user and the destination server. -When a user makes a request, the request is sent to the proxy server. The proxy server then forwards the request to -the destination server. The destination server processes the request and responds back to the proxy server, which then -sends the response back to the user. This allows the proxy server to intercept and manage network traffic effectively. +NanoProxy acts as a proxy Server that forwards network traffic between the user and the destination Server. +When a user makes a request, the request is sent to the proxy Server. The proxy Server then forwards the request to +the destination Server. The destination Server processes the request and responds back to the proxy Server, which then +sends the response back to the user. This allows the proxy Server to intercept and manage network traffic effectively. Here's how the data flows through the proxy: @@ -60,6 +61,27 @@ sequenceDiagram NanoProxy ->> User: Respond ``` +## Data Flow Through Proxy with HTTP Support + +NanoProxy supports HTTP proxying by handling HTTP requests and forwarding them to the destination server. Depending on +the request method (e.g., GET, POST, CONNECT), NanoProxy processes and forwards the request accordingly. + +Here's how the data flows through the proxy when using HTTP support: + +```mermaid +sequenceDiagram + participant User + participant NanoProxy + participant DestinationServer + User ->> NanoProxy: HTTP Request + NanoProxy ->> DestinationServer: Forward HTTP Request + DestinationServer ->> NanoProxy: Process & Respond + NanoProxy ->> User: Deliver Response +``` + +This process allows NanoProxy to act as an intermediary between the client and the destination server for HTTP traffic, +ensuring flexibility and traffic management. + ### Features of NanoProxy with Tor: - **Enhanced Anonymity**: Traffic is routed through multiple Tor nodes, making it difficult to trace the origin of the @@ -68,7 +90,7 @@ sequenceDiagram - **Secure Data Transmission**: Encryption between Tor nodes protects data from snooping. This distinct data flow employing the Tor network ensures that users enjoy increased privacy without compromising on the -flexible functionality of the proxy server. +flexible functionality of the proxy Server. ### Impact of Using NanoProxy with Tor: @@ -87,8 +109,9 @@ especially if anonymity is prioritized over connection speed or stability. NanoProxy provides the following features: -- [x] **SOCKS5 proxy server.** NanoProxy is a SOCKS5 proxy server that can be used to proxy network traffic for various +- [x] **SOCKS5 proxy Server.** NanoProxy is a SOCKS5 proxy Server that can be used to proxy network traffic for various applications. +- [x] **HTTP proxy Server.** NanoProxy can now act as an HTTP proxy Server for forwarding HTTP requests. - [x] **TOR support.** NanoProxy can be run with Tor support to provide anonymized network traffic (Docker only). - [x] **IP Rotation with Tor.** NanoProxy allows for IP rotation using the Tor network, providing enhanced anonymity and privacy by periodically changing exit nodes. @@ -185,13 +208,13 @@ nanoproxy You can also run NanoProxy using Docker. To do so, you can use the following command: ```shell -docker run -p 1080:1080 ghcr.io/ryanbekhen/nanoproxy:latest +docker run -p 1080:1080 -p 8080:8080 ghcr.io/ryanbekhen/nanoproxy:latest ``` You can also run NanoProxy behind Tor using the following command: ```shell -docker run --rm -e TOR_ENABLED=true -d --privileged --cap-add=NET_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv4.conf.all.src_valid_mark=1 -p 1080:1080 ghcr.io/ryanbekhen/nanoproxy-tor:latest +docker run --rm -e TOR_ENABLED=true -d --privileged --cap-add=NET_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0 --sysctl net.ipv4.conf.all.src_valid_mark=1 -p 1080:1080 -p 8080:8080 ghcr.io/ryanbekhen/nanoproxy-tor:latest ``` ## Configuration @@ -201,6 +224,7 @@ desired values: ```text ADDR=:1080 +ADDR_HTTP=:8080 NETWORK=tcp TZ=Asia/Jakarta CLIENT_TIMEOUT=10s @@ -229,17 +253,24 @@ The following table lists the available configuration options: | Name | Description | Default Value | |-----------------------|-----------------------------------------------------------------|---------------| | ADDR | The address to listen on. | `:1080` | +| ADDR_HTTP | The address to listen on for HTTP requests. | `:8080` | | NETWORK | The network to listen on. (tcp, tcp4, tcp6) | `tcp` | | TZ | The timezone to use. | `Local` | -| CLIENT_TIMEOUT | The timeout for connecting to the destination server. | `10s` | +| CLIENT_TIMEOUT | The timeout for connecting to the destination Server. | `10s` | | DNS_TIMEOUT | The timeout for DNS resolution. | `10s` | | CREDENTIALS | The credentials to use for authentication. | `""` | | TOR_ENABLED | Enable Tor support. (works only on Docker) | `false` | -| TOR_IDENTITY_INTERVAL | The interval to change the Tor identity. (works only on Docker) | `10m` |ß +| TOR_IDENTITY_INTERVAL | The interval to change the Tor identity. (works only on Docker) | `10m` | + +- **ADDR_HTTP**: By default, NanoProxy listens for HTTP proxy traffic on `:8080`. You can set this address to any host: + port combination for custom setups. +- **CREDENTIALS**: When enabled, both SOCKS5 and HTTP Proxy requests are authenticated using the credentials provided in + this field. This supports `username:password` pairs. ## Logging -NanoProxy logs all requests and responses to the standard output. You can use the `journalctl` command to view the logs: +NanoProxy logs all requests and responses to standard output, including SOCKS5 and HTTP Proxy traffic. To view logs for +HTTP Proxy requests, use the `journalctl` command: ```shell journalctl -u nanoproxy @@ -250,15 +281,43 @@ journalctl -u nanoproxy To test the proxy using cURL, you can use the `-x` flag followed by the proxy URL. For example, to fetch the Google homepage using the proxy running on `localhost:8080`, use the following command: +### SOCKS5 Proxy + ```shell curl -x socks5://localhost:1080 https://google.com ``` -Replace localhost:8080 with the actual address and port where your NanoProxy instance is running. This command instructs -cURL to use the specified proxy for the request, allowing you to see the request and response through the proxy server. +### HTTP Proxy + +```shell +curl -x localhost:8080 https://google.com +``` + +If credentials are enabled for HTTP Proxy, use the `-U` flag to supply the username and password: + +```shell +curl -x http://localhost:8080 -U username:password https://example.com +``` + +In both cases, replace `localhost:8080` with the actual address and port where your NanoProxy instance is running. + +## Authentication for HTTP Proxy + +If authentication is enabled (via the `CREDENTIALS` configuration), the HTTP Proxy requires clients to include the +`Proxy-Authorization` header in their requests. The header must use the following format: + +```http +Proxy-Authorization: Basic +``` + +For example, to use the HTTP Proxy with curl and provide authentication, run: + +```shell +curl -x http://localhost:8080 -U username:password https://example.com +``` -Remember that you can adjust the proxy address and port as needed based on your setup. This is a convenient way to -verify that NanoProxy is correctly intercepting and forwarding the traffic. +If authentication fails or is not provided, the proxy will return `407 Proxy Authentication Required` along with the +appropriate `Proxy-Authenticate` header. ## Contributions diff --git a/go.mod b/go.mod index ee2e9bd..9849020 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/caarlos0/env/v10 v10.0.0 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.30.0 + golang.org/x/crypto v0.31.0 golang.org/x/net v0.32.0 ) diff --git a/go.sum b/go.sum index 27f687c..206380c 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= -golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/nanoproxy.go b/nanoproxy.go index 34a53d7..5eca6ee 100644 --- a/nanoproxy.go +++ b/nanoproxy.go @@ -1,13 +1,16 @@ -//go:build !test - package main import ( "github.com/caarlos0/env/v10" "github.com/rs/zerolog" "github.com/ryanbekhen/nanoproxy/pkg/config" + "github.com/ryanbekhen/nanoproxy/pkg/credential" + "github.com/ryanbekhen/nanoproxy/pkg/httpproxy" + "github.com/ryanbekhen/nanoproxy/pkg/resolver" "github.com/ryanbekhen/nanoproxy/pkg/socks5" "github.com/ryanbekhen/nanoproxy/pkg/tor" + "net" + "net/http" "os" "strings" "time" @@ -28,28 +31,40 @@ func main() { time.Local = loc } - socks5Config := socks5.Config{ - Logger: &logger, - DestConnTimeout: cfg.DestTimeout, - ClientConnTimeout: cfg.ClientTimeout, - Resolver: &socks5.DNSResolver{}, - } - - credentials := socks5.StaticCredentialStore{} + credentials := credential.StaticCredentialStore{} for _, cred := range cfg.Credentials { credArr := strings.Split(cred, ":") if len(credArr) != 2 { logger.Fatal().Msgf("Invalid credential: %s", cred) } + credentials[credArr[0]] = credArr[1] } - if len(credentials) > 0 { - socks5Config.Credentials = credentials + + dnsResolver := &resolver.DNSResolver{} + + httpConfig := httpproxy.Config{ + Credentials: credentials, + Logger: &logger, + DestConnTimeout: cfg.DestTimeout, + ClientConnTimeout: cfg.ClientTimeout, + Dial: net.Dial, + Resolver: dnsResolver, + } + + httpServer := httpproxy.New(&httpConfig) + + socks5Config := socks5.Config{ + Logger: &logger, + DestConnTimeout: cfg.DestTimeout, + ClientConnTimeout: cfg.ClientTimeout, + Resolver: dnsResolver, } if cfg.TorEnabled { torDialer := &tor.DefaultDialer{} socks5Config.Dial = torDialer.Dial + httpConfig.Dial = torDialer.Dial logger.Info().Msg("Tor mode enabled") torController := tor.NewTorController(torDialer) @@ -62,10 +77,37 @@ func main() { }() } + if len(credentials) > 0 { + authenticator := &socks5.UserPassAuthenticator{ + Credentials: credentials, + } + socks5Config.Authentication = append(socks5Config.Authentication, authenticator) + } + sock5Server := socks5.New(&socks5Config) - logger.Info().Msgf("Starting socks5 server on %s://%s", cfg.Network, cfg.ADDR) - if err := sock5Server.ListenAndServe(cfg.Network, cfg.ADDR); err != nil { - logger.Fatal().Msg(err.Error()) - } + go func() { + logger.Info().Msgf("Starting HTTP proxy server on %s://%s", cfg.Network, cfg.ADDRHttp) + + server := &http.Server{ + Addr: cfg.ADDRHttp, + Handler: httpServer, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal().Msg(err.Error()) + } + }() + + go func() { + logger.Info().Msgf("Starting SOCKS5 server on %s://%s", cfg.Network, cfg.ADDR) + if err := sock5Server.ListenAndServe(cfg.Network, cfg.ADDR); err != nil { + logger.Fatal().Msg(err.Error()) + } + }() + + select {} } diff --git a/pkg/config/config.go b/pkg/config/config.go index f0a6dc3..d7239a4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -6,6 +6,7 @@ type Config struct { Timezone string `env:"TZ" envDefault:"Local"` Network string `env:"NETWORK" envDefault:"tcp"` ADDR string `env:"ADDR" envDefault:":1080"` + ADDRHttp string `env:"ADDR_HTTP" envDefault:":8080"` Credentials []string `env:"CREDENTIALS" envSeparator:","` ClientTimeout time.Duration `env:"CLIENT_TIMEOUT" envDefault:"15s"` DestTimeout time.Duration `env:"DEST_TIMEOUT" envDefault:"15s"` diff --git a/pkg/socks5/credentials.go b/pkg/credential/credentials.go similarity index 87% rename from pkg/socks5/credentials.go rename to pkg/credential/credentials.go index e222e3b..e6bccea 100644 --- a/pkg/socks5/credentials.go +++ b/pkg/credential/credentials.go @@ -1,10 +1,10 @@ -package socks5 +package credential import ( "golang.org/x/crypto/bcrypt" ) -type CredentialStore interface { +type Store interface { Valid(user, password string) bool } diff --git a/pkg/socks5/credentials_test.go b/pkg/credential/credentials_test.go similarity index 82% rename from pkg/socks5/credentials_test.go rename to pkg/credential/credentials_test.go index a8490a2..b65deea 100644 --- a/pkg/socks5/credentials_test.go +++ b/pkg/credential/credentials_test.go @@ -1,4 +1,4 @@ -package socks5 +package credential import ( "github.com/stretchr/testify/assert" @@ -6,8 +6,7 @@ import ( ) func Test_CredentialStore_Valid(t *testing.T) { - var s CredentialStore - s = StaticCredentialStore{ + s := StaticCredentialStore{ "foo": "$2y$05$Xr4Vj6wbsCuf70.Fif2guuX8Ez97GB0VysyCTRL2EMkIikCpY/ugi", } assert.True(t, s.Valid("foo", "bar")) diff --git a/pkg/httpproxy/httpproxy.go b/pkg/httpproxy/httpproxy.go new file mode 100644 index 0000000..b4b09ef --- /dev/null +++ b/pkg/httpproxy/httpproxy.go @@ -0,0 +1,255 @@ +package httpproxy + +import ( + "encoding/base64" + "fmt" + "github.com/rs/zerolog" + "github.com/ryanbekhen/nanoproxy/pkg/credential" + "github.com/ryanbekhen/nanoproxy/pkg/resolver" + "io" + "net" + "net/http" + "os" + "strings" + "time" +) + +var hopHeaders = []string{ + "Connection", + "Proxy-Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "Te", + "Trailers", + "Transfer-Encoding", + "Upgrade", +} + +type Config struct { + Credentials credential.Store + Logger *zerolog.Logger + DestConnTimeout time.Duration + ClientConnTimeout time.Duration + Dial func(network, addr string) (net.Conn, error) + Resolver resolver.Resolver +} + +type Server struct { + config *Config +} + +func New(conf *Config) *Server { + if conf.Logger == nil { + logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}).With().Timestamp().Logger() + conf.Logger = &logger + } + + if conf.Resolver == nil { + conf.Resolver = &resolver.DNSResolver{} + } + + if conf.DestConnTimeout == 0 { + conf.DestConnTimeout = 5 * time.Second + } + + if conf.ClientConnTimeout == 0 { + conf.ClientConnTimeout = 5 * time.Second + } + + server := &Server{ + config: conf, + } + + return server +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + s.handleConnect(w, r) + } else { + s.handleHTTP(w, r) + } +} + +func (s *Server) authenticateRequest(r *http.Request) bool { + if s.config.Credentials == nil { + return true + } + + authHeader := r.Header.Get("Proxy-Authorization") + if authHeader == "" { + return false + } + + if strings.HasPrefix(authHeader, "Basic ") { + encodedCreds := strings.TrimPrefix(authHeader, "Basic ") + decoded, err := base64.StdEncoding.DecodeString(encodedCreds) + if err != nil { + return false + } + + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return false + } + + username, password := parts[0], parts[1] + return s.config.Credentials.Valid(username, password) + } + + return false +} + +func (s *Server) handleConnect(w http.ResponseWriter, r *http.Request) { + if !s.authenticateRequest(r) { + s.config.Logger.Error(). + Str("client_addr", r.RemoteAddr). + Msg("Unauthorized CONNECT request") + w.Header().Set("Proxy-Authenticate", "Basic realm=\"Restricted area\"") + http.Error(w, "Proxy authentication required or unauthorized", http.StatusProxyAuthRequired) + return + } + startTime := time.Now() + serverConn, err := s.config.Dial("tcp", r.Host) + latency := time.Since(startTime).Milliseconds() + if err != nil { + s.config.Logger.Error(). + Str("client_addr", r.RemoteAddr). + Str("dest_addr", r.Host). + Str("latency", fmt.Sprintf("%dms", latency)). + Msg("CONNECT failed") + http.Error(w, "Service unavailable", http.StatusServiceUnavailable) + return + } + defer serverConn.Close() + + clientConn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + s.config.Logger.Error(). + Str("client_addr", r.RemoteAddr). + Msg("Failed to hijack client connection") + http.Error(w, "Service unavailable", http.StatusServiceUnavailable) + return + } + defer clientConn.Close() + + _, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + + s.config.Logger.Info(). + Str("client_addr", r.RemoteAddr). + Str("dest_addr", r.Host). + Str("latency", fmt.Sprintf("%dms", latency)). + Msg("CONNECT request completed") + + go func() { + _, _ = io.Copy(serverConn, clientConn) + }() + _, _ = io.Copy(clientConn, serverConn) +} + +func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { + if !s.authenticateRequest(r) { + s.config.Logger.Error(). + Str("client_addr", r.RemoteAddr). + Msg("Unauthorized HTTP request") + w.Header().Set("Proxy-Authenticate", "Basic realm=\"Restricted area\"") + http.Error(w, "Proxy authentication required or unauthorized", http.StatusProxyAuthRequired) + return + } + + startTime := time.Now() + clientIP := r.RemoteAddr + + if !strings.HasPrefix(r.URL.Scheme, "http") { + s.config.Logger.Error(). + Str("client_addr", clientIP). + Str("dest_addr", r.URL.String()). + Msg("Invalid URL scheme") + http.Error(w, "Invalid URL scheme", http.StatusBadRequest) + return + } + + destAddr := r.URL.Host + if s.config.Resolver != nil { + ip, err := s.config.Resolver.Resolve(destAddr) + if err != nil { + latency := time.Since(startTime).Milliseconds() + s.config.Logger.Error(). + Str("client_addr", clientIP). + Str("dest_addr", r.URL.String()). + Str("latency", fmt.Sprintf("%dms", latency)). + Msg("Failed to resolve destination - Bad Gateway") + http.Error(w, "Bad gateway: failed to resolve destination", http.StatusBadGateway) + return + } + destAddr = net.JoinHostPort(ip.String(), r.URL.Port()) + } + + proxyReq, err := http.NewRequest(r.Method, r.URL.String(), r.Body) + if err != nil { + latency := time.Since(startTime).Milliseconds() + s.config.Logger.Error(). + Str("client_addr", clientIP). + Str("dest_addr", r.URL.String()). + Str("latency", fmt.Sprintf("%dms", latency)). + Msg("Failed to create request - Internal Server Error") + http.Error(w, "Internal server error while creating request", http.StatusInternalServerError) + return + } + + for key, values := range r.Header { + if isHopHeader(key) { + continue + } + + for _, value := range values { + proxyReq.Header.Add(key, value) + } + } + + client := &http.Client{ + Timeout: s.config.ClientConnTimeout, + } + resp, err := client.Do(proxyReq) + latency := time.Since(startTime).Milliseconds() + if err != nil { + s.config.Logger.Error(). + Str("client_addr", clientIP). + Str("dest_addr", r.URL.String()). + Str("latency", fmt.Sprintf("%dms", latency)). + Msg("Failed to send request - Bad Gateway") + http.Error(w, "Bad gateway: failed to send request", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + s.config.Logger.Info(). + Str("client_addr", clientIP). + Str("dest_addr", r.URL.String()). + Str("latency", fmt.Sprintf("%dms", latency)). + Msg("HTTP request successfully proxied") + + for _, key := range hopHeaders { + resp.Header.Del(key) + } + + for key, values := range resp.Header { + if !isHopHeader(key) { + w.Header()[key] = values + } + } + + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(w, resp.Body) +} + +func isHopHeader(header string) bool { + header = strings.ToLower(header) + for _, h := range hopHeaders { + if strings.EqualFold(header, h) { + return true + } + } + return false +} diff --git a/pkg/httpproxy/httpproxy_test.go b/pkg/httpproxy/httpproxy_test.go new file mode 100644 index 0000000..8574b3e --- /dev/null +++ b/pkg/httpproxy/httpproxy_test.go @@ -0,0 +1,296 @@ +package httpproxy + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "errors" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +type MockCredentialStore struct{} + +func (m *MockCredentialStore) Valid(username, password string) bool { + return username == "user" && password == "password" +} + +type MockResolver struct{} + +func (m *MockResolver) Resolve(host string) (net.IP, error) { + if host == "validhost.com" { + return net.ParseIP("127.0.0.1"), nil + } + return nil, errors.New("host not found") +} + +type MockNetConn struct{} + +func (m *MockNetConn) Read(b []byte) (n int, err error) { + return 0, io.EOF +} + +func (m *MockNetConn) Write(b []byte) (n int, err error) { + return len(b), nil +} + +func (m *MockNetConn) Close() error { + return nil +} + +func (m *MockNetConn) LocalAddr() net.Addr { return nil } +func (m *MockNetConn) RemoteAddr() net.Addr { return nil } +func (m *MockNetConn) SetDeadline(t time.Time) error { return nil } +func (m *MockNetConn) SetReadDeadline(t time.Time) error { return nil } +func (m *MockNetConn) SetWriteDeadline(t time.Time) error { return nil } + +type MockHijacker struct { + *httptest.ResponseRecorder +} + +func (m *MockHijacker) Hijack() (net.Conn, *bufio.ReadWriter, error) { + mockConn := &MockNetConn{} + buf := bufio.NewReadWriter(bufio.NewReader(mockConn), bufio.NewWriter(mockConn)) + return mockConn, buf, nil +} + +func TestServer_ServeHTTP(t *testing.T) { + logger := zerolog.New(io.Discard) + mockCredentials := &MockCredentialStore{} + mockResolver := &MockResolver{} + + server := New(&Config{ + Credentials: mockCredentials, + Logger: &logger, + DestConnTimeout: 2 * time.Second, + ClientConnTimeout: 2 * time.Second, + Dial: func(network, addr string) (net.Conn, error) { + return &MockNetConn{}, nil + }, + Resolver: mockResolver, + }) + + t.Run("Handle HTTP - unauthorized request", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusProxyAuthRequired, rr.Code) + assert.Contains(t, rr.Body.String(), "Proxy authentication required") + }) + + t.Run("Handle HTTP - successful authorization but Dial fails", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com", nil) + req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("user:password"))) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadGateway, rr.Code) + }) + + t.Run("Handle HTTP - failed to resolve host", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://invalidhost.com", nil) + req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("user:password"))) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadGateway, rr.Code) + assert.Contains(t, rr.Body.String(), "Bad gateway: failed to resolve destination") + }) +} + +func TestServer_HandleCONNECT(t *testing.T) { + logger := zerolog.New(io.Discard) + mockCredentials := &MockCredentialStore{} + + server := New(&Config{ + Credentials: mockCredentials, + Logger: &logger, + DestConnTimeout: 2 * time.Second, + ClientConnTimeout: 2 * time.Second, + Dial: func(network, addr string) (net.Conn, error) { + return &MockNetConn{}, nil + }, + }) + + t.Run("Handle CONNECT - unauthorized request", func(t *testing.T) { + req := httptest.NewRequest(http.MethodConnect, "http://example.com", nil) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusProxyAuthRequired, rr.Code) + assert.Contains(t, rr.Body.String(), "Proxy authentication required") + }) + + t.Run("Handle CONNECT - successful connection", func(t *testing.T) { + req := httptest.NewRequest(http.MethodConnect, "example.com:443", nil) + req.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("user:password"))) + rr := httptest.NewRecorder() + + hj := &MockHijacker{ResponseRecorder: rr} + done := make(chan bool, 1) + + go func() { + defer close(done) + server.ServeHTTP(hj, req) + done <- true + }() + + select { + case <-done: + assert.Equal(t, http.StatusOK, rr.Code) + case <-time.After(5 * time.Second): + t.Fatal("Test timeout after 5 seconds") + } + }) +} + +func TestProxy_ForwardRequests(t *testing.T) { + targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Connection")) + assert.Empty(t, r.Header.Get("Keep-Alive")) + w.Header().Set("X-Test-Header", "TestValue") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Target server response")) + })) + defer targetServer.Close() + + logger := zerolog.New(io.Discard) + + server := New(&Config{ + Logger: &logger, + ClientConnTimeout: 2 * time.Second, + }) + + proxy := httptest.NewServer(server) + defer proxy.Close() + + t.Run("Successful forward request", func(t *testing.T) { + client := &http.Client{Timeout: 2 * time.Second} + req, _ := http.NewRequest(http.MethodGet, targetServer.URL, nil) + + resp, err := client.Do(req) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + defer resp.Body.Close() + + assert.Equal(t, "Target server response", string(body)) + }) +} + +func TestServer_HandleHTTP_WithProxyRequest(t *testing.T) { + targetURL := "http://httpbin.org" + logger := zerolog.New(io.Discard) + + proxy := New(&Config{ + Credentials: &MockCredentialStore{}, + Logger: &logger, + DestConnTimeout: 2 * time.Second, + ClientConnTimeout: 2 * time.Second, + Dial: func(network, addr string) (net.Conn, error) { + return net.Dial(network, addr) + }, + }) + proxyServer := httptest.NewServer(proxy) + defer proxyServer.Close() + + t.Run("Forward HTTP request successfully", func(t *testing.T) { + clientReq, err := http.NewRequest(http.MethodGet, targetURL+"/anything", nil) + if err != nil { + t.Fatalf("Gagal membuat request: %v", err) + } + + clientReq.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("user:password"))) + clientReq.Header.Set("X-Custom-Header", "TestValue") + clientReq.Header.Set("X-Backend-Response", "Success") + + proxyClient := &http.Client{ + Transport: &http.Transport{ + Proxy: func(req *http.Request) (*url.URL, error) { + return url.Parse(proxyServer.URL) + }, + }, + Timeout: 2 * time.Second, + } + + resp, err := proxyClient.Do(clientReq) + assert.NoError(t, err) + if err != nil { + t.Fatalf("[ERROR] Proxy client mengalami error: %v", err) + } + + assert.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + defer resp.Body.Close() + + var responseJSON map[string]interface{} + err = json.Unmarshal(body, &responseJSON) + assert.NoError(t, err) + + headers := responseJSON["headers"].(map[string]interface{}) + + expectedBackendResponse := "Success" + actualBackendResponse := headers["X-Backend-Response"] + assert.Equal(t, expectedBackendResponse, actualBackendResponse) + + expectedCustomHeader := "TestValue" + actualCustomHeader := headers["X-Custom-Header"] + assert.Equal(t, expectedCustomHeader, actualCustomHeader) + }) +} + +func TestServer_HandleHTTP_InvalidURLScheme(t *testing.T) { + logger := zerolog.New(io.Discard) + + server := New(&Config{ + Logger: &logger, + ClientConnTimeout: 2 * time.Second, + }) + + t.Run("Invalid URL Scheme", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "ftp://example.com", nil) // Skema tidak valid (ftp) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) // Memastikan statusnya Bad Request + assert.Contains(t, rr.Body.String(), "Invalid URL scheme") // Memastikan pesan error sesuai + }) +} + +func TestServer_HandleHTTP_ClientDoError(t *testing.T) { + logger := zerolog.New(io.Discard) + + server := New(&Config{ + Logger: &logger, + ClientConnTimeout: 2 * time.Second, + }) + + t.Run("Failed to resolve DNS", func(t *testing.T) { + // Membuat permintaan HTTP Proxy + proxyReq := httptest.NewRequest(http.MethodGet, "http://unreachablehost", nil) + proxyReq.Header.Set("Proxy-Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("user:password"))) + + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, proxyReq) + + // Kode status harus 502: Bad Gateway karena resolve gagal + assert.Equal(t, http.StatusBadGateway, rr.Code) + + // Validasi pesan error + assert.Contains(t, rr.Body.String(), "Bad gateway: failed to resolve destination") + }) +} diff --git a/pkg/socks5/resolver.go b/pkg/resolver/resolver.go similarity index 94% rename from pkg/socks5/resolver.go rename to pkg/resolver/resolver.go index d9acd9d..17071fe 100644 --- a/pkg/socks5/resolver.go +++ b/pkg/resolver/resolver.go @@ -1,4 +1,4 @@ -package socks5 +package resolver import ( "net" diff --git a/pkg/socks5/resolver_test.go b/pkg/resolver/resolver_test.go similarity index 95% rename from pkg/socks5/resolver_test.go rename to pkg/resolver/resolver_test.go index 3156825..939ab3a 100644 --- a/pkg/socks5/resolver_test.go +++ b/pkg/resolver/resolver_test.go @@ -1,4 +1,4 @@ -package socks5 +package resolver import ( "github.com/stretchr/testify/assert" diff --git a/pkg/socks5/auth.go b/pkg/socks5/auth.go index 70eca58..826ba27 100644 --- a/pkg/socks5/auth.go +++ b/pkg/socks5/auth.go @@ -2,6 +2,7 @@ package socks5 import ( "fmt" + "github.com/ryanbekhen/nanoproxy/pkg/credential" "io" ) @@ -34,8 +35,8 @@ var ( ErrAuthFailure = fmt.Errorf("authentication failure") ) -// AuthContext encapsulates authentication state provided during negotiation -type AuthContext struct { +// Context encapsulates authentication state provided during negotiation +type Context struct { // Method is the provided auth method Method AuthType // Payload provided during negotiation. @@ -46,7 +47,7 @@ type AuthContext struct { // Authenticator is the interface implemented by types that can handle authentication type Authenticator interface { - Authenticate(reader io.Reader, writer io.Writer) (*AuthContext, error) + Authenticate(reader io.Reader, writer io.Writer) (*Context, error) GetCode() AuthType } @@ -59,14 +60,14 @@ func (a *NoAuthAuthenticator) GetCode() AuthType { } // Authenticate handles the authentication process -func (a *NoAuthAuthenticator) Authenticate(_ io.Reader, writer io.Writer) (*AuthContext, error) { +func (a *NoAuthAuthenticator) Authenticate(_ io.Reader, writer io.Writer) (*Context, error) { _, err := writer.Write([]byte{Version, uint8(NoAuth)}) - return &AuthContext{NoAuth, nil}, err + return &Context{NoAuth, nil}, err } // UserPassAuthenticator is used to handle username/password-based authentication type UserPassAuthenticator struct { - Credentials CredentialStore + Credentials credential.Store } // GetCode returns the code of the authenticator @@ -75,7 +76,7 @@ func (a *UserPassAuthenticator) GetCode() AuthType { } // Authenticate handles the authentication process -func (a *UserPassAuthenticator) Authenticate(reader io.Reader, writer io.Writer) (*AuthContext, error) { +func (a *UserPassAuthenticator) Authenticate(reader io.Reader, writer io.Writer) (*Context, error) { if _, err := writer.Write([]byte{Version, uint8(UserPassAuth)}); err != nil { return nil, err } @@ -122,7 +123,7 @@ func (a *UserPassAuthenticator) Authenticate(reader io.Reader, writer io.Writer) return nil, fmt.Errorf("invalid credentials") } - return &AuthContext{UserPassAuth, map[string]string{"Username": string(user)}}, nil + return &Context{UserPassAuth, map[string]string{"Username": string(user)}}, nil } func readMethods(bufConn io.Reader) ([]byte, error) { diff --git a/pkg/socks5/request.go b/pkg/socks5/request.go index 5439f14..60ab1c5 100644 --- a/pkg/socks5/request.go +++ b/pkg/socks5/request.go @@ -45,7 +45,7 @@ func (a *AddrSpec) Address() string { type Request struct { Version uint8 Command Command - AuthContext *AuthContext + AuthContext *Context RemoteAddr *AddrSpec DestAddr *AddrSpec realAddr *AddrSpec diff --git a/pkg/socks5/request_test.go b/pkg/socks5/request_test.go index f53549a..24e74a6 100644 --- a/pkg/socks5/request_test.go +++ b/pkg/socks5/request_test.go @@ -3,6 +3,7 @@ package socks5 import ( "bytes" "encoding/binary" + "github.com/ryanbekhen/nanoproxy/pkg/resolver" "github.com/stretchr/testify/assert" "io" "net" @@ -89,7 +90,7 @@ func Test_NewRequest(t *testing.T) { lAddr := l.Addr().(*net.TCPAddr) s := &Server{ config: &Config{ - Resolver: &DNSResolver{}, + Resolver: &resolver.DNSResolver{}, }, } diff --git a/pkg/socks5/socks5.go b/pkg/socks5/socks5.go index 064f850..630c44e 100644 --- a/pkg/socks5/socks5.go +++ b/pkg/socks5/socks5.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "github.com/rs/zerolog" + "github.com/ryanbekhen/nanoproxy/pkg/credential" + "github.com/ryanbekhen/nanoproxy/pkg/resolver" "net" "os" "strings" @@ -13,13 +15,13 @@ import ( type Config struct { Authentication []Authenticator - Credentials CredentialStore + Credentials credential.Store Logger *zerolog.Logger DestConnTimeout time.Duration ClientConnTimeout time.Duration Dial func(network, addr string) (net.Conn, error) AfterRequest func(req *Request, conn net.Conn) - Resolver Resolver + Resolver resolver.Resolver Rewriter AddressRewriter } @@ -44,7 +46,7 @@ func New(conf *Config) *Server { } if conf.Resolver == nil { - conf.Resolver = &DNSResolver{} + conf.Resolver = &resolver.DNSResolver{} } if conf.DestConnTimeout == 0 { @@ -129,7 +131,7 @@ func (s *Server) handleConnection(conn net.Conn) { // Authenticate authContext, err := s.authenticate(conn, connectionBuffer) if err != nil { - s.config.Logger.Err(err).Msg("failed to authenticate") + s.config.Logger.Err(err).Msg("SOCKS5 authentication failed") return } @@ -159,7 +161,7 @@ func (s *Server) handleConnection(conn net.Conn) { Str("client_addr", conn.RemoteAddr().String()). Str("dest_addr", request.DestAddr.String()). Str("latency", request.Latency.String()). - Msg("request completed") + Msg("SOCKS5 request completed") } if s.config.AfterRequest != nil { @@ -167,7 +169,7 @@ func (s *Server) handleConnection(conn net.Conn) { } } -func (s *Server) authenticate(conn net.Conn, bufConn *bufio.Reader) (*AuthContext, error) { +func (s *Server) authenticate(conn net.Conn, bufConn *bufio.Reader) (*Context, error) { // Get the methods methods, err := readMethods(bufConn) if err != nil { @@ -176,8 +178,8 @@ func (s *Server) authenticate(conn net.Conn, bufConn *bufio.Reader) (*AuthContex // Select a usable method for _, method := range methods { - if auth, ok := s.authentication[AuthType(method)]; ok { - return auth.Authenticate(bufConn, conn) + if a, ok := s.authentication[AuthType(method)]; ok { + return a.Authenticate(bufConn, conn) } } diff --git a/pkg/socks5/socks5_test.go b/pkg/socks5/socks5_test.go index 6c12d7b..fc167bc 100644 --- a/pkg/socks5/socks5_test.go +++ b/pkg/socks5/socks5_test.go @@ -5,6 +5,8 @@ import ( "encoding/binary" "errors" "fmt" + "github.com/ryanbekhen/nanoproxy/pkg/credential" + "github.com/ryanbekhen/nanoproxy/pkg/resolver" "github.com/stretchr/testify/assert" "io" "net" @@ -16,7 +18,7 @@ func TestNew(t *testing.T) { conf := &Config{ Authentication: []Authenticator{&NoAuthAuthenticator{}}, Logger: nil, - Resolver: &DNSResolver{}, + Resolver: &resolver.DNSResolver{}, } server := New(conf) @@ -46,7 +48,7 @@ func TestListenAndServe(t *testing.T) { }() lAddr := l.Addr().(*net.TCPAddr) - credentials := StaticCredentialStore{ + credentials := credential.StaticCredentialStore{ "foo": "$2y$05$Xr4Vj6wbsCuf70.Fif2guuX8Ez97GB0VysyCTRL2EMkIikCpY/ugi", // foo:bar } auth := &UserPassAuthenticator{Credentials: credentials} @@ -110,7 +112,7 @@ func TestListenAndServe_InvalidCredentials(t *testing.T) { lAddr := l.Addr().(*net.TCPAddr) - credentials := StaticCredentialStore{ + credentials := credential.StaticCredentialStore{ "foo": "bar", } auth := &UserPassAuthenticator{Credentials: credentials} @@ -162,7 +164,7 @@ func TestListenAndServe_InvalidAuthType(t *testing.T) { assert.NoError(t, err) lAddr := l.Addr().(*net.TCPAddr) - credentials := StaticCredentialStore{ + credentials := credential.StaticCredentialStore{ "foo": "bar", } diff --git a/pkg/tor/identity_test.go b/pkg/tor/identity_test.go index 278b3f4..f693f3d 100644 --- a/pkg/tor/identity_test.go +++ b/pkg/tor/identity_test.go @@ -1,45 +1,88 @@ -package tor_test +package tor import ( - "bytes" - "fmt" - "github.com/ryanbekhen/nanoproxy/pkg/tor" + "errors" + "github.com/rs/zerolog" "testing" "time" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" ) +// Mock Requester untuk menggantikan implementasi sebenarnya type MockRequester struct { - shouldFail bool - callCount int + RequestNewTorIdentityFunc func(logger *zerolog.Logger) error } func (m *MockRequester) RequestNewTorIdentity(logger *zerolog.Logger) error { - m.callCount++ - if m.shouldFail { - return fmt.Errorf("simulated failure") - } - return nil + return m.RequestNewTorIdentityFunc(logger) +} + +func TestWaitForTorBootstrap(t *testing.T) { + logger := zerolog.Nop() + timeout := 2 * time.Second + + t.Run("Successful bootstrap", func(t *testing.T) { + mockRequester := &MockRequester{ + RequestNewTorIdentityFunc: func(logger *zerolog.Logger) error { + return nil // selalu sukses + }, + } + + err := WaitForTorBootstrap(&logger, mockRequester, timeout) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + }) + + t.Run("Timeout occurs", func(t *testing.T) { + mockRequester := &MockRequester{ + RequestNewTorIdentityFunc: func(logger *zerolog.Logger) error { + time.Sleep(3 * time.Second) // memicu timeout + return nil + }, + } + + err := WaitForTorBootstrap(&logger, mockRequester, timeout) + if err == nil { + t.Errorf("expected timeout error, got nil") + } + }) + + t.Run("Error in RequestNewTorIdentity", func(t *testing.T) { + mockRequester := &MockRequester{ + RequestNewTorIdentityFunc: func(logger *zerolog.Logger) error { + return errors.New("requester error") + }, + } + + err := WaitForTorBootstrap(&logger, mockRequester, timeout) + if err == nil || err.Error() != "timeout: Tor bootstrap not complete after 2s" { + t.Errorf("expected timeout error due to RequestNewTorIdentity failure, got %v", err) + } + }) } func TestSwitcherIdentity(t *testing.T) { - logger := zerolog.New(zerolog.ConsoleWriter{Out: &bytes.Buffer{}}).With().Logger() - requester := &MockRequester{shouldFail: false} - done := make(chan bool) + logger := zerolog.Nop() + switchInterval := 1 * time.Second + done := make(chan bool, 1) - // Set up a Goroutine to stop the SwitcherIdentity after a short delay - go func() { - time.Sleep(10 * time.Millisecond) - done <- true - }() + t.Run("Switcher stops when done signal is received", func(t *testing.T) { + mockRequester := &MockRequester{ + RequestNewTorIdentityFunc: func(logger *zerolog.Logger) error { + return nil + }, + } - // Call the SwitcherIdentity function with a very short interval - go tor.SwitcherIdentity(&logger, requester, 1*time.Millisecond, done) + go func() { + time.Sleep(2 * time.Second) + done <- true + }() - // Wait for a moment to ensure goroutine have run - time.Sleep(15 * time.Millisecond) + go func() { + SwitcherIdentity(&logger, mockRequester, switchInterval, done) + }() - assert.True(t, requester.callCount > 0, "expected SwitcherIdentity to call RequestNewTorIdentity multiple times") + time.Sleep(3 * time.Second) + // Tidak ada log error karena mockRequester selalu berhasil + }) }