diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..415db55 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +turbotunnel diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..50e7504 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2013 Zach Wily + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..27fd8da --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# TurboTunnel + +TurboTunnel creates on-demand ssh tunnels. It listens on local ports, +starts up ssh connections when something connects to those ports, and +proxies data through the remote tunnel. + +Trust me, it's magic. + +## Sample Config + +```yaml +tunnels: + - name: Work Intranet + localPort: 10001 + jumpHost: jump1.example.com + remoteHost: 10.0.13.10 + remotePort: 80 + - name: Work Active Directory RDP + localPort: 10002 + jumpHost: root@jump1.example.com + remoteHost: 10.0.0.4 + remotePort: 3389 +``` + +## Running + +```bash +$ turbotunnel -config /path/to/config.yml +``` + +## Using + +Once TurboTunnel is running, you can then open `http://localhost:10001` +in your browser. TurboTunnel will see the connection to port 10001 and +initiate an ssh connection to jump1.example.com forwarding a local port +to 10.0.13.10:80. TurboTunnel will then proxy all data between the +opened connection and the local tunnel. + +## Building + +```bash +$ go get +$ go build +``` + diff --git a/main.go b/main.go new file mode 100644 index 0000000..2058346 --- /dev/null +++ b/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "flag" + "github.com/zwily/turbotunnel/server" + "io/ioutil" + "launchpad.net/goyaml" + "log" + "os" + "os/signal" + "syscall" +) + +type TunnelDef struct { + Name string + LocalPort int `yaml:"localPort"` + JumpHost string `yaml:"jumpHost"` + RemoteHost string `yaml:"remoteHost"` + RemotePort int `yaml:"remotePort"` +} + +type Config struct { + Tunnels []TunnelDef +} + +func main() { + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) + + var configPath = flag.String("config", "", "Path to config file") + flag.Parse() + + configYaml, err := ioutil.ReadFile(*configPath) + if err != nil { + log.Fatal(err) + } + + var config Config + goyaml.Unmarshal(configYaml, &config) + + for _, t := range config.Tunnels { + s := server.New(t.Name, t.LocalPort, t.JumpHost, t.RemoteHost, t.RemotePort) + go s.Listen() + defer s.Close() + } + + sigchan := make(chan os.Signal) + signal.Notify(sigchan, syscall.SIGINT, syscall.SIGTERM) + <-sigchan +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..6725272 --- /dev/null +++ b/server/server.go @@ -0,0 +1,189 @@ +package server + +import ( + "fmt" + "github.com/tuxychandru/pubsub" + "log" + "net" + "os/exec" + "time" +) + +type Server struct { + name string + localPort int + jumpHost string + remoteHost string + remotePort int + proxyPort int + + pubsub *pubsub.PubSub + cmd *exec.Cmd +} + +func New(name string, localPort int, jumpHost string, remoteHost string, remotePort int) *Server { + s := &Server{ + name: name, + localPort: localPort, + jumpHost: jumpHost, + remoteHost: remoteHost, + remotePort: remotePort, + } + + s.pubsub = pubsub.New(0) + + return s +} + +func (s *Server) copyConn(from *net.TCPConn, to *net.TCPConn, complete chan bool) { + var err error + var bytes []byte = make([]byte, 1024) + var read int = 0 + for { + read, err = from.Read(bytes) + if err != nil { + complete <- true + break + } + + _, err = to.Write(bytes[:read]) + if err != nil { + complete <- true + break + } + } +} + +func (s *Server) proxyConn(lconn *net.TCPConn, rconn *net.TCPConn) { + // now proxy the connection + complete := make(chan bool) + go s.copyConn(lconn, rconn, complete) + go s.copyConn(rconn, lconn, complete) + <-complete + rconn.Close() + lconn.Close() +} + +func (s *Server) connectConn(lconn *net.TCPConn) { + var rconn *net.TCPConn + + for { + addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("localhost:%d", s.proxyPort)) + if err != nil { + log.Fatal(err) + } + + rconn, err = net.DialTCP("tcp", nil, addr) + if err == nil { + break + } + + // couldn't connect... wait for notice that we're connected + connectChan := s.pubsub.SubOnce("process-running") + log.Printf("%s: waiting for process...", s.name) + <-connectChan + } + + log.Printf("%s: connected to local port, proxying", s.name) + s.proxyConn(lconn, rconn) +} + +func (s *Server) heartbeat() { + c := time.Tick(1 * time.Second) + for _ = range c { + if s.cmd != nil { + s.pubsub.Pub(true, "process-running") + } + } +} + +func (s *Server) handlePending(in <-chan *net.TCPConn) { + sshDone := make(chan bool) + + go s.heartbeat() + + for { + select { + case <-sshDone: + log.Printf("%s: ssh closed", s.name) + s.cmd = nil + + case lconn := <-in: + log.Printf("%s: new connection received", s.name) + if s.cmd == nil { + // find an unused local port for the proxying + l, _ := net.Listen("tcp", "") + s.proxyPort = l.Addr().(*net.TCPAddr).Port + log.Printf("%s: using local proxy port %d", s.name, s.proxyPort) + l.Close() + + notifyCmd := exec.Command("/usr/local/bin/terminal-notifier", + "-title", s.name, + "-message", "Tunnel connecting", + ) + go notifyCmd.Run() + + cmd := exec.Command("/usr/bin/ssh", + "-N", + "-o", "ExitOnForwardFailure=true", + "-L", + fmt.Sprintf("localhost:%d:%s:%d", s.proxyPort, s.remoteHost, s.remotePort), + s.jumpHost, + ) + + log.Printf("%s: starting ssh\n", s.name) + + err := cmd.Start() + if err != nil { + log.Fatal(err) + } + s.cmd = cmd + + go func(cmd *exec.Cmd, done chan bool) { + cmd.Wait() + done <- true + }(cmd, sshDone) + } + + go s.connectConn(lconn) + } + } +} + +func (s *Server) Close() { + if s.cmd != nil { + log.Printf("%s: killing subprocess", s.name) + s.cmd.Process.Kill() + } +} + +func (s *Server) Listen() error { + var err error + + addrString := fmt.Sprintf("localhost:%d", s.localPort) + log.Println(addrString) + + addr, err := net.ResolveTCPAddr("tcp", addrString) + if err != nil { + log.Fatal(err) + } + + listener, err := net.ListenTCP("tcp", addr) + if err != nil { + log.Fatal(err) + } + + pending := make(chan *net.TCPConn) + go s.handlePending(pending) + + log.Printf("%s: listening on %d", s.name, s.localPort) + + for { + conn, err := listener.AcceptTCP() + if err != nil { + log.Fatal(err) + } + + pending <- conn + } +}