├── .github └── workflows │ ├── build.yml │ └── docker-ci.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── clock └── wallclock.go ├── conn ├── dialer.go ├── plainfactory.go └── tlsfactory.go ├── dnscache └── wrapper.go ├── go.mod ├── go.sum ├── log ├── condlog.go └── logwriter.go ├── main.go ├── pool └── connpool.go ├── queue ├── queue.go └── queue_test.go ├── server ├── handler.go └── listener.go └── snapcraft.yaml /.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 | steady-tun 17 | steady-tun.* 18 | steady-tun.exe 19 | bin/ 20 | 21 | *.snap 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1 AS build 2 | 3 | ARG GIT_DESC=undefined 4 | 5 | WORKDIR /go/src/github.com/Snawoot/steady-tun 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/steady-tun/steady-tun / 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 57800/tcp 20 | ENTRYPOINT ["/steady-tun", "-bind-address", "0.0.0.0"] 21 | -------------------------------------------------------------------------------- /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 = steady-tun 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) 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 | # steady-tun 2 | 3 | [![steady-tun](https://snapcraft.io//steady-tun/badge.svg)](https://snapcraft.io/steady-tun) 4 | 5 | Secure TLS tunnel with pool of prepared upstream connections 6 | 7 | Accepts TCP connections on listen port and forwards them, wrapped in TLS, to destination port. steady-tun maintains pool of fresh established TLS connections effectively cancelling delay caused by TLS handshake. Optionally it can be used as just TCP connection pool (option `-tls-enabled=false`). 8 | 9 | steady-tun may serve as drop-in replacement for stunnel or haproxy for purpose of secure tunneling of TCP connections. Thus, it is intended for use with stunnel or haproxy on server side, accepting TLS connections and forwarding them, for example, to SOCKS proxy. In such configuration make sure your server timeouts long enough to allow fit lifetime of idle client TLS sessions (-T option). 10 | 11 | steady-tun can be used with custom CAs and/or mutual TLS auth with certificates. 12 | 13 | --- 14 | 15 | :heart: :heart: :heart: 16 | 17 | You can say thanks to the author by donations to these wallets: 18 | 19 | - ETH: `0xB71250010e8beC90C5f9ddF408251eBA9dD7320e` 20 | - BTC: 21 | - Legacy: `1N89PRvG1CSsUk9sxKwBwudN6TjTPQ1N8a` 22 | - Segwit: `bc1qc0hcyxc000qf0ketv4r44ld7dlgmmu73rtlntw` 23 | 24 | --- 25 | 26 | ## Features 27 | 28 | * Based on proven TLS security and works with well-known server side daemons for TLS termination like haproxy and stunnel. 29 | * Firewall- and DPI-proof: connections are indistinguishable from HTTPS traffic. 30 | * Greater practical performance comparing to other TCP traffic forwading solutions thanks to separate TLS session for each TCP connection. 31 | * Hides TLS connection delay with connection pooling. 32 | * Supports TLS SNI (server name indication) spoof - it may be useful to bypass SNI based filters in firewalls. 33 | * Cross-plaform: runs on Linux, macOS, Windows and other Unix-like systems. 34 | 35 | ## Installation 36 | 37 | #### Pre-built binaries 38 | 39 | Pre-built binaries available on [releases](https://github.com/Snawoot/steady-tun/releases/latest) page. 40 | 41 | #### From source 42 | 43 | Alternatively, you may install steady-tun from source: 44 | 45 | ``` 46 | go install github.com/Snawoot/steady-tun@latest 47 | ``` 48 | 49 | #### From Snap Store 50 | 51 | [![Get it from the Snap Store](https://snapcraft.io/static/images/badges/en/snap-store-black.svg)](https://snapcraft.io/steady-tun) 52 | 53 | ```sh 54 | sudo snap install steady-tun 55 | ``` 56 | 57 | #### Docker 58 | 59 | ```sh 60 | docker run -it --rm -v certs:/certs -p 57800:57800 \ 61 | yarmak/steady-tun \ 62 | -dsthost proxy.example.com \ 63 | -dstport 443 \ 64 | -cert /certs/user.pem \ 65 | -key /certs/user.key \ 66 | -cafile /certs/ca.pem \ 67 | -ttl 300s 68 | ``` 69 | 70 | ## Usage example 71 | 72 | ```sh 73 | ~/go/bin/steady-tun \ 74 | -dsthost proxy.example.com \ 75 | -dstport 443 \ 76 | -cert user.pem \ 77 | -key user.key \ 78 | -cafile ca.pem \ 79 | -ttl 300s 80 | ``` 81 | 82 | Command in this example will start forwarding TCP connections from default local port 57800 to `proxy.example.com:443`. Authentication is performed with client certificate and key. Server verification is performed with custom certificate in file ca.pem. 83 | 84 | ## Synopsis 85 | 86 | ``` 87 | $ ~/go/bin/steady-tun -h 88 | Usage of steady-tun: 89 | -backoff duration 90 | delay between connection attempts (default 5s) 91 | -bind-address string 92 | bind address (default "127.0.0.1") 93 | -bind-port uint 94 | bind port (default 57800) 95 | -cafile string 96 | override default CA certs by specified in file 97 | -cert string 98 | use certificate for client TLS auth 99 | -dialers uint 100 | concurrency limit for TLS connection attempts (default 16) 101 | -dns-cache-ttl duration 102 | DNS cache TTL (default 30s) 103 | -dns-neg-cache-ttl duration 104 | negative DNS cache TTL (default 1s) 105 | -dsthost string 106 | destination server hostname 107 | -dstport uint 108 | destination server port 109 | -hostname-check 110 | check hostname in server cert subject (default true) 111 | -key string 112 | key for TLS certificate 113 | -pool-size uint 114 | connection pool size (default 50) 115 | -timeout duration 116 | server connect timeout (default 4s) 117 | -tls-enabled 118 | enable TLS client for pool connections (default true) 119 | -tls-servername string 120 | specifies hostname to expect in server cert 121 | -tls-session-cache 122 | enable TLS session cache (default true) 123 | -ttl duration 124 | lifetime of idle pool connection in seconds (default 30s) 125 | -verbosity int 126 | logging verbosity (10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical) (default 20) 127 | -version 128 | show program version and exit 129 | ``` 130 | -------------------------------------------------------------------------------- /clock/wallclock.go: -------------------------------------------------------------------------------- 1 | package clock 2 | 3 | import "time" 4 | 5 | const WALLCLOCK_PRECISION = 1 * time.Second 6 | 7 | func AfterWallClock(d time.Duration) <-chan time.Time { 8 | ch := make(chan time.Time, 1) 9 | deadline := time.Now().Add(d).Truncate(0) 10 | after_ch := time.After(d) 11 | ticker := time.NewTicker(WALLCLOCK_PRECISION) 12 | go func() { 13 | var t time.Time 14 | defer ticker.Stop() 15 | for { 16 | select { 17 | case t = <-after_ch: 18 | ch <- t 19 | return 20 | case t = <-ticker.C: 21 | if t.After(deadline) { 22 | ch <- t 23 | return 24 | } 25 | } 26 | } 27 | }() 28 | return ch 29 | } 30 | -------------------------------------------------------------------------------- /conn/dialer.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "context" 5 | "net" 6 | ) 7 | 8 | type ContextDialer = func(ctx context.Context, network, address string) (net.Conn, error) 9 | 10 | type Factory interface { 11 | DialContext(ctx context.Context) (net.Conn, error) 12 | } 13 | -------------------------------------------------------------------------------- /conn/plainfactory.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "strconv" 8 | ) 9 | 10 | type PlainConnFactory struct { 11 | addr string 12 | dialer ContextDialer 13 | } 14 | 15 | var _ Factory = &PlainConnFactory{} 16 | 17 | func NewPlainConnFactory(host string, port uint16, dialer ContextDialer) *PlainConnFactory { 18 | return &PlainConnFactory{ 19 | addr: net.JoinHostPort(host, strconv.Itoa(int(port))), 20 | dialer: dialer, 21 | } 22 | } 23 | 24 | func (cf *PlainConnFactory) DialContext(ctx context.Context) (net.Conn, error) { 25 | conn, err := cf.dialer(ctx, "tcp", cf.addr) 26 | if err != nil { 27 | return nil, fmt.Errorf("cf.dialer.DialContext(ctx, \"tcp\", %q) failed: %v", cf.addr, err) 28 | } 29 | return conn, nil 30 | } 31 | -------------------------------------------------------------------------------- /conn/tlsfactory.go: -------------------------------------------------------------------------------- 1 | package conn 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "io/ioutil" 10 | "net" 11 | "strconv" 12 | 13 | "golang.org/x/sync/semaphore" 14 | 15 | clog "github.com/Snawoot/steady-tun/log" 16 | ) 17 | 18 | type TLSConnFactory struct { 19 | addr string 20 | tlsConfig *tls.Config 21 | dialer ContextDialer 22 | sem *semaphore.Weighted 23 | } 24 | 25 | var _ Factory = &TLSConnFactory{} 26 | 27 | func NewTLSConnFactory(host string, port uint16, dialer ContextDialer, 28 | certfile, keyfile string, cafile string, hostname_check bool, 29 | tls_servername string, dialers uint, sessionCache tls.ClientSessionCache, logger *clog.CondLogger) (*TLSConnFactory, error) { 30 | if !hostname_check && cafile == "" { 31 | return nil, errors.New("Hostname check should not be disabled in absence of custom CA file") 32 | } 33 | if certfile != "" && keyfile == "" || certfile == "" && keyfile != "" { 34 | return nil, errors.New("Certificate file and key file must be specified only together") 35 | } 36 | var certs []tls.Certificate 37 | if certfile != "" && keyfile != "" { 38 | cert, err := tls.LoadX509KeyPair(certfile, keyfile) 39 | if err != nil { 40 | return nil, err 41 | } 42 | certs = append(certs, cert) 43 | } 44 | var roots *x509.CertPool 45 | if cafile != "" { 46 | roots = x509.NewCertPool() 47 | certs, err := ioutil.ReadFile(cafile) 48 | if err != nil { 49 | return nil, err 50 | } 51 | if ok := roots.AppendCertsFromPEM(certs); !ok { 52 | return nil, errors.New("Failed to load CA certificates") 53 | } 54 | } 55 | servername := host 56 | if tls_servername != "" { 57 | servername = tls_servername 58 | } 59 | tlsConfig := tls.Config{ 60 | RootCAs: roots, 61 | ServerName: servername, 62 | Certificates: certs, 63 | ClientSessionCache: sessionCache, 64 | } 65 | if !hostname_check { 66 | tlsConfig.InsecureSkipVerify = true 67 | tlsConfig.VerifyPeerCertificate = func(certificates [][]byte, _ [][]*x509.Certificate) error { 68 | certs := make([]*x509.Certificate, len(certificates)) 69 | for i, asn1Data := range certificates { 70 | cert, err := x509.ParseCertificate(asn1Data) 71 | if err != nil { 72 | return errors.New("tls: failed to parse certificate from server: " + err.Error()) 73 | } 74 | certs[i] = cert 75 | } 76 | 77 | opts := x509.VerifyOptions{ 78 | Roots: roots, // On the server side, use config.ClientCAs. 79 | DNSName: "", // No hostname check 80 | Intermediates: x509.NewCertPool(), 81 | } 82 | for _, cert := range certs[1:] { 83 | opts.Intermediates.AddCert(cert) 84 | } 85 | _, err := certs[0].Verify(opts) 86 | return err 87 | } 88 | } 89 | return &TLSConnFactory{ 90 | addr: net.JoinHostPort(host, strconv.Itoa(int(port))), 91 | tlsConfig: &tlsConfig, 92 | dialer: dialer, 93 | sem: semaphore.NewWeighted(int64(dialers)), 94 | }, nil 95 | } 96 | 97 | func (cf *TLSConnFactory) DialContext(ctx context.Context) (net.Conn, error) { 98 | if cf.sem.Acquire(ctx, 1) != nil { 99 | return nil, errors.New("Context was cancelled") 100 | } 101 | defer cf.sem.Release(1) 102 | netConn, err := cf.dialer(ctx, "tcp", cf.addr) 103 | if err != nil { 104 | return nil, fmt.Errorf("cf.dialer.DialContext(ctx, \"tcp\", %q) failed: %v", cf.addr, err) 105 | } 106 | tlsConn := tls.Client(netConn, cf.tlsConfig) 107 | err = tlsConn.HandshakeContext(ctx) 108 | if err != nil { 109 | netConn.Close() 110 | return nil, fmt.Errorf("tlsConn.HandshakeContext(ctx) failed: %v", err) 111 | } 112 | return tlsConn, nil 113 | } 114 | -------------------------------------------------------------------------------- /dnscache/wrapper.go: -------------------------------------------------------------------------------- 1 | package dnscache 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/netip" 8 | "time" 9 | 10 | "github.com/jellydator/ttlcache/v3" 11 | ) 12 | 13 | type ContextDialer = func(ctx context.Context, network, address string) (net.Conn, error) 14 | 15 | type cacheKey struct { 16 | network string 17 | host string 18 | } 19 | 20 | type cacheValue struct { 21 | addrs []netip.Addr 22 | err error 23 | } 24 | 25 | type Resolver interface { 26 | LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) 27 | } 28 | 29 | func WrapDialer(dialer ContextDialer, resolver Resolver, size int, posTTL, negTTL, timeout time.Duration) ContextDialer { 30 | cache := ttlcache.New[cacheKey, cacheValue]( 31 | ttlcache.WithDisableTouchOnHit[cacheKey, cacheValue](), 32 | ttlcache.WithLoader( 33 | ttlcache.NewSuppressedLoader( 34 | ttlcache.LoaderFunc[cacheKey, cacheValue]( 35 | func(c *ttlcache.Cache[cacheKey, cacheValue], key cacheKey) *ttlcache.Item[cacheKey, cacheValue] { 36 | ctx, cl := context.WithTimeout(context.Background(), timeout) 37 | defer cl() 38 | res, err := resolver.LookupNetIP(ctx, key.network, key.host) 39 | setTTL := negTTL 40 | if err == nil { 41 | setTTL = posTTL 42 | } 43 | return c.Set(key, cacheValue{ 44 | addrs: res, 45 | err: err, 46 | }, setTTL) 47 | }, 48 | ), 49 | nil), 50 | ), 51 | ttlcache.WithCapacity[cacheKey, cacheValue](uint64(size)), 52 | ) 53 | wrapped := func(ctx context.Context, network, address string) (net.Conn, error) { 54 | var resolveNetwork string 55 | switch network { 56 | case "udp4", "tcp4", "ip4": 57 | resolveNetwork = "ip4" 58 | case "udp6", "tcp6", "ip6": 59 | resolveNetwork = "ip6" 60 | case "udp", "tcp", "ip": 61 | resolveNetwork = "ip" 62 | default: 63 | return nil, fmt.Errorf("resolving dial %q: unsupported network %q", address, network) 64 | } 65 | 66 | host, port, err := net.SplitHostPort(address) 67 | if err != nil { 68 | return nil, fmt.Errorf("failed to extract host and port from %s: %w", address, err) 69 | } 70 | 71 | resItem := cache.Get(cacheKey{ 72 | network: resolveNetwork, 73 | host: host, 74 | }) 75 | if resItem == nil { 76 | return nil, fmt.Errorf("cache lookup failed for pair <%q, %q>", resolveNetwork, host) 77 | } 78 | 79 | res := resItem.Value() 80 | if res.err != nil { 81 | return nil, res.err 82 | } 83 | 84 | var conn net.Conn 85 | 86 | for _, ip := range res.addrs { 87 | conn, err = dialer(ctx, network, net.JoinHostPort(ip.String(), port)) 88 | if err == nil { 89 | return conn, nil 90 | } 91 | } 92 | 93 | return nil, fmt.Errorf("failed to dial %s: %w", address, err) 94 | } 95 | return wrapped 96 | } 97 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Snawoot/steady-tun 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/huandu/skiplist v1.2.1 9 | github.com/jellydator/ttlcache/v3 v3.3.0 10 | golang.org/x/sync v0.8.0 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 15 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 4 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= 6 | github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= 7 | github.com/huandu/skiplist v1.2.1 h1:dTi93MgjwErA/8idWTzIw4Y1kZsMWx35fmI2c8Rij7w= 8 | github.com/huandu/skiplist v1.2.1/go.mod h1:7v3iFjLcSAzO4fN5B8dvebvo/qsfumiLiDXMrPiHF9w= 9 | github.com/jellydator/ttlcache/v3 v3.3.0 h1:BdoC9cE81qXfrxeb9eoJi9dWrdhSuwXMAnHTbnBm4Wc= 10 | github.com/jellydator/ttlcache/v3 v3.3.0/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 13 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 16 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 17 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 18 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 19 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 20 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 21 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 23 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 24 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 25 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 26 | -------------------------------------------------------------------------------- /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 | "crypto/tls" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "os" 10 | "os/signal" 11 | "runtime" 12 | "syscall" 13 | "time" 14 | 15 | conn "github.com/Snawoot/steady-tun/conn" 16 | "github.com/Snawoot/steady-tun/dnscache" 17 | clog "github.com/Snawoot/steady-tun/log" 18 | "github.com/Snawoot/steady-tun/pool" 19 | "github.com/Snawoot/steady-tun/server" 20 | ) 21 | 22 | var ( 23 | version = "undefined" 24 | ) 25 | 26 | func perror(msg string) { 27 | fmt.Fprintln(os.Stderr, "") 28 | fmt.Fprintln(os.Stderr, msg) 29 | } 30 | 31 | func arg_fail(msg string) { 32 | perror(msg) 33 | perror("Usage:") 34 | flag.PrintDefaults() 35 | os.Exit(2) 36 | } 37 | 38 | type CLIArgs struct { 39 | host string 40 | port uint 41 | verbosity int 42 | bind_address string 43 | bind_port uint 44 | pool_size uint 45 | dialers uint 46 | backoff, ttl, timeout time.Duration 47 | cert, key, cafile string 48 | hostname_check bool 49 | tls_servername string 50 | tlsSessionCache bool 51 | tlsEnabled bool 52 | dnsCacheTTL time.Duration 53 | dnsNegCacheTTL time.Duration 54 | showVersion bool 55 | } 56 | 57 | func parse_args() CLIArgs { 58 | args := CLIArgs{} 59 | flag.StringVar(&args.host, "dsthost", "", "destination server hostname") 60 | flag.UintVar(&args.port, "dstport", 0, "destination server port") 61 | flag.IntVar(&args.verbosity, "verbosity", 20, "logging verbosity "+ 62 | "(10 - debug, 20 - info, 30 - warning, 40 - error, 50 - critical)") 63 | flag.StringVar(&args.bind_address, "bind-address", "127.0.0.1", "bind address") 64 | flag.UintVar(&args.bind_port, "bind-port", 57800, "bind port") 65 | flag.UintVar(&args.pool_size, "pool-size", 50, "connection pool size") 66 | flag.UintVar(&args.dialers, "dialers", uint(4*runtime.GOMAXPROCS(0)), "concurrency limit for TLS connection attempts") 67 | flag.DurationVar(&args.backoff, "backoff", 5*time.Second, "delay between connection attempts") 68 | flag.DurationVar(&args.ttl, "ttl", 30*time.Second, "lifetime of idle pool connection in seconds") 69 | flag.DurationVar(&args.timeout, "timeout", 4*time.Second, "server connect timeout") 70 | flag.StringVar(&args.cert, "cert", "", "use certificate for client TLS auth") 71 | flag.StringVar(&args.key, "key", "", "key for TLS certificate") 72 | flag.StringVar(&args.cafile, "cafile", "", "override default CA certs by specified in file") 73 | flag.BoolVar(&args.hostname_check, "hostname-check", true, "check hostname in server cert subject") 74 | flag.StringVar(&args.tls_servername, "tls-servername", "", "specifies hostname to expect in server cert") 75 | flag.BoolVar(&args.tlsSessionCache, "tls-session-cache", true, "enable TLS session cache") 76 | flag.BoolVar(&args.showVersion, "version", false, "show program version and exit") 77 | flag.BoolVar(&args.tlsEnabled, "tls-enabled", true, "enable TLS client for pool connections") 78 | flag.DurationVar(&args.dnsCacheTTL, "dns-cache-ttl", 30*time.Second, "DNS cache TTL") 79 | flag.DurationVar(&args.dnsNegCacheTTL, "dns-neg-cache-ttl", 1*time.Second, "negative DNS cache TTL") 80 | flag.Parse() 81 | if args.showVersion { 82 | return args 83 | } 84 | if args.host == "" { 85 | arg_fail("Destination host argument is required!") 86 | } 87 | if args.port == 0 { 88 | arg_fail("Destination host argument is required!") 89 | } 90 | if args.port >= 65536 { 91 | arg_fail("Bad destination port!") 92 | } 93 | if args.bind_port >= 65536 { 94 | arg_fail("Bad bind port!") 95 | } 96 | if args.dialers < 1 { 97 | arg_fail("dialers parameter should be not less than 1") 98 | } 99 | return args 100 | } 101 | 102 | func main() { 103 | args := parse_args() 104 | if args.showVersion { 105 | fmt.Println(version) 106 | return 107 | } 108 | 109 | logWriter := clog.NewLogWriter(os.Stderr) 110 | defer logWriter.Close() 111 | 112 | mainLogger := clog.NewCondLogger(log.New(logWriter, "MAIN : ", log.LstdFlags|log.Lshortfile), 113 | args.verbosity) 114 | listenerLogger := clog.NewCondLogger(log.New(logWriter, "LISTENER: ", log.LstdFlags|log.Lshortfile), 115 | args.verbosity) 116 | handlerLogger := clog.NewCondLogger(log.New(logWriter, "HANDLER : ", log.LstdFlags|log.Lshortfile), 117 | args.verbosity) 118 | connLogger := clog.NewCondLogger(log.New(logWriter, "CONN : ", log.LstdFlags|log.Lshortfile), 119 | args.verbosity) 120 | poolLogger := clog.NewCondLogger(log.New(logWriter, "POOL : ", log.LstdFlags|log.Lshortfile), 121 | args.verbosity) 122 | 123 | var ( 124 | dialer conn.ContextDialer 125 | connfactory conn.Factory 126 | err error 127 | ) 128 | dialer = (&net.Dialer{ 129 | Timeout: args.timeout, 130 | }).DialContext 131 | 132 | if args.dnsCacheTTL > 0 { 133 | dialer = dnscache.WrapDialer(dialer, net.DefaultResolver, 128, args.dnsCacheTTL, args.dnsNegCacheTTL, args.timeout) 134 | } 135 | 136 | if args.tlsEnabled { 137 | var sessionCache tls.ClientSessionCache 138 | if args.tlsSessionCache { 139 | sessionCache = tls.NewLRUClientSessionCache(2 * int(args.pool_size)) 140 | } 141 | connfactory, err = conn.NewTLSConnFactory(args.host, 142 | uint16(args.port), 143 | dialer, 144 | args.cert, 145 | args.key, 146 | args.cafile, 147 | args.hostname_check, 148 | args.tls_servername, 149 | args.dialers, 150 | sessionCache, 151 | connLogger) 152 | if err != nil { 153 | panic(err) 154 | } 155 | } else { 156 | connfactory = conn.NewPlainConnFactory(args.host, uint16(args.port), dialer) 157 | } 158 | connPool := pool.NewConnPool(args.pool_size, args.ttl, args.backoff, connfactory.DialContext, poolLogger) 159 | connPool.Start() 160 | defer connPool.Stop() 161 | 162 | listener := server.NewTCPListener(args.bind_address, 163 | uint16(args.bind_port), 164 | server.NewConnHandler(connPool, handlerLogger).Handle, 165 | listenerLogger) 166 | if err := listener.Start(); err != nil { 167 | panic(err) 168 | } 169 | defer listener.Stop() 170 | 171 | mainLogger.Info("Listener started.") 172 | sigs := make(chan os.Signal, 1) 173 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 174 | <-sigs 175 | mainLogger.Info("Shutting down...") 176 | } 177 | -------------------------------------------------------------------------------- /pool/connpool.go: -------------------------------------------------------------------------------- 1 | package pool 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "sync" 7 | "time" 8 | 9 | "github.com/Snawoot/steady-tun/clock" 10 | clog "github.com/Snawoot/steady-tun/log" 11 | "github.com/Snawoot/steady-tun/queue" 12 | ) 13 | 14 | type ConnFactory = func(context.Context) (net.Conn, error) 15 | 16 | type ConnPool struct { 17 | size uint 18 | ttl, backoff time.Duration 19 | connFactory ConnFactory 20 | prepared *queue.RAQueue 21 | qmux sync.Mutex 22 | logger *clog.CondLogger 23 | ctx context.Context 24 | cancel context.CancelFunc 25 | shutdown sync.WaitGroup 26 | } 27 | 28 | type watchedConn struct { 29 | conn net.Conn 30 | cancel context.CancelFunc 31 | canceldone chan struct{} 32 | } 33 | 34 | func NewConnPool(size uint, ttl, backoff time.Duration, 35 | connFactory ConnFactory, logger *clog.CondLogger) *ConnPool { 36 | ctx, cancel := context.WithCancel(context.Background()) 37 | return &ConnPool{ 38 | size: size, 39 | ttl: ttl, 40 | backoff: backoff, 41 | connFactory: connFactory, 42 | prepared: queue.NewRAQueue(), 43 | logger: logger, 44 | ctx: ctx, 45 | cancel: cancel, 46 | } 47 | } 48 | 49 | func (p *ConnPool) Start() { 50 | p.shutdown.Add(int(p.size)) 51 | for i := uint(0); i < p.size; i++ { 52 | go p.worker() 53 | } 54 | } 55 | 56 | func (p *ConnPool) do_backoff() { 57 | select { 58 | case <-clock.AfterWallClock(p.backoff): 59 | case <-p.ctx.Done(): 60 | } 61 | } 62 | 63 | func (p *ConnPool) kill_prepared(queue_id uint, watched *watchedConn, output_ch chan *watchedConn) { 64 | p.qmux.Lock() 65 | deleted_elem := p.prepared.Delete(queue_id) 66 | p.qmux.Unlock() 67 | if deleted_elem == nil { 68 | // Someone already grabbed this slot from queue. Dispatch anyway. 69 | p.logger.Debug("Dead conn %v was grabbed from queue", watched.conn.LocalAddr()) 70 | output_ch <- watched 71 | } else { 72 | watched.conn.Close() 73 | } 74 | } 75 | 76 | func (p *ConnPool) worker() { 77 | defer p.shutdown.Done() 78 | output_ch := make(chan *watchedConn) 79 | dummybuf := make([]byte, 1) 80 | for { 81 | select { 82 | case <-p.ctx.Done(): 83 | return 84 | default: 85 | } 86 | conn, err := p.connFactory(p.ctx) 87 | if err != nil { 88 | select { 89 | case <-p.ctx.Done(): 90 | return 91 | default: 92 | p.logger.Error("Upstream connection error: %v", err) 93 | p.do_backoff() 94 | continue 95 | } 96 | } 97 | localaddr := conn.LocalAddr() 98 | p.logger.Debug("Established upstream connection %v", localaddr) 99 | 100 | p.qmux.Lock() 101 | queue_id := p.prepared.Push(output_ch) 102 | p.qmux.Unlock() 103 | readctx, readcancel := context.WithCancel(p.ctx) 104 | readdone := make(chan struct{}) 105 | go func() { 106 | connReadContext(readctx, conn, dummybuf) 107 | close(readdone) 108 | }() 109 | watched := &watchedConn{conn, readcancel, readdone} 110 | select { 111 | // Connection delivered via queue 112 | case output_ch <- watched: 113 | p.logger.Debug("Pool connection %v delivered via queue", localaddr) 114 | // Connection disrupted 115 | case <-readdone: 116 | p.logger.Debug("Pool connection %v was disrupted", localaddr) 117 | p.kill_prepared(queue_id, watched, output_ch) 118 | p.do_backoff() 119 | // Expired 120 | case <-clock.AfterWallClock(p.ttl): 121 | p.logger.Debug("Connection %v seem to be expired", localaddr) 122 | p.kill_prepared(queue_id, watched, output_ch) 123 | // Pool context cancelled 124 | case <-p.ctx.Done(): 125 | conn.Close() 126 | } 127 | } 128 | } 129 | 130 | func (p *ConnPool) Get(ctx context.Context) (net.Conn, error) { 131 | p.qmux.Lock() 132 | free := p.prepared.Pop() 133 | p.qmux.Unlock() 134 | if free == nil { 135 | p.logger.Warning("pool shortage! calling factory directly!") 136 | return p.connFactory(ctx) 137 | } else { 138 | watched := <-(free.(chan *watchedConn)) 139 | watched.cancel() 140 | <-watched.canceldone 141 | return watched.conn, nil 142 | } 143 | } 144 | 145 | func (p *ConnPool) Stop() { 146 | p.cancel() 147 | p.shutdown.Wait() 148 | } 149 | 150 | func connReadContext(ctx context.Context, conn net.Conn, p []byte) (n int, err error) { 151 | readDone := make(chan struct{}) 152 | go func() { 153 | defer close(readDone) 154 | n, err = conn.Read(p) 155 | }() 156 | select { 157 | case <-ctx.Done(): 158 | conn.SetReadDeadline(time.Unix(0, 0)) 159 | <-readDone 160 | conn.SetReadDeadline(time.Time{}) 161 | case <-readDone: 162 | } 163 | return 164 | } 165 | -------------------------------------------------------------------------------- /queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "github.com/huandu/skiplist" 5 | ) 6 | 7 | const MaxUint = ^uint(0) 8 | const WrapTreshold = (MaxUint >> 1) + 1 9 | 10 | type RAQueue struct { 11 | l *skiplist.SkipList 12 | cur_lsn uint 13 | } 14 | 15 | func NewRAQueue() *RAQueue { 16 | return &RAQueue{ 17 | l: skiplist.New(skiplist.GreaterThanFunc(func(lhs, rhs interface{}) int { 18 | x, y := lhs.(uint), rhs.(uint) 19 | switch { 20 | case x == y: 21 | return 0 22 | case (x < y && (y-x) <= WrapTreshold) || (x > y && (x-y) > WrapTreshold): 23 | return -1 24 | default: 25 | return 1 26 | } 27 | })), 28 | } 29 | } 30 | 31 | func (q *RAQueue) Push(e interface{}) uint { 32 | lsn := q.cur_lsn 33 | q.cur_lsn++ 34 | q.l.Set(lsn, e) 35 | return lsn 36 | } 37 | 38 | func (q *RAQueue) Pop() interface{} { 39 | if q.l.Len() == 0 { 40 | return nil 41 | } 42 | return q.l.RemoveFront().Value 43 | } 44 | 45 | func (q *RAQueue) Delete(key uint) interface{} { 46 | elem := q.l.Remove(key) 47 | if elem == nil { 48 | return nil 49 | } else { 50 | return elem.Value 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /queue/queue_test.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPushPop(t *testing.T) { 8 | queue := NewRAQueue() 9 | data := []string{"first", "second", "third"} 10 | for _, str := range data { 11 | queue.Push(str) 12 | } 13 | 14 | for _, str := range data { 15 | if queue.Pop().(string) != str { 16 | t.Fail() 17 | } 18 | } 19 | 20 | if queue.Pop() != nil { 21 | t.Fail() 22 | } 23 | } 24 | 25 | func TestDelete(t *testing.T) { 26 | queue := NewRAQueue() 27 | data := []string{"first", "second", "third"} 28 | idx := make([]uint, 3) 29 | for i, str := range data { 30 | idx[i] = queue.Push(str) 31 | } 32 | 33 | if queue.Delete(idx[1]).(string) != "second" { 34 | t.Fail() 35 | } 36 | 37 | data = []string{"first", "third"} 38 | for _, str := range data { 39 | if queue.Pop().(string) != str { 40 | t.Fail() 41 | } 42 | } 43 | 44 | if queue.Pop() != nil { 45 | t.Fail() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | "sync" 8 | 9 | clog "github.com/Snawoot/steady-tun/log" 10 | "github.com/Snawoot/steady-tun/pool" 11 | ) 12 | 13 | type ConnHandler struct { 14 | pool *pool.ConnPool 15 | logger *clog.CondLogger 16 | } 17 | 18 | func NewConnHandler(pool *pool.ConnPool, logger *clog.CondLogger) *ConnHandler { 19 | return &ConnHandler{pool, logger} 20 | } 21 | 22 | func (h *ConnHandler) proxy(ctx context.Context, left, right net.Conn) { 23 | wg := sync.WaitGroup{} 24 | cpy := func(dst, src net.Conn) { 25 | defer wg.Done() 26 | b, err := io.Copy(dst, src) 27 | dst.Close() 28 | h.logger.Debug("cpy done: bytes=%d err=%v", b, err) 29 | } 30 | wg.Add(2) 31 | go cpy(left, right) 32 | go cpy(right, left) 33 | groupdone := make(chan struct{}) 34 | go func() { 35 | wg.Wait() 36 | groupdone <- struct{}{} 37 | }() 38 | select { 39 | case <-ctx.Done(): 40 | left.Close() 41 | right.Close() 42 | case <-groupdone: 43 | return 44 | } 45 | <-groupdone 46 | return 47 | } 48 | 49 | func (h *ConnHandler) Handle(ctx context.Context, c net.Conn) { 50 | remote_addr := c.RemoteAddr() 51 | h.logger.Info("Got new connection from %s", remote_addr) 52 | defer h.logger.Info("Connection %s done", remote_addr) 53 | 54 | tlsconn, err := h.pool.Get(ctx) 55 | if err != nil { 56 | h.logger.Error("Error on connection retrieve from pool: %v", err) 57 | c.Close() 58 | return 59 | } 60 | h.proxy(ctx, c, tlsconn) 61 | } 62 | -------------------------------------------------------------------------------- /server/listener.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "sync" 7 | 8 | clog "github.com/Snawoot/steady-tun/log" 9 | ) 10 | 11 | type HandlerFunc func(context.Context, net.Conn) 12 | 13 | type TCPListener struct { 14 | address string 15 | port uint16 16 | handler HandlerFunc 17 | quitaccept chan struct{} 18 | listener *net.TCPListener 19 | logger *clog.CondLogger 20 | ctx context.Context 21 | cancel context.CancelFunc 22 | shutdown sync.WaitGroup 23 | } 24 | 25 | func NewTCPListener(address string, port uint16, handler HandlerFunc, 26 | logger *clog.CondLogger) *TCPListener { 27 | ctx, cancel := context.WithCancel(context.Background()) 28 | return &TCPListener{ 29 | address: address, 30 | port: port, 31 | handler: handler, 32 | logger: logger, 33 | quitaccept: make(chan struct{}, 1), 34 | ctx: ctx, 35 | cancel: cancel, 36 | } 37 | } 38 | 39 | func (l *TCPListener) Start() error { 40 | ips, err := net.LookupIP(l.address) 41 | if err != nil { 42 | return err 43 | } 44 | listener, err := net.ListenTCP("tcp", &net.TCPAddr{ 45 | IP: ips[0], 46 | Port: int(l.port), 47 | }) 48 | if err != nil { 49 | return err 50 | } 51 | l.listener = listener 52 | go l.serve() 53 | return nil 54 | } 55 | 56 | func (l *TCPListener) serve() { 57 | for { 58 | conn, err := l.listener.Accept() 59 | if err != nil { 60 | select { 61 | case <-l.quitaccept: 62 | l.logger.Info("Leaving accept loop.") 63 | l.quitaccept <- struct{}{} 64 | return 65 | default: 66 | l.logger.Error("Accept error: %s", err) 67 | continue 68 | } 69 | } 70 | l.shutdown.Add(1) 71 | go func(c net.Conn) { 72 | defer l.shutdown.Done() 73 | l.handler(l.ctx, c) 74 | }(conn) 75 | } 76 | } 77 | 78 | func (l *TCPListener) Stop() { 79 | l.quitaccept <- struct{}{} 80 | l.listener.Close() 81 | <-l.quitaccept 82 | l.cancel() 83 | l.shutdown.Wait() 84 | } 85 | -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: steady-tun 2 | version: '1.4.0' 3 | summary: Secure TLS tunnel with pool of prepared upstream connections 4 | description: > 5 | Secure TLS tunnel with pool of prepared upstream connections 6 | 7 | confinement: strict 8 | base: core18 9 | 10 | parts: 11 | steady-tun: 12 | plugin: go 13 | go-importpath: github.com/Snawoot/steady-tun 14 | source: . 15 | 16 | apps: 17 | steady-tun: 18 | command: bin/steady-tun 19 | plugs: 20 | - network 21 | - network-bind 22 | --------------------------------------------------------------------------------