Merge pull request #78 from Raerten/dev/moul/support-windows

[FIX] log path and log filename for Windows OS
This commit is contained in:
Manfred Touron 2018-11-16 09:58:46 +01:00 committed by GitHub
commit a125e25b04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 435 additions and 65 deletions

View file

@ -2,11 +2,19 @@
## master (unreleased)
* No entry
## v1.8.0 (2018-04-02)
* The default created user now has the same username as the user starting sshportal (was hardcoded "admin")
* Add Telnet support
* Add TTY audit feature ([#23](https://github.com/moul/sshportal/issues/23)) by [@sabban](https://github.com/sabban)
* Fix `--assign-*` commands when using MySQL driver ([#45](https://github.com/moul/sshportal/issues/45))
* Add *HOP* support, an efficient and integrated way of using a jump host transparently ([#47](https://github.com/moul/sshportal/issues/47)) by [@mathieui](https://github.com/mathieui)
* Fix panic on some `ls` commands ([#54](https://github.com/moul/sshportal/pull/54)) by [@jle64](https://github.com/jle64)
* Add tunnels (`direct-tcp`) support with logging ([#44](https://github.com/moul/sshportal/issues/44)) by [@sabban](https://github.com/sabban)
* Add `key import` command ([#52](https://github.com/moul/sshportal/issues/52)) by [@adyxax](https://github.com/adyxax)
* Add 'exec' logging ([#40](https://github.com/moul/sshportal/issues/40)) by [@sabban](https://github.com/sabban)
## v1.7.1 (2018-01-03)

View file

@ -28,7 +28,8 @@ Jump host/Jump server without the jump, a.k.a Transparent SSH bastion
* Admin commands can be run directly or in an interactive shell
* Host management
* User management (invite, group, stats)
* Host Key management (remote host key learning)
* Host Key management (create, remove, update, import)
* Automatic remote host key learning
* User Key management (multile keys per user)
* ACL management (acl+user-groups+host-groups)
* User roles (admin, trusted, standard, ...)
@ -38,6 +39,7 @@ Jump host/Jump server without the jump, a.k.a Transparent SSH bastion
* Session management (see active connections, history, stats, stop)
* Audit log (logging every user action)
* Record TTY Session
* Tunnels logging
* Host Keys verifications shared across users
* Healthcheck user (replying OK to any user)
* SSH compatibility
@ -194,6 +196,7 @@ hostgroup rm [-h] HOSTGROUP...
# key management
key help
key create [-h] [--name=<value>] [--type=<value>] [--length=<value>] [--comment=<value>]
key import [-h] [--name=<value>] [--comment=<value>]
key inspect [-h] [--decrypt] KEY...
key ls [-h] [--latest] [--quiet]
key rm [-h] KEY...
@ -236,7 +239,7 @@ An [automated build is setup on the Docker Hub](https://hub.docker.com/r/moul/ss
```console
# Start a server in background
# mount `pwd` to persist the sqlite database file
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.7.1
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.8.0
# check logs (mandatory on first run to get the administrator invite token)
docker logs -f sshportal
@ -245,7 +248,7 @@ docker logs -f sshportal
The easier way to upgrade sshportal is to do the following:
```sh
# we consider you were using an old version and you want to use the new version v1.7.1
# we consider you were using an old version and you want to use the new version v1.8.0
# stop and rename the last working container + backup the database
docker stop sshportal
@ -253,7 +256,7 @@ docker rename sshportal sshportal_old
cp sshportal.db sshportal.db.bkp
# run the new version
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.7.1
docker run -p 2222:2222 -d --name=sshportal -v "$(pwd):$(pwd)" -w "$(pwd)" moul/sshportal:v1.8.0
# check the logs for migration or cross-version incompabitility errors
docker logs -f sshportal
```

View file

@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"time"
"github.com/urfave/cli"
)
@ -13,6 +14,7 @@ type configServe struct {
logsLocation string
bindAddr string
debug, demo bool
idleTimeout time.Duration
}
func parseServeConfig(c *cli.Context) (*configServe, error) {
@ -24,6 +26,7 @@ func parseServeConfig(c *cli.Context) (*configServe, error) {
debug: c.Bool("debug"),
demo: c.Bool("demo"),
logsLocation: c.String("logs-location"),
idleTimeout: c.Duration("idle-timeout"),
}
switch len(ret.aesKey) {
case 0, 16, 24, 32:

View file

@ -9,6 +9,7 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"strings"
@ -52,6 +53,42 @@ func NewSSHKey(keyType string, length uint) (*SSHKey, error) {
return &key, nil
}
func ImportSSHKey(keyValue string) (*SSHKey, error) {
key := SSHKey{
Type: "rsa",
}
parsedKey, err := gossh.ParseRawPrivateKey([]byte(keyValue))
if err != nil {
return nil, err
}
var privateKey *rsa.PrivateKey
var ok bool
if privateKey, ok = parsedKey.(*rsa.PrivateKey); !ok {
return nil, errors.New("key type not supported")
}
key.Length = uint(privateKey.PublicKey.N.BitLen())
// convert priv key to x509 format
var pemKey = &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
buf := bytes.NewBufferString("")
if err = pem.Encode(buf, pemKey); err != nil {
return nil, err
}
key.PrivKey = buf.String()
// generte authorized-key formatted pubkey output
pub, err := gossh.NewPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, err
}
key.PubKey = strings.TrimSpace(string(gossh.MarshalAuthorizedKey(pub)))
return &key, nil
}
func encrypt(key []byte, text string) (string, error) {
plaintext := []byte(text)
block, err := aes.NewCipher(key)

View file

@ -64,12 +64,16 @@ ssh sshportal -l admin config backup --indent --ignore-events > backup-2
diff backup-1.clean backup-2.clean
)
# bastion
ssh sshportal -l admin host create --name=testserver toto@testserver:2222
out="$(ssh sshportal -l testserver echo hello | head -n 1)"
test "$out" = '{"User":"toto","Environ":null,"Command":["echo","hello"]}'
if [ "$CIRCLECI" = "true" ]; then
echo "Strage behavior with cross-container communication on CircleCI, skipping some tests..."
else
# bastion
ssh sshportal -l admin host create --name=testserver toto@testserver:2222
out="$(ssh sshportal -l testserver echo hello | head -n 1)"
test "$out" = '{"User":"toto","Environ":null,"Command":["echo","hello"]}'
out="$(TEST_A=1 TEST_B=2 TEST_C=3 TEST_D=4 TEST_E=5 TEST_F=6 TEST_G=7 TEST_H=8 TEST_I=9 ssh sshportal -l testserver echo hello | head -n 1)"
test "$out" = '{"User":"toto","Environ":["TEST_A=1","TEST_B=2","TEST_C=3","TEST_D=4","TEST_E=5","TEST_F=6","TEST_G=7","TEST_H=8","TEST_I=9"],"Command":["echo","hello"]}'
out="$(TEST_A=1 TEST_B=2 TEST_C=3 TEST_D=4 TEST_E=5 TEST_F=6 TEST_G=7 TEST_H=8 TEST_I=9 ssh sshportal -l testserver echo hello | head -n 1)"
test "$out" = '{"User":"toto","Environ":["TEST_A=1","TEST_B=2","TEST_C=3","TEST_D=4","TEST_E=5","TEST_F=6","TEST_G=7","TEST_H=8","TEST_I=9"],"Command":["echo","hello"]}'
fi
# TODO: test more cases (forwards, scp, sftp, interactive, pty, stdin, exit code, ...)

16
main.go
View file

@ -3,6 +3,7 @@ package main
import (
"fmt"
"log"
"math"
"math/rand"
"net"
"os"
@ -18,7 +19,7 @@ import (
var (
// Version should be updated by hand at each release
Version = "1.7.1+dev"
Version = "1.8.0+dev"
// GitTag will be overwritten automatically by the build system
GitTag string
// GitSha will be overwritten automatically by the build system
@ -79,6 +80,11 @@ func main() {
Value: "./log",
Usage: "Store user session files",
},
cli.DurationFlag{
Name: "idle-timeout",
Value: 0,
Usage: "Duration before an inactive connection is timed out (0 to disable)",
},
},
}, {
Name: "healthcheck",
@ -144,6 +150,12 @@ func server(c *configServe) (err error) {
Version: fmt.Sprintf("sshportal-%s", Version),
ChannelHandler: channelHandler,
}
if c.idleTimeout != 0 {
srv.IdleTimeout = c.idleTimeout
// gliderlabs/ssh requires MaxTimeout to be non-zero if we want to use IdleTimeout.
// So, set it to the max value, because we don't want a max timeout.
srv.MaxTimeout = math.MaxInt64
}
for _, opt := range []ssh.Option{
// custom PublicKeyAuth handler
@ -157,6 +169,6 @@ func server(c *configServe) (err error) {
}
}
log.Printf("info: SSH Server accepting connections on %s", c.bindAddr)
log.Printf("info: SSH Server accepting connections on %s, idle-timout=%v", c.bindAddr, c.idleTimeout)
return srv.Serve(ln)
}

View file

@ -5,14 +5,24 @@ import (
"io"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/arkan/bastion/pkg/logchannel"
"github.com/gliderlabs/ssh"
"github.com/moul/sshportal/pkg/logtunnel"
gossh "golang.org/x/crypto/ssh"
)
type ForwardData struct {
DestinationHost string
DestinationPort uint32
SourceHost string
SourcePort uint32
}
type Config struct {
Addr string
Logs string
@ -20,76 +30,141 @@ type Config struct {
}
func MultiChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context, configs []Config) error {
if newChan.ChannelType() != "session" {
newChan.Reject(gossh.UnknownChannelType, "unsupported channel type")
return nil
}
lch, lreqs, err := newChan.Accept()
// TODO: defer clean closer
if err != nil {
// TODO: trigger event callback
return nil
}
var lastClient *gossh.Client
// go through all the hops
for _, config := range configs {
var client *gossh.Client
if lastClient == nil {
client, err = gossh.Dial("tcp", config.Addr, config.ClientConfig)
} else {
rconn, err := lastClient.Dial("tcp", config.Addr)
if err != nil {
return err
}
ncc, chans, reqs, err := gossh.NewClientConn(rconn, config.Addr, config.ClientConfig)
if err != nil {
return err
}
client = gossh.NewClient(ncc, chans, reqs)
switch newChan.ChannelType() {
case "session":
lch, lreqs, err := newChan.Accept()
// TODO: defer clean closer
if err != nil {
// TODO: trigger event callback
return nil
}
// go through all the hops
for _, config := range configs {
var client *gossh.Client
if lastClient == nil {
client, err = gossh.Dial("tcp", config.Addr, config.ClientConfig)
} else {
rconn, err := lastClient.Dial("tcp", config.Addr)
if err != nil {
return err
}
ncc, chans, reqs, err := gossh.NewClientConn(rconn, config.Addr, config.ClientConfig)
if err != nil {
return err
}
client = gossh.NewClient(ncc, chans, reqs)
}
if err != nil {
return err
}
defer func() { _ = client.Close() }()
lastClient = client
}
rch, rreqs, err := lastClient.OpenChannel("session", []byte{})
if err != nil {
return err
}
defer func() { _ = client.Close() }()
lastClient = client
user := conn.User()
// pipe everything
return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1].Logs, user, newChan)
case "direct-tcpip":
lch, lreqs, err := newChan.Accept()
// TODO: defer clean closer
if err != nil {
// TODO: trigger event callback
return nil
}
// go through all the hops
for _, config := range configs {
var client *gossh.Client
if lastClient == nil {
client, err = gossh.Dial("tcp", config.Addr, config.ClientConfig)
} else {
rconn, err := lastClient.Dial("tcp", config.Addr)
if err != nil {
return err
}
ncc, chans, reqs, err := gossh.NewClientConn(rconn, config.Addr, config.ClientConfig)
if err != nil {
return err
}
client = gossh.NewClient(ncc, chans, reqs)
}
if err != nil {
return err
}
defer func() { _ = client.Close() }()
lastClient = client
}
d := logtunnel.ForwardData{}
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
return err
}
rch, rreqs, err := lastClient.OpenChannel("direct-tcpip", newChan.ExtraData())
if err != nil {
return err
}
user := conn.User()
// pipe everything
return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1].Logs, user, newChan)
default:
newChan.Reject(gossh.UnknownChannelType, "unsupported channel type")
return nil
}
rch, rreqs, err := lastClient.OpenChannel("session", []byte{})
if err != nil {
return err
}
user := conn.User()
// pipe everything
return pipe(lreqs, rreqs, lch, rch, configs[len(configs)-1].Logs, user)
}
func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel, logsLocation string, user string) error {
func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel, logsLocation string, user string, newChan gossh.NewChannel) error {
defer func() {
_ = lch.Close()
_ = rch.Close()
}()
errch := make(chan error, 1)
file_name := strings.Join([]string{logsLocation, "/", user, "-", time.Now().Format(time.RFC3339)}, "") // get user
channeltype := newChan.ChannelType()
fileNameUnix := strings.Join([]string{logsLocation, "/", user, "-", channeltype, "-", strconv.FormatInt(time.Now().UnixNano(), 10)}, "") // get user
file_name := filepath.FromSlash(fileNameUnix)
f, err := os.OpenFile(file_name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
defer f.Close()
if err != nil {
log.Fatalf("error: %v", err)
}
log.Printf("Session is recorded in %v", file_name)
wrappedlch := logchannel.New(lch, f)
go func() {
_, _ = io.Copy(wrappedlch, rch)
errch <- errors.New("lch closed the connection")
}()
log.Printf("Session %v is recorded in %v", channeltype, file_name)
if channeltype == "session" {
wrappedlch := logchannel.New(lch, f)
go func() {
_, _ = io.Copy(wrappedlch, rch)
errch <- errors.New("lch closed the connection")
}()
defer f.Close()
go func() {
_, _ = io.Copy(rch, lch)
errch <- errors.New("rch closed the connection")
}()
}
if channeltype == "direct-tcpip" {
d := logtunnel.ForwardData{}
if err := gossh.Unmarshal(newChan.ExtraData(), &d); err != nil {
return err
}
wrappedlch := logtunnel.New(lch, f, d.SourceHost)
wrappedrch := logtunnel.New(rch, f, d.DestinationHost)
go func() {
_, _ = io.Copy(wrappedlch, rch)
errch <- errors.New("lch closed the connection")
}()
go func() {
_, _ = io.Copy(rch, lch)
errch <- errors.New("rch closed the connection")
}()
go func() {
_, _ = io.Copy(wrappedrch, lch)
errch <- errors.New("rch closed the connection")
}()
}
for {
select {
@ -98,6 +173,12 @@ func pipe(lreqs, rreqs <-chan *gossh.Request, lch, rch gossh.Channel, logsLocati
return nil
}
b, err := rch.SendRequest(req.Type, req.WantReply, req.Payload)
if req.Type == "exec" {
wrappedlch := logchannel.New(lch, f)
command := append(req.Payload, []byte("\n")...)
wrappedlch.LogWrite(command)
}
if err != nil {
return err
}

View file

@ -0,0 +1,59 @@
package logtunnel
import (
"encoding/binary"
"io"
"syscall"
"time"
"golang.org/x/crypto/ssh"
)
type logTunnel struct {
host string
channel ssh.Channel
writer io.WriteCloser
}
type ForwardData struct {
DestinationHost string
DestinationPort uint32
SourceHost string
SourcePort uint32
}
func writeHeader(fd io.Writer, length int) {
t := time.Now()
tv := syscall.NsecToTimeval(t.UnixNano())
binary.Write(fd, binary.LittleEndian, int32(tv.Sec))
binary.Write(fd, binary.LittleEndian, int32(tv.Usec))
binary.Write(fd, binary.LittleEndian, int32(length))
}
func New(channel ssh.Channel, writer io.WriteCloser, host string) *logTunnel {
return &logTunnel{
host: host,
channel: channel,
writer: writer,
}
}
func (l *logTunnel) Read(data []byte) (int, error) {
return l.Read(data)
}
func (l *logTunnel) Write(data []byte) (int, error) {
writeHeader(l.writer, len(data) + len(l.host + ": "))
l.writer.Write([]byte(l.host + ": "))
l.writer.Write(data)
return l.channel.Write(data)
}
func (l *logTunnel) Close() error {
l.writer.Close()
return l.channel.Close()
}

158
shell.go
View file

@ -32,6 +32,10 @@ var banner = `
`
var startTime = time.Now()
const (
naMessage = "n/a"
)
func shell(s ssh.Session) error {
var (
sshCommand = s.Command()
@ -1091,6 +1095,47 @@ GLOBAL OPTIONS:
return HostGroupsByIdentifiers(db, c.Args()).Delete(&HostGroup{}).Error
},
}, {
Name: "update",
Usage: "Updates a host group",
ArgsUsage: "HOSTGROUP...",
Flags: []cli.Flag{
cli.StringFlag{Name: "name", Usage: "Assigns a new name to the host group"},
cli.StringFlag{Name: "comment", Usage: "Adds a comment"},
},
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return cli.ShowSubcommandHelp(c)
}
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
var hostgroups []HostGroup
if err := HostGroupsByIdentifiers(db, c.Args()).Find(&hostgroups).Error; err != nil {
return err
}
if len(hostgroups) > 1 && c.String("name") != "" {
return fmt.Errorf("cannot set --name when editing multiple hostgroups at once")
}
tx := db.Begin()
for _, hostgroup := range hostgroups {
model := tx.Model(&hostgroup)
// simple fields
for _, fieldname := range []string{"name", "comment"} {
if c.String(fieldname) != "" {
if err := model.Update(fieldname, c.String(fieldname)).Error; err != nil {
tx.Rollback()
return err
}
}
}
}
return tx.Commit().Error
},
},
},
}, {
@ -1178,6 +1223,60 @@ GLOBAL OPTIONS:
return nil
},
}, {
Name: "import",
Usage: "Imports an existing private key",
Description: "$> key import\n $> key import --name=mykey",
Flags: []cli.Flag{
cli.StringFlag{Name: "name", Usage: "Assigns a name to the key"},
cli.StringFlag{Name: "comment", Usage: "Adds a comment"},
},
Action: func(c *cli.Context) error {
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
var name string
if c.String("name") != "" {
name = c.String("name")
} else {
name = namesgenerator.GetRandomName(0)
}
var value string
term := terminal.NewTerminal(s, "Paste your key and end with a blank line> ")
for {
line, err := term.ReadLine()
if err != nil {
return err
}
if line != "" {
value += line + "\n"
} else {
break
}
}
key, err := ImportSSHKey(value)
if err != nil {
return err
}
key.Name = name
key.Comment = c.String("comment")
if _, err := govalidator.ValidateStruct(key); err != nil {
return err
}
// FIXME: check if name already exists
// save the key in database
if err := db.Create(&key).Error; err != nil {
return err
}
fmt.Fprintf(s, "%d\n", key.ID)
return nil
},
}, {
Name: "inspect",
Usage: "Shows detailed information on one or more keys",
ArgsUsage: "KEY...",
@ -1746,6 +1845,47 @@ GLOBAL OPTIONS:
return UserGroupsByIdentifiers(db, c.Args()).Delete(&UserGroup{}).Error
},
}, {
Name: "update",
Usage: "Updates a user group",
ArgsUsage: "USERGROUP...",
Flags: []cli.Flag{
cli.StringFlag{Name: "name", Usage: "Assigns a new name to the user group"},
cli.StringFlag{Name: "comment", Usage: "Adds a comment"},
},
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return cli.ShowSubcommandHelp(c)
}
if err := myself.CheckRoles([]string{"admin"}); err != nil {
return err
}
var usergroups []UserGroup
if err := UserGroupsByIdentifiers(db, c.Args()).Find(&usergroups).Error; err != nil {
return err
}
if len(usergroups) > 1 && c.String("name") != "" {
return fmt.Errorf("cannot set --name when editing multiple usergroups at once")
}
tx := db.Begin()
for _, usergroup := range usergroups {
model := tx.Model(&usergroup)
// simple fields
for _, fieldname := range []string{"name", "comment"} {
if c.String(fieldname) != "" {
if err := model.Update(fieldname, c.String(fieldname)).Error; err != nil {
tx.Rollback()
return err
}
}
}
}
return tx.Commit().Error
},
},
},
}, {
@ -1862,9 +2002,13 @@ GLOBAL OPTIONS:
table.SetBorder(false)
table.SetCaption(true, fmt.Sprintf("Total: %d userkeys.", len(userKeys)))
for _, userkey := range userKeys {
email := naMessage
if userkey.User != nil {
email = userkey.User.Email
}
table.Append([]string{
fmt.Sprintf("%d", userkey.ID),
userkey.User.Email,
email,
// FIXME: add fingerprint
humanize.Time(userkey.UpdatedAt),
humanize.Time(userkey.CreatedAt),
@ -1961,10 +2105,18 @@ GLOBAL OPTIONS:
duration = humanize.RelTime(session.CreatedAt, *session.StoppedAt, "", "")
}
duration = strings.Replace(duration, "now", "1 second", 1)
hostname := naMessage
if session.Host != nil {
hostname = session.Host.Name
}
username := naMessage
if session.User != nil {
username = session.User.Name
}
table.Append([]string{
fmt.Sprintf("%d", session.ID),
session.User.Name,
session.Host.Name,
username,
hostname,
session.Status,
humanize.Time(session.CreatedAt),
duration,

8
ssh.go
View file

@ -86,6 +86,7 @@ func dynamicHostKey(db *gorm.DB, host *Host) gossh.HostKeyCallback {
func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
switch newChan.ChannelType() {
case "session":
case "direct-tcpip":
default:
// TODO: handle direct-tcp (only for ssh scheme)
if err := newChan.Reject(gossh.UnknownChannelType, "unsupported channel type"); err != nil {
@ -157,7 +158,12 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
return
}
err = bastionsession.MultiChannelHandler(srv, conn, newChan, ctx, sessionConfigs)
go func() {
err = bastionsession.MultiChannelHandler(srv, conn, newChan, ctx, sessionConfigs)
if err != nil {
log.Printf("Error: %v", err)
}
}()
now := time.Now()
sessUpdate := Session{

View file

@ -42,6 +42,11 @@ func (l *logChannel) Write(data []byte) (int, error) {
return l.channel.Write(data)
}
func (l *logChannel) LogWrite(data []byte) (int, error) {
writeTTYRecHeader(l.writer, len(data))
return l.writer.Write(data)
}
func (l *logChannel) Close() error {
l.writer.Close()