├── .github └── workflows │ └── docker-image.yaml ├── .gitignore ├── Dockerfile ├── LICENSE ├── LICENSE.Tailscale ├── README.md ├── config.example.yaml ├── config.go ├── go.mod ├── go.sum ├── logger.go ├── main.go ├── pipe.go ├── proxy.go └── service.go /.github/workflows/docker-image.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Image 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | 7 | env: 8 | IMAGE_NAME: menci/tsukasa 9 | 10 | jobs: 11 | build_push: 12 | name: Build and Push 13 | runs-on: ubuntu-latest 14 | permissions: 15 | packages: write 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Set up QEMU 22 | uses: docker/setup-qemu-action@v3 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | - name: Login to ghcr.io 26 | uses: docker/login-action@v3 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.repository_owner }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | - name: Generate Tag List 32 | run: | 33 | echo "TAGS<> $GITHUB_ENV 34 | 35 | REGISTRIES=( 36 | ghcr.io 37 | ) 38 | DATE="$(date +'%Y%m%d')" 39 | for REGISTRY in "${REGISTRIES[@]}"; do 40 | echo $REGISTRY/$IMAGE_NAME:$DATE.$RUN_ID >> $GITHUB_ENV 41 | GIT_TAGS=$(git tag --points-at HEAD) 42 | if [[ "$GIT_TAGS" != "" ]]; then 43 | for GIT_TAG in $GIT_TAGS; do 44 | echo $REGISTRY/$IMAGE_NAME:$GIT_TAG >> $GITHUB_ENV 45 | done 46 | echo $REGISTRY/$IMAGE_NAME:latest >> $GITHUB_ENV 47 | fi 48 | done 49 | 50 | echo "EOF" >> $GITHUB_ENV 51 | env: 52 | RUN_ID: ${{ github.run_id }} 53 | - name: Build Container Image 54 | uses: docker/build-push-action@v6 55 | with: 56 | platforms: linux/amd64,linux/386,linux/arm64,linux/arm/v7,linux/riscv64 57 | push: true 58 | tags: ${{ env.TAGS }} 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | # built binary 28 | /tsukasa 29 | 30 | # debug 31 | /config.yaml 32 | /state 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG ALPINE_VERSION=3.20 2 | ARG GOLANG_VERSION=1.22.5 3 | 4 | FROM docker.io/library/golang:${GOLANG_VERSION}-alpine AS builder 5 | COPY . /build 6 | RUN cd /build && go build -o tsukasa 7 | 8 | FROM alpine:$ALPINE_VERSION 9 | COPY --from=builder /build/tsukasa /usr/local/bin/tsukasa 10 | ENTRYPOINT ["/usr/local/bin/tsukasa"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Menci 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 | -------------------------------------------------------------------------------- /LICENSE.Tailscale: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020 Tailscale Inc & AUTHORS. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tsusaka 2 | 3 | Tsusaka is a flexible port forwarder among: 4 | 5 | * TCP Ports 6 | * UNIX Sockets 7 | * Tailscale TCP Ports (without Tailscale daemon or TUN/TAP permission! This is made possible with [tsnet](https://tailscale.com/kb/1244/tsnet)) 8 | * Also supports SOCKS5/HTTP proxy feature of `tailscaled`. 9 | * If you don't use Tailscale features, it won't initialize Tailscale components and just behaves like a local port forwarder. 10 | 11 | It also supports passing the client IP with PROXY protocol (for listening on TCP or Tailscale TCP). 12 | 13 | > The name Tsusaka comes from the character **Tenma Tsukasa** from the music visual novel game Project SEKAI. He is a member of the musical show unit "Wonderlands x Showtime". Tsukasa has bucketloads of confidence and loves to be the center of attention. A theater show he saw as a kid impressed him so much that he made it his ultimate goal to become the greatest star in the world. 14 | 15 | # Development 16 | 17 | Simply build the program with `go build` or build the Docker image with `docker build`. 18 | 19 | # Usage 20 | 21 | Use with command-line configuration: 22 | 23 | ```bash 24 | ./tsusaka --ts-hostname Tsusaka \ 25 | --ts-authkey "$TS_AUTHKEY" \ 26 | --ts-ephemeral false \ 27 | --ts-state-dir /var/lib/tailscale \ 28 | --ts-listen-socks5 localhost:1080 \ 29 | --ts-listen-http localhost:8080 \ 30 | --ts-verbose true \ 31 | nginx,listen=tailscale://0.0.0.0:80,connect=tcp://127.0.0.1:8080,log-level=info,proxy-protocol \ 32 | myapp,listen=unix:/var/run/myapp.sock,connect=tailscale://app-hosted-in-tailnet:8080 33 | ``` 34 | 35 | Or use with configuration file: 36 | 37 | ```yaml 38 | # Tailscale configuration is not required and Tailsccale will not be loaded if no services with Tailscale defined. 39 | tailscale: 40 | hostname: Tsusaka 41 | # `null` to Use `TS_AUTHKEY` from environment or interactive login. 42 | authKey: null 43 | ephemeral: false 44 | stateDir: /var/lib/tailscale 45 | listen: 46 | socks5: 1080 47 | http: 8080 48 | verbose: true 49 | services: 50 | nginx: 51 | listen: tailscale://0.0.0.0:80 # Only "0.0.0.0" and "::" allowed in Tailscale listener. 52 | connect: tcp://127.0.0.1:8080 53 | logLevel: info # "error" / "info" / "verbose". By default "info". 54 | proxyProtocol: true # Listening on UNIX socket doesn't support PROXY protocol. 55 | myapp: 56 | listen: unix:/var/run/myapp.sock 57 | connect: tailscale://app-hosted-in-tailnet:8080 58 | ``` 59 | 60 | Configuration file could be specified with command-line configuration options at the same time. 61 | 62 | ```bash 63 | ./tsusaka --conf tsusaka.yaml 64 | ``` 65 | 66 | You can also completely omit Tailscale-related configuration and use Tsukasa as a simple port forward between TCP port and UNIX socket. 67 | 68 | # Docker 69 | 70 | To use Tsukasa with Docker, it's recommended to start Tsusaka in the host network mode to ensure Tailscale's UDP hole punching to work (Docker's MASQUERADE routing is nearly blocking NAT traversal). 71 | 72 | ```bash 73 | docker run \ 74 | --network=host \ 75 | -e TS_AUTHKEY="$TS_AUTHKEY" \ 76 | -v ./tailscale-state:/var/lib/tailscale \ 77 | ghcr.io/menci/tsusaka \ 78 | --ts-hostname Tsusaka \ 79 | --ts-state-dir /var/lib/tailscale \ 80 | myapp,listen=tcp://0.0.0.0:80,connect=tailscale://app-hosted-in-tailnet:8080 81 | ``` 82 | 83 | If you want to expose something in a container to your Tailnet, use UNIX socket and a shared volume. Here is an example with [Docker Compose](https://docs.docker.com/compose/). Note that if your application doesn't support listening on a UNIX socket, you can also start another instance of Tsukasa to work as a simple port forwarder from/to UNIX socket and TCP port in the virtual network. 84 | 85 | ```yaml 86 | services: 87 | initialize: 88 | image: busybox 89 | command: | 90 | # The initialize container empties the shared-sockets directory each time. 91 | rm -rf /socket/* 92 | volumes: 93 | - shared-sockets:/socket 94 | tsukasa: 95 | image: ghcr.io/menci/tsukasa 96 | network_mode: host 97 | depends_on: 98 | initialize: 99 | condition: service_completed_successfully 100 | volumes: 101 | - tailscale-state:/var/lib/tailscale 102 | - shared-sockets:/socket 103 | environment: 104 | TS_AUTHKEY: ${TAILSCALE_AUTHKEY} 105 | command: 106 | - app,listen=tailscale://0.0.0.0:80,connect=unix:/socket/app.sock 107 | app: 108 | image: # Here comes your app, which listens on /socket/app.sock 109 | depends_on: 110 | initialize: 111 | condition: service_completed_successfully 112 | volumes: 113 | - shared-sockets:/socket 114 | command: my_app --listen /socket/app.sock 115 | ``` 116 | -------------------------------------------------------------------------------- /config.example.yaml: -------------------------------------------------------------------------------- 1 | # Tailscale configuration is not required and Tailsccale will not be loaded if no services with Tailscale defined. 2 | tailscale: 3 | hostname: Tsusaka 4 | # `null` to Use `TS_AUTHKEY` from environment or interactive login. 5 | authKey: null 6 | ephemeral: false 7 | stateDir: /var/lib/tailscale 8 | listen: 9 | socks5: 1080 10 | http: 8080 11 | verbose: true 12 | services: 13 | nginx: 14 | listen: tailscale://0.0.0.0:80 # Only "0.0.0.0" and "::" allowed in Tailscale listener. 15 | connect: tcp://127.0.0.1:8080 16 | logLevel: info # "error" / "info" / "verbose". By default "info". 17 | proxyProtocol: true # Listening on UNIX socket doesn't support PROXY protocol. 18 | myapp: 19 | listen: unix:/var/run/myapp.sock 20 | connect: tailscale://app-hosted-in-tailnet:8080 21 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "gopkg.in/yaml.v2" 13 | ) 14 | 15 | type TailscaleListenConfig struct { 16 | Socks5 string `yaml:"socks5,omitempty"` 17 | HTTP string `yaml:"http,omitempty"` 18 | } 19 | 20 | type TailscaleConfig struct { 21 | Hostname string `yaml:"hostname,omitempty"` 22 | AuthKey string `yaml:"authKey"` 23 | Ephemeral bool `yaml:"ephemeral,omitempty"` 24 | StateDir string `yaml:"stateDir"` 25 | Listen TailscaleListenConfig `yaml:"listen,omitempty"` 26 | Verbose bool `yaml:"verbose,omitempty"` 27 | } 28 | 29 | type ServiceConfig struct { 30 | Listen string `yaml:"listen"` 31 | Connect string `yaml:"connect"` 32 | logLevel string `yaml:"logLevel,omitempty"` 33 | ProxyProtocol bool `yaml:"proxyProtocol,omitempty"` 34 | timeout string `yaml:"timeout,omitempty"` 35 | 36 | LogLevel LogLevel 37 | Timeout time.Duration 38 | } 39 | 40 | func parseLogLevel(s string) (LogLevel, error) { 41 | switch s { 42 | case "error": 43 | return Error, nil 44 | case "info": 45 | return Info, nil 46 | case "verbose": 47 | return Verbose, nil 48 | case "": 49 | return Info, nil 50 | default: 51 | return 0, fmt.Errorf("unknown log level: %s", s) 52 | } 53 | } 54 | 55 | type Config struct { 56 | timeout string `yaml:"timeout,omitempty"` 57 | Tailscale TailscaleConfig `yaml:"tailscale"` 58 | Services map[string]*ServiceConfig `yaml:"services"` 59 | 60 | Timeout time.Duration 61 | } 62 | 63 | type boolFlag struct { 64 | value bool 65 | set bool 66 | } 67 | 68 | func (f *boolFlag) Set(value string) error { 69 | v, err := strconv.ParseBool(value) 70 | if err != nil { 71 | return err 72 | } 73 | f.value = v 74 | f.set = true 75 | return nil 76 | } 77 | 78 | func (f *boolFlag) String() string { 79 | return strconv.FormatBool(f.value) 80 | } 81 | 82 | type arguments struct { 83 | conf string 84 | timeout string 85 | tsHostname string 86 | tsAuthKey string 87 | tsEphemeral boolFlag 88 | tsStateDir string 89 | tsListenSocks5 string 90 | tsListenHttp string 91 | tsVerbose boolFlag 92 | 93 | services []string 94 | } 95 | 96 | func parseArguments() *arguments { 97 | flags := &arguments{} 98 | flag.StringVar(&flags.conf, "conf", "", "YAML Configuration file") 99 | flag.StringVar(&flags.timeout, "timeout", "", "Default connection timeout of services (and Tailscale proxies)") 100 | flag.StringVar(&flags.tsHostname, "ts-hostname", "", "Tailscale hostname") 101 | flag.StringVar(&flags.tsAuthKey, "ts-authkey", "", "Tailscale authentication key (default to $TS_AUTHKEY)") 102 | flag.Var(&flags.tsEphemeral, "ts-ephemeral", "Set the Tailscale host to ephemeral") 103 | flag.StringVar(&flags.tsStateDir, "ts-state-dir", "", "Tailscale state directory") 104 | flag.StringVar(&flags.tsListenSocks5, "ts-listen-socks5", "", "Start SOCKS5 proxy server on [host]:port to access Tailnet") 105 | flag.StringVar(&flags.tsListenHttp, "ts-listen-http", "", "Start HTTP proxy server on [host]:port to access Tailnet") 106 | flag.Var(&flags.tsVerbose, "ts-verbose", "Print Tailscale logs") 107 | flag.Usage = func() { 108 | f := flag.CommandLine.Output() 109 | fmt.Fprintf(f, "Usage: %s [options] service1 service2 ...\n", os.Args[0]) 110 | fmt.Fprint(f, "\nTsukasa - A flexible port forwarder among TCP, UNIX Socket and Tailscale TCP ports.\n\n") 111 | flag.PrintDefaults() 112 | fmt.Fprintf(f, "\nExample: %s \\\n", os.Args[0]) 113 | fmt.Fprintln(f, " --timeout 10s \\") 114 | fmt.Fprintln(f, " --ts-hostname Tsukasa \\") 115 | fmt.Fprintln(f, " --ts-authkey \"$TS_AUTHKEY\" \\") 116 | fmt.Fprintln(f, " --ts-ephemeral false \\") 117 | fmt.Fprintln(f, " --ts-state-dir /var/lib/tailscale \\") 118 | fmt.Fprintln(f, " --ts-listen-socks5 127.0.0.1:1118 \\") 119 | fmt.Fprintln(f, " --ts-listen-http 127.0.0.1:8080 \\") 120 | fmt.Fprintln(f, " --ts-verbose true \\") 121 | fmt.Fprintln(f, " nginx,listen=tailscale://0.0.0.0:80,connect=tcp://127.0.0.1:8080,log-level=info,proxy-protocol \\") 122 | fmt.Fprintln(f, " myapp,listen=unix:/var/run/myapp.sock,connect=tailscale://app-hosted-in-tailnet:8080") 123 | } 124 | flag.Parse() 125 | flags.services = flag.Args() 126 | return flags 127 | } 128 | 129 | var nameRegexp = regexp.MustCompile(`^[$a-zA-Z0-9_-]+$`) 130 | 131 | func parseService(s string) (name string, service *ServiceConfig, err error) { 132 | // Examples: 133 | // nginx,listen=tailscale://0.0.0.0:80,connect=tcp://127.0.0.1:8080,log-level=info,proxy-protocol 134 | // myapp,listen=unix:/var/run/myapp.sock,connect=tailscale://app-hosted-in-tailnet:8080 135 | 136 | // Split the string by commas 137 | parts := strings.Split(s, ",") 138 | 139 | // The first part is the service name 140 | name = parts[0] 141 | parts = parts[1:] 142 | 143 | if !nameRegexp.MatchString(name) { 144 | return "", nil, fmt.Errorf("invalid service name: %s", name) 145 | } 146 | 147 | // The rest of the parts are key-value pairs 148 | service = &ServiceConfig{} 149 | for _, part := range parts { 150 | kv := strings.SplitN(part, "=", 2) 151 | 152 | key := kv[0] 153 | var value *string 154 | if len(kv) == 2 { 155 | value = &kv[1] 156 | } 157 | 158 | switch key { 159 | case "listen": 160 | if value == nil { 161 | return "", nil, fmt.Errorf("required value for option `listen`") 162 | } 163 | service.Listen = *value 164 | case "connect": 165 | if value == nil { 166 | return "", nil, fmt.Errorf("required value for option `connect`") 167 | } 168 | service.Connect = *value 169 | case "log-level": 170 | if value == nil { 171 | return "", nil, fmt.Errorf("required value for option `log-level`") 172 | } 173 | service.logLevel = *value 174 | case "proxy-protocol": 175 | if value != nil { 176 | return "", nil, fmt.Errorf("no value expected for option `proxy-protocol`") 177 | } 178 | service.ProxyProtocol = true 179 | case "timeout": 180 | if value == nil { 181 | return "", nil, fmt.Errorf("required value for option `timeout`") 182 | } 183 | service.timeout = *value 184 | default: 185 | return "", nil, fmt.Errorf("unknown service argument: %s", key) 186 | } 187 | } 188 | 189 | return name, service, nil 190 | } 191 | 192 | func mergeConfig(c *Config, a *arguments) error { 193 | if a.timeout != "" { 194 | c.timeout = a.timeout 195 | } 196 | 197 | if a.tsHostname != "" { 198 | c.Tailscale.Hostname = a.tsHostname 199 | } 200 | 201 | if a.tsAuthKey != "" { 202 | c.Tailscale.AuthKey = a.tsAuthKey 203 | } 204 | 205 | if a.tsEphemeral.set { 206 | c.Tailscale.Ephemeral = a.tsEphemeral.value 207 | } 208 | 209 | if a.tsStateDir != "" { 210 | c.Tailscale.StateDir = a.tsStateDir 211 | } 212 | 213 | if a.tsListenSocks5 != "" { 214 | c.Tailscale.Listen.Socks5 = a.tsListenSocks5 215 | } 216 | 217 | if a.tsListenHttp != "" { 218 | c.Tailscale.Listen.HTTP = a.tsListenHttp 219 | } 220 | 221 | if a.tsVerbose.set { 222 | c.Tailscale.Verbose = a.tsVerbose.value 223 | } 224 | 225 | for _, s := range a.services { 226 | name, service, err := parseService(s) 227 | if err != nil { 228 | return err 229 | } 230 | c.Services[name] = service 231 | } 232 | 233 | return nil 234 | } 235 | 236 | func (c *Config) ValidateTailscaleConfig() error { 237 | if c.Tailscale.Hostname == "" { 238 | return fmt.Errorf("missing Tailscale hostname") 239 | } 240 | 241 | if c.Tailscale.StateDir == "" { 242 | return fmt.Errorf("missing Tailscale state directory") 243 | } 244 | 245 | return nil 246 | } 247 | 248 | func (c *Config) ProcessServices() error { 249 | for name, service := range c.Services { 250 | if service.Listen == "" { 251 | return fmt.Errorf("missing listen address for service %s", name) 252 | } 253 | 254 | if service.Connect == "" { 255 | return fmt.Errorf("missing connect address for service %s", name) 256 | } 257 | 258 | if service.timeout == "" { 259 | service.Timeout = c.Timeout 260 | } else { 261 | if timeout, err := time.ParseDuration(service.timeout); err != nil { 262 | return fmt.Errorf("invalid timeout for service %s: %v", name, err) 263 | } else { 264 | service.Timeout = timeout 265 | } 266 | } 267 | } 268 | 269 | return nil 270 | } 271 | 272 | func GetConfig() (*Config, error) { 273 | a := parseArguments() 274 | 275 | c := &Config{ 276 | Tailscale: TailscaleConfig{}, 277 | Services: make(map[string]*ServiceConfig), 278 | } 279 | if a.conf != "" { 280 | f, err := os.Open(a.conf) 281 | if err != nil { 282 | return nil, err 283 | } 284 | 285 | err = yaml.NewDecoder(f).Decode(&c) 286 | if err != nil { 287 | return nil, err 288 | } 289 | } 290 | 291 | if err := mergeConfig(c, a); err != nil { 292 | return nil, err 293 | } 294 | 295 | if c.timeout != "" { 296 | if timeout, err := time.ParseDuration(c.timeout); err != nil { 297 | return nil, fmt.Errorf("invalid default timeout: %v", err) 298 | } else { 299 | c.Timeout = timeout 300 | } 301 | } else { 302 | c.Timeout = 10 * time.Second 303 | } 304 | 305 | if c.Tailscale.AuthKey == "" { 306 | c.Tailscale.AuthKey = os.Getenv("TS_AUTHKEY") 307 | } 308 | 309 | for name, service := range c.Services { 310 | var err error 311 | if service.LogLevel, err = parseLogLevel(service.logLevel); err != nil { 312 | return nil, fmt.Errorf("invalid log level for service %s: %v", name, err) 313 | } 314 | } 315 | 316 | return c, nil 317 | } 318 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Menci/tsukasa 2 | 3 | go 1.22.5 4 | 5 | require gopkg.in/yaml.v2 v2.4.0 6 | 7 | require ( 8 | filippo.io/edwards25519 v1.1.0 // indirect 9 | github.com/akutz/memconn v0.1.0 // indirect 10 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 11 | github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect 12 | github.com/aws/aws-sdk-go-v2/config v1.26.5 // indirect 13 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 // indirect 14 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect 15 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect 16 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect 17 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect 18 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect 19 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect 20 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect 21 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect 24 | github.com/aws/smithy-go v1.19.0 // indirect 25 | github.com/bits-and-blooms/bitset v1.13.0 // indirect 26 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect 27 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect 28 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect 29 | github.com/fxamacker/cbor/v2 v2.6.0 // indirect 30 | github.com/gaissmai/bart v0.11.1 // indirect 31 | github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 // indirect 32 | github.com/go-ole/go-ole v1.3.0 // indirect 33 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 34 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 35 | github.com/google/btree v1.1.2 // indirect 36 | github.com/google/go-cmp v0.6.0 // indirect 37 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect 38 | github.com/google/uuid v1.6.0 // indirect 39 | github.com/gorilla/csrf v1.7.2 // indirect 40 | github.com/gorilla/securecookie v1.1.2 // indirect 41 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 42 | github.com/illarion/gonotify v1.0.1 // indirect 43 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect 44 | github.com/jmespath/go-jmespath v0.4.0 // indirect 45 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 // indirect 46 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect 47 | github.com/klauspost/compress v1.17.4 // indirect 48 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect 49 | github.com/mdlayher/genetlink v1.3.2 // indirect 50 | github.com/mdlayher/netlink v1.7.2 // indirect 51 | github.com/mdlayher/sdnotify v1.0.0 // indirect 52 | github.com/mdlayher/socket v0.5.0 // indirect 53 | github.com/miekg/dns v1.1.58 // indirect 54 | github.com/mitchellh/go-ps v1.0.0 // indirect 55 | github.com/pierrec/lz4/v4 v4.1.21 // indirect 56 | github.com/prometheus-community/pro-bing v0.4.0 // indirect 57 | github.com/safchain/ethtool v0.3.0 // indirect 58 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect 59 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 60 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect 61 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 62 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect 63 | github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect 64 | github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 // indirect 65 | github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 // indirect 66 | github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1 // indirect 67 | github.com/tcnksm/go-httpstat v0.2.0 // indirect 68 | github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e // indirect 69 | github.com/vishvananda/netlink v1.2.1-beta.2 // indirect 70 | github.com/vishvananda/netns v0.0.4 // indirect 71 | github.com/x448/float16 v0.8.4 // indirect 72 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 // indirect 73 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 74 | golang.org/x/crypto v0.24.0 // indirect 75 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect 76 | golang.org/x/mod v0.18.0 // indirect 77 | golang.org/x/net v0.26.0 // indirect 78 | golang.org/x/sync v0.7.0 // indirect 79 | golang.org/x/sys v0.21.0 // indirect 80 | golang.org/x/term v0.21.0 // indirect 81 | golang.org/x/text v0.16.0 // indirect 82 | golang.org/x/time v0.5.0 // indirect 83 | golang.org/x/tools v0.22.0 // indirect 84 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 85 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 86 | gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 // indirect 87 | nhooyr.io/websocket v1.8.10 // indirect 88 | tailscale.com v1.70.0 // indirect 89 | ) 90 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 4 | github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 5 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 6 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 7 | github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= 8 | github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= 9 | github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw= 10 | github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU= 11 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= 12 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= 13 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= 14 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= 15 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= 16 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= 17 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= 18 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= 19 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= 20 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= 21 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= 22 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= 23 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= 24 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= 25 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= 26 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= 27 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= 28 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= 29 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= 30 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= 31 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= 32 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= 33 | github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= 34 | github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= 35 | github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= 36 | github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 37 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= 38 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 39 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 40 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= 41 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= 42 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= 43 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= 44 | github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= 45 | github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 46 | github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc= 47 | github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg= 48 | github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0 h1:ymLjT4f35nQbASLnvxEde4XOBL+Sn7rFuV+FOJqkljg= 49 | github.com/go-json-experiment/json v0.0.0-20231102232822-2e55bd4e08b0/go.mod h1:6daplAwHHGbUGib4990V3Il26O0OC4aRyvewaaAihaA= 50 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 51 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 52 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= 53 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= 54 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 55 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 56 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 57 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 58 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 59 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 60 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= 61 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= 62 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 63 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 64 | github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= 65 | github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 66 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 67 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 68 | github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= 69 | github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 70 | github.com/illarion/gonotify v1.0.1 h1:F1d+0Fgbq/sDWjj/r66ekjDG+IDeecQKUFH4wNwsoio= 71 | github.com/illarion/gonotify v1.0.1/go.mod h1:zt5pmDofZpU1f8aqlK0+95eQhoEAn/d4G4B/FjVW4jE= 72 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= 73 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= 74 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 75 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 76 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 77 | github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= 78 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 h1:elKwZS1OcdQ0WwEDBeqxKwb7WB62QX8bvZ/FJnVXIfk= 79 | github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86/go.mod h1:aFAMtuldEgx/4q7iSGazk22+IcgvtiC+HIimFO9XlS8= 80 | github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= 81 | github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= 82 | github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= 83 | github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 84 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= 85 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= 86 | github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 87 | github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 88 | github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= 89 | github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= 90 | github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= 91 | github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= 92 | github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 93 | github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 94 | github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= 95 | github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= 96 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 97 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 98 | github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 99 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 100 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 101 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 102 | github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= 103 | github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= 104 | github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= 105 | github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= 106 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 107 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= 108 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= 109 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 110 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 111 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 h1:rXZGgEa+k2vJM8xT0PoSKfVXwFGPQ3z3CJfmnHJkZZw= 112 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= 113 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= 114 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= 115 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= 116 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 117 | github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 h1:zrsUcqrG2uQSPhaUPjUQwozcRdDdSxxqhNgNZ3drZFk= 118 | github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 119 | github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4 h1:Gz0rz40FvFVLTBk/K8UNAenb36EbDSnh+q7Z9ldcC8w= 120 | github.com/tailscale/peercred v0.0.0-20240214030740-b535050b2aa4/go.mod h1:phI29ccmHQBc+wvroosENp1IF9195449VDnFDhJ4rJU= 121 | github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1 h1:tdUdyPqJ0C97SJfjB9tW6EylTtreyee9C44de+UBG0g= 122 | github.com/tailscale/web-client-prebuilt v0.0.0-20240226180453-5db17b287bf1/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= 123 | github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1 h1:ycpNCSYwzZ7x4G4ioPNtKQmIY0G/3o4pVf8wCZq6blY= 124 | github.com/tailscale/wireguard-go v0.0.0-20240705152531-2f5d148bcfe1/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= 125 | github.com/tcnksm/go-httpstat v0.2.0 h1:rP7T5e5U2HfmOBmZzGgGZjBQ5/GluWUylujl0tJ04I0= 126 | github.com/tcnksm/go-httpstat v0.2.0/go.mod h1:s3JVJFtQxtBEBC9dwcdTTXS9xFnM3SXAZwPG41aurT8= 127 | github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e h1:BA9O3BmlTmpjbvajAwzWx4Wo2TRVdpPXZEeemGQcajw= 128 | github.com/u-root/uio v0.0.0-20240118234441-a3c409a6018e/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= 129 | github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= 130 | github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= 131 | github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= 132 | github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= 133 | github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 134 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 135 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 136 | go4.org/mem v0.0.0-20220726221520-4f986261bf13 h1:CbZeCBZ0aZj8EfVgnqQcYZgf0lpZ3H9rmp5nkDTAst8= 137 | go4.org/mem v0.0.0-20220726221520-4f986261bf13/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 138 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 139 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 140 | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= 141 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 142 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a h1:Q8/wZp0KX97QFTc2ywcOE0YRjZPVIx+MXInMzdvQqcA= 143 | golang.org/x/exp v0.0.0-20240119083558-1b970713d09a/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= 144 | golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= 145 | golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 146 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 147 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 148 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 149 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 150 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 151 | golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 152 | golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 153 | golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 154 | golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 155 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 156 | golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 157 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 158 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 159 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 160 | golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= 161 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 162 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 163 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 164 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 165 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 166 | golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= 167 | golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 168 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 169 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 170 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 171 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 172 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 173 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 174 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 175 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 176 | gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 h1:/8/t5pz/mgdRXhYOIeqqYhFAQLE4DDGegc0Y4ZjyFJM= 177 | gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3/go.mod h1:NQHVAzMwvZ+Qe3ElSiHmq9RUm1MdNHpUZ52fiEqvn+0= 178 | nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= 179 | nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= 180 | tailscale.com v1.70.0 h1:SW7mxDepkXBv2iKITeyFDEfHCJBfOeHM+U79lQ0d5zQ= 181 | tailscale.com v1.70.0/go.mod h1:a5yWox+uO5CI4tCB9ot0ZPMdQMiC+Pis9mudVaYETIo= 182 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | ) 8 | 9 | type LogLevel int 10 | 11 | const ( 12 | Error LogLevel = 0 13 | Info LogLevel = 1 14 | Verbose LogLevel = 2 15 | ) 16 | 17 | type Logger struct { 18 | Printf func(format string, v ...interface{}) 19 | LogLevel LogLevel 20 | } 21 | 22 | func (l *Logger) Fatalf(format string, v ...interface{}) { 23 | l.Printf(format, v...) 24 | os.Exit(1) 25 | } 26 | 27 | func (l *Logger) Logf(logLevel LogLevel, format string, v ...interface{}) { 28 | if l.LogLevel >= logLevel { 29 | l.Printf(format, v...) 30 | } 31 | } 32 | 33 | func (l *Logger) Errorf(format string, v ...interface{}) { 34 | l.Logf(Error, format, v...) 35 | } 36 | 37 | func (l *Logger) Infof(format string, v ...interface{}) { 38 | l.Logf(Info, format, v...) 39 | } 40 | 41 | func (l *Logger) Verbosef(format string, v ...interface{}) { 42 | l.Logf(Verbose, format, v...) 43 | } 44 | 45 | func CreateLogger(prefix string, logLevel LogLevel) *Logger { 46 | return &Logger{ 47 | Printf: log.New(os.Stderr, fmt.Sprintf("[%s] ", prefix), log.LstdFlags).Printf, 48 | LogLevel: logLevel, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "syscall" 10 | 11 | "tailscale.com/tsnet" 12 | ) 13 | 14 | type Logf func(format string, args ...any) 15 | 16 | func main() { 17 | logger := CreateLogger("main", Info) 18 | 19 | config, err := GetConfig() 20 | if err != nil { 21 | logger.Fatalf("invalid config: %v", err) 22 | } 23 | 24 | if err := config.ProcessServices(); err != nil { 25 | logger.Fatalf("invalid service config: %v", err) 26 | } 27 | 28 | tsnet := new(tsnet.Server) 29 | tsnet.Hostname = config.Tailscale.Hostname 30 | tsnet.AuthKey = config.Tailscale.AuthKey 31 | tsnet.Ephemeral = config.Tailscale.Ephemeral 32 | tsnet.Dir = config.Tailscale.StateDir 33 | 34 | var tsLogLevel LogLevel 35 | if config.Tailscale.Verbose { 36 | tsLogLevel = Verbose 37 | } else { 38 | tsLogLevel = Info 39 | } 40 | tsLogger := CreateLogger("tailscale", tsLogLevel) 41 | tsnet.Logf = tsLogger.Verbosef 42 | tsnet.UserLogf = tsLogger.Infof 43 | 44 | shutdownCh := make(chan struct{}) 45 | shutdownWg := &sync.WaitGroup{} 46 | serviceContext := &ServiceContext{ 47 | TsNet: tsnet, 48 | ShutdownCh: shutdownCh, 49 | ShutdownWg: shutdownWg, 50 | } 51 | 52 | usingTailscale := false 53 | if config.Tailscale.Listen.Socks5 != "" || config.Tailscale.Listen.HTTP != "" { 54 | usingTailscale = true 55 | } 56 | 57 | var services []*Service 58 | for name, serviceConfig := range config.Services { 59 | service, err := CreateService(serviceContext, name, serviceConfig) 60 | if err != nil { 61 | logger.Fatalf("failed to create service %q: %v", name, err) 62 | } 63 | if service.ListenType == AddressTailscaleTCP || service.ConnectType == AddressTailscaleTCP { 64 | usingTailscale = true 65 | } 66 | services = append(services, service) 67 | } 68 | 69 | if usingTailscale { 70 | if err := config.ValidateTailscaleConfig(); err != nil { 71 | logger.Fatalf("Tailscale used but got invalid Tailscale config: %v", err) 72 | } 73 | if config.Tailscale.AuthKey == "" { 74 | logger.Infof("Tailscale authkey not provided, will try interactive login") 75 | } 76 | if err := tsnet.Start(); err != nil { 77 | logger.Fatalf("failed to start Tailscale: %v", err) 78 | } 79 | defer tsnet.Close() 80 | } 81 | 82 | somethingRunning := false 83 | 84 | if config.Tailscale.Listen.Socks5 != "" || config.Tailscale.Listen.HTTP != "" { 85 | proxyDial := func(ctx context.Context, network, address string) (net.Conn, error) { 86 | ctx2, cancel := context.WithTimeout(ctx, config.Timeout) 87 | defer cancel() 88 | return tsnet.Dial(ctx2, network, address) 89 | } 90 | if config.Tailscale.Listen.Socks5 != "" { 91 | somethingRunning = true 92 | StartProxy(tsLogger, config.Tailscale.Listen.Socks5, proxyDial, Socks5) 93 | } 94 | if config.Tailscale.Listen.HTTP != "" { 95 | somethingRunning = true 96 | StartProxy(tsLogger, config.Tailscale.Listen.HTTP, proxyDial, HTTP) 97 | } 98 | } 99 | 100 | // Start services. 101 | for _, service := range services { 102 | somethingRunning = true 103 | go service.Start() 104 | } 105 | 106 | if !somethingRunning { 107 | logger.Fatalf("no listener defined. run %s -h for help", os.Args[0]) 108 | } 109 | 110 | // Wait for signal to shutdown. 111 | c := make(chan os.Signal, 1) 112 | signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) 113 | <-c 114 | close(shutdownCh) 115 | shutdownWg.Wait() 116 | } 117 | -------------------------------------------------------------------------------- /pipe.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "sync/atomic" 7 | ) 8 | 9 | func PipeAndClose(conn net.Conn, targetConn net.Conn, logger *Logger) { 10 | var closed uint32 = 0 11 | close := func() { 12 | if atomic.CompareAndSwapUint32(&closed, 0, 1) { 13 | conn.Close() 14 | targetConn.Close() 15 | } 16 | } 17 | 18 | // Forward data between conn and targetConn. 19 | go func() { 20 | defer close() 21 | 22 | _, err := io.Copy(targetConn, conn) 23 | if err != nil && atomic.LoadUint32(&closed) == 0 { 24 | logger.Errorf("error copying data to target: %v\n", err) 25 | } 26 | conn.Close() 27 | }() 28 | 29 | defer close() 30 | 31 | if _, err := io.Copy(conn, targetConn); err != nil && atomic.LoadUint32(&closed) == 0 { 32 | logger.Errorf("error copying data from target: %v\n", err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | "net/http" 8 | "net/http/httputil" 9 | "os" 10 | "strings" 11 | 12 | "tailscale.com/net/socks5" 13 | ) 14 | 15 | // Copied from https://github.com/tailscale/tailscale/blob/a2c42d3cd4e914b8ac879ac0a21c284ecaf143fc/cmd/tailscaled/proxy.go#L21 16 | // 17 | // Copyright (c) Tailscale Inc & AUTHORS 18 | // SPDX-License-Identifier: BSD-3-Clause 19 | // 20 | // httpProxyHandler returns an HTTP proxy http.Handler using the 21 | // provided backend dialer. 22 | func httpProxyHandler(dialer func(ctx context.Context, netw, addr string) (net.Conn, error)) http.Handler { 23 | rp := &httputil.ReverseProxy{ 24 | Director: func(r *http.Request) {}, // no change 25 | Transport: &http.Transport{ 26 | DialContext: dialer, 27 | }, 28 | } 29 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 30 | if r.Method != "CONNECT" { 31 | backURL := r.RequestURI 32 | if strings.HasPrefix(backURL, "/") || backURL == "*" { 33 | http.Error(w, "bogus RequestURI; must be absolute URL or CONNECT", 400) 34 | return 35 | } 36 | rp.ServeHTTP(w, r) 37 | return 38 | } 39 | 40 | // CONNECT support: 41 | 42 | dst := r.RequestURI 43 | c, err := dialer(r.Context(), "tcp", dst) 44 | if err != nil { 45 | w.Header().Set("Tailscale-Connect-Error", err.Error()) 46 | http.Error(w, err.Error(), 500) 47 | return 48 | } 49 | defer c.Close() 50 | 51 | cc, ccbuf, err := w.(http.Hijacker).Hijack() 52 | if err != nil { 53 | http.Error(w, err.Error(), 500) 54 | return 55 | } 56 | defer cc.Close() 57 | 58 | io.WriteString(cc, "HTTP/1.1 200 OK\r\n\r\n") 59 | 60 | var clientSrc io.Reader = ccbuf 61 | if ccbuf.Reader.Buffered() == 0 { 62 | // In the common case (with no 63 | // buffered data), read directly from 64 | // the underlying client connection to 65 | // save some memory, letting the 66 | // bufio.Reader/Writer get GC'ed. 67 | clientSrc = cc 68 | } 69 | 70 | errc := make(chan error, 1) 71 | go func() { 72 | _, err := io.Copy(cc, c) 73 | errc <- err 74 | }() 75 | go func() { 76 | _, err := io.Copy(c, clientSrc) 77 | errc <- err 78 | }() 79 | <-errc 80 | }) 81 | } 82 | 83 | type ProxyType string 84 | 85 | const ( 86 | Socks5 ProxyType = "SOCKS5" 87 | HTTP ProxyType = "HTTP" 88 | ) 89 | 90 | type Dialer func(ctx context.Context, network, address string) (net.Conn, error) 91 | 92 | func StartProxy(logger *Logger, address string, dialer Dialer, proxyType ProxyType) { 93 | var listener net.Listener 94 | var err error 95 | var cleanup func() 96 | 97 | if strings.HasPrefix(address, "unix:") { 98 | filename := address[5:] 99 | listener, err = net.Listen("unix", filename) 100 | cleanup = func() { 101 | listener.Close() 102 | os.Remove(filename) 103 | } 104 | } else { 105 | listener, err = net.Listen("tcp", address) 106 | cleanup = func() { 107 | listener.Close() 108 | } 109 | } 110 | 111 | if err != nil { 112 | logger.Fatalf("failed to start %s proxy on %s: %v", proxyType, address, err) 113 | } 114 | 115 | var serve func(listener net.Listener) error 116 | switch proxyType { 117 | case Socks5: 118 | ss := &socks5.Server{ 119 | Logf: logger.Verbosef, 120 | Dialer: dialer, 121 | } 122 | serve = ss.Serve 123 | case HTTP: 124 | hs := &http.Server{ 125 | Handler: httpProxyHandler(dialer), 126 | } 127 | serve = hs.Serve 128 | default: 129 | logger.Fatalf("unknown proxy type: %s", proxyType) 130 | } 131 | 132 | go func() { 133 | err := serve(listener) 134 | cleanup() 135 | if err != nil { 136 | logger.Fatalf("failed to serve %s proxy on %s: %v", proxyType, address, err) 137 | } 138 | }() 139 | logger.Infof("started %s proxy on %s", proxyType, address) 140 | } 141 | -------------------------------------------------------------------------------- /service.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/url" 8 | "os" 9 | "strconv" 10 | "sync" 11 | "time" 12 | 13 | "tailscale.com/tsnet" 14 | ) 15 | 16 | type AddressType int 17 | 18 | const ( 19 | AddressTCP AddressType = iota 20 | AddressUNIXSocket 21 | AddressTailscaleTCP 22 | ) 23 | 24 | type ServiceContext struct { 25 | TsNet *tsnet.Server 26 | ShutdownCh chan struct{} 27 | ShutdownWg *sync.WaitGroup 28 | } 29 | 30 | type Service struct { 31 | ServiceContext *ServiceContext 32 | Config *ServiceConfig 33 | 34 | Name string 35 | ListenType AddressType 36 | ListenAddress string 37 | ListenPort int16 38 | ConnectType AddressType 39 | ConnectAddress string 40 | ConnectPort int16 41 | ConnectProxyProtocol bool 42 | LogLevel LogLevel 43 | Timeout time.Duration 44 | } 45 | 46 | func parsePort(portString string) (int16, error) { 47 | if portString == "" { 48 | return 0, fmt.Errorf("empty port") 49 | } else if port, err := strconv.ParseInt(portString, 10, 16); err != nil { 50 | return 0, fmt.Errorf("invalid port") 51 | } else { 52 | return int16(port), nil 53 | } 54 | } 55 | 56 | type urlType string 57 | 58 | const ( 59 | urlTypeListen urlType = "listen" 60 | urlTypeConnect urlType = "connect" 61 | ) 62 | 63 | func parseUrl(urlType urlType, urlString string) (addressType AddressType, address string, port int16, e error) { 64 | if url, err := url.Parse(urlString); err != nil { 65 | e = fmt.Errorf("failed to parse %s URL: %v", urlType, err) 66 | } else { 67 | switch url.Scheme { 68 | case "tcp": 69 | if port, err = parsePort(url.Port()); err != nil { 70 | e = fmt.Errorf("failed to parse %s port: %v", urlType, err) 71 | } else { 72 | addressType = AddressTCP 73 | address = url.Hostname() 74 | } 75 | case "unix": 76 | addressType = AddressUNIXSocket 77 | address = url.Path 78 | case "tailscale": 79 | // Allowed ListenAddress for Tailscale is "::" or "0.0.0.0" 80 | if urlType == urlTypeListen && (url.Hostname() != "::" && url.Hostname() != "0.0.0.0") { 81 | e = fmt.Errorf("invalid Tailscale %s address: %s (only \"::\" and \"0.0.0.0\" allowed)", urlType, url.Hostname()) 82 | } else if port, err = parsePort(url.Port()); err != nil { 83 | e = fmt.Errorf("failed to parse %s port: %v", urlType, err) 84 | } else { 85 | addressType = AddressTailscaleTCP 86 | address = url.Hostname() 87 | } 88 | default: 89 | e = fmt.Errorf("unsupported %s URL scheme: %s", urlType, url.Scheme) 90 | } 91 | } 92 | return 93 | } 94 | 95 | func CreateService(serviceContext *ServiceContext, name string, config *ServiceConfig) (service *Service, err error) { 96 | service = &Service{ 97 | ServiceContext: serviceContext, 98 | Config: config, 99 | Name: name, 100 | ConnectProxyProtocol: config.ProxyProtocol, 101 | LogLevel: config.LogLevel, 102 | Timeout: config.Timeout, 103 | } 104 | if service.ListenType, service.ListenAddress, service.ListenPort, err = parseUrl(urlTypeListen, config.Listen); err != nil { 105 | return nil, err 106 | } 107 | if service.ConnectType, service.ConnectAddress, service.ConnectPort, err = parseUrl(urlTypeConnect, config.Connect); err != nil { 108 | return nil, err 109 | } 110 | return 111 | } 112 | 113 | func (s *Service) Listen() (listener net.Listener, cleanup func(), err error) { 114 | switch s.ListenType { 115 | case AddressTCP: 116 | listener, err = net.Listen("tcp", net.JoinHostPort(s.ListenAddress, strconv.Itoa(int(s.ListenPort)))) 117 | cleanup = func() { 118 | listener.Close() 119 | } 120 | case AddressUNIXSocket: 121 | listener, err = net.Listen("unix", s.ListenAddress) 122 | cleanup = func() { 123 | listener.Close() 124 | os.Remove(s.ListenAddress) 125 | } 126 | case AddressTailscaleTCP: 127 | listener, err = s.ServiceContext.TsNet.Listen("tcp", ":"+strconv.Itoa(int(s.ListenPort))) 128 | cleanup = func() { 129 | listener.Close() 130 | } 131 | default: 132 | return nil, nil, fmt.Errorf("invalid listen address type: %v", s.ListenType) 133 | } 134 | return 135 | } 136 | 137 | func (s *Service) CreateConnector() (func() (net.Conn, error), error) { 138 | switch s.ConnectType { 139 | case AddressTCP: 140 | return func() (net.Conn, error) { 141 | return net.DialTimeout("tcp", s.ConnectAddress+":"+strconv.Itoa(int(s.ConnectPort)), s.Timeout) 142 | }, nil 143 | case AddressUNIXSocket: 144 | return func() (net.Conn, error) { 145 | return net.DialTimeout("unix", s.ConnectAddress, s.Timeout) 146 | }, nil 147 | case AddressTailscaleTCP: 148 | return func() (net.Conn, error) { 149 | ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) 150 | defer cancel() 151 | return s.ServiceContext.TsNet.Dial(ctx, "tcp", s.ConnectAddress+":"+strconv.Itoa(int(s.ConnectPort))) 152 | }, nil 153 | default: 154 | return nil, fmt.Errorf("invalid connect address type: %v", s.ConnectType) 155 | } 156 | } 157 | 158 | func (s *Service) Start() { 159 | logger := CreateLogger("services/"+s.Name, s.LogLevel) 160 | 161 | listener, cleanup, err := s.Listen() 162 | if err != nil { 163 | logger.Errorf("failed to create listener: %v", err) 164 | return 165 | } 166 | 167 | connector, err := s.CreateConnector() 168 | if err != nil { 169 | logger.Errorf("failed to create connector: %v", err) 170 | return 171 | } 172 | 173 | logger.Infof("listening on %s", s.Config.Listen) 174 | s.ServiceContext.ShutdownWg.Add(1) 175 | 176 | connCh := make(chan net.Conn) 177 | go func() { 178 | for { 179 | conn, err := listener.Accept() 180 | select { 181 | case <-s.ServiceContext.ShutdownCh: 182 | return 183 | default: 184 | if err != nil { 185 | logger.Errorf("failed to accept connection: %v", err) 186 | continue 187 | } 188 | logger.Verbosef("accepted connection from %v", conn.RemoteAddr()) 189 | connCh <- conn 190 | } 191 | } 192 | }() 193 | 194 | for { 195 | select { 196 | case <-s.ServiceContext.ShutdownCh: 197 | cleanup() 198 | s.ServiceContext.ShutdownWg.Done() 199 | return 200 | case conn := <-connCh: 201 | go func() { 202 | targetConn, err := connector() 203 | if err != nil { 204 | logger.Errorf("failed to connect to target: %v", err) 205 | conn.Close() 206 | return 207 | } 208 | logger.Verbosef("connected to target %v", targetConn.RemoteAddr()) 209 | 210 | if s.Config.ProxyProtocol { 211 | varsion, remoteIp, remotePort := tryExtractAddr(conn.RemoteAddr()) 212 | _, localIp, localPort := tryExtractAddr(conn.LocalAddr()) 213 | header := fmt.Sprintf("PROXY TCP%d %s %s %d %d\r\n", varsion, remoteIp, localIp, remotePort, localPort) 214 | logger.Verbosef("writing PROXY Protocol header: %v", header) 215 | if _, err := targetConn.Write([]byte(header)); err != nil { 216 | logger.Errorf("failed to write PROXY Protocol header: %v", err) 217 | targetConn.Close() 218 | conn.Close() 219 | return 220 | } 221 | } 222 | 223 | PipeAndClose(conn, targetConn, logger) 224 | }() 225 | } 226 | } 227 | } 228 | 229 | func tryExtractAddr(addr net.Addr) (version int, ip string, port int) { 230 | switch addr := addr.(type) { 231 | case *net.TCPAddr: 232 | if addr.IP.To4() == nil { 233 | version = 6 234 | } else { 235 | version = 4 236 | } 237 | ip = addr.IP.String() 238 | port = addr.Port 239 | default: 240 | version = 4 241 | ip = "0.0.0.0" 242 | port = 0 243 | } 244 | return 245 | } 246 | --------------------------------------------------------------------------------