Initial commit

This commit is contained in:
Trevor Slocum 2020-01-04 13:22:45 -08:00
commit d829810b8b
13 changed files with 468 additions and 0 deletions

14
.builds/amd64_freebsd.yml Normal file
View File

@ -0,0 +1,14 @@
arch: amd64
environment:
PROJECT_NAME: 'sshtargate'
CGO_ENABLED: '1'
GO111MODULE: 'on'
image: freebsd/latest
packages:
- go
sources:
- https://git.sr.ht/~tslocum/sshtargate
tasks:
- test: |
cd $PROJECT_NAME
go test ./...

View File

@ -0,0 +1,14 @@
arch: x86_64
environment:
PROJECT_NAME: 'sshtargate'
CGO_ENABLED: '1'
GO111MODULE: 'on'
image: alpine/edge
packages:
- go
sources:
- https://git.sr.ht/~tslocum/sshtargate
tasks:
- test: |
cd $PROJECT_NAME
go test ./...

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
.idea/
dist/
vendor/
sshtargate
*.sh

2
CHANGELOG Normal file
View File

@ -0,0 +1,2 @@
0.1.0:
- Initial release

33
CONFIGURATION.md Normal file
View File

@ -0,0 +1,33 @@
This document explains how to configure sshtargate.
# Configuration
Specify the path to a configuration file with ```--config``` or create a file
at the default path of ```~/.config/sshtargate/config.yaml```.
Define one or more **portals** by name/label with the following options:
- **command** - Command to execute
- **host** - One or more addresses to listen for connections
# Example config.yaml
```
portals:
date and time:
command: date
host:
- localhost:19001
uname:
command: uname -a
host:
- localhost:19002
process list:
command: ps -aux
host:
- localhost:19003
system monitor:
command: htop
host:
- localhost:19004
```

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Trevor Slocum <trevor@rocketnine.space>
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.

28
README.md Normal file
View File

