├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── assumptions_test.go ├── blocking.go ├── blocking_test.go ├── cmd ├── proxy │ └── main.go └── relay │ └── main.go ├── fuzz.go ├── go.mod ├── go.sum ├── nopdialer.go ├── nopdialer_test.go ├── proxy.go ├── test └── hosts ├── utils.go └── utils_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /proxy 2 | /relay 3 | /cmd/proxy/proxy 4 | /cmd/relay/relay 5 | 6 | *.code-workspace 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine3.21 2 | COPY . /httpRelay 3 | WORKDIR /httpRelay 4 | RUN go build -tags netgo ./cmd/proxy 5 | RUN go build -tags netgo ./cmd/relay 6 | 7 | FROM alpine:3.21 8 | COPY --from=0 /httpRelay/proxy ./ 9 | COPY --from=0 /httpRelay/relay ./ 10 | 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Danny van Heumen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Applications are built by default using 'netgo' build tag. 2 | # 3 | # Build for other architectures: `GOARCH=arm make` 4 | 5 | .PHONY: all 6 | all: library proxy relay 7 | 8 | .PHONY: library 9 | library: 10 | go build ./... 11 | 12 | .PHONY: proxy 13 | proxy: library 14 | go build -buildmode pie ./cmd/proxy 15 | 16 | .PHONY: relay 17 | relay: library 18 | go build -buildmode pie ./cmd/relay 19 | 20 | .PHONY: test 21 | test: library 22 | go test 23 | 24 | .PHONY: docker 25 | docker: 26 | docker build --security-opt label=disable -t httprelay . 27 | 28 | .PHONY: podman 29 | podman: 30 | podman build --security-opt label=disable -t httprelay . 31 | 32 | .PHONY: clean 33 | clean: 34 | rm -f proxy relay 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTP proxy to SOCKS 5 proxy relay 2 | 3 | Single-purpose HTTP proxy relaying requests to an existing (external) SOCKS 5 proxy server. This tool is useful in the case where the SOCKS proxy is the only proxy available, but the application that you wish to use does not support SOCKS. This little program is a welcome addition to SSH's capability of setting up a SOCKS proxy server. 4 | 5 | *Please note*: This implementation is specifically limited to this use case only. Other use cases may be trivial to create, however they are not part of this implementation. 6 | 7 | ## Usage 8 | 9 | `./relay -listen :8080 -socks localhost:8000 -socks-user socksUsername -socks-pass socksPassword -block "127.0.0.1,localhost,192.168/16"` 10 | 11 | Start a HTTP relay proxy that listens on port 8080 of every interface and connects to a SOCKS proxy server on localhost port 8000 for relaying your requests. Block requests that attempt to access 127.0.0.1, 'localhost' or any address in the ip range 192.168.0.0-192.168.255.255. 12 | 13 | `./proxy -listen localhost:8080 -block "127.0.0.1,localhost,192.168.0.1/16"` 14 | 15 | Start a (tiny) generic HTTP proxy server that listens on port 8080 of 'localhost' and proxies requests directly to the internet. Block any requests to 127.0.0.1, 'localhost' or any address in the ip range 192.168.0.0-192.168.255.255. 16 | 17 | ## Program arguments 18 | 19 | The program arguments that are available to both programs. 20 | 21 | - `-block` provide any number of network addresses/ranges to protect from access through the proxy/relay. 22 | - `-blocklist` specify a `hosts`-formatted blocklist to be loaded and used. 23 | - `-listen` specify the address and port on which to listen for incoming proxy connections. 24 | 25 | The following program arguments are applicable to `relay` only. 26 | 27 | - `-socks` the SOCKS proxy to which to forward http proxy requests. 28 | - `-socks-user` the username of SOCKS5 proxy server. 29 | - `-socks-pass` the password of SOCKS5 proxy server. 30 | 31 | ## Building 32 | 33 | The simplest way to build is: `make`. 34 | 35 | This build will use the build flag `-tags netgo` to make the result independent of `gcc`. Refer to `Makefile` for details. 36 | 37 | ## Changelog 38 | 39 | - _2023-08-15_ Command-line flags to provide username/password authentication for SOCKS5 proxy (relay) by [developbranch-cn](). 40 | - _2020-02-04_ Added support for loading in blocklists that are checked as part of the proxying process. 41 | The program argument `-blocklist ` allows loading hostname blocklists formatted as the OS `hosts` file. Blocklists in these formats can be downloaded from various places, such as [NoTracking][github-notracking] and [EnergizedPro][github-energizedpro]. 42 | - _2019-12-22_ Added support for Go modules. 43 | - _way back_ Support for http proxy/relay, with `-block` parameter to protect local network and/or specific addresses/networks from being accessed. 44 | 45 | ## References 46 | 47 | - [Go extension library for proxy connections](http://golang.org/x/net/proxy) 48 | - [What proxies must do](https://www.mnot.net/blog/2011/07/11/what_proxies_must_do) 49 | - [NoTracking blocklist][github-notracking] 50 | - [EnergizedPro][github-energizedpro] 51 | 52 | [github-notracking]: https://github.com/EnergizedProtection/block "NoTracking blocklist" 53 | [github-energizedpro]: https://github.com/EnergizedProtection/block "Energized Protection" 54 | -------------------------------------------------------------------------------- /assumptions_test.go: -------------------------------------------------------------------------------- 1 | package httprelay 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "net/http" 7 | "testing" 8 | 9 | assert "github.com/cobratbq/goutils/std/testing" 10 | ) 11 | 12 | // These tests are are based on instructions in the blog post 13 | // https://www.mnot.net/blog/2011/07/11/what_proxies_must_do. 14 | // This blog posts remarks on some (very) important checks that proxy servers 15 | // must do to ensure correct behaviour under (sometimes questionable) 16 | // circumstances. As most of these points are already covered by the net/http 17 | // package in the standard library, these tests are there for testing that our 18 | // assumptions still hold. 19 | 20 | // 0. Advertise HTTP/1.1 Correctly is handled automatically since the relay 21 | // initiates its own (new) HTTP client connection to the target server. This 22 | // means that the connection basics, such as the protocol, are determined by 23 | // the connection itself and not through copying the original request of the 24 | // user. 25 | 26 | // 1. Remove Hop-by-Hop Headers is explicitly handled in the code, so no need 27 | // to test it as an assumption. 28 | 29 | // TestAssumptionBadFraminingMultipleContentLength verifies that the standard 30 | // package handles bad framing issues by testing the parsing behaviour of the 31 | // net/http package. This is not a 100% perfect check, as we depend on http 32 | // server for hosting the relay server. 33 | // The assumption we make here is that the server and the ReadResponse function 34 | // use the same underlying implementation. 35 | // (2. Detect Bad Framing from the blog post.) 36 | func TestAssumptionBadFramingMultipleContentLength(t *testing.T) { 37 | // Acquire request instance by creating a basic request 38 | requestRdr := bytes.NewBufferString(`GET / HTTP/1.1 39 | Host: www.example.com 40 | 41 | `) 42 | requestBufRdr := bufio.NewReader(requestRdr) 43 | req, err := http.ReadRequest(requestBufRdr) 44 | assert.Nil(t, err) 45 | // Verify behaviour reading a response by providing corrupted response content 46 | responseRdr := bytes.NewBufferString(`HTTP/1.1 200 OK 47 | Content-Type: text/html; charset=utf-8 48 | Content-Length: 10 49 | Content-Length: 20 50 | 51 | abcdefghij`) 52 | responseBufRdr := bufio.NewReader(responseRdr) 53 | _, err = http.ReadResponse(responseBufRdr, req) 54 | assert.NotNil(t, err) 55 | } 56 | 57 | // TestAssumptionBadFraminingContentLengthWithChunked verifies that the standard 58 | // package handles bad framing issues by testing the parsing behaviour of the 59 | // net/http package. This is not a 100% perfect check, as we depend on http 60 | // server for hosting the relay server. 61 | // The assumption we make here is that the server and the ReadResponse function 62 | // use the same underlying implementation. 63 | // (2. Detect Bad Framing from the blog post.) 64 | func TestAssumptionBadFramingContentLengthWithChunked(t *testing.T) { 65 | // Acquire request instance by creating a basic request 66 | requestRdr := bytes.NewBufferString(`GET / HTTP/1.1 67 | Host: www.example.com 68 | 69 | `) 70 | requestBufRdr := bufio.NewReader(requestRdr) 71 | req, err := http.ReadRequest(requestBufRdr) 72 | assert.Nil(t, err) 73 | // Verify behaviour reading a response by providing corrupted response content 74 | responseRdr := bytes.NewBufferString(`HTTP/1.1 200 OK 75 | Content-Type: text/html; charset=utf-8 76 | Content-Length: 10 77 | Transfer-Encoding: chunked 78 | 79 | abcdefghij`) 80 | responseBufRdr := bufio.NewReader(responseRdr) 81 | resp, err := http.ReadResponse(responseBufRdr, req) 82 | assert.Nil(t, err) 83 | assert.Equal(t, resp.ContentLength, -1) 84 | assert.Equal(t, resp.TransferEncoding[0], "chunked") 85 | } 86 | 87 | // TestCorrectlyReadRoutingFromRequest tests whether conflicting information 88 | // w.r.t. host name gets read correctly. Absolute request URI overrides 89 | // information in Host header. 90 | // (3. Route Well from blog post) 91 | func TestCorrectlyReadRoutingFromRequest(t *testing.T) { 92 | // Acquire request instance by creating a basic request 93 | requestRdr := bytes.NewBufferString(`GET http://example.net/foo HTTP/1.1 94 | Host: www.example.com:8000 95 | 96 | `) 97 | requestBufRdr := bufio.NewReader(requestRdr) 98 | req, err := http.ReadRequest(requestBufRdr) 99 | assert.Nil(t, err) 100 | assert.Equal(t, req.Host, "example.net") 101 | assert.Equal(t, req.RequestURI, "http://example.net/foo") 102 | } 103 | -------------------------------------------------------------------------------- /blocking.go: -------------------------------------------------------------------------------- 1 | package httprelay 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "log" 7 | "net" 8 | "os" 9 | "strings" 10 | 11 | bufio_ "github.com/cobratbq/goutils/std/bufio" 12 | "github.com/cobratbq/goutils/std/builtin/set" 13 | "github.com/cobratbq/goutils/std/builtin/slices" 14 | "github.com/cobratbq/goutils/std/errors" 15 | io_ "github.com/cobratbq/goutils/std/io" 16 | net_ "github.com/cobratbq/goutils/std/net" 17 | "golang.org/x/net/proxy" 18 | ) 19 | 20 | // WrapPerHostBlocking wraps a dialer with a PerHost conditional bypass dialer that refuses dialing 21 | // any address that is local or custom specified according to parameters specified. 22 | func WrapPerHostBlocking(dialer proxy.Dialer, local bool, custom string) proxy.Dialer { 23 | // Prepare dialer to block addresses 24 | perHostDialer := proxy.NewPerHost(dialer, &NopDialer{}) 25 | if local { 26 | slices.ForEach(net_.PrivateNetworks, perHostDialer.AddNetwork) 27 | } 28 | if custom != "" { 29 | perHostDialer.AddFromString(custom) 30 | } 31 | return perHostDialer 32 | } 33 | 34 | // WrapBlocklistBlocking loads a blocklist from specified file and includes it in the dialer. Any 35 | // address present on the blocklist will not be allowed to dial. 36 | func WrapBlocklistBlocking(dialer proxy.Dialer, fileName string) (proxy.Dialer, error) { 37 | blocklistDialer := BlocklistDialer{ 38 | List: make(map[string]struct{}, 0), 39 | Dialer: dialer} 40 | if err := loadHostsFile(&blocklistDialer, fileName); err != nil { 41 | return nil, errors.Context(err, "failed to load blocklist: "+fileName) 42 | } 43 | return &blocklistDialer, nil 44 | } 45 | 46 | // loadHostsFile loads a `hosts`-formatted blocklist into provided BlocklistDialer. 47 | func loadHostsFile(dialer *BlocklistDialer, filename string) error { 48 | hostsFile, err := os.Open(filename) 49 | if err != nil { 50 | return errors.Context(err, "failed to open file "+filename) 51 | } 52 | defer io_.CloseLogged(hostsFile, "failed to close hosts file") 53 | return dialer.Load(hostsFile) 54 | } 55 | 56 | // BlocklistDialer checks the loaded blocklist before dialing. 57 | type BlocklistDialer struct { 58 | List map[string]struct{} 59 | Dialer proxy.Dialer 60 | } 61 | 62 | // Dial checks the address against the blocklist and if not present uses the provided dialer to dial 63 | // the address. 64 | func (b *BlocklistDialer) Dial(network, addr string) (net.Conn, error) { 65 | if i := strings.IndexByte(addr, ':'); i > -1 { 66 | addr = addr[:i] 67 | } 68 | if _, ok := b.List[addr]; ok { 69 | return nil, ErrBlockedHost 70 | } 71 | return b.Dialer.Dial(network, addr) 72 | } 73 | 74 | // Load loads a blocklist from provided reader that has content formatted like the operating system 75 | // 'hosts' files. 76 | func (b *BlocklistDialer) Load(in io.Reader) error { 77 | reader := bufio.NewReader(in) 78 | var skipped uint 79 | if err := bufio_.ReadStringLinesFunc(reader, '\n', func(line string) error { 80 | line = strings.TrimSpace(line) 81 | if line == "" || strings.HasPrefix(line, "#") { 82 | // skip comment lines 83 | return nil 84 | } 85 | parts := strings.Fields(line) 86 | if parts[0] != "0.0.0.0" { 87 | // for now, only allow resolutions to 0.0.0.0 for purpose of blocking 88 | skipped++ 89 | return nil 90 | } 91 | set.InsertMany(b.List, parts[1:]) 92 | return nil 93 | }); err != nil { 94 | return errors.Context(err, "failed to read hosts content") 95 | } 96 | if skipped > 0 { 97 | log.Printf("Skipped %d lines for not using destination address '0.0.0.0'.", skipped) 98 | } 99 | log.Println("Total entries in blocklist:", len(b.List)) 100 | return nil 101 | } 102 | -------------------------------------------------------------------------------- /blocking_test.go: -------------------------------------------------------------------------------- 1 | package httprelay 2 | 3 | import ( 4 | "bytes" 5 | "net" 6 | "testing" 7 | 8 | "github.com/cobratbq/goutils/std/builtin/set" 9 | assert "github.com/cobratbq/goutils/std/testing" 10 | ) 11 | 12 | func TestBlocklistDialerNilDialer(t *testing.T) { 13 | defer assert.RequirePanic(t) 14 | b := BlocklistDialer{List: make(map[string]struct{}, 0), Dialer: nil} 15 | b.Dial("tcp", "hello.world:80") 16 | t.FailNow() 17 | } 18 | 19 | func TestBlocklistDialerDirectDialer(t *testing.T) { 20 | b := BlocklistDialer{List: make(map[string]struct{}, 0), Dialer: &TestNopDialer{}} 21 | if _, err := b.Dial("tcp", "hello.world:80"); err != nil { 22 | t.FailNow() 23 | } 24 | } 25 | 26 | func TestBlocklistDialerBlockedAddress(t *testing.T) { 27 | b := BlocklistDialer{List: set.Create("hello.world"), Dialer: &TestNopDialer{}} 28 | if _, err := b.Dial("tcp", "hello.world:80"); err == ErrBlockedHost { 29 | return 30 | } 31 | t.FailNow() 32 | } 33 | 34 | func TestBlocklistDialerLoadHosts(t *testing.T) { 35 | hostsFile := []byte("127.0.0.1 localhost\n0.0.0.0 hello.world\n# the next line tests 2 host names for one destination address\n0.0.0.0 hello.world.too hello.world.future\n") 36 | b := BlocklistDialer{List: make(map[string]struct{}, 0), Dialer: &TestNopDialer{}} 37 | b.Load(bytes.NewReader(hostsFile)) 38 | if _, err := b.Dial("tcp", "hello.world:80"); err != ErrBlockedHost { 39 | t.FailNow() 40 | } 41 | if _, err := b.Dial("tcp", "hello.world.too:443"); err != ErrBlockedHost { 42 | t.FailNow() 43 | } 44 | if _, err := b.Dial("tcp", "hello.world.future:443"); err != ErrBlockedHost { 45 | t.FailNow() 46 | } 47 | if _, err := b.Dial("tcp", "hello.world.past:80"); err != nil { 48 | t.FailNow() 49 | } 50 | } 51 | 52 | func TestLoadBlocklistFromFile(t *testing.T) { 53 | dialer := BlocklistDialer{List: make(map[string]struct{}, 0), Dialer: &TestNopDialer{}} 54 | loadHostsFile(&dialer, "test/hosts") 55 | if _, err := dialer.Dial("tcp", "hello.world:443"); err != ErrBlockedHost { 56 | t.Fail() 57 | } 58 | if _, err := dialer.Dial("tcp", "hello.past:80"); err != ErrBlockedHost { 59 | t.Fail() 60 | } 61 | if _, err := dialer.Dial("tcp", "hello.future:443"); err != nil { 62 | t.Fail() 63 | } 64 | } 65 | 66 | type TestNopDialer struct{} 67 | 68 | func (*TestNopDialer) Dial(network, addr string) (net.Conn, error) { 69 | return nil, nil 70 | } 71 | -------------------------------------------------------------------------------- /cmd/proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "net/http" 7 | "os" 8 | "syscall" 9 | 10 | "github.com/cobratbq/goutils/std/log" 11 | net_ "github.com/cobratbq/goutils/std/net" 12 | "github.com/cobratbq/goutils/std/strings" 13 | "github.com/cobratbq/httprelay" 14 | "golang.org/x/net/proxy" 15 | ) 16 | 17 | func main() { 18 | listenAddr := flag.String("listen", ":8080", "Listening address and port for HTTP relay proxy.") 19 | blockAddrs := flag.String("block", "", "Comma-separated list of blocked host names, zone names, ip addresses and CIDR addresses.") 20 | blockLocal := flag.Bool("block-local", true, "Block known local addresses.") 21 | blocklist := flag.String("blocklist", "", "Filename referring to a hosts-formatted blocklist.") 22 | flag.Parse() 23 | // Prepare proxy dialer 24 | baseDialer := httprelay.DirectDialer() 25 | var dialer proxy.Dialer = &baseDialer 26 | if *blocklist != "" { 27 | log.Infoln("Loading blocklist from file:", *blocklist) 28 | var wrapErr error 29 | if dialer, wrapErr = httprelay.WrapBlocklistBlocking(dialer, *blocklist); wrapErr != nil { 30 | log.Errorln("Failed to load blocklist:", wrapErr.Error()) 31 | os.Exit(1) 32 | } 33 | } 34 | if *blockLocal || *blockAddrs != "" { 35 | log.Infoln("Blocking local addresses:", *blockLocal, ", custom addresses:", 36 | strings.OrDefault(*blockAddrs, "")) 37 | dialer = httprelay.WrapPerHostBlocking(dialer, *blockLocal, *blockAddrs) 38 | } 39 | 40 | // Start HTTP proxy server 41 | listener, listenErr := net_.ListenWithOptions(context.Background(), "tcp", *listenAddr, 42 | map[net_.Option]int{{Level: syscall.SOL_IP, Option: syscall.IP_FREEBIND}: 1}) 43 | if listenErr != nil { 44 | log.Errorln("Failed to open local address for proxy:", listenErr.Error()) 45 | os.Exit(1) 46 | } 47 | handler := httprelay.HTTPProxyHandler{Dialer: dialer, UserAgent: ""} 48 | server := http.Server{Handler: &handler} 49 | log.Infoln("HTTP proxy server started on", *listenAddr) 50 | log.Infoln(server.Serve(listener)) 51 | } 52 | -------------------------------------------------------------------------------- /cmd/relay/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "net/http" 7 | "os" 8 | "syscall" 9 | 10 | "github.com/cobratbq/goutils/std/log" 11 | net_ "github.com/cobratbq/goutils/std/net" 12 | "github.com/cobratbq/goutils/std/strings" 13 | "github.com/cobratbq/httprelay" 14 | "golang.org/x/net/proxy" 15 | ) 16 | 17 | func main() { 18 | socksAddr := flag.String("socks", "localhost:8000", "Address and port of SOCKS5 proxy server.") 19 | socksUsername := flag.String("socks-user", "", "Username for accessing the SOCKS5 proxy server.") 20 | socksPassword := flag.String("socks-pass", "", "Password for accessing the SOCKS5 proxy server.") 21 | listenAddr := flag.String("listen", ":8080", "Listening address and port for HTTP relay proxy.") 22 | blockAddrs := flag.String("block", "", "Comma-separated list of blocked host names, zone names, ip addresses and CIDR addresses.") 23 | blockLocal := flag.Bool("block-local", true, "Block known local addresses.") 24 | blocklist := flag.String("blocklist", "", "Filename referring to a hosts-formatted blocklist.") 25 | flag.Parse() 26 | // Compose SOCKS auth 27 | var auth *proxy.Auth 28 | if *socksUsername != "" && *socksPassword != "" { 29 | auth = new(proxy.Auth) 30 | auth.User = *socksUsername 31 | auth.Password = *socksPassword 32 | } 33 | // Prepare proxy relay with target SOCKS proxy 34 | baseDialer := httprelay.DirectDialer() 35 | dialer, err := proxy.SOCKS5("tcp", *socksAddr, auth, &baseDialer) 36 | if err != nil { 37 | log.Errorln("Failed to create proxy definition:", err.Error()) 38 | os.Exit(1) 39 | } 40 | if *blocklist != "" { 41 | log.Infoln("Loading blocklist from file:", *blocklist) 42 | var wrapErr error 43 | if dialer, wrapErr = httprelay.WrapBlocklistBlocking(dialer, *blocklist); wrapErr != nil { 44 | log.Errorln("Failed to load blocklist:", wrapErr.Error()) 45 | os.Exit(1) 46 | } 47 | } 48 | if *blockLocal || *blockAddrs != "" { 49 | log.Infoln("Blocking local addresses:", *blockLocal, ", custom addresses:", 50 | strings.OrDefault(*blockAddrs, "")) 51 | dialer = httprelay.WrapPerHostBlocking(dialer, *blockLocal, *blockAddrs) 52 | } 53 | // Start HTTP proxy server 54 | listener, listenErr := net_.ListenWithOptions(context.Background(), "tcp", *listenAddr, 55 | map[net_.Option]int{{Level: syscall.SOL_IP, Option: syscall.IP_FREEBIND}: 1}) 56 | if listenErr != nil { 57 | log.Errorln("Failed to open local address for proxy:", listenErr.Error()) 58 | os.Exit(1) 59 | } 60 | handler := httprelay.HTTPProxyHandler{Dialer: dialer, UserAgent: ""} 61 | server := http.Server{Handler: &handler} 62 | log.Infoln("HTTP proxy relay server started on", *listenAddr, "relaying to SOCKS proxy", *socksAddr) 63 | log.Infoln(server.Serve(listener)) 64 | } 65 | -------------------------------------------------------------------------------- /fuzz.go: -------------------------------------------------------------------------------- 1 | package httprelay 2 | 3 | // Fuzz is go-fuzz's fuzzing function. Though there is not much to fuzz. 4 | func Fuzz(data []byte) int { 5 | var val = string(data) 6 | var dropHdrs = map[string]struct{}{} 7 | bad := processConnectionHdr(dropHdrs, val) 8 | if len(dropHdrs) > 0 { 9 | return 1 10 | } 11 | if len(bad) == 0 { 12 | return 1 13 | } 14 | fullHost(val) 15 | return 0 16 | } 17 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cobratbq/httprelay 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/cobratbq/goutils v0.0.0-20250130155948-c1b4e3e5e9d5 7 | golang.org/x/net v0.34.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cobratbq/goutils v0.0.0-20230802185225-d540f72572df h1:UIzkAa2q6TTVnC4Q4OYB2T2RmareW8XQumR49KMiR34= 2 | github.com/cobratbq/goutils v0.0.0-20230802185225-d540f72572df/go.mod h1:ZQi9/vUKrqj15zquA2rL9QRXIumlYduVScl5lvS5fsc= 3 | github.com/cobratbq/goutils v0.0.0-20231211204757-91366acbc19b h1:/7jEolPcRduOgMyy5Rcmjn/c1ofiq7Jh1EUEb/Pz628= 4 | github.com/cobratbq/goutils v0.0.0-20231211204757-91366acbc19b/go.mod h1:ZQi9/vUKrqj15zquA2rL9QRXIumlYduVScl5lvS5fsc= 5 | github.com/cobratbq/goutils v0.0.0-20240617174750-7c91075dc8a1 h1:Y/kx0A89Pkt8shCbiUmdI7h3rn2NjAsQJ7jmwy6qNn8= 6 | github.com/cobratbq/goutils v0.0.0-20240617174750-7c91075dc8a1/go.mod h1:ZQi9/vUKrqj15zquA2rL9QRXIumlYduVScl5lvS5fsc= 7 | github.com/cobratbq/goutils v0.0.0-20241026021411-4359d654fc62 h1:2oWpte9OtAL1iwzBQP8AF5sDi/BuSBSq2ec6dtPSk88= 8 | github.com/cobratbq/goutils v0.0.0-20241026021411-4359d654fc62/go.mod h1:ZQi9/vUKrqj15zquA2rL9QRXIumlYduVScl5lvS5fsc= 9 | github.com/cobratbq/goutils v0.0.0-20241201184206-c6af5f6d69cb h1:n0zONAffW5wVyzK0S61Qs7aRNWCzU1G4XR/ZFfgcSKk= 10 | github.com/cobratbq/goutils v0.0.0-20241201184206-c6af5f6d69cb/go.mod h1:R3RuxFTWkwhpJtqQdku1cJqyeSuE4VboBT6H/gmLuHY= 11 | github.com/cobratbq/goutils v0.0.0-20250130155948-c1b4e3e5e9d5 h1:up/bnzQzt+bcSOeCcqPc1xNaytKuXaIKIo/6CgCx0EA= 12 | github.com/cobratbq/goutils v0.0.0-20250130155948-c1b4e3e5e9d5/go.mod h1:R3RuxFTWkwhpJtqQdku1cJqyeSuE4VboBT6H/gmLuHY= 13 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 14 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 15 | golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= 16 | golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= 17 | golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= 18 | golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= 19 | golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= 20 | golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= 21 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 22 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 23 | -------------------------------------------------------------------------------- /nopdialer.go: -------------------------------------------------------------------------------- 1 | package httprelay 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/cobratbq/goutils/std/errors" 7 | ) 8 | 9 | // NopDialer does not perform dialing operation as host is blocked. 10 | type NopDialer struct{} 11 | 12 | // Dial performs no-op dial operation. 13 | func (NopDialer) Dial(network, addr string) (net.Conn, error) { 14 | return nil, ErrBlockedHost 15 | } 16 | 17 | // ErrBlockedHost indicates that host is blocked. 18 | var ErrBlockedHost = errors.NewStringError("host is blocked") 19 | -------------------------------------------------------------------------------- /nopdialer_test.go: -------------------------------------------------------------------------------- 1 | package httprelay 2 | 3 | import "testing" 4 | 5 | func TestNopDialerDial(t *testing.T) { 6 | d := &NopDialer{} 7 | if conn, err := d.Dial("tcp", "www.google.com"); conn != nil || err == nil { 8 | t.Fatal("Expected error from NopDialer, but got actual connection or no error.") 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /proxy.go: -------------------------------------------------------------------------------- 1 | package httprelay 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "net" 8 | "net/http" 9 | "sync" 10 | "time" 11 | 12 | "github.com/cobratbq/goutils/std/errors" 13 | io_ "github.com/cobratbq/goutils/std/io" 14 | "github.com/cobratbq/goutils/std/log" 15 | http_ "github.com/cobratbq/goutils/std/net/http" 16 | "golang.org/x/net/proxy" 17 | ) 18 | 19 | func DirectDialer() net.Dialer { 20 | return net.Dialer{ 21 | Timeout: 0, 22 | Deadline: time.Time{}, 23 | KeepAlive: -1, 24 | FallbackDelay: -1, 25 | } 26 | } 27 | 28 | // mnot's blog: https://www.mnot.net/blog/2011/07/11/what_proxies_must_do 29 | // rfc: http://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-14#section-3.3 30 | // 31 | // 0. Advertise HTTP/1.1 Correctly - this is covered by starting a normal HTTP 32 | // client connection through the SOCKS proxy which establishes a (sane) 33 | // connection according to its own parameters, instead of blindly copying 34 | // parameters from the original requesting client's connection. 35 | // 36 | // 1. Remove Hop-by-hop Headers - this is covered in the copyHeaders function 37 | // which explicitly skips known hop-by-hop headers and checks 'Connection' 38 | // header for additional headers we need to skip. 39 | // 40 | // Remarks from the blog post not covered explicitly are tested in the tests 41 | // assumptions_test.go 42 | 43 | // HTTPProxyHandler is a proxy handler that passes on request to a SOCKS5 proxy server. 44 | type HTTPProxyHandler struct { 45 | // Dialer is the dialer for connecting to the SOCKS5 proxy. 46 | Dialer proxy.Dialer 47 | UserAgent string 48 | } 49 | 50 | func (h *HTTPProxyHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { 51 | var err error 52 | switch req.Method { 53 | case "CONNECT": 54 | // TODO Go 1.20 added an OnProxyConnect callback for use by proxies. This probably voids the use for connection hijacking. Investigate and possibly use. 55 | err = h.handleConnect(resp, req) 56 | default: 57 | err = h.processRequest(resp, req) 58 | } 59 | if err != nil { 60 | log.Warnln("Error serving request:", err.Error()) 61 | } 62 | } 63 | 64 | // TODO append body that explains the error as is expected from 5xx http status codes 65 | func (h *HTTPProxyHandler) processRequest(resp http.ResponseWriter, req *http.Request) error { 66 | // TODO what to do when body of request is very large? 67 | body, err := io.ReadAll(req.Body) 68 | if err != nil { 69 | resp.WriteHeader(http.StatusInternalServerError) 70 | return err 71 | } 72 | io_.CloseLogged(req.Body, "Failed to close request body: %+v") 73 | // The request body is only closed in certain error cases. In other cases, we 74 | // let body be closed by during processing of request to remote host. 75 | log.Infoln(req.Proto, req.Method, req.URL.Host) 76 | // Verification of requests is already handled by net/http library. 77 | // Establish connection with socks proxy 78 | conn, err := h.Dialer.Dial("tcp", fullHost(req.URL.Host)) 79 | if err == ErrBlockedHost { 80 | resp.WriteHeader(http.StatusForbidden) 81 | return errors.Context(err, "host '"+req.URL.Host+"'") 82 | } else if err != nil { 83 | resp.WriteHeader(http.StatusInternalServerError) 84 | return errors.Context(err, "failed to connect to host") 85 | } 86 | defer io_.CloseLoggedWithIgnores(conn, "Error closing connection to socks proxy: %+v", io.ErrClosedPipe) 87 | // Prepare request for socks proxy 88 | proxyReq, err := http.NewRequest(req.Method, req.RequestURI, bytes.NewReader(body)) 89 | if err != nil { 90 | resp.WriteHeader(http.StatusInternalServerError) 91 | return err 92 | } 93 | // Transfer headers to proxy request 94 | copyHeaders(proxyReq.Header, req.Header) 95 | if h.UserAgent != "" { 96 | // Add specified user agent as header. 97 | proxyReq.Header.Add("User-Agent", h.UserAgent) 98 | } 99 | // Send request to socks proxy 100 | if err = proxyReq.Write(conn); err != nil { 101 | resp.WriteHeader(http.StatusInternalServerError) 102 | return err 103 | } 104 | // Read proxy response 105 | proxyRespReader := bufio.NewReader(conn) 106 | proxyResp, err := http.ReadResponse(proxyRespReader, proxyReq) 107 | if err != nil { 108 | resp.WriteHeader(http.StatusInternalServerError) 109 | return err 110 | } 111 | // Transfer headers to client response 112 | copyHeaders(resp.Header(), proxyResp.Header) 113 | resp.Header().Set("Connection", "close") 114 | // Verification of response is already handled by net/http library. 115 | resp.WriteHeader(proxyResp.StatusCode) 116 | _, err = io.Copy(resp, proxyResp.Body) 117 | io_.CloseLoggedWithIgnores(proxyResp.Body, "Error closing response body: %+v", io.ErrClosedPipe) 118 | return err 119 | } 120 | 121 | // TODO append body that explains the error as is expected from 5xx http status codes 122 | func (h *HTTPProxyHandler) handleConnect(resp http.ResponseWriter, req *http.Request) error { 123 | defer io_.CloseLoggedWithIgnores(req.Body, "Error while closing request body: %+v", io.ErrClosedPipe) 124 | log.Infoln(req.Proto, req.Method, req.URL.Host) 125 | // Establish connection with socks proxy 126 | proxyConn, err := h.Dialer.Dial("tcp", req.Host) 127 | if err == ErrBlockedHost { 128 | resp.WriteHeader(http.StatusForbidden) 129 | return err 130 | } else if err != nil { 131 | resp.WriteHeader(http.StatusInternalServerError) 132 | return err 133 | } 134 | defer io_.CloseLoggedWithIgnores(proxyConn, "Failed to close connection to remote location: %+v", io.ErrClosedPipe) 135 | // Acquire raw connection to the client 136 | clientInput, clientConn, err := http_.HijackConnection(resp) 137 | if err != nil { 138 | resp.WriteHeader(http.StatusInternalServerError) 139 | return err 140 | } 141 | defer io_.CloseLoggedWithIgnores(clientConn, "Failed to close connection to local client: %+v", io.ErrClosedPipe) 142 | // Send 200 Connection established to client to signal tunnel ready 143 | // Responses to CONNECT requests MUST NOT contain any body payload. 144 | // TODO add additional headers to proxy server's response? (Via) 145 | if _, err = clientConn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil { 146 | resp.WriteHeader(http.StatusInternalServerError) 147 | return err 148 | } 149 | // Start copying data from one connection to the other 150 | var wg sync.WaitGroup 151 | wg.Add(2) 152 | go io_.Transfer(&wg, proxyConn, clientInput) 153 | go io_.Transfer(&wg, clientConn, proxyConn) 154 | wg.Wait() 155 | return nil 156 | } 157 | -------------------------------------------------------------------------------- /test/hosts: -------------------------------------------------------------------------------- 1 | 127.0.0.1 localhost 2 | 3 | # Blocklist starts here ... 4 | 0.0.0.0 hello.world 5 | 0.0.0.0 hello.past 6 | 7 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package httprelay 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/cobratbq/goutils/std/builtin/set" 9 | ) 10 | 11 | // headers that are dedicated to a single connection and should not copied to 12 | // the SOCKS proxy server connection 13 | var hopByHopHeaders = set.Create(connectionHeader, "Keep-Alive", "Proxy-Authorization", 14 | "Proxy-Authentication", "TE", "Trailer", "Transfer-Encoding", "Upgrade") 15 | 16 | // connectionHeader is the 'Connection' header which is an indicator for other 17 | // headers that should be dropped as hop-by-hop headers. 18 | const connectionHeader = "Connection" 19 | 20 | // fullHost appends the default port to the provided host if no port is specified. 21 | // FIXME check if this causes problems in case of IPv6 22 | func fullHost(host string) string { 23 | fullhost := host 24 | if strings.IndexByte(host, ':') == -1 { 25 | fullhost += ":80" 26 | } 27 | return fullhost 28 | } 29 | 30 | // copyHeaders copies all the headers that are not classified as hop-to-hop headers. (This satisfies 31 | // 1. Remove Hop-by-hop Headers.) 32 | func copyHeaders(dst http.Header, src http.Header) { 33 | var dynDropHdrs = map[string]struct{}{} 34 | if vals, ok := src[connectionHeader]; ok { 35 | for _, v := range vals { 36 | processConnectionHdr(dynDropHdrs, v) 37 | } 38 | } 39 | for k, vals := range src { 40 | // This assumes that Connection header is also an element of 41 | // hop-by-hop headers such that it will not be processed twice, 42 | // but instead is dropped with the others. 43 | if _, drop := hopByHopHeaders[k]; drop { 44 | continue 45 | } else if _, drop := dynDropHdrs[k]; drop { 46 | continue 47 | } 48 | for _, v := range vals { 49 | dst.Add(k, v) 50 | } 51 | } 52 | } 53 | 54 | // processConnectionHdr processes the Connection header and adds all headers listed in value as 55 | // droppable headers. 56 | func processConnectionHdr(dropHdrs map[string]struct{}, value string) []string { 57 | var bad []string 58 | parts := strings.Split(value, ",") 59 | for _, part := range parts { 60 | header := strings.TrimSpace(part) 61 | if tokenPattern.MatchString(header) { 62 | set.Insert(dropHdrs, header) 63 | } else { 64 | bad = append(bad, header) 65 | } 66 | } 67 | return bad 68 | } 69 | 70 | // tokenPattern is the pattern of a valid token. 71 | var tokenPattern = regexp.MustCompile(`^[\d\w\!#\$%&'\*\+\-\.\^_\|~` + "`" + `]+$`) 72 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package httprelay 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "testing" 7 | 8 | assert "github.com/cobratbq/goutils/std/testing" 9 | ) 10 | 11 | func TestProcessConnectionHdr(t *testing.T) { 12 | headers := map[string]struct{}{} 13 | processConnectionHdr(headers, "Keep-Alive, Foo ,Bar") 14 | assert.Equal(t, len(headers), 3) 15 | assert.ElementPresent(t, headers, "Keep-Alive") 16 | assert.ElementPresent(t, headers, "Foo") 17 | assert.ElementPresent(t, headers, "Bar") 18 | } 19 | 20 | func TestFullHost(t *testing.T) { 21 | var tests = map[string]string{ 22 | "localhost": "localhost:80", 23 | "localhost:1234": "localhost:1234", 24 | "bla": "bla:80", 25 | "www.google.com": "www.google.com:80", 26 | "www.google.com:80": "www.google.com:80", 27 | "www.google.com:443": "www.google.com:443", 28 | "google.com:8080": "google.com:8080", 29 | } 30 | var result string 31 | for src, dst := range tests { 32 | result = fullHost(src) 33 | assert.Equal(t, result, dst) 34 | } 35 | } 36 | 37 | func TestProcessConnectionHdrs(t *testing.T) { 38 | var hdrs = map[string]struct{}{} 39 | var val = "Keep-Alive , \tFoo,bar" 40 | processConnectionHdr(hdrs, val) 41 | assert.Equal(t, len(hdrs), 3) 42 | assert.ElementPresent(t, hdrs, "Keep-Alive") 43 | assert.ElementPresent(t, hdrs, "Foo") 44 | assert.ElementPresent(t, hdrs, "bar") 45 | } 46 | 47 | func TestProcessConnectionHdrsBad(t *testing.T) { 48 | var hdrs = map[string]struct{}{} 49 | var val = "Illegal spaces, Capiche?, close" 50 | processConnectionHdr(hdrs, val) 51 | assert.Equal(t, len(hdrs), 1) 52 | log.Printf("Headers: %#v\n", hdrs) 53 | assert.ElementAbsent(t, hdrs, "Illegal spaces") 54 | assert.ElementAbsent(t, hdrs, "Capiche?") 55 | assert.ElementPresent(t, hdrs, "close") 56 | } 57 | 58 | func TestCopyHeaders(t *testing.T) { 59 | var src = http.Header{} 60 | src.Add("Transfer-Encoding", "gzip") 61 | src.Add("Content-Type", "image/jpeg") 62 | src.Add("Trailer", "something") 63 | src.Add("Content-Encoding", "gzip") 64 | src.Add("Via", "bla:1234") 65 | src.Add("Connection", "Keep-Alive, Foo") 66 | src.Add("Keep-Alive", "close") 67 | src.Add("Foo", "Bar") 68 | var dst = http.Header{} 69 | copyHeaders(dst, src) 70 | var k string 71 | if len(dst) != 3 { 72 | t.Errorf("Expected exactly 2 headers, but found a different number: %#v\n", dst) 73 | } 74 | for k = range hopByHopHeaders { 75 | // check simple dropped headers 76 | assert.KeyAbsent(t, dst, k) 77 | } 78 | for _, k = range []string{"Connection", "Keep-Alive", "Foo"} { 79 | // check special treatment of Connection header and its values 80 | assert.KeyAbsent(t, dst, k) 81 | } 82 | for _, k = range []string{"Content-Type", "Content-Encoding", "Via"} { 83 | // check remaining headers 84 | assert.KeyPresent(t, dst, k) 85 | } 86 | } 87 | --------------------------------------------------------------------------------