├── .dockerignore ├── .github ├── stale.yml └── workflows │ ├── build.yml │ └── docker-ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── access ├── access.go ├── dst.go └── jsfilter.go ├── auth ├── auth.go ├── basic.go ├── cert.go ├── cert_test.go ├── common.go ├── constants.go ├── file.go ├── hmac.go ├── hmac_test.go ├── noauth.go ├── redis.go └── static.go ├── certcache ├── cryptobox.go ├── local.go └── redis.go ├── dialer ├── cache.go ├── dialer.go ├── dto │ └── dto.go ├── errors │ └── errors.go ├── filter.go ├── h2.go ├── hintdialer.go ├── jsrouter.go ├── optimistic.go ├── protect.go ├── rescache.go ├── resolve.go └── upstream.go ├── forward ├── bwlimit.go └── direct.go ├── go.mod ├── go.sum ├── handler ├── adapter.go ├── config.go ├── handler.go └── proxy.go ├── jsext ├── dto.go └── printer.go ├── log ├── condlog.go └── logwriter.go ├── main.go ├── snapcraft.yaml └── tlsutil └── util.go /.dockerignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - 21 | name: Setup Go 22 | uses: actions/setup-go@v4 23 | with: 24 | go-version: 'stable' 25 | - 26 | name: Read tag 27 | id: tag 28 | run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 29 | - 30 | name: Build 31 | run: >- 32 | make -j $(nproc) allplus 33 | NDK_CC_ARM64="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang" 34 | NDK_CC_ARM="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang" 35 | VERSION=${{steps.tag.outputs.tag}} 36 | - 37 | name: Release 38 | uses: softprops/action-gh-release@v1 39 | with: 40 | files: bin/* 41 | fail_on_unmatched_files: true 42 | generate_release_notes: true 43 | -------------------------------------------------------------------------------- /.github/workflows/docker-ci.yml: -------------------------------------------------------------------------------- 1 | name: docker-ci 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | DOCKER_BUILDKIT: 1 12 | 13 | jobs: 14 | build-and-push-image: 15 | runs-on: ubuntu-latest 16 | 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - 23 | name: Checkout 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 0 27 | - 28 | name: Find Git Tag 29 | id: tagger 30 | uses: jimschubert/query-tag-action@v2 31 | with: 32 | include: 'v*' 33 | exclude: '*-rc*' 34 | commit-ish: 'HEAD' 35 | skip-unshallow: 'true' 36 | abbrev: 7 37 | - 38 | name: Set up QEMU 39 | uses: docker/setup-qemu-action@v3 40 | - 41 | name: Set up Docker Buildx 42 | uses: docker/setup-buildx-action@v3 43 | - 44 | name: Login to DockerHub 45 | uses: docker/login-action@v3 46 | with: 47 | registry: ${{ env.REGISTRY }} 48 | username: ${{ github.actor }} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | - 51 | name: Docker scratch meta 52 | id: scratch_meta 53 | uses: docker/metadata-action@v5 54 | with: 55 | # list of Docker images to use as base name for tags 56 | images: | 57 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 58 | # generate Docker tags based on the following events/attributes 59 | tags: | 60 | type=semver,pattern={{version}} 61 | type=semver,pattern={{major}}.{{minor}} 62 | type=semver,pattern={{major}} 63 | type=sha 64 | - 65 | name: Build and push scratch image 66 | id: docker_build_scratch 67 | uses: docker/build-push-action@v5 68 | with: 69 | context: . 70 | target: scratch 71 | platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7 72 | push: true 73 | tags: ${{ steps.scratch_meta.outputs.tags }} 74 | labels: ${{ steps.scratch_meta.outputs.labels }} 75 | build-args: 'GIT_DESC=${{steps.tagger.outputs.tag}}' 76 | - 77 | name: Docker alpine meta 78 | id: alpine_meta 79 | uses: docker/metadata-action@v5 80 | with: 81 | # list of Docker images to use as base name for tags 82 | images: | 83 | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 84 | # generate Docker tags based on the following events/attributes 85 | flavor: | 86 | suffix=-alpine,onlatest=true 87 | tags: | 88 | type=semver,pattern={{version}} 89 | type=semver,pattern={{major}}.{{minor}} 90 | type=semver,pattern={{major}} 91 | type=sha 92 | - 93 | name: Build and push alpine image 94 | id: docker_build_alpine 95 | uses: docker/build-push-action@v5 96 | with: 97 | context: . 98 | target: alpine 99 | platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7 100 | push: true 101 | tags: ${{ steps.alpine_meta.outputs.tags }} 102 | labels: ${{ steps.alpine_meta.outputs.labels }} 103 | build-args: 'GIT_DESC=${{steps.tagger.outputs.tag}}' 104 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | bin/ 17 | *.snap 18 | passwd.txt 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang AS build 2 | 3 | ARG GIT_DESC=undefined 4 | 5 | WORKDIR /go/src/github.com/SenseUnit/dumbproxy 6 | COPY . . 7 | ARG TARGETOS TARGETARCH 8 | RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -a -tags netgo -ldflags '-s -w -extldflags "-static" -X main.version='"$GIT_DESC" 9 | RUN mkdir /.dumbproxy 10 | 11 | FROM scratch AS scratch 12 | COPY --from=build /go/src/github.com/SenseUnit/dumbproxy/dumbproxy / 13 | COPY --from=build --chown=9999:9999 /.dumbproxy /.dumbproxy 14 | USER 9999:9999 15 | EXPOSE 8080/tcp 16 | ENTRYPOINT ["/dumbproxy", "-bind-address", ":8080"] 17 | 18 | FROM alpine AS alpine 19 | COPY --from=build /go/src/github.com/SenseUnit/dumbproxy/dumbproxy / 20 | COPY --from=build --chown=9999:9999 /.dumbproxy /.dumbproxy 21 | RUN apk add --no-cache tzdata 22 | USER 9999:9999 23 | EXPOSE 8080/tcp 24 | ENTRYPOINT ["/dumbproxy", "-bind-address", ":8080"] 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Snawoot 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROGNAME = dumbproxy 2 | OUTSUFFIX = bin/$(PROGNAME) 3 | VERSION := $(shell git describe) 4 | BUILDOPTS = -a -tags netgo -trimpath -asmflags -trimpath 5 | LDFLAGS = -ldflags '-s -w -extldflags "-static" -X main.version=$(VERSION)' 6 | LDFLAGS_NATIVE = -ldflags '-s -w -X main.version=$(VERSION)' 7 | 8 | NDK_CC_ARM = $(abspath ../../ndk-toolchain-arm/bin/arm-linux-androideabi-gcc) 9 | NDK_CC_ARM64 = $(abspath ../../ndk-toolchain-arm64/bin/aarch64-linux-android21-clang) 10 | 11 | GO := go 12 | 13 | src = $(wildcard *.go */*.go */*/*.go) go.mod go.sum 14 | 15 | native: bin-native 16 | all: bin-linux-amd64 bin-linux-386 bin-linux-arm bin-linux-arm64 \ 17 | bin-freebsd-amd64 bin-freebsd-386 bin-freebsd-arm bin-freebsd-arm64 \ 18 | bin-netbsd-amd64 bin-netbsd-386 bin-netbsd-arm bin-netbsd-arm64 \ 19 | bin-openbsd-amd64 bin-openbsd-386 bin-openbsd-arm bin-openbsd-arm64 \ 20 | bin-darwin-amd64 bin-darwin-arm64 \ 21 | bin-windows-amd64 bin-windows-386 bin-windows-arm 22 | 23 | allplus: all \ 24 | bin-android-arm bin-android-arm64 25 | 26 | bin-native: $(OUTSUFFIX) 27 | bin-linux-amd64: $(OUTSUFFIX).linux-amd64 28 | bin-linux-386: $(OUTSUFFIX).linux-386 29 | bin-linux-arm: $(OUTSUFFIX).linux-arm 30 | bin-linux-arm64: $(OUTSUFFIX).linux-arm64 31 | bin-freebsd-amd64: $(OUTSUFFIX).freebsd-amd64 32 | bin-freebsd-386: $(OUTSUFFIX).freebsd-386 33 | bin-freebsd-arm: $(OUTSUFFIX).freebsd-arm 34 | bin-freebsd-arm64: $(OUTSUFFIX).freebsd-arm64 35 | bin-netbsd-amd64: $(OUTSUFFIX).netbsd-amd64 36 | bin-netbsd-386: $(OUTSUFFIX).netbsd-386 37 | bin-netbsd-arm: $(OUTSUFFIX).netbsd-arm 38 | bin-netbsd-arm64: $(OUTSUFFIX).netbsd-arm64 39 | bin-openbsd-amd64: $(OUTSUFFIX).openbsd-amd64 40 | bin-openbsd-386: $(OUTSUFFIX).openbsd-386 41 | bin-openbsd-arm: $(OUTSUFFIX).openbsd-arm 42 | bin-openbsd-arm64: $(OUTSUFFIX).openbsd-arm64 43 | bin-darwin-amd64: $(OUTSUFFIX).darwin-amd64 44 | bin-darwin-arm64: $(OUTSUFFIX).darwin-arm64 45 | bin-windows-amd64: $(OUTSUFFIX).windows-amd64.exe 46 | bin-windows-386: $(OUTSUFFIX).windows-386.exe 47 | bin-windows-arm: $(OUTSUFFIX).windows-arm.exe 48 | bin-android-arm: $(OUTSUFFIX).android-arm 49 | bin-android-arm64: $(OUTSUFFIX).android-arm64 50 | 51 | $(OUTSUFFIX): $(src) 52 | $(GO) build $(LDFLAGS_NATIVE) -o $@ 53 | 54 | $(OUTSUFFIX).linux-amd64: $(src) 55 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 56 | 57 | $(OUTSUFFIX).linux-386: $(src) 58 | CGO_ENABLED=0 GOOS=linux GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 59 | 60 | $(OUTSUFFIX).linux-arm: $(src) 61 | CGO_ENABLED=0 GOOS=linux GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 62 | 63 | $(OUTSUFFIX).linux-arm64: $(src) 64 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 65 | 66 | $(OUTSUFFIX).freebsd-amd64: $(src) 67 | CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 68 | 69 | $(OUTSUFFIX).freebsd-386: $(src) 70 | CGO_ENABLED=0 GOOS=freebsd GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 71 | 72 | $(OUTSUFFIX).freebsd-arm: $(src) 73 | CGO_ENABLED=0 GOOS=freebsd GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 74 | 75 | $(OUTSUFFIX).freebsd-arm64: $(src) 76 | CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 77 | 78 | $(OUTSUFFIX).netbsd-amd64: $(src) 79 | CGO_ENABLED=0 GOOS=netbsd GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 80 | 81 | $(OUTSUFFIX).netbsd-386: $(src) 82 | CGO_ENABLED=0 GOOS=netbsd GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 83 | 84 | $(OUTSUFFIX).netbsd-arm: $(src) 85 | CGO_ENABLED=0 GOOS=netbsd GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 86 | 87 | $(OUTSUFFIX).netbsd-arm64: $(src) 88 | CGO_ENABLED=0 GOOS=netbsd GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 89 | 90 | $(OUTSUFFIX).openbsd-amd64: $(src) 91 | CGO_ENABLED=0 GOOS=openbsd GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 92 | 93 | $(OUTSUFFIX).openbsd-386: $(src) 94 | CGO_ENABLED=0 GOOS=openbsd GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 95 | 96 | $(OUTSUFFIX).openbsd-arm: $(src) 97 | CGO_ENABLED=0 GOOS=openbsd GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 98 | 99 | $(OUTSUFFIX).openbsd-arm64: $(src) 100 | CGO_ENABLED=0 GOOS=openbsd GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 101 | 102 | $(OUTSUFFIX).darwin-amd64: $(src) 103 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 104 | 105 | $(OUTSUFFIX).darwin-arm64: $(src) 106 | CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 107 | 108 | $(OUTSUFFIX).windows-amd64.exe: $(src) 109 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 110 | 111 | $(OUTSUFFIX).windows-386.exe: $(src) 112 | CGO_ENABLED=0 GOOS=windows GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 113 | 114 | $(OUTSUFFIX).windows-arm.exe: $(src) 115 | CGO_ENABLED=0 GOOS=windows GOARCH=arm GOARM=7 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 116 | 117 | $(OUTSUFFIX).android-arm: $(src) 118 | CC=$(NDK_CC_ARM) CGO_ENABLED=1 GOOS=android GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS_NATIVE) -o $@ 119 | 120 | $(OUTSUFFIX).android-arm64: $(src) 121 | CC=$(NDK_CC_ARM64) CGO_ENABLED=1 GOOS=android GOARCH=arm64 $(GO) build $(LDFLAGS_NATIVE) -o $@ 122 | 123 | clean: 124 | rm -f bin/* 125 | 126 | fmt: 127 | $(GO) fmt ./... 128 | 129 | run: 130 | $(GO) run $(LDFLAGS) . 131 | 132 | install: 133 | $(GO) install $(LDFLAGS_NATIVE) . 134 | 135 | .PHONY: clean all native fmt install \ 136 | bin-native \ 137 | bin-linux-amd64 \ 138 | bin-linux-386 \ 139 | bin-linux-arm \ 140 | bin-linux-arm64 \ 141 | bin-freebsd-amd64 \ 142 | bin-freebsd-386 \ 143 | bin-freebsd-arm \ 144 | bin-freebsd-arm64 \ 145 | bin-netbsd-amd64 \ 146 | bin-netbsd-386 \ 147 | bin-netbsd-arm \ 148 | bin-netbsd-arm64 \ 149 | bin-openbsd-amd64 \ 150 | bin-openbsd-386 \ 151 | bin-openbsd-arm \ 152 | bin-openbsd-arm64 \ 153 | bin-darwin-amd64 \ 154 | bin-darwin-arm64 \ 155 | bin-windows-amd64 \ 156 | bin-windows-386 \ 157 | bin-windows-arm \ 158 | bin-android-arm \ 159 | bin-android-arm64 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | dumbproxy 2 | ========= 3 | 4 | [![dumbproxy](https://snapcraft.io//dumbproxy/badge.svg)](https://snapcraft.io/dumbproxy) 5 | 6 | Simple, scriptable, secure forward proxy. 7 | 8 | ## Features 9 | 10 | * Cross-platform (Windows/Mac OS/Linux/Android (via shell)/\*BSD) 11 | * Deployment with a single self-contained binary 12 | * Zero-configuration 13 | * Supports CONNECT method and forwarding of HTTPS connections 14 | * Supports `Basic` proxy authentication 15 | * Via auto-reloaded NCSA httpd-style passwords file 16 | * Via static login and password 17 | * Via HMAC signatures provisioned by central authority (e.g. some webservice) 18 | * Via Redis or Redis Cluster database 19 | * Supports TLS operation mode (HTTP(S) proxy over TLS) 20 | * Supports client authentication with client TLS certificates 21 | * Native ACME support (can issue TLS certificates automatically using Let's Encrypt or BuyPass) 22 | * Certificate cache in local directory 23 | * Certificate cache in Redis/Redis Cluster 24 | * Optional local in-memory inner cache 25 | * Optional AEAD encryption layer for cache 26 | * Per-user bandwidth limits 27 | * HTTP/2 support, both server and client, including h2c support 28 | * Optional DNS cache 29 | * Resilient to DPI (including active probing, see `hidden_domain` option for authentication providers) 30 | * Connecting via upstream HTTP(S)/SOCKS5 proxies (proxy chaining) 31 | * systemd socket activation 32 | * [Proxy protocol](https://github.com/haproxy/haproxy/blob/master/doc/proxy-protocol.txt) support for working behind a reverse proxy (HAProxy, Nginx) 33 | * Scripting with JavaScript: 34 | * Access filter by JS function 35 | * Upstream proxy selection by JS function 36 | 37 | ## Installation 38 | 39 | #### Binary download 40 | 41 | Pre-built binaries available on [releases](https://github.com/SenseUnit/dumbproxy/releases/latest) page. 42 | 43 | #### From source 44 | 45 | Alternatively, you may install dumbproxy from source. Run within source directory 46 | 47 | ``` 48 | go install . 49 | ``` 50 | 51 | #### Docker 52 | 53 | Docker image is available as well. Here is an example for running proxy as a background service: 54 | 55 | ```sh 56 | docker run -d \ 57 | --security-opt no-new-privileges \ 58 | -p 8080:8080 \ 59 | --restart unless-stopped \ 60 | --name dumbproxy \ 61 | ghcr.io/senseunit/dumbproxy -auth 'static://?username=admin&password=123456' 62 | ``` 63 | 64 | #### Snap Store 65 | 66 | [![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/dumbproxy) 67 | 68 | ```bash 69 | sudo snap install dumbproxy 70 | ``` 71 | 72 | ## Usage 73 | 74 | Just run program and it'll start accepting connections on port 8080 (default). 75 | 76 | ### Example: plain proxy 77 | 78 | Run proxy on port 1234 with `Basic` authentication with username `admin` and password `123456`: 79 | 80 | ```sh 81 | dumbproxy -bind-address :1234 -auth 'static://?username=admin&password=123456' 82 | ``` 83 | 84 | ### Example: HTTP proxy over TLS (LetsEncrypt automatic certs) 85 | 86 | Run HTTPS proxy (HTTP proxy over TLS) with automatic certs from LetsEncrypt on port 443 with `Basic` authentication with username `admin` and password `123456`: 87 | 88 | ```sh 89 | dumbproxy -bind-address :443 -auth 'static://?username=admin&password=123456' -autocert 90 | ``` 91 | 92 | ### Example: HTTP proxy over TLS (pre-issued cert) behind Nginx reverse proxy performing SNI routing 93 | 94 | Run HTTPS proxy (HTTP proxy over TLS) with pre-issued cert listening proxy protocol on localhost's 10443 with `Basic` authentication (users and passwords in /etc/dumbproxy.htpasswd)): 95 | 96 | ```sh 97 | dumbproxy \ 98 | -bind-address 127.0.0.1:10443 \ 99 | -proxyproto \ 100 | -auth basicfile://?path=/etc/dumbproxy.htpasswd \ 101 | -cert=/etc/letsencrypt/live/proxy.example.com/fullchain.pem \ 102 | -key=/etc/letsencrypt/live/proxy.example.com/privkey.pem 103 | ``` 104 | 105 | Nginx config snippet: 106 | 107 | ``` 108 | stream 109 | { 110 | ssl_preread on; 111 | 112 | map $ssl_preread_server_name $backend 113 | { 114 | proxy.example.com dumbproxy; 115 | ... 116 | } 117 | 118 | upstream dumbproxy 119 | { 120 | server 127.0.0.1:10443; 121 | } 122 | 123 | server 124 | { 125 | listen 443; 126 | listen [::]:443; 127 | proxy_protocol on; 128 | proxy_pass $backend; 129 | } 130 | 131 | } 132 | ``` 133 | 134 | ### Example: HTTP proxy over TLS (BuyPass automatic certs) 135 | 136 | Run HTTPS proxy (HTTP proxy over TLS) with automatic certs from BuyPass on port 443 with `Basic` authentication with username `admin` and password `123456`: 137 | 138 | ```sh 139 | dumbproxy \ 140 | -bind-address :443 \ 141 | -auth 'static://?username=admin&password=123456' \ 142 | -autocert \ 143 | -autocert-acme 'https://api.buypass.com/acme/directory' \ 144 | -autocert-email YOUR-EMAIL@EXAMPLE.ORG \ 145 | -autocert-http :80 146 | ``` 147 | 148 | ## Using HTTP-over-TLS proxy 149 | 150 | It's quite trivial to set up program which supports proxies to use dumbproxy in plain HTTP mode. However, using HTTP proxy over TLS connection with browsers is little bit tricky. Note that TLS must be enabled (`-cert` and `-key` options or `-autocert` option) for this to work. 151 | 152 | ### Routing all browsers on Windows via HTTPS proxy 153 | 154 | Open proxy settings in system's network settings: 155 | 156 | ![win10-proxy-settings](https://user-images.githubusercontent.com/3524671/83258553-216f7700-a1bf-11ea-8af9-3d8aed5b2e71.png) 157 | 158 | Turn on setup script option and set script address: 159 | 160 | ``` 161 | data:,function FindProxyForURL(u, h){return "HTTPS example.com:8080";} 162 | ``` 163 | 164 | where instead of `example.com:8080` you should use actual address of your HTTPS proxy. 165 | 166 | Note: this method will not work with MS Edge Legacy. 167 | 168 | ### Using with Firefox 169 | 170 | #### Option 1. Inline PAC file in settings. 171 | 172 | Open Firefox proxy settings, switch proxy mode to "Automatic proxy configuration URL". Specify URL: 173 | 174 | ``` 175 | data:,function FindProxyForURL(u, h){return "HTTPS example.com:8080";} 176 | ``` 177 | 178 | ![ff\_https\_proxy](https://user-images.githubusercontent.com/3524671/82768442-afea9e00-9e37-11ea-80fd-1eccf55b89fa.png) 179 | 180 | #### Option 2. Browser extension. 181 | 182 | Use any proxy switching browser extension which supports HTTPS proxies like [this one](https://addons.mozilla.org/en-US/firefox/addon/zeroomega/). 183 | 184 | ### Using with Chrome 185 | 186 | #### Option 1. CLI option. 187 | 188 | Specify proxy via command line: 189 | 190 | ``` 191 | chromium-browser --proxy-server='https://example.com:8080' 192 | ``` 193 | 194 | #### Option 2. Browser extension. 195 | 196 | Use any proxy switching browser extension which supports HTTPS proxies like [this one](https://chromewebstore.google.com/detail/proxy-switchyomega-3-zero/pfnededegaaopdmhkdmcofjmoldfiped). 197 | 198 | ### Using with other applications 199 | 200 | It is possible to expose remote HTTPS proxy as a local plaintext HTTP proxy with the help of some application which performs remote communication via TLS and exposes local plaintext socket. dumbproxy itself can play this role and use upstream proxy to provide local proxy service. For example, command 201 | 202 | ``` 203 | dumbproxy -bind-address 127.0.0.1:8080 -proxy 'https://login:password@example.org' 204 | ``` 205 | 206 | would expose remote HTTPS proxy at example.org:443 with `login` and `password` on local port 8080 as a regular HTTP proxy without authentication. Or, if you prefer mTLS authentication, it would be 207 | 208 | ``` 209 | dumbproxy -bind-address 127.0.0.1:8080 -proxy 'https://example.org?cert=cert.pem&key=key.pem&cafile=ca.pem' 210 | ``` 211 | 212 | ### Using with Android 213 | 214 | 1. Run proxy as in [examples](#usage) above. 215 | 2. Install Adguard on your Android: [Guide](https://adguard.com/en/adguard-android/overview.html). 216 | 3. Follow [this guide](https://adguard.com/en/blog/configure-proxy.html#configuringproxyinadguardforandroid), skipping server configuration. Use proxy type HTTPS if you set up TLS-enabled server or else use HTTP type. 217 | 4. Enjoy! 218 | 219 | ## Authentication 220 | 221 | Authentication parameters are passed as URI via `-auth` parameter. Scheme of URI defines authentication metnod and query parameters define parameter values for authentication provider. 222 | 223 | * `none` - no authentication. Example: `none://`. This is default. 224 | * `static` - basic authentication for single login and password pair. Example: `static://?username=admin&password=123456`. Parameters: 225 | * `username` - login. 226 | * `password` - password. 227 | * `hidden_domain` - if specified and is not an empty string, proxy will respond with "407 Proxy Authentication Required" only on specified domain. All unauthenticated clients will receive "400 Bad Request" status. This option is useful to prevent DPI active probing from discovering that service is a proxy, hiding proxy authentication prompt when no valid auth header was provided. Hidden domain is used for generating 407 response code to trigger browser authorization request in cases when browser has no prior knowledge proxy authentication is required. In such cases user has to navigate to any hidden domain page via plaintext HTTP, authenticate themselves and then browser will remember authentication. 228 | * `basicfile` - use htpasswd-like file with login and password pairs for authentication. Such file can be created/updated with command like this: `dumbproxy -passwd /etc/dumbproxy.htpasswd username password` or with `htpasswd` utility from Apache HTTPD utils. `path` parameter in URL for this provider must point to a local file with login and bcrypt-hashed password lines. Example: `basicfile://?path=/etc/dumbproxy.htpasswd`. Parameters: 229 | * `path` - location of file with login and password pairs. File format is similar to htpasswd files. Each line must be in form `:`. Empty lines and lines starting with `#` are ignored. 230 | * `hidden_domain` - same as in `static` provider. 231 | * `reload` - interval for conditional password file reload, if it was modified since last load. Use negative duration to disable autoreload. Default: `15s`. 232 | * `hmac` - authentication with HMAC-signatures passed as username and password via basic authentication scheme. In that scheme username represents user login as usual and password should be constructed as follows: *password := urlsafe\_base64\_without\_padding(expire\_timestamp || hmac\_sha256(secret, "dumbproxy grant token v1" || username || expire\_timestamp))*, where *expire_timestamp* is 64-bit big-endian UNIX timestamp and *||* is a concatenation operator. [This Python script](https://gist.github.com/Snawoot/2b5acc232680d830f0f308f14e540f1d) can be used as a reference implementation of signing. Dumbproxy itself also provides built-in signer: `dumbproxy -hmac-sign `. Parameters of this auth scheme are: 233 | * `secret` - hex-encoded HMAC secret key. Alternatively it can be specified by `DUMBPROXY_HMAC_SECRET` environment variable. Secret key can be generated with command like this: `openssl rand -hex 32` or `dumbproxy -hmac-genkey`. 234 | * `hidden_domain` - same as in `static` provider. 235 | * `cert` - use mutual TLS authentication with client certificates. In order to use this auth provider server must listen sockert in TLS mode (`-cert` and `-key` options) and client CA file must be specified (`-cacert`). Example: `cert://`. Parameters of this scheme are: 236 | * `blacklist` - location of file with list of serial numbers of blocked certificates, one per each line in form of hex-encoded colon-separated bytes. Example: `ab:01:02:03`. Empty lines and comments starting with `#` are ignored. 237 | * `reload` - interval for certificate blacklist file reload, if it was modified since last load. Use negative duration to disable autoreload. Default: `15s`. 238 | * `redis` - use external Redis database to lookup password verifiers for users. The password format is similar to `basicfile` mode or `htpasswd` encoding except username goes into Redis key name, colon is skipped and the rest goes to value of this key. For example, login-password pair `test` / `123456` can be encoded as Redis key `test` with value `$2y$05$zs1EJayCIyYtG.NQVzu9SeNvMP0XYWa42fQv.XNDx33wwbg98SnUq`. Example of auth parameter: `-auth 'redis://?url=redis%3A//default%3A123456Y%40redis-14623.c531.europe-west3-1.gce.redns.redis-cloud.com%3A17954/0&key_prefix=auth_'`. Parameters: 239 | * `url` - URL specifying Redis instance to connect to. See [ParseURL](https://pkg.go.dev/github.com/redis/go-redis/v9#ParseURL) documentation for the complete specification of Redis URL format. 240 | * `key_prefix' - prefix to prepend to each key before lookup. Helps isolate keys under common prefix. Default is empty string (`""`). 241 | * `hidden\_domain` - same as in `static provider. 242 | * `redis-cluster` - same as Redis, but uses Redis Cluster client instead. 243 | * `url` - URL specifying Redis instance to connect to. See [ParseClusterURL](https://pkg.go.dev/github.com/redis/go-redis/v9#ParseClusterURL) documentation for the complete specification of Redis URL format. 244 | * `key_prefix' - prefix to prepend to each key before lookup. Helps isolate keys under common prefix. Default is empty string (`""`). 245 | * `hidden\_domain` - same as in `static provider. 246 | 247 | ## Scripting 248 | 249 | With the dumbproxy, it is possible to modify request processing behaviour using simple scripts written in the JavaScript programming language. 250 | 251 | ### Access filter by JS script 252 | 253 | It is possible to filter (allow or deny) requests with simple `access` JS function. Such function can be loaded with the `-js-access-filter` option. Option value must specify location of script file where `access` function is defined. 254 | 255 | `access` function is invoked with following parameters: 256 | 257 | 1. **Request** *(Object)*. It contains following properties: 258 | * **method** *(String)* - HTTP method used in request (CONNECT, GET, POST, PUT, etc.). 259 | * **url** *(String)* - URL parsed from the URI supplied on the Request-Line. 260 | * **proto** *(String)* - the protocol version for incoming server requests. 261 | * **protoMajor** *(Number)* - numeric major protocol version. 262 | * **protoMinor** *(Number)* - numeric minor protocol version. 263 | * **header** *(Object)* - mapping of *String* headers (except Host) in canonical form to an *Array* of their *String* values. 264 | * **contentLength** *(Number)* - length of request body, if known. 265 | * **transferEncoding** *(Array)* - lists the request's transfer encodings from outermost to innermost. 266 | * **host** *(String)* - specifies the host on which the URL is sought. For HTTP/1 (per RFC 7230, section 5.4), this is either the value of the "Host" header or the host name given in the URL itself. For HTTP/2, it is the value of the ":authority" pseudo-header field. 267 | * **remoteAddr** *(String)* - client's IP:port. 268 | * **requestURI** *(String)* - the unmodified request-target of the Request-Line (RFC 7230, Section 3.1.1) as sent by the client to a server. 269 | 2. **Destination address** *(Object)*. It's an address where actual connection is about to be created. It contains following properties: 270 | * **network** *(String)* - connection type. Should be `"tcp"` in most cases unless restricted to specific address family (`"tcp4"` or `"tcp6"`). 271 | * **originalHost** *(String)* - original hostname or IP address parsed from request. 272 | * **resolvedHost** *(String)* - resolved hostname from request or `null` if resolving was not performed (e.g. if upstream dialer is a proxy). 273 | * **port** *(Number)* - port number. 274 | 3. **Username** *(String)*. Name of the authenticated user or an empty string if there is no authentication. 275 | 276 | `access` function must return boolean value, `true` allows request and `false` forbids it. Any exception will be reported to log and the corresponding request will be denied. 277 | 278 | Also it is possible to use builtin `print` function to print arbitrary values into dumbproxy log for debugging purposes. 279 | 280 | Example: 281 | 282 | ```js 283 | // Deny unsafe ports for HTTP and non-SSL ports for HTTPS. 284 | 285 | const SSL_ports = [ 286 | 443, 287 | ] 288 | const Safe_ports = [ 289 | 80, // http 290 | 21, // ftp 291 | 443, // https 292 | 70, // gopher 293 | 210, // wais 294 | 280, // http-mgmt 295 | 488, // gss-http 296 | 591, // filemaker 297 | 777, // multiling http 298 | ] 299 | const highPortBase = 1025 300 | 301 | function access(req, dst, username) { 302 | if (req.method == "CONNECT") { 303 | if (SSL_ports.includes(dst.port)) return true 304 | } else { 305 | if (dst.port >= highPortBase || Safe_ports.includes(dst.port)) return true 306 | } 307 | return false 308 | } 309 | ``` 310 | 311 | ### Upstream proxy selection by JS script 312 | 313 | dumbproxy can select upstream proxy dynamically invoking `getProxy` JS function from file specified by `-js-proxy-router` option. 314 | 315 | Note that this option can be repeated multiple times, same as `-proxy` option for chaining of proxies. These two options can be used together and order of chaining will be as they come in command line. For generalization purposes we can say that `-proxy` option is equivalent to `-js-proxy-router` option with script which returns just one static proxy. 316 | 317 | `getProxy` function is invoked with the [same parameters](#access-filter-by-js-script) as the `access` function. But unlike `access` function it is expected to return proxy URL in string format *scheme://[user:password@]host:port* or empty string `""` if no additional upstream proxy needed (i.e. direct connection if there are no other proxy dialers defined in chain). See [supported upstream proxy schemes](#supported-upstream-proxy-schemes) for details. 318 | 319 | Example: 320 | 321 | ```js 322 | // Redirect .onion hidden domains to Tor SOCKS5 proxy 323 | 324 | function getProxy(req, dst, username) { 325 | if (dst.originalHost.replace(/\.$/, "").toLowerCase().endsWith(".onion")) { 326 | return "socks5://127.0.0.1:9050" 327 | } 328 | return "" 329 | } 330 | ``` 331 | 332 | > [!NOTE] 333 | > `getProxy` can be invoked once or twice per request. If first invocation with `null` resolved host address returns "direct" mode and no other dialer has suppressed name resolving, name resolution will be performed and `getProxy` will be invoked once again with resolved address for the final decision. 334 | > 335 | > This shouldn't be much of concern, though, if `getProxy` function doesn't use dst.resolvedHost and returns consistent values across invocations with the rest of inputs having same values. 336 | 337 | ## Supported upstream proxy schemes 338 | 339 | Supported proxy schemes are: 340 | 341 | * `http` - regular HTTP proxy with the CONNECT method support. Examples: `http://example.org:3128`. 342 | * `https` - HTTP proxy over TLS connection. Examples: `https://user:password@example.org`, `https://example.org?cert=cert.pem&key=key.pem`. This method also supports additional parameters passed in query string: 343 | * `cafile` - file with CA certificates in PEM format used to verify TLS peer. 344 | * `sni` - override value of ServerName Indication extension. 345 | * `peername` - expect specified name in peer certificate. Empty string relaxes any name constraints. 346 | * `cert` - file with user certificate for mutual TLS authentication. Should be used in conjunction with `key`. 347 | * `key` - file with private key matching user certificate specified with `cert` option. 348 | * `ciphers` - colon-separated list of enabled TLS ciphersuites. 349 | * `curves` - colon-separated list of enabled TLS key exchange curves. 350 | * `min-tls-version` - minimum TLS version. 351 | * `max-tls-version` - maximum TLS version. 352 | * `http+optimistic` - (EXPERIMENTAL) HTTP proxy dialer which reads the connection success response asynchronously. 353 | * `https+optimistic` - (EXPERIMENTAL) HTTP proxy over TLS dialer which reads the connection success response asynchronously. Options are same as for `https` dialer. 354 | * `h2` - HTTP/2 proxy over TLS connection. Examples: `h2://user:password@example.org`, `h2://example.org?cert=cert.pem&key=key.pem`. This method also supports additional parameters passed in query string: 355 | * `cafile` - file with CA certificates in PEM format used to verify TLS peer. 356 | * `sni` - override value of ServerName Indication extension. 357 | * `peername` - expect specified name in peer certificate. Empty string relaxes any name constraints. 358 | * `cert` - file with user certificate for mutual TLS authentication. Should be used in conjunction with `key`. 359 | * `key` - file with private key matching user certificate specified with `cert` option. 360 | * `ciphers` - colon-separated list of enabled TLS ciphersuites. 361 | * `curves` - colon-separated list of enabled TLS key exchange curves. 362 | * `min-tls-version` - minimum TLS version. 363 | * `max-tls-version` - maximum TLS version. 364 | * `h2c` - HTTP/2 proxy over plaintext connection with the CONNECT method support. Examples: `h2c://example.org:8080`. 365 | * `socks5`, `socks5h` - SOCKS5 proxy with hostname resolving via remote proxy. Example: `socks5://127.0.0.1:9050`. 366 | * `set-src-hints` - not an actual proxy, but a signal to use different source IP address hints for this connection. It's useful to route traffic across multiple network interfaces, including VPN connections. URL has to have one query parameter `hints` with a comma-separated list of IP addresses. See `-ip-hints` command line option for more details. Example: `set-src-hints://?hints=10.2.0.2` 367 | * `cached` - pseudo-dialer which caches construction of another dialer specified by URL passed in `url` parameter of query string. Useful for dialers which are constructed dynamically from JS router script and which load certificate files. Example: `cache://?url=https%3A%2F%2Fexample.org%3Fcert%3Dcert.pem%26key%3Dkey.pem&ttl=5m`. Query string parameters are: 368 | * `url` - actual proxy URL. Note that just like any query string parameter this one has to be URL-encoded to be passed as query string value. 369 | * `ttl` - time to live for cache record. Examples: `15s`, `2m`, `1h`. 370 | 371 | ## Configuration files 372 | 373 | Reading options from configuration file is now supported too! For example, command from one of examples above: 374 | 375 | ``` 376 | dumbproxy \ 377 | -bind-address 127.0.0.1:10443 \ 378 | -proxyproto \ 379 | -auth basicfile://?path=/etc/dumbproxy.htpasswd \ 380 | -cert=/etc/letsencrypt/live/proxy.example.com/fullchain.pem \ 381 | -key=/etc/letsencrypt/live/proxy.example.com/privkey.pem 382 | ``` 383 | 384 | becomes just 385 | 386 | ``` 387 | dumbproxy -config dp.cfg 388 | ``` 389 | 390 | having dp.cfg file with following content: 391 | 392 | ``` 393 | bind-address 127.0.0.1:10443 394 | proxyproto 395 | auth basicfile://?path=/etc/dumbproxy.htpasswd 396 | cert /etc/letsencrypt/live/proxy.example.com/fullchain.pem 397 | key /etc/letsencrypt/live/proxy.example.com/privkey.pem 398 | ``` 399 | 400 | Configuration format is [RFC 4180](https://www.rfc-editor.org/rfc/rfc4180.html) CSV with space character (`" "`) as a field separator instead of comma, `#` as a comment start character. Lines with only one field are treated as boolean flags, lines with two or more fields are treated as a key and its value, having extra fields joined with space. 401 | 402 | ## Synopsis 403 | 404 | ``` 405 | $ ~/go/bin/dumbproxy -h 406 | Usage of /home/user/go/bin/dumbproxy: 407 | -auth string 408 | auth parameters (default "none://") 409 | -autocert 410 | issue TLS certificates automatically 411 | -autocert-acme string 412 | custom ACME endpoint (default "https://acme-v02.api.letsencrypt.org/directory") 413 | -autocert-cache-enc-key value 414 | hex-encoded encryption key for cert cache entries. Can be also set with DUMBPROXY_CACHE_ENC_KEY environment variable 415 | -autocert-cache-redis value 416 | use Redis URL for autocert cache 417 | -autocert-cache-redis-cluster value 418 | use Redis Cluster URL for autocert cache 419 | -autocert-cache-redis-prefix string 420 | prefix to use for keys in Redis or Redis Cluster cache 421 | -autocert-dir value 422 | use directory path for autocert cache 423 | -autocert-email string 424 | email used for ACME registration 425 | -autocert-http string 426 | listen address for HTTP-01 challenges handler of ACME 427 | -autocert-local-cache-timeout duration 428 | timeout for cert cache queries (default 10s) 429 | -autocert-local-cache-ttl duration 430 | enables in-memory cache for certificates 431 | -autocert-whitelist value 432 | restrict autocert domains to this comma-separated list 433 | -bind-address string 434 | HTTP proxy listen address. Set empty value to use systemd socket activation. (default ":8080") 435 | -bind-pprof string 436 | enables pprof debug endpoints 437 | -bind-reuseport 438 | allow multiple server instances on the same port 439 | -bw-limit uint 440 | per-user bandwidth limit in bytes per second 441 | -bw-limit-buckets uint 442 | number of buckets of bandwidth limit (default 1048576) 443 | -bw-limit-burst int 444 | allowed burst size for bandwidth limit, how many "tokens" can fit into leaky bucket 445 | -bw-limit-separate 446 | separate upload and download bandwidth limits 447 | -cafile string 448 | CA file to authenticate clients with certificates 449 | -cert string 450 | enable TLS and use certificate 451 | -ciphers string 452 | colon-separated list of enabled ciphers 453 | -config value 454 | read configuration from file with space-separated keys and values 455 | -curves string 456 | colon-separated list of enabled key exchange curves 457 | -deny-dst-addr value 458 | comma-separated list of CIDR prefixes of forbidden IP addresses (default 127.0.0.0/8, 0.0.0.0/32, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1/128, ::/128, fe80::/10) 459 | -disable-http2 460 | disable HTTP2 461 | -dns-cache-neg-ttl duration 462 | TTL for negative responses of DNS cache (default 1s) 463 | -dns-cache-timeout duration 464 | timeout for shared resolves of DNS cache (default 5s) 465 | -dns-cache-ttl duration 466 | enable DNS cache with specified fixed TTL 467 | -hmac-genkey 468 | generate hex-encoded HMAC signing key of optimal length 469 | -hmac-sign 470 | sign username with specified key for given validity period. Positional arguments are: hex-encoded HMAC key, username, validity duration. 471 | -ip-hints string 472 | a comma-separated list of source addresses to use on dial attempts. "$lAddr" gets expanded to local address of connection. Example: "10.0.0.1,fe80::2,$lAddr,0.0.0.0,::" 473 | -js-access-filter string 474 | path to JS script file with the "access" filter function 475 | -js-access-filter-instances int 476 | number of JS VM instances to handle access filter requests (default 4) 477 | -js-proxy-router value 478 | path to JS script file with the "getProxy" function 479 | -js-proxy-router-instances int 480 | number of JS VM instances to handle proxy router requests (default 4) 481 | -key string 482 | key for TLS certificate 483 | -list-ciphers 484 | list ciphersuites 485 | -list-curves 486 | list key exchange curves 487 | -max-tls-version value 488 | maximum TLS version accepted by server (default TLS13) 489 | -min-tls-version value 490 | minimum TLS version accepted by server (default TLS12) 491 | -passwd string 492 | update given htpasswd file and add/set password for username. Username and password can be passed as positional arguments or requested interactively 493 | -passwd-cost int 494 | bcrypt password cost (for -passwd mode) (default 4) 495 | -proxy value 496 | upstream proxy URL. Can be repeated multiple times to chain proxies. Examples: socks5h://127.0.0.1:9050; https://user:password@example.com:443 497 | -proxyproto 498 | listen proxy protocol 499 | -req-header-timeout duration 500 | amount of time allowed to read request headers (default 30s) 501 | -user-ip-hints 502 | allow IP hints to be specified by user in X-Src-IP-Hints header 503 | -verbosity int 504 | logging verbosity (10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical) (default 20) 505 | -version 506 | show program version and exit 507 | ``` 508 | 509 | ## See Also 510 | 511 | * [Project Wiki](https://github.com/SenseUnit/dumbproxy/wiki) 512 | * [Community in Telegram](https://t.me/alternative_proxy) 513 | -------------------------------------------------------------------------------- /access/access.go: -------------------------------------------------------------------------------- 1 | package access 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type Filter interface { 9 | Access(ctx context.Context, req *http.Request, username, network, address string) error 10 | } 11 | 12 | type AlwaysAllow struct{} 13 | 14 | func (_ AlwaysAllow) Access(_ context.Context, _ *http.Request, _, _, _ string) error { 15 | return nil 16 | } 17 | -------------------------------------------------------------------------------- /access/dst.go: -------------------------------------------------------------------------------- 1 | package access 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "net/netip" 8 | ) 9 | 10 | type DstAddrFilter struct { 11 | pfxList []netip.Prefix 12 | next Filter 13 | } 14 | 15 | type ErrDestinationAddressNotAllowed struct { 16 | a netip.Addr 17 | p netip.Prefix 18 | } 19 | 20 | func (e ErrDestinationAddressNotAllowed) Error() string { 21 | return fmt.Sprintf("destination address %s is not allowed by filter prefix %s", 22 | e.a.String(), e.p.String()) 23 | } 24 | 25 | func NewDstAddrFilter(prefixes []netip.Prefix, next Filter) DstAddrFilter { 26 | return DstAddrFilter{ 27 | pfxList: prefixes, 28 | next: next, 29 | } 30 | } 31 | 32 | func (f DstAddrFilter) Access(ctx context.Context, req *http.Request, username, network, address string) error { 33 | addrport, err := netip.ParseAddrPort(address) 34 | if err != nil { 35 | // not an IP address, no action needed 36 | return f.next.Access(ctx, req, username, network, address) 37 | } 38 | addr := addrport.Addr().Unmap() 39 | for _, pfx := range f.pfxList { 40 | if pfx.Contains(addr) { 41 | return ErrDestinationAddressNotAllowed{addr, pfx} 42 | } 43 | } 44 | return f.next.Access(ctx, req, username, network, address) 45 | } 46 | -------------------------------------------------------------------------------- /access/jsfilter.go: -------------------------------------------------------------------------------- 1 | package access 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/dop251/goja" 11 | 12 | "github.com/SenseUnit/dumbproxy/jsext" 13 | clog "github.com/SenseUnit/dumbproxy/log" 14 | ) 15 | 16 | var ErrJSDenied = errors.New("denied by JS filter") 17 | 18 | type JSFilterFunc = func(req *jsext.JSRequestInfo, dst *jsext.JSDstInfo, username string) (bool, error) 19 | 20 | // JSFilter is not suitable for concurrent use! 21 | // Wrap it with filter pool for that! 22 | type JSFilter struct { 23 | funcPool chan JSFilterFunc 24 | next Filter 25 | } 26 | 27 | func NewJSFilter(filename string, instances int, logger *clog.CondLogger, next Filter) (*JSFilter, error) { 28 | script, err := os.ReadFile(filename) 29 | if err != nil { 30 | return nil, fmt.Errorf("unable to load JS script file %q: %w", filename, err) 31 | } 32 | 33 | instances = max(1, instances) 34 | pool := make(chan JSFilterFunc, instances) 35 | 36 | for i := 0; i < instances; i++ { 37 | vm := goja.New() 38 | err = jsext.AddPrinter(vm, logger) 39 | if err != nil { 40 | return nil, errors.New("can't add print function to runtime") 41 | } 42 | vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) 43 | _, err = vm.RunString(string(script)) 44 | if err != nil { 45 | return nil, fmt.Errorf("script run failed: %w", err) 46 | } 47 | 48 | var f JSFilterFunc 49 | var accessFnJSVal goja.Value 50 | if ex := vm.Try(func() { 51 | accessFnJSVal = vm.Get("access") 52 | }); ex != nil { 53 | return nil, fmt.Errorf("\"access\" function cannot be located in VM context: %w", err) 54 | } 55 | if accessFnJSVal == nil { 56 | return nil, errors.New("\"access\" function is not defined") 57 | } 58 | err = vm.ExportTo(accessFnJSVal, &f) 59 | if err != nil { 60 | return nil, fmt.Errorf("can't export \"access\" function from JS VM: %w", err) 61 | } 62 | 63 | pool <- f 64 | } 65 | 66 | return &JSFilter{ 67 | funcPool: pool, 68 | next: next, 69 | }, nil 70 | } 71 | 72 | func (j *JSFilter) Access(ctx context.Context, req *http.Request, username, network, address string) error { 73 | ri := jsext.JSRequestInfoFromRequest(req) 74 | di, err := jsext.JSDstInfoFromContext(ctx, network, address) 75 | if err != nil { 76 | return fmt.Errorf("unable to construct dst info: %w", err) 77 | } 78 | var res bool 79 | func() { 80 | f := <-j.funcPool 81 | defer func(pool chan JSFilterFunc, f JSFilterFunc) { 82 | pool <- f 83 | }(j.funcPool, f) 84 | res, err = f(ri, di, username) 85 | }() 86 | if err != nil { 87 | return fmt.Errorf("JS access script exception: %w", err) 88 | } 89 | if !res { 90 | return ErrJSDenied 91 | } 92 | return j.next.Access(ctx, req, username, network, address) 93 | } 94 | -------------------------------------------------------------------------------- /auth/auth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | clog "github.com/SenseUnit/dumbproxy/log" 11 | ) 12 | 13 | type Auth interface { 14 | Validate(ctx context.Context, wr http.ResponseWriter, req *http.Request) (string, bool) 15 | Stop() 16 | } 17 | 18 | func NewAuth(paramstr string, logger *clog.CondLogger) (Auth, error) { 19 | url, err := url.Parse(paramstr) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | switch strings.ToLower(url.Scheme) { 25 | case "static": 26 | return NewStaticAuth(url, logger) 27 | case "basicfile": 28 | return NewBasicFileAuth(url, logger) 29 | case "hmac": 30 | return NewHMACAuth(url, logger) 31 | case "cert": 32 | return NewCertAuth(url, logger) 33 | case "redis": 34 | return NewRedisAuth(url, false, logger) 35 | case "redis-cluster": 36 | return NewRedisAuth(url, true, logger) 37 | case "none": 38 | return NoAuth{}, nil 39 | default: 40 | return nil, errors.New("Unknown auth scheme") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /auth/basic.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "sync/atomic" 14 | "time" 15 | 16 | "github.com/tg123/go-htpasswd" 17 | 18 | clog "github.com/SenseUnit/dumbproxy/log" 19 | ) 20 | 21 | type pwFile struct { 22 | file *htpasswd.File 23 | modTime time.Time 24 | } 25 | type BasicAuth struct { 26 | pw atomic.Pointer[pwFile] 27 | pwFilename string 28 | logger *clog.CondLogger 29 | hiddenDomain string 30 | stopOnce sync.Once 31 | stopChan chan struct{} 32 | } 33 | 34 | func NewBasicFileAuth(param_url *url.URL, logger *clog.CondLogger) (*BasicAuth, error) { 35 | values, err := url.ParseQuery(param_url.RawQuery) 36 | if err != nil { 37 | return nil, err 38 | } 39 | filename := values.Get("path") 40 | if filename == "" { 41 | return nil, errors.New("\"path\" parameter is missing from auth config URI") 42 | } 43 | 44 | auth := &BasicAuth{ 45 | hiddenDomain: strings.ToLower(values.Get("hidden_domain")), 46 | pwFilename: filename, 47 | logger: logger, 48 | stopChan: make(chan struct{}), 49 | } 50 | 51 | if err := auth.reload(); err != nil { 52 | return nil, fmt.Errorf("unable to load initial password list: %w", err) 53 | } 54 | 55 | reloadInterval := 15 * time.Second 56 | if reloadIntervalOption := values.Get("reload"); reloadIntervalOption != "" { 57 | parsedInterval, err := time.ParseDuration(reloadIntervalOption) 58 | if err != nil { 59 | logger.Warning("unable to parse reload interval: %v. using default value.", err) 60 | } 61 | reloadInterval = parsedInterval 62 | } 63 | if reloadInterval > 0 { 64 | go auth.reloadLoop(reloadInterval) 65 | } 66 | 67 | return auth, nil 68 | } 69 | 70 | func (auth *BasicAuth) reload() error { 71 | var oldModTime time.Time 72 | if oldPw := auth.pw.Load(); oldPw != nil { 73 | oldModTime = oldPw.modTime 74 | } 75 | 76 | f, modTime, err := openIfModified(auth.pwFilename, oldModTime) 77 | if err != nil { 78 | return err 79 | } 80 | if f == nil { 81 | // no changes since last modTime 82 | return nil 83 | } 84 | 85 | auth.logger.Info("reloading password file from %q...", auth.pwFilename) 86 | newPwFile, err := htpasswd.NewFromReader(f, htpasswd.DefaultSystems, func(parseErr error) { 87 | auth.logger.Error("failed to parse line in %q: %v", auth.pwFilename, parseErr) 88 | }) 89 | if err != nil { 90 | return err 91 | } 92 | 93 | newPw := &pwFile{ 94 | file: newPwFile, 95 | modTime: modTime, 96 | } 97 | auth.pw.Store(newPw) 98 | auth.logger.Info("password file reloaded.") 99 | 100 | return nil 101 | } 102 | 103 | func (auth *BasicAuth) reloadLoop(interval time.Duration) { 104 | ticker := time.NewTicker(interval) 105 | defer ticker.Stop() 106 | for { 107 | select { 108 | case <-auth.stopChan: 109 | return 110 | case <-ticker.C: 111 | if err := auth.reload(); err != nil { 112 | auth.logger.Error("reload failed: %v", err) 113 | } 114 | } 115 | } 116 | } 117 | 118 | func (auth *BasicAuth) Validate(_ context.Context, wr http.ResponseWriter, req *http.Request) (string, bool) { 119 | hdr := req.Header.Get("Proxy-Authorization") 120 | if hdr == "" { 121 | requireBasicAuth(wr, req, auth.hiddenDomain) 122 | return "", false 123 | } 124 | hdr_parts := strings.SplitN(hdr, " ", 2) 125 | if len(hdr_parts) != 2 || strings.ToLower(hdr_parts[0]) != "basic" { 126 | requireBasicAuth(wr, req, auth.hiddenDomain) 127 | return "", false 128 | } 129 | 130 | token := hdr_parts[1] 131 | data, err := base64.StdEncoding.DecodeString(token) 132 | if err != nil { 133 | requireBasicAuth(wr, req, auth.hiddenDomain) 134 | return "", false 135 | } 136 | 137 | pair := strings.SplitN(string(data), ":", 2) 138 | if len(pair) != 2 { 139 | requireBasicAuth(wr, req, auth.hiddenDomain) 140 | return "", false 141 | } 142 | 143 | login := pair[0] 144 | password := pair[1] 145 | 146 | pwFile := auth.pw.Load().file 147 | 148 | if pwFile.Match(login, password) { 149 | if auth.hiddenDomain != "" && 150 | (req.Host == auth.hiddenDomain || req.URL.Host == auth.hiddenDomain) { 151 | wr.Header().Set("Content-Length", strconv.Itoa(len([]byte(AUTH_TRIGGERED_MSG)))) 152 | wr.Header().Set("Pragma", "no-cache") 153 | wr.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 154 | wr.Header().Set("Expires", EPOCH_EXPIRE) 155 | wr.Header()["Date"] = nil 156 | wr.WriteHeader(http.StatusOK) 157 | wr.Write([]byte(AUTH_TRIGGERED_MSG)) 158 | return "", false 159 | } else { 160 | return login, true 161 | } 162 | } 163 | requireBasicAuth(wr, req, auth.hiddenDomain) 164 | return "", false 165 | } 166 | 167 | func (auth *BasicAuth) Stop() { 168 | auth.stopOnce.Do(func() { 169 | close(auth.stopChan) 170 | }) 171 | } 172 | -------------------------------------------------------------------------------- /auth/cert.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "encoding/hex" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "math/big" 12 | "net/http" 13 | "net/url" 14 | "sync" 15 | "sync/atomic" 16 | "time" 17 | 18 | clog "github.com/SenseUnit/dumbproxy/log" 19 | 20 | us "github.com/Snawoot/uniqueslice" 21 | ) 22 | 23 | type serialNumberSetFile struct { 24 | file *serialNumberSet 25 | modTime time.Time 26 | } 27 | 28 | type CertAuth struct { 29 | blacklist atomic.Pointer[serialNumberSetFile] 30 | blacklistFilename string 31 | logger *clog.CondLogger 32 | stopOnce sync.Once 33 | stopChan chan struct{} 34 | } 35 | 36 | func NewCertAuth(param_url *url.URL, logger *clog.CondLogger) (*CertAuth, error) { 37 | values, err := url.ParseQuery(param_url.RawQuery) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | auth := &CertAuth{ 43 | blacklistFilename: values.Get("blacklist"), 44 | logger: logger, 45 | stopChan: make(chan struct{}), 46 | } 47 | auth.blacklist.Store(new(serialNumberSetFile)) 48 | 49 | reloadInterval := 15 * time.Second 50 | if reloadIntervalOption := values.Get("reload"); reloadIntervalOption != "" { 51 | parsedInterval, err := time.ParseDuration(reloadIntervalOption) 52 | if err != nil { 53 | logger.Warning("unable to parse reload interval: %v. using default value.", err) 54 | } 55 | reloadInterval = parsedInterval 56 | } 57 | if auth.blacklistFilename != "" { 58 | if err := auth.reload(); err != nil { 59 | return nil, fmt.Errorf("unable to load initial certificate blacklist: %w", err) 60 | } 61 | if reloadInterval > 0 { 62 | go auth.reloadLoop(reloadInterval) 63 | } 64 | } 65 | 66 | return auth, nil 67 | } 68 | 69 | func (auth *CertAuth) Validate(_ context.Context, wr http.ResponseWriter, req *http.Request) (string, bool) { 70 | if req.TLS == nil || len(req.TLS.VerifiedChains) < 1 || len(req.TLS.VerifiedChains[0]) < 1 { 71 | http.Error(wr, BAD_REQ_MSG, http.StatusBadRequest) 72 | return "", false 73 | } 74 | eeCert := req.TLS.VerifiedChains[0][0] 75 | if auth.blacklist.Load().file.Has(eeCert.SerialNumber) { 76 | http.Error(wr, BAD_REQ_MSG, http.StatusBadRequest) 77 | return "", false 78 | } 79 | return fmt.Sprintf( 80 | "Subject: %s, Serial Number: %s", 81 | eeCert.Subject.String(), 82 | formatSerial(eeCert.SerialNumber), 83 | ), true 84 | } 85 | 86 | func (auth *CertAuth) Stop() { 87 | auth.stopOnce.Do(func() { 88 | close(auth.stopChan) 89 | }) 90 | } 91 | 92 | func (auth *CertAuth) reload() error { 93 | var oldModTime time.Time 94 | if oldBL := auth.blacklist.Load(); oldBL != nil { 95 | oldModTime = oldBL.modTime 96 | } 97 | 98 | f, modTime, err := openIfModified(auth.blacklistFilename, oldModTime) 99 | if err != nil { 100 | return err 101 | } 102 | if f == nil { 103 | // no changes since last modTime 104 | return nil 105 | } 106 | 107 | auth.logger.Info("reloading certificate blacklist from %q...", auth.blacklistFilename) 108 | newBlacklistSet, err := newSerialNumberSetFromReader(f, func(parseErr error) { 109 | auth.logger.Error("failed to parse line in %q: %v", auth.blacklistFilename, parseErr) 110 | }) 111 | if err != nil { 112 | return err 113 | } 114 | 115 | newBlacklist := &serialNumberSetFile{ 116 | file: newBlacklistSet, 117 | modTime: modTime, 118 | } 119 | auth.blacklist.Store(newBlacklist) 120 | auth.logger.Info("blacklist file reloaded.") 121 | 122 | return nil 123 | } 124 | 125 | func (auth *CertAuth) reloadLoop(interval time.Duration) { 126 | ticker := time.NewTicker(interval) 127 | defer ticker.Stop() 128 | for { 129 | select { 130 | case <-auth.stopChan: 131 | return 132 | case <-ticker.C: 133 | if err := auth.reload(); err != nil { 134 | auth.logger.Error("reload failed: %v", err) 135 | } 136 | } 137 | } 138 | } 139 | 140 | // formatSerial from https://codereview.stackexchange.com/a/165708 141 | func formatSerial(serial *big.Int) string { 142 | b := serial.Bytes() 143 | buf := make([]byte, 0, 3*len(b)) 144 | x := buf[1*len(b) : 3*len(b)] 145 | hex.Encode(x, b) 146 | for i := 0; i < len(x); i += 2 { 147 | buf = append(buf, x[i], x[i+1], ':') 148 | } 149 | if serial.Sign() == -1 { 150 | return "(Negative)" + string(buf[:len(buf)-1]) 151 | } 152 | return string(buf[:len(buf)-1]) 153 | } 154 | 155 | type serialNumberKey = us.Handle[[]byte, byte] 156 | type serialNumberSet struct { 157 | sns map[serialNumberKey]struct{} 158 | } 159 | 160 | func cutLeadingZeroes(b []byte) []byte { 161 | for len(b) > 1 && b[0] == 0 { 162 | b = b[1:] 163 | } 164 | return b 165 | } 166 | 167 | func (s *serialNumberSet) Has(serial *big.Int) bool { 168 | key := us.Make(cutLeadingZeroes(serial.Bytes())) 169 | if s == nil || s.sns == nil { 170 | return false 171 | } 172 | _, found := s.sns[key] 173 | return found 174 | } 175 | 176 | func newSerialNumberSetFromReader(r io.Reader, bad func(error)) (*serialNumberSet, error) { 177 | set := make(map[serialNumberKey]struct{}) 178 | scanner := bufio.NewScanner(r) 179 | for scanner.Scan() { 180 | line, _, _ := bytes.Cut(scanner.Bytes(), []byte{'#'}) 181 | line = bytes.TrimSpace(line) 182 | if len(line) == 0 { 183 | continue 184 | } 185 | serial, err := parseSerialBytes(line) 186 | if err != nil { 187 | if bad != nil { 188 | bad(fmt.Errorf("bad serial number line %q: %w", line, err)) 189 | } 190 | continue 191 | } 192 | set[us.Make(cutLeadingZeroes(serial))] = struct{}{} 193 | } 194 | 195 | if err := scanner.Err(); err != nil { 196 | return nil, fmt.Errorf("unable to load serial number set: %w", err) 197 | } 198 | 199 | return &serialNumberSet{ 200 | sns: set, 201 | }, nil 202 | } 203 | 204 | func parseSerialBytes(serial []byte) ([]byte, error) { 205 | res := make([]byte, (len(serial)+2)/3) 206 | 207 | var i int 208 | for ; i < len(res) && i*3+1 < len(serial); i++ { 209 | if _, err := hex.Decode(res[i:i+1], serial[i*3:i*3+2]); err != nil { 210 | return nil, fmt.Errorf("parseSerialBytes() failed: %w", err) 211 | } 212 | if i*3+2 < len(serial) && serial[i*3+2] != ':' { 213 | return nil, errors.New("missing colon delimiter") 214 | } 215 | } 216 | if i < len(res) { 217 | return nil, errors.New("incomplete serial number string") 218 | } 219 | 220 | return res, nil 221 | } 222 | -------------------------------------------------------------------------------- /auth/cert_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "math/big" 7 | "strings" 8 | "testing" 9 | ) 10 | 11 | func mkbytes(l uint) []byte { 12 | b := make([]byte, l) 13 | for i := uint(0); i < l; i++ { 14 | b[i] = byte(i) 15 | } 16 | return b 17 | } 18 | 19 | var mask *big.Int = big.NewInt(0).Add(big.NewInt(0).Lsh(big.NewInt(1), uint(8*len(serialNumberKey{}))), big.NewInt(-1)) 20 | 21 | func TestNormalizeSNBytes(t *testing.T) { 22 | for i := uint(0); i <= 32; i++ { 23 | t.Run(fmt.Sprintf("%d-bytes", i), func(t *testing.T) { 24 | s := mkbytes(i) 25 | k := normalizeSNBytes(s) 26 | var a, b big.Int 27 | a.SetBytes(s).And(&a, mask) 28 | b.SetBytes(k[:]) 29 | if a.Cmp(&b) != 0 { 30 | t.Fatalf("%d != %d", &a, &b) 31 | } 32 | }) 33 | } 34 | } 35 | 36 | type parseSerialBytesTestcase struct { 37 | input []byte 38 | output []byte 39 | error bool 40 | } 41 | 42 | func TestParseSerialBytes(t *testing.T) { 43 | testcases := []parseSerialBytesTestcase{ 44 | { 45 | input: []byte(""), 46 | output: []byte{}, 47 | }, 48 | { 49 | input: []byte("01:02:03"), 50 | output: []byte{1, 2, 3}, 51 | }, 52 | { 53 | input: []byte("ff"), 54 | output: []byte{255}, 55 | }, 56 | { 57 | input: []byte("ff:f"), 58 | error: true, 59 | }, 60 | { 61 | input: []byte("f"), 62 | error: true, 63 | }, 64 | { 65 | input: []byte("fff"), 66 | error: true, 67 | }, 68 | { 69 | input: []byte("---"), 70 | error: true, 71 | }, 72 | } 73 | for i, testcase := range testcases { 74 | t.Run(fmt.Sprintf("Testcase[%d]", i), func(t *testing.T) { 75 | out, err := parseSerialBytes(testcase.input) 76 | if (err != nil) != testcase.error { 77 | t.Fatalf("unexpected error: %v", err) 78 | } 79 | if bytes.Compare(out, testcase.output) != 0 { 80 | t.Fatalf("expected %v, got %v", testcase.output, out) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | type serialNumberSetTestcase struct { 87 | input *big.Int 88 | output bool 89 | } 90 | 91 | func TestSerialNumberSetSmoke(t *testing.T) { 92 | const testFile = ` 93 | 01:00:00:00:00 # test 94 | # test 2 95 | 03 96 | 03 97 | 98 | 00 99 | 01 100 | 02` 101 | testcases := []serialNumberSetTestcase{ 102 | { 103 | input: big.NewInt(1 << 32), 104 | output: true, 105 | }, 106 | { 107 | input: big.NewInt(0), 108 | output: true, 109 | }, 110 | { 111 | input: big.NewInt(1), 112 | output: true, 113 | }, 114 | { 115 | input: big.NewInt(2), 116 | output: true, 117 | }, 118 | { 119 | input: big.NewInt(3), 120 | output: true, 121 | }, 122 | { 123 | input: big.NewInt(4), 124 | output: false, 125 | }, 126 | { 127 | input: big.NewInt(-2), 128 | output: true, 129 | }, 130 | } 131 | s, err := newSerialNumberSetFromReader(strings.NewReader(testFile), nil) 132 | if err != nil { 133 | t.Fatalf("unable to load test set: %v", err) 134 | } 135 | for i, testcase := range testcases { 136 | t.Run(fmt.Sprintf("Testcase[%d]", i), func(t *testing.T) { 137 | out := s.Has(testcase.input) 138 | if out != testcase.output { 139 | t.Fatalf("expected %v, got %v", testcase.output, out) 140 | } 141 | }) 142 | } 143 | } 144 | 145 | func TestSerialNumberSetEmpty(t *testing.T) { 146 | const testFile = "" 147 | testcases := []serialNumberSetTestcase{ 148 | { 149 | input: big.NewInt(0), 150 | output: false, 151 | }, 152 | { 153 | input: big.NewInt(1), 154 | output: false, 155 | }, 156 | { 157 | input: big.NewInt(2), 158 | output: false, 159 | }, 160 | } 161 | s, err := newSerialNumberSetFromReader(strings.NewReader(testFile), nil) 162 | if err != nil { 163 | t.Fatalf("unable to load test set: %v", err) 164 | } 165 | for i, testcase := range testcases { 166 | t.Run(fmt.Sprintf("Testcase[%d]", i), func(t *testing.T) { 167 | out := s.Has(testcase.input) 168 | if out != testcase.output { 169 | t.Fatalf("expected %v, got %v", testcase.output, out) 170 | } 171 | }) 172 | } 173 | } 174 | 175 | func TestSerialNumberSetNullMap(t *testing.T) { 176 | const testFile = "" 177 | testcases := []serialNumberSetTestcase{ 178 | { 179 | input: big.NewInt(0), 180 | output: false, 181 | }, 182 | { 183 | input: big.NewInt(1), 184 | output: false, 185 | }, 186 | { 187 | input: big.NewInt(2), 188 | output: false, 189 | }, 190 | } 191 | s := new(serialNumberSet) 192 | for i, testcase := range testcases { 193 | t.Run(fmt.Sprintf("Testcase[%d]", i), func(t *testing.T) { 194 | out := s.Has(testcase.input) 195 | if out != testcase.output { 196 | t.Fatalf("expected %v, got %v", testcase.output, out) 197 | } 198 | }) 199 | } 200 | } 201 | 202 | func TestSerialNumberSetNull(t *testing.T) { 203 | const testFile = "" 204 | testcases := []serialNumberSetTestcase{ 205 | { 206 | input: big.NewInt(0), 207 | output: false, 208 | }, 209 | { 210 | input: big.NewInt(1), 211 | output: false, 212 | }, 213 | { 214 | input: big.NewInt(2), 215 | output: false, 216 | }, 217 | } 218 | s := (*serialNumberSet)(nil) 219 | for i, testcase := range testcases { 220 | t.Run(fmt.Sprintf("Testcase[%d]", i), func(t *testing.T) { 221 | out := s.Has(testcase.input) 222 | if out != testcase.output { 223 | t.Fatalf("expected %v, got %v", testcase.output, out) 224 | } 225 | }) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /auth/common.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "crypto/subtle" 5 | "errors" 6 | "net" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/tg123/go-htpasswd" 12 | ) 13 | 14 | func matchHiddenDomain(host, hidden_domain string) bool { 15 | if h, _, err := net.SplitHostPort(host); err == nil { 16 | host = h 17 | } 18 | host = strings.ToLower(host) 19 | return subtle.ConstantTimeCompare([]byte(host), []byte(hidden_domain)) == 1 20 | } 21 | 22 | func requireBasicAuth(wr http.ResponseWriter, req *http.Request, hidden_domain string) { 23 | if hidden_domain != "" && 24 | !matchHiddenDomain(req.URL.Host, hidden_domain) && 25 | !matchHiddenDomain(req.Host, hidden_domain) { 26 | http.Error(wr, BAD_REQ_MSG, http.StatusBadRequest) 27 | } else { 28 | wr.Header().Set("Proxy-Authenticate", `Basic realm="dumbproxy"`) 29 | wr.Header().Set("Content-Length", strconv.Itoa(len([]byte(AUTH_REQUIRED_MSG)))) 30 | wr.WriteHeader(407) 31 | wr.Write([]byte(AUTH_REQUIRED_MSG)) 32 | } 33 | } 34 | 35 | func makePasswdMatcher(encoded string) (htpasswd.EncodedPasswd, error) { 36 | for _, p := range htpasswd.DefaultSystems { 37 | matcher, err := p(encoded) 38 | if err != nil { 39 | return nil, err 40 | } 41 | if matcher != nil { 42 | return matcher, nil 43 | } 44 | } 45 | return nil, errors.New("no suitable password encoding system found") 46 | } 47 | -------------------------------------------------------------------------------- /auth/constants.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | const AUTH_REQUIRED_MSG = "Proxy authentication required.\n" 4 | const BAD_REQ_MSG = "Bad Request\n" 5 | const AUTH_TRIGGERED_MSG = "Browser auth triggered!\n" 6 | const EPOCH_EXPIRE = "Thu, 01 Jan 1970 00:00:01 GMT" 7 | -------------------------------------------------------------------------------- /auth/file.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | ) 8 | 9 | func openIfModified(filename string, since time.Time) (*os.File, time.Time, error) { 10 | f, err := os.Open(filename) 11 | if err != nil { 12 | return nil, time.Time{}, fmt.Errorf("openIfModified(): can't open file %q: %w", filename, err) 13 | } 14 | 15 | fi, err := f.Stat() 16 | if err != nil { 17 | f.Close() 18 | return nil, time.Time{}, fmt.Errorf("openIfModified(): can't stat file %q: %w", filename, err) 19 | } 20 | 21 | modTime := fi.ModTime() 22 | if (since != time.Time{}) && !since.Before(modTime) { 23 | f.Close() 24 | return nil, modTime, nil 25 | } 26 | 27 | return f, modTime, nil 28 | } 29 | -------------------------------------------------------------------------------- /auth/hmac.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/base64" 8 | "encoding/binary" 9 | "encoding/hex" 10 | "errors" 11 | "fmt" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "strconv" 16 | "strings" 17 | "time" 18 | 19 | clog "github.com/SenseUnit/dumbproxy/log" 20 | ) 21 | 22 | const ( 23 | HMACSignaturePrefix = "dumbproxy grant token v1" 24 | HMACSignatureSize = 32 25 | HMACTimestampSize = 8 26 | EnvVarHMACSecret = "DUMBPROXY_HMAC_SECRET" 27 | ) 28 | 29 | type HMACAuth struct { 30 | secret []byte 31 | hiddenDomain string 32 | logger *clog.CondLogger 33 | } 34 | 35 | func NewHMACAuth(param_url *url.URL, logger *clog.CondLogger) (*HMACAuth, error) { 36 | values, err := url.ParseQuery(param_url.RawQuery) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | hexSecret := os.Getenv(EnvVarHMACSecret) 42 | if hs := values.Get("secret"); hs != "" { 43 | hexSecret = hs 44 | } 45 | 46 | if hexSecret == "" { 47 | return nil, errors.New("no HMAC secret specified. Please specify \"secret\" parameter for auth provider or set " + EnvVarHMACSecret + " environment variable.") 48 | } 49 | 50 | secret, err := hex.DecodeString(hexSecret) 51 | if err != nil { 52 | return nil, fmt.Errorf("can't hex-decode HMAC secret: %w", err) 53 | } 54 | 55 | return &HMACAuth{ 56 | secret: secret, 57 | logger: logger, 58 | hiddenDomain: strings.ToLower(values.Get("hidden_domain")), 59 | }, nil 60 | } 61 | 62 | type HMACToken struct { 63 | Expire int64 64 | Signature [HMACSignatureSize]byte 65 | } 66 | 67 | func VerifyHMACLoginAndPassword(secret []byte, login, password string) bool { 68 | marshaledToken, err := base64.RawURLEncoding.DecodeString(password) 69 | if err != nil { 70 | return false 71 | } 72 | 73 | var token HMACToken 74 | _, err = binary.Decode(marshaledToken, binary.BigEndian, &token) 75 | if err != nil { 76 | return false 77 | } 78 | 79 | if time.Unix(token.Expire, 0).Before(time.Now()) { 80 | return false 81 | } 82 | 83 | expectedMAC := CalculateHMACSignature(secret, login, token.Expire) 84 | return hmac.Equal(token.Signature[:], expectedMAC) 85 | } 86 | 87 | func (auth *HMACAuth) Validate(_ context.Context, wr http.ResponseWriter, req *http.Request) (string, bool) { 88 | hdr := req.Header.Get("Proxy-Authorization") 89 | if hdr == "" { 90 | requireBasicAuth(wr, req, auth.hiddenDomain) 91 | return "", false 92 | } 93 | hdr_parts := strings.SplitN(hdr, " ", 2) 94 | if len(hdr_parts) != 2 || strings.ToLower(hdr_parts[0]) != "basic" { 95 | requireBasicAuth(wr, req, auth.hiddenDomain) 96 | return "", false 97 | } 98 | 99 | token := hdr_parts[1] 100 | data, err := base64.StdEncoding.DecodeString(token) 101 | if err != nil { 102 | requireBasicAuth(wr, req, auth.hiddenDomain) 103 | return "", false 104 | } 105 | 106 | pair := strings.SplitN(string(data), ":", 2) 107 | if len(pair) != 2 { 108 | requireBasicAuth(wr, req, auth.hiddenDomain) 109 | return "", false 110 | } 111 | 112 | login := pair[0] 113 | password := pair[1] 114 | 115 | if VerifyHMACLoginAndPassword(auth.secret, login, password) { 116 | if auth.hiddenDomain != "" && 117 | (req.Host == auth.hiddenDomain || req.URL.Host == auth.hiddenDomain) { 118 | wr.Header().Set("Content-Length", strconv.Itoa(len([]byte(AUTH_TRIGGERED_MSG)))) 119 | wr.Header().Set("Pragma", "no-cache") 120 | wr.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 121 | wr.Header().Set("Expires", EPOCH_EXPIRE) 122 | wr.Header()["Date"] = nil 123 | wr.WriteHeader(http.StatusOK) 124 | wr.Write([]byte(AUTH_TRIGGERED_MSG)) 125 | return "", false 126 | } else { 127 | return login, true 128 | } 129 | } 130 | requireBasicAuth(wr, req, auth.hiddenDomain) 131 | return "", false 132 | } 133 | 134 | func (auth *HMACAuth) Stop() { 135 | } 136 | 137 | func CalculateHMACSignature(secret []byte, username string, expire int64) []byte { 138 | mac := hmac.New(sha256.New, secret) 139 | mac.Write([]byte(HMACSignaturePrefix)) 140 | mac.Write([]byte(username)) 141 | binary.Write(mac, binary.BigEndian, expire) 142 | return mac.Sum(nil) 143 | } 144 | -------------------------------------------------------------------------------- /auth/hmac_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "encoding/binary" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | var ( 13 | resBytes []byte 14 | resBool bool 15 | ) 16 | 17 | func BenchmarkCalculateHMACSignature(b *testing.B) { 18 | var r []byte 19 | secret := make([]byte, HMACSignatureSize) 20 | if _, err := rand.Read(secret); err != nil { 21 | b.Fatalf("CSPRNG failure: %v", err) 22 | } 23 | b.ResetTimer() 24 | 25 | for n := 0; n < b.N; n++ { 26 | r = CalculateHMACSignature(secret, "username", 0) 27 | } 28 | resBytes = r 29 | } 30 | 31 | func BenchmarkVerifyHMACLoginAndPassword(b *testing.B) { 32 | var r bool 33 | secret := make([]byte, HMACSignatureSize) 34 | if _, err := rand.Read(secret); err != nil { 35 | b.Fatalf("CSPRNG failure: %v", err) 36 | } 37 | username := "username" 38 | expire := time.Now().Add(time.Hour).Unix() 39 | mac := CalculateHMACSignature(secret, username, expire) 40 | token := HMACToken{ 41 | Expire: expire, 42 | } 43 | copy(token.Signature[:], mac) 44 | var resBuf bytes.Buffer 45 | enc := base64.NewEncoder(base64.RawURLEncoding, &resBuf) 46 | if err := binary.Write(enc, binary.BigEndian, &token); err != nil { 47 | b.Fatalf("token encoding failed: %v", err) 48 | } 49 | enc.Close() 50 | b.ResetTimer() 51 | 52 | for n := 0; n < b.N; n++ { 53 | r = VerifyHMACLoginAndPassword(secret, username, resBuf.String()) 54 | if !r { 55 | b.Fail() 56 | } 57 | } 58 | resBool = r 59 | } 60 | -------------------------------------------------------------------------------- /auth/noauth.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type NoAuth struct{} 9 | 10 | func (_ NoAuth) Validate(_ context.Context, _ http.ResponseWriter, _ *http.Request) (string, bool) { 11 | return "", true 12 | } 13 | 14 | func (_ NoAuth) Stop() {} 15 | -------------------------------------------------------------------------------- /auth/redis.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "net/http" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | 11 | clog "github.com/SenseUnit/dumbproxy/log" 12 | 13 | "github.com/redis/go-redis/v9" 14 | ) 15 | 16 | type RedisAuth struct { 17 | logger *clog.CondLogger 18 | hiddenDomain string 19 | r redis.Cmdable 20 | keyPrefix string 21 | } 22 | 23 | func NewRedisAuth(param_url *url.URL, cluster bool, logger *clog.CondLogger) (*RedisAuth, error) { 24 | values, err := url.ParseQuery(param_url.RawQuery) 25 | if err != nil { 26 | return nil, err 27 | } 28 | auth := &RedisAuth{ 29 | logger: logger, 30 | hiddenDomain: strings.ToLower(values.Get("hidden_domain")), 31 | keyPrefix: values.Get("key_prefix"), 32 | } 33 | if cluster { 34 | opts, err := redis.ParseClusterURL(values.Get("url")) 35 | if err != nil { 36 | return nil, err 37 | } 38 | auth.r = redis.NewClusterClient(opts) 39 | } else { 40 | opts, err := redis.ParseURL(values.Get("url")) 41 | if err != nil { 42 | return nil, err 43 | } 44 | auth.r = redis.NewClient(opts) 45 | } 46 | return auth, nil 47 | } 48 | 49 | func (auth *RedisAuth) Validate(ctx context.Context, wr http.ResponseWriter, req *http.Request) (string, bool) { 50 | hdr := req.Header.Get("Proxy-Authorization") 51 | if hdr == "" { 52 | requireBasicAuth(wr, req, auth.hiddenDomain) 53 | return "", false 54 | } 55 | hdr_parts := strings.SplitN(hdr, " ", 2) 56 | if len(hdr_parts) != 2 || strings.ToLower(hdr_parts[0]) != "basic" { 57 | requireBasicAuth(wr, req, auth.hiddenDomain) 58 | return "", false 59 | } 60 | 61 | token := hdr_parts[1] 62 | data, err := base64.StdEncoding.DecodeString(token) 63 | if err != nil { 64 | requireBasicAuth(wr, req, auth.hiddenDomain) 65 | return "", false 66 | } 67 | 68 | pair := strings.SplitN(string(data), ":", 2) 69 | if len(pair) != 2 { 70 | requireBasicAuth(wr, req, auth.hiddenDomain) 71 | return "", false 72 | } 73 | 74 | login := pair[0] 75 | password := pair[1] 76 | 77 | encodedPasswd, err := auth.r.Get(ctx, auth.keyPrefix+login).Result() 78 | if err != nil { 79 | auth.logger.Debug("error fetching key %q from Redis: %v", auth.keyPrefix+login, err) 80 | requireBasicAuth(wr, req, auth.hiddenDomain) 81 | return "", false 82 | } 83 | matcher, err := makePasswdMatcher(encodedPasswd) 84 | if err != nil { 85 | auth.logger.Debug("can't create password matcher from Redis key %q: %v", auth.keyPrefix+login, err) 86 | requireBasicAuth(wr, req, auth.hiddenDomain) 87 | return "", false 88 | } 89 | 90 | if matcher.MatchesPassword(password) { 91 | if auth.hiddenDomain != "" && 92 | (req.Host == auth.hiddenDomain || req.URL.Host == auth.hiddenDomain) { 93 | wr.Header().Set("Content-Length", strconv.Itoa(len([]byte(AUTH_TRIGGERED_MSG)))) 94 | wr.Header().Set("Pragma", "no-cache") 95 | wr.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 96 | wr.Header().Set("Expires", EPOCH_EXPIRE) 97 | wr.Header()["Date"] = nil 98 | wr.WriteHeader(http.StatusOK) 99 | wr.Write([]byte(AUTH_TRIGGERED_MSG)) 100 | return "", false 101 | } else { 102 | return login, true 103 | } 104 | } 105 | requireBasicAuth(wr, req, auth.hiddenDomain) 106 | return "", false 107 | } 108 | 109 | func (auth *RedisAuth) Stop() { 110 | } 111 | -------------------------------------------------------------------------------- /auth/static.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/tg123/go-htpasswd" 11 | "golang.org/x/crypto/bcrypt" 12 | 13 | clog "github.com/SenseUnit/dumbproxy/log" 14 | ) 15 | 16 | func NewStaticAuth(param_url *url.URL, logger *clog.CondLogger) (*BasicAuth, error) { 17 | values, err := url.ParseQuery(param_url.RawQuery) 18 | if err != nil { 19 | return nil, err 20 | } 21 | username := values.Get("username") 22 | if username == "" { 23 | return nil, errors.New("\"username\" parameter is missing from auth config URI") 24 | } 25 | password := values.Get("password") 26 | if password == "" { 27 | return nil, errors.New("\"password\" parameter is missing from auth config URI") 28 | } 29 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) 30 | if err != nil { 31 | return nil, err 32 | } 33 | buf := bytes.NewBufferString(username) 34 | buf.WriteByte(':') 35 | buf.Write(hashedPassword) 36 | 37 | f, err := htpasswd.NewFromReader(buf, htpasswd.DefaultSystems, func(parseError error) { 38 | logger.Error("static auth: password entry parse error: %v", err) 39 | }) 40 | if err != nil { 41 | return nil, fmt.Errorf("can't instantiate pwFile: %w", err) 42 | } 43 | 44 | ba := &BasicAuth{ 45 | hiddenDomain: strings.ToLower(values.Get("hidden_domain")), 46 | logger: logger, 47 | stopChan: make(chan struct{}), 48 | } 49 | ba.pw.Store(&pwFile{file: f}) 50 | return ba, nil 51 | } 52 | -------------------------------------------------------------------------------- /certcache/cryptobox.go: -------------------------------------------------------------------------------- 1 | package certcache 2 | 3 | import ( 4 | "context" 5 | "crypto/cipher" 6 | cryptorand "crypto/rand" 7 | "errors" 8 | 9 | "golang.org/x/crypto/acme/autocert" 10 | "golang.org/x/crypto/chacha20poly1305" 11 | ) 12 | 13 | type EncryptedCache struct { 14 | aead cipher.AEAD 15 | next autocert.Cache 16 | } 17 | 18 | func NewEncryptedCache(key []byte, next autocert.Cache) (*EncryptedCache, error) { 19 | aead, err := chacha20poly1305.NewX(key) 20 | if err != nil { 21 | return nil, err 22 | } 23 | return &EncryptedCache{ 24 | aead: aead, 25 | next: next, 26 | }, nil 27 | } 28 | 29 | func (c *EncryptedCache) Get(ctx context.Context, key string) ([]byte, error) { 30 | encryptedData, err := c.next.Get(ctx, key) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | if len(encryptedData) < c.aead.NonceSize() { 36 | return nil, errors.New("ciphertext too short") 37 | } 38 | 39 | // Split nonce and ciphertext. 40 | nonce, ciphertext := encryptedData[:c.aead.NonceSize()], encryptedData[c.aead.NonceSize():] 41 | 42 | // Decrypt the data and check it wasn't tampered with. 43 | plaintext, err := c.aead.Open(nil, nonce, ciphertext, []byte(key)) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | return plaintext, nil 49 | } 50 | 51 | func (c *EncryptedCache) Put(ctx context.Context, key string, data []byte) error { 52 | // Select a random nonce, and leave capacity for the ciphertext. 53 | nonce := make([]byte, c.aead.NonceSize(), c.aead.NonceSize()+len(data)+c.aead.Overhead()) 54 | if _, err := cryptorand.Read(nonce); err != nil { 55 | return err 56 | } 57 | 58 | // Encrypt the message and append the ciphertext to the nonce. 59 | encryptedData := c.aead.Seal(nonce, nonce, data, []byte(key)) 60 | 61 | return c.next.Put(ctx, key, encryptedData) 62 | } 63 | 64 | func (c *EncryptedCache) Delete(ctx context.Context, key string) error { 65 | return c.next.Delete(ctx, key) 66 | } 67 | -------------------------------------------------------------------------------- /certcache/local.go: -------------------------------------------------------------------------------- 1 | package certcache 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/jellydator/ttlcache/v3" 9 | "golang.org/x/crypto/acme/autocert" 10 | ) 11 | 12 | type certCacheKey = string 13 | type certCacheValue struct { 14 | res []byte 15 | err error 16 | } 17 | 18 | type LocalCertCache struct { 19 | cache *ttlcache.Cache[certCacheKey, certCacheValue] 20 | next autocert.Cache 21 | startOnce sync.Once 22 | stopOnce sync.Once 23 | } 24 | 25 | func NewLocalCertCache(next autocert.Cache, ttl, timeout time.Duration) *LocalCertCache { 26 | cache := ttlcache.New[certCacheKey, certCacheValue]( 27 | ttlcache.WithTTL[certCacheKey, certCacheValue](ttl), 28 | ttlcache.WithLoader( 29 | ttlcache.NewSuppressedLoader( 30 | ttlcache.LoaderFunc[certCacheKey, certCacheValue]( 31 | func(c *ttlcache.Cache[certCacheKey, certCacheValue], key certCacheKey) *ttlcache.Item[certCacheKey, certCacheValue] { 32 | ctx, cl := context.WithTimeout(context.Background(), timeout) 33 | defer cl() 34 | res, err := next.Get(ctx, key) 35 | if err != nil { 36 | return c.Set(key, certCacheValue{res, err}, -100) 37 | } 38 | return c.Set(key, certCacheValue{res, err}, 0) 39 | }, 40 | ), 41 | nil), 42 | ), 43 | ) 44 | return &LocalCertCache{ 45 | cache: cache, 46 | next: next, 47 | } 48 | } 49 | 50 | func (cc *LocalCertCache) Get(_ context.Context, key string) ([]byte, error) { 51 | resItem := cc.cache.Get(key).Value() 52 | return resItem.res, resItem.err 53 | } 54 | 55 | func (cc *LocalCertCache) Put(ctx context.Context, key string, data []byte) error { 56 | cc.cache.Set(key, certCacheValue{data, nil}, 0) 57 | return cc.next.Put(ctx, key, data) 58 | } 59 | 60 | func (cc *LocalCertCache) Delete(ctx context.Context, key string) error { 61 | cc.cache.Delete(key) 62 | return cc.next.Delete(ctx, key) 63 | } 64 | 65 | func (cc *LocalCertCache) Start() { 66 | cc.startOnce.Do(func() { 67 | go cc.cache.Start() 68 | }) 69 | } 70 | 71 | func (cc *LocalCertCache) Stop() { 72 | cc.stopOnce.Do(cc.cache.Stop) 73 | } 74 | 75 | var _ autocert.Cache = new(LocalCertCache) 76 | -------------------------------------------------------------------------------- /certcache/redis.go: -------------------------------------------------------------------------------- 1 | package certcache 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/redis/go-redis/v9" 7 | "golang.org/x/crypto/acme/autocert" 8 | ) 9 | 10 | type RedisCache struct { 11 | r redis.Cmdable 12 | pfx string 13 | } 14 | 15 | func NewRedisCache(r redis.Cmdable, prefix string) *RedisCache { 16 | return &RedisCache{ 17 | r: r, 18 | pfx: prefix, 19 | } 20 | } 21 | 22 | func (r *RedisCache) Get(ctx context.Context, key string) ([]byte, error) { 23 | res, err := r.r.Get(ctx, r.pfx+key).Bytes() 24 | if err != nil { 25 | if err == redis.Nil { 26 | return nil, autocert.ErrCacheMiss 27 | } 28 | return nil, err 29 | } 30 | return res, nil 31 | } 32 | 33 | func (r *RedisCache) Put(ctx context.Context, key string, data []byte) error { 34 | return r.r.Set(ctx, r.pfx+key, data, 0).Err() 35 | } 36 | 37 | func (r *RedisCache) Delete(ctx context.Context, key string) error { 38 | return r.r.Del(ctx, r.pfx+key).Err() 39 | } 40 | 41 | func RedisCacheFromURL(url string, prefix string) (*RedisCache, error) { 42 | opts, err := redis.ParseURL(url) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | r := redis.NewClient(opts) 48 | return NewRedisCache(r, prefix), nil 49 | } 50 | 51 | func RedisClusterCacheFromURL(url string, prefix string) (*RedisCache, error) { 52 | opts, err := redis.ParseClusterURL(url) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | r := redis.NewClusterClient(opts) 58 | return NewRedisCache(r, prefix), nil 59 | } 60 | -------------------------------------------------------------------------------- /dialer/cache.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/jellydator/ttlcache/v3" 10 | xproxy "golang.org/x/net/proxy" 11 | "golang.org/x/sync/singleflight" 12 | ) 13 | 14 | type dialerCacheKey struct { 15 | url string 16 | next xproxy.Dialer 17 | } 18 | 19 | type dialerCacheValue struct { 20 | dialer xproxy.Dialer 21 | err error 22 | } 23 | 24 | var ( 25 | dialerCache = ttlcache.New[dialerCacheKey, dialerCacheValue]( 26 | ttlcache.WithDisableTouchOnHit[dialerCacheKey, dialerCacheValue](), 27 | ) 28 | dialerCacheSingleFlight = new(singleflight.Group) 29 | ) 30 | 31 | func init() { 32 | go dialerCache.Start() 33 | } 34 | 35 | func GetCachedDialer(u *url.URL, next xproxy.Dialer) (xproxy.Dialer, error) { 36 | params, err := url.ParseQuery(u.RawQuery) 37 | if err != nil { 38 | return nil, err 39 | } 40 | if !params.Has("url") { 41 | return nil, errors.New("cached dialer: no \"url\" parameter specified") 42 | } 43 | parsedURL, err := url.Parse(params.Get("url")) 44 | if err != nil { 45 | return nil, fmt.Errorf("unable to parse proxy URL: %w", err) 46 | } 47 | if !params.Has("ttl") { 48 | return nil, errors.New("cached dialer: no \"ttl\" parameter specified") 49 | } 50 | ttl, err := time.ParseDuration(params.Get("ttl")) 51 | if err != nil { 52 | return nil, fmt.Errorf("cached dialer: unable to parse TTL duration %q: %w", params.Get("ttl"), err) 53 | } 54 | cacheRes := dialerCache.Get( 55 | dialerCacheKey{ 56 | url: params.Get("url"), 57 | next: next, 58 | }, 59 | ttlcache.WithLoader[dialerCacheKey, dialerCacheValue]( 60 | ttlcache.NewSuppressedLoader[dialerCacheKey, dialerCacheValue]( 61 | ttlcache.LoaderFunc[dialerCacheKey, dialerCacheValue]( 62 | func(c *ttlcache.Cache[dialerCacheKey, dialerCacheValue], key dialerCacheKey) *ttlcache.Item[dialerCacheKey, dialerCacheValue] { 63 | dialer, err := xproxy.FromURL(parsedURL, next) 64 | return c.Set( 65 | key, 66 | dialerCacheValue{ 67 | dialer: dialer, 68 | err: err, 69 | }, 70 | ttl, 71 | ) 72 | }, 73 | ), 74 | dialerCacheSingleFlight, 75 | ), 76 | ), 77 | ).Value() 78 | return cacheRes.dialer, cacheRes.err 79 | } 80 | -------------------------------------------------------------------------------- /dialer/dialer.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/url" 8 | 9 | xproxy "golang.org/x/net/proxy" 10 | ) 11 | 12 | func init() { 13 | xproxy.RegisterDialerType("http", HTTPProxyDialerFromURL) 14 | xproxy.RegisterDialerType("https", HTTPProxyDialerFromURL) 15 | xproxy.RegisterDialerType("http+optimistic", OptimisticHTTPProxyDialerFromURL) 16 | xproxy.RegisterDialerType("https+optimistic", OptimisticHTTPProxyDialerFromURL) 17 | xproxy.RegisterDialerType("h2", H2ProxyDialerFromURL) 18 | xproxy.RegisterDialerType("h2c", H2ProxyDialerFromURL) 19 | xproxy.RegisterDialerType("set-src-hints", NewHintsSettingDialerFromURL) 20 | xproxy.RegisterDialerType("cached", GetCachedDialer) 21 | } 22 | 23 | type LegacyDialer interface { 24 | Dial(network, address string) (net.Conn, error) 25 | } 26 | 27 | type Dialer interface { 28 | LegacyDialer 29 | DialContext(ctx context.Context, network, address string) (net.Conn, error) 30 | } 31 | 32 | func ProxyDialerFromURL(proxyURL string, forward Dialer) (Dialer, error) { 33 | parsedURL, err := url.Parse(proxyURL) 34 | if err != nil { 35 | return nil, fmt.Errorf("unable to parse proxy URL: %w", err) 36 | } 37 | d, err := xproxy.FromURL(parsedURL, forward) 38 | if err != nil { 39 | return nil, fmt.Errorf("unable to construct proxy dialer from URL %q: %w", proxyURL, err) 40 | } 41 | return MaybeWrapWithHostnameWanter(MaybeWrapWithContextDialer(d)), nil 42 | } 43 | 44 | type wrappedDialer struct { 45 | d LegacyDialer 46 | } 47 | 48 | func (wd wrappedDialer) Dial(net, address string) (net.Conn, error) { 49 | return wd.d.Dial(net, address) 50 | } 51 | 52 | func (wd wrappedDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 53 | var ( 54 | conn net.Conn 55 | done = make(chan struct{}, 1) 56 | err error 57 | ) 58 | go func() { 59 | conn, err = wd.d.Dial(network, address) 60 | close(done) 61 | if conn != nil && ctx.Err() != nil { 62 | conn.Close() 63 | } 64 | }() 65 | select { 66 | case <-ctx.Done(): 67 | err = ctx.Err() 68 | case <-done: 69 | } 70 | return conn, err 71 | } 72 | 73 | func MaybeWrapWithContextDialer(d LegacyDialer) Dialer { 74 | if xd, ok := d.(Dialer); ok { 75 | return xd 76 | } 77 | return wrappedDialer{d} 78 | } 79 | -------------------------------------------------------------------------------- /dialer/dto/dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type boundDialerContextKey struct{} 9 | 10 | type boundDialerContextValue struct { 11 | hints *string 12 | localAddr string 13 | } 14 | 15 | func BoundDialerParamsToContext(ctx context.Context, hints *string, localAddr string) context.Context { 16 | return context.WithValue(ctx, boundDialerContextKey{}, boundDialerContextValue{hints, localAddr}) 17 | } 18 | 19 | func BoundDialerParamsFromContext(ctx context.Context) (*string, string, bool) { 20 | val, ok := ctx.Value(boundDialerContextKey{}).(boundDialerContextValue) 21 | if !ok { 22 | return nil, "", false 23 | } 24 | return val.hints, val.localAddr, true 25 | } 26 | 27 | type filterContextKey struct{} 28 | 29 | type filterContextParams struct { 30 | req *http.Request 31 | username string 32 | } 33 | 34 | func FilterParamsFromContext(ctx context.Context) (*http.Request, string) { 35 | params := ctx.Value(filterContextKey{}).(filterContextParams) 36 | return params.req, params.username 37 | } 38 | 39 | func FilterParamsToContext(ctx context.Context, req *http.Request, username string) context.Context { 40 | return context.WithValue(ctx, filterContextKey{}, filterContextParams{req, username}) 41 | } 42 | 43 | type origDstKey struct{} 44 | 45 | func OrigDstFromContext(ctx context.Context) (string, bool) { 46 | orig, ok := ctx.Value(origDstKey{}).(string) 47 | return orig, ok 48 | } 49 | 50 | func OrigDstToContext(ctx context.Context, dst string) context.Context { 51 | return context.WithValue(ctx, origDstKey{}, dst) 52 | } 53 | -------------------------------------------------------------------------------- /dialer/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "fmt" 4 | 5 | type ErrAccessDenied struct { 6 | Err error 7 | } 8 | 9 | func (e ErrAccessDenied) Error() string { 10 | return fmt.Sprintf("access denied: %v", e.Err) 11 | } 12 | 13 | func (e ErrAccessDenied) Unwrap() error { 14 | return e.Err 15 | } 16 | -------------------------------------------------------------------------------- /dialer/filter.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | 8 | "github.com/SenseUnit/dumbproxy/dialer/dto" 9 | "github.com/SenseUnit/dumbproxy/dialer/errors" 10 | ) 11 | 12 | type FilterFunc = func(ctx context.Context, req *http.Request, username, network, address string) error 13 | 14 | type FilterDialer struct { 15 | f FilterFunc 16 | next Dialer 17 | } 18 | 19 | func NewFilterDialer(filterFunc FilterFunc, next Dialer) FilterDialer { 20 | return FilterDialer{ 21 | f: filterFunc, 22 | next: next, 23 | } 24 | } 25 | 26 | func (f FilterDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 27 | req, username := dto.FilterParamsFromContext(ctx) 28 | if ferr := f.f(ctx, req, username, network, address); ferr != nil { 29 | return nil, errors.ErrAccessDenied{ferr} 30 | } 31 | return f.next.DialContext(ctx, network, address) 32 | } 33 | 34 | func (f FilterDialer) Dial(network, address string) (net.Conn, error) { 35 | panic("dialer tree linking issue: FilterDialer should never receive calls without context") 36 | } 37 | 38 | func (f FilterDialer) WantsHostname(ctx context.Context, network, address string) bool { 39 | return WantsHostname(ctx, network, address, f.next) 40 | } 41 | 42 | var _ Dialer = FilterDialer{} 43 | var _ HostnameWanter = FilterDialer{} 44 | -------------------------------------------------------------------------------- /dialer/h2.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "net/url" 12 | "slices" 13 | "strings" 14 | "time" 15 | 16 | "github.com/SenseUnit/dumbproxy/tlsutil" 17 | "golang.org/x/net/http2" 18 | xproxy "golang.org/x/net/proxy" 19 | ) 20 | 21 | type H2ProxyDialer struct { 22 | address string 23 | tlsConfig *tls.Config 24 | userinfo *url.Userinfo 25 | next Dialer 26 | t *http2.Transport 27 | } 28 | 29 | func H2ProxyDialerFromURL(u *url.URL, next xproxy.Dialer) (xproxy.Dialer, error) { 30 | host := u.Hostname() 31 | port := u.Port() 32 | 33 | var ( 34 | tlsConfig *tls.Config 35 | err error 36 | h2c bool 37 | ) 38 | switch strings.ToLower(u.Scheme) { 39 | case "h2c": 40 | if port == "" { 41 | port = "80" 42 | } 43 | h2c = true 44 | case "h2": 45 | if port == "" { 46 | port = "443" 47 | } 48 | tlsConfig, err = tlsutil.TLSConfigFromURL(u) 49 | if !slices.Contains(tlsConfig.NextProtos, "h2") { 50 | tlsConfig.NextProtos = append([]string{"h2"}, tlsConfig.NextProtos...) 51 | } 52 | if err != nil { 53 | return nil, fmt.Errorf("TLS configuration failed: %w", err) 54 | } 55 | default: 56 | return nil, errors.New("unsupported proxy type") 57 | } 58 | 59 | address := net.JoinHostPort(host, port) 60 | t := &http2.Transport{ 61 | AllowHTTP: h2c, 62 | TLSClientConfig: tlsConfig, 63 | } 64 | nextDialer := MaybeWrapWithContextDialer(next) 65 | if h2c { 66 | t.DialTLSContext = func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { 67 | return nextDialer.DialContext(ctx, network, address) 68 | } 69 | } else { 70 | t.DialTLSContext = func(ctx context.Context, network, _ string, _ *tls.Config) (net.Conn, error) { 71 | conn, err := nextDialer.DialContext(ctx, network, address) 72 | if err != nil { 73 | return nil, err 74 | } 75 | conn = tls.Client(conn, tlsConfig) 76 | return conn, nil 77 | } 78 | } 79 | 80 | return &H2ProxyDialer{ 81 | address: address, 82 | tlsConfig: tlsConfig, 83 | userinfo: u.User, 84 | next: MaybeWrapWithContextDialer(next), 85 | t: t, 86 | }, nil 87 | } 88 | 89 | func (d *H2ProxyDialer) Dial(network, address string) (net.Conn, error) { 90 | return d.DialContext(context.Background(), network, address) 91 | } 92 | 93 | func (d *H2ProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 94 | h2c := d.tlsConfig == nil 95 | scheme := "https" 96 | if h2c { 97 | scheme = "http" 98 | } 99 | pr, pw := io.Pipe() 100 | connCtx, connCl := context.WithCancel(ctx) 101 | req := (&http.Request{ 102 | Method: "CONNECT", 103 | URL: &url.URL{ 104 | Scheme: scheme, 105 | Host: address, 106 | }, 107 | Header: http.Header{ 108 | "User-Agent": []string{"dumbproxy"}, 109 | }, 110 | Body: pr, 111 | Host: address, 112 | }).WithContext(connCtx) 113 | if d.userinfo != nil { 114 | req.Header.Set("Proxy-Authorization", basicAuthHeader(d.userinfo)) 115 | } 116 | resp, err := d.t.RoundTrip(req) 117 | if err != nil { 118 | return nil, err 119 | } 120 | if resp.StatusCode != http.StatusOK { 121 | resp.Body.Close() 122 | pw.Close() 123 | return nil, errors.New(resp.Status) 124 | } 125 | return &h2Conn{ 126 | r: resp.Body, 127 | w: pw, 128 | cl: connCl, 129 | }, nil 130 | } 131 | 132 | type h2Conn struct { 133 | r io.ReadCloser 134 | w io.WriteCloser 135 | cl func() 136 | } 137 | 138 | func (c *h2Conn) Read(b []byte) (n int, err error) { 139 | return c.r.Read(b) 140 | } 141 | 142 | func (c *h2Conn) Write(b []byte) (n int, err error) { 143 | return c.w.Write(b) 144 | } 145 | 146 | func (c *h2Conn) Close() (err error) { 147 | defer c.cl() 148 | return errors.Join(c.w.Close(), c.r.Close()) 149 | } 150 | 151 | func (c *h2Conn) LocalAddr() net.Addr { 152 | return &net.TCPAddr{IP: net.IPv4zero, Port: 0} 153 | } 154 | 155 | func (c *h2Conn) RemoteAddr() net.Addr { 156 | return &net.TCPAddr{IP: net.IPv4zero, Port: 0} 157 | } 158 | 159 | func (c *h2Conn) SetDeadline(t time.Time) error { 160 | return &net.OpError{Op: "set", Net: "h2", Source: nil, Addr: nil, Err: errors.New("deadline not supported")} 161 | } 162 | 163 | func (c *h2Conn) SetReadDeadline(t time.Time) error { 164 | return &net.OpError{Op: "set", Net: "h2", Source: nil, Addr: nil, Err: errors.New("deadline not supported")} 165 | } 166 | 167 | func (c *h2Conn) SetWriteDeadline(t time.Time) error { 168 | return &net.OpError{Op: "set", Net: "h2", Source: nil, Addr: nil, Err: errors.New("deadline not supported")} 169 | } 170 | 171 | func (c *h2Conn) CloseWrite() error { 172 | return c.w.Close() 173 | } 174 | -------------------------------------------------------------------------------- /dialer/hintdialer.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/url" 9 | "os" 10 | "strings" 11 | 12 | "github.com/SenseUnit/dumbproxy/dialer/dto" 13 | "github.com/hashicorp/go-multierror" 14 | xproxy "golang.org/x/net/proxy" 15 | ) 16 | 17 | var ( 18 | ErrNoSuitableAddress = errors.New("no suitable address") 19 | ErrBadIPAddressLength = errors.New("bad IP address length") 20 | ErrUnknownNetwork = errors.New("unknown network") 21 | ) 22 | 23 | type BoundDialer struct { 24 | defaultDialer Dialer 25 | defaultHints string 26 | } 27 | 28 | func NewBoundDialer(defaultDialer Dialer, defaultHints string) *BoundDialer { 29 | if defaultDialer == nil { 30 | defaultDialer = &net.Dialer{} 31 | } 32 | return &BoundDialer{ 33 | defaultDialer: defaultDialer, 34 | defaultHints: defaultHints, 35 | } 36 | } 37 | 38 | func (d *BoundDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 39 | hints := d.defaultHints 40 | lAddr := "" 41 | if h, la, ok := dto.BoundDialerParamsFromContext(ctx); ok { 42 | if h != nil { 43 | hints = *h 44 | } 45 | lAddr = la 46 | } 47 | 48 | parsedHints, err := parseHints(hints, lAddr) 49 | if err != nil { 50 | return nil, fmt.Errorf("dial failed: %w", err) 51 | } 52 | 53 | if len(parsedHints) == 0 { 54 | return d.defaultDialer.DialContext(ctx, network, address) 55 | } 56 | 57 | var netBase string 58 | switch network { 59 | case "tcp", "tcp4", "tcp6": 60 | netBase = "tcp" 61 | case "udp", "udp4", "udp6": 62 | netBase = "udp" 63 | case "ip", "ip4", "ip6": 64 | netBase = "ip" 65 | default: 66 | return d.defaultDialer.DialContext(ctx, network, address) 67 | } 68 | 69 | var resErr error 70 | for _, lIP := range parsedHints { 71 | lAddr, restrictedNetwork, err := ipToLAddr(netBase, lIP) 72 | if err != nil { 73 | resErr = multierror.Append(resErr, fmt.Errorf("ipToLAddr(%q) failed: %w", lIP.String(), err)) 74 | continue 75 | } 76 | if network != netBase && network != restrictedNetwork { 77 | continue 78 | } 79 | 80 | conn, err := (&net.Dialer{ 81 | LocalAddr: lAddr, 82 | }).DialContext(ctx, restrictedNetwork, address) 83 | if err != nil { 84 | resErr = multierror.Append(resErr, fmt.Errorf("dial failed: %w", err)) 85 | } else { 86 | return conn, nil 87 | } 88 | } 89 | 90 | if resErr == nil { 91 | resErr = ErrNoSuitableAddress 92 | } 93 | return nil, resErr 94 | } 95 | 96 | func (d *BoundDialer) Dial(network, address string) (net.Conn, error) { 97 | return d.DialContext(context.Background(), network, address) 98 | } 99 | 100 | func (d *BoundDialer) WantsHostname(ctx context.Context, net, address string) bool { 101 | switch net { 102 | case "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6", "ip", "ip4", "ip6": 103 | return false 104 | default: 105 | return WantsHostname(ctx, net, address, d.defaultDialer) 106 | } 107 | } 108 | 109 | var _ HostnameWanter = new(BoundDialer) 110 | 111 | func ipToLAddr(network string, ip net.IP) (net.Addr, string, error) { 112 | v6 := true 113 | if ip4 := ip.To4(); len(ip4) == net.IPv4len { 114 | ip = ip4 115 | v6 = false 116 | } else if len(ip) != net.IPv6len { 117 | return nil, "", ErrBadIPAddressLength 118 | } 119 | 120 | var lAddr net.Addr 121 | var lNetwork string 122 | switch network { 123 | case "tcp", "tcp4", "tcp6": 124 | lAddr = &net.TCPAddr{ 125 | IP: ip, 126 | } 127 | if v6 { 128 | lNetwork = "tcp6" 129 | } else { 130 | lNetwork = "tcp4" 131 | } 132 | case "udp", "udp4", "udp6": 133 | lAddr = &net.UDPAddr{ 134 | IP: ip, 135 | } 136 | if v6 { 137 | lNetwork = "udp6" 138 | } else { 139 | lNetwork = "udp4" 140 | } 141 | case "ip", "ip4", "ip6": 142 | lAddr = &net.IPAddr{ 143 | IP: ip, 144 | } 145 | if v6 { 146 | lNetwork = "ip6" 147 | } else { 148 | lNetwork = "ip4" 149 | } 150 | default: 151 | return nil, "", ErrUnknownNetwork 152 | } 153 | 154 | return lAddr, lNetwork, nil 155 | } 156 | 157 | func parseIPList(list string) ([]net.IP, error) { 158 | res := make([]net.IP, 0) 159 | for _, elem := range strings.Split(list, ",") { 160 | elem = strings.TrimSpace(elem) 161 | if len(elem) == 0 { 162 | continue 163 | } 164 | if parsed := net.ParseIP(elem); parsed == nil { 165 | return nil, fmt.Errorf("unable to parse IP address %q", elem) 166 | } else { 167 | res = append(res, parsed) 168 | } 169 | } 170 | return res, nil 171 | } 172 | 173 | func parseHints(hints, lAddr string) ([]net.IP, error) { 174 | hints = os.Expand(hints, func(key string) string { 175 | switch key { 176 | case "lAddr": 177 | return lAddr 178 | default: 179 | return fmt.Sprintf("", key) 180 | } 181 | }) 182 | res, err := parseIPList(hints) 183 | if err != nil { 184 | return nil, fmt.Errorf("unable to parse source IP hints %q: %w", hints, err) 185 | } 186 | return res, nil 187 | } 188 | 189 | var _ HostnameWanter = new(BoundDialer) 190 | 191 | type HintsSettingDialer struct { 192 | hints string 193 | next Dialer 194 | } 195 | 196 | func NewHintsSettingDialerFromURL(u *url.URL, next xproxy.Dialer) (xproxy.Dialer, error) { 197 | values, err := url.ParseQuery(u.RawQuery) 198 | if err != nil { 199 | return nil, fmt.Errorf("HintsSettingDialer parameter parsing failed: %w", err) 200 | } 201 | 202 | if !values.Has("hints") { 203 | return nil, errors.New("no \"hints\" parameter is provided in HintsSettingDialer configuration URL") 204 | } 205 | 206 | return &HintsSettingDialer{ 207 | hints: values.Get("hints"), 208 | next: MaybeWrapWithContextDialer(next), 209 | }, nil 210 | } 211 | 212 | func (hs *HintsSettingDialer) Dial(network, address string) (net.Conn, error) { 213 | return hs.DialContext(context.Background(), network, address) 214 | } 215 | 216 | func (hs *HintsSettingDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 217 | _, la, _ := dto.BoundDialerParamsFromContext(ctx) 218 | ctx = dto.BoundDialerParamsToContext(ctx, &(hs.hints), la) 219 | return hs.next.DialContext(ctx, network, address) 220 | } 221 | 222 | func (hs *HintsSettingDialer) WantsHostname(ctx context.Context, net, address string) bool { 223 | return WantsHostname(ctx, net, address, hs.next) 224 | } 225 | 226 | var _ Dialer = new(HintsSettingDialer) 227 | var _ HostnameWanter = new(HintsSettingDialer) 228 | -------------------------------------------------------------------------------- /dialer/jsrouter.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "os" 9 | 10 | "github.com/dop251/goja" 11 | 12 | "github.com/SenseUnit/dumbproxy/dialer/dto" 13 | "github.com/SenseUnit/dumbproxy/jsext" 14 | clog "github.com/SenseUnit/dumbproxy/log" 15 | ) 16 | 17 | type JSRouterFunc = func(req *jsext.JSRequestInfo, dst *jsext.JSDstInfo, username string) (string, error) 18 | 19 | type JSRouter struct { 20 | funcPool chan JSRouterFunc 21 | proxyFactory func(string) (Dialer, error) 22 | next Dialer 23 | } 24 | 25 | func NewJSRouter(filename string, instances int, factory func(string) (Dialer, error), logger *clog.CondLogger, next Dialer) (*JSRouter, error) { 26 | script, err := os.ReadFile(filename) 27 | if err != nil { 28 | return nil, fmt.Errorf("unable to load JS script file %q: %w", filename, err) 29 | } 30 | 31 | instances = max(1, instances) 32 | pool := make(chan JSRouterFunc, instances) 33 | 34 | for i := 0; i < instances; i++ { 35 | vm := goja.New() 36 | err := jsext.AddPrinter(vm, logger) 37 | if err != nil { 38 | return nil, errors.New("can't add print function to runtime") 39 | } 40 | vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) 41 | _, err = vm.RunString(string(script)) 42 | if err != nil { 43 | return nil, fmt.Errorf("script run failed: %w", err) 44 | } 45 | 46 | var f JSRouterFunc 47 | var routerFnJSVal goja.Value 48 | if ex := vm.Try(func() { 49 | routerFnJSVal = vm.Get("getProxy") 50 | }); ex != nil { 51 | return nil, fmt.Errorf("\"getProxy\" function cannot be located in VM context: %w", err) 52 | } 53 | if routerFnJSVal == nil { 54 | return nil, errors.New("\"getProxy\" function is not defined") 55 | } 56 | err = vm.ExportTo(routerFnJSVal, &f) 57 | if err != nil { 58 | return nil, fmt.Errorf("can't export \"getProxy\" function from JS VM: %w", err) 59 | } 60 | 61 | pool <- f 62 | } 63 | 64 | return &JSRouter{ 65 | funcPool: pool, 66 | proxyFactory: factory, 67 | next: next, 68 | }, nil 69 | } 70 | 71 | func (j *JSRouter) getNextDialer(ctx context.Context, network, address string) (Dialer, error) { 72 | req, username := dto.FilterParamsFromContext(ctx) 73 | ri := jsext.JSRequestInfoFromRequest(req) 74 | di, err := jsext.JSDstInfoFromContext(ctx, network, address) 75 | if err != nil { 76 | return nil, fmt.Errorf("unable to construct dst info: %w", err) 77 | } 78 | 79 | var res string 80 | func() { 81 | f := <-j.funcPool 82 | defer func(pool chan JSRouterFunc, f JSRouterFunc) { 83 | pool <- f 84 | }(j.funcPool, f) 85 | res, err = f(ri, di, username) 86 | }() 87 | if err != nil { 88 | return nil, fmt.Errorf("JS routing script exception: %w", err) 89 | } 90 | 91 | if res == "" { 92 | return j.next, nil 93 | } 94 | 95 | d, err := j.proxyFactory(res) 96 | if err != nil { 97 | return nil, fmt.Errorf("proxy factory returned error: %w", err) 98 | } 99 | return d, nil 100 | } 101 | 102 | func (j *JSRouter) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 103 | d, err := j.getNextDialer(ctx, network, address) 104 | if err != nil { 105 | return nil, fmt.Errorf("unable to route request: %w", err) 106 | } 107 | return d.DialContext(ctx, network, address) 108 | } 109 | 110 | func (j *JSRouter) WantsHostname(ctx context.Context, network, address string) bool { 111 | d, err := j.getNextDialer(ctx, network, address) 112 | if err != nil { 113 | return false 114 | } 115 | return WantsHostname(ctx, network, address, d) 116 | } 117 | 118 | func (j *JSRouter) Dial(network, address string) (net.Conn, error) { 119 | panic("dialer tree linking issue: JSFilter should never receive calls without context") 120 | } 121 | 122 | var _ Dialer = new(JSRouter) 123 | var _ HostnameWanter = new(JSRouter) 124 | -------------------------------------------------------------------------------- /dialer/optimistic.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "errors" 8 | "fmt" 9 | "net" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | 14 | xproxy "golang.org/x/net/proxy" 15 | 16 | "github.com/SenseUnit/dumbproxy/tlsutil" 17 | ) 18 | 19 | type OptimisticHTTPProxyDialer struct { 20 | address string 21 | tlsConfig *tls.Config 22 | userinfo *url.Userinfo 23 | next Dialer 24 | } 25 | 26 | func NewOptimisticHTTPProxyDialer(address string, tlsConfig *tls.Config, userinfo *url.Userinfo, next LegacyDialer) *OptimisticHTTPProxyDialer { 27 | return &OptimisticHTTPProxyDialer{ 28 | address: address, 29 | tlsConfig: tlsConfig, 30 | next: MaybeWrapWithContextDialer(next), 31 | userinfo: userinfo, 32 | } 33 | } 34 | 35 | func OptimisticHTTPProxyDialerFromURL(u *url.URL, next xproxy.Dialer) (xproxy.Dialer, error) { 36 | host := u.Hostname() 37 | port := u.Port() 38 | 39 | var tlsConfig *tls.Config 40 | var err error 41 | switch strings.ToLower(u.Scheme) { 42 | case "http+optimistic": 43 | if port == "" { 44 | port = "80" 45 | } 46 | case "https+optimistic": 47 | if port == "" { 48 | port = "443" 49 | } 50 | tlsConfig, err = tlsutil.TLSConfigFromURL(u) 51 | if err != nil { 52 | return nil, fmt.Errorf("TLS configuration failed: %w", err) 53 | } 54 | default: 55 | return nil, errors.New("unsupported proxy type") 56 | } 57 | 58 | address := net.JoinHostPort(host, port) 59 | 60 | return NewOptimisticHTTPProxyDialer(address, tlsConfig, u.User, next), nil 61 | } 62 | 63 | func (d *OptimisticHTTPProxyDialer) Dial(network, address string) (net.Conn, error) { 64 | return d.DialContext(context.Background(), network, address) 65 | } 66 | 67 | func (d *OptimisticHTTPProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 68 | switch network { 69 | case "tcp", "tcp4", "tcp6": 70 | default: 71 | return nil, errors.New("only \"tcp\" network is supported") 72 | } 73 | conn, err := d.next.DialContext(ctx, "tcp", d.address) 74 | if err != nil { 75 | return nil, fmt.Errorf("proxy dialer is unable to make connection: %w", err) 76 | } 77 | if d.tlsConfig != nil { 78 | conn = tls.Client(conn, d.tlsConfig) 79 | } 80 | 81 | return &futureH1ProxiedConn{ 82 | Conn: conn, 83 | address: address, 84 | userinfo: d.userinfo, 85 | }, nil 86 | } 87 | 88 | type futureH1ProxiedConn struct { 89 | net.Conn 90 | address string 91 | userinfo *url.Userinfo 92 | rDone bool 93 | wDone bool 94 | rErr error 95 | wErr error 96 | } 97 | 98 | func (c *futureH1ProxiedConn) Write(b []byte) (n int, err error) { 99 | if c.wErr != nil { 100 | return 0, c.wErr 101 | } 102 | if !c.wDone { 103 | buf := new(bytes.Buffer) 104 | fmt.Fprintf(buf, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n", c.address, c.address) 105 | if c.userinfo != nil { 106 | fmt.Fprintf(buf, "Proxy-Authorization: %s\r\n", basicAuthHeader(c.userinfo)) 107 | } 108 | fmt.Fprintf(buf, "User-Agent: dumbproxy\r\n\r\n") 109 | prologueBytes := buf.Len() 110 | buf.Write(b) 111 | n, err := c.Conn.Write(buf.Bytes()) 112 | if err != nil { 113 | c.wErr = err 114 | } 115 | c.wDone = true 116 | c.address = "" 117 | c.userinfo = nil 118 | return max(0, n-prologueBytes), err 119 | } 120 | return c.Conn.Write(b) 121 | } 122 | 123 | func (c *futureH1ProxiedConn) Read(b []byte) (n int, err error) { 124 | if c.rErr != nil { 125 | return 0, c.rErr 126 | } 127 | if !c.rDone { 128 | resp, err := readResponse(c.Conn) 129 | if err != nil { 130 | c.rErr = fmt.Errorf("reading proxy response failed: %w", err) 131 | return 0, c.rErr 132 | } 133 | if resp.StatusCode != http.StatusOK { 134 | c.rErr = fmt.Errorf("bad status code from proxy: %d", resp.StatusCode) 135 | return 0, c.rErr 136 | } 137 | c.rDone = true 138 | } 139 | return c.Conn.Read(b) 140 | } 141 | -------------------------------------------------------------------------------- /dialer/protect.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "net" 6 | ) 7 | 8 | type HostnameWanter interface { 9 | WantsHostname(ctx context.Context, net, address string) bool 10 | } 11 | 12 | type WrappedHostnameDialer struct { 13 | Dialer Dialer 14 | } 15 | 16 | func AlwaysRequireHostname(d Dialer) Dialer { 17 | return WrappedHostnameDialer{ 18 | Dialer: d, 19 | } 20 | } 21 | 22 | func (w WrappedHostnameDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 23 | return w.Dialer.DialContext(ctx, network, address) 24 | } 25 | 26 | func (w WrappedHostnameDialer) Dial(network, address string) (net.Conn, error) { 27 | return w.Dialer.Dial(network, address) 28 | } 29 | 30 | func (w WrappedHostnameDialer) WantsHostname(_ context.Context, _, _ string) bool { 31 | return true 32 | } 33 | 34 | var _ Dialer = WrappedHostnameDialer{} 35 | var _ HostnameWanter = WrappedHostnameDialer{} 36 | 37 | func WantsHostname(ctx context.Context, net, address string, d Dialer) bool { 38 | if w, ok := d.(HostnameWanter); ok { 39 | return w.WantsHostname(ctx, net, address) 40 | } 41 | return false 42 | } 43 | 44 | func MaybeWrapWithHostnameWanter(d Dialer) Dialer { 45 | if _, ok := d.(HostnameWanter); ok { 46 | return d 47 | } 48 | return AlwaysRequireHostname(d) 49 | } 50 | -------------------------------------------------------------------------------- /dialer/rescache.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/netip" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/SenseUnit/dumbproxy/dialer/dto" 13 | "github.com/hashicorp/go-multierror" 14 | "github.com/jellydator/ttlcache/v3" 15 | ) 16 | 17 | type resolverCacheKey struct { 18 | network string 19 | host string 20 | } 21 | 22 | type resolverCacheValue struct { 23 | addrs []netip.Addr 24 | err error 25 | } 26 | 27 | type NameResolveCachingDialer struct { 28 | cache *ttlcache.Cache[resolverCacheKey, resolverCacheValue] 29 | next Dialer 30 | startOnce sync.Once 31 | stopOnce sync.Once 32 | } 33 | 34 | func NewNameResolveCachingDialer(next Dialer, resolver Resolver, posTTL, negTTL, timeout time.Duration) *NameResolveCachingDialer { 35 | cache := ttlcache.New[resolverCacheKey, resolverCacheValue]( 36 | ttlcache.WithDisableTouchOnHit[resolverCacheKey, resolverCacheValue](), 37 | ttlcache.WithLoader( 38 | ttlcache.NewSuppressedLoader( 39 | ttlcache.LoaderFunc[resolverCacheKey, resolverCacheValue]( 40 | func(c *ttlcache.Cache[resolverCacheKey, resolverCacheValue], key resolverCacheKey) *ttlcache.Item[resolverCacheKey, resolverCacheValue] { 41 | ctx, cl := context.WithTimeout(context.Background(), timeout) 42 | defer cl() 43 | res, err := resolver.LookupNetIP(ctx, key.network, key.host) 44 | for i := range res { 45 | res[i] = res[i].Unmap() 46 | } 47 | setTTL := negTTL 48 | if err == nil { 49 | setTTL = posTTL 50 | } 51 | return c.Set(key, resolverCacheValue{ 52 | addrs: res, 53 | err: err, 54 | }, setTTL) 55 | }, 56 | ), 57 | nil), 58 | ), 59 | ) 60 | return &NameResolveCachingDialer{ 61 | cache: cache, 62 | next: next, 63 | } 64 | } 65 | 66 | func (nrcd *NameResolveCachingDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 67 | if WantsHostname(ctx, network, address, nrcd.next) { 68 | return nrcd.next.DialContext(ctx, network, address) 69 | } 70 | 71 | host, port, err := net.SplitHostPort(address) 72 | if err != nil { 73 | return nil, fmt.Errorf("failed to extract host and port from %s: %w", address, err) 74 | } 75 | 76 | if addr, err := netip.ParseAddr(host); err == nil { 77 | // literal IP address, just do unmapping 78 | return nrcd.next.DialContext(ctx, network, net.JoinHostPort(addr.Unmap().String(), port)) 79 | } 80 | 81 | var resolveNetwork string 82 | switch network { 83 | case "udp4", "tcp4", "ip4": 84 | resolveNetwork = "ip4" 85 | case "udp6", "tcp6", "ip6": 86 | resolveNetwork = "ip6" 87 | case "udp", "tcp", "ip": 88 | resolveNetwork = "ip" 89 | default: 90 | return nil, fmt.Errorf("resolving dial %q: unsupported network %q", address, network) 91 | } 92 | 93 | host = strings.ToLower(host) 94 | 95 | resItem := nrcd.cache.Get(resolverCacheKey{ 96 | network: resolveNetwork, 97 | host: host, 98 | }) 99 | if resItem == nil { 100 | return nil, fmt.Errorf("cache lookup failed for pair <%q, %q>", resolveNetwork, host) 101 | } 102 | 103 | res := resItem.Value() 104 | if res.err != nil { 105 | return nil, res.err 106 | } 107 | 108 | ctx = dto.OrigDstToContext(ctx, address) 109 | 110 | var dialErr error 111 | var conn net.Conn 112 | 113 | for _, ip := range res.addrs { 114 | conn, err = nrcd.next.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) 115 | if err == nil { 116 | return conn, nil 117 | } 118 | dialErr = multierror.Append(dialErr, err) 119 | } 120 | 121 | return nil, fmt.Errorf("failed to dial %s: %w", address, dialErr) 122 | } 123 | 124 | func (nrcd *NameResolveCachingDialer) Dial(network, address string) (net.Conn, error) { 125 | return nrcd.DialContext(context.Background(), network, address) 126 | } 127 | 128 | func (nrcd *NameResolveCachingDialer) WantsHostname(ctx context.Context, net, address string) bool { 129 | return WantsHostname(ctx, net, address, nrcd.next) 130 | } 131 | 132 | func (nrcd *NameResolveCachingDialer) Start() { 133 | nrcd.startOnce.Do(func() { 134 | go nrcd.cache.Start() 135 | }) 136 | } 137 | 138 | func (nrcd *NameResolveCachingDialer) Stop() { 139 | nrcd.stopOnce.Do(nrcd.cache.Stop) 140 | } 141 | 142 | var _ Dialer = new(NameResolveCachingDialer) 143 | var _ HostnameWanter = new(NameResolveCachingDialer) 144 | -------------------------------------------------------------------------------- /dialer/resolve.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/netip" 8 | 9 | "github.com/SenseUnit/dumbproxy/dialer/dto" 10 | "github.com/hashicorp/go-multierror" 11 | ) 12 | 13 | type Resolver interface { 14 | LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) 15 | } 16 | 17 | type NameResolvingDialer struct { 18 | next Dialer 19 | resolver Resolver 20 | } 21 | 22 | func NewNameResolvingDialer(next Dialer, resolver Resolver) NameResolvingDialer { 23 | return NameResolvingDialer{ 24 | next: next, 25 | resolver: resolver, 26 | } 27 | } 28 | 29 | func (nrd NameResolvingDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 30 | if WantsHostname(ctx, network, address, nrd.next) { 31 | return nrd.next.DialContext(ctx, network, address) 32 | } 33 | 34 | host, port, err := net.SplitHostPort(address) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to extract host and port from %s: %w", address, err) 37 | } 38 | 39 | if addr, err := netip.ParseAddr(host); err == nil { 40 | // literal IP address, just do unmapping 41 | return nrd.next.DialContext(ctx, network, net.JoinHostPort(addr.Unmap().String(), port)) 42 | } 43 | 44 | var resolveNetwork string 45 | switch network { 46 | case "udp4", "tcp4", "ip4": 47 | resolveNetwork = "ip4" 48 | case "udp6", "tcp6", "ip6": 49 | resolveNetwork = "ip6" 50 | case "udp", "tcp", "ip": 51 | resolveNetwork = "ip" 52 | default: 53 | return nil, fmt.Errorf("resolving dial %q: unsupported network %q", address, network) 54 | } 55 | 56 | res, err := nrd.resolver.LookupNetIP(ctx, resolveNetwork, host) 57 | if err != nil { 58 | return nil, fmt.Errorf("resolving %q (%s) failed: %w", host, network, err) 59 | } 60 | for i := range res { 61 | res[i] = res[i].Unmap() 62 | } 63 | 64 | ctx = dto.OrigDstToContext(ctx, address) 65 | 66 | var dialErr error 67 | var conn net.Conn 68 | 69 | for _, ip := range res { 70 | conn, err = nrd.next.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) 71 | if err == nil { 72 | return conn, nil 73 | } 74 | dialErr = multierror.Append(dialErr, err) 75 | } 76 | 77 | return nil, fmt.Errorf("failed to dial %s: %w", address, dialErr) 78 | } 79 | 80 | func (nrd NameResolvingDialer) Dial(network, address string) (net.Conn, error) { 81 | return nrd.DialContext(context.Background(), network, address) 82 | } 83 | 84 | func (nrd NameResolvingDialer) WantsHostname(ctx context.Context, net, address string) bool { 85 | return WantsHostname(ctx, net, address, nrd.next) 86 | } 87 | 88 | var _ Dialer = NameResolvingDialer{} 89 | var _ HostnameWanter = NameResolvingDialer{} 90 | -------------------------------------------------------------------------------- /dialer/upstream.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "crypto/tls" 8 | "encoding/base64" 9 | "errors" 10 | "fmt" 11 | "io" 12 | "net" 13 | "net/http" 14 | "net/url" 15 | "strings" 16 | "sync" 17 | 18 | xproxy "golang.org/x/net/proxy" 19 | 20 | "github.com/SenseUnit/dumbproxy/tlsutil" 21 | ) 22 | 23 | type HTTPProxyDialer struct { 24 | address string 25 | tlsConfig *tls.Config 26 | userinfo *url.Userinfo 27 | next Dialer 28 | } 29 | 30 | func NewHTTPProxyDialer(address string, tlsConfig *tls.Config, userinfo *url.Userinfo, next LegacyDialer) *HTTPProxyDialer { 31 | return &HTTPProxyDialer{ 32 | address: address, 33 | tlsConfig: tlsConfig, 34 | next: MaybeWrapWithContextDialer(next), 35 | userinfo: userinfo, 36 | } 37 | } 38 | 39 | func HTTPProxyDialerFromURL(u *url.URL, next xproxy.Dialer) (xproxy.Dialer, error) { 40 | host := u.Hostname() 41 | port := u.Port() 42 | 43 | var tlsConfig *tls.Config 44 | var err error 45 | switch strings.ToLower(u.Scheme) { 46 | case "http": 47 | if port == "" { 48 | port = "80" 49 | } 50 | case "https": 51 | if port == "" { 52 | port = "443" 53 | } 54 | tlsConfig, err = tlsutil.TLSConfigFromURL(u) 55 | if err != nil { 56 | return nil, fmt.Errorf("TLS configuration failed: %w", err) 57 | } 58 | default: 59 | return nil, errors.New("unsupported proxy type") 60 | } 61 | 62 | address := net.JoinHostPort(host, port) 63 | 64 | return NewHTTPProxyDialer(address, tlsConfig, u.User, next), nil 65 | } 66 | 67 | func (d *HTTPProxyDialer) Dial(network, address string) (net.Conn, error) { 68 | return d.DialContext(context.Background(), network, address) 69 | } 70 | 71 | func (d *HTTPProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 72 | switch network { 73 | case "tcp", "tcp4", "tcp6": 74 | default: 75 | return nil, errors.New("only \"tcp\" network is supported") 76 | } 77 | conn, err := d.next.DialContext(ctx, "tcp", d.address) 78 | if err != nil { 79 | return nil, fmt.Errorf("proxy dialer is unable to make connection: %w", err) 80 | } 81 | if d.tlsConfig != nil { 82 | conn = tls.Client(conn, d.tlsConfig) 83 | } 84 | 85 | stopGuardEvent := make(chan struct{}) 86 | guardErr := make(chan error, 1) 87 | go func() { 88 | select { 89 | case <-stopGuardEvent: 90 | close(guardErr) 91 | case <-ctx.Done(): 92 | conn.Close() 93 | guardErr <- ctx.Err() 94 | } 95 | }() 96 | var stopGuardOnce sync.Once 97 | stopGuard := func() { 98 | stopGuardOnce.Do(func() { 99 | close(stopGuardEvent) 100 | }) 101 | } 102 | defer stopGuard() 103 | 104 | var reqBuf bytes.Buffer 105 | fmt.Fprintf(&reqBuf, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n", address, address) 106 | if d.userinfo != nil { 107 | fmt.Fprintf(&reqBuf, "Proxy-Authorization: %s\r\n", basicAuthHeader(d.userinfo)) 108 | } 109 | fmt.Fprintf(&reqBuf, "User-Agent: dumbproxy\r\n\r\n") 110 | _, err = io.Copy(conn, &reqBuf) 111 | if err != nil { 112 | conn.Close() 113 | return nil, fmt.Errorf("unable to write proxy request for remote connection: %w", err) 114 | } 115 | 116 | resp, err := readResponse(conn) 117 | if err != nil { 118 | conn.Close() 119 | return nil, fmt.Errorf("reading proxy response failed: %w", err) 120 | } 121 | 122 | if resp.StatusCode != http.StatusOK { 123 | conn.Close() 124 | return nil, fmt.Errorf("bad status code from proxy: %d", resp.StatusCode) 125 | } 126 | 127 | stopGuard() 128 | if err := <-guardErr; err != nil { 129 | return nil, fmt.Errorf("context error: %w", err) 130 | } 131 | return conn, nil 132 | } 133 | 134 | var ( 135 | responseTerminator = []byte("\r\n\r\n") 136 | ) 137 | 138 | func readResponse(r io.Reader) (*http.Response, error) { 139 | var respBuf bytes.Buffer 140 | b := make([]byte, 1) 141 | for !bytes.HasSuffix(respBuf.Bytes(), responseTerminator) { 142 | n, err := r.Read(b) 143 | if err != nil { 144 | return nil, fmt.Errorf("unable to read HTTP response: %w", err) 145 | } 146 | if n == 0 { 147 | continue 148 | } 149 | _, err = respBuf.Write(b) 150 | if err != nil { 151 | return nil, fmt.Errorf("unable to store byte into buffer: %w", err) 152 | } 153 | } 154 | resp, err := http.ReadResponse(bufio.NewReader(&respBuf), nil) 155 | if err != nil { 156 | return nil, fmt.Errorf("unable to decode proxy response: %w", err) 157 | } 158 | return resp, nil 159 | } 160 | 161 | func basicAuthHeader(userinfo *url.Userinfo) string { 162 | username := userinfo.Username() 163 | password, _ := userinfo.Password() 164 | return "Basic " + base64.StdEncoding.EncodeToString( 165 | []byte(username+":"+password)) 166 | } 167 | -------------------------------------------------------------------------------- /forward/bwlimit.go: -------------------------------------------------------------------------------- 1 | package forward 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "time" 8 | 9 | "github.com/zeebo/xxh3" 10 | "golang.org/x/time/rate" 11 | ) 12 | 13 | const copyChunkSize = 128 * 1024 14 | 15 | type BWLimit struct { 16 | d []rate.Limiter 17 | u []rate.Limiter 18 | } 19 | 20 | func NewBWLimit(bytesPerSecond float64, burst int64, buckets uint, separate bool) *BWLimit { 21 | if buckets == 0 { 22 | buckets = 1 23 | } 24 | lim := *(rate.NewLimiter(rate.Limit(bytesPerSecond), max(copyChunkSize, burst))) 25 | d := make([]rate.Limiter, buckets) 26 | for i := range d { 27 | d[i] = lim 28 | } 29 | u := d 30 | if separate { 31 | u = make([]rate.Limiter, buckets) 32 | for i := range u { 33 | u[i] = lim 34 | } 35 | } 36 | return &BWLimit{ 37 | d: d, 38 | u: u, 39 | } 40 | } 41 | 42 | func (l *BWLimit) copy(ctx context.Context, rl *rate.Limiter, dst io.Writer, src io.Reader) (written int64, err error) { 43 | lim := &io.LimitedReader{ 44 | R: src, 45 | N: copyChunkSize, 46 | } 47 | var n int64 48 | for { 49 | t := time.Now() 50 | r := rl.ReserveN(t, copyChunkSize) 51 | if !r.OK() { 52 | err = errors.New("can't get rate limit reservation") 53 | return 54 | } 55 | delay := r.DelayFrom(t) 56 | if delay > 0 { 57 | select { 58 | case <-time.After(delay): 59 | case <-ctx.Done(): 60 | err = ctx.Err() 61 | return 62 | } 63 | } 64 | n, err = io.Copy(dst, lim) 65 | written += n 66 | if n < copyChunkSize { 67 | r.CancelAt(t) 68 | if n > 0 { 69 | rl.ReserveN(t, n) 70 | } 71 | } 72 | if err != nil { 73 | return 74 | } 75 | if lim.N > 0 { 76 | // EOF from underlying stream 77 | return 78 | } 79 | lim.N = copyChunkSize 80 | } 81 | return written, err 82 | } 83 | 84 | func (l *BWLimit) copyAndCloseWrite(ctx context.Context, rl *rate.Limiter, dst io.WriteCloser, src io.ReadCloser) error { 85 | _, err := l.copy(ctx, rl, dst, src) 86 | if closeWriter, ok := dst.(interface { 87 | CloseWrite() error 88 | }); ok { 89 | closeWriter.CloseWrite() 90 | } else { 91 | dst.Close() 92 | } 93 | return err 94 | } 95 | 96 | func (l *BWLimit) futureCopyAndCloseWrite(ctx context.Context, c chan<- error, rl *rate.Limiter, dst io.WriteCloser, src io.ReadCloser) { 97 | c <- l.copyAndCloseWrite(ctx, rl, dst, src) 98 | close(c) 99 | } 100 | 101 | func (l *BWLimit) getRatelimiters(username string) (*rate.Limiter, *rate.Limiter) { 102 | idx := int(hashUsername(username, uint64(len(l.d)))) 103 | return &(l.d[idx]), &(l.u[idx]) 104 | } 105 | 106 | func (l *BWLimit) PairConnections(ctx context.Context, username string, incoming, outgoing io.ReadWriteCloser) error { 107 | dl, ul := l.getRatelimiters(username) 108 | 109 | var err error 110 | i2oErr := make(chan error, 1) 111 | o2iErr := make(chan error, 1) 112 | ctxErr := ctx.Done() 113 | 114 | go l.futureCopyAndCloseWrite(ctx, i2oErr, ul, outgoing, incoming) 115 | go l.futureCopyAndCloseWrite(ctx, o2iErr, dl, incoming, outgoing) 116 | 117 | // do while we're listening to children channels 118 | for i2oErr != nil || o2iErr != nil { 119 | select { 120 | case e := <-i2oErr: 121 | if err == nil { 122 | err = e 123 | } 124 | i2oErr = nil // unsubscribe 125 | case e := <-o2iErr: 126 | if err == nil { 127 | err = e 128 | } 129 | o2iErr = nil // unsubscribe 130 | case <-ctxErr: 131 | if err == nil { 132 | err = ctx.Err() 133 | } 134 | ctxErr = nil // unsubscribe 135 | incoming.Close() 136 | outgoing.Close() 137 | } 138 | } 139 | 140 | return err 141 | } 142 | 143 | func hashUsername(s string, nslots uint64) uint64 { 144 | if nslots == 0 { 145 | panic("number of slots can't be zero") 146 | } 147 | 148 | hash := xxh3.New() 149 | iv := []byte{0} 150 | 151 | if nslots&(nslots-1) == 0 { 152 | hash.Write(iv) 153 | hash.Write([]byte(s)) 154 | return hash.Sum64() & (nslots - 1) 155 | } 156 | 157 | minBiased := -((-nslots) % nslots) // == 2**64 - (2**64%nslots) 158 | 159 | var hv uint64 160 | for { 161 | hash.Write(iv) 162 | hash.Write([]byte(s)) 163 | hv = hash.Sum64() 164 | if hv < minBiased { 165 | break 166 | } 167 | iv[0]++ 168 | hash.Reset() 169 | } 170 | return hv % nslots 171 | } 172 | -------------------------------------------------------------------------------- /forward/direct.go: -------------------------------------------------------------------------------- 1 | package forward 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | func copyAndCloseWrite(dst io.WriteCloser, src io.ReadCloser) error { 9 | _, err := io.Copy(dst, src) 10 | if closeWriter, ok := dst.(interface { 11 | CloseWrite() error 12 | }); ok { 13 | closeWriter.CloseWrite() 14 | } else { 15 | dst.Close() 16 | } 17 | return err 18 | } 19 | 20 | func futureCopyAndCloseWrite(c chan<- error, dst io.WriteCloser, src io.ReadCloser) { 21 | c <- copyAndCloseWrite(dst, src) 22 | close(c) 23 | } 24 | 25 | func PairConnections(ctx context.Context, username string, incoming, outgoing io.ReadWriteCloser) error { 26 | var err error 27 | i2oErr := make(chan error, 1) 28 | o2iErr := make(chan error, 1) 29 | ctxErr := ctx.Done() 30 | 31 | go futureCopyAndCloseWrite(i2oErr, outgoing, incoming) 32 | go futureCopyAndCloseWrite(o2iErr, incoming, outgoing) 33 | 34 | // do while we're listening to children channels 35 | for i2oErr != nil || o2iErr != nil { 36 | select { 37 | case e := <-i2oErr: 38 | if err == nil { 39 | err = e 40 | } 41 | i2oErr = nil // unsubscribe 42 | case e := <-o2iErr: 43 | if err == nil { 44 | err = e 45 | } 46 | o2iErr = nil // unsubscribe 47 | case <-ctxErr: 48 | if err == nil { 49 | err = ctx.Err() 50 | } 51 | ctxErr = nil // unsubscribe 52 | incoming.Close() 53 | outgoing.Close() 54 | } 55 | } 56 | 57 | return err 58 | } 59 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/SenseUnit/dumbproxy 2 | 3 | go 1.24 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/Snawoot/uniqueslice v0.1.1 9 | github.com/coreos/go-systemd/v22 v22.5.0 10 | github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c 11 | github.com/hashicorp/go-multierror v1.1.1 12 | github.com/jellydator/ttlcache/v3 v3.3.0 13 | github.com/libp2p/go-reuseport v0.4.0 14 | github.com/redis/go-redis/v9 v9.8.0 15 | github.com/tg123/go-htpasswd v1.2.4 16 | github.com/zeebo/xxh3 v1.0.2 17 | golang.org/x/crypto v0.38.0 18 | golang.org/x/crypto/x509roots/fallback v0.0.0-20250512154111-9f6bf8449a9f 19 | golang.org/x/net v0.40.0 20 | golang.org/x/sync v0.14.0 21 | golang.org/x/time v0.11.0 22 | ) 23 | 24 | require ( 25 | github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 // indirect 26 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 27 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 28 | github.com/dlclark/regexp2 v1.11.5 // indirect 29 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect 30 | github.com/google/pprof v0.0.0-20250501235452-c0086092b71a // indirect 31 | github.com/hashicorp/errwrap v1.1.0 // indirect 32 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 33 | github.com/pires/go-proxyproto v0.8.1 34 | golang.org/x/sys v0.33.0 // indirect 35 | golang.org/x/term v0.32.0 // indirect 36 | golang.org/x/text v0.25.0 // indirect 37 | ) 38 | 39 | replace golang.org/x/time => github.com/Snawoot/xtime v0.0.0-20250501122004-d1ce456948bb 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= 2 | github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= 3 | github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= 4 | github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= 5 | github.com/Snawoot/uniqueslice v0.1.1 h1:KEfv3FtAXiNEoxvcc79pFQDhnqwYXQyZIkxOM4e/qpw= 6 | github.com/Snawoot/uniqueslice v0.1.1/go.mod h1:K9zIaHO43FGLHbqm6WCDFeY6+CN/du5eiio/vxvDVC8= 7 | github.com/Snawoot/xtime v0.0.0-20250501122004-d1ce456948bb h1:PleTDwc/EQenzLsvIal2BgvIXr2D214M88RFac3WkeI= 8 | github.com/Snawoot/xtime v0.0.0-20250501122004-d1ce456948bb/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 9 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 10 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 11 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 12 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 13 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 14 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 15 | github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= 16 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 20 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 21 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 22 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 23 | github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c h1:mxWGS0YyquJ/ikZOjSrRjjFIbUqIP9ojyYQ+QZTU3Rg= 24 | github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= 25 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= 26 | github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= 27 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 28 | github.com/google/pprof v0.0.0-20250501235452-c0086092b71a h1:rDA3FfmxwXR+BVKKdz55WwMJ1pD2hJQNW31d+l3mPk4= 29 | github.com/google/pprof v0.0.0-20250501235452-c0086092b71a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= 30 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 31 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 32 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 33 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 34 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 35 | github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= 36 | github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= 37 | github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 38 | github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 39 | github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= 40 | github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= 41 | github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= 42 | github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= 43 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 44 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 45 | github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= 46 | github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= 47 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 48 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 49 | github.com/tg123/go-htpasswd v1.2.4 h1:HgH8KKCjdmo7jjXWN9k1nefPBd7Be3tFCTjc2jPraPU= 50 | github.com/tg123/go-htpasswd v1.2.4/go.mod h1:EKThQok9xHkun6NBMynNv6Jmu24A33XdZzzl4Q7H1+0= 51 | github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= 52 | github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 53 | github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= 54 | github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 55 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 56 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 57 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 58 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 59 | golang.org/x/crypto/x509roots/fallback v0.0.0-20250512154111-9f6bf8449a9f h1:0qZaMiA1ndKoBWDPFxbzuCYdenXh27tchH7+2h/bZFQ= 60 | golang.org/x/crypto/x509roots/fallback v0.0.0-20250512154111-9f6bf8449a9f/go.mod h1:lxN5T34bK4Z/i6cMaU7frUU57VkDXFD4Kamfl/cp9oU= 61 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 62 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 63 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 64 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 65 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 66 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 67 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 68 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 69 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 70 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 71 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 72 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 73 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 74 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 75 | -------------------------------------------------------------------------------- /handler/adapter.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | ) 7 | 8 | type wrappedH2 struct { 9 | r io.ReadCloser 10 | w io.Writer 11 | } 12 | 13 | func wrapH2(r io.ReadCloser, w io.Writer) wrappedH2 { 14 | return wrappedH2{ 15 | r: r, 16 | w: w, 17 | } 18 | } 19 | 20 | func (w wrappedH2) Read(p []byte) (n int, err error) { 21 | return w.r.Read(p) 22 | } 23 | 24 | func (w wrappedH2) Write(p []byte) (n int, err error) { 25 | n, err = w.w.Write(p) 26 | if err != nil { 27 | return 28 | } 29 | if f, ok := w.w.(http.Flusher); ok { 30 | f.Flush() 31 | } 32 | return 33 | } 34 | 35 | func (w wrappedH2) Close() error { 36 | // can't really close response writer, but at least we can disrupt copy 37 | // closing Reader 38 | return w.r.Close() 39 | } 40 | 41 | var _ io.ReadWriteCloser = wrappedH2{} 42 | 43 | type wrappedH1ReqBody struct { 44 | r io.ReadCloser 45 | } 46 | 47 | func wrapH1ReqBody(r io.ReadCloser) wrappedH1ReqBody { 48 | return wrappedH1ReqBody{ 49 | r: r, 50 | } 51 | } 52 | 53 | func (w wrappedH1ReqBody) Read(p []byte) (n int, err error) { 54 | return w.r.Read(p) 55 | } 56 | 57 | func (w wrappedH1ReqBody) Write(p []byte) (n int, err error) { 58 | return len(p), nil 59 | } 60 | 61 | func (w wrappedH1ReqBody) Close() error { 62 | return w.r.Close() 63 | } 64 | 65 | func (w wrappedH1ReqBody) CloseWrite() error { 66 | return nil 67 | } 68 | 69 | var _ io.ReadWriteCloser = wrappedH1ReqBody{} 70 | var _ interface{ CloseWrite() error } = wrappedH1ReqBody{} 71 | 72 | type h1ReqBodyPipe struct { 73 | r *io.PipeReader 74 | w *io.PipeWriter 75 | } 76 | 77 | func newH1ReqBodyPipe() h1ReqBodyPipe { 78 | r, w := io.Pipe() 79 | return h1ReqBodyPipe{ 80 | r: r, 81 | w: w, 82 | } 83 | } 84 | 85 | func (w h1ReqBodyPipe) Read(p []byte) (n int, err error) { 86 | return 0, io.EOF 87 | } 88 | 89 | func (w h1ReqBodyPipe) Write(p []byte) (n int, err error) { 90 | return w.w.Write(p) 91 | } 92 | 93 | func (w h1ReqBodyPipe) Close() error { 94 | return w.CloseWrite() 95 | } 96 | 97 | func (w h1ReqBodyPipe) CloseWrite() error { 98 | return w.w.Close() 99 | } 100 | 101 | func (w h1ReqBodyPipe) Body() io.ReadCloser { 102 | return w.r 103 | } 104 | 105 | var _ io.ReadWriteCloser = h1ReqBodyPipe{} 106 | var _ interface{ CloseWrite() error } = h1ReqBodyPipe{} 107 | 108 | type wrappedH1RespWriter struct { 109 | w io.Writer 110 | } 111 | 112 | func wrapH1RespWriter(w io.Writer) wrappedH1RespWriter { 113 | return wrappedH1RespWriter{ 114 | w: w, 115 | } 116 | } 117 | 118 | func (w wrappedH1RespWriter) Read(p []byte) (n int, err error) { 119 | return 0, io.EOF 120 | } 121 | 122 | func (w wrappedH1RespWriter) Write(p []byte) (n int, err error) { 123 | n, err = w.w.Write(p) 124 | if f, ok := w.w.(http.Flusher); ok { 125 | f.Flush() 126 | } 127 | return 128 | } 129 | 130 | func (w wrappedH1RespWriter) Close() error { 131 | // can't really close response writer, just make copier return 132 | // and finish request 133 | return nil 134 | } 135 | 136 | var _ io.ReadWriteCloser = wrappedH1RespWriter{} 137 | -------------------------------------------------------------------------------- /handler/config.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/SenseUnit/dumbproxy/auth" 8 | clog "github.com/SenseUnit/dumbproxy/log" 9 | ) 10 | 11 | type Config struct { 12 | // Dialer optionally specifies dialer to use for creating 13 | // connections originating from proxy. 14 | Dialer HandlerDialer 15 | // Auth optionally specifies request validator used to verify users 16 | // and return their username. 17 | Auth auth.Auth 18 | // Logger specifies optional custom logger. 19 | Logger *clog.CondLogger 20 | // Forward optionally specifies custom connection pairing function 21 | // which does actual data forwarding. 22 | Forward func(ctx context.Context, username string, incoming, outgoing io.ReadWriteCloser) error 23 | // UserIPHints specifies whether allow IP hints set by user or not 24 | UserIPHints bool 25 | } 26 | -------------------------------------------------------------------------------- /handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net" 10 | "net/http" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/SenseUnit/dumbproxy/auth" 15 | "github.com/SenseUnit/dumbproxy/dialer" 16 | ddto "github.com/SenseUnit/dumbproxy/dialer/dto" 17 | derrors "github.com/SenseUnit/dumbproxy/dialer/errors" 18 | "github.com/SenseUnit/dumbproxy/forward" 19 | clog "github.com/SenseUnit/dumbproxy/log" 20 | ) 21 | 22 | const HintsHeaderName = "X-Src-IP-Hints" 23 | 24 | type HandlerDialer interface { 25 | DialContext(ctx context.Context, net, address string) (net.Conn, error) 26 | } 27 | 28 | type ProxyHandler struct { 29 | auth auth.Auth 30 | logger *clog.CondLogger 31 | dialer HandlerDialer 32 | forward func(ctx context.Context, username string, incoming, outgoing io.ReadWriteCloser) error 33 | httptransport http.RoundTripper 34 | outbound map[string]string 35 | outboundMux sync.RWMutex 36 | userIPHints bool 37 | } 38 | 39 | func NewProxyHandler(config *Config) *ProxyHandler { 40 | d := config.Dialer 41 | if d == nil { 42 | d = dialer.NewBoundDialer(nil, "") 43 | } 44 | httptransport := &http.Transport{ 45 | DialContext: d.DialContext, 46 | DisableKeepAlives: true, 47 | } 48 | a := config.Auth 49 | if a == nil { 50 | a = auth.NoAuth{} 51 | } 52 | l := config.Logger 53 | if l == nil { 54 | l = clog.NewCondLogger(log.New(io.Discard, "", 0), 0) 55 | } 56 | f := config.Forward 57 | if f == nil { 58 | f = forward.PairConnections 59 | } 60 | return &ProxyHandler{ 61 | auth: a, 62 | logger: l, 63 | dialer: d, 64 | forward: f, 65 | httptransport: httptransport, 66 | outbound: make(map[string]string), 67 | userIPHints: config.UserIPHints, 68 | } 69 | } 70 | 71 | func (s *ProxyHandler) HandleTunnel(wr http.ResponseWriter, req *http.Request, username string) { 72 | conn, err := s.dialer.DialContext(req.Context(), "tcp", req.RequestURI) 73 | if err != nil { 74 | var accessErr derrors.ErrAccessDenied 75 | if errors.As(err, &accessErr) { 76 | s.logger.Warning("Access denied: %v", err) 77 | http.Error(wr, "Access denied", http.StatusForbidden) 78 | return 79 | } 80 | s.logger.Error("Can't satisfy CONNECT request: %v", err) 81 | http.Error(wr, "Can't satisfy CONNECT request", http.StatusBadGateway) 82 | return 83 | } 84 | 85 | localAddr := conn.LocalAddr().String() 86 | s.outboundMux.Lock() 87 | s.outbound[localAddr] = req.RemoteAddr 88 | s.outboundMux.Unlock() 89 | defer func() { 90 | conn.Close() 91 | s.outboundMux.Lock() 92 | delete(s.outbound, localAddr) 93 | s.outboundMux.Unlock() 94 | }() 95 | 96 | if req.ProtoMajor == 0 || req.ProtoMajor == 1 { 97 | // Upgrade client connection 98 | localconn, rw, err := hijack(wr) 99 | if err != nil { 100 | s.logger.Error("Can't hijack client connection: %v", err) 101 | http.Error(wr, "Can't hijack client connection", http.StatusInternalServerError) 102 | return 103 | } 104 | defer localconn.Close() 105 | 106 | if buffered := rw.Reader.Buffered(); buffered > 0 { 107 | s.logger.Debug("saving %d bytes buffered in bufio.ReadWriter", buffered) 108 | s.forward( 109 | req.Context(), 110 | username, 111 | wrapH1ReqBody(io.NopCloser(io.LimitReader(rw.Reader, int64(buffered)))), 112 | wrapH1RespWriter(conn), 113 | ) 114 | s.forward( 115 | req.Context(), 116 | username, 117 | wrapPendingWrite( 118 | []byte(fmt.Sprintf("HTTP/%d.%d 200 OK\r\n\r\n", req.ProtoMajor, req.ProtoMinor)), 119 | localconn, 120 | ), 121 | conn, 122 | ) 123 | } else { 124 | s.logger.Debug("not rescuing remaining data in bufio.ReadWriter") 125 | fmt.Fprintf(localconn, "HTTP/%d.%d 200 OK\r\n\r\n", req.ProtoMajor, req.ProtoMinor) 126 | s.forward(req.Context(), username, localconn, conn) 127 | } 128 | } else if req.ProtoMajor == 2 { 129 | wr.Header()["Date"] = nil 130 | wr.WriteHeader(http.StatusOK) 131 | flush(wr) 132 | s.forward(req.Context(), username, wrapH2(req.Body, wr), conn) 133 | } else { 134 | s.logger.Error("Unsupported protocol version: %s", req.Proto) 135 | http.Error(wr, "Unsupported protocol version.", http.StatusBadRequest) 136 | return 137 | } 138 | } 139 | 140 | func (s *ProxyHandler) HandleRequest(wr http.ResponseWriter, req *http.Request, username string) { 141 | req.RequestURI = "" 142 | forwardReqBody := newH1ReqBodyPipe() 143 | origBody := req.Body 144 | req.Body = forwardReqBody.Body() 145 | go func() { 146 | s.forward(req.Context(), username, wrapH1ReqBody(origBody), forwardReqBody) 147 | }() 148 | if req.ProtoMajor == 2 { 149 | req.URL.Scheme = "http" // We can't access :scheme pseudo-header, so assume http 150 | req.URL.Host = req.Host 151 | } 152 | resp, err := s.httptransport.RoundTrip(req) 153 | if err != nil { 154 | var accessErr derrors.ErrAccessDenied 155 | if errors.As(err, &accessErr) { 156 | s.logger.Warning("Access denied: %v", err) 157 | http.Error(wr, "Access denied", http.StatusForbidden) 158 | return 159 | } 160 | s.logger.Error("HTTP fetch error: %v", err) 161 | http.Error(wr, "Server Error", http.StatusInternalServerError) 162 | return 163 | } 164 | defer resp.Body.Close() 165 | s.logger.Info("%v %v %v %v", req.RemoteAddr, req.Method, req.URL, resp.Status) 166 | delHopHeaders(resp.Header) 167 | copyHeader(wr.Header(), resp.Header) 168 | wr.WriteHeader(resp.StatusCode) 169 | flush(wr) 170 | s.forward(req.Context(), username, wrapH1RespWriter(wr), wrapH1ReqBody(resp.Body)) 171 | } 172 | 173 | func (s *ProxyHandler) isLoopback(req *http.Request) (string, bool) { 174 | s.outboundMux.RLock() 175 | originator, found := s.outbound[req.RemoteAddr] 176 | s.outboundMux.RUnlock() 177 | return originator, found 178 | } 179 | 180 | func (s *ProxyHandler) ServeHTTP(wr http.ResponseWriter, req *http.Request) { 181 | if originator, isLoopback := s.isLoopback(req); isLoopback { 182 | s.logger.Critical("Loopback tunnel detected: %s is an outbound "+ 183 | "address for another request from %s", req.RemoteAddr, originator) 184 | http.Error(wr, auth.BAD_REQ_MSG, http.StatusBadRequest) 185 | return 186 | } 187 | 188 | isConnect := strings.ToUpper(req.Method) == "CONNECT" 189 | if (req.URL.Host == "" || req.URL.Scheme == "" && !isConnect) && req.ProtoMajor < 2 || 190 | req.Host == "" && req.ProtoMajor == 2 { 191 | http.Error(wr, auth.BAD_REQ_MSG, http.StatusBadRequest) 192 | return 193 | } 194 | 195 | ctx := req.Context() 196 | username, ok := s.auth.Validate(ctx, wr, req) 197 | localAddr := getLocalAddr(req.Context()) 198 | s.logger.Info("Request: %v => %v %q %v %v %v", req.RemoteAddr, localAddr, username, req.Proto, req.Method, req.URL) 199 | 200 | if !ok { 201 | return 202 | } 203 | 204 | var ipHints *string 205 | if s.userIPHints { 206 | hintValues := req.Header.Values(HintsHeaderName) 207 | if len(hintValues) > 0 { 208 | req.Header.Del(HintsHeaderName) 209 | ipHints = &hintValues[0] 210 | } 211 | } 212 | ctx = ddto.BoundDialerParamsToContext(ctx, ipHints, trimAddrPort(localAddr)) 213 | ctx = ddto.FilterParamsToContext(ctx, req, username) 214 | req = req.WithContext(ctx) 215 | delHopHeaders(req.Header) 216 | if isConnect { 217 | s.HandleTunnel(wr, req, username) 218 | } else { 219 | s.HandleRequest(wr, req, username) 220 | } 221 | } 222 | 223 | func trimAddrPort(addrPort string) string { 224 | res, _, err := net.SplitHostPort(addrPort) 225 | if err != nil { 226 | return addrPort 227 | } 228 | return res 229 | } 230 | 231 | func getLocalAddr(ctx context.Context) string { 232 | if addr, ok := ctx.Value(http.LocalAddrContextKey).(net.Addr); ok { 233 | return addr.String() 234 | } 235 | return "" 236 | } 237 | -------------------------------------------------------------------------------- /handler/proxy.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "net" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | // Hop-by-hop headers. These are removed when sent to the backend. 12 | // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html 13 | var hopHeaders = []string{ 14 | "Connection", 15 | "Keep-Alive", 16 | "Proxy-Authenticate", 17 | "Proxy-Connection", 18 | "Proxy-Authorization", 19 | "Te", // canonicalized version of "TE" 20 | "Trailers", 21 | "Transfer-Encoding", 22 | "Upgrade", 23 | } 24 | 25 | func copyHeader(dst, src http.Header) { 26 | for k, vv := range src { 27 | for _, v := range vv { 28 | dst.Add(k, v) 29 | } 30 | } 31 | } 32 | 33 | func delHopHeaders(header http.Header) { 34 | for _, h := range hopHeaders { 35 | header.Del(h) 36 | } 37 | } 38 | 39 | func hijack(hijackable interface{}) (net.Conn, *bufio.ReadWriter, error) { 40 | hj, ok := hijackable.(http.Hijacker) 41 | if !ok { 42 | return nil, nil, errors.New("Connection doesn't support hijacking") 43 | } 44 | conn, rw, err := hj.Hijack() 45 | if err != nil { 46 | return nil, nil, err 47 | } 48 | var emptytime time.Time 49 | err = conn.SetDeadline(emptytime) 50 | if err != nil { 51 | conn.Close() 52 | return nil, nil, err 53 | } 54 | return conn, rw, nil 55 | } 56 | 57 | func flush(flusher interface{}) bool { 58 | f, ok := flusher.(http.Flusher) 59 | if !ok { 60 | return false 61 | } 62 | f.Flush() 63 | return true 64 | } 65 | 66 | func wrapPendingWrite(data []byte, c net.Conn) *pendingWriteConn { 67 | return &pendingWriteConn{ 68 | data: data, 69 | Conn: c, 70 | } 71 | } 72 | 73 | type pendingWriteConn struct { 74 | net.Conn 75 | data []byte 76 | done bool 77 | wErr error 78 | } 79 | 80 | func (p *pendingWriteConn) Write(b []byte) (n int, err error) { 81 | if p.wErr != nil { 82 | return 0, p.wErr 83 | } 84 | if !p.done { 85 | buf := append(append(make([]byte, 0, len(p.data)+len(b)), p.data...), b...) 86 | n, err := p.Conn.Write(buf) 87 | if err != nil { 88 | p.wErr = err 89 | } 90 | n = max(0, n-len(p.data)) 91 | p.done = true 92 | p.data = nil 93 | return n, err 94 | } 95 | return p.Conn.Write(b) 96 | } 97 | -------------------------------------------------------------------------------- /jsext/dto.go: -------------------------------------------------------------------------------- 1 | package jsext 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "strconv" 8 | 9 | ddto "github.com/SenseUnit/dumbproxy/dialer/dto" 10 | ) 11 | 12 | type JSRequestInfo struct { 13 | Method string `json:"method"` 14 | URL string `json:"url"` 15 | Proto string `json:"proto"` 16 | ProtoMajor int `json:"protoMajor"` 17 | ProtoMinor int `json:"protoMinor"` 18 | Header http.Header `json:"header"` 19 | ContentLength int64 `json:"contentLength"` 20 | TransferEncoding []string `json:"transferEncoding"` 21 | Host string `json:"host"` 22 | RemoteAddr string `json:"remoteAddr"` 23 | RequestURI string `json:"requestURI"` 24 | } 25 | 26 | func JSRequestInfoFromRequest(req *http.Request) *JSRequestInfo { 27 | return &JSRequestInfo{ 28 | Method: req.Method, 29 | URL: req.URL.String(), 30 | Proto: req.Proto, 31 | ProtoMajor: req.ProtoMajor, 32 | ProtoMinor: req.ProtoMinor, 33 | Header: req.Header, 34 | ContentLength: req.ContentLength, 35 | TransferEncoding: req.TransferEncoding, 36 | Host: req.Host, 37 | RemoteAddr: req.RemoteAddr, 38 | RequestURI: req.RequestURI, 39 | } 40 | } 41 | 42 | type JSDstInfo struct { 43 | Network string `json:"network"` 44 | OriginalHost string `json:"originalHost"` 45 | ResolvedHost *string `json:"resolvedHost"` 46 | Port uint16 `json:"port"` 47 | } 48 | 49 | func JSDstInfoFromContext(ctx context.Context, network, address string) (*JSDstInfo, error) { 50 | host, port, err := net.SplitHostPort(address) 51 | if err != nil { 52 | return nil, err 53 | } 54 | portNum, err := strconv.ParseUint(port, 10, 16) 55 | if err != nil { 56 | return nil, err 57 | } 58 | if origDst, ok := ddto.OrigDstFromContext(ctx); ok { 59 | origHost, _, err := net.SplitHostPort(origDst) 60 | if err != nil { 61 | return nil, err 62 | } 63 | return &JSDstInfo{ 64 | Network: network, 65 | OriginalHost: origHost, 66 | ResolvedHost: &host, 67 | Port: uint16(portNum), 68 | }, nil 69 | } else { 70 | return &JSDstInfo{ 71 | Network: network, 72 | OriginalHost: host, 73 | ResolvedHost: nil, 74 | Port: uint16(portNum), 75 | }, nil 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /jsext/printer.go: -------------------------------------------------------------------------------- 1 | package jsext 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/dop251/goja" 7 | 8 | clog "github.com/SenseUnit/dumbproxy/log" 9 | ) 10 | 11 | func AddPrinter(vm *goja.Runtime, logger *clog.CondLogger) error { 12 | return vm.Set("print", func(call goja.FunctionCall) goja.Value { 13 | printArgs := make([]interface{}, len(call.Arguments)) 14 | for i, arg := range call.Arguments { 15 | printArgs[i] = arg 16 | } 17 | logger.Info("%s", fmt.Sprintln(printArgs...)) 18 | return goja.Undefined() 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /log/condlog.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | ) 7 | 8 | const ( 9 | CRITICAL = 50 10 | ERROR = 40 11 | WARNING = 30 12 | INFO = 20 13 | DEBUG = 10 14 | NOTSET = 0 15 | ) 16 | 17 | type CondLogger struct { 18 | logger *log.Logger 19 | verbosity int 20 | } 21 | 22 | func (cl *CondLogger) Log(verb int, format string, v ...interface{}) error { 23 | if verb >= cl.verbosity { 24 | return cl.logger.Output(2, fmt.Sprintf(format, v...)) 25 | } 26 | return nil 27 | } 28 | 29 | func (cl *CondLogger) log(verb int, format string, v ...interface{}) error { 30 | if verb >= cl.verbosity { 31 | return cl.logger.Output(3, fmt.Sprintf(format, v...)) 32 | } 33 | return nil 34 | } 35 | 36 | func (cl *CondLogger) Critical(s string, v ...interface{}) error { 37 | return cl.log(CRITICAL, "CRITICAL "+s, v...) 38 | } 39 | 40 | func (cl *CondLogger) Error(s string, v ...interface{}) error { 41 | return cl.log(ERROR, "ERROR "+s, v...) 42 | } 43 | 44 | func (cl *CondLogger) Warning(s string, v ...interface{}) error { 45 | return cl.log(WARNING, "WARNING "+s, v...) 46 | } 47 | 48 | func (cl *CondLogger) Info(s string, v ...interface{}) error { 49 | return cl.log(INFO, "INFO "+s, v...) 50 | } 51 | 52 | func (cl *CondLogger) Debug(s string, v ...interface{}) error { 53 | return cl.log(DEBUG, "DEBUG "+s, v...) 54 | } 55 | 56 | func NewCondLogger(logger *log.Logger, verbosity int) *CondLogger { 57 | return &CondLogger{verbosity: verbosity, logger: logger} 58 | } 59 | -------------------------------------------------------------------------------- /log/logwriter.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "time" 7 | ) 8 | 9 | const MAX_LOG_QLEN = 128 10 | const QUEUE_SHUTDOWN_TIMEOUT = 500 * time.Millisecond 11 | 12 | type LogWriter struct { 13 | writer io.Writer 14 | ch chan []byte 15 | done chan struct{} 16 | } 17 | 18 | func (lw *LogWriter) Write(p []byte) (int, error) { 19 | if p == nil { 20 | return 0, errors.New("Can't write nil byte slice") 21 | } 22 | buf := make([]byte, len(p)) 23 | copy(buf, p) 24 | select { 25 | case lw.ch <- buf: 26 | return len(p), nil 27 | default: 28 | return 0, errors.New("Writer queue overflow") 29 | } 30 | } 31 | 32 | func NewLogWriter(writer io.Writer) *LogWriter { 33 | lw := &LogWriter{writer, 34 | make(chan []byte, MAX_LOG_QLEN), 35 | make(chan struct{})} 36 | go lw.loop() 37 | return lw 38 | } 39 | 40 | func (lw *LogWriter) loop() { 41 | for p := range lw.ch { 42 | if p == nil { 43 | break 44 | } 45 | lw.writer.Write(p) 46 | } 47 | lw.done <- struct{}{} 48 | } 49 | 50 | func (lw *LogWriter) Close() { 51 | lw.ch <- nil 52 | timer := time.After(QUEUE_SHUTDOWN_TIMEOUT) 53 | select { 54 | case <-timer: 55 | case <-lw.done: 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "crypto/tls" 7 | "encoding/base64" 8 | "encoding/binary" 9 | "encoding/csv" 10 | "encoding/hex" 11 | "errors" 12 | "flag" 13 | "fmt" 14 | "io" 15 | "log" 16 | "net" 17 | "net/http" 18 | "net/http/pprof" 19 | "net/netip" 20 | "os" 21 | "path/filepath" 22 | "runtime" 23 | "strings" 24 | "time" 25 | 26 | "github.com/coreos/go-systemd/v22/activation" 27 | "github.com/libp2p/go-reuseport" 28 | "golang.org/x/crypto/acme" 29 | "golang.org/x/crypto/acme/autocert" 30 | "golang.org/x/crypto/bcrypt" 31 | "golang.org/x/crypto/ssh/terminal" 32 | 33 | "github.com/SenseUnit/dumbproxy/access" 34 | "github.com/SenseUnit/dumbproxy/auth" 35 | "github.com/SenseUnit/dumbproxy/certcache" 36 | "github.com/SenseUnit/dumbproxy/dialer" 37 | "github.com/SenseUnit/dumbproxy/forward" 38 | "github.com/SenseUnit/dumbproxy/handler" 39 | clog "github.com/SenseUnit/dumbproxy/log" 40 | "github.com/SenseUnit/dumbproxy/tlsutil" 41 | proxyproto "github.com/pires/go-proxyproto" 42 | 43 | _ "golang.org/x/crypto/x509roots/fallback" 44 | ) 45 | 46 | var ( 47 | home, _ = os.UserHomeDir() 48 | version = "undefined" 49 | ) 50 | 51 | func perror(msg string) { 52 | fmt.Fprintln(os.Stderr, "") 53 | fmt.Fprintln(os.Stderr, msg) 54 | } 55 | 56 | func arg_fail(msg string) { 57 | perror(msg) 58 | perror("Usage:") 59 | flag.PrintDefaults() 60 | os.Exit(2) 61 | } 62 | 63 | type CSVArg []string 64 | 65 | func (a *CSVArg) Set(s string) error { 66 | *a = strings.Split(s, ",") 67 | return nil 68 | } 69 | 70 | func (a *CSVArg) String() string { 71 | if a == nil { 72 | return "" 73 | } 74 | if *a == nil { 75 | return "" 76 | } 77 | return strings.Join(*a, ",") 78 | } 79 | 80 | func (a *CSVArg) Value() []string { 81 | return []string(*a) 82 | } 83 | 84 | type PrefixList []netip.Prefix 85 | 86 | func (l *PrefixList) Set(s string) error { 87 | var pfxList []netip.Prefix 88 | parts := strings.Split(s, ",") 89 | for i, part := range parts { 90 | part = strings.TrimSpace(part) 91 | if part == "" { 92 | continue 93 | } 94 | pfx, err := netip.ParsePrefix(part) 95 | if err != nil { 96 | return fmt.Errorf("unable to parse prefix list element %d (%q): %w", i, part, err) 97 | } 98 | pfxList = append(pfxList, pfx) 99 | } 100 | *l = PrefixList(pfxList) 101 | return nil 102 | } 103 | 104 | func (l *PrefixList) String() string { 105 | if l == nil || *l == nil { 106 | return "" 107 | } 108 | parts := make([]string, 0, len([]netip.Prefix(*l))) 109 | for _, part := range []netip.Prefix(*l) { 110 | parts = append(parts, part.String()) 111 | } 112 | return strings.Join(parts, ", ") 113 | } 114 | 115 | func (l *PrefixList) Value() []netip.Prefix { 116 | return []netip.Prefix(*l) 117 | } 118 | 119 | type TLSVersionArg uint16 120 | 121 | func (a *TLSVersionArg) Set(s string) error { 122 | ver, err := tlsutil.ParseVersion(s) 123 | if err != nil { 124 | return err 125 | } 126 | *a = TLSVersionArg(ver) 127 | return nil 128 | } 129 | 130 | func (a *TLSVersionArg) String() string { 131 | return tlsutil.FormatVersion(uint16(*a)) 132 | } 133 | 134 | type proxyArg struct { 135 | literal bool 136 | value string 137 | } 138 | 139 | type hexArg struct { 140 | value []byte 141 | } 142 | 143 | func (a *hexArg) String() string { 144 | return hex.EncodeToString(a.value) 145 | } 146 | 147 | func (a *hexArg) Set(s string) error { 148 | b, err := hex.DecodeString(s) 149 | if err != nil { 150 | return err 151 | } 152 | a.value = b 153 | return nil 154 | } 155 | 156 | func (a *hexArg) Value() []byte { 157 | return a.value 158 | } 159 | 160 | type cacheKind int 161 | 162 | const ( 163 | cacheKindDir cacheKind = iota 164 | cacheKindRedis 165 | cacheKindRedisCluster 166 | ) 167 | 168 | type autocertCache struct { 169 | kind cacheKind 170 | value string 171 | } 172 | 173 | const envCacheEncKey = "DUMBPROXY_CACHE_ENC_KEY" 174 | 175 | type CLIArgs struct { 176 | bindAddress string 177 | bindReusePort bool 178 | bindPprof string 179 | auth string 180 | verbosity int 181 | cert, key, cafile string 182 | list_ciphers bool 183 | list_curves bool 184 | ciphers string 185 | curves string 186 | disableHTTP2 bool 187 | showVersion bool 188 | autocert bool 189 | autocertWhitelist CSVArg 190 | autocertCache autocertCache 191 | autocertCacheRedisPrefix string 192 | autocertACME string 193 | autocertEmail string 194 | autocertHTTP string 195 | autocertLocalCacheTTL time.Duration 196 | autocertLocalCacheTimeout time.Duration 197 | autocertCacheEncKey hexArg 198 | passwd string 199 | passwdCost int 200 | hmacSign bool 201 | hmacGenKey bool 202 | positionalArgs []string 203 | proxy []proxyArg 204 | sourceIPHints string 205 | userIPHints bool 206 | minTLSVersion TLSVersionArg 207 | maxTLSVersion TLSVersionArg 208 | bwLimit uint64 209 | bwBurst int64 210 | bwBuckets uint 211 | bwSeparate bool 212 | dnsCacheTTL time.Duration 213 | dnsCacheNegTTL time.Duration 214 | dnsCacheTimeout time.Duration 215 | reqHeaderTimeout time.Duration 216 | denyDstAddr PrefixList 217 | jsAccessFilter string 218 | jsAccessFilterInstances int 219 | jsProxyRouterInstances int 220 | proxyproto bool 221 | } 222 | 223 | func parse_args() CLIArgs { 224 | args := CLIArgs{ 225 | minTLSVersion: TLSVersionArg(tls.VersionTLS12), 226 | maxTLSVersion: TLSVersionArg(tls.VersionTLS13), 227 | denyDstAddr: PrefixList{ 228 | netip.MustParsePrefix("127.0.0.0/8"), 229 | netip.MustParsePrefix("0.0.0.0/32"), 230 | netip.MustParsePrefix("10.0.0.0/8"), 231 | netip.MustParsePrefix("172.16.0.0/12"), 232 | netip.MustParsePrefix("192.168.0.0/16"), 233 | netip.MustParsePrefix("169.254.0.0/16"), 234 | netip.MustParsePrefix("::1/128"), 235 | netip.MustParsePrefix("::/128"), 236 | netip.MustParsePrefix("fe80::/10"), 237 | }, 238 | autocertCache: autocertCache{ 239 | kind: cacheKindDir, 240 | value: filepath.Join(home, ".dumbproxy", "autocert"), 241 | }, 242 | } 243 | args.autocertCacheEncKey.Set(os.Getenv(envCacheEncKey)) 244 | flag.StringVar(&args.bindAddress, "bind-address", ":8080", "HTTP proxy listen address. Set empty value to use systemd socket activation.") 245 | flag.BoolVar(&args.bindReusePort, "bind-reuseport", false, "allow multiple server instances on the same port") 246 | flag.StringVar(&args.bindPprof, "bind-pprof", "", "enables pprof debug endpoints") 247 | flag.StringVar(&args.auth, "auth", "none://", "auth parameters") 248 | flag.IntVar(&args.verbosity, "verbosity", 20, "logging verbosity "+ 249 | "(10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical)") 250 | flag.StringVar(&args.cert, "cert", "", "enable TLS and use certificate") 251 | flag.StringVar(&args.key, "key", "", "key for TLS certificate") 252 | flag.StringVar(&args.cafile, "cafile", "", "CA file to authenticate clients with certificates") 253 | flag.BoolVar(&args.list_ciphers, "list-ciphers", false, "list ciphersuites") 254 | flag.BoolVar(&args.list_curves, "list-curves", false, "list key exchange curves") 255 | flag.StringVar(&args.ciphers, "ciphers", "", "colon-separated list of enabled ciphers") 256 | flag.StringVar(&args.curves, "curves", "", "colon-separated list of enabled key exchange curves") 257 | flag.BoolVar(&args.disableHTTP2, "disable-http2", false, "disable HTTP2") 258 | flag.BoolVar(&args.showVersion, "version", false, "show program version and exit") 259 | flag.BoolVar(&args.autocert, "autocert", false, "issue TLS certificates automatically") 260 | flag.Var(&args.autocertWhitelist, "autocert-whitelist", "restrict autocert domains to this comma-separated list") 261 | flag.Func("autocert-dir", "use directory path for autocert cache", func(p string) error { 262 | args.autocertCache = autocertCache{ 263 | kind: cacheKindDir, 264 | value: p, 265 | } 266 | return nil 267 | }) 268 | flag.Func("autocert-cache-redis", "use Redis URL for autocert cache", func(p string) error { 269 | args.autocertCache = autocertCache{ 270 | kind: cacheKindRedis, 271 | value: p, 272 | } 273 | return nil 274 | }) 275 | flag.Func("autocert-cache-redis-cluster", "use Redis Cluster URL for autocert cache", func(p string) error { 276 | args.autocertCache = autocertCache{ 277 | kind: cacheKindRedisCluster, 278 | value: p, 279 | } 280 | return nil 281 | }) 282 | flag.StringVar(&args.autocertCacheRedisPrefix, "autocert-cache-redis-prefix", "", "prefix to use for keys in Redis or Redis Cluster cache") 283 | flag.Var(&args.autocertCacheEncKey, "autocert-cache-enc-key", "hex-encoded encryption key for cert cache entries. Can be also set with "+envCacheEncKey+" environment variable") 284 | flag.StringVar(&args.autocertACME, "autocert-acme", autocert.DefaultACMEDirectory, "custom ACME endpoint") 285 | flag.StringVar(&args.autocertEmail, "autocert-email", "", "email used for ACME registration") 286 | flag.StringVar(&args.autocertHTTP, "autocert-http", "", "listen address for HTTP-01 challenges handler of ACME") 287 | flag.DurationVar(&args.autocertLocalCacheTTL, "autocert-local-cache-ttl", 0, "enables in-memory cache for certificates") 288 | flag.DurationVar(&args.autocertLocalCacheTimeout, "autocert-local-cache-timeout", 10*time.Second, "timeout for cert cache queries") 289 | flag.StringVar(&args.passwd, "passwd", "", "update given htpasswd file and add/set password for username. "+ 290 | "Username and password can be passed as positional arguments or requested interactively") 291 | flag.IntVar(&args.passwdCost, "passwd-cost", bcrypt.MinCost, "bcrypt password cost (for -passwd mode)") 292 | flag.BoolVar(&args.hmacSign, "hmac-sign", false, "sign username with specified key for given validity period. "+ 293 | "Positional arguments are: hex-encoded HMAC key, username, validity duration.") 294 | flag.BoolVar(&args.hmacGenKey, "hmac-genkey", false, "generate hex-encoded HMAC signing key of optimal length") 295 | flag.Func("proxy", "upstream proxy URL. Can be repeated multiple times to chain proxies. Examples: socks5h://127.0.0.1:9050; https://user:password@example.com:443", func(p string) error { 296 | args.proxy = append(args.proxy, proxyArg{true, p}) 297 | return nil 298 | }) 299 | flag.StringVar(&args.sourceIPHints, "ip-hints", "", "a comma-separated list of source addresses to use on dial attempts. \"$lAddr\" gets expanded to local address of connection. Example: \"10.0.0.1,fe80::2,$lAddr,0.0.0.0,::\"") 300 | flag.BoolVar(&args.userIPHints, "user-ip-hints", false, "allow IP hints to be specified by user in X-Src-IP-Hints header") 301 | flag.Var(&args.minTLSVersion, "min-tls-version", "minimum TLS version accepted by server") 302 | flag.Var(&args.maxTLSVersion, "max-tls-version", "maximum TLS version accepted by server") 303 | flag.Uint64Var(&args.bwLimit, "bw-limit", 0, "per-user bandwidth limit in bytes per second") 304 | flag.Int64Var(&args.bwBurst, "bw-limit-burst", 0, "allowed burst size for bandwidth limit, how many \"tokens\" can fit into leaky bucket") 305 | flag.UintVar(&args.bwBuckets, "bw-limit-buckets", 1024*1024, "number of buckets of bandwidth limit") 306 | flag.BoolVar(&args.bwSeparate, "bw-limit-separate", false, "separate upload and download bandwidth limits") 307 | flag.DurationVar(&args.dnsCacheTTL, "dns-cache-ttl", 0, "enable DNS cache with specified fixed TTL") 308 | flag.DurationVar(&args.dnsCacheNegTTL, "dns-cache-neg-ttl", time.Second, "TTL for negative responses of DNS cache") 309 | flag.DurationVar(&args.dnsCacheTimeout, "dns-cache-timeout", 5*time.Second, "timeout for shared resolves of DNS cache") 310 | flag.DurationVar(&args.reqHeaderTimeout, "req-header-timeout", 30*time.Second, "amount of time allowed to read request headers") 311 | flag.Var(&args.denyDstAddr, "deny-dst-addr", "comma-separated list of CIDR prefixes of forbidden IP addresses") 312 | flag.StringVar(&args.jsAccessFilter, "js-access-filter", "", "path to JS script file with the \"access\" filter function") 313 | flag.IntVar(&args.jsAccessFilterInstances, "js-access-filter-instances", runtime.GOMAXPROCS(0), "number of JS VM instances to handle access filter requests") 314 | flag.IntVar(&args.jsProxyRouterInstances, "js-proxy-router-instances", runtime.GOMAXPROCS(0), "number of JS VM instances to handle proxy router requests") 315 | flag.Func("js-proxy-router", "path to JS script file with the \"getProxy\" function", func(p string) error { 316 | args.proxy = append(args.proxy, proxyArg{false, p}) 317 | return nil 318 | }) 319 | flag.BoolVar(&args.proxyproto, "proxyproto", false, "listen proxy protocol") 320 | flag.Func("config", "read configuration from file with space-separated keys and values", readConfig) 321 | flag.Parse() 322 | args.positionalArgs = flag.Args() 323 | return args 324 | } 325 | 326 | func run() int { 327 | args := parse_args() 328 | 329 | // handle special invocation modes 330 | if args.showVersion { 331 | fmt.Println(version) 332 | return 0 333 | } 334 | 335 | if args.list_ciphers { 336 | list_ciphers() 337 | return 0 338 | } 339 | 340 | if args.list_curves { 341 | list_curves() 342 | return 0 343 | } 344 | 345 | if args.passwd != "" { 346 | if err := passwd(args.passwd, args.passwdCost, args.positionalArgs...); err != nil { 347 | log.Fatalf("can't set password: %v", err) 348 | } 349 | return 0 350 | } 351 | 352 | if args.hmacSign { 353 | if err := hmacSign(args.positionalArgs...); err != nil { 354 | log.Fatalf("can't sign: %v", err) 355 | } 356 | return 0 357 | } 358 | 359 | if args.hmacGenKey { 360 | if err := hmacGenKey(); err != nil { 361 | log.Fatalf("can't generate key: %v", err) 362 | } 363 | return 0 364 | } 365 | 366 | // we don't expect positional arguments in the main operation mode 367 | if len(args.positionalArgs) > 0 { 368 | arg_fail("Unexpected positional arguments! Check your command line.") 369 | } 370 | 371 | // setup logging 372 | logWriter := clog.NewLogWriter(os.Stderr) 373 | defer logWriter.Close() 374 | 375 | mainLogger := clog.NewCondLogger(log.New(logWriter, "MAIN : ", 376 | log.LstdFlags|log.Lshortfile), 377 | args.verbosity) 378 | proxyLogger := clog.NewCondLogger(log.New(logWriter, "PROXY : ", 379 | log.LstdFlags|log.Lshortfile), 380 | args.verbosity) 381 | authLogger := clog.NewCondLogger(log.New(logWriter, "AUTH : ", 382 | log.LstdFlags|log.Lshortfile), 383 | args.verbosity) 384 | jsAccessLogger := clog.NewCondLogger(log.New(logWriter, "JSACCESS: ", 385 | log.LstdFlags|log.Lshortfile), 386 | args.verbosity) 387 | jsRouterLogger := clog.NewCondLogger(log.New(logWriter, "JSROUTER: ", 388 | log.LstdFlags|log.Lshortfile), 389 | args.verbosity) 390 | 391 | // setup auth provider 392 | auth, err := auth.NewAuth(args.auth, authLogger) 393 | if err != nil { 394 | mainLogger.Critical("Failed to instantiate auth provider: %v", err) 395 | return 3 396 | } 397 | defer auth.Stop() 398 | 399 | // setup access filters 400 | var filterRoot access.Filter = access.AlwaysAllow{} 401 | if args.jsAccessFilter != "" { 402 | j, err := access.NewJSFilter( 403 | args.jsAccessFilter, 404 | args.jsAccessFilterInstances, 405 | jsAccessLogger, 406 | filterRoot, 407 | ) 408 | if err != nil { 409 | mainLogger.Critical("Failed to run JS filter: %v", err) 410 | return 3 411 | } 412 | filterRoot = j 413 | } 414 | if len(args.denyDstAddr.Value()) > 0 { 415 | filterRoot = access.NewDstAddrFilter(args.denyDstAddr.Value(), filterRoot) 416 | } 417 | 418 | // construct dialers 419 | var dialerRoot dialer.Dialer = dialer.NewBoundDialer(new(net.Dialer), args.sourceIPHints) 420 | if len(args.proxy) > 0 { 421 | for _, proxy := range args.proxy { 422 | if proxy.literal { 423 | newDialer, err := dialer.ProxyDialerFromURL(proxy.value, dialerRoot) 424 | if err != nil { 425 | mainLogger.Critical("Failed to create dialer for proxy %q: %v", proxy.value, err) 426 | return 3 427 | } 428 | dialerRoot = newDialer 429 | } else { 430 | newDialer, err := dialer.NewJSRouter( 431 | proxy.value, 432 | args.jsProxyRouterInstances, 433 | func(root dialer.Dialer) func(url string) (dialer.Dialer, error) { 434 | return func(url string) (dialer.Dialer, error) { 435 | return dialer.ProxyDialerFromURL(url, root) 436 | } 437 | }(dialerRoot), 438 | jsRouterLogger, 439 | dialerRoot, 440 | ) 441 | if err != nil { 442 | mainLogger.Critical("Failed to create JS proxy router: %v", err) 443 | return 3 444 | } 445 | dialerRoot = newDialer 446 | } 447 | } 448 | } 449 | 450 | dialerRoot = dialer.NewFilterDialer(filterRoot.Access, dialerRoot) // must follow after resolving in chain 451 | 452 | if args.dnsCacheTTL > 0 { 453 | cd := dialer.NewNameResolveCachingDialer( 454 | dialerRoot, 455 | net.DefaultResolver, 456 | args.dnsCacheTTL, 457 | args.dnsCacheNegTTL, 458 | args.dnsCacheTimeout, 459 | ) 460 | cd.Start() 461 | defer cd.Stop() 462 | dialerRoot = cd 463 | } else { 464 | dialerRoot = dialer.NewNameResolvingDialer(dialerRoot, net.DefaultResolver) 465 | } 466 | 467 | // handler requisites 468 | forwarder := forward.PairConnections 469 | if args.bwLimit != 0 { 470 | forwarder = forward.NewBWLimit( 471 | float64(args.bwLimit), 472 | args.bwBurst, 473 | args.bwBuckets, 474 | args.bwSeparate, 475 | ).PairConnections 476 | } 477 | 478 | server := http.Server{ 479 | Addr: args.bindAddress, 480 | Handler: handler.NewProxyHandler(&handler.Config{ 481 | Dialer: dialerRoot, 482 | Auth: auth, 483 | Logger: proxyLogger, 484 | UserIPHints: args.userIPHints, 485 | Forward: forwarder, 486 | }), 487 | ErrorLog: log.New(logWriter, "HTTPSRV : ", log.LstdFlags|log.Lshortfile), 488 | ReadTimeout: 0, 489 | ReadHeaderTimeout: args.reqHeaderTimeout, 490 | WriteTimeout: 0, 491 | IdleTimeout: 0, 492 | Protocols: new(http.Protocols), 493 | } 494 | 495 | // listener setup 496 | if args.disableHTTP2 { 497 | server.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) 498 | server.Protocols.SetHTTP1(true) 499 | } else { 500 | server.Protocols.SetHTTP1(true) 501 | server.Protocols.SetHTTP2(true) 502 | server.Protocols.SetUnencryptedHTTP2(true) 503 | } 504 | 505 | mainLogger.Info("Starting proxy server...") 506 | var listener net.Listener 507 | if args.bindAddress == "" { 508 | // socket activation 509 | listeners, err := activation.Listeners() 510 | if err != nil { 511 | mainLogger.Critical("socket activation failed: %v", err) 512 | return 3 513 | } 514 | if len(listeners) != 1 { 515 | mainLogger.Critical("socket activation failed: unexpected number of listeners: %d", 516 | len(listeners)) 517 | return 3 518 | } 519 | if listeners[0] == nil { 520 | mainLogger.Critical("socket activation failed: nil listener returned") 521 | return 3 522 | } 523 | listener = listeners[0] 524 | } else { 525 | listenerFactory := net.Listen 526 | if args.bindReusePort { 527 | if reuseport.Available() { 528 | listenerFactory = reuseport.Listen 529 | } else { 530 | mainLogger.Warning("reuseport was requested but not available!") 531 | } 532 | } 533 | newListener, err := listenerFactory("tcp", args.bindAddress) 534 | if err != nil { 535 | mainLogger.Critical("listen failed: %v", err) 536 | return 3 537 | } 538 | listener = newListener 539 | } 540 | 541 | if args.proxyproto { 542 | mainLogger.Info("Listening proxy protocol") 543 | listener = &proxyproto.Listener{Listener: listener} 544 | } 545 | 546 | if args.cert != "" { 547 | cfg, err1 := makeServerTLSConfig(args.cert, args.key, args.cafile, 548 | args.ciphers, args.curves, 549 | uint16(args.minTLSVersion), uint16(args.maxTLSVersion), !args.disableHTTP2) 550 | if err1 != nil { 551 | mainLogger.Critical("TLS config construction failed: %v", err1) 552 | return 3 553 | } 554 | listener = tls.NewListener(listener, cfg) 555 | } else if args.autocert { 556 | // cert caching chain 557 | var certCache autocert.Cache 558 | switch args.autocertCache.kind { 559 | case cacheKindDir: 560 | certCache = autocert.DirCache(args.autocertCache.value) 561 | case cacheKindRedis: 562 | certCache, err = certcache.RedisCacheFromURL(args.autocertCache.value, args.autocertCacheRedisPrefix) 563 | if err != nil { 564 | mainLogger.Critical("redis cache construction failed: %v", err) 565 | return 3 566 | } 567 | case cacheKindRedisCluster: 568 | certCache, err = certcache.RedisClusterCacheFromURL(args.autocertCache.value, args.autocertCacheRedisPrefix) 569 | if err != nil { 570 | mainLogger.Critical("redis cluster cache construction failed: %v", err) 571 | return 3 572 | } 573 | } 574 | if len(args.autocertCacheEncKey.Value()) > 0 { 575 | certCache, err = certcache.NewEncryptedCache(args.autocertCacheEncKey.Value(), certCache) 576 | if err != nil { 577 | mainLogger.Critical("unable to construct cache encryption layer: %v", err) 578 | return 3 579 | } 580 | } 581 | if args.autocertLocalCacheTTL > 0 { 582 | lcc := certcache.NewLocalCertCache( 583 | certCache, 584 | args.autocertLocalCacheTTL, 585 | args.autocertLocalCacheTimeout, 586 | ) 587 | lcc.Start() 588 | defer lcc.Stop() 589 | certCache = lcc 590 | } 591 | 592 | m := &autocert.Manager{ 593 | Cache: certCache, 594 | Prompt: autocert.AcceptTOS, 595 | Client: &acme.Client{DirectoryURL: args.autocertACME}, 596 | Email: args.autocertEmail, 597 | } 598 | if args.autocertWhitelist.Value() != nil { 599 | m.HostPolicy = autocert.HostWhitelist(args.autocertWhitelist.Value()...) 600 | } 601 | if args.autocertHTTP != "" { 602 | go func() { 603 | log.Fatalf("HTTP-01 ACME challenge server stopped: %v", 604 | http.ListenAndServe(args.autocertHTTP, m.HTTPHandler(nil))) 605 | }() 606 | } 607 | cfg := m.TLSConfig() 608 | cfg, err = updateServerTLSConfig(cfg, args.cafile, args.ciphers, args.curves, 609 | uint16(args.minTLSVersion), uint16(args.maxTLSVersion), !args.disableHTTP2) 610 | if err != nil { 611 | mainLogger.Critical("TLS config construction failed: %v", err) 612 | return 3 613 | } 614 | listener = tls.NewListener(listener, cfg) 615 | } 616 | // debug endpoints setup 617 | if args.bindPprof != "" { 618 | mux := http.NewServeMux() 619 | mux.HandleFunc("/debug/pprof/", pprof.Index) 620 | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) 621 | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) 622 | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) 623 | mux.HandleFunc("/debug/pprof/trace", pprof.Trace) 624 | go func() { log.Fatal(http.ListenAndServe(args.bindPprof, mux)) }() 625 | } 626 | 627 | mainLogger.Info("Proxy server started.") 628 | 629 | // setup done 630 | err = server.Serve(listener) 631 | mainLogger.Critical("Server terminated with a reason: %v", err) 632 | mainLogger.Info("Shutting down...") 633 | return 0 634 | } 635 | 636 | func makeServerTLSConfig(certfile, keyfile, cafile, ciphers, curves string, minVer, maxVer uint16, h2 bool) (*tls.Config, error) { 637 | cfg := tls.Config{ 638 | MinVersion: minVer, 639 | MaxVersion: maxVer, 640 | } 641 | cert, err := tls.LoadX509KeyPair(certfile, keyfile) 642 | if err != nil { 643 | return nil, err 644 | } 645 | cfg.Certificates = []tls.Certificate{cert} 646 | if cafile != "" { 647 | roots, err := tlsutil.LoadCAfile(cafile) 648 | if err != nil { 649 | return nil, err 650 | } 651 | cfg.ClientCAs = roots 652 | cfg.ClientAuth = tls.VerifyClientCertIfGiven 653 | } 654 | cfg.CipherSuites, err = tlsutil.ParseCipherList(ciphers) 655 | if err != nil { 656 | return nil, err 657 | } 658 | cfg.CurvePreferences, err = tlsutil.ParseCurveList(curves) 659 | if err != nil { 660 | return nil, err 661 | } 662 | if h2 { 663 | cfg.NextProtos = []string{"h2", "http/1.1"} 664 | } else { 665 | cfg.NextProtos = []string{"http/1.1"} 666 | } 667 | return &cfg, nil 668 | } 669 | 670 | func updateServerTLSConfig(cfg *tls.Config, cafile, ciphers, curves string, minVer, maxVer uint16, h2 bool) (*tls.Config, error) { 671 | if cafile != "" { 672 | roots, err := tlsutil.LoadCAfile(cafile) 673 | if err != nil { 674 | return nil, err 675 | } 676 | cfg.ClientCAs = roots 677 | cfg.ClientAuth = tls.VerifyClientCertIfGiven 678 | } 679 | var err error 680 | cfg.CipherSuites, err = tlsutil.ParseCipherList(ciphers) 681 | if err != nil { 682 | return nil, err 683 | } 684 | cfg.CurvePreferences, err = tlsutil.ParseCurveList(curves) 685 | if err != nil { 686 | return nil, err 687 | } 688 | if h2 { 689 | cfg.NextProtos = []string{"h2", "http/1.1", "acme-tls/1"} 690 | } else { 691 | cfg.NextProtos = []string{"http/1.1", "acme-tls/1"} 692 | } 693 | cfg.MinVersion = minVer 694 | cfg.MaxVersion = maxVer 695 | return cfg, nil 696 | } 697 | 698 | func list_ciphers() { 699 | for _, cipher := range tls.CipherSuites() { 700 | fmt.Println(cipher.Name) 701 | } 702 | } 703 | 704 | func list_curves() { 705 | for _, curve := range tlsutil.Curves() { 706 | fmt.Println(curve.String()) 707 | } 708 | } 709 | 710 | func passwd(filename string, cost int, args ...string) error { 711 | var ( 712 | username, password, password2 string 713 | err error 714 | ) 715 | 716 | if len(args) > 0 { 717 | username = args[0] 718 | } else { 719 | username, err = prompt("Enter username: ", false) 720 | if err != nil { 721 | return fmt.Errorf("can't get username: %w", err) 722 | } 723 | } 724 | 725 | if len(args) > 1 { 726 | password = args[1] 727 | } else { 728 | password, err = prompt("Enter password: ", true) 729 | if err != nil { 730 | return fmt.Errorf("can't get password: %w", err) 731 | } 732 | password2, err = prompt("Repeat password: ", true) 733 | if err != nil { 734 | return fmt.Errorf("can't get password (repeat): %w", err) 735 | } 736 | if password != password2 { 737 | return fmt.Errorf("passwords do not match") 738 | } 739 | } 740 | 741 | hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) 742 | if err != nil { 743 | return fmt.Errorf("can't generate password hash: %w", err) 744 | } 745 | 746 | f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 747 | if err != nil { 748 | return fmt.Errorf("can't open file: %w", err) 749 | } 750 | defer f.Close() 751 | 752 | _, err = f.WriteString(fmt.Sprintf("%s:%s\n", username, hash)) 753 | if err != nil { 754 | return fmt.Errorf("can't write to file: %w", err) 755 | } 756 | 757 | return nil 758 | } 759 | 760 | func hmacSign(args ...string) error { 761 | if len(args) != 3 { 762 | fmt.Fprintln(os.Stderr, "Usage:") 763 | fmt.Fprintln(os.Stderr, "") 764 | fmt.Fprintln(os.Stderr, "dumbproxy -hmac-sign ") 765 | fmt.Fprintln(os.Stderr, "") 766 | return errors.New("bad command line arguments") 767 | } 768 | 769 | secret, err := hex.DecodeString(args[0]) 770 | if err != nil { 771 | return fmt.Errorf("unable to hex-decode HMAC secret: %w", err) 772 | } 773 | 774 | validity, err := time.ParseDuration(args[2]) 775 | if err != nil { 776 | return fmt.Errorf("unable to parse validity duration: %w", err) 777 | } 778 | 779 | expire := time.Now().Add(validity).Unix() 780 | mac := auth.CalculateHMACSignature(secret, args[1], expire) 781 | token := auth.HMACToken{ 782 | Expire: expire, 783 | } 784 | copy(token.Signature[:], mac) 785 | 786 | var resBuf bytes.Buffer 787 | enc := base64.NewEncoder(base64.RawURLEncoding, &resBuf) 788 | if err := binary.Write(enc, binary.BigEndian, &token); err != nil { 789 | return fmt.Errorf("token encoding failed: %w", err) 790 | } 791 | enc.Close() 792 | 793 | fmt.Println("Username:", args[1]) 794 | fmt.Println("Password:", resBuf.String()) 795 | return nil 796 | } 797 | 798 | func hmacGenKey(args ...string) error { 799 | buf := make([]byte, auth.HMACSignatureSize) 800 | if _, err := rand.Read(buf); err != nil { 801 | return fmt.Errorf("CSPRNG failure: %w", err) 802 | } 803 | fmt.Println(hex.EncodeToString(buf)) 804 | return nil 805 | } 806 | 807 | func prompt(prompt string, secure bool) (string, error) { 808 | var input string 809 | fmt.Print(prompt) 810 | 811 | if secure { 812 | b, err := terminal.ReadPassword(int(os.Stdin.Fd())) 813 | if err != nil { 814 | return "", err 815 | } 816 | input = string(b) 817 | fmt.Println() 818 | } else { 819 | fmt.Scanln(&input) 820 | } 821 | return input, nil 822 | } 823 | 824 | func readConfig(filename string) error { 825 | f, err := os.Open(filename) 826 | if err != nil { 827 | return fmt.Errorf("unable to open config file %q: %w", filename, err) 828 | } 829 | defer f.Close() 830 | r := csv.NewReader(f) 831 | r.Comma = ' ' 832 | r.Comment = '#' 833 | r.FieldsPerRecord = -1 834 | r.TrimLeadingSpace = true 835 | r.ReuseRecord = true 836 | for { 837 | record, err := r.Read() 838 | if err == io.EOF { 839 | break 840 | } 841 | if err != nil { 842 | return fmt.Errorf("configuration file parsing failed: %w", err) 843 | } 844 | switch len(record) { 845 | case 0: 846 | continue 847 | case 1: 848 | if err := flag.Set(record[0], "true"); err != nil { 849 | line, _ := r.FieldPos(0) 850 | return fmt.Errorf("error parsing config file %q at line %d: %w", filename, line, err) 851 | } 852 | case 2: 853 | if err := flag.Set(record[0], record[1]); err != nil { 854 | line, _ := r.FieldPos(0) 855 | return fmt.Errorf("error parsing config file %q at line %d: %w", filename, line, err) 856 | } 857 | default: 858 | unified := strings.Join(record[1:], " ") 859 | if err := flag.Set(record[0], unified); err != nil { 860 | line, _ := r.FieldPos(0) 861 | return fmt.Errorf("error parsing config file %q at line %d: %w", filename, line, err) 862 | } 863 | } 864 | } 865 | return nil 866 | } 867 | 868 | func main() { 869 | os.Exit(run()) 870 | } 871 | -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: dumbproxy 2 | summary: Simple, scriptable, secure forward proxy. 3 | description: > 4 | Dumbest HTTP proxy ever. See documentation for details: 5 | https://github.com/SenseUnit/dumbproxy/blob/master/README.md 6 | 7 | confinement: strict 8 | base: core22 9 | adopt-info: dumbproxy 10 | 11 | parts: 12 | dumbproxy: 13 | plugin: go 14 | build-snaps: [go/latest/stable] 15 | build-packages: 16 | - make 17 | - git-core 18 | source: https://github.com/SenseUnit/dumbproxy 19 | source-type: git 20 | override-pull: | 21 | craftctl default 22 | craftctl set version="$(git describe --tags --always --match=v*.*.* | sed 's/v//')" 23 | override-build: 24 | make && 25 | cp bin/dumbproxy "$SNAPCRAFT_PART_INSTALL" 26 | stage: 27 | - dumbproxy 28 | 29 | apps: 30 | dumbproxy: 31 | command: dumbproxy 32 | plugs: 33 | - network 34 | - network-bind 35 | -------------------------------------------------------------------------------- /tlsutil/util.go: -------------------------------------------------------------------------------- 1 | package tlsutil 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "net/url" 8 | "os" 9 | "strings" 10 | ) 11 | 12 | func ExpectPeerName(name string, roots *x509.CertPool) func(cs tls.ConnectionState) error { 13 | return func(cs tls.ConnectionState) error { 14 | opts := x509.VerifyOptions{ 15 | Roots: roots, 16 | DNSName: name, 17 | Intermediates: x509.NewCertPool(), 18 | } 19 | for _, cert := range cs.PeerCertificates[1:] { 20 | opts.Intermediates.AddCert(cert) 21 | } 22 | _, err := cs.PeerCertificates[0].Verify(opts) 23 | return err 24 | } 25 | } 26 | 27 | func LoadCAfile(filename string) (*x509.CertPool, error) { 28 | roots := x509.NewCertPool() 29 | pem, err := os.ReadFile(filename) 30 | if err != nil { 31 | return nil, fmt.Errorf("unable to load CA PEM from file %q: %w", filename, err) 32 | } 33 | if ok := roots.AppendCertsFromPEM(pem); !ok { 34 | return nil, fmt.Errorf("no certificates were read from CA file %q", filename) 35 | } 36 | return roots, nil 37 | } 38 | 39 | var ( 40 | cipherNameToID map[string]uint16 41 | curveNameToID map[string]tls.CurveID 42 | fullCurveList = []tls.CurveID{ 43 | tls.CurveP256, 44 | tls.CurveP384, 45 | tls.CurveP521, 46 | tls.X25519, 47 | tls.X25519MLKEM768, 48 | } 49 | ) 50 | 51 | func Curves() []tls.CurveID { 52 | res := make([]tls.CurveID, len(fullCurveList)) 53 | copy(res, fullCurveList) 54 | return res 55 | } 56 | 57 | func init() { 58 | cipherNameToID = make(map[string]uint16) 59 | for _, cipher := range tls.CipherSuites() { 60 | cipherNameToID[cipher.Name] = cipher.ID 61 | } 62 | curveNameToID = make(map[string]tls.CurveID) 63 | for _, curve := range fullCurveList { 64 | curveNameToID[curve.String()] = curve 65 | } 66 | } 67 | 68 | func ParseCipherList(ciphers string) ([]uint16, error) { 69 | if ciphers == "" { 70 | return nil, nil 71 | } 72 | 73 | cipherNameList := strings.Split(ciphers, ":") 74 | cipherIDList := make([]uint16, 0, len(cipherNameList)) 75 | 76 | for _, name := range cipherNameList { 77 | id, ok := cipherNameToID[name] 78 | if !ok { 79 | return nil, fmt.Errorf("unknown cipher %q", name) 80 | } 81 | cipherIDList = append(cipherIDList, id) 82 | } 83 | 84 | return cipherIDList, nil 85 | } 86 | 87 | func ParseCurveList(curves string) ([]tls.CurveID, error) { 88 | if curves == "" { 89 | return nil, nil 90 | } 91 | 92 | curveNameList := strings.Split(curves, ":") 93 | curveIDList := make([]tls.CurveID, 0, len(curveNameList)) 94 | 95 | for _, name := range curveNameList { 96 | id, ok := curveNameToID[name] 97 | if !ok { 98 | return nil, fmt.Errorf("unknown curve %q", name) 99 | } 100 | curveIDList = append(curveIDList, id) 101 | } 102 | 103 | return curveIDList, nil 104 | } 105 | 106 | func ParseVersion(s string) (uint16, error) { 107 | var ver uint16 108 | switch strings.ToUpper(s) { 109 | case "TLS10": 110 | ver = tls.VersionTLS10 111 | case "TLS11": 112 | ver = tls.VersionTLS11 113 | case "TLS12": 114 | ver = tls.VersionTLS12 115 | case "TLS13": 116 | ver = tls.VersionTLS13 117 | case "TLS1.0": 118 | ver = tls.VersionTLS10 119 | case "TLS1.1": 120 | ver = tls.VersionTLS11 121 | case "TLS1.2": 122 | ver = tls.VersionTLS12 123 | case "TLS1.3": 124 | ver = tls.VersionTLS13 125 | case "10": 126 | ver = tls.VersionTLS10 127 | case "11": 128 | ver = tls.VersionTLS11 129 | case "12": 130 | ver = tls.VersionTLS12 131 | case "13": 132 | ver = tls.VersionTLS13 133 | case "1.0": 134 | ver = tls.VersionTLS10 135 | case "1.1": 136 | ver = tls.VersionTLS11 137 | case "1.2": 138 | ver = tls.VersionTLS12 139 | case "1.3": 140 | ver = tls.VersionTLS13 141 | case "": 142 | default: 143 | return 0, fmt.Errorf("unknown TLS version %q", s) 144 | } 145 | return ver, nil 146 | } 147 | 148 | func FormatVersion(v uint16) string { 149 | switch v { 150 | case tls.VersionTLS10: 151 | return "TLS10" 152 | case tls.VersionTLS11: 153 | return "TLS11" 154 | case tls.VersionTLS12: 155 | return "TLS12" 156 | case tls.VersionTLS13: 157 | return "TLS13" 158 | default: 159 | return fmt.Sprintf("%#04x", v) 160 | } 161 | } 162 | 163 | func TLSConfigFromURL(u *url.URL) (*tls.Config, error) { 164 | host := u.Hostname() 165 | params, err := url.ParseQuery(u.RawQuery) 166 | if err != nil { 167 | return nil, fmt.Errorf("unable to parse query string of proxy specification URL %q: %w", u.String(), err) 168 | } 169 | tlsConfig := &tls.Config{ 170 | ServerName: host, 171 | } 172 | if params.Has("cafile") { 173 | roots, err := LoadCAfile(params.Get("cafile")) 174 | if err != nil { 175 | return nil, err 176 | } 177 | tlsConfig.RootCAs = roots 178 | } 179 | if params.Has("sni") { 180 | tlsConfig.ServerName = params.Get("sni") 181 | tlsConfig.InsecureSkipVerify = true 182 | tlsConfig.VerifyConnection = ExpectPeerName(host, tlsConfig.RootCAs) 183 | } 184 | if params.Has("peername") { 185 | tlsConfig.InsecureSkipVerify = true 186 | tlsConfig.VerifyConnection = ExpectPeerName(params.Get("peername"), tlsConfig.RootCAs) 187 | } 188 | if params.Has("cert") { 189 | cert, err := tls.LoadX509KeyPair(params.Get("cert"), params.Get("key")) 190 | if err != nil { 191 | return nil, err 192 | } 193 | tlsConfig.Certificates = []tls.Certificate{cert} 194 | } 195 | if params.Has("ciphers") { 196 | cipherList, err := ParseCipherList(params.Get("ciphers")) 197 | if err != nil { 198 | return nil, err 199 | } 200 | tlsConfig.CipherSuites = cipherList 201 | } 202 | if params.Has("curves") { 203 | curveList, err := ParseCurveList(params.Get("curves")) 204 | if err != nil { 205 | return nil, err 206 | } 207 | tlsConfig.CurvePreferences = curveList 208 | } 209 | if params.Has("min-tls-version") { 210 | ver, err := ParseVersion(params.Get("min-tls-version")) 211 | if err != nil { 212 | return nil, err 213 | } 214 | tlsConfig.MinVersion = ver 215 | } 216 | if params.Has("max-tls-version") { 217 | ver, err := ParseVersion(params.Get("max-tls-version")) 218 | if err != nil { 219 | return nil, err 220 | } 221 | tlsConfig.MaxVersion = ver 222 | } 223 | return tlsConfig, nil 224 | } 225 | --------------------------------------------------------------------------------