├── .dockerignore ├── .github ├── stale.yml └── workflows │ ├── build.yml │ └── docker-ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── clock └── clock.go ├── dialer ├── fixed.go ├── resolver.go └── upstream.go ├── go.mod ├── go.sum ├── handler ├── handler.go └── socks.go ├── log ├── condlog.go └── logwriter.go ├── main.go ├── seclient ├── csrand.go ├── hash.go ├── jar.go ├── messages.go ├── randutils.go └── seclient.go └── snapcraft.yaml /.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 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - 18 | name: Find Git Tag 19 | id: tagger 20 | uses: jimschubert/query-tag-action@v2 21 | with: 22 | include: 'v*' 23 | exclude: '*-rc*' 24 | commit-ish: 'HEAD' 25 | skip-unshallow: 'true' 26 | abbrev: 7 27 | - name: Docker meta 28 | id: meta 29 | uses: docker/metadata-action@v5 30 | with: 31 | # list of Docker images to use as base name for tags 32 | images: | 33 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }} 34 | # generate Docker tags based on the following events/attributes 35 | tags: | 36 | type=semver,pattern={{version}} 37 | type=semver,pattern={{major}}.{{minor}} 38 | type=semver,pattern={{major}} 39 | type=sha 40 | - 41 | name: Set up QEMU 42 | uses: docker/setup-qemu-action@v3 43 | - 44 | name: Set up Docker Buildx 45 | uses: docker/setup-buildx-action@v3 46 | - 47 | name: Login to DockerHub 48 | uses: docker/login-action@v3 49 | with: 50 | username: ${{ secrets.DOCKERHUB_USERNAME }} 51 | password: ${{ secrets.DOCKERHUB_TOKEN }} 52 | - 53 | name: Build and push 54 | id: docker_build 55 | uses: docker/build-push-action@v5 56 | with: 57 | context: . 58 | platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v7 59 | push: true 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | build-args: 'GIT_DESC=${{steps.tagger.outputs.tag}}' 63 | -------------------------------------------------------------------------------- /.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 | opera-proxy 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1 AS build 2 | 3 | ARG GIT_DESC=undefined 4 | 5 | WORKDIR /go/src/github.com/Snawoot/opera-proxy 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 | ADD https://curl.haxx.se/ca/cacert.pem /certs.crt 10 | RUN chmod 0644 /certs.crt 11 | 12 | FROM scratch AS arrange 13 | COPY --from=build /go/src/github.com/Snawoot/opera-proxy/opera-proxy / 14 | COPY --from=build /certs.crt /etc/ssl/certs/ca-certificates.crt 15 | 16 | FROM scratch 17 | COPY --from=arrange / / 18 | USER 9999:9999 19 | EXPOSE 18080/tcp 20 | ENTRYPOINT ["/opera-proxy", "-bind-address", "0.0.0.0:18080"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 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 = opera-proxy 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-linux-mips bin-linux-mipsle bin-linux-mips64 bin-linux-mips64le \ 18 | bin-freebsd-amd64 bin-freebsd-386 bin-freebsd-arm bin-freebsd-arm64 \ 19 | bin-netbsd-amd64 bin-netbsd-386 bin-netbsd-arm bin-netbsd-arm64 \ 20 | bin-openbsd-amd64 bin-openbsd-386 bin-openbsd-arm bin-openbsd-arm64 \ 21 | bin-darwin-amd64 bin-darwin-arm64 \ 22 | bin-windows-amd64 bin-windows-386 bin-windows-arm 23 | 24 | allplus: all \ 25 | bin-android-arm bin-android-arm64 26 | 27 | bin-native: $(OUTSUFFIX) 28 | bin-linux-amd64: $(OUTSUFFIX).linux-amd64 29 | bin-linux-386: $(OUTSUFFIX).linux-386 30 | bin-linux-arm: $(OUTSUFFIX).linux-arm 31 | bin-linux-arm64: $(OUTSUFFIX).linux-arm64 32 | bin-linux-mips: $(OUTSUFFIX).linux-mips 33 | bin-linux-mipsle: $(OUTSUFFIX).linux-mipsle 34 | bin-linux-mips64: $(OUTSUFFIX).linux-mips64 35 | bin-linux-mips64le: $(OUTSUFFIX).linux-mips64le 36 | bin-freebsd-amd64: $(OUTSUFFIX).freebsd-amd64 37 | bin-freebsd-386: $(OUTSUFFIX).freebsd-386 38 | bin-freebsd-arm: $(OUTSUFFIX).freebsd-arm 39 | bin-freebsd-arm64: $(OUTSUFFIX).freebsd-arm64 40 | bin-netbsd-amd64: $(OUTSUFFIX).netbsd-amd64 41 | bin-netbsd-386: $(OUTSUFFIX).netbsd-386 42 | bin-netbsd-arm: $(OUTSUFFIX).netbsd-arm 43 | bin-netbsd-arm64: $(OUTSUFFIX).netbsd-arm64 44 | bin-openbsd-amd64: $(OUTSUFFIX).openbsd-amd64 45 | bin-openbsd-386: $(OUTSUFFIX).openbsd-386 46 | bin-openbsd-arm: $(OUTSUFFIX).openbsd-arm 47 | bin-openbsd-arm64: $(OUTSUFFIX).openbsd-arm64 48 | bin-darwin-amd64: $(OUTSUFFIX).darwin-amd64 49 | bin-darwin-arm64: $(OUTSUFFIX).darwin-arm64 50 | bin-windows-amd64: $(OUTSUFFIX).windows-amd64.exe 51 | bin-windows-386: $(OUTSUFFIX).windows-386.exe 52 | bin-windows-arm: $(OUTSUFFIX).windows-arm.exe 53 | bin-android-arm: $(OUTSUFFIX).android-arm 54 | bin-android-arm64: $(OUTSUFFIX).android-arm64 55 | 56 | $(OUTSUFFIX): $(src) 57 | $(GO) build $(LDFLAGS_NATIVE) -o $@ 58 | 59 | $(OUTSUFFIX).linux-amd64: $(src) 60 | CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 61 | 62 | $(OUTSUFFIX).linux-386: $(src) 63 | CGO_ENABLED=0 GOOS=linux GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 64 | 65 | $(OUTSUFFIX).linux-arm: $(src) 66 | CGO_ENABLED=0 GOOS=linux GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 67 | 68 | $(OUTSUFFIX).linux-arm64: $(src) 69 | CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 70 | 71 | $(OUTSUFFIX).linux-mips: $(src) 72 | CGO_ENABLED=0 GOOS=linux GOARCH=mips GOMIPS=softfloat $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 73 | 74 | $(OUTSUFFIX).linux-mips64: $(src) 75 | CGO_ENABLED=0 GOOS=linux GOARCH=mips64 GOMIPS=softfloat $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 76 | 77 | $(OUTSUFFIX).linux-mipsle: $(src) 78 | CGO_ENABLED=0 GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 79 | 80 | $(OUTSUFFIX).linux-mips64le: $(src) 81 | CGO_ENABLED=0 GOOS=linux GOARCH=mips64le GOMIPS=softfloat $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 82 | 83 | $(OUTSUFFIX).freebsd-amd64: $(src) 84 | CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 85 | 86 | $(OUTSUFFIX).freebsd-386: $(src) 87 | CGO_ENABLED=0 GOOS=freebsd GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 88 | 89 | $(OUTSUFFIX).freebsd-arm: $(src) 90 | CGO_ENABLED=0 GOOS=freebsd GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 91 | 92 | $(OUTSUFFIX).freebsd-arm64: $(src) 93 | CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 94 | 95 | $(OUTSUFFIX).netbsd-amd64: $(src) 96 | CGO_ENABLED=0 GOOS=netbsd GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 97 | 98 | $(OUTSUFFIX).netbsd-386: $(src) 99 | CGO_ENABLED=0 GOOS=netbsd GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 100 | 101 | $(OUTSUFFIX).netbsd-arm: $(src) 102 | CGO_ENABLED=0 GOOS=netbsd GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 103 | 104 | $(OUTSUFFIX).netbsd-arm64: $(src) 105 | CGO_ENABLED=0 GOOS=netbsd GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 106 | 107 | $(OUTSUFFIX).openbsd-amd64: $(src) 108 | CGO_ENABLED=0 GOOS=openbsd GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 109 | 110 | $(OUTSUFFIX).openbsd-386: $(src) 111 | CGO_ENABLED=0 GOOS=openbsd GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 112 | 113 | $(OUTSUFFIX).openbsd-arm: $(src) 114 | CGO_ENABLED=0 GOOS=openbsd GOARCH=arm $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 115 | 116 | $(OUTSUFFIX).openbsd-arm64: $(src) 117 | CGO_ENABLED=0 GOOS=openbsd GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 118 | 119 | $(OUTSUFFIX).darwin-amd64: $(src) 120 | CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 121 | 122 | $(OUTSUFFIX).darwin-arm64: $(src) 123 | CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 124 | 125 | $(OUTSUFFIX).windows-amd64.exe: $(src) 126 | CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 127 | 128 | $(OUTSUFFIX).windows-386.exe: $(src) 129 | CGO_ENABLED=0 GOOS=windows GOARCH=386 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 130 | 131 | $(OUTSUFFIX).windows-arm.exe: $(src) 132 | CGO_ENABLED=0 GOOS=windows GOARCH=arm GOARM=7 $(GO) build $(BUILDOPTS) $(LDFLAGS) -o $@ 133 | 134 | $(OUTSUFFIX).android-arm: $(src) 135 | CC=$(NDK_CC_ARM) CGO_ENABLED=1 GOOS=android GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS_NATIVE) -o $@ 136 | 137 | $(OUTSUFFIX).android-arm64: $(src) 138 | CC=$(NDK_CC_ARM64) CGO_ENABLED=1 GOOS=android GOARCH=arm64 $(GO) build $(LDFLAGS_NATIVE) -o $@ 139 | 140 | clean: 141 | rm -f bin/* 142 | 143 | fmt: 144 | $(GO) fmt ./... 145 | 146 | run: 147 | $(GO) run $(LDFLAGS) . 148 | 149 | install: 150 | $(GO) install $(LDFLAGS_NATIVE) . 151 | 152 | .PHONY: clean all native fmt install \ 153 | bin-native \ 154 | bin-linux-amd64 \ 155 | bin-linux-386 \ 156 | bin-linux-arm \ 157 | bin-linux-arm64 \ 158 | bin-linux-mips \ 159 | bin-linux-mipsle \ 160 | bin-linux-mips64 \ 161 | bin-linux-mips64le \ 162 | bin-freebsd-amd64 \ 163 | bin-freebsd-386 \ 164 | bin-freebsd-arm \ 165 | bin-freebsd-arm64 \ 166 | bin-netbsd-amd64 \ 167 | bin-netbsd-386 \ 168 | bin-netbsd-arm \ 169 | bin-netbsd-arm64 \ 170 | bin-openbsd-amd64 \ 171 | bin-openbsd-386 \ 172 | bin-openbsd-arm \ 173 | bin-openbsd-arm64 \ 174 | bin-darwin-amd64 \ 175 | bin-darwin-arm64 \ 176 | bin-windows-amd64 \ 177 | bin-windows-386 \ 178 | bin-windows-arm \ 179 | bin-android-arm \ 180 | bin-android-arm64 181 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | opera-proxy 2 | =========== 3 | 4 | [![opera-proxy](https://snapcraft.io//opera-proxy/badge.svg)](https://snapcraft.io/opera-proxy) 5 | 6 | 7 | 8 | Standalone Opera VPN client. Younger brother of [hola-proxy](https://github.com/Snawoot/hola-proxy/). 9 | 10 | Just run it and it'll start a plain HTTP proxy server forwarding traffic through "Opera VPN" proxies of your choice. 11 | By default the application listens on 127.0.0.1:18080. 12 | 13 | ## Features 14 | 15 | * Cross-platform (Windows/Mac OS/Linux/Android (via shell)/\*BSD) 16 | * Uses TLS for secure communication with upstream proxies 17 | * Zero configuration 18 | * Simple and straightforward 19 | 20 | ## Installation 21 | 22 | #### Binaries 23 | 24 | Pre-built binaries are available [here](https://github.com/Snawoot/opera-proxy/releases/latest). 25 | 26 | #### Build from source 27 | 28 | Alternatively, you may install opera-proxy from source. Run the following within the source directory: 29 | 30 | ``` 31 | make install 32 | ``` 33 | 34 | #### Docker 35 | 36 | A docker image is available as well. Here is an example of running opera-proxy as a background service: 37 | 38 | ```sh 39 | docker run -d \ 40 | --security-opt no-new-privileges \ 41 | -p 127.0.0.1:18080:18080 \ 42 | --restart unless-stopped \ 43 | --name opera-proxy \ 44 | yarmak/opera-proxy 45 | ``` 46 | 47 | #### Snap Store 48 | 49 | [![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/opera-proxy) 50 | 51 | ```bash 52 | sudo snap install opera-proxy 53 | ``` 54 | 55 | ## Usage 56 | 57 | List available countries: 58 | 59 | ``` 60 | $ ./opera-proxy -list-countries 61 | country code,country name 62 | EU,Europe 63 | AS,Asia 64 | AM,Americas 65 | ``` 66 | 67 | Run proxy via country of your choice: 68 | 69 | ``` 70 | $ ./opera-proxy -country EU 71 | ``` 72 | 73 | Also it is possible to export proxy addresses and credentials: 74 | 75 | ``` 76 | $ ./opera-proxy -country EU -list-proxies 77 | Proxy login: ABCF206831D0BDC0C8C3AE5283F99EF6726444B3 78 | Proxy password: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb3VudHJ5IjoidWEiLCJpYXQiOjE2MTY4MDkxMTIsImlkIjoic2UwMzE2LTYweGY3aTBxMGhoOWQ1MWF0emd0IiwiaXAiOiI3Ny4xMTEuMjQ3LjE3IiwidnBuX2xvZ2luIjoiSzJYdmJ5R0tUb3JLbkpOaDNtUGlGSTJvSytyVTA5bXMraGt2c2UwRWJBcz1Ac2UwMzE2LmJlc3QudnBuIn0.ZhqqzVyKmc3hZG6VVwWfn4nvVIPuZvaEfOLXfTppyvo 79 | Proxy-Authorization: Basic QUJDRjIwNjgzMUQwQkRDMEM4QzNBRTUyODNGOTlFRjY3MjY0NDRCMzpleUpoYkdjaU9pSklVekkxTmlJc0luUjVjQ0k2SWtwWFZDSjkuZXlKamIzVnVkSEo1SWpvaWRXRWlMQ0pwWVhRaU9qRTJNVFk0TURreE1USXNJbWxrSWpvaWMyVXdNekUyTFRZd2VHWTNhVEJ4TUdob09XUTFNV0YwZW1kMElpd2lhWEFpT2lJM055NHhNVEV1TWpRM0xqRTNJaXdpZG5CdVgyeHZaMmx1SWpvaVN6SllkbUo1UjB0VWIzSkxia3BPYUROdFVHbEdTVEp2U3l0eVZUQTViWE1yYUd0MmMyVXdSV0pCY3oxQWMyVXdNekUyTG1KbGMzUXVkbkJ1SW4wLlpocXF6VnlLbWMzaFpHNlZWd1dmbjRudlZJUHVadmFFZk9MWGZUcHB5dm8= 80 | 81 | host,ip_address,port 82 | eu0.sec-tunnel.com,77.111.244.26,443 83 | eu1.sec-tunnel.com,77.111.244.67,443 84 | eu2.sec-tunnel.com,77.111.247.51,443 85 | eu3.sec-tunnel.com,77.111.244.22,443 86 | ``` 87 | 88 | ## List of arguments 89 | 90 | | Argument | Type | Description | 91 | | -------- | ---- | ----------- | 92 | | api-address | String | override IP address of api2.sec-tunnel.com | 93 | | api-client-type | String | client type reported to SurfEasy API (default "se0316") | 94 | | api-client-version | String | client version reported to SurfEasy API (default "Stable 114.0.5282.21") | 95 | | api-login | String | SurfEasy API login (default "se0316") | 96 | | api-password | String | SurfEasy API password (default "SILrMEPBmJuhomxWkfm3JalqHX2Eheg1YhlEZiMh8II") | 97 | | api-user-agent | String | user agent reported to SurfEasy API (default "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0") | 98 | | bind-address | String | proxy listen address (default "127.0.0.1:18080") | 99 | | bootstrap-dns | String | Comma-separated list of DNS/DoH/DoT/DoQ resolvers for initial discovery of SurfEasy API address. See https://github.com/ameshkov/dnslookup/ for upstream DNS URL format. Examples: `https://1.1.1.1/dns-query`, `quic://dns.adguard.com` (default `https://1.1.1.3/dns-query,https://8.8.8.8/dns-query,https://dns.google/dns-query,https://security.cloudflare-dns.com/dns-query,https://fidelity.vm-0.com/q,https://wikimedia-dns.org/dns-query,https://dns.adguard-dns.com/dns-query,https://dns.quad9.net/dns-query,https://doh.cleanbrowsing.org/doh/adult-filter/`) | 100 | | cafile | String | use custom CA certificate bundle file | 101 | | certchain-workaround | Boolean | add bundled cross-signed intermediate cert to certchain to make it check out on old systems (default true) | 102 | | country | String | desired proxy location (default "EU") | 103 | | fake-SNI | String | domain name to use as SNI in communications with servers | 104 | | init-retries | Number | number of attempts for initialization steps, zero for unlimited retry | 105 | | init-retry-interval | Duration | delay between initialization retries (default 5s) | 106 | | list-countries | - | list available countries and exit | 107 | | list-proxies | - | output proxy list and exit | 108 | | override-proxy-address | string | use fixed proxy address instead of server address returned by SurfEasy API | 109 | | proxy | String | sets base proxy to use for all dial-outs. Format: `://[login:password@]host[:port]` Examples: `http://user:password@192.168.1.1:3128`, `socks5://10.0.0.1:1080` | 110 | | refresh | Duration | login refresh interval (default 4h0m0s) | 111 | | refresh-retry | Duration | login refresh retry interval (default 5s) | 112 | | timeout | Duration | timeout for network operations (default 10s) | 113 | | verbosity | Number | logging verbosity (10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical) (default 20) | 114 | | version | - | show program version and exit | 115 | | socks-mode | - | listen for SOCKS requests instead of HTTP | 116 | 117 | ## See also 118 | 119 | * [Project wiki](https://github.com/Snawoot/opera-proxy/wiki) 120 | * [Community in Telegram](https://t.me/alternative_proxy) 121 | -------------------------------------------------------------------------------- /clock/clock.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | const WALLCLOCK_PRECISION = 1 * time.Second 9 | 10 | func AfterWallClock(d time.Duration) <-chan time.Time { 11 | ch := make(chan time.Time, 1) 12 | deadline := time.Now().Add(d).Truncate(0) 13 | after_ch := time.After(d) 14 | ticker := time.NewTicker(WALLCLOCK_PRECISION) 15 | go func() { 16 | var t time.Time 17 | defer ticker.Stop() 18 | for { 19 | select { 20 | case t = <-after_ch: 21 | ch <- t 22 | return 23 | case t = <-ticker.C: 24 | if t.After(deadline) { 25 | ch <- t 26 | return 27 | } 28 | } 29 | } 30 | }() 31 | return ch 32 | } 33 | 34 | func RunTicker(ctx context.Context, interval, retryInterval time.Duration, cb func(context.Context) error) { 35 | go func() { 36 | var err error 37 | for { 38 | nextInterval := interval 39 | if err != nil { 40 | nextInterval = retryInterval 41 | } 42 | select { 43 | case <-ctx.Done(): 44 | return 45 | case <-AfterWallClock(nextInterval): 46 | err = cb(ctx) 47 | } 48 | } 49 | }() 50 | } 51 | -------------------------------------------------------------------------------- /dialer/fixed.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "net" 6 | ) 7 | 8 | type FixedDialer struct { 9 | fixedAddress string 10 | next ContextDialer 11 | } 12 | 13 | func NewFixedDialer(address string, next ContextDialer) *FixedDialer { 14 | return &FixedDialer{ 15 | fixedAddress: address, 16 | next: next, 17 | } 18 | } 19 | 20 | func (d *FixedDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 21 | _, port, err := net.SplitHostPort(address) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return d.next.DialContext(ctx, network, net.JoinHostPort(d.fixedAddress, port)) 27 | } 28 | 29 | func (d *FixedDialer) Dial(network, address string) (net.Conn, error) { 30 | return d.DialContext(context.Background(), network, address) 31 | } 32 | -------------------------------------------------------------------------------- /dialer/resolver.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/netip" 9 | "time" 10 | 11 | "github.com/AdguardTeam/dnsproxy/upstream" 12 | "github.com/hashicorp/go-multierror" 13 | ) 14 | 15 | type Resolver struct { 16 | resolvers upstream.ParallelResolver 17 | timeout time.Duration 18 | } 19 | 20 | func NewResolver(addresses []string, timeout time.Duration) (*Resolver, error) { 21 | resolvers := make([]upstream.Resolver, 0, len(addresses)) 22 | opts := &upstream.Options{ 23 | Timeout: timeout, 24 | } 25 | for _, addr := range addresses { 26 | u, err := upstream.AddressToUpstream(addr, opts) 27 | if err != nil { 28 | return nil, fmt.Errorf("unable to construct upstream resolver from string %q: %w", 29 | addr, err) 30 | } 31 | resolvers = append(resolvers, &upstream.UpstreamResolver{Upstream: u}) 32 | } 33 | return &Resolver{ 34 | resolvers: resolvers, 35 | timeout: timeout, 36 | }, nil 37 | } 38 | 39 | func (r *Resolver) LookupNetIP(ctx context.Context, network string, host string) (addrs []netip.Addr, err error) { 40 | return r.resolvers.LookupNetIP(ctx, network, host) 41 | } 42 | 43 | func (r *Resolver) Close() error { 44 | var res error 45 | for _, resolver := range r.resolvers { 46 | if closer, ok := resolver.(io.Closer); ok { 47 | if err := closer.Close(); err != nil { 48 | res = multierror.Append(res, err) 49 | } 50 | } 51 | } 52 | return res 53 | } 54 | 55 | type LookupNetIPer interface { 56 | LookupNetIP(context.Context, string, string) ([]netip.Addr, error) 57 | } 58 | 59 | type ResolvingDialer struct { 60 | lookup LookupNetIPer 61 | next ContextDialer 62 | } 63 | 64 | func NewResolvingDialer(lookup LookupNetIPer, next ContextDialer) *ResolvingDialer { 65 | return &ResolvingDialer{ 66 | lookup: lookup, 67 | next: next, 68 | } 69 | } 70 | 71 | func (d *ResolvingDialer) Dial(network, address string) (net.Conn, error) { 72 | return d.DialContext(context.Background(), network, address) 73 | } 74 | 75 | func (d *ResolvingDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 76 | host, port, err := net.SplitHostPort(address) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to extract host and port from %s: %w", address, err) 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 | resolved, err := d.lookup.LookupNetIP(ctx, resolveNetwork, host) 93 | if err != nil { 94 | return nil, fmt.Errorf("dial failed on address lookup: %w", err) 95 | } 96 | 97 | var conn net.Conn 98 | for _, ip := range resolved { 99 | conn, err = d.next.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) 100 | if err == nil { 101 | return conn, nil 102 | } 103 | } 104 | 105 | return nil, fmt.Errorf("failed to dial %s: %w", address, err) 106 | } 107 | -------------------------------------------------------------------------------- /dialer/upstream.go: -------------------------------------------------------------------------------- 1 | package dialer 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "crypto/tls" 8 | "crypto/x509" 9 | "encoding/base64" 10 | "encoding/pem" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "net" 15 | "net/http" 16 | "net/http/httputil" 17 | "net/url" 18 | "strings" 19 | ) 20 | 21 | const ( 22 | PROXY_CONNECT_METHOD = "CONNECT" 23 | PROXY_HOST_HEADER = "Host" 24 | PROXY_AUTHORIZATION_HEADER = "Proxy-Authorization" 25 | MISSING_CHAIN_CERT = `-----BEGIN CERTIFICATE----- 26 | MIID0zCCArugAwIBAgIQVmcdBOpPmUxvEIFHWdJ1lDANBgkqhkiG9w0BAQwFADB7 27 | MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD 28 | VQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE 29 | AwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTE5MDMxMjAwMDAwMFoXDTI4 30 | MTIzMTIzNTk1OVowgYgxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpOZXcgSmVyc2V5 31 | MRQwEgYDVQQHEwtKZXJzZXkgQ2l0eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBO 32 | ZXR3b3JrMS4wLAYDVQQDEyVVU0VSVHJ1c3QgRUNDIENlcnRpZmljYXRpb24gQXV0 33 | aG9yaXR5MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEGqxUWqn5aCPnetUkb1PGWthL 34 | q8bVttHmc3Gu3ZzWDGH926CJA7gFFOxXzu5dP+Ihs8731Ip54KODfi2X0GHE8Znc 35 | JZFjq38wo7Rw4sehM5zzvy5cU7Ffs30yf4o043l5o4HyMIHvMB8GA1UdIwQYMBaA 36 | FKARCiM+lvEH7OKvKe+CpX/QMKS0MB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1 37 | xmNjmjAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zARBgNVHSAECjAI 38 | MAYGBFUdIAAwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5j 39 | b20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQG 40 | CCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQEM 41 | BQADggEBABns652JLCALBIAdGN5CmXKZFjK9Dpx1WywV4ilAbe7/ctvbq5AfjJXy 42 | ij0IckKJUAfiORVsAYfZFhr1wHUrxeZWEQff2Ji8fJ8ZOd+LygBkc7xGEJuTI42+ 43 | FsMuCIKchjN0djsoTI0DQoWz4rIjQtUfenVqGtF8qmchxDM6OW1TyaLtYiKou+JV 44 | bJlsQ2uRl9EMC5MCHdK8aXdJ5htN978UeAOwproLtOGFfy/cQjutdAFI3tZs4RmY 45 | CV4Ks2dH/hzg1cEo70qLRDEmBDeNiXQ2Lu+lIg+DdEmSx/cQwgwp+7e9un/jX9Wf 46 | 8qn0dNW44bOwgeThpWOjzOoEeJBuv/c= 47 | -----END CERTIFICATE----- 48 | ` 49 | ) 50 | 51 | var missingLinkDER, _ = pem.Decode([]byte(MISSING_CHAIN_CERT)) 52 | var missingLink, _ = x509.ParseCertificate(missingLinkDER.Bytes) 53 | 54 | type stringCb = func() (string, error) 55 | 56 | type Dialer interface { 57 | Dial(network, address string) (net.Conn, error) 58 | } 59 | 60 | type ContextDialer interface { 61 | Dialer 62 | DialContext(ctx context.Context, network, address string) (net.Conn, error) 63 | } 64 | 65 | type ProxyDialer struct { 66 | address stringCb 67 | tlsServerName stringCb 68 | fakeSNI stringCb 69 | auth stringCb 70 | next ContextDialer 71 | intermediateWorkaround bool 72 | caPool *x509.CertPool 73 | } 74 | 75 | func NewProxyDialer(address, tlsServerName, fakeSNI, auth stringCb, intermediateWorkaround bool, caPool *x509.CertPool, nextDialer ContextDialer) *ProxyDialer { 76 | return &ProxyDialer{ 77 | address: address, 78 | tlsServerName: tlsServerName, 79 | fakeSNI: fakeSNI, 80 | auth: auth, 81 | next: nextDialer, 82 | intermediateWorkaround: intermediateWorkaround, 83 | caPool: caPool, 84 | } 85 | } 86 | 87 | func ProxyDialerFromURL(u *url.URL, next ContextDialer) (*ProxyDialer, error) { 88 | host := u.Hostname() 89 | port := u.Port() 90 | tlsServerName := "" 91 | var auth stringCb = nil 92 | 93 | switch strings.ToLower(u.Scheme) { 94 | case "http": 95 | if port == "" { 96 | port = "80" 97 | } 98 | case "https": 99 | if port == "" { 100 | port = "443" 101 | } 102 | tlsServerName = host 103 | default: 104 | return nil, errors.New("unsupported proxy type") 105 | } 106 | 107 | address := net.JoinHostPort(host, port) 108 | 109 | if u.User != nil { 110 | username := u.User.Username() 111 | password, _ := u.User.Password() 112 | auth = WrapStringToCb(BasicAuthHeader(username, password)) 113 | } 114 | return NewProxyDialer( 115 | WrapStringToCb(address), 116 | WrapStringToCb(tlsServerName), 117 | WrapStringToCb(tlsServerName), 118 | auth, 119 | false, 120 | nil, 121 | next), nil 122 | } 123 | 124 | func (d *ProxyDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { 125 | switch network { 126 | case "tcp", "tcp4", "tcp6": 127 | default: 128 | return nil, errors.New("bad network specified for DialContext: only tcp is supported") 129 | } 130 | 131 | uAddress, err := d.address() 132 | if err != nil { 133 | return nil, err 134 | } 135 | conn, err := d.next.DialContext(ctx, "tcp", uAddress) 136 | if err != nil { 137 | return nil, err 138 | } 139 | 140 | uTLSServerName, err := d.tlsServerName() 141 | if err != nil { 142 | return nil, err 143 | } 144 | fakeSNI, err := d.fakeSNI() 145 | if err != nil { 146 | return nil, err 147 | } 148 | if uTLSServerName != "" { 149 | // Custom cert verification logic: 150 | // DO NOT send SNI extension of TLS ClientHello 151 | // DO peer certificate verification against specified servername 152 | conn = tls.Client(conn, &tls.Config{ 153 | ServerName: fakeSNI, 154 | InsecureSkipVerify: true, 155 | VerifyConnection: func(cs tls.ConnectionState) error { 156 | opts := x509.VerifyOptions{ 157 | DNSName: uTLSServerName, 158 | Intermediates: x509.NewCertPool(), 159 | Roots: d.caPool, 160 | } 161 | waRequired := false 162 | for _, cert := range cs.PeerCertificates[1:] { 163 | opts.Intermediates.AddCert(cert) 164 | if d.intermediateWorkaround && !waRequired && 165 | bytes.Compare(cert.AuthorityKeyId, missingLink.SubjectKeyId) == 0 { 166 | waRequired = true 167 | } 168 | } 169 | if waRequired { 170 | opts.Intermediates.AddCert(missingLink) 171 | } 172 | _, err := cs.PeerCertificates[0].Verify(opts) 173 | return err 174 | }, 175 | }) 176 | } 177 | 178 | req := &http.Request{ 179 | Method: PROXY_CONNECT_METHOD, 180 | Proto: "HTTP/1.1", 181 | ProtoMajor: 1, 182 | ProtoMinor: 1, 183 | RequestURI: address, 184 | Host: address, 185 | Header: http.Header{ 186 | PROXY_HOST_HEADER: []string{address}, 187 | }, 188 | } 189 | 190 | if d.auth != nil { 191 | auth, err := d.auth() 192 | if err != nil { 193 | return nil, err 194 | } 195 | req.Header.Set(PROXY_AUTHORIZATION_HEADER, auth) 196 | } 197 | 198 | rawreq, err := httputil.DumpRequest(req, false) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | _, err = conn.Write(rawreq) 204 | if err != nil { 205 | return nil, err 206 | } 207 | 208 | proxyResp, err := readResponse(conn, req) 209 | if err != nil { 210 | return nil, err 211 | } 212 | 213 | if proxyResp.StatusCode != http.StatusOK { 214 | return nil, errors.New(fmt.Sprintf("bad response from upstream proxy server: %s", proxyResp.Status)) 215 | } 216 | 217 | return conn, nil 218 | } 219 | 220 | func (d *ProxyDialer) Dial(network, address string) (net.Conn, error) { 221 | return d.DialContext(context.Background(), network, address) 222 | } 223 | 224 | func readResponse(r io.Reader, req *http.Request) (*http.Response, error) { 225 | endOfResponse := []byte("\r\n\r\n") 226 | buf := &bytes.Buffer{} 227 | b := make([]byte, 1) 228 | for { 229 | n, err := r.Read(b) 230 | if n < 1 && err == nil { 231 | continue 232 | } 233 | 234 | buf.Write(b) 235 | sl := buf.Bytes() 236 | if len(sl) < len(endOfResponse) { 237 | continue 238 | } 239 | 240 | if bytes.Equal(sl[len(sl)-4:], endOfResponse) { 241 | break 242 | } 243 | 244 | if err != nil { 245 | return nil, err 246 | } 247 | } 248 | return http.ReadResponse(bufio.NewReader(buf), req) 249 | } 250 | 251 | func BasicAuthHeader(login, password string) string { 252 | return "Basic " + base64.StdEncoding.EncodeToString( 253 | []byte(login+":"+password)) 254 | } 255 | 256 | func WrapStringToCb(s string) func() (string, error) { 257 | return func() (string, error) { 258 | return s, nil 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Snawoot/opera-proxy 2 | 3 | go 1.24.1 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/AdguardTeam/dnsproxy v0.75.2 9 | github.com/Snawoot/go-http-digest-auth-client v1.1.3 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 11 | github.com/hashicorp/go-multierror v1.1.1 12 | golang.org/x/net v0.39.0 13 | ) 14 | 15 | require ( 16 | github.com/AdguardTeam/golibs v0.32.7 // indirect 17 | github.com/ameshkov/dnscrypt/v2 v2.4.0 // indirect 18 | github.com/ameshkov/dnsstamps v1.0.3 // indirect 19 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 20 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect 21 | github.com/hashicorp/errwrap v1.1.0 // indirect 22 | github.com/miekg/dns v1.1.65 // indirect 23 | github.com/onsi/ginkgo/v2 v2.23.4 // indirect 24 | github.com/quic-go/qpack v0.5.1 // indirect 25 | github.com/quic-go/quic-go v0.50.1 // indirect 26 | go.uber.org/automaxprocs v1.6.0 // indirect 27 | go.uber.org/mock v0.5.1 // indirect 28 | golang.org/x/crypto v0.37.0 // indirect 29 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 30 | golang.org/x/mod v0.24.0 // indirect 31 | golang.org/x/sync v0.13.0 // indirect 32 | golang.org/x/sys v0.32.0 // indirect 33 | golang.org/x/text v0.24.0 // indirect 34 | golang.org/x/tools v0.32.0 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/AdguardTeam/dnsproxy v0.75.2 h1:bciOkzQh/GG8vcZGdFn6+rS3pu+2Npt9tbA4bNA/rsc= 2 | github.com/AdguardTeam/dnsproxy v0.75.2/go.mod h1:U/ouLftmXMIrkTAf8JepqbPuoQzsbXJo0Vxxn+LAdgA= 3 | github.com/AdguardTeam/golibs v0.32.7 h1:3dmGlAVgmvquCCwHsvEl58KKcRAK3z1UnjMnwSIeDH4= 4 | github.com/AdguardTeam/golibs v0.32.7/go.mod h1:bE8KV1zqTzgZjmjFyBJ9f9O5DEKO717r7e57j1HclJA= 5 | github.com/Snawoot/go-http-digest-auth-client v1.1.3 h1:Xd/SNBuIUJqotzmxRpbXovBJxmlVZOT19IZZdMdrJ0Q= 6 | github.com/Snawoot/go-http-digest-auth-client v1.1.3/go.mod h1:WiwNiPXTRGyjTGpBtSQJlM2wDPRRPpFGhMkMWpV4uqg= 7 | github.com/ameshkov/dnscrypt/v2 v2.4.0 h1:if6ZG2cuQmcP2TwSY+D0+8+xbPfoatufGlOQTMNkI9o= 8 | github.com/ameshkov/dnscrypt/v2 v2.4.0/go.mod h1:WpEFV2uhebXb8Jhes/5/fSdpmhGV8TL22RDaeWwV6hI= 9 | github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo= 10 | github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 12 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 16 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 17 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 18 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 19 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 20 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 21 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= 22 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 23 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 24 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 25 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 26 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 27 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 28 | github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc= 29 | github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= 30 | github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= 31 | github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= 32 | github.com/onsi/gomega v1.36.3 h1:hID7cr8t3Wp26+cYnfcjR6HpJ00fdogN6dqZ1t6IylU= 33 | github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 34 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 35 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 36 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= 37 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= 38 | github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= 39 | github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 40 | github.com/quic-go/quic-go v0.50.1 h1:unsgjFIUqW8a2oopkY7YNONpV1gYND6Nt9hnt1PN94Q= 41 | github.com/quic-go/quic-go v0.50.1/go.mod h1:Vim6OmUvlYdwBhXP9ZVrtGmCMWa3wEqhq3NgYrI8b4E= 42 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 43 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 44 | go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= 45 | go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= 46 | go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= 47 | go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 48 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 49 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 50 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 51 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 52 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 53 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 54 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 55 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 56 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 57 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 58 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 59 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 60 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 61 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 62 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 63 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 64 | golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= 65 | golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= 66 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= 67 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 68 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 69 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 70 | -------------------------------------------------------------------------------- /handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/Snawoot/opera-proxy/dialer" 16 | clog "github.com/Snawoot/opera-proxy/log" 17 | ) 18 | 19 | const ( 20 | COPY_BUF = 128 * 1024 21 | BAD_REQ_MSG = "Bad Request\n" 22 | ) 23 | 24 | type ProxyHandler struct { 25 | logger *clog.CondLogger 26 | dialer dialer.ContextDialer 27 | httptransport http.RoundTripper 28 | } 29 | 30 | func NewProxyHandler(dialer dialer.ContextDialer, logger *clog.CondLogger) *ProxyHandler { 31 | httptransport := &http.Transport{ 32 | MaxIdleConns: 100, 33 | IdleConnTimeout: 90 * time.Second, 34 | TLSHandshakeTimeout: 10 * time.Second, 35 | ExpectContinueTimeout: 1 * time.Second, 36 | DialContext: dialer.DialContext, 37 | } 38 | return &ProxyHandler{ 39 | logger: logger, 40 | dialer: dialer, 41 | httptransport: httptransport, 42 | } 43 | } 44 | 45 | func (s *ProxyHandler) HandleTunnel(wr http.ResponseWriter, req *http.Request) { 46 | ctx := req.Context() 47 | conn, err := s.dialer.DialContext(ctx, "tcp", req.RequestURI) 48 | if err != nil { 49 | s.logger.Error("Can't satisfy CONNECT request: %v", err) 50 | http.Error(wr, "Can't satisfy CONNECT request", http.StatusBadGateway) 51 | return 52 | } 53 | 54 | if req.ProtoMajor == 0 || req.ProtoMajor == 1 { 55 | // Upgrade client connection 56 | localconn, _, err := hijack(wr) 57 | if err != nil { 58 | s.logger.Error("Can't hijack client connection: %v", err) 59 | http.Error(wr, "Can't hijack client connection", http.StatusInternalServerError) 60 | return 61 | } 62 | defer localconn.Close() 63 | 64 | // Inform client connection is built 65 | fmt.Fprintf(localconn, "HTTP/%d.%d 200 OK\r\n\r\n", req.ProtoMajor, req.ProtoMinor) 66 | 67 | proxy(req.Context(), localconn, conn) 68 | } else if req.ProtoMajor == 2 { 69 | wr.Header()["Date"] = nil 70 | wr.WriteHeader(http.StatusOK) 71 | flush(wr) 72 | proxyh2(req.Context(), req.Body, wr, conn) 73 | } else { 74 | s.logger.Error("Unsupported protocol version: %s", req.Proto) 75 | http.Error(wr, "Unsupported protocol version.", http.StatusBadRequest) 76 | return 77 | } 78 | } 79 | 80 | func (s *ProxyHandler) HandleRequest(wr http.ResponseWriter, req *http.Request) { 81 | req.RequestURI = "" 82 | if req.ProtoMajor == 2 { 83 | req.URL.Scheme = "http" // We can't access :scheme pseudo-header, so assume http 84 | req.URL.Host = req.Host 85 | } 86 | resp, err := s.httptransport.RoundTrip(req) 87 | if err != nil { 88 | s.logger.Error("HTTP fetch error: %v", err) 89 | http.Error(wr, "Server Error", http.StatusInternalServerError) 90 | return 91 | } 92 | defer resp.Body.Close() 93 | s.logger.Info("%v %v %v %v", req.RemoteAddr, req.Method, req.URL, resp.Status) 94 | delHopHeaders(resp.Header) 95 | copyHeader(wr.Header(), resp.Header) 96 | wr.WriteHeader(resp.StatusCode) 97 | flush(wr) 98 | copyBody(wr, resp.Body) 99 | } 100 | 101 | func (s *ProxyHandler) ServeHTTP(wr http.ResponseWriter, req *http.Request) { 102 | s.logger.Info("Request: %v %v %v %v", req.RemoteAddr, req.Proto, req.Method, req.URL) 103 | 104 | isConnect := strings.ToUpper(req.Method) == "CONNECT" 105 | if (req.URL.Host == "" || req.URL.Scheme == "" && !isConnect) && req.ProtoMajor < 2 || 106 | req.Host == "" && req.ProtoMajor == 2 { 107 | http.Error(wr, BAD_REQ_MSG, http.StatusBadRequest) 108 | return 109 | } 110 | delHopHeaders(req.Header) 111 | if isConnect { 112 | s.HandleTunnel(wr, req) 113 | } else { 114 | s.HandleRequest(wr, req) 115 | } 116 | } 117 | 118 | func proxy(ctx context.Context, left, right net.Conn) { 119 | wg := sync.WaitGroup{} 120 | cpy := func(dst, src net.Conn) { 121 | defer wg.Done() 122 | io.Copy(dst, src) 123 | dst.Close() 124 | } 125 | wg.Add(2) 126 | go cpy(left, right) 127 | go cpy(right, left) 128 | groupdone := make(chan struct{}) 129 | go func() { 130 | wg.Wait() 131 | groupdone <- struct{}{} 132 | }() 133 | select { 134 | case <-ctx.Done(): 135 | left.Close() 136 | right.Close() 137 | case <-groupdone: 138 | return 139 | } 140 | <-groupdone 141 | return 142 | } 143 | 144 | func proxyh2(ctx context.Context, leftreader io.ReadCloser, leftwriter io.Writer, right net.Conn) { 145 | wg := sync.WaitGroup{} 146 | ltr := func(dst net.Conn, src io.Reader) { 147 | defer wg.Done() 148 | io.Copy(dst, src) 149 | dst.Close() 150 | } 151 | rtl := func(dst io.Writer, src io.Reader) { 152 | defer wg.Done() 153 | copyBody(dst, src) 154 | } 155 | wg.Add(2) 156 | go ltr(right, leftreader) 157 | go rtl(leftwriter, right) 158 | groupdone := make(chan struct{}, 1) 159 | go func() { 160 | wg.Wait() 161 | groupdone <- struct{}{} 162 | }() 163 | select { 164 | case <-ctx.Done(): 165 | leftreader.Close() 166 | right.Close() 167 | case <-groupdone: 168 | return 169 | } 170 | <-groupdone 171 | return 172 | } 173 | 174 | // Hop-by-hop headers. These are removed when sent to the backend. 175 | // http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html 176 | var hopHeaders = []string{ 177 | "Connection", 178 | "Keep-Alive", 179 | "Proxy-Authenticate", 180 | "Proxy-Connection", 181 | "Te", // canonicalized version of "TE" 182 | "Trailers", 183 | "Transfer-Encoding", 184 | "Upgrade", 185 | } 186 | 187 | func copyHeader(dst, src http.Header) { 188 | for k, vv := range src { 189 | for _, v := range vv { 190 | dst.Add(k, v) 191 | } 192 | } 193 | } 194 | 195 | func delHopHeaders(header http.Header) { 196 | for _, h := range hopHeaders { 197 | header.Del(h) 198 | } 199 | } 200 | 201 | func hijack(hijackable interface{}) (net.Conn, *bufio.ReadWriter, error) { 202 | hj, ok := hijackable.(http.Hijacker) 203 | if !ok { 204 | return nil, nil, errors.New("Connection doesn't support hijacking") 205 | } 206 | conn, rw, err := hj.Hijack() 207 | if err != nil { 208 | return nil, nil, err 209 | } 210 | var emptytime time.Time 211 | err = conn.SetDeadline(emptytime) 212 | if err != nil { 213 | conn.Close() 214 | return nil, nil, err 215 | } 216 | return conn, rw, nil 217 | } 218 | 219 | func flush(flusher interface{}) bool { 220 | f, ok := flusher.(http.Flusher) 221 | if !ok { 222 | return false 223 | } 224 | f.Flush() 225 | return true 226 | } 227 | 228 | func copyBody(wr io.Writer, body io.Reader) { 229 | buf := make([]byte, COPY_BUF) 230 | for { 231 | bread, read_err := body.Read(buf) 232 | var write_err error 233 | if bread > 0 { 234 | _, write_err = wr.Write(buf[:bread]) 235 | flush(wr) 236 | } 237 | if read_err != nil || write_err != nil { 238 | break 239 | } 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /handler/socks.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/Snawoot/opera-proxy/dialer" 5 | "github.com/armon/go-socks5" 6 | "log" 7 | ) 8 | 9 | func NewSocksServer(dialer dialer.ContextDialer, logger *log.Logger) (*socks5.Server, error) { 10 | return socks5.New(&socks5.Config{ 11 | Rules: &socks5.PermitCommand{ 12 | EnableConnect: true, 13 | }, 14 | Logger: logger, 15 | Dial: dialer.DialContext, 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /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 | "context" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "encoding/csv" 9 | "errors" 10 | "flag" 11 | "fmt" 12 | "io" 13 | "io/ioutil" 14 | "log" 15 | "net" 16 | "net/http" 17 | "net/url" 18 | "os" 19 | "strings" 20 | "time" 21 | 22 | xproxy "golang.org/x/net/proxy" 23 | 24 | "github.com/Snawoot/opera-proxy/clock" 25 | "github.com/Snawoot/opera-proxy/dialer" 26 | "github.com/Snawoot/opera-proxy/handler" 27 | clog "github.com/Snawoot/opera-proxy/log" 28 | se "github.com/Snawoot/opera-proxy/seclient" 29 | ) 30 | 31 | const ( 32 | API_DOMAIN = "api2.sec-tunnel.com" 33 | PROXY_SUFFIX = "sec-tunnel.com" 34 | ) 35 | 36 | var ( 37 | version = "undefined" 38 | ) 39 | 40 | func perror(msg string) { 41 | fmt.Fprintln(os.Stderr, "") 42 | fmt.Fprintln(os.Stderr, msg) 43 | } 44 | 45 | func arg_fail(msg string) { 46 | perror(msg) 47 | perror("Usage:") 48 | flag.PrintDefaults() 49 | os.Exit(2) 50 | } 51 | 52 | type CSVArg struct { 53 | values []string 54 | } 55 | 56 | func (a *CSVArg) String() string { 57 | if len(a.values) == 0 { 58 | return "" 59 | } 60 | buf := new(bytes.Buffer) 61 | wr := csv.NewWriter(buf) 62 | wr.Write(a.values) 63 | wr.Flush() 64 | return strings.TrimRight(buf.String(), "\n") 65 | } 66 | 67 | func (a *CSVArg) Set(line string) error { 68 | rd := csv.NewReader(strings.NewReader(line)) 69 | rd.FieldsPerRecord = -1 70 | rd.TrimLeadingSpace = true 71 | values, err := rd.Read() 72 | if err == io.EOF { 73 | a.values = nil 74 | return nil 75 | } 76 | if err != nil { 77 | return fmt.Errorf("unable to parse comma-separated argument: %w", err) 78 | } 79 | a.values = values 80 | return nil 81 | } 82 | 83 | type CLIArgs struct { 84 | country string 85 | listCountries bool 86 | listProxies bool 87 | bindAddress string 88 | socksMode bool 89 | verbosity int 90 | timeout time.Duration 91 | showVersion bool 92 | proxy string 93 | apiLogin string 94 | apiPassword string 95 | apiAddress string 96 | apiClientType string 97 | apiClientVersion string 98 | apiUserAgent string 99 | bootstrapDNS *CSVArg 100 | refresh time.Duration 101 | refreshRetry time.Duration 102 | initRetries int 103 | initRetryInterval time.Duration 104 | certChainWorkaround bool 105 | caFile string 106 | fakeSNI string 107 | overrideProxyAddress string 108 | } 109 | 110 | func parse_args() *CLIArgs { 111 | args := &CLIArgs{ 112 | bootstrapDNS: &CSVArg{ 113 | values: []string{ 114 | "https://1.1.1.3/dns-query", 115 | "https://8.8.8.8/dns-query", 116 | "https://dns.google/dns-query", 117 | "https://security.cloudflare-dns.com/dns-query", 118 | "https://fidelity.vm-0.com/q", 119 | "https://wikimedia-dns.org/dns-query", 120 | "https://dns.adguard-dns.com/dns-query", 121 | "https://dns.quad9.net/dns-query", 122 | "https://doh.cleanbrowsing.org/doh/adult-filter/", 123 | }, 124 | }, 125 | } 126 | flag.StringVar(&args.country, "country", "EU", "desired proxy location") 127 | flag.BoolVar(&args.listCountries, "list-countries", false, "list available countries and exit") 128 | flag.BoolVar(&args.listProxies, "list-proxies", false, "output proxy list and exit") 129 | flag.StringVar(&args.bindAddress, "bind-address", "127.0.0.1:18080", "proxy listen address") 130 | flag.BoolVar(&args.socksMode, "socks-mode", false, "listen for SOCKS requests instead of HTTP") 131 | flag.IntVar(&args.verbosity, "verbosity", 20, "logging verbosity "+ 132 | "(10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical)") 133 | flag.DurationVar(&args.timeout, "timeout", 10*time.Second, "timeout for network operations") 134 | flag.BoolVar(&args.showVersion, "version", false, "show program version and exit") 135 | flag.StringVar(&args.proxy, "proxy", "", "sets base proxy to use for all dial-outs. "+ 136 | "Format: ://[login:password@]host[:port] "+ 137 | "Examples: http://user:password@192.168.1.1:3128, socks5://10.0.0.1:1080") 138 | flag.StringVar(&args.apiClientVersion, "api-client-version", se.DefaultSESettings.ClientVersion, "client version reported to SurfEasy API") 139 | flag.StringVar(&args.apiClientType, "api-client-type", se.DefaultSESettings.ClientType, "client type reported to SurfEasy API") 140 | flag.StringVar(&args.apiUserAgent, "api-user-agent", se.DefaultSESettings.UserAgent, "user agent reported to SurfEasy API") 141 | flag.StringVar(&args.apiLogin, "api-login", "se0316", "SurfEasy API login") 142 | flag.StringVar(&args.apiPassword, "api-password", "SILrMEPBmJuhomxWkfm3JalqHX2Eheg1YhlEZiMh8II", "SurfEasy API password") 143 | flag.StringVar(&args.apiAddress, "api-address", "", fmt.Sprintf("override IP address of %s", API_DOMAIN)) 144 | flag.Var(args.bootstrapDNS, "bootstrap-dns", 145 | "comma-separated list of DNS/DoH/DoT/DoQ resolvers for initial discovery of SurfEasy API address. "+ 146 | "See https://github.com/ameshkov/dnslookup/ for upstream DNS URL format. "+ 147 | "Examples: https://1.1.1.1/dns-query,quic://dns.adguard.com") 148 | flag.DurationVar(&args.refresh, "refresh", 4*time.Hour, "login refresh interval") 149 | flag.DurationVar(&args.refreshRetry, "refresh-retry", 5*time.Second, "login refresh retry interval") 150 | flag.IntVar(&args.initRetries, "init-retries", 0, "number of attempts for initialization steps, zero for unlimited retry") 151 | flag.DurationVar(&args.initRetryInterval, "init-retry-interval", 5*time.Second, "delay between initialization retries") 152 | flag.BoolVar(&args.certChainWorkaround, "certchain-workaround", true, 153 | "add bundled cross-signed intermediate cert to certchain to make it check out on old systems") 154 | flag.StringVar(&args.caFile, "cafile", "", "use custom CA certificate bundle file") 155 | flag.StringVar(&args.fakeSNI, "fake-SNI", "", "domain name to use as SNI in communications with servers") 156 | flag.StringVar(&args.overrideProxyAddress, "override-proxy-address", "", "use fixed proxy address instead of server address returned by SurfEasy API") 157 | flag.Parse() 158 | if args.country == "" { 159 | arg_fail("Country can't be empty string.") 160 | } 161 | if args.listCountries && args.listProxies { 162 | arg_fail("list-countries and list-proxies flags are mutually exclusive") 163 | } 164 | return args 165 | } 166 | 167 | func proxyFromURLWrapper(u *url.URL, next xproxy.Dialer) (xproxy.Dialer, error) { 168 | cdialer, ok := next.(dialer.ContextDialer) 169 | if !ok { 170 | return nil, errors.New("only context dialers are accepted") 171 | } 172 | 173 | return dialer.ProxyDialerFromURL(u, cdialer) 174 | } 175 | 176 | func run() int { 177 | args := parse_args() 178 | if args.showVersion { 179 | fmt.Println(version) 180 | return 0 181 | } 182 | 183 | logWriter := clog.NewLogWriter(os.Stderr) 184 | defer logWriter.Close() 185 | 186 | mainLogger := clog.NewCondLogger(log.New(logWriter, "MAIN : ", 187 | log.LstdFlags|log.Lshortfile), 188 | args.verbosity) 189 | proxyLogger := clog.NewCondLogger(log.New(logWriter, "PROXY : ", 190 | log.LstdFlags|log.Lshortfile), 191 | args.verbosity) 192 | socksLogger := log.New(logWriter, "SOCKS : ", 193 | log.LstdFlags|log.Lshortfile) 194 | 195 | mainLogger.Info("opera-proxy client version %s is starting...", version) 196 | 197 | var d dialer.ContextDialer = &net.Dialer{ 198 | Timeout: 30 * time.Second, 199 | KeepAlive: 30 * time.Second, 200 | } 201 | 202 | if args.proxy != "" { 203 | xproxy.RegisterDialerType("http", proxyFromURLWrapper) 204 | xproxy.RegisterDialerType("https", proxyFromURLWrapper) 205 | proxyURL, err := url.Parse(args.proxy) 206 | if err != nil { 207 | mainLogger.Critical("Unable to parse base proxy URL: %v", err) 208 | return 6 209 | } 210 | pxDialer, err := xproxy.FromURL(proxyURL, d) 211 | if err != nil { 212 | mainLogger.Critical("Unable to instantiate base proxy dialer: %v", err) 213 | return 7 214 | } 215 | d = pxDialer.(dialer.ContextDialer) 216 | } 217 | 218 | seclientDialer := d 219 | if args.apiAddress != "" { 220 | mainLogger.Info("Using fixed API host IP address = %s", args.apiAddress) 221 | seclientDialer = dialer.NewFixedDialer(args.apiAddress, d) 222 | } else if len(args.bootstrapDNS.values) > 0 { 223 | resolver, err := dialer.NewResolver(args.bootstrapDNS.values, args.timeout) 224 | if err != nil { 225 | mainLogger.Critical("Unable to instantiate DNS resolver: %v", err) 226 | return 4 227 | } 228 | defer resolver.Close() 229 | seclientDialer = dialer.NewResolvingDialer(resolver, d) 230 | } 231 | 232 | // Dialing w/o SNI, receiving self-signed certificate, so skip verification. 233 | // Either way we'll validate certificate of actual proxy server. 234 | tlsConfig := &tls.Config{ 235 | ServerName: args.fakeSNI, 236 | InsecureSkipVerify: true, 237 | } 238 | seclient, err := se.NewSEClient(args.apiLogin, args.apiPassword, &http.Transport{ 239 | DialContext: seclientDialer.DialContext, 240 | DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) { 241 | conn, err := seclientDialer.DialContext(ctx, network, addr) 242 | if err != nil { 243 | return conn, err 244 | } 245 | return tls.Client(conn, tlsConfig), nil 246 | }, 247 | ForceAttemptHTTP2: true, 248 | MaxIdleConns: 100, 249 | IdleConnTimeout: 90 * time.Second, 250 | TLSHandshakeTimeout: 10 * time.Second, 251 | ExpectContinueTimeout: 1 * time.Second, 252 | }) 253 | if err != nil { 254 | mainLogger.Critical("Unable to construct SEClient: %v", err) 255 | return 8 256 | } 257 | seclient.Settings.ClientType = args.apiClientType 258 | seclient.Settings.ClientVersion = args.apiClientVersion 259 | seclient.Settings.UserAgent = args.apiUserAgent 260 | 261 | try := retryPolicy(args.initRetries, args.initRetryInterval, mainLogger) 262 | 263 | err = try("anonymous registration", func() error { 264 | ctx, cl := context.WithTimeout(context.Background(), args.timeout) 265 | defer cl() 266 | return seclient.AnonRegister(ctx) 267 | }) 268 | if err != nil { 269 | return 9 270 | } 271 | 272 | err = try("device registration", func() error { 273 | ctx, cl := context.WithTimeout(context.Background(), args.timeout) 274 | defer cl() 275 | return seclient.RegisterDevice(ctx) 276 | }) 277 | if err != nil { 278 | return 10 279 | } 280 | 281 | if args.listCountries { 282 | return printCountries(try, mainLogger, args.timeout, seclient) 283 | } 284 | 285 | var ips []se.SEIPEntry 286 | err = try("discover", func() error { 287 | ctx, cl := context.WithTimeout(context.Background(), args.timeout) 288 | defer cl() 289 | // TODO: learn about requested_geo value format 290 | res, err := seclient.Discover(ctx, fmt.Sprintf("\"%s\",,", args.country)) 291 | ips = res 292 | return err 293 | }) 294 | if err != nil { 295 | return 12 296 | } 297 | 298 | if args.listProxies { 299 | return printProxies(ips, seclient) 300 | } 301 | 302 | if len(ips) == 0 { 303 | mainLogger.Critical("Empty endpoint list!") 304 | return 13 305 | } 306 | 307 | clock.RunTicker(context.Background(), args.refresh, args.refreshRetry, func(ctx context.Context) error { 308 | mainLogger.Info("Refreshing login...") 309 | reqCtx, cl := context.WithTimeout(ctx, args.timeout) 310 | defer cl() 311 | err := seclient.Login(reqCtx) 312 | if err != nil { 313 | mainLogger.Error("Login refresh failed: %v", err) 314 | return err 315 | } 316 | mainLogger.Info("Login refreshed.") 317 | 318 | mainLogger.Info("Refreshing device password...") 319 | reqCtx, cl = context.WithTimeout(ctx, args.timeout) 320 | defer cl() 321 | err = seclient.DeviceGeneratePassword(reqCtx) 322 | if err != nil { 323 | mainLogger.Error("Device password refresh failed: %v", err) 324 | return err 325 | } 326 | mainLogger.Info("Device password refreshed.") 327 | return nil 328 | }) 329 | 330 | endpoint := ips[0] 331 | 332 | var caPool *x509.CertPool 333 | if args.caFile != "" { 334 | caPool = x509.NewCertPool() 335 | certs, err := ioutil.ReadFile(args.caFile) 336 | if err != nil { 337 | mainLogger.Error("Can't load CA file: %v", err) 338 | return 15 339 | } 340 | if ok := caPool.AppendCertsFromPEM(certs); !ok { 341 | mainLogger.Error("Can't load certificates from CA file") 342 | return 15 343 | } 344 | } 345 | 346 | var handlerBaseDialer = d 347 | if args.overrideProxyAddress != "" { 348 | mainLogger.Info("Original endpoint: %s", endpoint.IP) 349 | handlerBaseDialer = dialer.NewFixedDialer(args.overrideProxyAddress, handlerBaseDialer) 350 | mainLogger.Info("Endpoint override: %s", args.overrideProxyAddress) 351 | } else { 352 | mainLogger.Info("Endpoint: %s", endpoint.NetAddr()) 353 | } 354 | handlerDialer := dialer.NewProxyDialer( 355 | dialer.WrapStringToCb(endpoint.NetAddr()), 356 | dialer.WrapStringToCb(fmt.Sprintf("%s0.%s", args.country, PROXY_SUFFIX)), 357 | dialer.WrapStringToCb(args.fakeSNI), 358 | func() (string, error) { 359 | return dialer.BasicAuthHeader(seclient.GetProxyCredentials()), nil 360 | }, 361 | args.certChainWorkaround, 362 | caPool, 363 | handlerBaseDialer) 364 | mainLogger.Info("Starting proxy server...") 365 | if args.socksMode { 366 | socks, initError := handler.NewSocksServer(handlerDialer, socksLogger) 367 | if initError != nil { 368 | mainLogger.Critical("Failed to start: %v", err) 369 | return 16 370 | } 371 | mainLogger.Info("Init complete.") 372 | err = socks.ListenAndServe("tcp", args.bindAddress) 373 | } else { 374 | h := handler.NewProxyHandler(handlerDialer, proxyLogger) 375 | mainLogger.Info("Init complete.") 376 | err = http.ListenAndServe(args.bindAddress, h) 377 | } 378 | mainLogger.Critical("Server terminated with a reason: %v", err) 379 | mainLogger.Info("Shutting down...") 380 | return 0 381 | } 382 | 383 | func printCountries(try func(string, func() error) error, logger *clog.CondLogger, timeout time.Duration, seclient *se.SEClient) int { 384 | var list []se.SEGeoEntry 385 | err := try("geolist", func() error { 386 | ctx, cl := context.WithTimeout(context.Background(), timeout) 387 | defer cl() 388 | l, err := seclient.GeoList(ctx) 389 | list = l 390 | return err 391 | }) 392 | if err != nil { 393 | return 11 394 | } 395 | 396 | wr := csv.NewWriter(os.Stdout) 397 | defer wr.Flush() 398 | wr.Write([]string{"country code", "country name"}) 399 | for _, country := range list { 400 | wr.Write([]string{country.CountryCode, country.Country}) 401 | } 402 | return 0 403 | } 404 | 405 | func printProxies(ips []se.SEIPEntry, seclient *se.SEClient) int { 406 | wr := csv.NewWriter(os.Stdout) 407 | defer wr.Flush() 408 | login, password := seclient.GetProxyCredentials() 409 | fmt.Println("Proxy login:", login) 410 | fmt.Println("Proxy password:", password) 411 | fmt.Println("Proxy-Authorization:", dialer.BasicAuthHeader(login, password)) 412 | fmt.Println("") 413 | wr.Write([]string{"host", "ip_address", "port"}) 414 | for i, ip := range ips { 415 | for _, port := range ip.Ports { 416 | wr.Write([]string{ 417 | fmt.Sprintf("%s%d.%s", strings.ToLower(ip.Geo.CountryCode), i, PROXY_SUFFIX), 418 | ip.IP, 419 | fmt.Sprintf("%d", port), 420 | }) 421 | } 422 | } 423 | return 0 424 | } 425 | 426 | func main() { 427 | os.Exit(run()) 428 | } 429 | 430 | func retryPolicy(retries int, retryInterval time.Duration, logger *clog.CondLogger) func(string, func() error) error { 431 | return func(name string, f func() error) error { 432 | var err error 433 | for i := 1; retries <= 0 || i <= retries; i++ { 434 | if i > 1 { 435 | logger.Warning("Retrying action %q in %v...", name, retryInterval) 436 | time.Sleep(retryInterval) 437 | } 438 | logger.Info("Attempting action %q, attempt #%d...", name, i) 439 | err = f() 440 | if err == nil { 441 | logger.Info("Action %q succeeded on attempt #%d", name, i) 442 | return nil 443 | } 444 | logger.Warning("Action %q failed: %v", name, err) 445 | } 446 | logger.Critical("All attempts for action %q have failed. Last error: %v", name, err) 447 | return err 448 | } 449 | } 450 | -------------------------------------------------------------------------------- /seclient/csrand.go: -------------------------------------------------------------------------------- 1 | package seclient 2 | 3 | import ( 4 | crand "crypto/rand" 5 | "math/big" 6 | ) 7 | 8 | type secureRandomSource struct{} 9 | 10 | var RandomSource secureRandomSource 11 | 12 | var int63Limit = big.NewInt(0).Lsh(big.NewInt(1), 63) 13 | 14 | func (_ secureRandomSource) Seed(_ int64) { 15 | } 16 | 17 | func (_ secureRandomSource) Int63() int64 { 18 | randNum, err := crand.Int(crand.Reader, int63Limit) 19 | if err != nil { 20 | panic(err) 21 | } 22 | return randNum.Int64() 23 | } 24 | -------------------------------------------------------------------------------- /seclient/hash.go: -------------------------------------------------------------------------------- 1 | package seclient 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/hex" 6 | "strings" 7 | ) 8 | 9 | func capitalHexSHA1(input string) string { 10 | h := sha1.Sum([]byte(input)) 11 | return strings.ToUpper(hex.EncodeToString(h[:])) 12 | } 13 | -------------------------------------------------------------------------------- /seclient/jar.go: -------------------------------------------------------------------------------- 1 | package seclient 2 | 3 | import ( 4 | "net/http" 5 | "net/http/cookiejar" 6 | "net/url" 7 | "sync" 8 | 9 | "golang.org/x/net/publicsuffix" 10 | ) 11 | 12 | type StdJar struct { 13 | jar *cookiejar.Jar 14 | mux sync.RWMutex 15 | } 16 | 17 | func NewStdJar() (*StdJar, error) { 18 | var jar StdJar 19 | 20 | err := jar.Reset() 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | return &jar, nil 26 | } 27 | 28 | func (j *StdJar) SetCookies(u *url.URL, cookies []*http.Cookie) { 29 | j.mux.RLock() 30 | j.jar.SetCookies(u, cookies) 31 | j.mux.RUnlock() 32 | } 33 | 34 | func (j *StdJar) Cookies(u *url.URL) []*http.Cookie { 35 | j.mux.RLock() 36 | c := j.jar.Cookies(u) 37 | j.mux.RUnlock() 38 | return c 39 | } 40 | 41 | func (j *StdJar) Reset() error { 42 | jar, err := cookiejar.New(&cookiejar.Options{ 43 | PublicSuffixList: publicsuffix.List, 44 | }) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | j.mux.Lock() 50 | j.jar = jar 51 | j.mux.Unlock() 52 | return nil 53 | } 54 | -------------------------------------------------------------------------------- /seclient/messages.go: -------------------------------------------------------------------------------- 1 | package seclient 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "strconv" 9 | ) 10 | 11 | const ( 12 | SE_STATUS_OK int64 = 0 13 | ) 14 | 15 | type SEStatusPair struct { 16 | Code int64 17 | Message string 18 | } 19 | 20 | func (p *SEStatusPair) UnmarshalJSON(b []byte) error { 21 | var tmp map[string]string 22 | err := json.Unmarshal(b, &tmp) 23 | if err != nil { 24 | return err 25 | } 26 | 27 | if len(tmp) != 1 { 28 | return errors.New("ambiguous status") 29 | } 30 | 31 | var strCode, strStatus string 32 | for k, v := range tmp { 33 | strCode = k 34 | strStatus = v 35 | } 36 | 37 | code, err := strconv.ParseInt(strCode, 10, 64) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | *p = SEStatusPair{ 43 | Code: code, 44 | Message: strStatus, 45 | } 46 | return nil 47 | } 48 | 49 | type SERegisterSubscriberResponse struct { 50 | Data interface{} `json:"data"` 51 | Status SEStatusPair `json:"return_code"` 52 | } 53 | 54 | type SERegisterDeviceData struct { 55 | ClientType string `json:"client_type"` 56 | DeviceID string `json:"device_id"` 57 | DevicePassword string `json:"device_password"` 58 | } 59 | 60 | type SERegisterDeviceResponse struct { 61 | Data SERegisterDeviceData `json:"data"` 62 | Status SEStatusPair `json:"return_code"` 63 | } 64 | 65 | type SEDeviceGeneratePasswordData struct { 66 | DevicePassword string `json:"device_password"` 67 | } 68 | 69 | type SEDeviceGeneratePasswordResponse struct { 70 | Data SEDeviceGeneratePasswordData `json:"data"` 71 | Status SEStatusPair `json:"return_code"` 72 | } 73 | 74 | type SEGeoEntry struct { 75 | Country string `json:"country,omitempty"` 76 | CountryCode string `json:"country_code"` 77 | } 78 | 79 | type SEGeoListResponse struct { 80 | Data struct { 81 | Geos []SEGeoEntry `json:"geos"` 82 | } `json:"data"` 83 | Status SEStatusPair `json:"return_code"` 84 | } 85 | 86 | type SEIPEntry struct { 87 | Geo SEGeoEntry `json:"geo"` 88 | IP string `json:"ip"` 89 | Ports []uint16 `json:"ports"` 90 | } 91 | 92 | func (e *SEIPEntry) NetAddr() string { 93 | if len(e.Ports) == 0 { 94 | return net.JoinHostPort(e.IP, "443") 95 | } else { 96 | return net.JoinHostPort(e.IP, fmt.Sprintf("%d", e.Ports[0])) 97 | } 98 | } 99 | 100 | type SEDiscoverResponse struct { 101 | Data struct { 102 | IPs []SEIPEntry `json:"ips"` 103 | } `json:"data"` 104 | Status SEStatusPair `json:"return_code"` 105 | } 106 | 107 | type SESubscriberLoginResponse SERegisterSubscriberResponse 108 | -------------------------------------------------------------------------------- /seclient/randutils.go: -------------------------------------------------------------------------------- 1 | package seclient 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/hex" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | func randomEmailLocalPart(rng io.Reader) (string, error) { 11 | b := make([]byte, ANON_EMAIL_LOCALPART_BYTES) 12 | _, err := rng.Read(b) 13 | if err != nil { 14 | return "", err 15 | } 16 | return base64.StdEncoding.EncodeToString(b), nil 17 | } 18 | 19 | func randomCapitalHexString(rng io.Reader, length int) (string, error) { 20 | b := make([]byte, length) 21 | _, err := rng.Read(b) 22 | if err != nil { 23 | return "", err 24 | } 25 | return strings.ToUpper(hex.EncodeToString(b)), nil 26 | } 27 | -------------------------------------------------------------------------------- /seclient/seclient.go: -------------------------------------------------------------------------------- 1 | package seclient 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "math/rand" 10 | "net/http" 11 | "net/url" 12 | "strings" 13 | "sync" 14 | 15 | dac "github.com/Snawoot/go-http-digest-auth-client" 16 | ) 17 | 18 | const ( 19 | ANON_EMAIL_LOCALPART_BYTES = 32 20 | ANON_PASSWORD_BYTES = 20 21 | DEVICE_ID_BYTES = 20 22 | READ_LIMIT int64 = 128 * 1024 23 | ) 24 | 25 | type SEEndpoints struct { 26 | RegisterSubscriber string 27 | SubscriberLogin string 28 | RegisterDevice string 29 | DeviceGeneratePassword string 30 | GeoList string 31 | Discover string 32 | } 33 | 34 | var DefaultSEEndpoints = SEEndpoints{ 35 | RegisterSubscriber: "https://api2.sec-tunnel.com/v4/register_subscriber", 36 | SubscriberLogin: "https://api2.sec-tunnel.com/v4/subscriber_login", 37 | RegisterDevice: "https://api2.sec-tunnel.com/v4/register_device", 38 | DeviceGeneratePassword: "https://api2.sec-tunnel.com/v4/device_generate_password", 39 | GeoList: "https://api2.sec-tunnel.com/v4/geo_list", 40 | Discover: "https://api2.sec-tunnel.com/v4/discover", 41 | } 42 | 43 | type SESettings struct { 44 | ClientVersion string 45 | ClientType string 46 | DeviceName string 47 | OperatingSystem string 48 | UserAgent string 49 | Endpoints SEEndpoints 50 | } 51 | 52 | var DefaultSESettings = SESettings{ 53 | ClientVersion: "Stable 114.0.5282.21", 54 | ClientType: "se0316", 55 | UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 OPR/114.0.0.0", 56 | DeviceName: "Opera-Browser-Client", 57 | OperatingSystem: "Windows", 58 | Endpoints: DefaultSEEndpoints, 59 | } 60 | 61 | type SEClient struct { 62 | httpClient *http.Client 63 | Settings SESettings 64 | SubscriberEmail string 65 | SubscriberPassword string 66 | DeviceID string 67 | AssignedDeviceID string 68 | AssignedDeviceIDHash string 69 | DevicePassword string 70 | Mux sync.Mutex 71 | rng *rand.Rand 72 | } 73 | 74 | type StrKV map[string]string 75 | 76 | // Instantiates SurfEasy client with default settings and given API keys. 77 | // Optional `transport` parameter allows to override HTTP transport used 78 | // for HTTP calls 79 | func NewSEClient(apiUsername, apiSecret string, transport http.RoundTripper) (*SEClient, error) { 80 | if transport == nil { 81 | transport = http.DefaultTransport 82 | } 83 | 84 | rng := rand.New(RandomSource) 85 | 86 | device_id, err := randomCapitalHexString(rng, DEVICE_ID_BYTES) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | jar, err := NewStdJar() 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | res := &SEClient{ 97 | httpClient: &http.Client{ 98 | Jar: jar, 99 | Transport: dac.NewDigestTransport(apiUsername, apiSecret, transport), 100 | }, 101 | Settings: DefaultSESettings, 102 | rng: rng, 103 | DeviceID: device_id, 104 | } 105 | 106 | return res, nil 107 | } 108 | 109 | func (c *SEClient) ResetCookies() error { 110 | c.Mux.Lock() 111 | defer c.Mux.Unlock() 112 | return c.resetCookies() 113 | } 114 | 115 | func (c *SEClient) resetCookies() error { 116 | return (c.httpClient.Jar.(*StdJar)).Reset() 117 | } 118 | 119 | func (c *SEClient) AnonRegister(ctx context.Context) error { 120 | c.Mux.Lock() 121 | defer c.Mux.Unlock() 122 | 123 | localPart, err := randomEmailLocalPart(c.rng) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | c.SubscriberEmail = fmt.Sprintf("%s@%s.best.vpn", localPart, c.Settings.ClientType) 129 | c.SubscriberPassword = capitalHexSHA1(c.SubscriberEmail) 130 | 131 | return c.register(ctx) 132 | } 133 | 134 | func (c *SEClient) Register(ctx context.Context) error { 135 | c.Mux.Lock() 136 | defer c.Mux.Unlock() 137 | return c.register(ctx) 138 | } 139 | 140 | func (c *SEClient) register(ctx context.Context) error { 141 | err := c.resetCookies() 142 | if err != nil { 143 | return err 144 | } 145 | 146 | var regRes SERegisterSubscriberResponse 147 | err = c.rpcCall(ctx, c.Settings.Endpoints.RegisterSubscriber, StrKV{ 148 | "email": c.SubscriberEmail, 149 | "password": c.SubscriberPassword, 150 | }, ®Res) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | if regRes.Status.Code != SE_STATUS_OK { 156 | return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", 157 | regRes.Status.Code, regRes.Status.Message) 158 | } 159 | return nil 160 | } 161 | 162 | func (c *SEClient) RegisterDevice(ctx context.Context) error { 163 | c.Mux.Lock() 164 | defer c.Mux.Unlock() 165 | 166 | var regRes SERegisterDeviceResponse 167 | err := c.rpcCall(ctx, c.Settings.Endpoints.RegisterDevice, StrKV{ 168 | "client_type": c.Settings.ClientType, 169 | "device_hash": c.DeviceID, 170 | "device_name": c.Settings.DeviceName, 171 | }, ®Res) 172 | if err != nil { 173 | return err 174 | } 175 | 176 | if regRes.Status.Code != SE_STATUS_OK { 177 | return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", 178 | regRes.Status.Code, regRes.Status.Message) 179 | } 180 | 181 | c.AssignedDeviceID = regRes.Data.DeviceID 182 | c.DevicePassword = regRes.Data.DevicePassword 183 | c.AssignedDeviceIDHash = capitalHexSHA1(regRes.Data.DeviceID) 184 | return nil 185 | } 186 | 187 | func (c *SEClient) GeoList(ctx context.Context) ([]SEGeoEntry, error) { 188 | c.Mux.Lock() 189 | defer c.Mux.Unlock() 190 | 191 | var geoListRes SEGeoListResponse 192 | err := c.rpcCall(ctx, c.Settings.Endpoints.GeoList, StrKV{ 193 | "device_id": c.AssignedDeviceIDHash, 194 | }, &geoListRes) 195 | if err != nil { 196 | return nil, err 197 | } 198 | 199 | if geoListRes.Status.Code != SE_STATUS_OK { 200 | return nil, fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", 201 | geoListRes.Status.Code, geoListRes.Status.Message) 202 | } 203 | 204 | return geoListRes.Data.Geos, nil 205 | } 206 | 207 | func (c *SEClient) Discover(ctx context.Context, requestedGeo string) ([]SEIPEntry, error) { 208 | c.Mux.Lock() 209 | defer c.Mux.Unlock() 210 | 211 | var discoverRes SEDiscoverResponse 212 | err := c.rpcCall(ctx, c.Settings.Endpoints.Discover, StrKV{ 213 | "serial_no": c.AssignedDeviceIDHash, 214 | "requested_geo": requestedGeo, 215 | }, &discoverRes) 216 | if err != nil { 217 | return nil, err 218 | } 219 | 220 | if discoverRes.Status.Code != SE_STATUS_OK { 221 | return nil, fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", 222 | discoverRes.Status.Code, discoverRes.Status.Message) 223 | } 224 | 225 | return discoverRes.Data.IPs, nil 226 | } 227 | 228 | func (c *SEClient) Login(ctx context.Context) error { 229 | c.Mux.Lock() 230 | defer c.Mux.Unlock() 231 | 232 | err := c.resetCookies() 233 | if err != nil { 234 | return err 235 | } 236 | 237 | var loginRes SESubscriberLoginResponse 238 | err = c.rpcCall(ctx, c.Settings.Endpoints.SubscriberLogin, StrKV{ 239 | "login": c.SubscriberEmail, 240 | "password": c.SubscriberPassword, 241 | "client_type": c.Settings.ClientType, 242 | }, &loginRes) 243 | if err != nil { 244 | return err 245 | } 246 | 247 | if loginRes.Status.Code != SE_STATUS_OK { 248 | return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", 249 | loginRes.Status.Code, loginRes.Status.Message) 250 | } 251 | return nil 252 | } 253 | 254 | func (c *SEClient) DeviceGeneratePassword(ctx context.Context) error { 255 | c.Mux.Lock() 256 | defer c.Mux.Unlock() 257 | 258 | var genRes SEDeviceGeneratePasswordResponse 259 | err := c.rpcCall(ctx, c.Settings.Endpoints.DeviceGeneratePassword, StrKV{ 260 | "device_id": c.AssignedDeviceID, 261 | }, &genRes) 262 | if err != nil { 263 | return err 264 | } 265 | 266 | if genRes.Status.Code != SE_STATUS_OK { 267 | return fmt.Errorf("API responded with error message: code=%d, msg=\"%s\"", 268 | genRes.Status.Code, genRes.Status.Message) 269 | } 270 | 271 | c.DevicePassword = genRes.Data.DevicePassword 272 | return nil 273 | } 274 | 275 | func (c *SEClient) GetProxyCredentials() (string, string) { 276 | c.Mux.Lock() 277 | defer c.Mux.Unlock() 278 | 279 | return c.AssignedDeviceIDHash, c.DevicePassword 280 | } 281 | 282 | func (c *SEClient) populateRequest(req *http.Request) { 283 | req.Header["SE-Client-Version"] = []string{c.Settings.ClientVersion} 284 | req.Header["SE-Operating-System"] = []string{c.Settings.OperatingSystem} 285 | req.Header["User-Agent"] = []string{c.Settings.UserAgent} 286 | } 287 | 288 | func (c *SEClient) RpcCall(ctx context.Context, endpoint string, params map[string]string, res interface{}) error { 289 | c.Mux.Lock() 290 | defer c.Mux.Unlock() 291 | 292 | return c.rpcCall(ctx, endpoint, params, res) 293 | } 294 | 295 | func (c *SEClient) rpcCall(ctx context.Context, endpoint string, params map[string]string, res interface{}) error { 296 | input := make(url.Values) 297 | for k, v := range params { 298 | input[k] = []string{v} 299 | } 300 | req, err := http.NewRequestWithContext( 301 | ctx, 302 | "POST", 303 | endpoint, 304 | strings.NewReader(input.Encode()), 305 | ) 306 | if err != nil { 307 | return err 308 | } 309 | c.populateRequest(req) 310 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 311 | req.Header.Set("Accept", "application/json") 312 | 313 | resp, err := c.httpClient.Do(req) 314 | if err != nil { 315 | return err 316 | } 317 | 318 | if resp.StatusCode != http.StatusOK { 319 | return fmt.Errorf("bad http status: %s, headers: %#v", resp.Status, resp.Header) 320 | } 321 | 322 | decoder := json.NewDecoder(resp.Body) 323 | err = decoder.Decode(res) 324 | cleanupBody(resp.Body) 325 | 326 | if err != nil { 327 | return err 328 | } 329 | 330 | return nil 331 | } 332 | 333 | // Does cleanup of HTTP response in order to make it reusable by keep-alive 334 | // logic of HTTP client 335 | func cleanupBody(body io.ReadCloser) { 336 | io.Copy(ioutil.Discard, &io.LimitedReader{ 337 | R: body, 338 | N: READ_LIMIT, 339 | }) 340 | body.Close() 341 | } 342 | -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: opera-proxy 2 | summary: Standalone Opera VPN proxies client. 3 | description: | 4 | Standalone Opera VPN proxies client. Just run it and it'll start plain HTTP proxy server forwarding traffic via proxies of your choice. 5 | 6 | confinement: strict 7 | base: core22 8 | adopt-info: opera-proxy 9 | 10 | parts: 11 | opera-proxy: 12 | plugin: go 13 | build-snaps: [go/latest/stable] 14 | build-packages: 15 | - make 16 | - git-core 17 | source: https://github.com/Snawoot/opera-proxy 18 | source-type: git 19 | override-pull: | 20 | craftctl default 21 | craftctl set version="$(git describe --long --tags --always --match=v*.*.* | sed 's/v//')" 22 | override-build: 23 | make && 24 | cp bin/opera-proxy "$SNAPCRAFT_PART_INSTALL" 25 | stage: 26 | - opera-proxy 27 | 28 | apps: 29 | opera-proxy: 30 | command: opera-proxy 31 | plugs: 32 | - network 33 | - network-bind 34 | --------------------------------------------------------------------------------