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:
57
README.md
57
README.md
@@ -1,18 +1,22 @@
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# Get the tool
|
||||
go get github.com/turtlemonvh/tnnlr
|
||||
$ go get github.com/turtlemonvh/tnnlr
|
||||
|
||||
# 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
|
||||
cat > $GOPATH/src/github.com/turtlemonvh/tnnlr/.tunnlr << EOF
|
||||
$ cat > $GOPATH/src/github.com/turtlemonvh/tnnlr/.tunnlr << EOF
|
||||
[
|
||||
{
|
||||
"defaultUrl": "/",
|
||||
@@ -32,20 +36,55 @@ cat > $GOPATH/src/github.com/turtlemonvh/tnnlr/.tunnlr << EOF
|
||||
EOF
|
||||
|
||||
# Start up the web ui on localhost:8080
|
||||
tnnlr
|
||||
$ tnnlr
|
||||
|
||||
# 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
|
||||
|
||||
- Run tunnels in a goroutine
|
||||
- Serve logfiles through UI
|
||||
- Options to let tunnels continue running on shutdown
|
||||
- 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
|
||||
- Less ugly code
|
||||
- Less ugly UI
|
||||
- Command line interface
|
||||
- Poll in the background and keep tunnels open
|
||||
- Kill tunnels on shutdown
|
||||
- Make `ls` command do something useful
|
||||
|
||||
|
||||
34
opt.go
Normal file
34
opt.go
Normal 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)
|
||||
}
|
||||
23
templates.go
23
templates.go
@@ -5,6 +5,13 @@ var homePage string = `
|
||||
<head>
|
||||
<title>Tnnlr</title>
|
||||
<style>
|
||||
#tips {
|
||||
float: left;
|
||||
clear: left;
|
||||
}
|
||||
#tips > h2 {
|
||||
float: none;
|
||||
}
|
||||
table {
|
||||
float: left;
|
||||
clear: left;
|
||||
@@ -39,7 +46,7 @@ var homePage string = `
|
||||
{{ if $.HasMessages }}
|
||||
<h2>Messages</h2>
|
||||
{{range $nmsg, $msg := $.Messages }}
|
||||
<p class="msg">{{ $msg }}</p>
|
||||
<p class="msg">{{ $msg.String }}</p>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -113,5 +120,19 @@ var homePage string = `
|
||||
</table>
|
||||
|
||||
</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>
|
||||
`
|
||||
|
||||
157
tnnlr.go
157
tnnlr.go
@@ -7,6 +7,8 @@ import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -32,6 +34,7 @@ type Tnnlr struct {
|
||||
SshExec string // path to ssh executable
|
||||
LogLevel string
|
||||
TunnelReloadFile string
|
||||
Port int
|
||||
msgs chan Message
|
||||
tunnels map[string]*Tunnel
|
||||
}
|
||||
@@ -40,6 +43,16 @@ func (t *Tnnlr) Init() {
|
||||
var err error
|
||||
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
|
||||
t.Template, err = template.New("Homepage").Parse(homePage)
|
||||
if err != nil {
|
||||
@@ -57,6 +70,7 @@ func (t *Tnnlr) Init() {
|
||||
log.SetLevel(level)
|
||||
|
||||
// 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.tunnels = make(map[string]*Tunnel)
|
||||
|
||||
@@ -78,6 +92,8 @@ func (t *Tnnlr) AddMessage(msg string) {
|
||||
}
|
||||
|
||||
func (t *Tnnlr) Run() {
|
||||
go t.CleanBookkeepingDirs()
|
||||
|
||||
if log.GetLevel() != log.DebugLevel {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
@@ -90,7 +106,7 @@ func (t *Tnnlr) Run() {
|
||||
r.GET("/reload/:id", t.ReloadOne)
|
||||
r.GET("/bash_command/:id", t.ShowCommand)
|
||||
r.GET("/status/:id", t.ReloadOne)
|
||||
r.Run()
|
||||
r.Run(fmt.Sprintf(":%d", t.Port))
|
||||
}
|
||||
|
||||
// HTTP views
|
||||
@@ -241,6 +257,7 @@ func (t *Tnnlr) ReloadOne(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Errors are handled in the functions themselves
|
||||
// Stop process if it is running now
|
||||
t.RemoveTunnel(rTnnlId)
|
||||
t.AddTunnel(foundTnnl)
|
||||
|
||||
@@ -283,6 +300,11 @@ func (t *Tnnlr) Add(c *gin.Context) {
|
||||
func (t *Tnnlr) ShowCommand(c *gin.Context) {
|
||||
rTnnlId := c.Param("id")
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"id": rTnnlId,
|
||||
"tunnels": t.tunnels,
|
||||
}).Info("Showing command for tunnel")
|
||||
|
||||
tnnl, ok := t.tunnels[rTnnlId]
|
||||
if !ok {
|
||||
message := "Failed to find tunnel with the requested id"
|
||||
@@ -294,9 +316,7 @@ func (t *Tnnlr) ShowCommand(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"cmd": tnnl.getCommand(),
|
||||
})
|
||||
c.String(200, tnnl.getCommand())
|
||||
}
|
||||
|
||||
// Load from disk
|
||||
@@ -370,3 +390,132 @@ func (t *Tnnlr) KillAllTunnels() {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/timjchin/unpuzzled"
|
||||
"github.com/turtlemonvh/tnnlr"
|
||||
)
|
||||
@@ -10,6 +13,11 @@ import (
|
||||
// Config
|
||||
|
||||
func main() {
|
||||
logLevels := make([]string, len(logrus.AllLevels))
|
||||
for il, l := range logrus.AllLevels {
|
||||
logLevels[il] = l.String()
|
||||
}
|
||||
|
||||
myTnnlr := &tnnlr.Tnnlr{}
|
||||
app := unpuzzled.NewApp()
|
||||
app.Command = &unpuzzled.Command{
|
||||
@@ -18,8 +26,27 @@ func main() {
|
||||
&unpuzzled.StringVariable{
|
||||
Name: "log-level",
|
||||
Destination: &(myTnnlr.LogLevel),
|
||||
Description: fmt.Sprintf("Logging levels. Options are: [%s]", strings.Join(logLevels, ",")),
|
||||
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() {
|
||||
// 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)
|
||||
}
|
||||
|
||||
73
tunnel.go
73
tunnel.go
@@ -1,10 +1,16 @@
|
||||
package tnnlr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tunnels
|
||||
@@ -16,6 +22,7 @@ type Tunnel struct {
|
||||
Username string `form:"username" json:"userName"` // can be ""
|
||||
LocalPort int32 `form:"localPort" json:"localPort" binding:"required"`
|
||||
RemotePort int32 `form:"remotePort" json:"remotePort" binding:"required"`
|
||||
Pid int `json:"pid"` // not set until after process starts
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
@@ -24,7 +31,7 @@ func (t *Tunnel) getCommand() string {
|
||||
if t.Username != "" {
|
||||
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.RemotePort,
|
||||
remote,
|
||||
@@ -34,6 +41,10 @@ func (t *Tunnel) getCommand() string {
|
||||
// Check if process running the tunnel is alive
|
||||
// FIXME: Seems to return true even if process has exited
|
||||
func (t *Tunnel) IsAlive() bool {
|
||||
if !t.PortInUse() {
|
||||
return false
|
||||
}
|
||||
|
||||
if t.cmd == nil {
|
||||
return false
|
||||
}
|
||||
@@ -59,7 +70,30 @@ func (t *Tunnel) Validate() error {
|
||||
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
|
||||
/*
|
||||
Writes process information into ~/.tnnl/proc/XXX.pid
|
||||
Writes log information into ~/.tnnl/log/XXX.log
|
||||
*/
|
||||
func (t *Tunnel) Run(sshExec string) error {
|
||||
var err error
|
||||
|
||||
@@ -68,13 +102,45 @@ func (t *Tunnel) Run(sshExec string) error {
|
||||
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(), " ")
|
||||
cmd := exec.Command(sshExec, cmdParts[1:]...)
|
||||
cmd.Stdout = logOut
|
||||
cmd.Stderr = logOut
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -85,5 +151,10 @@ func (t *Tunnel) Stop() error {
|
||||
return t.cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
// Clear pid file path
|
||||
p, err := t.PidPath()
|
||||
if err == nil {
|
||||
os.Remove(p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user