1
0
mirror of https://github.com/turtlemonvh/tnnlr.git synced 2021-09-26 15:22:24 +03:00

A whole lot of changes to make this thing usable. Fixes to http server, starting point for background process, logging.

This commit is contained in:
timothy
2018-08-11 16:07:00 -04:00
parent 38596472a9
commit 393a1efcc3
6 changed files with 366 additions and 15 deletions

View File

@@ -1,18 +1,22 @@
# Tunnlr # Tunnlr
Tnnlr is a simple utility to managing ssh tunnels. It is currently a very ugly work in progress. Tnnlr is a simple utility to managing ssh tunnels. It is currently a very ugly work in progress, but it does work
* keep tunnels open
* compose new tunnels using a helpful ui
* reload a whole set of tunnels quickly
## Usage ## Usage
```bash ```bash
# Get the tool # Get the tool
go get github.com/turtlemonvh/tnnlr $ go get github.com/turtlemonvh/tnnlr
# Install binary # Install binary
go install $GOPATH/src/github.com/turtlemonvh/tnnlr/tnnlr $ go install $GOPATH/src/github.com/turtlemonvh/tnnlr/tnnlr
# Create a config file with tunnels for this project # Create a config file with tunnels for this project
cat > $GOPATH/src/github.com/turtlemonvh/tnnlr/.tunnlr << EOF $ cat > $GOPATH/src/github.com/turtlemonvh/tnnlr/.tunnlr << EOF
[ [
{ {
"defaultUrl": "/", "defaultUrl": "/",
@@ -32,20 +36,55 @@ cat > $GOPATH/src/github.com/turtlemonvh/tnnlr/.tunnlr << EOF
EOF EOF
# Start up the web ui on localhost:8080 # Start up the web ui on localhost:8080
tnnlr $ tnnlr
# Go to localhost:8080 and click the "Reload Tunnels from File" button. # Go to localhost:8080 and click the "Reload Tunnels from File" button.
# Check help docs for more information
$ tnnlr -h
APP:
cli
COMMAND:
tnnlr
AVAILABLE SUBCOMMANDS:
ls : List running tunnels
help : Print this help message
PARSING ORDER: (set values will override in this order)
CLI Flag > Environment
VARIABLES:
+-------------+---------+----------+-----------+----------------------------------------+
| FLAG | DEFAULT | REQUIRED | ENV NAME | DESCRIPTION |
+-------------+---------+----------+-----------+----------------------------------------+
| --log-level | info | No | LOG_LEVEL | Logging levels. Options are: |
| | | | | [panic,fatal,error,warning,info,debug] |
| --tunnels | .tnnlr | No | TUNNELS | Configuration file listing |
| | | | | tunnels to load. |
| --ssh-exec | ssh | No | SSH_EXEC | The executable process to use |
| | | | | for ssh. Can be a full path or |
| | | | | just a cmd name. |
| --port | 8080 | No | PORT | The port to run the webserver |
| | | | | on. |
+-------------+---------+----------+-----------+----------------------------------------+
Authors:
Timothy Van Heest (timothy@ionic.com)
``` ```
## TODO ## TODO
- Run tunnels in a goroutine - Serve logfiles through UI
- Options to let tunnels continue running on shutdown
- Option for https default url - Option for https default url
- Updates and cleanup to configuration
- Option to load whole sets of tunnels at a time easily, via file select in browser - Option to load whole sets of tunnels at a time easily, via file select in browser
- Less ugly code - Less ugly code
- Less ugly UI - Less ugly UI
- Command line interface - Command line interface
- Poll in the background and keep tunnels open - Make `ls` command do something useful
- Kill tunnels on shutdown

34
opt.go Normal file
View File

@@ -0,0 +1,34 @@
package tnnlr
import (
"os"
"path/filepath"
"github.com/mitchellh/go-homedir"
)
/*
Bookkeeping operations for tnnlr.
Logging and pid tracking for launched processes.
*/
var baseDir = "~/.tnnlr"
var relProc = "proc"
var relLog = "log"
func getRelativePath(subdir string) (string, error) {
basePath, err := homedir.Expand(baseDir)
if err != nil {
return "", err
}
return filepath.Join(basePath, subdir), nil
}
func createRelDir(subdir string) error {
procDir, err := getRelativePath(subdir)
if err != nil {
return err
}
return os.MkdirAll(procDir, os.ModePerm)
}

View File

@@ -5,6 +5,13 @@ var homePage string = `
<head> <head>
<title>Tnnlr</title> <title>Tnnlr</title>
<style> <style>
#tips {
float: left;
clear: left;
}
#tips > h2 {
float: none;
}
table { table {
float: left; float: left;
clear: left; clear: left;
@@ -39,7 +46,7 @@ var homePage string = `
{{ if $.HasMessages }} {{ if $.HasMessages }}
<h2>Messages</h2> <h2>Messages</h2>
{{range $nmsg, $msg := $.Messages }} {{range $nmsg, $msg := $.Messages }}
<p class="msg">{{ $msg }}</p> <p class="msg">{{ $msg.String }}</p>
{{ end }} {{ end }}
{{ end }} {{ end }}
@@ -113,5 +120,19 @@ var homePage string = `
</table> </table>
</form> </form>
<div id="tips">
<hr>
<h2>Tips</h2>
<ul>
<li>
The process may be marked "not alive" because of a network timeout. Try reloading this page to check status again.
</li>
<li>
Running "reload" both re-loads the definition of a process disk and restarts that process. Be sure to save any edited process state to disk before reloading.
</li>
</ul>
</div>
</body> </body>
` `

157
tnnlr.go
View File

@@ -7,6 +7,8 @@ import (
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -32,6 +34,7 @@ type Tnnlr struct {
SshExec string // path to ssh executable SshExec string // path to ssh executable
LogLevel string LogLevel string
TunnelReloadFile string TunnelReloadFile string
Port int
msgs chan Message msgs chan Message
tunnels map[string]*Tunnel tunnels map[string]*Tunnel
} }
@@ -40,6 +43,16 @@ func (t *Tnnlr) Init() {
var err error var err error
var level log.Level var level log.Level
// Create bookkeeping directories
for _, dir := range []string{relProc, relLog} {
if err = createRelDir(dir); err != nil {
log.WithFields(log.Fields{
"err": err,
"dir": dir,
}).Fatal("Unable to create bookkeeping directory")
}
}
// Parse template // Parse template
t.Template, err = template.New("Homepage").Parse(homePage) t.Template, err = template.New("Homepage").Parse(homePage)
if err != nil { if err != nil {
@@ -57,6 +70,7 @@ func (t *Tnnlr) Init() {
log.SetLevel(level) log.SetLevel(level)
// A generously buffered channel // A generously buffered channel
// FIXME: Use a list for messages instead so app doesn't lock when queue is full
t.msgs = make(chan Message, 100) t.msgs = make(chan Message, 100)
t.tunnels = make(map[string]*Tunnel) t.tunnels = make(map[string]*Tunnel)
@@ -78,6 +92,8 @@ func (t *Tnnlr) AddMessage(msg string) {
} }
func (t *Tnnlr) Run() { func (t *Tnnlr) Run() {
go t.CleanBookkeepingDirs()
if log.GetLevel() != log.DebugLevel { if log.GetLevel() != log.DebugLevel {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
@@ -90,7 +106,7 @@ func (t *Tnnlr) Run() {
r.GET("/reload/:id", t.ReloadOne) r.GET("/reload/:id", t.ReloadOne)
r.GET("/bash_command/:id", t.ShowCommand) r.GET("/bash_command/:id", t.ShowCommand)
r.GET("/status/:id", t.ReloadOne) r.GET("/status/:id", t.ReloadOne)
r.Run() r.Run(fmt.Sprintf(":%d", t.Port))
} }
// HTTP views // HTTP views
@@ -241,6 +257,7 @@ func (t *Tnnlr) ReloadOne(c *gin.Context) {
} }
// Errors are handled in the functions themselves // Errors are handled in the functions themselves
// Stop process if it is running now
t.RemoveTunnel(rTnnlId) t.RemoveTunnel(rTnnlId)
t.AddTunnel(foundTnnl) t.AddTunnel(foundTnnl)
@@ -283,6 +300,11 @@ func (t *Tnnlr) Add(c *gin.Context) {
func (t *Tnnlr) ShowCommand(c *gin.Context) { func (t *Tnnlr) ShowCommand(c *gin.Context) {
rTnnlId := c.Param("id") rTnnlId := c.Param("id")
log.WithFields(log.Fields{
"id": rTnnlId,
"tunnels": t.tunnels,
}).Info("Showing command for tunnel")
tnnl, ok := t.tunnels[rTnnlId] tnnl, ok := t.tunnels[rTnnlId]
if !ok { if !ok {
message := "Failed to find tunnel with the requested id" message := "Failed to find tunnel with the requested id"
@@ -294,9 +316,7 @@ func (t *Tnnlr) ShowCommand(c *gin.Context) {
return return
} }
c.JSON(200, gin.H{ c.String(200, tnnl.getCommand())
"cmd": tnnl.getCommand(),
})
} }
// Load from disk // Load from disk
@@ -370,3 +390,132 @@ func (t *Tnnlr) KillAllTunnels() {
} }
t.Unlock() t.Unlock()
} }
func (t *Tnnlr) ManagedTunnels() map[string]bool {
t.Lock()
var tunnelIds = make(map[string]bool)
for tnnlId, _ := range t.tunnels {
tunnelIds[tnnlId] = true
}
t.Unlock()
return tunnelIds
}
/*
Background cleanup and management of jobs
TODO
- check cmd and pid information directly to see if process is running instead of checking port
- option to leave process running when tnnlr shuts down
*/
func (t *Tnnlr) CleanBookkeepingDirs() {
procDir, err := getRelativePath(relProc)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("Unable to check proc dir to reap processes")
return
}
logDir, err := getRelativePath(relLog)
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("Unable to check log dir to clean up logs")
return
}
for {
// Loop every 10 seconds
time.Sleep(10 * time.Second)
// Based on scanning pid dir
runningProcesses := make(map[string]bool)
// From the state of the program
managedProcesses := t.ManagedTunnels()
log.WithFields(log.Fields{
"dir": procDir,
}).Debug("Scanning pid dir for changed files")
// Load all tunnels
// Clean up any pid files associated with tunnels that are not currently running and are not known to the current live process
pidFiles, err := filepath.Glob(fmt.Sprintf("%s/*.pid", procDir))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("Error listing files in proc dir")
continue
}
for _, pf := range pidFiles {
c, e := ioutil.ReadFile(pf)
if e != nil {
os.Remove(pf)
}
var tnnl Tunnel
err = json.Unmarshal(c, &tnnl)
if e != nil {
os.Remove(pf)
}
// Check if it this process is running
if !tnnl.PortInUse() {
// The process is dead.
// Check if we should be running this and restart.
if managedProcesses[tnnl.Id] {
// Should restart
log.WithFields(log.Fields{
"id": tnnl.Id,
"name": tnnl.Name,
"cmd": tnnl.getCommand(),
}).Info("Found dead process, restarting")
tnnl.Run(t.SshExec)
runningProcesses[tnnl.Id] = true
} else {
// Cleanup
log.WithFields(log.Fields{
"id": tnnl.Id,
"name": tnnl.Name,
"cmd": tnnl.getCommand(),
}).Info("Found dead process, cleaning up")
tnnl.Stop()
}
} else {
log.WithFields(log.Fields{
"id": tnnl.Id,
"name": tnnl.Name,
"cmd": tnnl.getCommand(),
}).Debug("Found running process in pid dir")
runningProcesses[tnnl.Id] = true
}
}
// Load all logfiles
// Clean up any not associated with a process that is live or being restarted
logFiles, err := filepath.Glob(fmt.Sprintf("%s/*.log", logDir))
if err != nil {
log.WithFields(log.Fields{
"err": err,
}).Error("Error listing files in log dir")
continue
}
for _, lf := range logFiles {
// Get the id from the filename
pts := strings.Split(lf, "/")
lpt := pts[len(pts)-1]
id := strings.Split(lpt, ".log")[0]
// If this isn't running, delete it
if !runningProcesses[id] {
log.WithFields(log.Fields{
"logFile": lf,
}).Info("Removing logfile for dead process")
os.Remove(lf)
}
}
}
}

View File

@@ -1,8 +1,11 @@
package main package main
import ( import (
"fmt"
"os" "os"
"strings"
"github.com/sirupsen/logrus"
"github.com/timjchin/unpuzzled" "github.com/timjchin/unpuzzled"
"github.com/turtlemonvh/tnnlr" "github.com/turtlemonvh/tnnlr"
) )
@@ -10,6 +13,11 @@ import (
// Config // Config
func main() { func main() {
logLevels := make([]string, len(logrus.AllLevels))
for il, l := range logrus.AllLevels {
logLevels[il] = l.String()
}
myTnnlr := &tnnlr.Tnnlr{} myTnnlr := &tnnlr.Tnnlr{}
app := unpuzzled.NewApp() app := unpuzzled.NewApp()
app.Command = &unpuzzled.Command{ app.Command = &unpuzzled.Command{
@@ -18,8 +26,27 @@ func main() {
&unpuzzled.StringVariable{ &unpuzzled.StringVariable{
Name: "log-level", Name: "log-level",
Destination: &(myTnnlr.LogLevel), Destination: &(myTnnlr.LogLevel),
Description: fmt.Sprintf("Logging levels. Options are: [%s]", strings.Join(logLevels, ",")),
Default: "info", Default: "info",
}, },
&unpuzzled.StringVariable{
Name: "tunnels",
Destination: &(myTnnlr.TunnelReloadFile),
Description: "Configuration file listing tunnels to load.",
Default: ".tnnlr",
},
&unpuzzled.StringVariable{
Name: "ssh-exec",
Destination: &(myTnnlr.SshExec),
Description: "The executable process to use for ssh. Can be a full path or just a cmd name.",
Default: "ssh",
},
&unpuzzled.IntVariable{
Name: "port",
Destination: &(myTnnlr.Port),
Description: "The port to run the webserver on.",
Default: 8080,
},
}, },
Action: func() { Action: func() {
// Run website // Run website
@@ -38,5 +65,15 @@ func main() {
}, },
}, },
} }
app.Authors = []unpuzzled.Author{
{
Name: "Timothy Van Heest",
Email: "timothy@ionic.com",
},
}
app.ParsingOrder = []unpuzzled.ParsingType{
unpuzzled.EnvironmentVariables,
unpuzzled.CliFlags,
}
app.Run(os.Args) app.Run(os.Args)
} }

View File

@@ -1,10 +1,16 @@
package tnnlr package tnnlr
import ( import (
"encoding/json"
"fmt" "fmt"
"io/ioutil"
"net"
"os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"syscall" "syscall"
"time"
) )
// Tunnels // Tunnels
@@ -16,6 +22,7 @@ type Tunnel struct {
Username string `form:"username" json:"userName"` // can be "" Username string `form:"username" json:"userName"` // can be ""
LocalPort int32 `form:"localPort" json:"localPort" binding:"required"` LocalPort int32 `form:"localPort" json:"localPort" binding:"required"`
RemotePort int32 `form:"remotePort" json:"remotePort" binding:"required"` RemotePort int32 `form:"remotePort" json:"remotePort" binding:"required"`
Pid int `json:"pid"` // not set until after process starts
cmd *exec.Cmd cmd *exec.Cmd
} }
@@ -24,7 +31,7 @@ func (t *Tunnel) getCommand() string {
if t.Username != "" { if t.Username != "" {
remote = fmt.Sprintf("%s@%s", t.Username, remote) remote = fmt.Sprintf("%s@%s", t.Username, remote)
} }
return fmt.Sprintf(`ssh -L %d:localhost:%d %s -N`, return fmt.Sprintf(`ssh -v -L %d:localhost:%d %s -N`,
t.LocalPort, t.LocalPort,
t.RemotePort, t.RemotePort,
remote, remote,
@@ -34,6 +41,10 @@ func (t *Tunnel) getCommand() string {
// Check if process running the tunnel is alive // Check if process running the tunnel is alive
// FIXME: Seems to return true even if process has exited // FIXME: Seems to return true even if process has exited
func (t *Tunnel) IsAlive() bool { func (t *Tunnel) IsAlive() bool {
if !t.PortInUse() {
return false
}
if t.cmd == nil { if t.cmd == nil {
return false return false
} }
@@ -59,7 +70,30 @@ func (t *Tunnel) Validate() error {
return nil return nil
} }
func (t *Tunnel) LogPath() (string, error) {
return getRelativePath(filepath.Join(relLog, fmt.Sprintf("%s.log", t.Id)))
}
func (t *Tunnel) PidPath() (string, error) {
return getRelativePath(filepath.Join(relProc, fmt.Sprintf("%s.pid", t.Id)))
}
func (t *Tunnel) PortInUse() bool {
// Check if this port is already in use
// https://stackoverflow.com/questions/40296483/continuously-check-if-tcp-port-is-in-use
conn, _ := net.DialTimeout("tcp", net.JoinHostPort("", fmt.Sprintf("%d", t.LocalPort)), time.Duration(1*time.Millisecond))
if conn != nil {
conn.Close()
return true
}
return false
}
// Run the cmd and set the active process // Run the cmd and set the active process
/*
Writes process information into ~/.tnnl/proc/XXX.pid
Writes log information into ~/.tnnl/log/XXX.log
*/
func (t *Tunnel) Run(sshExec string) error { func (t *Tunnel) Run(sshExec string) error {
var err error var err error
@@ -68,13 +102,45 @@ func (t *Tunnel) Run(sshExec string) error {
return err return err
} }
if t.PortInUse() {
return fmt.Errorf("Port %d is already in use", t.LocalPort)
}
// Set up logging and launch task
// FIXME: Allow this to live beyond the life of the process
logPath, err := t.LogPath()
if err != nil {
return err
}
logOut, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
cmdParts := strings.Split(t.getCommand(), " ") cmdParts := strings.Split(t.getCommand(), " ")
cmd := exec.Command(sshExec, cmdParts[1:]...) cmd := exec.Command(sshExec, cmdParts[1:]...)
cmd.Stdout = logOut
cmd.Stderr = logOut
err = cmd.Start() err = cmd.Start()
if err != nil { if err != nil {
return err return err
} }
t.cmd = cmd t.cmd = cmd
t.Pid = cmd.Process.Pid
// Write JSON representation of task to pid file
pidPath, err := t.PidPath()
if err != nil {
return err
}
tJSON, err := json.Marshal(t)
if err != nil {
return err
}
err = ioutil.WriteFile(pidPath, tJSON, 0644)
if err != nil {
return err
}
return nil return nil
} }
@@ -85,5 +151,10 @@ func (t *Tunnel) Stop() error {
return t.cmd.Process.Kill() return t.cmd.Process.Kill()
} }
} }
// Clear pid file path
p, err := t.PidPath()
if err == nil {
os.Remove(p)
}
return nil return nil
} }