@ -0,0 +1,28 @@
# sshtargate
[![GoDoc](https://godoc.org/git.sr.ht/~tslocum/sshtargate?status.svg)](https://godoc.org/git.sr.ht/~tslocum/sshtargate)
[![builds.sr.ht status](https://builds.sr.ht/~tslocum/sshtargate.svg)](https://builds.sr.ht/~tslocum/sshtargate)
[![Donate](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space)
Host SSH portals to applications
## Install
Choose one of the following methods:
### Download
[**Download sshtargate**](https://sshtargate.rocketnine.space/download/?sort=name&order=desc)
### Compile
```
GO111MODULE=on go get git.sr.ht/~tslocum/sshtargate
```
## Configure
See [CONFIGURATION.md](https://man.sr.ht/~tslocum/sshtargate/CONFIGURATION.md)
## Support
Please share issues/suggestions [here](https://todo.sr.ht/~tslocum/sshtargate).

38
config.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"fmt"
"io/ioutil"
"log"
"gopkg.in/yaml.v2"
)
type PortalConfig struct {
Command string
Host []string `yaml:",flow"`
}
type Config struct {
Portals map[string]*PortalConfig
}
var config = &Config{}
func readConfig(configPath string) error {
configData, err := ioutil.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read file: %s", err)
}
err = yaml.Unmarshal(configData, config)
if err != nil {
return fmt.Errorf("failed to parse file: %s", err)
}
if len(config.Portals) == 0 {
log.Println("Warning: No portals are defined")
}
return nil
}

12
go.mod Normal file
View File

@ -0,0 +1,12 @@
module git.sr.ht/~tslocum/sshtargate
go 1.13
require (
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239
github.com/creack/pty v1.1.9
github.com/gliderlabs/ssh v0.2.2
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876
golang.org/x/sys v0.0.0-20200103143344-a1369afcdac7 // indirect
gopkg.in/yaml.v2 v2.2.7
)

20
go.sum Normal file
View File

@ -0,0 +1,20 @@
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876 h1:sKJQZMuxjOAR/Uo2LBfU90onWEf1dF4C+0hPJCc9Mpc=
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200103143344-a1369afcdac7 h1:/W9OPMnnpmFXHYkcp2rQsbFUbRlRzfECQjmAFiOyHE8=
golang.org/x/sys v0.0.0-20200103143344-a1369afcdac7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

33
goreleaser.yml Normal file
View File

@ -0,0 +1,33 @@
project_name: sshtargate
builds:
-
id: sshtargate
binary: sshtargate
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X git.sr.ht/~tslocum/sshtargate/version={{.Version}}
goos:
- darwin
- freebsd
- linux
goarch:
- 386
- amd64
archives:
-
id: sshtargate
builds:
- sshtargate
replacements:
386: i386
format_overrides:
- goos: windows
format: zip
files:
- ./*.md
- CHANGELOG
- LICENSE
checksum:
name_template: 'checksums.txt'

115
main.go Normal file
View File

@ -0,0 +1,115 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"os/signal"
"path"
"strings"
"sync"
"syscall"
"git.sr.ht/~tslocum/sshtargate/pkg/gate"
"github.com/anmitsu/go-shlex"
)
const (
version = "0.0.0"
versionInfo = `sshtargate - Host SSH portals to applications - v` + version + `
https://git.sr.ht/~tslocum/sshtargate
The MIT License (MIT)
Copyright (c) 2020 Trevor Slocum <trevor@rocketnine.space>
`
)
var (
printVersionInfo bool
configPath string
portals []*gate.Portal
portalsLock = new(sync.Mutex)
done = make(chan bool)
)
func main() {
flag.BoolVar(&printVersionInfo, "version", false, "print version information and exit")
flag.StringVar(&configPath, "config", "", "path to configuration file")
flag.Parse()
if printVersionInfo {
fmt.Print(versionInfo)
return
}
// TODO: Allow portals to be specified via arguments
// TODO: Catch SIGHUP
sigc := make(chan os.Signal, 1)
signal.Notify(sigc,
syscall.SIGINT,
syscall.SIGTERM)
go func() {
<-sigc
done <- true
}()
log.Println("Initializing sshtargate...")
if configPath == "" {
homedir, err := os.UserHomeDir()
if err == nil && homedir != "" {
configPath = path.Join(homedir, ".config", "sshtargate", "config.yaml")
}
}
err := readConfig(configPath)
if err != nil {
log.Fatalf("failed to read configuration file %s: %s", configPath, err)
}
for pname, pcfg := range config.Portals {
cs, err := shlex.Split(pcfg.Command, true)
if err != nil {
log.Fatalf("failed to split command %s", pcfg.Command)
}
pname, pcfg := pname, pcfg // Capture
go func() {
wg := new(sync.WaitGroup)
for _, address := range pcfg.Host {
wg.Add(1)
address := address // Capture
go func() {
p, err := gate.NewPortal(pname, address, cs)
if err != nil {
log.Fatalf("failed to start portal %s on %s: %s", pname, address, err)
}
portalsLock.Lock()
portals = append(portals, p)
portalsLock.Unlock()
wg.Done()
}()
}
wg.Wait()
log.Printf("Opened portal %s on %s to %s", pname, strings.Join(pcfg.Host, ","), pcfg.Command)
}()
}
<-done
portalsLock.Lock()
for _, p := range portals {
p.Shutdown()
}
portalsLock.Unlock()
}

133
pkg/gate/portal.go Normal file
View File

@ -0,0 +1,133 @@
package gate
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path"
"syscall"
"time"
"unsafe"
"github.com/creack/pty"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh"
)
const (
ListenTimeout = 1 * time.Second
IdleTimeout = 1 * time.Minute
)
type Portal struct {
Name string
Address string
Command []string
Server *ssh.Server
}
func setWinsize(f *os.File, w, h int) {
syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ),
uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0})))
}
func NewPortal(name string, address string, command []string) (*Portal, error) {
if address == "" {
return nil, errors.New("no address supplied")
} else if command == nil || command[0] == "" {
return nil, errors.New("no command supplied")
}
server := &ssh.Server{
Addr: address,
IdleTimeout: IdleTimeout,
Handler: func(sshSession ssh.Session) {
ptyReq, winCh, isPty := sshSession.Pty()
if !isPty {
io.WriteString(sshSession, "failed to start command: non-interactive terminals are not supported\n")
sshSession.Exit(1)
return
}
cmdCtx, cancelCmd := context.WithCancel(sshSession.Context())
defer cancelCmd()
var args []string
if len(command) > 1 {
args = command[1:]
}
cmd := exec.CommandContext(cmdCtx, command[0], args...)
cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))
f, err := pty.Start(cmd)
if err != nil {
io.WriteString(sshSession, fmt.Sprintf("failed to start command: failed to initialize pseudo-terminal: %s\n", err))
sshSession.Exit(1)
return
}
defer f.Close()
go func() {
for win := range winCh {
setWinsize(f, win.Width, win.Height)
}
}()
go func() {
io.Copy(f, sshSession)
}()
io.Copy(sshSession, f)
cmd.Wait()
},
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
return true
},
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
return true
},
PasswordHandler: func(ctx ssh.Context, password string) bool {
return true
},
KeyboardInteractiveHandler: func(ctx ssh.Context, challenger gossh.KeyboardInteractiveChallenge) bool {
return true
},
}
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to retrieve user home dir: %s", err)
}
err = server.SetOption(ssh.HostKeyFile(path.Join(homeDir, ".ssh", "id_rsa")))
if err != nil {
return nil, fmt.Errorf("failed to set host key file: %s", err)
}
t := time.NewTimer(ListenTimeout)
errs := make(chan error)
go func() {
err := server.ListenAndServe()
if err != nil {
errs <- fmt.Errorf("failed to start SSH server: %s", err)
}
}()
select {
case err = <-errs:
return nil, err
case <-t.C:
// Server started
}
p := Portal{Name: name, Address: address, Command: command, Server: server}
return &p, nil
}
func (p *Portal) Shutdown() {
p.Server.Close()
}