├── .gitignore ├── CODEOWNERS ├── snap ├── local │ └── start-aproxy ├── hooks │ └── configure └── snapcraft.yaml ├── go.mod ├── go.sum ├── .github ├── .jira_sync_config.yaml └── workflows │ ├── tests.yaml │ ├── publish.yaml │ └── integration-tests.yaml ├── syscall_linux.go ├── SECURITY.md ├── aproxy_test.go ├── README.md ├── LICENSE └── aproxy.go /.gitignore: -------------------------------------------------------------------------------- 1 | aproxy 2 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @canonical/platform-engineering -------------------------------------------------------------------------------- /snap/local/start-aproxy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | eval "exec aproxy $(cat $SNAP_DATA/args)" 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module aproxy 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require golang.org/x/crypto v0.45.0 8 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= 2 | golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 3 | -------------------------------------------------------------------------------- /.github/.jira_sync_config.yaml: -------------------------------------------------------------------------------- 1 | settings: 2 | add_gh_comment: true 3 | components: 4 | - Aproxy 5 | epic_key: ISD-3981 6 | jira_project_key: ISD 7 | label_mapping: 8 | bug: Bug 9 | enhancement: Story 10 | status_mapping: 11 | closed: done 12 | not_planned: rejected 13 | opened: Untriaged 14 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | workflow_call: 6 | 7 | jobs: 8 | test: 9 | name: Run Tests 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v2 17 | with: 18 | go-version: 1.21 19 | 20 | - name: Ensure No Formatting Changes 21 | run: | 22 | go fmt ./... 23 | git diff --exit-code 24 | 25 | - name: Build and Test 26 | run: | 27 | go test -race ./... 28 | -------------------------------------------------------------------------------- /syscall_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package main 4 | 5 | import ( 6 | "encoding/binary" 7 | "fmt" 8 | "net" 9 | "syscall" 10 | "unsafe" 11 | ) 12 | 13 | func GetsockoptIPv4OriginalDst(fd uintptr) (*net.TCPAddr, error) { 14 | var sockaddr [16]byte 15 | var size uint32 = 16 16 | _, _, e := syscall.Syscall6( 17 | syscall.SYS_GETSOCKOPT, 18 | fd, 19 | uintptr(syscall.SOL_IP), 20 | uintptr(80), // SO_ORIGINAL_DST 21 | uintptr(unsafe.Pointer(&sockaddr)), 22 | uintptr(unsafe.Pointer(&size)), 23 | 0, 24 | ) 25 | if e != 0 { 26 | return nil, fmt.Errorf("getsockopt SO_ORIGINAL_DST failed: errno %d", e) 27 | } 28 | return &net.TCPAddr{ 29 | IP: sockaddr[4:8], 30 | Port: int(binary.BigEndian.Uint16(sockaddr[2:4])), 31 | }, nil 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | tests: 9 | uses: ./.github/workflows/tests.yaml 10 | 11 | integration-tests: 12 | uses: ./.github/workflows/integration-tests.yaml 13 | 14 | publish: 15 | name: Publish Aproxy 16 | runs-on: ubuntu-latest 17 | needs: [ tests, integration-tests ] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | 22 | - name: Build Aproxy Snap 23 | id: snapcraft-build 24 | uses: snapcore/action-build@v1 25 | 26 | - name: Publish Aproxy 27 | env: 28 | SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPSTORE_TOKEN }} 29 | run: | 30 | for snap in aproxy*.snap 31 | do 32 | snapcraft upload $snap --release edge 33 | done 34 | -------------------------------------------------------------------------------- /snap/hooks/configure: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | [ -z "$(snapctl get listen)" ] && snapctl set listen=":8443" 5 | 6 | validate_proxy() { 7 | local hostport="$1" 8 | local host 9 | local port 10 | 11 | host="${hostport%:*}" 12 | port="${hostport#*:}" 13 | 14 | if [[ ! "$host" =~ ^[a-zA-Z0-9.-]+$ ]]; then 15 | echo "invalid proxy: '$hostport'" 16 | return 1 17 | fi 18 | 19 | if ! [[ "$port" =~ ^[0-9]+$ ]] || (( port <= 0 || port > 65535 )); then 20 | echo "invalid proxy: '$hostport'" 21 | return 1 22 | fi 23 | 24 | return 0 25 | } 26 | 27 | proxy="$(snapctl get proxy)" 28 | listen="$(snapctl get listen)" 29 | 30 | if [ -z "${proxy}" ]; then 31 | echo "set upstream proxy using \`snap set aproxy proxy=example:1234\`" 32 | exit 0 33 | fi 34 | 35 | validate_proxy "$proxy" 36 | 37 | echo "--proxy $proxy --listen $listen" > $SNAP_DATA/args 38 | 39 | snapctl stop ${SNAP_NAME}.aproxy 40 | snapctl start ${SNAP_NAME}.aproxy --enable 41 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security policy 2 | 3 | ## What qualifies as a security issue 4 | 5 | Credentials leakage, outdated dependencies with known vulnerabilities, and 6 | other issues that could lead to unprivileged or unauthorised access to the 7 | database or the system. 8 | 9 | ## Reporting a vulnerability 10 | 11 | The easiest way to report a security issue is through GitHub. 12 | See [Privately reporting a security 13 | vulnerability](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability) 14 | for instructions. 15 | 16 | The repository admins will be notified of the issue and will work with you 17 | to determine whether the issue qualifies as a security issue and, if so, in 18 | which component. We will then handle figuring out a fix, getting a CVE 19 | assigned and coordinating the release of the fix. 20 | 21 | The [Ubuntu Security disclosure and embargo policy](https://ubuntu.com/security/disclosure-policy) 22 | contains more information about what you can expect when you contact us, and what we 23 | expect from you. 24 | -------------------------------------------------------------------------------- /snap/snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: aproxy 2 | version: 0.2.5 3 | summary: Transparent proxy for HTTP and HTTPS/TLS connections. 4 | description: | 5 | Aproxy is a transparent proxy for HTTP and HTTPS/TLS connections. By 6 | pre-reading the Host header in HTTP requests and the SNI in TLS client 7 | hellos, it forwards HTTP proxy requests with the hostname, therefore, 8 | complies with HTTP proxies requiring destination hostname for auditing or 9 | access control. 10 | 11 | license: Apache-2.0 12 | base: core22 13 | grade: stable 14 | confinement: strict 15 | architectures: 16 | - build-on: amd64 17 | build-for: amd64 18 | - build-on: amd64 19 | build-for: arm64 20 | - build-on: amd64 21 | build-for: s390x 22 | - build-on: amd64 23 | build-for: ppc64el 24 | 25 | apps: 26 | aproxy: 27 | command: start-aproxy 28 | daemon: simple 29 | install-mode: disable 30 | plugs: 31 | - network 32 | - network-bind 33 | 34 | parts: 35 | aproxy: 36 | plugin: nil 37 | source: . 38 | build-snaps: 39 | - go 40 | override-build: | 41 | snapcraftctl build 42 | if [ $SNAPCRAFT_TARGET_ARCH == ppc64el ]; then 43 | export GOARCH=ppc64le 44 | else 45 | export GOARCH=$SNAPCRAFT_TARGET_ARCH 46 | fi 47 | export CGO_ENABLED=0 48 | go mod download 49 | go build -ldflags="-w -s" 50 | mkdir ${SNAPCRAFT_PART_INSTALL}/bin 51 | cp aproxy ${SNAPCRAFT_PART_INSTALL}/bin 52 | install-start-script: 53 | plugin: dump 54 | source: ./snap/local 55 | prime: 56 | - start-aproxy 57 | 58 | hooks: 59 | configure: { } 60 | -------------------------------------------------------------------------------- /aproxy_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/hex" 5 | "io" 6 | "net" 7 | "testing" 8 | ) 9 | 10 | func TestPrereadConn(t *testing.T) { 11 | remote, local := net.Pipe() 12 | go remote.Write([]byte("hello, world")) 13 | preread := &PrereadConn{conn: local} 14 | buf := make([]byte, 5) 15 | _, err := preread.Read(buf) 16 | if err != nil { 17 | t.Fatalf("Read failed during preread: %s", err) 18 | } 19 | buf = make([]byte, 3) 20 | _, err = preread.Read(buf) 21 | if err != nil { 22 | t.Fatalf("Read failed during preread: %s", err) 23 | } 24 | preread.EndPreread() 25 | buf2 := make([]byte, 12) 26 | _, err = io.ReadFull(preread, buf2) 27 | if err != nil { 28 | t.Fatalf("Read failed after preread: %s", err) 29 | } 30 | if string(buf2) != "hello, world" { 31 | t.Fatalf("preread altered the read state: got %s", string(buf2)) 32 | } 33 | } 34 | 35 | func TestPrereadSNI(t *testing.T) { 36 | remote, local := net.Pipe() 37 | // data obtained from https://gitlab.com/wireshark/wireshark/-/blob/master/test/captures/tls12-aes256gcm.pcap 38 | clientHello, _ := hex.DecodeString("160301004f0100004b0303588e60d1d96bad5f1fcf0b8818466257d73385bdaaed0ac4bfd7228a6da059ad00000200a9010000200005000501000000000000000e000c0000096c6f63616c686f7374ff01000100") 39 | go remote.Write(clientHello) 40 | sni, err := PrereadSNI(NewPrereadConn(local)) 41 | if err != nil { 42 | t.Fatalf("PrereadSNI failed: %s", err) 43 | } 44 | if sni != "localhost" { 45 | t.Fatalf("PrereadSNI returns incorrect SNI: expected: localhost, got %s", sni) 46 | } 47 | } 48 | 49 | func TestPrereadHttpHost(t *testing.T) { 50 | remote, local := net.Pipe() 51 | go remote.Write([]byte("GET / HTTP/1.1\r\nHost: example.com\r\nAccept: */*\r\n\r\n")) 52 | host, err := PrereadHttpHost(NewPrereadConn(local)) 53 | if err != nil { 54 | t.Fatalf("PrereadHttpHost failed: %s", err) 55 | } 56 | if host != "example.com" { 57 | t.Fatalf("PrereadHttpHost returns incorrect host: expected: example.com, got %s", host) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aproxy - transparent proxy for HTTP and HTTPS/TLS 2 | 3 | Aproxy is a transparent proxy for HTTP and HTTPS/TLS connections. By pre-reading 4 | the Host header in HTTP requests and the SNI in TLS client hellos, it forwards 5 | HTTP proxy requests with the hostname, therefore, complies with HTTP proxies 6 | requiring destination hostname for auditing or access control. 7 | 8 | ## Usage 9 | 10 | Install aproxy using snap, and configure the upstream http proxy. 11 | 12 | ```bash 13 | sudo snap install aproxy --edge 14 | sudo snap set aproxy proxy=squid.internal:3128 15 | ``` 16 | 17 | Create the following nftables rules to redirect outbound traffic to aproxy on 18 | the same machine. Please note that aproxy for now only works with IPv4, 19 | supporting only HTTP on port 80 and HTTPS/TLS on port 443. 20 | 21 | ```bash 22 | sudo nft -f - << EOF 23 | define default-ip = $(ip route get $(ip route show 0.0.0.0/0 | grep -oP 'via \K\S+') | grep -oP 'src \K\S+') 24 | define private-ips = { 10.0.0.0/8, 127.0.0.1/8, 172.16.0.0/12, 192.168.0.0/16 } 25 | table ip aproxy 26 | flush table ip aproxy 27 | table ip aproxy { 28 | chain prerouting { 29 | type nat hook prerouting priority dstnat; policy accept; 30 | ip daddr != \$private-ips tcp dport { 80, 443 } counter dnat to \$default-ip:8443 31 | } 32 | 33 | chain output { 34 | type nat hook output priority -100; policy accept; 35 | ip daddr != \$private-ips tcp dport { 80, 443 } counter dnat to \$default-ip:8443 36 | } 37 | } 38 | EOF 39 | ``` 40 | 41 | You can inspect the access logs of aproxy using: 42 | 43 | ```bash 44 | sudo snap logs aproxy.aproxy -n=all 45 | ``` 46 | 47 | ## Running from Source 48 | 49 | To run this application directly from the source code, you'll need to have Go 50 | 1.21 installed on your system. 51 | 52 | Follow these steps to get started: 53 | 54 | ```bash 55 | git clone https://github.com/canonical/aproxy.git 56 | cd aproxy 57 | go mod download 58 | go run . --proxy=squid.internal:3128 59 | ``` 60 | -------------------------------------------------------------------------------- /.github/workflows/integration-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Integration Tests 2 | 3 | on: 4 | pull_request: 5 | workflow_call: 6 | 7 | jobs: 8 | integration-test: 9 | name: Run Integration Tests 10 | runs-on: [ self-hosted, linux, x64, jammy, large ] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Build aproxy Snap 16 | id: snapcraft-build 17 | uses: snapcore/action-build@v1 18 | with: 19 | snapcraft-args: --build-for amd64 20 | 21 | - name: Upload aproxy Snap 22 | uses: actions/upload-artifact@v4 23 | with: 24 | name: snap 25 | path: aproxy*.snap 26 | 27 | - name: Install aproxy Snap 28 | run: | 29 | sudo snap install --dangerous aproxy_*_amd64.snap 30 | 31 | - name: Show aproxy Configuration 32 | run: | 33 | sudo snap get aproxy 34 | 35 | - name: Configure aproxy 36 | run: | 37 | sudo nft -f - << EOF 38 | define default-ip = $(ip route get $(ip route show 0.0.0.0/0 | grep -oP 'via \K\S+') | grep -oP 'src \K\S+') 39 | define private-ips = { 10.0.0.0/8, 127.0.0.1/8, 172.16.0.0/12, 192.168.0.0/16 } 40 | define aproxy-port = $(sudo snap get aproxy listen | cut -d ":" -f 2) 41 | table ip aproxy 42 | flush table ip aproxy 43 | table ip aproxy { 44 | chain prerouting { 45 | type nat hook prerouting priority dstnat; policy accept; 46 | ip daddr != \$private-ips tcp dport { 80, 443, 11371, 4242 } counter dnat to \$default-ip:\$aproxy-port 47 | } 48 | 49 | chain output { 50 | type nat hook output priority -100; policy accept; 51 | ip daddr != \$private-ips tcp dport { 80, 443, 11371, 4242 } counter dnat to \$default-ip:\$aproxy-port 52 | } 53 | } 54 | EOF 55 | 56 | - name: Start tcpdump 57 | run: | 58 | sudo tcpdump -i any -s 65535 -w capture.pcap & 59 | echo $! > tcpdump.pid 60 | 61 | - name: Test HTTP 62 | run: | 63 | timeout 60 curl --noproxy "*" http://example.com -svS -o /dev/null 64 | 65 | - name: Test HTTPS 66 | run: | 67 | timeout 60 curl --noproxy "*" https://example.com -svS -o /dev/null 68 | 69 | - name: Test HKP 70 | run: | 71 | timeout 60 gpg -vvv --keyserver hkp://keyserver.ubuntu.com --recv-keys E1DE584A8CCA52DC29550F18ABAC58F075A17EFA 72 | 73 | - name: Test TCP4 74 | run: | 75 | sudo apt install -y socat 76 | timeout 60 socat /dev/null TCP4:tcpbin.com:4242 77 | 78 | - name: Test Access Logs 79 | run: | 80 | sudo snap logs aproxy.aproxy | grep -Fq "example.com:80" 81 | sudo snap logs aproxy.aproxy | grep -Fq "example.com:443" 82 | sudo snap logs aproxy.aproxy | grep -Fq "keyserver.ubuntu.com:11371" 83 | sudo snap logs aproxy.aproxy | grep -Eq "[0-9.]+:4242" 84 | 85 | - name: Show Access Logs 86 | if: failure() 87 | run: | 88 | sudo snap logs aproxy.aproxy -n=all 89 | 90 | - name: Stop tcpdump 91 | if: failure() 92 | run: | 93 | PID=$(cat tcpdump.pid) 94 | if [ -n "$PID" ]; then 95 | sudo kill -2 "$PID" || true 96 | fi 97 | sleep 1 98 | 99 | - name: Upload tcpdump capture 100 | if: failure() 101 | uses: actions/upload-artifact@v4 102 | with: 103 | name: tcpdump 104 | path: capture.pcap 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /aproxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "encoding/binary" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "io" 12 | "log" 13 | "log/slog" 14 | "net" 15 | "net/http" 16 | "net/url" 17 | "os" 18 | "os/signal" 19 | "strings" 20 | "sync" 21 | "sync/atomic" 22 | 23 | "golang.org/x/crypto/cryptobyte" 24 | ) 25 | 26 | var version = "0.2.4" 27 | 28 | // PrereadConn is a wrapper around net.Conn that supports pre-reading from the underlying connection. 29 | // Any Read before the EndPreread can be undone and read again by calling the EndPreread function. 30 | type PrereadConn struct { 31 | ended bool 32 | buf []byte 33 | mu sync.Mutex 34 | conn net.Conn 35 | } 36 | 37 | // EndPreread ends the pre-reading phase. Any Read before will be undone and data in the stream can be read again. 38 | // EndPreread can be only called once. 39 | func (c *PrereadConn) EndPreread() { 40 | c.mu.Lock() 41 | defer c.mu.Unlock() 42 | if c.ended { 43 | panic("call EndPreread after preread has ended or hasn't started") 44 | } 45 | c.ended = true 46 | } 47 | 48 | // Read reads from the underlying connection. Read during the pre-reading phase can be undone by EndPreread. 49 | func (c *PrereadConn) Read(p []byte) (n int, err error) { 50 | c.mu.Lock() 51 | defer c.mu.Unlock() 52 | if c.ended { 53 | n = copy(p, c.buf) 54 | bufLen := len(c.buf) 55 | c.buf = c.buf[n:] 56 | if n == len(p) || (bufLen > 0 && bufLen == n) { 57 | return n, nil 58 | } 59 | rn, err := c.conn.Read(p[n:]) 60 | return rn + n, err 61 | } else { 62 | n, err = c.conn.Read(p) 63 | c.buf = append(c.buf, p[:n]...) 64 | return n, err 65 | } 66 | } 67 | 68 | // Write writes data to the underlying connection. 69 | func (c *PrereadConn) Write(p []byte) (n int, err error) { 70 | return c.conn.Write(p) 71 | } 72 | 73 | // NewPrereadConn wraps the network connection and return a *PrereadConn. 74 | // It's recommended to not touch the original connection after wrapped. 75 | func NewPrereadConn(conn net.Conn) *PrereadConn { 76 | return &PrereadConn{conn: conn} 77 | } 78 | 79 | // PrereadSNI pre-reads the Server Name Indication (SNI) from a TLS connection. 80 | func PrereadSNI(conn *PrereadConn) (_ string, err error) { 81 | defer conn.EndPreread() 82 | defer func() { 83 | if err != nil { 84 | err = fmt.Errorf("failed to preread TLS client hello: %w", err) 85 | } 86 | }() 87 | recordHeader := make([]byte, 5) 88 | n, err := io.ReadFull(conn, recordHeader) 89 | if err != nil { 90 | return "", fmt.Errorf("failed to read TLS record layer header: %w", err) 91 | } 92 | if n != 5 { 93 | return "", fmt.Errorf("failed to read TLS record layer header: too short, less than 5 bytes (%d)", n) 94 | } 95 | if recordHeader[0] != 22 { 96 | return "", errors.New("not a TCP handshake") 97 | } 98 | msgLen := binary.BigEndian.Uint16(recordHeader[3:]) 99 | buf := make([]byte, msgLen+5) 100 | n, err = io.ReadFull(conn, buf[5:]) 101 | if n != int(msgLen) { 102 | return "", fmt.Errorf("client hello too short (%d < %d), err: %w", n, msgLen, err) 103 | } 104 | copy(buf[:5], recordHeader) 105 | return extractSNI(buf) 106 | } 107 | 108 | func extractSNI(data []byte) (string, error) { 109 | s := cryptobyte.String(data) 110 | var ( 111 | version uint16 112 | random []byte 113 | sessionId []byte 114 | ) 115 | 116 | if !s.Skip(9) || 117 | !s.ReadUint16(&version) || !s.ReadBytes(&random, 32) || 118 | !s.ReadUint8LengthPrefixed((*cryptobyte.String)(&sessionId)) { 119 | return "", fmt.Errorf("failed to parse TLS client hello version, random or session id") 120 | } 121 | 122 | var cipherSuitesData cryptobyte.String 123 | if !s.ReadUint16LengthPrefixed(&cipherSuitesData) { 124 | return "", fmt.Errorf("failed to parse TLS client hello cipher suites") 125 | } 126 | 127 | var cipherSuites []uint16 128 | for !cipherSuitesData.Empty() { 129 | var suite uint16 130 | if !cipherSuitesData.ReadUint16(&suite) { 131 | return "", fmt.Errorf("failed to parse TLS client hello cipher suites") 132 | } 133 | cipherSuites = append(cipherSuites, suite) 134 | } 135 | 136 | var compressionMethods []byte 137 | if !s.ReadUint8LengthPrefixed((*cryptobyte.String)(&compressionMethods)) { 138 | return "", fmt.Errorf("failed to parse TLS client hello compression methods") 139 | } 140 | 141 | if s.Empty() { 142 | // ClientHello is optionally followed by extension data 143 | return "", fmt.Errorf("no extension data in TLS client hello") 144 | } 145 | 146 | var extensions cryptobyte.String 147 | if !s.ReadUint16LengthPrefixed(&extensions) || !s.Empty() { 148 | return "", fmt.Errorf("failed to parse TLS client hello extensions") 149 | } 150 | 151 | finalServerName := "" 152 | for !extensions.Empty() { 153 | var extension uint16 154 | var extData cryptobyte.String 155 | if !extensions.ReadUint16(&extension) || 156 | !extensions.ReadUint16LengthPrefixed(&extData) { 157 | return "", fmt.Errorf("failed to parse TLS client hello extension") 158 | } 159 | if extension != 0 { 160 | continue 161 | } 162 | var nameList cryptobyte.String 163 | if !extData.ReadUint16LengthPrefixed(&nameList) || nameList.Empty() { 164 | return "", fmt.Errorf("failed to parse server name extension") 165 | } 166 | 167 | for !nameList.Empty() { 168 | var nameType uint8 169 | var serverName cryptobyte.String 170 | if !nameList.ReadUint8(&nameType) || 171 | !nameList.ReadUint16LengthPrefixed(&serverName) || 172 | serverName.Empty() { 173 | return "", fmt.Errorf("failed to parse server name indication extension") 174 | } 175 | if nameType != 0 { 176 | continue 177 | } 178 | if len(finalServerName) != 0 { 179 | return "", fmt.Errorf("multiple names of the same name_type are prohibited in server name extension") 180 | } 181 | finalServerName = string(serverName) 182 | if strings.HasSuffix(finalServerName, ".") { 183 | return "", fmt.Errorf("SNI name ends with a trailing dot") 184 | } 185 | } 186 | } 187 | return finalServerName, nil 188 | } 189 | 190 | // PrereadHttpHost pre-reads the HTTP Host header from an HTTP connection. 191 | func PrereadHttpHost(conn *PrereadConn) (_ string, err error) { 192 | defer func() { 193 | if err != nil { 194 | err = fmt.Errorf("failed to preread HTTP request: %w", err) 195 | } 196 | }() 197 | 198 | defer conn.EndPreread() 199 | req, err := http.ReadRequest(bufio.NewReader(conn)) 200 | if err != nil { 201 | return "", err 202 | } 203 | host := req.Host 204 | if host == "" { 205 | return "", errors.New("http request doesn't have host") 206 | } 207 | return host, nil 208 | } 209 | 210 | // DialProxy dials the TCP connection to the proxy. 211 | func DialProxy(proxy string) (net.Conn, error) { 212 | proxyAddr, err := net.ResolveTCPAddr("tcp", proxy) 213 | if err != nil { 214 | return nil, fmt.Errorf("failed to resolve proxy address: %w", err) 215 | } 216 | conn, err := net.DialTCP("tcp", nil, proxyAddr) 217 | if err != nil { 218 | return nil, fmt.Errorf("failed to connect to proxy: %w", err) 219 | } 220 | return conn, nil 221 | } 222 | 223 | // DialProxyConnect dials the TCP connection and finishes the HTTP CONNECT handshake with the proxy. 224 | // dst: HOST:PORT or IP:PORT 225 | func DialProxyConnect(proxy string, dst string) (net.Conn, error) { 226 | conn, err := DialProxy(proxy) 227 | if err != nil { 228 | return nil, err 229 | } 230 | request := http.Request{ 231 | Method: "CONNECT", 232 | URL: &url.URL{ 233 | Host: dst, 234 | }, 235 | Proto: "HTTP/1.1", 236 | ProtoMajor: 1, 237 | ProtoMinor: 1, 238 | Header: map[string][]string{ 239 | "User-Agent": {fmt.Sprintf("aproxy/%s", version)}, 240 | }, 241 | Host: dst, 242 | } 243 | err = request.Write(conn) 244 | if err != nil { 245 | return nil, fmt.Errorf("failed to send connect request to http proxy: %w", err) 246 | } 247 | response, err := http.ReadResponse(bufio.NewReaderSize(conn, 0), &request) 248 | if err != nil { 249 | return nil, fmt.Errorf("failed to receive http connect response from proxy: %w", err) 250 | } 251 | if response.StatusCode != 200 { 252 | return nil, fmt.Errorf("proxy return %d response for connect request", response.StatusCode) 253 | } 254 | return conn, nil 255 | } 256 | 257 | // GetOriginalDst get the original destination address of a TCP connection before dstnat. 258 | func GetOriginalDst(conn *net.TCPConn) (*net.TCPAddr, error) { 259 | file, err := conn.File() 260 | defer func(file *os.File) { 261 | err := file.Close() 262 | if err != nil { 263 | slog.Error("failed to close the duplicated TCP socket file descriptor") 264 | } 265 | }(file) 266 | if err != nil { 267 | return nil, fmt.Errorf("failed to convert connection to file: %w", err) 268 | } 269 | return GetsockoptIPv4OriginalDst(file.Fd()) 270 | } 271 | 272 | // RelayTCP relays data between the incoming TCP connection and the proxy connection. 273 | func RelayTCP(conn io.ReadWriter, proxyConn io.ReadWriteCloser, logger *slog.Logger) { 274 | var closed atomic.Bool 275 | go func() { 276 | _, err := io.Copy(proxyConn, conn) 277 | if err != nil && !closed.Load() { 278 | logger.Error("failed to relay network traffic to proxy", "error", err) 279 | } 280 | closed.Store(true) 281 | _ = proxyConn.Close() 282 | }() 283 | _, err := io.Copy(conn, proxyConn) 284 | if err != nil && !closed.Load() { 285 | logger.Error("failed to relay network traffic from proxy", "error", err) 286 | } 287 | closed.Store(true) 288 | } 289 | 290 | // RelayHTTP relays a single HTTP request and response between a local connection and a proxy. 291 | // It modifies the Connection header to "close" in both the request and response. 292 | func RelayHTTP(conn io.ReadWriter, proxyConn io.ReadWriteCloser, logger *slog.Logger) { 293 | defer proxyConn.Close() 294 | req, err := http.ReadRequest(bufio.NewReader(conn)) 295 | if err != nil { 296 | logger.Error("failed to read HTTP request from connection", "error", err) 297 | return 298 | } 299 | req.URL.Host = req.Host 300 | req.URL.Scheme = "http" 301 | if req.UserAgent() == "" { 302 | req.Header.Set("User-Agent", "") 303 | } 304 | req.Header.Set("Connection", "close") 305 | if req.Proto == "HTTP/1.0" { 306 | // no matter what the request protocol is, Go enforces a minimum version of HTTP/1.1 307 | // this causes problems for HTTP/1.0 only clients like GPG (HKP) 308 | // manually modify and send the HTTP/1.0 request to the proxy server 309 | buf := bytes.NewBuffer(nil) 310 | err := req.WriteProxy(buf) 311 | if err != nil { 312 | logger.Error("failed to serialize HTTP/1.0 request", "error", err) 313 | return 314 | } 315 | reqStr := buf.String() 316 | crlfIndex := strings.Index(reqStr, "\r\n") 317 | protoSpaceIndex := strings.LastIndex(reqStr[:crlfIndex], " ") 318 | reqStr = reqStr[:protoSpaceIndex+1] + "HTTP/1.0" + reqStr[crlfIndex:] 319 | _, err = proxyConn.Write([]byte(reqStr)) 320 | if err != nil { 321 | logger.Error("failed to send HTTP request to proxy", "error", err) 322 | return 323 | } 324 | } else { 325 | if err := req.WriteProxy(proxyConn); err != nil { 326 | logger.Error("failed to send HTTP request to proxy", "error", err) 327 | return 328 | } 329 | } 330 | resp, err := http.ReadResponse(bufio.NewReader(proxyConn), req) 331 | if err != nil { 332 | logger.Error("failed to read HTTP response from proxy", "error", err) 333 | return 334 | } 335 | resp.Header.Set("Connection", "close") 336 | if err := resp.Write(conn); err != nil { 337 | logger.Error("failed to send HTTP response to connection", "error", err) 338 | return 339 | } 340 | } 341 | 342 | // HandleConn manages the incoming connections. 343 | func HandleConn(conn net.Conn, proxy string) { 344 | defer conn.Close() 345 | logger := slog.With("src", conn.RemoteAddr()) 346 | dst, err := GetOriginalDst(conn.(*net.TCPConn)) 347 | if err != nil { 348 | slog.Error("failed to get connection original destination", "error", err) 349 | return 350 | } 351 | logger = logger.With("original_dst", dst) 352 | consigned := NewPrereadConn(conn) 353 | switch dst.Port { 354 | case 443: 355 | sni, err := PrereadSNI(consigned) 356 | if err != nil { 357 | logger.Error("failed to preread SNI from connection", "error", err) 358 | return 359 | } else { 360 | host := fmt.Sprintf("%s:%d", sni, dst.Port) 361 | logger = logger.With("host", host) 362 | proxyConn, err := DialProxyConnect(proxy, host) 363 | if err != nil { 364 | logger.Error("failed to connect to http proxy", "error", err) 365 | return 366 | } 367 | logger.Info("relay TLS connection to proxy") 368 | RelayTCP(consigned, proxyConn, logger) 369 | } 370 | case 80, 11371: 371 | host, err := PrereadHttpHost(consigned) 372 | if err != nil { 373 | logger.Error("failed to preread HTTP host from connection", "error", err) 374 | return 375 | } 376 | if !strings.Contains(host, ":") { 377 | host = fmt.Sprintf("%s:%d", host, dst.Port) 378 | } 379 | logger = logger.With("host", host) 380 | proxyConn, err := DialProxy(proxy) 381 | if err != nil { 382 | logger.Error("failed to connect to http proxy", "error", err) 383 | return 384 | } 385 | logger.Info("relay HTTP connection to proxy") 386 | RelayHTTP(consigned, proxyConn, logger) 387 | default: 388 | consigned.EndPreread() 389 | logger = logger.With("host", fmt.Sprintf("%s:%d", dst.IP.String(), dst.Port)) 390 | proxyConn, err := DialProxyConnect(proxy, fmt.Sprintf("%s:%d", dst.IP.String(), dst.Port)) 391 | if err != nil { 392 | logger.Error("failed to connect to tcp proxy", "error", err) 393 | return 394 | } 395 | logger.Info("relay TCP connection to proxy") 396 | RelayTCP(consigned, proxyConn, logger) 397 | } 398 | } 399 | 400 | func main() { 401 | proxyFlag := flag.String("proxy", "", "upstream proxy address in the 'host:port' format") 402 | listenFlag := flag.String("listen", ":8443", "the address and port on which the server will listen") 403 | flag.Parse() 404 | listenAddr := *listenFlag 405 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 406 | defer stop() 407 | listenConfig := new(net.ListenConfig) 408 | listener, err := listenConfig.Listen(ctx, "tcp", listenAddr) 409 | if err != nil { 410 | log.Fatalf("failed to listen on %#v", listenAddr) 411 | } 412 | slog.Info(fmt.Sprintf("start listening on %s", listenAddr)) 413 | proxy := *proxyFlag 414 | if proxy == "" { 415 | log.Fatalf("no upstream proxy specified") 416 | } 417 | slog.Info(fmt.Sprintf("start forwarding to proxy %s", proxy)) 418 | go func() { 419 | for { 420 | conn, err := listener.Accept() 421 | if err != nil { 422 | slog.Error("failed to accept connection", "error", err) 423 | continue 424 | } 425 | go HandleConn(conn, proxy) 426 | } 427 | }() 428 | <-ctx.Done() 429 | stop() 430 | } 431 | --------------------------------------------------------------------------------