From 5bdf895dfc410d5c6ebe9faf856826174e93a830 Mon Sep 17 00:00:00 2001 From: Boshi Lian Date: Sat, 9 Jul 2022 18:33:32 +0000 Subject: [PATCH] add yaml plugin --- e2e/yaml_test.go | 292 ++++++++++++++++++++++++++++++++++++++++++++ go.mod | 3 +- go.sum | 2 + plugin/yaml/main.go | 52 ++++++++ plugin/yaml/yaml.go | 281 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 629 insertions(+), 1 deletion(-) create mode 100644 e2e/yaml_test.go create mode 100644 plugin/yaml/main.go create mode 100644 plugin/yaml/yaml.go diff --git a/e2e/yaml_test.go b/e2e/yaml_test.go new file mode 100644 index 00000000..d3304e20 --- /dev/null +++ b/e2e/yaml_test.go @@ -0,0 +1,292 @@ +package e2e_test + +import ( + "encoding/base64" + "fmt" + "html/template" + "os" + "path" + "testing" + "time" + + "github.com/google/uuid" +) + +const yamlConfigTemplate = ` +version: "1.0" +pipes: +- from: + - username: "password_simple" + to: + host: host-password:2222 + username: "user" + ignore_hostkey: true +- from: + - username: "password_.*_regex" + username_regex_match: true + to: + host: host-password:2222 + username: "user" + ignore_hostkey: true +- from: + - username: "publickey_simple" + authorized_keys: {{ .AuthorizedKeys_Simple }} + to: + host: host-publickey:2222 + username: "user" + private_key: {{ .PrivateKey }} + known_hosts_data: {{ .KnownHosts }} +- from: + - username: ".*" + username_regex_match: true + authorized_keys: {{ .AuthorizedKeys_Catchall }} + to: + host: host-publickey:2222 + username: "user" + ignore_hostkey: true + private_key: {{ .PrivateKey }} +` + +func TestYaml(t *testing.T) { + + yamldir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + yamlfile, err := os.OpenFile(path.Join(yamldir, "config.yaml"), os.O_RDWR|os.O_CREATE, 0400) + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + { + // simple key + if err := runCmdAndWait("rm", "-f", path.Join(yamldir, "id_rsa_simple")); err != nil { + t.Errorf("failed to remove id_rsa: %v", err) + } + + if err := runCmdAndWait( + "ssh-keygen", + "-N", + "", + "-f", + path.Join(yamldir, "id_rsa_simple"), + ); err != nil { + t.Errorf("failed to generate private key: %v", err) + } + + // catch all key + if err := runCmdAndWait("rm", "-f", path.Join(yamldir, "id_rsa_catchall")); err != nil { + t.Errorf("failed to remove id_rsa: %v", err) + } + + if err := runCmdAndWait( + "ssh-keygen", + "-N", + "", + "-f", + path.Join(yamldir, "id_rsa_catchall"), + ); err != nil { + t.Errorf("failed to generate private key: %v", err) + } + + // upstream key + if err := runCmdAndWait("rm", "-f", path.Join(yamldir, "id_rsa")); err != nil { + t.Errorf("failed to remove id_rsa: %v", err) + } + + if err := runCmdAndWait( + "ssh-keygen", + "-N", + "", + "-f", + path.Join(yamldir, "id_rsa"), + ); err != nil { + t.Errorf("failed to generate private key: %v", err) + } + + if err := runCmdAndWait( + "/bin/cp", + path.Join(yamldir, "id_rsa.pub"), + "/sshconfig_publickey/.ssh/authorized_keys", + ); err != nil { + t.Errorf("failed to copy public key: %v", err) + } + } + + knownHostsData, err := runAndGetStdout( + "ssh-keyscan", + "-p", + "2222", + "host-publickey", + ) + + if err != nil { + t.Errorf("failed to run ssh-keyscan: %v", err) + } + + if err := template.Must(template.New("yaml").Parse(yamlConfigTemplate)).ExecuteTemplate(yamlfile, "yaml", struct { + KnownHosts string + PrivateKey string + + AuthorizedKeys_Simple string + AuthorizedKeys_Catchall string + }{ + KnownHosts: base64.StdEncoding.EncodeToString(knownHostsData), + PrivateKey: path.Join(yamldir, "id_rsa"), + + AuthorizedKeys_Simple: path.Join(yamldir, "id_rsa_simple.pub"), + AuthorizedKeys_Catchall: path.Join(yamldir, "id_rsa_catchall.pub"), + }); err != nil { + t.Fatalf("Failed to write yaml file %v", err) + } + + // dump config.yaml to stdout + _ = runCmdAndWait("cat", "-n", path.Join(yamldir, "config.yaml")) + + piperaddr, piperport := nextAvailablePiperAddress() + + piper, _, _, err := runCmd("/sshpiperd/sshpiperd", + "-p", + piperport, + "/sshpiperd/plugins/yaml", + "--config", + yamlfile.Name(), + ) + + if err != nil { + t.Errorf("failed to run sshpiperd: %v", err) + } + + defer killCmd(piper) + waitForEndpointReady(piperaddr) + + t.Run("password_simple", func(t *testing.T) { + randtext := uuid.New().String() + targetfie := uuid.New().String() + + c, stdin, stdout, err := runCmd( + "ssh", + "-v", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + piperport, + "-l", + "password_simple", + "127.0.0.1", + fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), + ) + + if err != nil { + t.Errorf("failed to ssh to piper, %v", err) + } + + defer killCmd(c) + + enterPassword(stdin, stdout, "pass") + + time.Sleep(time.Second) // wait for file flush + + checkSharedFileContent(t, targetfie, randtext) + }) + + t.Run("password_regex", func(t *testing.T) { + randtext := uuid.New().String() + targetfie := uuid.New().String() + + c, stdin, stdout, err := runCmd( + "ssh", + "-v", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + piperport, + "-l", + "password_XXX_regex", + "127.0.0.1", + fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), + ) + + if err != nil { + t.Errorf("failed to ssh to piper, %v", err) + } + + defer killCmd(c) + + enterPassword(stdin, stdout, "pass") + + time.Sleep(time.Second) // wait for file flush + + checkSharedFileContent(t, targetfie, randtext) + }) + + t.Run("publickey_simple", func(t *testing.T) { + randtext := uuid.New().String() + targetfie := uuid.New().String() + + c, _, _, err := runCmd( + "ssh", + "-v", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + piperport, + "-l", + "publickey_simple", + "-i", + path.Join(yamldir, "id_rsa_simple"), + "127.0.0.1", + fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), + ) + + if err != nil { + t.Errorf("failed to ssh to piper, %v", err) + } + + defer killCmd(c) + + time.Sleep(time.Second) // wait for file flush + + checkSharedFileContent(t, targetfie, randtext) + }) + + t.Run("catch_all", func(t *testing.T) { + randtext := uuid.New().String() + targetfie := uuid.New().String() + + c, _, _, err := runCmd( + "ssh", + "-v", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + piperport, + "-l", + "anyusername", + "-i", + path.Join(yamldir, "id_rsa_catchall"), + "127.0.0.1", + fmt.Sprintf(`sh -c "echo -n %v > /shared/%v"`, randtext, targetfie), + ) + + if err != nil { + t.Errorf("failed to ssh to piper, %v", err) + } + + defer killCmd(c) + + time.Sleep(time.Second) // wait for file flush + + checkSharedFileContent(t, targetfie, randtext) + }) + +} diff --git a/go.mod b/go.mod index 8124dda4..67c04625 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/google/uuid v1.3.0 github.com/microsoft/kiota-authentication-azure-go v0.3.1 github.com/microsoftgraph/msgraph-sdk-go v0.28.0 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pires/go-proxyproto v0.6.2 github.com/sirupsen/logrus v1.8.1 github.com/tg123/remotesigner v0.0.0-20210928104451-7c20285909d1 @@ -18,6 +19,7 @@ require ( golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d google.golang.org/grpc v1.47.0 google.golang.org/protobuf v1.28.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -44,5 +46,4 @@ require ( golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect golang.org/x/text v0.3.7 // indirect google.golang.org/genproto v0.0.0-20211001223012-bfb93cce50d9 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c3f2a2a0..d3f8c4d2 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/microsoftgraph/msgraph-sdk-go v0.28.0/go.mod h1:MB2iPD8vd8RTRAqlKTZfh github.com/microsoftgraph/msgraph-sdk-go-core v0.26.1 h1:wzEoPk6uyd+SkBur9NpzpPfKseUQYT1yjFkDUueLpfk= github.com/microsoftgraph/msgraph-sdk-go-core v0.26.1/go.mod h1:cKWHZ9CfJnSvBJxzXnAXRA+SSy8JeAbS+4kOn3uyOIY= github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pires/go-proxyproto v0.6.2 h1:KAZ7UteSOt6urjme6ZldyFm4wDe/z0ZUP0Yv0Dos0d8= github.com/pires/go-proxyproto v0.6.2/go.mod h1:Odh9VFOZJCf9G8cLW5o435Xf1J95Jw9Gw5rnCjcwzAY= github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4 h1:Qj1ukM4GlMWXNdMBuXcXfz/Kw9s1qm0CLY32QxuSImI= diff --git a/plugin/yaml/main.go b/plugin/yaml/main.go new file mode 100644 index 00000000..ecd003eb --- /dev/null +++ b/plugin/yaml/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "github.com/tg123/sshpiper/libplugin" + "github.com/urfave/cli/v2" +) + +func main() { + + plugin := newYamlPlugin() + + libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ + Name: "yaml", + Usage: "sshpiperd yaml plugin", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Usage: "path to yaml config file", + Required: true, + EnvVars: []string{"SSHPIPERD_YAML_CONFIG"}, + Destination: &plugin.File, + }, + &cli.BoolFlag{ + Name: "no-check-perm", + Usage: "disable 0400 checking", + EnvVars: []string{"SSHPIPERD_YAML_NOCHECKPERM"}, + Destination: &plugin.NoCheckPerm, + }, + }, + CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { + + return &libplugin.SshPiperPluginConfig{ + + NextAuthMethodsCallback: func(_ libplugin.ConnMetadata) ([]string, error) { + return plugin.supportedMethods() + }, + + PasswordCallback: func(conn libplugin.ConnMetadata, password []byte) (*libplugin.Upstream, error) { + return plugin.findAndCreateUpstream(conn, string(password), nil) + }, + + PublicKeyCallback: func(conn libplugin.ConnMetadata, key []byte) (*libplugin.Upstream, error) { + return plugin.findAndCreateUpstream(conn, "", key) + }, + + VerifyHostKeyCallback: func(conn libplugin.ConnMetadata, hostname, netaddr string, key []byte) error { + return plugin.verifyHostKey(conn, hostname, netaddr, key) + }, + }, nil + }, + }) +} diff --git a/plugin/yaml/yaml.go b/plugin/yaml/yaml.go new file mode 100644 index 00000000..2a8205ec --- /dev/null +++ b/plugin/yaml/yaml.go @@ -0,0 +1,281 @@ +package main + +import ( + "bytes" + "encoding/base64" + "fmt" + "io/ioutil" + "net" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/patrickmn/go-cache" + "github.com/tg123/sshpiper/libplugin" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + "gopkg.in/yaml.v3" +) + +type pipeConfigFrom struct { + Username string `yaml:"username"` + UsernameRegexMatch bool `yaml:"username_regex_match,omitempty"` + AuthorizedKeys string `yaml:"authorized_keys,omitempty"` + AuthorizedKeysData string `yaml:"authorized_keys_data,omitempty"` +} + +type pipeConfigTo struct { + Username string `yaml:"username,omitempty"` + Host string `yaml:"host"` + Password string `yaml:"password,omitempty"` + PrivateKey string `yaml:"private_key,omitempty"` + PrivateKeyData string `yaml:"private_key_data,omitempty"` + KnownHosts string `yaml:"known_hosts,omitempty"` + KnownHostsData string `yaml:"known_hosts_data,omitempty"` + IgnoreHostkey bool `yaml:"ignore_hostkey,omitempty"` +} + +type pipeConfig struct { + From []pipeConfigFrom `yaml:"from,flow"` + To pipeConfigTo `yaml:"to,flow"` +} + +type piperConfig struct { + Version string `yaml:"version"` + Pipes []pipeConfig `yaml:"pipes,flow"` +} + +type plugin struct { + File string + NoCheckPerm bool + + cache *cache.Cache +} + +func newYamlPlugin() *plugin { + return &plugin{ + cache: cache.New(1*time.Minute, 10*time.Minute), + } +} + +func (p *plugin) checkPerm() error { + filename := p.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.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.File) + if err != nil { + return config, err + } + + err = yaml.Unmarshal(configbyte, &config) + if err != nil { + return config, err + } + + return config, nil +} + +func (p *plugin) loadFileOrDecode(file string, base64data string, vars map[string]string) ([]byte, error) { + if file != "" { + + file = os.Expand(file, func(placeholderName string) string { + v, ok := vars[placeholderName] + if ok { + return v + } + + return os.Getenv(placeholderName) + }) + + if !filepath.IsAbs(file) { + file = filepath.Join(filepath.Dir(p.File), file) + } + + return ioutil.ReadFile(file) + } + + if base64data != "" { + return base64.StdEncoding.DecodeString(base64data) + } + + return nil, nil +} + +func (p *plugin) supportedMethods() ([]string, error) { + config, err := p.loadConfig() + if err != nil { + return nil, err + } + + set := make(map[string]bool) + + for _, pipe := range config.Pipes { + for _, from := range pipe.From { + if from.AuthorizedKeys != "" || from.AuthorizedKeysData != "" { + set["publickey"] = true // found authorized_keys, so we support publickey + } else { + set["password"] = true // no authorized_keys, so we support password + } + } + } + + var methods []string + for k := range set { + methods = append(methods, k) + } + return methods, nil +} + +func (p *plugin) verifyHostKey(conn libplugin.ConnMetadata, hostname, netaddr string, key []byte) error { + item, found := p.cache.Get(conn.UniqueID()) + + if !found { + return fmt.Errorf("connection expired") + } + + to := item.(*pipeConfigTo) + + data, err := p.loadFileOrDecode(to.KnownHosts, to.KnownHostsData, map[string]string{ + "DOWNSTREAM_USER": conn.User(), + "UPSTREAM_USER": to.Username, + }) + if err != nil { + return err + } + + hostKeyCallback, err := knownhosts.NewFromReader(bytes.NewBuffer(data)) + if err != nil { + return err + } + + pub, err := ssh.ParsePublicKey(key) + if err != nil { + return err + } + + addr, err := net.ResolveTCPAddr("tcp", netaddr) + if err != nil { + return err + } + + return hostKeyCallback(hostname, addr, pub) +} + +func (p *plugin) createUpstream(conn libplugin.ConnMetadata, to pipeConfigTo, originPassword string) (*libplugin.Upstream, error) { + + host, port, err := libplugin.SplitHostPortForSSH(to.Host) + if err != nil { + return nil, err + } + + u := &libplugin.Upstream{ + Host: host, + Port: int32(port), + UserName: to.Username, + IgnoreHostKey: to.IgnoreHostkey, + } + + pass := to.Password + if pass == "" { + pass = originPassword + } + + // password found + if pass != "" { + u.Auth = libplugin.CreatePasswordAuth([]byte(pass)) + return u, p.cache.Add(conn.UniqueID(), &to, cache.DefaultExpiration) + } + + // try private key + data, err := p.loadFileOrDecode(to.PrivateKey, to.PrivateKeyData, map[string]string{ + "DOWNSTREAM_USER": conn.User(), + "UPSTREAM_USER": to.Username, + }) + if err != nil { + return nil, err + } + + if data != nil { + u.Auth = libplugin.CreatePrivateKeyAuth(data) + return u, p.cache.Add(conn.UniqueID(), &to, cache.DefaultExpiration) + } + + return nil, fmt.Errorf("no password or private key found") +} + +func (p *plugin) findAndCreateUpstream(conn libplugin.ConnMetadata, password string, publicKey []byte) (*libplugin.Upstream, error) { + user := conn.User() + + config, err := p.loadConfig() + if err != nil { + return nil, err + } + + for _, pipe := range config.Pipes { + for _, from := range pipe.From { + matched := from.Username == user + + if from.UsernameRegexMatch { + matched, _ = regexp.MatchString(from.Username, user) + } + + if !matched { + continue + } + + if publicKey == nil { + return p.createUpstream(conn, pipe.To, password) + } + + rest, err := p.loadFileOrDecode(from.AuthorizedKeys, from.AuthorizedKeysData, map[string]string{ + "DOWNSTREAM_USER": user, + }) + 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 + } + + if bytes.Equal(authedPubkey.Marshal(), publicKey) { + return p.createUpstream(conn, pipe.To, "") + } + } + } + } + + return nil, fmt.Errorf("no matching pipe for username [%v] found", user) +}