Compare commits
29 Commits
dev/moul/d
...
guardrails
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26a7186f40 | ||
|
|
41eeb364f8 | ||
|
|
a22f8f0b7b | ||
|
|
bd1c3609a7 | ||
|
|
c5e75df64f | ||
|
|
6b181dd291 | ||
|
|
4ab88cad10 | ||
|
|
b902953df4 | ||
|
|
e141368734 | ||
|
|
980da40988 | ||
|
|
22d25f1e70 | ||
|
|
84d77d0a9f | ||
|
|
b0afdf933a | ||
|
|
e9eef9a49e | ||
|
|
6f2b58cbdc | ||
|
|
09ac2c35f3 | ||
|
|
47a6fc9906 | ||
|
|
c3d49fde95 | ||
|
|
ec1e4d5c8a | ||
|
|
e65ef7ccc1 | ||
|
|
68e7fd2090 | ||
|
|
b958f8461f | ||
|
|
a08d84e7ed | ||
|
|
2b66d8d56a | ||
|
|
a40789e1f2 | ||
|
|
63571af252 | ||
|
|
75c6840ecd | ||
|
|
e6a02a85f0 | ||
|
|
2c3de75f3d |
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@@ -11,7 +11,7 @@ If this is a FEATURE REQUEST, please:
|
||||
|
||||
**What you expected to happen**:
|
||||
|
||||
**How to reproduce it (as minimally and precisely as possible):
|
||||
**How to reproduce it (as minimally and precisely as possible)**:
|
||||
|
||||
**Anything else we need to know?**:
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
/log/
|
||||
/sshportal
|
||||
*.db
|
||||
/data
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -2,9 +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)
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -24,7 +24,7 @@ _docker_install:
|
||||
.PHONY: dev
|
||||
dev:
|
||||
-go get github.com/githubnemo/CompileDaemon
|
||||
CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal --debug --bind-address=:$(PORT) --aes-key=$(AES_KEY) $(EXTRA_RUN_OPTS)" .
|
||||
CompileDaemon -exclude-dir=.git -exclude=".#*" -color=true -command="./sshportal server --debug --bind-address=:$(PORT) --aes-key=$(AES_KEY) $(EXTRA_RUN_OPTS)" .
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
|
||||
17
README.md
17
README.md
@@ -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
|
||||
@@ -178,11 +180,11 @@ event inspect [-h] EVENT...
|
||||
|
||||
# host management
|
||||
host help
|
||||
host create [-h] [--name=<value>] [--password=<value>] [--comment=<value>] [--key=KEY] [--group=HOSTGROUP...] <username>[:<password>]@<host>[:<port>]
|
||||
host create [-h] [--name=<value>] [--password=<value>] [--comment=<value>] [--key=KEY] [--group=HOSTGROUP...] [--hop=HOST] <username>[:<password>]@<host>[:<port>]
|
||||
host inspect [-h] [--decrypt] HOST...
|
||||
host ls [-h] [--latest] [--quiet]
|
||||
host rm [-h] HOST...
|
||||
host update [-h] [--name=<value>] [--comment=<value>] [--key=KEY] [--assign-group=HOSTGROUP...] [--unassign-group=HOSTGROUP...] HOST...
|
||||
host update [-h] [--name=<value>] [--comment=<value>] [--key=KEY] [--assign-group=HOSTGROUP...] [--unassign-group=HOSTGROUP...] [--set-hop=HOST] [--unset-hop] HOST...
|
||||
|
||||
# hostgroup management
|
||||
hostgroup help
|
||||
@@ -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
|
||||
```
|
||||
@@ -411,4 +414,4 @@ This is totally experimental for now, so please file issues to let me know what
|
||||
|
||||
|
||||
## License
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2Fmoul%2Fsshportal?ref=badge_large)
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2Fmoul%2Fsshportal?ref=badge_large) [](https://www.guardrails.io)
|
||||
|
||||
37
crypto.go
37
crypto.go
@@ -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)
|
||||
|
||||
2
db.go
2
db.go
@@ -63,6 +63,8 @@ type Host struct {
|
||||
HostKey []byte `sql:"size:10000" valid:"optional"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Comment string `valid:"optional"`
|
||||
Hop *Host
|
||||
HopID uint
|
||||
}
|
||||
|
||||
// UserKey defines a user public key used by sshportal to identify the user
|
||||
|
||||
24
dbinit.go
24
dbinit.go
@@ -458,6 +458,30 @@ func dbInit(db *gorm.DB) error {
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
}, {
|
||||
ID: "29",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
type Host struct {
|
||||
// FIXME: use uuid for ID
|
||||
gorm.Model
|
||||
Name string `gorm:"size:32"`
|
||||
Addr string
|
||||
User string
|
||||
Password string
|
||||
URL string
|
||||
SSHKey *SSHKey `gorm:"ForeignKey:SSHKeyID"`
|
||||
SSHKeyID uint `gorm:"index"`
|
||||
HostKey []byte `sql:"size:10000"`
|
||||
Groups []*HostGroup `gorm:"many2many:host_host_groups;"`
|
||||
Comment string
|
||||
Hop *Host
|
||||
HopID uint
|
||||
}
|
||||
return tx.AutoMigrate(&Host{}).Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := m.Migrate(); err != nil {
|
||||
|
||||
@@ -47,6 +47,10 @@ ssh sshportal -l admin host create test42
|
||||
ssh sshportal -l admin host create --name=testtest --comment=test --password=test test@test.test
|
||||
ssh sshportal -l admin host create --group=hg1 --group=hg2 hostwithgroups.org
|
||||
ssh sshportal -l admin host inspect example test42 testtest hostwithgroups
|
||||
ssh sshportal -l admin host update --assign-group=hg1 test42
|
||||
ssh sshportal -l admin host update --unassign-group=hg1 test42
|
||||
ssh sshportal -l admin host update --assign-group=hg1 test42
|
||||
ssh sshportal -l admin host update --assign-group=hg2 --unassign-group=hg2 test42
|
||||
ssh sshportal -l admin host ls
|
||||
|
||||
# backup/restore
|
||||
@@ -60,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, ...)
|
||||
|
||||
2
main.go
2
main.go
@@ -18,7 +18,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
|
||||
|
||||
@@ -3,75 +3,166 @@ package bastionsession
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
"os"
|
||||
"log"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
|
||||
"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
|
||||
ClientConfig *gossh.ClientConfig
|
||||
}
|
||||
|
||||
func ChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context, config Config) error {
|
||||
if newChan.ChannelType() != "session" {
|
||||
func MultiChannelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context, configs []Config) error {
|
||||
var lastClient *gossh.Client
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
lch, lreqs, err := newChan.Accept()
|
||||
// TODO: defer clean closer
|
||||
if err != nil {
|
||||
// TODO: trigger event callback
|
||||
return nil
|
||||
}
|
||||
|
||||
// open client channel
|
||||
rconn, err := gossh.Dial("tcp", config.Addr, config.ClientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rconn.Close() }()
|
||||
rch, rreqs, err := rconn.OpenChannel("session", []byte{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := conn.User()
|
||||
// pipe everything
|
||||
return pipe(lreqs, rreqs, lch, rch, config.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()
|
||||
|
||||
file_name := strings.Join([]string{logsLocation, "/", user, "-", channeltype, "-", time.Now().Format(time.RFC3339)}, "") // get user
|
||||
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")
|
||||
}()
|
||||
|
||||
defer f.Close()
|
||||
|
||||
go func() {
|
||||
_, _ = io.Copy(rch, lch)
|
||||
errch <- errors.New("rch 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")
|
||||
}()
|
||||
|
||||
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(wrappedrch, lch)
|
||||
errch <- errors.New("rch closed the connection")
|
||||
}()
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -80,6 +171,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
|
||||
}
|
||||
|
||||
59
pkg/logtunnel/logtunnel.go
Normal file
59
pkg/logtunnel/logtunnel.go
Normal 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()
|
||||
}
|
||||
245
shell.go
245
shell.go
@@ -32,6 +32,10 @@ var banner = `
|
||||
`
|
||||
var startTime = time.Now()
|
||||
|
||||
const (
|
||||
naMessage = "n/a"
|
||||
)
|
||||
|
||||
func shell(s ssh.Session) error {
|
||||
var (
|
||||
sshCommand = s.Command()
|
||||
@@ -271,10 +275,16 @@ GLOBAL OPTIONS:
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := model.Association("UserGroups").Append(&appendUserGroups).Delete(deleteUserGroups).Error; err != nil {
|
||||
if err := model.Association("UserGroups").Append(&appendUserGroups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if len(deleteUserGroups) > 0 {
|
||||
if err := model.Association("UserGroups").Delete(deleteUserGroups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var appendHostGroups []HostGroup
|
||||
var deleteHostGroups []HostGroup
|
||||
@@ -286,10 +296,16 @@ GLOBAL OPTIONS:
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := model.Association("HostGroups").Append(&appendHostGroups).Delete(deleteHostGroups).Error; err != nil {
|
||||
if err := model.Association("HostGroups").Append(&appendHostGroups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if len(deleteHostGroups) > 0 {
|
||||
if err := model.Association("HostGroups").Delete(deleteHostGroups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
@@ -642,6 +658,7 @@ GLOBAL OPTIONS:
|
||||
cli.StringFlag{Name: "password, p", Usage: "If present, sshportal will use password-based authentication"},
|
||||
cli.StringFlag{Name: "comment, c"},
|
||||
cli.StringFlag{Name: "key, k", Usage: "`KEY` to use for authentication"},
|
||||
cli.StringFlag{Name: "hop, o", Usage: "Hop to use for connecting to the server"},
|
||||
cli.StringSliceFlag{Name: "group, g", Usage: "Assigns the host to `HOSTGROUPS` (default: \"default\")"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
@@ -665,7 +682,13 @@ GLOBAL OPTIONS:
|
||||
host.Password = c.String("password")
|
||||
}
|
||||
host.Name = strings.Split(host.Hostname(), ".")[0]
|
||||
|
||||
if c.String("hop") != "" {
|
||||
hop, err := HostByName(db, c.String("hop"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
host.Hop = hop
|
||||
}
|
||||
if c.String("name") != "" {
|
||||
host.Name = c.String("name")
|
||||
}
|
||||
@@ -776,7 +799,7 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
|
||||
table := tablewriter.NewWriter(s)
|
||||
table.SetHeader([]string{"ID", "Name", "URL", "Key", "Groups", "Updated", "Created", "Comment"})
|
||||
table.SetHeader([]string{"ID", "Name", "URL", "Key", "Groups", "Updated", "Created", "Comment", "Hop"})
|
||||
table.SetBorder(false)
|
||||
table.SetCaption(true, fmt.Sprintf("Total: %d hosts.", len(hosts)))
|
||||
for _, host := range hosts {
|
||||
@@ -790,6 +813,14 @@ GLOBAL OPTIONS:
|
||||
for _, hostGroup := range host.Groups {
|
||||
groupNames = append(groupNames, hostGroup.Name)
|
||||
}
|
||||
var hop string
|
||||
if host.HopID != 0 {
|
||||
var hopHost Host
|
||||
db.Model(&host).Related(&hopHost, "HopID")
|
||||
hop = hopHost.Name
|
||||
} else {
|
||||
hop = ""
|
||||
}
|
||||
table.Append([]string{
|
||||
fmt.Sprintf("%d", host.ID),
|
||||
host.Name,
|
||||
@@ -799,6 +830,7 @@ GLOBAL OPTIONS:
|
||||
humanize.Time(host.UpdatedAt),
|
||||
humanize.Time(host.CreatedAt),
|
||||
host.Comment,
|
||||
hop,
|
||||
//FIXME: add some stats about last access time etc
|
||||
})
|
||||
}
|
||||
@@ -829,6 +861,8 @@ GLOBAL OPTIONS:
|
||||
cli.StringFlag{Name: "url, u", Usage: "Update connection URL"},
|
||||
cli.StringFlag{Name: "comment, c", Usage: "Update/set a host comment"},
|
||||
cli.StringFlag{Name: "key, k", Usage: "Link a `KEY` to use for authentication"},
|
||||
cli.StringFlag{Name: "hop, o", Usage: "Change the hop to use for connecting to the server"},
|
||||
cli.BoolFlag{Name: "unset-hop", Usage: "Remove the hop set for this host"},
|
||||
cli.StringSliceFlag{Name: "assign-group, g", Usage: "Assign the host to a new `HOSTGROUPS`"},
|
||||
cli.StringSliceFlag{Name: "unassign-group", Usage: "Unassign the host from a `HOSTGROUPS`"},
|
||||
},
|
||||
@@ -876,6 +910,29 @@ GLOBAL OPTIONS:
|
||||
}
|
||||
}
|
||||
|
||||
// hop
|
||||
if c.String("hop") != "" {
|
||||
hop, err := HostByName(db, c.String("hop"))
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := model.Association("Hop").Replace(hop).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// remove the hop
|
||||
if c.Bool("unset-hop") {
|
||||
var hopHost Host
|
||||
db.Model(&host).Related(&hopHost, "HopID")
|
||||
if err := model.Association("Hop").Delete(hopHost).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// associations
|
||||
if c.String("key") != "" {
|
||||
var key SSHKey
|
||||
@@ -898,10 +955,16 @@ GLOBAL OPTIONS:
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := model.Association("Groups").Append(&appendGroups).Delete(deleteGroups).Error; err != nil {
|
||||
if err := model.Association("Groups").Append(&appendGroups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if len(deleteGroups) > 0 {
|
||||
if err := model.Association("Groups").Delete(deleteGroups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
@@ -1032,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
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
@@ -1119,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...",
|
||||
@@ -1525,11 +1683,16 @@ GLOBAL OPTIONS:
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := model.Association("Groups").Append(&appendGroups).Delete(deleteGroups).Error; err != nil {
|
||||
if err := model.Association("Groups").Append(&appendGroups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if len(deleteGroups) > 0 {
|
||||
if err := model.Association("Groups").Delete(deleteGroups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
var appendRoles []UserRole
|
||||
if err := UserRolesByIdentifiers(db, c.StringSlice("assign-role")).Find(&appendRoles).Error; err != nil {
|
||||
tx.Rollback()
|
||||
@@ -1540,12 +1703,17 @@ GLOBAL OPTIONS:
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := model.Association("Roles").Append(&appendRoles).Delete(deleteRoles).Error; err != nil {
|
||||
if err := model.Association("Roles").Append(&appendRoles).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if len(deleteRoles) > 0 {
|
||||
if err := model.Association("Roles").Delete(deleteRoles).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
},
|
||||
},
|
||||
@@ -1677,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
|
||||
},
|
||||
},
|
||||
},
|
||||
}, {
|
||||
@@ -1793,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),
|
||||
@@ -1892,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,
|
||||
|
||||
43
ssh.go
43
ssh.go
@@ -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 {
|
||||
@@ -113,16 +114,33 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
||||
|
||||
switch host.Scheme() {
|
||||
case BastionSchemeSSH:
|
||||
clientConfig, err := bastionClientConfig(ctx, host)
|
||||
if err != nil {
|
||||
ch, _, err2 := newChan.Accept()
|
||||
sessionConfigs := make([]bastionsession.Config, 0)
|
||||
currentHost := host
|
||||
for currentHost != nil {
|
||||
clientConfig, err2 := bastionClientConfig(ctx, currentHost)
|
||||
if err2 != nil {
|
||||
ch, _, err3 := newChan.Accept()
|
||||
if err3 != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ch, "error: %v\n", err2)
|
||||
// FIXME: force close all channels
|
||||
_ = ch.Close()
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ch, "error: %v\n", err)
|
||||
// FIXME: force close all channels
|
||||
_ = ch.Close()
|
||||
return
|
||||
sessionConfigs = append([]bastionsession.Config{{
|
||||
Addr: currentHost.DialAddr(),
|
||||
ClientConfig: clientConfig,
|
||||
Logs: actx.config.logsLocation,
|
||||
}}, sessionConfigs...)
|
||||
if currentHost.HopID != 0 {
|
||||
var newHost Host
|
||||
actx.db.Model(currentHost).Related(&newHost, "HopID")
|
||||
hostname := newHost.Name
|
||||
currentHost, _ = HostByName(actx.db, hostname)
|
||||
} else {
|
||||
currentHost = nil
|
||||
}
|
||||
}
|
||||
|
||||
sess := Session{
|
||||
@@ -140,11 +158,12 @@ func channelHandler(srv *ssh.Server, conn *gossh.ServerConn, newChan gossh.NewCh
|
||||
return
|
||||
}
|
||||
|
||||
err = bastionsession.ChannelHandler(srv, conn, newChan, ctx, bastionsession.Config{
|
||||
Addr: host.DialAddr(),
|
||||
ClientConfig: clientConfig,
|
||||
Logs: actx.config.logsLocation,
|
||||
})
|
||||
go func() {
|
||||
err = bastionsession.MultiChannelHandler(srv, conn, newChan, ctx, sessionConfigs)
|
||||
if err != nil {
|
||||
log.Printf("Error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
now := time.Now()
|
||||
sessUpdate := Session{
|
||||
|
||||
5
vendor/github.com/arkan/bastion/pkg/logchannel/logchannel.go
generated
vendored
5
vendor/github.com/arkan/bastion/pkg/logchannel/logchannel.go
generated
vendored
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user