diff --git a/.gitignore b/.gitignore index 1f2c696..255d3cd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ go.work.sum # built binary /tsukasa + +# debug +/config.yaml +/state diff --git a/LICENSE.Tailscale b/LICENSE.Tailscale new file mode 100644 index 0000000..394db19 --- /dev/null +++ b/LICENSE.Tailscale @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2020 Tailscale Inc & AUTHORS. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index fdd07cf..5a077c1 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Tsusaka is a flexible port forwarder among: * TCP Ports * UNIX Sockets * Tailscale TCP Ports (without Tailscale daemon or TUN/TAP permission! This is made possible with [tsnet](https://tailscale.com/kb/1244/tsnet)) + * Also supports SOCKS5/HTTP proxy feature of `tailscaled`. * If you don't use Tailscale features, it won't initialize Tailscale components and just behaves like a local port forwarder. It also supports passing the client IP with PROXY protocol (for listening on TCP or Tailscale TCP). @@ -24,6 +25,8 @@ Use with command-line configuration: --ts-authkey "$TS_AUTHKEY" \ --ts-ephemeral false \ --ts-state-dir /var/lib/tailscale \ + --ts-listen-socks5 localhost:1080 \ + --ts-listen-http localhost:8080 \ --ts-verbose true \ nginx,listen=tailscale://0.0.0.0:80,connect=tcp://127.0.0.1:8080,log-level=info,proxy-protocol \ myapp,listen=unix:/var/run/myapp.sock,connect=tailscale://app-hosted-in-tailnet:8080 @@ -39,6 +42,9 @@ tailscale: authKey: null ephemeral: false stateDir: /var/lib/tailscale + listen: + socks5: 1080 + http: 8080 verbose: true services: nginx: diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..c103298 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,20 @@ +# Tailscale configuration is not required and Tailsccale will not be loaded if no services with Tailscale defined. +tailscale: + hostname: Tsusaka + # `null` to Use `TS_AUTHKEY` from environment or interactive login. + authKey: null + ephemeral: false + stateDir: /var/lib/tailscale + listen: + socks5: 1080 + http: 8080 + verbose: true +services: + nginx: + listen: tailscale://0.0.0.0:80 # Only "0.0.0.0" and "::" allowed in Tailscale listener. + connect: tcp://127.0.0.1:8080 + logLevel: info # "error" / "info" / "verbose". By default "info". + proxyProtocol: true # Listening on UNIX socket doesn't support PROXY protocol. + myapp: + listen: unix:/var/run/myapp.sock + connect: tailscale://app-hosted-in-tailnet:8080 diff --git a/config.go b/config.go index f70bab1..5d41ff5 100644 --- a/config.go +++ b/config.go @@ -5,17 +5,24 @@ import ( "fmt" "os" "regexp" + "strconv" "strings" "gopkg.in/yaml.v2" ) +type TailscaleListenConfig struct { + Socks5 string `yaml:"socks5,omitempty"` + HTTP string `yaml:"http,omitempty"` +} + type TailscaleConfig struct { - Hostname string `yaml:"hostname,omitempty"` - AuthKey string `yaml:"authKey"` - Ephemeral bool `yaml:"ephemeral,omitempty"` - StateDir string `yaml:"stateDir"` - Verbose bool `yaml:"verbose,omitempty"` + Hostname string `yaml:"hostname,omitempty"` + AuthKey string `yaml:"authKey"` + Ephemeral bool `yaml:"ephemeral,omitempty"` + StateDir string `yaml:"stateDir"` + Listen TailscaleListenConfig `yaml:"listen,omitempty"` + Verbose bool `yaml:"verbose,omitempty"` } type ServiceConfig struct { @@ -47,27 +54,48 @@ type Config struct { Services map[string]*ServiceConfig `yaml:"services"` } -// A private struct to hold the command-line flags +type boolFlag struct { + value bool + set bool +} + +func (f *boolFlag) Set(value string) error { + v, err := strconv.ParseBool(value) + if err != nil { + return err + } + f.value = v + f.set = true + return nil +} + +func (f *boolFlag) String() string { + return strconv.FormatBool(f.value) +} + type arguments struct { - conf *string - tsHostname *string - tsAuthKey *string - tsEphemeral *bool - tsStateDir *string - tsVerbose *bool + conf string + tsHostname string + tsAuthKey string + tsEphemeral boolFlag + tsStateDir string + tsListenSocks5 string + tsListenHttp string + tsVerbose boolFlag services []string } func parseArguments() *arguments { - flags := &arguments{ - conf: flag.String("conf", "", "YAML Configuration file"), - tsHostname: flag.String("ts-hostname", "", "Tailscale hostname"), - tsAuthKey: flag.String("ts-authkey", "", "Tailscale authentication key (default to $TS_AUTHKEY)"), - tsEphemeral: flag.Bool("ts-ephemeral", false, "Set the Tailscale host to ephemeral"), - tsStateDir: flag.String("ts-state-dir", "", "Tailscale state directory"), - tsVerbose: flag.Bool("ts-verbose", false, "Print Tailscale logs"), - } + flags := &arguments{} + flag.StringVar(&flags.conf, "conf", "", "YAML Configuration file") + flag.StringVar(&flags.tsHostname, "ts-hostname", "", "Tailscale hostname") + flag.StringVar(&flags.tsAuthKey, "ts-authkey", "", "Tailscale authentication key (default to $TS_AUTHKEY)") + flag.Var(&flags.tsEphemeral, "ts-ephemeral", "Set the Tailscale host to ephemeral") + flag.StringVar(&flags.tsStateDir, "ts-state-dir", "", "Tailscale state directory") + flag.StringVar(&flags.tsListenSocks5, "ts-listen-socks5", "", "Start SOCKS5 proxy server on [host]:port to access Tailnet") + flag.StringVar(&flags.tsListenHttp, "ts-listen-http", "", "Start HTTP proxy server on [host]:port to access Tailnet") + flag.Var(&flags.tsVerbose, "ts-verbose", "Print Tailscale logs") flag.Usage = func() { f := flag.CommandLine.Output() fmt.Fprintf(f, "Usage: %s [options] service1 service2 ...\n", os.Args[0]) @@ -78,10 +106,11 @@ func parseArguments() *arguments { fmt.Fprintln(f, " --ts-authkey \"$TS_AUTHKEY\" \\") fmt.Fprintln(f, " --ts-ephemeral false \\") fmt.Fprintln(f, " --ts-state-dir /var/lib/tailscale \\") + fmt.Fprintln(f, " --ts-listen-socks5 127.0.0.1:1118 \\") + fmt.Fprintln(f, " --ts-listen-http 127.0.0.1:8080 \\") fmt.Fprintln(f, " --ts-verbose true \\") fmt.Fprintln(f, " nginx,listen=tailscale://0.0.0.0:80,connect=tcp://127.0.0.1:8080,log-level=info,proxy-protocol \\") fmt.Fprintln(f, " myapp,listen=unix:/var/run/myapp.sock,connect=tailscale://app-hosted-in-tailnet:8080") - } flag.Parse() flags.services = flag.Args() @@ -147,24 +176,32 @@ func parseService(s string) (name string, service *ServiceConfig, err error) { } func mergeConfig(c *Config, a *arguments) error { - if a.tsHostname != nil && *a.tsHostname != "" { - c.Tailscale.Hostname = *a.tsHostname + if a.tsHostname != "" { + c.Tailscale.Hostname = a.tsHostname + } + + if a.tsAuthKey != "" { + c.Tailscale.AuthKey = a.tsAuthKey + } + + if a.tsEphemeral.set { + c.Tailscale.Ephemeral = a.tsEphemeral.value } - if a.tsAuthKey != nil && *a.tsAuthKey != "" { - c.Tailscale.AuthKey = *a.tsAuthKey + if a.tsStateDir != "" { + c.Tailscale.StateDir = a.tsStateDir } - if a.tsEphemeral != nil { - c.Tailscale.Ephemeral = *a.tsEphemeral + if a.tsListenSocks5 != "" { + c.Tailscale.Listen.Socks5 = a.tsListenSocks5 } - if a.tsStateDir != nil && *a.tsStateDir != "" { - c.Tailscale.StateDir = *a.tsStateDir + if a.tsListenHttp != "" { + c.Tailscale.Listen.HTTP = a.tsListenHttp } - if a.tsVerbose != nil { - c.Tailscale.Verbose = *a.tsVerbose + if a.tsVerbose.set { + c.Tailscale.Verbose = a.tsVerbose.value } for _, s := range a.services { @@ -211,8 +248,8 @@ func GetConfig() (*Config, error) { Tailscale: TailscaleConfig{}, Services: make(map[string]*ServiceConfig), } - if *a.conf != "" { - f, err := os.Open(*a.conf) + if a.conf != "" { + f, err := os.Open(a.conf) if err != nil { return nil, err } diff --git a/main.go b/main.go index 0d43df0..e42679d 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - "log" "os" "os/signal" "sync" @@ -13,7 +12,7 @@ import ( type Logf func(format string, args ...any) func main() { - logger := CreateLogger("tsukasa", Info) + logger := CreateLogger("main", Info) config, err := GetConfig() if err != nil { @@ -29,12 +28,16 @@ func main() { tsnet.AuthKey = config.Tailscale.AuthKey tsnet.Ephemeral = config.Tailscale.Ephemeral tsnet.Dir = config.Tailscale.StateDir - tsnet.UserLogf = log.New(os.Stderr, "[tsnet] ", log.LstdFlags).Printf + + var tsLogLevel LogLevel if config.Tailscale.Verbose { - tsnet.Logf = tsnet.UserLogf + tsLogLevel = Verbose } else { - tsnet.Logf = func(format string, args ...any) {} + tsLogLevel = Info } + tsLogger := CreateLogger("tailscale", tsLogLevel) + tsnet.Logf = tsLogger.Verbosef + tsnet.UserLogf = tsLogger.Infof shutdownCh := make(chan struct{}) shutdownWg := &sync.WaitGroup{} @@ -45,6 +48,10 @@ func main() { } usingTailscale := false + if config.Tailscale.Listen.Socks5 != "" || config.Tailscale.Listen.HTTP != "" { + usingTailscale = true + } + var services []*Service for name, serviceConfig := range config.Services { service, err := CreateService(serviceContext, name, serviceConfig) @@ -70,14 +77,27 @@ func main() { defer tsnet.Close() } - // Start services. - if len(services) == 0 { - logger.Fatalf("no services defined. run %s -h for help", os.Args[0]) + somethingRunning := false + + if config.Tailscale.Listen.Socks5 != "" { + somethingRunning = true + StartProxy(tsLogger, config.Tailscale.Listen.Socks5, tsnet.Dial, Socks5) } + if config.Tailscale.Listen.HTTP != "" { + somethingRunning = true + StartProxy(tsLogger, config.Tailscale.Listen.HTTP, tsnet.Dial, HTTP) + } + + // Start services. for _, service := range services { + somethingRunning = true go service.Start() } + if !somethingRunning { + logger.Fatalf("no listener defined. run %s -h for help", os.Args[0]) + } + // Wait for signal to shutdown. c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) diff --git a/proxy.go b/proxy.go new file mode 100644 index 0000000..c0b48ea --- /dev/null +++ b/proxy.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httputil" + "strings" + + "tailscale.com/net/socks5" +) + +// Copied from https://github.com/tailscale/tailscale/blob/a2c42d3cd4e914b8ac879ac0a21c284ecaf143fc/cmd/tailscaled/proxy.go#L21 +// +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause +// +// httpProxyHandler returns an HTTP proxy http.Handler using the +// provided backend dialer. +func httpProxyHandler(dialer func(ctx context.Context, netw, addr string) (net.Conn, error)) http.Handler { + rp := &httputil.ReverseProxy{ + Director: func(r *http.Request) {}, // no change + Transport: &http.Transport{ + DialContext: dialer, + }, + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "CONNECT" { + backURL := r.RequestURI + if strings.HasPrefix(backURL, "/") || backURL == "*" { + http.Error(w, "bogus RequestURI; must be absolute URL or CONNECT", 400) + return + } + rp.ServeHTTP(w, r) + return + } + + // CONNECT support: + + dst := r.RequestURI + c, err := dialer(r.Context(), "tcp", dst) + if err != nil { + w.Header().Set("Tailscale-Connect-Error", err.Error()) + http.Error(w, err.Error(), 500) + return + } + defer c.Close() + + cc, ccbuf, err := w.(http.Hijacker).Hijack() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer cc.Close() + + io.WriteString(cc, "HTTP/1.1 200 OK\r\n\r\n") + + var clientSrc io.Reader = ccbuf + if ccbuf.Reader.Buffered() == 0 { + // In the common case (with no + // buffered data), read directly from + // the underlying client connection to + // save some memory, letting the + // bufio.Reader/Writer get GC'ed. + clientSrc = cc + } + + errc := make(chan error, 1) + go func() { + _, err := io.Copy(cc, c) + errc <- err + }() + go func() { + _, err := io.Copy(c, clientSrc) + errc <- err + }() + <-errc + }) +} + +type ProxyType string + +const ( + Socks5 ProxyType = "SOCKS5" + HTTP ProxyType = "HTTP" +) + +type Dialer func(ctx context.Context, network, address string) (net.Conn, error) + +func StartProxy(logger *Logger, address string, dialer Dialer, proxyType ProxyType) { + listener, err := net.Listen("tcp", address) + if err != nil { + logger.Fatalf("failed to start %s proxy on %s: %v", proxyType, address, err) + } + + var serve func(listener net.Listener) error + switch proxyType { + case Socks5: + ss := &socks5.Server{ + Logf: logger.Verbosef, + Dialer: dialer, + } + serve = ss.Serve + case HTTP: + hs := &http.Server{ + Handler: httpProxyHandler(dialer), + } + serve = hs.Serve + default: + logger.Fatalf("unknown proxy type: %s", proxyType) + } + + go func() { + if err := serve(listener); err != nil { + logger.Fatalf("failed to serve %s proxy on %s: %v", proxyType, address, err) + } + }() + logger.Infof("started %s proxy on %s", proxyType, address) +}