From 0f58c3b44905e66971df4b69d71e68c859f0b738 Mon Sep 17 00:00:00 2001 From: Boshi Lian Date: Mon, 18 May 2020 08:16:15 -0700 Subject: [PATCH] introduce Yaml upstream (#49) YAML upstream provides a more flexible way to config mappings --- go.mod | 3 +- go.sum | 8 +- sshpiperd/e2e/docker-compose.yml | 40 ++- sshpiperd/e2e/sshpiperd.yaml | 81 ++++++ sshpiperd/e2e/test.sh | 33 ++- sshpiperd/loader.go | 1 + sshpiperd/snap/snapcraft.yaml | 2 +- sshpiperd/upstream/database/handler.go | 16 +- sshpiperd/upstream/provider.go | 13 + sshpiperd/upstream/yaml/pipemgr.go | 250 ++++++++++++++++ sshpiperd/upstream/yaml/plugin.go | 41 +++ sshpiperd/upstream/yaml/schema.json | 0 sshpiperd/upstream/yaml/yaml.go | 379 +++++++++++++++++++++++++ ver | 2 +- 14 files changed, 820 insertions(+), 49 deletions(-) create mode 100644 sshpiperd/e2e/sshpiperd.yaml create mode 100644 sshpiperd/upstream/yaml/pipemgr.go create mode 100644 sshpiperd/upstream/yaml/plugin.go create mode 100644 sshpiperd/upstream/yaml/schema.json create mode 100644 sshpiperd/upstream/yaml/yaml.go diff --git a/go.mod b/go.mod index c4aa36e9..1d28939e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.12 replace ( github.com/jessevdk/go-flags => github.com/tg123/go-flags v1.4.0-globalref - golang.org/x/crypto => github.com/tg123/sshpiper.crypto v0.0.0-sshpiper-20200515 + golang.org/x/crypto => github.com/tg123/sshpiper.crypto v0.0.0-sshpiper-20200515-v2 ) require ( @@ -29,4 +29,5 @@ require ( github.com/pkg/errors v0.9.1 // indirect golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c // indirect + gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86 ) diff --git a/go.sum b/go.sum index ed39e43b..c596516a 100644 --- a/go.sum +++ b/go.sum @@ -77,8 +77,8 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/tg123/go-flags v1.4.0-globalref h1:pfUF3Mdnw5gZ5izA7s95Yrp+AetZPlnO9x39RByCi98= github.com/tg123/go-flags v1.4.0-globalref/go.mod h1:G60U6XrJAj49cFQ8MY2Wr+SEjylerbSqj0I8FZy2tFE= -github.com/tg123/sshpiper.crypto v0.0.0-sshpiper-20200515 h1:ag4BBUO+Ixmiz5vpSxBVlmvGqQfOlY+0NGa2QLR1yyo= -github.com/tg123/sshpiper.crypto v0.0.0-sshpiper-20200515/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +github.com/tg123/sshpiper.crypto v0.0.0-sshpiper-20200515-v2 h1:vGv7a9lvys1S0eng/lz3p56uQbAES4H1D0OU1rp8Bh4= +github.com/tg123/sshpiper.crypto v0.0.0-sshpiper-20200515-v2/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= @@ -88,3 +88,7 @@ golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86 h1:OfFoIUYv/me30yv7XlMy4F9RJw8DEm8WQ6QG1Ph4bH0= +gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sshpiperd/e2e/docker-compose.yml b/sshpiperd/e2e/docker-compose.yml index 0a2b7388..5953fe4c 100644 --- a/sshpiperd/e2e/docker-compose.yml +++ b/sshpiperd/e2e/docker-compose.yml @@ -19,28 +19,22 @@ services: - names:/names - ./test.sh:/test.sh - ./wait.sh:/wait.sh - links: - - "piper:piper" - - "piper_sqlite:piper_sqlite" - - "piper_mysql:piper_mysql" - - "piper_pg:piper_pg" command: - "/bin/sh" - "/test.sh" depends_on: - piper + - piper_yaml - piper_mysql - piper_pg - piper_mssql + - piper_sqlite piper: environment: - "SSHPIPERD_UPSTREAM_WORKINGDIR_NOCHECKPERM=true" - "SSHPIPERD_AUDITOR=typescript-logger" - "SSHPIPERD_BANNERTEXT=hellopiper" build: ../.. - links: - - "host1:host1" - - "host2:host2" volumes: - workingdir:/var/sshpiper - ./piper.sh:/piper.sh @@ -51,15 +45,27 @@ services: depends_on: - host1 - host2 + piper_yaml: + environment: + - "SSHPIPERD_UPSTREAM_DRIVER=yaml" + - "SSHPIPERD_UPSTREAM_YAML_NOCHECKPERM=true" + - "SSHPIPERD_UPSTREAM_YAML_FILE=/sshpiperd.yaml" + - "SSHPIPERD_AUDITOR=typescript-logger" + - "SSHPIPERD_BANNERTEXT=hellopiper" + build: ../.. + volumes: + - localkey:/local + - workingdir:/var/sshpiper + - ./sshpiperd.yaml:/sshpiperd.yaml + depends_on: + - host1 + - host2 piper_sqlite: environment: - "SSHPIPERD_UPSTREAM_DRIVER=sqlite" - "SSHPIPERD_AUDITOR=typescript-logger" - "SSHPIPERD_BANNERTEXT=hellopiper" build: ../.. - links: - - "host1:host1" - - "host2:host2" volumes: - workingdir:/var/sshpiper - ./piper.sh:/piper.sh @@ -79,10 +85,6 @@ services: - "SSHPIPERD_AUDITOR=typescript-logger" - "SSHPIPERD_BANNERTEXT=hellopiper" build: ../.. - links: - - "host1:host1" - - "host2:host2" - - "mysql:mysql" volumes: - ./piper.sh:/piper.sh - workingdir:/var/sshpiper @@ -111,10 +113,6 @@ services: - "SSHPIPERD_AUDITOR=typescript-logger" - "SSHPIPERD_BANNERTEXT=hellopiper" build: ../.. - links: - - "host1:host1" - - "host2:host2" - - "postgres:postgres" volumes: - ./piper.sh:/piper.sh - workingdir:/var/sshpiper @@ -145,10 +143,6 @@ services: - "SSHPIPERD_AUDITOR=typescript-logger" - "SSHPIPERD_BANNERTEXT=hellopiper" build: ../.. - links: - - "host1:host1" - - "host2:host2" - - "mssql:mssql" volumes: - ./piper.sh:/piper.sh - workingdir:/var/sshpiper diff --git a/sshpiperd/e2e/sshpiperd.yaml b/sshpiperd/e2e/sshpiperd.yaml new file mode 100644 index 00000000..d7ce8ddd --- /dev/null +++ b/sshpiperd/e2e/sshpiperd.yaml @@ -0,0 +1,81 @@ +version: 1 +pipes: + - username: passthrough + upstream_host: host2 + ignore_hostkey: true + authmap: + mapped_username: root + - username: mappasspass + upstream_host: host2 + ignore_hostkey: true + authmap: + mapped_username: root + from: + - type: password + password: pass + to: + type: password + password: root + - username: mappasskey + upstream_host: host1 + ignore_hostkey: true + authmap: + mapped_username: root + from: + - type: password + password: pass + to: + type: privatekey + private_key: /var/sshpiper/host1/id_rsa + - username: mapkeypass + upstream_host: host2 + ignore_hostkey: true + authmap: + mapped_username: root + from: + - type: publickey + authorized_keys: /local/id_rsa2.pub + to: + type: password + password: root + - username: mapkeykey + upstream_host: host1 + ignore_hostkey: true + authmap: + mapped_username: root + from: + - type: publickey + authorized_keys: /local/id_rsa2.pub + to: + type: privatekey + private_key: /var/sshpiper/host1/id_rsa + - username: mapkeykey2 + upstream_host: host1 + ignore_hostkey: true + authmap: + mapped_username: root + from: + - type: publickey + allow_any_public_key: true + to: + type: privatekey + key_map: + - authorized_keys: /local/id_rsa2.pub + private_key: /var/sshpiper/host1/id_rsa + - username: ^regex.*$ + username_regex_match: true + upstream_host: host2 + ignore_hostkey: true + authmap: + mapped_username: root + - username: ^host.*$ + username_regex_match: true + upstream_host: host1 + ignore_hostkey: true + authmap: + mapped_username: root + from: + - type: any + to: + type: privatekey + private_key: /var/sshpiper/$USER/id_rsa \ No newline at end of file diff --git a/sshpiperd/e2e/test.sh b/sshpiperd/e2e/test.sh index a7e444d4..540b66e5 100755 --- a/sshpiperd/e2e/test.sh +++ b/sshpiperd/e2e/test.sh @@ -1,6 +1,9 @@ #!/bin/sh + /wait.sh piper 2222 +/wait.sh piper_yaml 2222 +/wait.sh piper_sqlite 2222 /wait.sh piper_mysql 2222 /wait.sh piper_pg 2222 /wait.sh piper_mssql 2222 @@ -14,6 +17,9 @@ mkdir -p /workingdir/host{1,2} ssh-keygen -N '' -f /local/id_rsa ssh-keygen -N '' -f /workingdir/host1/id_rsa #TODO pipe cmd +ssh-keygen -N '' -f /local/id_rsa2 + + /bin/cp /local/id_rsa.pub /workingdir/host1/authorized_keys /bin/cp /workingdir/host1/id_rsa.pub /host1/authorized_keys @@ -23,11 +29,13 @@ succ="\033[0;32mSUCC\033[0m" runtest(){ casename=$1 host=$2 + user=$3 + cmd=$4 rnd=`head -c 20 /dev/urandom | base64` echo $rnd > /names/$host rm -f /tmp/$host.stderr - t=$($3 2>/tmp/$host.stderr) + t=$($cmd 2>/tmp/$host.stderr) if [ "$t" != "$rnd" ];then echo -e $casename $fail @@ -36,7 +44,7 @@ runtest(){ echo -e $casename $succ fi - grep $rnd /workingdir/$host/* + grep $rnd /workingdir/$user/* if [ $? -ne 0 ];then echo -e "grep typescript logger" $fail @@ -51,11 +59,20 @@ runtest(){ fi } -runtest "host1 with public key:" "host1" "ssh host1@piper -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /local/id_rsa cat /names/host1" -runtest "host2 with password:" "host2" "sshpass -p root ssh host2@piper -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" +runtest "host1 with public key:" "host1" "host1" "ssh host1@piper -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /local/id_rsa cat /names/host1" +runtest "host2 with password:" "host2" "host2" "sshpass -p root ssh host2@piper -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" -runtest "sqlite host2 with password:" "host2" "sshpass -p root ssh host2@piper_sqlite -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" -runtest "mysql host2 with password:" "host2" "sshpass -p root ssh host2@piper_mysql -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" -runtest "pg host2 with password:" "host2" "sshpass -p root ssh host2@piper_pg -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" -runtest "msql host2 with password:" "host2" "sshpass -p root ssh host2@piper_mssql -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" +runtest "sqlite host2 with password:" "host2" "host2" "sshpass -p root ssh host2@piper_sqlite -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" +runtest "mysql host2 with password:" "host2" "host2" "sshpass -p root ssh host2@piper_mysql -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" +runtest "pg host2 with password:" "host2" "host2" "sshpass -p root ssh host2@piper_pg -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" +runtest "msql host2 with password:" "host2" "host2" "sshpass -p root ssh host2@piper_mssql -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" + +runtest "yaml host2 with password passthrough:" "host2" "passthrough" "sshpass -p root ssh passthrough@piper_yaml -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" +runtest "yaml host2 with password mappasspass:" "host2" "mappasspass" "sshpass -p pass ssh mappasspass@piper_yaml -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" +runtest "yaml host1 with password mappasskey:" "host1" "mappasskey" "sshpass -p pass ssh mappasskey@piper_yaml -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host1" +runtest "yaml host2 with password mapkeypass:" "host2" "mapkeypass" "ssh mapkeypass@piper_yaml -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /local/id_rsa2 cat /names/host2" +runtest "yaml host2 with key mapkeykey:" "host1" "mapkeykey" "ssh mapkeykey@piper_yaml -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /local/id_rsa2 cat /names/host1" +runtest "yaml host2 with key mapkeykey2:" "host1" "mapkeykey2" "ssh mapkeykey2@piper_yaml -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /local/id_rsa2 cat /names/host1" +runtest "yaml host2 with password regex:" "host2" "regex000" "sshpass -p root ssh regex000@piper_yaml -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host2" +runtest "yaml host1 with none host1:" "host1" "host1" "ssh host1@piper_yaml -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null cat /names/host1" diff --git a/sshpiperd/loader.go b/sshpiperd/loader.go index fd5fa7e3..87833031 100644 --- a/sshpiperd/loader.go +++ b/sshpiperd/loader.go @@ -3,6 +3,7 @@ package main import ( _ "github.com/tg123/sshpiper/sshpiperd/upstream/database" _ "github.com/tg123/sshpiper/sshpiperd/upstream/workingdir" + _ "github.com/tg123/sshpiper/sshpiperd/upstream/yaml" _ "github.com/tg123/sshpiper/sshpiperd/challenger/authy" _ "github.com/tg123/sshpiper/sshpiperd/challenger/azdevicecode" diff --git a/sshpiperd/snap/snapcraft.yaml b/sshpiperd/snap/snapcraft.yaml index 978bd12c..d8ed1210 100644 --- a/sshpiperd/snap/snapcraft.yaml +++ b/sshpiperd/snap/snapcraft.yaml @@ -1,5 +1,5 @@ name: sshpiperd -version: 0.4.5 +version: 0.5.0 summary: The missing reverse proxy for ssh scp description: | SSH Piper works as a proxy-like ware, and route connections by username, src ip , etc. diff --git a/sshpiperd/upstream/database/handler.go b/sshpiperd/upstream/database/handler.go index 327f711c..151b53b1 100644 --- a/sshpiperd/upstream/database/handler.go +++ b/sshpiperd/upstream/database/handler.go @@ -6,6 +6,8 @@ import ( "net" "golang.org/x/crypto/ssh" + + upstreamprovider "github.com/tg123/sshpiper/sshpiperd/upstream" ) func (p *plugin) findUpstream(conn ssh.ConnMetadata, challengeContext ssh.AdditionalChallengeContext) (net.Conn, *ssh.AuthPipe, error) { @@ -26,7 +28,7 @@ func (p *plugin) findUpstream(conn ssh.ConnMetadata, challengeContext ssh.Additi logger.Printf("mapping user [%v] to [%v@%v]", user, upuser, addr) - c, err := dial(addr) + c, err := upstreamprovider.DialForSSH(addr) if err != nil { return nil, nil, err @@ -115,15 +117,3 @@ func lookupConfigValue(db *gorm.DB, entry string) (string, error) { return c.Value, nil } - -func dial(addr string) (net.Conn, error) { - - if _, _, err := net.SplitHostPort(addr); err != nil && addr != "" { - // test valid after concat :22 - if _, _, err := net.SplitHostPort(addr + ":22"); err == nil { - addr += ":22" - } - } - - return net.Dial("tcp", addr) -} diff --git a/sshpiperd/upstream/provider.go b/sshpiperd/upstream/provider.go index 4c39bda6..6ce3b0e5 100644 --- a/sshpiperd/upstream/provider.go +++ b/sshpiperd/upstream/provider.go @@ -102,3 +102,16 @@ func SplitHostPortForSSH(addr string) (host string, port int, err error) { return } + +// DialForSSH is the modified version of net.Dial, would add ":22" automaticlly +func DialForSSH(addr string) (net.Conn, error) { + + if _, _, err := net.SplitHostPort(addr); err != nil && addr != "" { + // test valid after concat :22 + if _, _, err := net.SplitHostPort(addr + ":22"); err == nil { + addr += ":22" + } + } + + return net.Dial("tcp", addr) +} diff --git a/sshpiperd/upstream/yaml/pipemgr.go b/sshpiperd/upstream/yaml/pipemgr.go new file mode 100644 index 00000000..259b35dd --- /dev/null +++ b/sshpiperd/upstream/yaml/pipemgr.go @@ -0,0 +1,250 @@ +package yaml + +import ( + "bytes" + "fmt" + "io/ioutil" + + "github.com/tg123/sshpiper/sshpiperd/upstream" + "gopkg.in/yaml.v3" +) + +// Return All pipes inside upstream +func (p *plugin) ListPipe() ([]upstream.Pipe, error) { + + config, err := p.loadConfig() + + if err != nil { + return nil, err + } + + out, err := yaml.Marshal(config) + if err != nil { + return nil, err + } + + fmt.Println(string(out)) + + var pipes []upstream.Pipe + + for _, pipe := range config.Pipes { + host, port, err := upstream.SplitHostPortForSSH(pipe.UpstreamHost) + + if err != nil { + return nil, err + } + + mappeduser := pipe.Authmap.MappedUsername + + if mappeduser == "" { + mappeduser = pipe.Username + } + + pipes = append(pipes, upstream.Pipe{ + Host: host, + Port: port, + UpstreamUsername: mappeduser, + Username: pipe.Username, + }) + } + + return pipes, nil +} + +func findnode(root *yaml.Node, test func(*yaml.Node) bool) *yaml.Node { + var q []*yaml.Node + + q = append(q, root) + + for len(q) > 0 { + e := q[0] + q = q[1:] + + if test(e) { + return e + } + + for _, n := range e.Content { + q = append(q, n) + } + } + + return nil +} + +func findByMapKey(m *yaml.Node, k string) (*yaml.Node, int) { + for i := 1; i < len(m.Content); i += 2 { + if m.Content[i-1].Value == k { + return m.Content[i], i + } + } + + return nil, -1 +} + +func toYamlNode(s interface{}) (*yaml.Node, error) { + t := yaml.Node{} + + out, err := yaml.Marshal(s) + if err != nil { + return nil, err + } + + err = yaml.Unmarshal(out, &t) + if err != nil { + return nil, err + } + + return t.Content[0], nil +} + +func (p *plugin) loadConfigRaw() ([]byte, *yaml.Node, error) { + var config yaml.Node + + configbyte, err := ioutil.ReadFile(p.Config.File) + if err != nil { + return nil, nil, err + } + + err = yaml.Unmarshal(configbyte, &config) + if err != nil { + return nil, nil, err + } + + return configbyte, &config, nil +} + +func (p *plugin) writeConfig(config []byte) error { + return ioutil.WriteFile(p.Config.File, config, 0600) +} + +func toPipeConfig(opt upstream.CreatePipeOption) pipeConfig { + p := pipeConfig{ + Username: opt.Username, + UpstreamHost: fmt.Sprintf("%v:%v", opt.Host, opt.Port), + } + + if len(opt.UpstreamUsername) > 0 { + p.Authmap.MappedUsername = opt.UpstreamUsername + } + + return p +} + +// Create a pipe inside upstream +func (p *plugin) CreatePipe(opt upstream.CreatePipeOption) error { + configbyte, config, err := p.loadConfigRaw() + + if err != nil { + return err + } + + if len(config.Content) == 0 { + var buf bytes.Buffer + buf.Write(configbyte) + fmt.Fprintln(&buf) + + out, err := yaml.Marshal(piperConfig{ + Version: 1, + Pipes: []pipeConfig{toPipeConfig(opt)}, + }) + + if err != nil { + return err + } + + buf.Write(out) + + return p.writeConfig(buf.Bytes()) + } + + pipes, idx := findByMapKey(config.Content[0], "pipes") + + if idx > 0 && pipes.Tag == "!!null" { + // replace null + t, err := toYamlNode([]pipeConfig{toPipeConfig(opt)}) + if err != nil { + return err + } + + config.Content[0].Content[idx] = t + + out, err := yaml.Marshal(config) + if err != nil { + return err + } + + return p.writeConfig(out) + } + + if pipes.Kind != yaml.SequenceNode { + return fmt.Errorf("pipes should be !!seq") + } + + for _, pnode := range pipes.Content { + pipe, _ := findByMapKey(pnode, "username") + + if pipe != nil && pipe.Value == opt.Username { + return fmt.Errorf("username [%v] already exists", opt.Username) + } + } + + // append + + { + t, err := toYamlNode(toPipeConfig(opt)) + if err != nil { + return err + } + + pipes.Content = append(pipes.Content, t) + + out, err := yaml.Marshal(config) + if err != nil { + return err + } + + return p.writeConfig(out) + } +} + +// Remove a pipe from upstream +func (p *plugin) RemovePipe(name string) error { + _, config, err := p.loadConfigRaw() + + if err != nil { + return err + } + + if len(config.Content) == 0 { + return nil + } + + pipes, idx := findByMapKey(config.Content[0], "pipes") + + if idx > 0 && pipes.Tag == "!!null" { + return nil + } + + if pipes.Kind != yaml.SequenceNode { + return fmt.Errorf("pipes should be !!seq") + } + + for i, pnode := range pipes.Content { + pipe, _ := findByMapKey(pnode, "username") + + if pipe != nil && pipe.Value == name { + rest := pipes.Content[i+1:] + pipes.Content = append(pipes.Content[:i], rest...) + + out, err := yaml.Marshal(config) + if err != nil { + return err + } + + return p.writeConfig(out) + } + } + + return nil +} diff --git a/sshpiperd/upstream/yaml/plugin.go b/sshpiperd/upstream/yaml/plugin.go new file mode 100644 index 00000000..ebf52836 --- /dev/null +++ b/sshpiperd/upstream/yaml/plugin.go @@ -0,0 +1,41 @@ +package yaml + +import ( + "log" + + "github.com/tg123/sshpiper/sshpiperd/upstream" +) + +type plugin struct { + Config struct { + File string `long:"upstream-yaml-file" default:"/var/sshpiper/sshpiperd.yaml" description:"Yaml config file path" env:"SSHPIPERD_UPSTREAM_YAML_FILE" ini-name:"upstream-yaml-file"` + NoCheckPerm bool `long:"upstream-yaml-nocheckperm" description:"Disable 0400 checking when using config file" env:"SSHPIPERD_UPSTREAM_YAML_NOCHECKPERM" ini-name:"upstream-yaml-nocheckperm"` + } + + logger *log.Logger +} + +// The name of the Plugin +func (p *plugin) GetName() string { + return "yaml" +} + +// A ref to a struct which holds the options for the plugins +// will be populated by cmd or other plugin runners +func (p *plugin) GetOpts() interface{} { + return &p.Config +} + +// Will be called before the Plugin is used to ensure the Plugin is ready +func (p *plugin) Init(logger *log.Logger) error { + p.logger = logger + return nil +} + +func (p *plugin) GetHandler() upstream.Handler { + return p.findUpstream +} + +func init() { + upstream.Register("yaml", &plugin{}) +} diff --git a/sshpiperd/upstream/yaml/schema.json b/sshpiperd/upstream/yaml/schema.json new file mode 100644 index 00000000..e69de29b diff --git a/sshpiperd/upstream/yaml/yaml.go b/sshpiperd/upstream/yaml/yaml.go new file mode 100644 index 00000000..1d6b7430 --- /dev/null +++ b/sshpiperd/upstream/yaml/yaml.go @@ -0,0 +1,379 @@ +package yaml + +import ( + "bytes" + "encoding/base64" + "fmt" + "io/ioutil" + "net" + "os" + "path/filepath" + "regexp" + + "github.com/tg123/sshpiper/sshpiperd/upstream" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + "gopkg.in/yaml.v3" +) + +type pipeConfig struct { + Username string `yaml:"username"` + UsernameRegexMatch bool `yaml:"username_regex_match,omitempty"` + UpstreamHost string `yaml:"upstream_host"` + Authmap struct { + MappedUsername string `yaml:"mapped_username,omitempty"` + From []struct { + Type string `yaml:"type"` + Password string `yaml:"password,omitempty"` + AuthorizedKeys string `yaml:"authorized_keys,omitempty"` + AuthorizedKeysData string `yaml:"authorized_keys_data,omitempty"` + AllowAnyPublicKey bool `yaml:"allow_any_public_key,omitempty"` + } `yaml:"from,flow"` + + To struct { + Type string `yaml:"type"` + Password string `yaml:"password,omitempty"` + PrivateKey string `yaml:"private_key,omitempty"` + PrivateKeyData string `yaml:"private_key_data,omitempty"` + KeyMap []struct { + AuthorizedKeys string `yaml:"authorized_keys,omitempty"` + AuthorizedKeysData string `yaml:"authorized_keys_data,omitempty"` + PrivateKey string `yaml:"private_key,omitempty"` + PrivateKeyData string `yaml:"private_key_data,omitempty"` + } `yaml:"key_map,flow"` + } `yaml:"to,flow"` + + NoPassthrough bool `yaml:"no_passthrough,omitempty"` + } `yaml:"authmap,omitempty,flow"` + + KnownHosts string `yaml:"known_hosts,omitempty"` + KnownHostsData string `yaml:"known_hosts_data,omitempty"` + IgnoreHostkey bool `yaml:"ignore_hostkey,omitempty"` +} + +type piperConfig struct { + Version int `yaml:"version"` + Pipes []pipeConfig `yaml:"pipes,flow"` +} + +func (p *plugin) checkPerm() error { + filename := p.Config.File + f, err := os.Open(filename) + if err != nil { + return err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return err + } + + if p.Config.NoCheckPerm { + return nil + } + + if fi.Mode().Perm()&0077 != 0 { + return fmt.Errorf("%v's perm is too open", filename) + } + + return nil +} + +func (p *plugin) loadConfig() (piperConfig, error) { + var config piperConfig + + err := p.checkPerm() + + if err != nil { + return config, err + } + + configbyte, err := ioutil.ReadFile(p.Config.File) + if err != nil { + return config, err + } + + err = yaml.Unmarshal(configbyte, &config) + if err != nil { + return config, err + } + + return config, nil +} + +type createPipeCtx struct { + pipe pipeConfig + conn ssh.ConnMetadata + challengeContext ssh.AdditionalChallengeContext +} + +func (p *plugin) loadFileOrDecode(file string, base64data string, ctx createPipeCtx) ([]byte, error) { + if file != "" { + + file = os.Expand(file, func(placeholderName string) string { + switch placeholderName { + case "USER": + return ctx.conn.User() + case "MAPPED_USER": + return ctx.pipe.Authmap.MappedUsername + } + + return os.Getenv(placeholderName) + }) + + if !filepath.IsAbs(file) { + file = filepath.Join(filepath.Dir(p.Config.File), file) + } + + return ioutil.ReadFile(file) + } + + if base64data != "" { + return base64.StdEncoding.DecodeString(base64data) + } + + return nil, nil +} + +func (p *plugin) createAuthPipe(pipe pipeConfig, conn ssh.ConnMetadata, challengeContext ssh.AdditionalChallengeContext) (*ssh.AuthPipe, error) { + ctx := createPipeCtx{pipe, conn, challengeContext} + + hostKeyCallback := ssh.InsecureIgnoreHostKey() + if !pipe.IgnoreHostkey { + + data, err := p.loadFileOrDecode(pipe.KnownHosts, pipe.KnownHostsData, ctx) + if err != nil { + return nil, err + } + + if len(data) == 0 { + return nil, fmt.Errorf("no known hosts spicified") + } + + hostKeyCallback, err = knownhosts.NewFromReader(bytes.NewReader(data)) + if err != nil { + return nil, err + } + } + + to := func(key ssh.PublicKey) (ssh.AuthPipeType, ssh.AuthMethod, error) { + + switch pipe.Authmap.To.Type { + case "none": + return ssh.AuthPipeTypeNone, nil, nil + + case "password": + return ssh.AuthPipeTypeMap, ssh.Password(pipe.Authmap.To.Password), nil + + case "privatekey": + + privateBytes, err := p.loadFileOrDecode(pipe.Authmap.To.PrivateKey, pipe.Authmap.To.PrivateKeyData, ctx) + if err != nil { + return ssh.AuthPipeTypeDiscard, nil, err + } + + // did not find to 1 private key try key map + if len(privateBytes) == 0 && key != nil { + for _, privkey := range pipe.Authmap.To.KeyMap { + rest, err := p.loadFileOrDecode(privkey.AuthorizedKeys, privkey.AuthorizedKeysData, ctx) + if err != nil { + return ssh.AuthPipeTypeDiscard, nil, err + } + + var authedPubkey ssh.PublicKey + + for len(rest) > 0 { + authedPubkey, _, _, rest, err = ssh.ParseAuthorizedKey(rest) + if err != nil { + return ssh.AuthPipeTypeDiscard, nil, err + } + + keydata := key.Marshal() + + if bytes.Equal(authedPubkey.Marshal(), keydata) { + privateBytes, err = p.loadFileOrDecode(privkey.PrivateKey, privkey.PrivateKeyData, ctx) + + if err != nil { + return ssh.AuthPipeTypeDiscard, nil, err + } + + if len(privateBytes) > 0 { + // found mapped + break + } + } + } + + } + } + + if len(privateBytes) == 0 { + return ssh.AuthPipeTypeDiscard, nil, fmt.Errorf("no private key found") + } + + private, err := ssh.ParsePrivateKey(privateBytes) + if err != nil { + return ssh.AuthPipeTypeDiscard, nil, err + } + + return ssh.AuthPipeTypeMap, ssh.PublicKeys(private), nil + + default: + p.logger.Printf("unsupport type [%v] fallback to passthrough", pipe.Authmap.To.Type) + } + + if pipe.Authmap.NoPassthrough { + return ssh.AuthPipeTypeDiscard, nil, nil + } + + return ssh.AuthPipeTypePassThrough, nil, nil + } + + allowPasswords := make(map[string]bool) + var allowPubKeys []ssh.PublicKey + allowAnyPubKey := false + + a := &ssh.AuthPipe{ + User: pipe.Authmap.MappedUsername, + + UpstreamHostKeyCallback: hostKeyCallback, + } + + for _, from := range pipe.Authmap.From { + switch from.Type { + case "none": + + if a.NoneAuthCallback == nil { + a.NoneAuthCallback = func(conn ssh.ConnMetadata) (ssh.AuthPipeType, ssh.AuthMethod, error) { + return to(nil) + } + } + + case "password": + allowPasswords[from.Password] = true + + if a.PasswordCallback == nil { + a.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (ssh.AuthPipeType, ssh.AuthMethod, error) { + + _, ok := allowPasswords[string(password)] + + if ok { + return to(nil) + } + + if pipe.Authmap.NoPassthrough { + return ssh.AuthPipeTypeDiscard, nil, nil + } + + return ssh.AuthPipeTypePassThrough, nil, nil + } + } + + case "publickey": + + allowAnyPubKey = allowAnyPubKey || from.AllowAnyPublicKey + + if !allowAnyPubKey { + + rest, err := p.loadFileOrDecode(from.AuthorizedKeys, from.AuthorizedKeysData, ctx) + if err != nil { + return nil, err + } + + var authedPubkey ssh.PublicKey + + for len(rest) > 0 { + authedPubkey, _, _, rest, err = ssh.ParseAuthorizedKey(rest) + if err != nil { + return nil, err + } + + allowPubKeys = append(allowPubKeys, authedPubkey) + } + } + + if a.PublicKeyCallback == nil { + a.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (ssh.AuthPipeType, ssh.AuthMethod, error) { + + if allowAnyPubKey { + return to(key) + } + + keydata := key.Marshal() + + for _, authedPubkey := range allowPubKeys { + if bytes.Equal(authedPubkey.Marshal(), keydata) { + return to(key) + } + } + + if pipe.Authmap.NoPassthrough { + return ssh.AuthPipeTypeDiscard, nil, nil + } + + // will fail but discard will lead a timeout + return ssh.AuthPipeTypePassThrough, nil, nil + } + } + + case "any": + a.NoneAuthCallback = func(conn ssh.ConnMetadata) (ssh.AuthPipeType, ssh.AuthMethod, error) { + return to(nil) + } + + a.PasswordCallback = func(conn ssh.ConnMetadata, password []byte) (ssh.AuthPipeType, ssh.AuthMethod, error) { + return to(nil) + } + + a.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (ssh.AuthPipeType, ssh.AuthMethod, error) { + return to(key) + } + + return a, nil + + default: + p.logger.Printf("unsupport type [%v], ignore section", from.Type) + } + + } + + return a, nil +} + +func (p *plugin) findUpstream(conn ssh.ConnMetadata, challengeContext ssh.AdditionalChallengeContext) (net.Conn, *ssh.AuthPipe, error) { + user := conn.User() + + config, err := p.loadConfig() + + if err != nil { + return nil, nil, err + } + + for _, pipe := range config.Pipes { + matched := pipe.Username == user + + if pipe.UsernameRegexMatch { + matched, _ = regexp.MatchString(pipe.Username, user) + } + + if matched { + + p.logger.Printf("mapping [%v] to [%v]", user, pipe.Username) + + c, err := upstream.DialForSSH(pipe.UpstreamHost) + if err != nil { + return nil, nil, err + } + + a, err := p.createAuthPipe(pipe, conn, challengeContext) + if err != nil { + return nil, nil, err + } + + return c, a, nil + } + } + + return nil, nil, fmt.Errorf("username not [%v] found", user) +} diff --git a/ver b/ver index aed7b553..b043aa64 100644 --- a/ver +++ b/ver @@ -1 +1 @@ -v0.4.5dev +v0.5.0