diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a405943e..7ee6f511 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -116,20 +116,6 @@ builds: binary: plugins/failtoban tags: - full - - id: plugin_workingdirbykey - env: - - CGO_ENABLED=0 - goos: - - linux - - windows - # - darwin - goarch: - - amd64 - - arm64 - main: ./plugin/workingdirbykey - binary: plugins/workingdirbykey - tags: - - full - id: plugin_totp env: - CGO_ENABLED=0 diff --git a/libplugin/skel.go b/libplugin/skel.go new file mode 100644 index 00000000..eaa0cc97 --- /dev/null +++ b/libplugin/skel.go @@ -0,0 +1,312 @@ +package libplugin + +import ( + "bytes" + "crypto/subtle" + "fmt" + "time" + + "github.com/patrickmn/go-cache" + "golang.org/x/crypto/ssh" + + log "github.com/sirupsen/logrus" +) + +type SkelPlugin struct { + cache *cache.Cache + listPipe func(ConnMetadata) ([]SkelPipe, error) +} + +func NewSkelPlugin(listPipe func(ConnMetadata) ([]SkelPipe, error)) *SkelPlugin { + return &SkelPlugin{ + cache: cache.New(1*time.Minute, 10*time.Minute), + listPipe: listPipe, + } +} + +type SkelPipe interface { + From() []SkelPipeFrom +} + +type SkelPipeFrom interface { + MatchConn(conn ConnMetadata) (SkelPipeTo, error) +} + +type SkelPipeFromPassword interface { + SkelPipeFrom + + TestPassword(conn ConnMetadata, password []byte) (bool, error) +} + +type SkelPipeFromPublicKey interface { + SkelPipeFrom + + AuthorizedKeys(conn ConnMetadata) ([]byte, error) + TrustedUserCAKeys(conn ConnMetadata) ([]byte, error) +} + +type SkelPipeTo interface { + Host(conn ConnMetadata) string + User(conn ConnMetadata) string + IgnoreHostKey(conn ConnMetadata) bool + KnownHosts(conn ConnMetadata) ([]byte, error) +} + +type SkelPipeToPassword interface { + SkelPipeTo + + OverridePassword(conn ConnMetadata) ([]byte, error) +} + +type SkelPipeToPrivateKey interface { + SkelPipeTo + + PrivateKey(conn ConnMetadata) ([]byte, []byte, error) +} + +func (p *SkelPlugin) CreateConfig() *SshPiperPluginConfig { + return &SshPiperPluginConfig{ + NextAuthMethodsCallback: p.SupportedMethods, + PasswordCallback: p.PasswordCallback, + PublicKeyCallback: p.PublicKeyCallback, + VerifyHostKeyCallback: p.VerifyHostKeyCallback, + } +} + +func (p *SkelPlugin) SupportedMethods(conn ConnMetadata) ([]string, error) { + set := make(map[string]bool) + + pipes, err := p.listPipe(conn) + if err != nil { + return nil, err + } + + for _, pipe := range pipes { + for _, from := range pipe.From() { + + switch from.(type) { + case SkelPipeFromPublicKey: + set["publickey"] = true + default: + set["password"] = true + } + + if len(set) == 2 { + break + } + } + } + + var methods []string + for k := range set { + methods = append(methods, k) + } + + return methods, nil +} + +func (p *SkelPlugin) VerifyHostKeyCallback(conn ConnMetadata, hostname, netaddr string, key []byte) error { + item, found := p.cache.Get(conn.UniqueID()) + if !found { + log.Warnf("connection expired when verifying host key for conn [%v]", conn.UniqueID()) + return fmt.Errorf("connection expired") + } + + to := item.(SkelPipeTo) + + data, err := to.KnownHosts(conn) + if err != nil { + return err + } + + return VerifyHostKeyFromKnownHosts(bytes.NewBuffer(data), hostname, netaddr, key) +} + +func (p *SkelPlugin) match(conn ConnMetadata, verify func(SkelPipeFrom) (bool, error)) (SkelPipeFrom, SkelPipeTo, error) { + pipes, err := p.listPipe(conn) + if err != nil { + return nil, nil, err + } + + for _, pipe := range pipes { + for _, from := range pipe.From() { + + to, err := from.MatchConn(conn) + if err != nil { + return nil, nil, err + } + + if to == nil { + continue + } + + ok, err := verify(from) + if err != nil { + return nil, nil, err + } + + if ok { + return from, to, nil + } + } + } + + return nil, nil, fmt.Errorf("no matching pipe for username [%v] found", conn.User()) +} + +func (p *SkelPlugin) PasswordCallback(conn ConnMetadata, password []byte) (*Upstream, error) { + _, to, err := p.match(conn, func(from SkelPipeFrom) (bool, error) { + frompass, ok := from.(SkelPipeFromPassword) + + if !ok { + return false, nil + } + + return frompass.TestPassword(conn, password) + }) + if err != nil { + return nil, err + } + + u, err := p.createUpstream(conn, to) + if err != nil { + return nil, err + } + + toPass, ok := to.(SkelPipeToPassword) + if !ok { + return nil, fmt.Errorf("pipe to does not support password") + } + + overridepassword, err := toPass.OverridePassword(conn) + if err != nil { + return nil, err + } + + if overridepassword != nil { + u.Auth = CreatePasswordAuth(overridepassword) + } else { + u.Auth = CreatePasswordAuth(password) + } + + return u, nil + +} + +func (p *SkelPlugin) PublicKeyCallback(conn ConnMetadata, publicKey []byte) (*Upstream, error) { + + pubKey, err := ssh.ParsePublicKey(publicKey) + if err != nil { + return nil, err + } + + pkcert, isCert := pubKey.(*ssh.Certificate) + if isCert { + // ensure cert is valid first + + if pkcert.CertType != ssh.UserCert { + return nil, fmt.Errorf("only user certificates are supported, cert type: %v", pkcert.CertType) + } + + certChecker := ssh.CertChecker{} + if err := certChecker.CheckCert(conn.User(), pkcert); err != nil { + return nil, err + } + } + + _, to, err := p.match(conn, func(from SkelPipeFrom) (bool, error) { + // verify public key + fromPubKey, ok := from.(SkelPipeFromPublicKey) + if !ok { + return false, nil + } + + verified := false + + if isCert { + rest, err := fromPubKey.TrustedUserCAKeys(conn) + if err != nil { + return false, err + } + + log.Debugf("trusted user ca keys: %v", rest) + + var trustedca ssh.PublicKey + for len(rest) > 0 { + trustedca, _, _, rest, err = ssh.ParseAuthorizedKey(rest) + if err != nil { + return false, err + } + + if subtle.ConstantTimeCompare(trustedca.Marshal(), pkcert.SignatureKey.Marshal()) == 1 { + verified = true + break + } + } + } else { + rest, err := fromPubKey.AuthorizedKeys(conn) + if err != nil { + return false, err + } + + var authedPubkey ssh.PublicKey + for len(rest) > 0 { + authedPubkey, _, _, rest, err = ssh.ParseAuthorizedKey(rest) + if err != nil { + return false, err + } + + if subtle.ConstantTimeCompare(authedPubkey.Marshal(), publicKey) == 1 { + verified = true + break + } + } + } + + return verified, nil + }) + + if err != nil { + return nil, err + } + + u, err := p.createUpstream(conn, to) + if err != nil { + return nil, err + } + + toPrivateKey, ok := to.(SkelPipeToPrivateKey) + if !ok { + return nil, fmt.Errorf("pipe to does not support private key") + } + + priv, cert, err := toPrivateKey.PrivateKey(conn) + if err != nil { + return nil, err + } + + u.Auth = CreatePrivateKeyAuth(priv, cert) + + return u, nil +} + +func (p *SkelPlugin) createUpstream(conn ConnMetadata, to SkelPipeTo) (*Upstream, error) { + host, port, err := SplitHostPortForSSH(to.Host(conn)) + if err != nil { + return nil, err + } + + user := to.User(conn) + if user == "" { + user = conn.User() + } + + p.cache.SetDefault(conn.UniqueID(), to) + + return &Upstream{ + Host: host, + Port: int32(port), // port is already checked to be within int32 range in SplitHostPortForSSH + UserName: user, + IgnoreHostKey: to.IgnoreHostKey(conn), + }, err +} diff --git a/libplugin/util.go b/libplugin/util.go index 2426b4b2..c931c9a8 100644 --- a/libplugin/util.go +++ b/libplugin/util.go @@ -67,11 +67,12 @@ func SplitHostPortForSSH(addr string) (host string, port int, err error) { h, p, err := net.SplitHostPort(host) if err == nil { host = h - port, err = strconv.Atoi(p) - + var parsedPort int64 + parsedPort, err = strconv.ParseInt(p, 10, 32) if err != nil { return } + port = int(parsedPort) } else if host != "" { // test valid after concat :22 if _, _, err = net.SplitHostPort(host + ":22"); err == nil { diff --git a/plugin/docker/docker.go b/plugin/docker/docker.go index 2e6c3eb1..e635a11c 100644 --- a/plugin/docker/docker.go +++ b/plugin/docker/docker.go @@ -4,8 +4,6 @@ package main import ( "context" - "crypto/subtle" - "encoding/base64" "fmt" "net" @@ -13,8 +11,6 @@ import ( "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" log "github.com/sirupsen/logrus" - "github.com/tg123/sshpiper/libplugin" - "golang.org/x/crypto/ssh" ) type pipe struct { @@ -39,7 +35,7 @@ func newDockerPlugin() (*plugin, error) { }, nil } -func (p *plugin) listPipes() ([]pipe, error) { +func (p *plugin) list() ([]pipe, error) { // filter := filters.NewArgs() // filter.Add("label", fmt.Sprintf("sshpiper.username=%v", username)) @@ -115,107 +111,3 @@ func (p *plugin) listPipes() ([]pipe, error) { return pipes, nil } - -func (p *plugin) supportedMethods() ([]string, error) { - pipes, err := p.listPipes() - if err != nil { - return nil, err - } - - set := make(map[string]bool) - - for _, pipe := range pipes { - if pipe.AuthorizedKeys != "" { - 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) createUpstream(conn libplugin.ConnMetadata, to pipe, 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.ContainerUsername, - IgnoreHostKey: true, - } - - // password found - if originPassword != "" { - u.Auth = libplugin.CreatePasswordAuth([]byte(originPassword)) - return u, nil - } - - // try private key - data, err := base64.StdEncoding.DecodeString(to.PrivateKey) - if err != nil { - return nil, err - } - - if data != nil { - u.Auth = libplugin.CreatePrivateKeyAuth(data) - return u, nil - } - - 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() - - pipes, err := p.listPipes() - if err != nil { - return nil, err - } - - for _, pipe := range pipes { - - // test password - if publicKey == nil && password != "" { - if pipe.ClientUsername != user { - continue - } - - return p.createUpstream(conn, pipe, password) - } - - // test public key - if pipe.ClientUsername != "" { - if pipe.ClientUsername != user { - continue - } - } - - // ignore username and match all - rest, err := base64.StdEncoding.DecodeString(pipe.AuthorizedKeys) - 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 subtle.ConstantTimeCompare(authedPubkey.Marshal(), publicKey) == 1 { - return p.createUpstream(conn, pipe, "") - } - } - } - - return nil, fmt.Errorf("no matching pipe for username [%v] found", user) -} diff --git a/plugin/docker/main.go b/plugin/docker/main.go index 591368df..a0f502e9 100644 --- a/plugin/docker/main.go +++ b/plugin/docker/main.go @@ -18,20 +18,8 @@ func main() { return nil, err } - 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) - }, - }, nil + skel := libplugin.NewSkelPlugin(plugin.listPipe) + return skel.CreateConfig(), nil }, }) } diff --git a/plugin/docker/skel.go b/plugin/docker/skel.go new file mode 100644 index 00000000..762e8aa5 --- /dev/null +++ b/plugin/docker/skel.go @@ -0,0 +1,130 @@ +//go:build full || e2e + +package main + +import ( + "encoding/base64" + + "github.com/tg123/sshpiper/libplugin" +) + +type skelpipeWrapper struct { + plugin *plugin + + pipe *pipe +} + +type skelpipeFromWrapper struct { + skelpipeWrapper +} + +type skelpipePasswordWrapper struct { + skelpipeFromWrapper +} + +type skelpipePublicKeyWrapper struct { + skelpipeFromWrapper +} + +type skelpipeToWrapper struct { + skelpipeWrapper + + username string +} + +func (s *skelpipeWrapper) From() []libplugin.SkelPipeFrom { + + w := skelpipeFromWrapper{ + skelpipeWrapper: *s, + } + + if s.pipe.PrivateKey != "" || s.pipe.AuthorizedKeys != "" { + return []libplugin.SkelPipeFrom{&skelpipePublicKeyWrapper{ + skelpipeFromWrapper: w, + }} + } else { + return []libplugin.SkelPipeFrom{&skelpipePasswordWrapper{ + skelpipeFromWrapper: w, + }} + } +} + +func (s *skelpipeToWrapper) User(conn libplugin.ConnMetadata) string { + return s.username +} + +func (s *skelpipeToWrapper) Host(conn libplugin.ConnMetadata) string { + return s.pipe.Host +} + +func (s *skelpipeToWrapper) IgnoreHostKey(conn libplugin.ConnMetadata) bool { + return true // TODO support this +} + +func (s *skelpipeToWrapper) KnownHosts(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil // TODO support this +} + +func (s *skelpipeFromWrapper) MatchConn(conn libplugin.ConnMetadata) (libplugin.SkelPipeTo, error) { + user := conn.User() + + matched := s.pipe.ClientUsername == user || s.pipe.ClientUsername == "" + targetuser := s.pipe.ContainerUsername + + if targetuser == "" { + targetuser = user + } + + if matched { + return &skelpipeToWrapper{ + skelpipeWrapper: s.skelpipeWrapper, + username: targetuser, + }, nil + } + + return nil, nil +} + +func (s *skelpipePasswordWrapper) TestPassword(conn libplugin.ConnMetadata, password []byte) (bool, error) { + return true, nil // do not test input password +} + +func (s *skelpipePublicKeyWrapper) AuthorizedKeys(conn libplugin.ConnMetadata) ([]byte, error) { + return base64.StdEncoding.DecodeString(s.pipe.AuthorizedKeys) +} + +func (s *skelpipePublicKeyWrapper) TrustedUserCAKeys(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil // TODO support this +} + +func (s *skelpipeToWrapper) PrivateKey(conn libplugin.ConnMetadata) ([]byte, []byte, error) { + k, err := base64.StdEncoding.DecodeString(s.pipe.PrivateKey) + if err != nil { + return nil, nil, err + } + + return k, nil, nil +} + +func (s *skelpipeToWrapper) OverridePassword(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil +} + +func (p *plugin) listPipe(_ libplugin.ConnMetadata) ([]libplugin.SkelPipe, error) { + dpipes, err := p.list() + if err != nil { + return nil, err + } + + var pipes []libplugin.SkelPipe + for _, pipe := range dpipes { + wrapper := &skelpipeWrapper{ + plugin: p, + pipe: &pipe, + } + pipes = append(pipes, wrapper) + + } + + return pipes, nil +} diff --git a/plugin/kubernetes/kubernetes.go b/plugin/kubernetes/kubernetes.go index caf84947..90fa4d98 100644 --- a/plugin/kubernetes/kubernetes.go +++ b/plugin/kubernetes/kubernetes.go @@ -1,23 +1,9 @@ package main import ( - "bytes" - "context" - "crypto/subtle" - "encoding/base64" - "fmt" - "os" - "regexp" - "time" - - gocache "github.com/patrickmn/go-cache" - log "github.com/sirupsen/logrus" - "github.com/tg123/go-htpasswd" - "github.com/tg123/sshpiper/libplugin" piperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" sshpiper "github.com/tg123/sshpiper/plugin/kubernetes/generated/clientset/versioned" piperlister "github.com/tg123/sshpiper/plugin/kubernetes/generated/listers/sshpiper/v1beta1" - "golang.org/x/crypto/ssh" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/labels" @@ -31,7 +17,6 @@ type plugin struct { k8sclient corev1.CoreV1Interface lister piperlister.PipeLister stop chan<- struct{} - cache *gocache.Cache } func newKubernetesPlugin(allNamespaces bool, kubeConfigPath string) (*plugin, error) { @@ -77,7 +62,6 @@ func newKubernetesPlugin(allNamespaces bool, kubeConfigPath string) (*plugin, er k8sclient: k8sclient.CoreV1(), lister: lister, stop: stop, - cache: gocache.New(1*time.Minute, 10*time.Minute), }, nil } @@ -88,221 +72,3 @@ func (p *plugin) Stop() { func (p *plugin) list() ([]*piperv1beta1.Pipe, error) { return p.lister.List(labels.Everything()) } - -func (p *plugin) supportedMethods() ([]string, error) { - pipes, err := p.list() - if err != nil { - return nil, err - } - - set := make(map[string]bool) - - for _, pipe := range pipes { - for _, from := range pipe.Spec.From { - if from.AuthorizedKeysData != "" || from.AuthorizedKeysFile != "" { - set["publickey"] = true // found authorized_keys, so we support publickey - } else { - set["password"] = true // no authorized_keys, so we support password - } - - if from.HtpasswdData != "" || from.HtpasswdFile != "" { - set["password"] = true // found htpasswd, 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") - } - - pipe := item.(*piperv1beta1.Pipe) - to := pipe.Spec.To - - data, err := base64.StdEncoding.DecodeString(to.KnownHostsData) - if err != nil { - return err - } - - return libplugin.VerifyHostKeyFromKnownHosts(bytes.NewBuffer(data), hostname, netaddr, key) -} - -func (p *plugin) createUpstream(conn libplugin.ConnMetadata, pipe *piperv1beta1.Pipe, originPassword string) (*libplugin.Upstream, error) { - to := pipe.Spec.To - 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, - } - - if to.PrivateKeySecret.Name != "" { - log.Debugf("mapping to %v private key using secret %v", to.Host, to.PrivateKeySecret.Name) - secret, err := p.k8sclient.Secrets(pipe.Namespace).Get(context.Background(), to.PrivateKeySecret.Name, metav1.GetOptions{}) - if err != nil { - return nil, err - } - - anno := pipe.GetAnnotations() - var publicKey []byte - var privateKey []byte - - for _, k := range []string{anno["privatekey_field_name"], "ssh-privatekey", "privatekey"} { - data := secret.Data[k] - if data != nil { - log.Debugf("found private key in secret %v/%v", to.PrivateKeySecret.Name, k) - privateKey = data - break - } - } - - for _, k := range []string{anno["publickey_field_name"], "ssh-publickey-cert", "publickey-cert", "ssh-publickey", "publickey"} { - data := secret.Data[k] - if data != nil { - log.Debugf("found publickey key cert in secret %v/%v", to.PrivateKeySecret.Name, k) - publicKey = data - break - } - } - - if privateKey != nil { - u.Auth = libplugin.CreatePrivateKeyAuth(privateKey, publicKey) - p.cache.Set(conn.UniqueID(), pipe, gocache.DefaultExpiration) - return u, nil - } - } else if to.PasswordSecret.Name != "" { - log.Debugf("mapping to %v password using secret %v", to.Host, to.PasswordSecret.Name) - secret, err := p.k8sclient.Secrets(pipe.Namespace).Get(context.Background(), to.PasswordSecret.Name, metav1.GetOptions{}) - if err != nil { - return nil, err - } - - anno := pipe.GetAnnotations() - for _, k := range []string{anno["password_field_name"], "password"} { - data := secret.Data[k] - if data != nil { - log.Debugf("found password in secret %v/%v", to.PasswordSecret.Name, k) - u.Auth = libplugin.CreatePasswordAuth(data) - p.cache.Set(conn.UniqueID(), pipe, gocache.DefaultExpiration) - return u, nil - } - } - } else if originPassword != "" { - log.Debugf("mapping to %v using user input password", to.Host) - u.Auth = libplugin.CreatePasswordAuth([]byte(originPassword)) - p.cache.Set(conn.UniqueID(), pipe, gocache.DefaultExpiration) - return u, nil - } - - return nil, fmt.Errorf("no password or private key found") -} - -func loadStringAndFile(base64orraw string, filepath string) ([][]byte, error) { - - all := make([][]byte, 0, 2) - - if base64orraw != "" { - data, err := base64.StdEncoding.DecodeString(base64orraw) - if err != nil { - data = []byte(base64orraw) - } - - all = append(all, data) - } - - if filepath != "" { - data, err := os.ReadFile(filepath) - if err != nil { - return nil, err - } - - all = append(all, data) - } - - return all, nil -} - -func (p *plugin) findAndCreateUpstream(conn libplugin.ConnMetadata, password string, publicKey []byte) (*libplugin.Upstream, error) { - user := conn.User() - - pipes, err := p.list() - if err != nil { - return nil, err - } - - for _, pipe := range pipes { - for _, from := range pipe.Spec.From { - matched := from.Username == user - - if from.UsernameRegexMatch { - matched, _ = regexp.MatchString(from.Username, user) - } - - if !matched { - continue - } - - if publicKey == nil && password != "" { - - pwds, err := loadStringAndFile(from.HtpasswdData, from.HtpasswdFile) - if err != nil { - return nil, err - } - - pwdmatched := len(pwds) == 0 - - for _, data := range pwds { - log.Debugf("try to match password using htpasswd") - auth, err := htpasswd.NewFromReader(bytes.NewReader(data), htpasswd.DefaultSystems, nil) - if err != nil { - return nil, err - } - - if auth.Match(user, password) { - pwdmatched = true - } - } - - if pwdmatched { - return p.createUpstream(conn, pipe, password) - } - } - - log.Debugf("try to match public using authorized key") - pubkeydata, err := loadStringAndFile(from.AuthorizedKeysData, from.AuthorizedKeysFile) - if err != nil { - return nil, err - } - - for _, rest := range pubkeydata { - var authedPubkey ssh.PublicKey - for len(rest) > 0 { - authedPubkey, _, _, rest, err = ssh.ParseAuthorizedKey(rest) - if err != nil { - return nil, err - } - - if subtle.ConstantTimeCompare(authedPubkey.Marshal(), publicKey) == 1 { - return p.createUpstream(conn, pipe, "") - } - } - } - } - } - - return nil, fmt.Errorf("no matching pipe for username [%v] found", user) -} diff --git a/plugin/kubernetes/main.go b/plugin/kubernetes/main.go index 39c415bb..52d5ab01 100644 --- a/plugin/kubernetes/main.go +++ b/plugin/kubernetes/main.go @@ -28,23 +28,8 @@ func main() { if err != nil { return nil, err } - 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 + skel := libplugin.NewSkelPlugin(plugin.listPipe) + return skel.CreateConfig(), nil }, }) } diff --git a/plugin/kubernetes/skel.go b/plugin/kubernetes/skel.go new file mode 100644 index 00000000..b315fd73 --- /dev/null +++ b/plugin/kubernetes/skel.go @@ -0,0 +1,257 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "os" + "regexp" + + log "github.com/sirupsen/logrus" + "github.com/tg123/go-htpasswd" + "github.com/tg123/sshpiper/libplugin" + piperv1beta1 "github.com/tg123/sshpiper/plugin/kubernetes/apis/sshpiper/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type skelpipeWrapper struct { + plugin *plugin + + pipe *piperv1beta1.Pipe +} + +type skelpipeFromWrapper struct { + plugin *plugin + + pipe *piperv1beta1.Pipe + from *piperv1beta1.FromSpec + to *piperv1beta1.ToSpec +} + +type skelpipePasswordWrapper struct { + skelpipeFromWrapper +} + +type skelpipePublicKeyWrapper struct { + skelpipeFromWrapper +} + +type skelpipeToWrapper struct { + plugin *plugin + + pipe *piperv1beta1.Pipe + username string + to *piperv1beta1.ToSpec +} + +func (s *skelpipeWrapper) From() []libplugin.SkelPipeFrom { + var froms []libplugin.SkelPipeFrom + for _, f := range s.pipe.Spec.From { + + w := &skelpipeFromWrapper{ + plugin: s.plugin, + pipe: s.pipe, + from: &f, + to: &s.pipe.Spec.To, + } + + if f.AuthorizedKeysData != "" || f.AuthorizedKeysFile != "" { + froms = append(froms, &skelpipePublicKeyWrapper{ + skelpipeFromWrapper: *w, + }) + } else { + froms = append(froms, &skelpipePasswordWrapper{ + skelpipeFromWrapper: *w, + }) + } + } + return froms +} + +func (s *skelpipeToWrapper) User(conn libplugin.ConnMetadata) string { + return s.username +} + +func (s *skelpipeToWrapper) Host(conn libplugin.ConnMetadata) string { + return s.to.Host +} + +func (s *skelpipeToWrapper) IgnoreHostKey(conn libplugin.ConnMetadata) bool { + return s.to.IgnoreHostkey +} + +func (s *skelpipeToWrapper) KnownHosts(conn libplugin.ConnMetadata) ([]byte, error) { + return base64.StdEncoding.DecodeString(s.to.KnownHostsData) +} + +func (s *skelpipeFromWrapper) MatchConn(conn libplugin.ConnMetadata) (libplugin.SkelPipeTo, error) { + user := conn.User() + + matched := s.from.Username == user + targetuser := s.to.Username + + if targetuser == "" { + targetuser = user + } + + if s.from.UsernameRegexMatch { + re, err := regexp.Compile(s.from.Username) + if err != nil { + return nil, err + } + + matched = re.MatchString(user) + + if matched { + targetuser = re.ReplaceAllString(user, s.to.Username) + } + } + + if matched { + return &skelpipeToWrapper{ + plugin: s.plugin, + pipe: s.pipe, + username: targetuser, + to: s.to, + }, nil + } + + return nil, nil +} + +func (s *skelpipePasswordWrapper) TestPassword(conn libplugin.ConnMetadata, password []byte) (bool, error) { + + pwds, err := loadStringAndFile(s.from.HtpasswdData, s.from.HtpasswdFile) + if err != nil { + return false, err + } + + pwdmatched := len(pwds) == 0 + + for _, data := range pwds { + log.Debugf("try to match password using htpasswd") + auth, err := htpasswd.NewFromReader(bytes.NewReader(data), htpasswd.DefaultSystems, nil) + if err != nil { + return false, err + } + + if auth.Match(conn.User(), string(password)) { + pwdmatched = true + } + } + + return pwdmatched, nil // yaml do not test input password +} + +func (s *skelpipePublicKeyWrapper) AuthorizedKeys(conn libplugin.ConnMetadata) ([]byte, error) { + byteSlices, err := loadStringAndFile(s.from.AuthorizedKeysData, s.from.AuthorizedKeysFile) + if err != nil { + return nil, err + } + + return bytes.Join(byteSlices, []byte("\n")), nil +} + +func (s *skelpipePublicKeyWrapper) TrustedUserCAKeys(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil // TODO support trusted_user_ca_keys +} + +func (s *skelpipeToWrapper) PrivateKey(conn libplugin.ConnMetadata) ([]byte, []byte, error) { + + log.Debugf("mapping to %v private key using secret %v", s.to.Host, s.to.PrivateKeySecret.Name) + secret, err := s.plugin.k8sclient.Secrets(s.pipe.Namespace).Get(context.Background(), s.to.PrivateKeySecret.Name, metav1.GetOptions{}) + if err != nil { + return nil, nil, err + } + + anno := s.pipe.GetAnnotations() + var publicKey []byte + var privateKey []byte + + for _, k := range []string{anno["privatekey_field_name"], "ssh-privatekey", "privatekey"} { + data := secret.Data[k] + if data != nil { + log.Debugf("found private key in secret %v/%v", s.to.PrivateKeySecret.Name, k) + privateKey = data + break + } + } + + for _, k := range []string{anno["publickey_field_name"], "ssh-publickey-cert", "publickey-cert", "ssh-publickey", "publickey"} { + data := secret.Data[k] + if data != nil { + log.Debugf("found publickey key cert in secret %v/%v", s.to.PrivateKeySecret.Name, k) + publicKey = data + break + } + } + + return privateKey, publicKey, nil +} + +func (s *skelpipeToWrapper) OverridePassword(conn libplugin.ConnMetadata) ([]byte, error) { + if s.to.PasswordSecret.Name != "" { + log.Debugf("mapping to %v password using secret %v", s.to.Host, s.to.PasswordSecret.Name) + secret, err := s.plugin.k8sclient.Secrets(s.pipe.Namespace).Get(context.Background(), s.to.PasswordSecret.Name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + anno := s.pipe.GetAnnotations() + for _, k := range []string{anno["password_field_name"], "password"} { + data := secret.Data[k] + if data != nil { + log.Debugf("found password in secret %v/%v", s.to.PasswordSecret.Name, k) + return data, nil + } + } + + log.Warnf("password field not found in secret %v", s.to.PasswordSecret.Name) + } + + return nil, nil +} + +func loadStringAndFile(base64orraw string, filepath string) ([][]byte, error) { + + all := make([][]byte, 0, 2) + + if base64orraw != "" { + data, err := base64.StdEncoding.DecodeString(base64orraw) + if err != nil { + data = []byte(base64orraw) + } + + all = append(all, data) + } + + if filepath != "" { + data, err := os.ReadFile(filepath) + if err != nil { + return nil, err + } + + all = append(all, data) + } + + return all, nil +} + +func (p *plugin) listPipe(_ libplugin.ConnMetadata) ([]libplugin.SkelPipe, error) { + kpipes, err := p.list() + if err != nil { + return nil, err + } + + var pipes []libplugin.SkelPipe + for _, pipe := range kpipes { + wrapper := &skelpipeWrapper{ + plugin: p, + pipe: pipe, + } + pipes = append(pipes, wrapper) + + } + + return pipes, nil +} diff --git a/plugin/workingdir/README.md b/plugin/workingdir/README.md index d68de23c..f629bbc2 100644 --- a/plugin/workingdir/README.md +++ b/plugin/workingdir/README.md @@ -71,6 +71,25 @@ google.com:12345 when `--strict-hostkey` is set, upstream server's public key must present in known_hosts + +## Recursive mode (--recursive-search) + +`--recursive-search` will search all sub directories of the `username` directory to find the `downstream` key in `authorized_keys` file. + +``` +├── git +│ ├── bitbucket +│ │ └── sshpiper_upstream +│ ├── github +│ │ ├── authorized_keys +│ │ ├── id_rsa +│ │ └── sshpiper_upstream +│ └── gitlab +│ └── sshpiper_upstream +├── linode.... +``` + + ## FAQ * Q: why sshpiperd still asks for password even I disabled password auth in upstream (different behavior from `v0`) diff --git a/plugin/workingdir/main.go b/plugin/workingdir/main.go index 16117b91..94cffed2 100644 --- a/plugin/workingdir/main.go +++ b/plugin/workingdir/main.go @@ -1,31 +1,10 @@ package main import ( - "fmt" - "path" - "github.com/tg123/sshpiper/libplugin" "github.com/urfave/cli/v2" - - "github.com/tg123/sshpiper/plugin/internal/workingdir" ) -func createWorkingdir(c *cli.Context, user string) (*workingdir.Workingdir, error) { - if !c.Bool("allow-baduser-name") { - if !workingdir.IsUsernameSecure(user) { - return nil, fmt.Errorf("bad username: %s", user) - } - } - - root := c.String("root") - - return &workingdir.Workingdir{ - Path: path.Join(root, user), - NoCheckPerm: c.Bool("no-check-perm"), - Strict: c.Bool("strict-hostkey"), - }, nil -} - func main() { libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ @@ -58,64 +37,33 @@ func main() { Usage: "disable password authentication and only use public key authentication", EnvVars: []string{"SSHPIPERD_WORKINGDIR_NOPASSWORD_AUTH"}, }, + &cli.BoolFlag{ + Name: "recursive-search", + Usage: "search subdirectories under user directory for upsteam", + EnvVars: []string{"SSHPIPERD_WORKINGDIR_RECURSIVESEARCH"}, + }, }, CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { - return &libplugin.SshPiperPluginConfig{ + fac := workdingdirFactory{ + root: c.String("root"), + allowBadUsername: c.Bool("allow-baduser-name"), + noPasswordAuth: c.Bool("no-password-auth"), + noCheckPerm: c.Bool("no-check-perm"), + strictHostKey: c.Bool("strict-hostkey"), + recursiveSearch: c.Bool("recursive-search"), + } - NextAuthMethodsCallback: func(_ libplugin.ConnMetadata) ([]string, error) { - if c.Bool("no-password-auth") { - return []string{"publickey"}, nil - } + skel := libplugin.NewSkelPlugin(fac.listPipe) + config := skel.CreateConfig() + config.NextAuthMethodsCallback = func(_ libplugin.ConnMetadata) ([]string, error) { + if fac.noPasswordAuth { + return []string{"publickey"}, nil + } - return []string{"password", "publickey"}, nil - }, - - PasswordCallback: func(conn libplugin.ConnMetadata, password []byte) (*libplugin.Upstream, error) { - w, err := createWorkingdir(c, conn.User()) - if err != nil { - return nil, err - } - - u, err := w.CreateUpstream() - if err != nil { - return nil, err - } - - u.Auth = libplugin.CreatePasswordAuth(password) - return u, nil - }, - - PublicKeyCallback: func(conn libplugin.ConnMetadata, key []byte) (*libplugin.Upstream, error) { - w, err := createWorkingdir(c, conn.User()) - if err != nil { - return nil, err - } - - u, err := w.CreateUpstream() - if err != nil { - return nil, err - } - - k, err := w.Mapkey(key) - if err != nil { - return nil, err - } - - u.Auth = libplugin.CreatePrivateKeyAuth(k) - - return u, nil - }, - - VerifyHostKeyCallback: func(conn libplugin.ConnMetadata, hostname, netaddr string, key []byte) error { - w, err := createWorkingdir(c, conn.User()) - if err != nil { - return err - } - - return w.VerifyHostKey(hostname, netaddr, key) - }, - }, nil + return []string{"password", "publickey"}, nil + } + return config, nil }, }) } diff --git a/plugin/workingdir/skel.go b/plugin/workingdir/skel.go new file mode 100644 index 00000000..2951e6fd --- /dev/null +++ b/plugin/workingdir/skel.go @@ -0,0 +1,166 @@ +package main + +import ( + "fmt" + "os" + "path" + "path/filepath" + + log "github.com/sirupsen/logrus" + "github.com/tg123/sshpiper/libplugin" +) + +type workdingdirFactory struct { + root string + allowBadUsername bool + noPasswordAuth bool + noCheckPerm bool + strictHostKey bool + recursiveSearch bool +} + +type skelpipeWrapper struct { + dir *workingdir + + host string + username string +} + +type skelpipeFromWrapper struct { + skelpipeWrapper +} + +type skelpipePasswordWrapper struct { + skelpipeFromWrapper +} + +type skelpipePublicKeyWrapper struct { + skelpipeFromWrapper +} + +type skelpipeToWrapper struct { + skelpipeWrapper +} + +func (s *skelpipeWrapper) From() []libplugin.SkelPipeFrom { + + w := skelpipeFromWrapper{ + skelpipeWrapper: *s, + } + + if s.dir.Exists(userAuthorizedKeysFile) && s.dir.Exists(userKeyFile) { + return []libplugin.SkelPipeFrom{&skelpipePublicKeyWrapper{ + skelpipeFromWrapper: w, + }} + } else { + return []libplugin.SkelPipeFrom{&skelpipePasswordWrapper{ + skelpipeFromWrapper: w, + }} + } +} + +func (s *skelpipeToWrapper) User(conn libplugin.ConnMetadata) string { + return s.username +} + +func (s *skelpipeToWrapper) Host(conn libplugin.ConnMetadata) string { + return s.host +} + +func (s *skelpipeToWrapper) IgnoreHostKey(conn libplugin.ConnMetadata) bool { + return !s.dir.Strict +} + +func (s *skelpipeToWrapper) KnownHosts(conn libplugin.ConnMetadata) ([]byte, error) { + return s.dir.Readfile(userKnownHosts) +} + +func (s *skelpipeFromWrapper) MatchConn(conn libplugin.ConnMetadata) (libplugin.SkelPipeTo, error) { + return &skelpipeToWrapper{ + skelpipeWrapper: s.skelpipeWrapper, + }, nil +} + +func (s *skelpipePasswordWrapper) TestPassword(conn libplugin.ConnMetadata, password []byte) (bool, error) { + return true, nil // TODO support later +} + +func (s *skelpipePublicKeyWrapper) AuthorizedKeys(conn libplugin.ConnMetadata) ([]byte, error) { + return s.dir.Readfile(userAuthorizedKeysFile) +} + +func (s *skelpipePublicKeyWrapper) TrustedUserCAKeys(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil // TODO support this +} + +func (s *skelpipeToWrapper) PrivateKey(conn libplugin.ConnMetadata) ([]byte, []byte, error) { + k, err := s.dir.Readfile(userKeyFile) + if err != nil { + return nil, nil, err + } + + return k, nil, nil +} + +func (s *skelpipeToWrapper) OverridePassword(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil +} + +func (wf *workdingdirFactory) listPipe(conn libplugin.ConnMetadata) ([]libplugin.SkelPipe, error) { + + user := conn.User() + + if !wf.allowBadUsername { + if !isUsernameSecure(user) { + return nil, fmt.Errorf("bad username: %s", user) + } + } + + var pipes []libplugin.SkelPipe + userdir := path.Join(wf.root, conn.User()) + + _ = filepath.Walk(userdir, func(path string, info os.FileInfo, err error) (stop error) { + + log.Infof("search upstreams in path: %v", path) + if err != nil { + log.Infof("error walking path: %v", err) + return + } + + if !info.IsDir() { + return + } + + if !wf.recursiveSearch { + stop = fmt.Errorf("stop") + } + + w := &workingdir{ + Path: path, + NoCheckPerm: wf.noCheckPerm, + Strict: wf.strictHostKey, + } + + data, err := w.Readfile(userUpstreamFile) + if err != nil { + log.Infof("error reading upstream file: %v in %v", err, w.Path) + return + } + + host, user, err := parseUpstreamFile(string(data)) + if err != nil { + log.Infof("ignore upstream folder %v due to: %v", w.Path, err) + return + } + + pipes = append(pipes, &skelpipeWrapper{ + dir: w, + host: host, + username: user, + }) + + return + }) + + return pipes, nil +} diff --git a/plugin/workingdir/workingdir.go b/plugin/workingdir/workingdir.go new file mode 100644 index 00000000..4eeb93b5 --- /dev/null +++ b/plugin/workingdir/workingdir.go @@ -0,0 +1,109 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path" + "regexp" + "strings" + + "github.com/tg123/sshpiper/libplugin" +) + +type workingdir struct { + Path string + NoCheckPerm bool + Strict bool +} + +var ( + // Base username validation on Debians default: https://sources.debian.net/src/adduser/3.113%2Bnmu3/adduser.conf/#L85 + // -> NAME_REGEX="^[a-z][-a-z0-9_]*\$" + // The length is limited to 32 characters. See man 8 useradd: https://linux.die.net/man/8/useradd + usernameRule *regexp.Regexp = regexp.MustCompile("^[a-z_][-a-z0-9_]{0,31}$") +) + +const ( + userAuthorizedKeysFile = "authorized_keys" + userKeyFile = "id_rsa" + userUpstreamFile = "sshpiper_upstream" + userKnownHosts = "known_hosts" +) + +func isUsernameSecure(user string) bool { + return usernameRule.MatchString(user) +} + +func (w *workingdir) checkPerm(file string) error { + filename := path.Join(w.Path, file) + f, err := os.Open(filename) + if err != nil { + return err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return err + } + + if w.NoCheckPerm { + return nil + } + + if fi.Mode().Perm()&0077 != 0 { + return fmt.Errorf("%v's perm is too open", filename) + } + + return nil +} + +func (w *workingdir) fullpath(file string) string { + return path.Join(w.Path, file) +} + +func (w *workingdir) Readfile(file string) ([]byte, error) { + if err := w.checkPerm(file); err != nil { + return nil, err + } + + return os.ReadFile(w.fullpath(file)) +} + +func (w *workingdir) Exists(file string) bool { + info, err := os.Stat(w.fullpath(file)) + if os.IsNotExist(err) { + return false + } + + return !info.IsDir() +} + +// TODO refactor this +func parseUpstreamFile(data string) (host string, user string, err error) { + r := bufio.NewReader(strings.NewReader(data)) + for { + host, err = r.ReadString('\n') + if err != nil { + break + } + + host = strings.TrimSpace(host) + + if host != "" && host[0] != '#' { + break + } + } + + t := strings.SplitN(host, "@", 2) + + if len(t) > 1 { + user = t[0] + host = t[1] + } + + _, _, err = libplugin.SplitHostPortForSSH(host) + + return +} diff --git a/plugin/workingdirbykey/README.md b/plugin/workingdirbykey/README.md deleted file mode 100644 index 9812e651..00000000 --- a/plugin/workingdirbykey/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Publickey based Working Directory plugin for sshpiperd - -This plugin is smilar to the [workingdir](../workingdir/) plugin, but it uses public key to route. - -workingdir tree - -``` -├── git -│ ├── bitbucket -│ │ └── sshpiper_upstream -│ ├── github -│ │ ├── authorized_keys -│ │ ├── id_rsa -│ │ └── sshpiper_upstream -│ └── gitlab -│ └── sshpiper_upstream -├── linode.... -``` - -The plugin will search across all sub directories of the `username` directory to see if the `downstream` key is in `authorized_keys` file. -The first matched sub directory will be used to route to the upstream. \ No newline at end of file diff --git a/plugin/workingdirbykey/main.go b/plugin/workingdirbykey/main.go deleted file mode 100644 index 6aa1f216..00000000 --- a/plugin/workingdirbykey/main.go +++ /dev/null @@ -1,100 +0,0 @@ -//go:build full - -package main - -import ( - "fmt" - "os" - "path" - "path/filepath" - - "github.com/tg123/sshpiper/libplugin" - "github.com/urfave/cli/v2" - - "github.com/tg123/sshpiper/plugin/internal/workingdir" - - log "github.com/sirupsen/logrus" -) - -func main() { - - libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ - Name: "workingdir", - Usage: "sshpiperd workingdir plugin", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "root", - Usage: "path to root working directory", - Value: "/var/sshpiper", - EnvVars: []string{"SSHPIPERD_WORKINGDIR_ROOT"}, - }, - &cli.BoolFlag{ - Name: "allow-baduser-name", - Usage: "allow bad username", - EnvVars: []string{"SSHPIPERD_WORKINGDIR_ALLOWBADUSERNAME"}, - }, - &cli.BoolFlag{ - Name: "no-check-perm", - Usage: "disable 0400 checking", - EnvVars: []string{"SSHPIPERD_WORKINGDIR_NOCHECKPERM"}, - }, - &cli.BoolFlag{ - Name: "strict-hostkey", - Usage: "upstream host public key must be in known_hosts file, otherwise drop the connection", - EnvVars: []string{"SSHPIPERD_WORKINGDIR_STRICTHOSTKEY"}, - }, - }, - CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { - - root := c.String("root") - - return &libplugin.SshPiperPluginConfig{ - PublicKeyCallback: func(conn libplugin.ConnMetadata, key []byte) (*libplugin.Upstream, error) { - - userdir := path.Join(root, conn.User()) - - var upstream *libplugin.Upstream - - _ = filepath.Walk(userdir, func(path string, info os.FileInfo, err error) error { - - log.Infof("search public key in path: %v", path) - if err != nil { - log.Infof("error walking path: %v", err) - return nil - } - - if !info.IsDir() { - return nil - } - - w := &workingdir.Workingdir{ - Path: path, - NoCheckPerm: c.Bool("no-check-perm"), - Strict: false, - } - - u, err := w.CreateUpstream() - if err != nil { - return nil - } - - k, err := w.Mapkey(key) - if err != nil { - return nil - } - - u.Auth = libplugin.CreatePrivateKeyAuth(k) - upstream = u - return fmt.Errorf("stop") - }) - - if upstream != nil { - return upstream, nil - } - - return nil, fmt.Errorf("no matching public key found in %v", userdir) - }, - }, nil - }, - }) -} diff --git a/plugin/yaml/main.go b/plugin/yaml/main.go index b0f22164..e1a522f9 100644 --- a/plugin/yaml/main.go +++ b/plugin/yaml/main.go @@ -8,7 +8,6 @@ import ( ) func main() { - plugin := newYamlPlugin() libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ @@ -30,25 +29,8 @@ func main() { }, }, 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 + skel := libplugin.NewSkelPlugin(plugin.listPipe) + return skel.CreateConfig(), nil }, }) } diff --git a/plugin/yaml/skel.go b/plugin/yaml/skel.go new file mode 100644 index 00000000..bf1e9711 --- /dev/null +++ b/plugin/yaml/skel.go @@ -0,0 +1,163 @@ +//go:build full || e2e + +package main + +import ( + "regexp" + + "github.com/tg123/sshpiper/libplugin" +) + +type skelpipeWrapper struct { + plugin *plugin + + pipe *yamlPipe +} +type skelpipeFromWrapper struct { + plugin *plugin + + from *yamlPipeFrom + to *yamlPipeTo +} +type skelpipePasswordWrapper struct { + skelpipeFromWrapper +} + +type skelpipePublicKeyWrapper struct { + skelpipeFromWrapper +} + +type skelpipeToWrapper struct { + plugin *plugin + + username string + to *yamlPipeTo +} + +func (s *skelpipeWrapper) From() []libplugin.SkelPipeFrom { + var froms []libplugin.SkelPipeFrom + for _, f := range s.pipe.From { + + w := &skelpipeFromWrapper{ + plugin: s.plugin, + from: &f, + to: &s.pipe.To, + } + + if f.SupportPublicKey() { + froms = append(froms, &skelpipePublicKeyWrapper{ + skelpipeFromWrapper: *w, + }) + } else { + froms = append(froms, &skelpipePasswordWrapper{ + skelpipeFromWrapper: *w, + }) + } + } + return froms +} + +func (s *skelpipeToWrapper) User(conn libplugin.ConnMetadata) string { + return s.username +} + +func (s *skelpipeToWrapper) Host(conn libplugin.ConnMetadata) string { + return s.to.Host +} + +func (s *skelpipeToWrapper) IgnoreHostKey(conn libplugin.ConnMetadata) bool { + return s.to.IgnoreHostkey +} + +func (s *skelpipeToWrapper) KnownHosts(conn libplugin.ConnMetadata) ([]byte, error) { + return s.plugin.loadFileOrDecodeMany(s.to.KnownHosts, s.to.KnownHostsData, map[string]string{ + "DOWNSTREAM_USER": conn.User(), + "UPSTREAM_USER": s.username, + }) +} + +func (s *skelpipeFromWrapper) MatchConn(conn libplugin.ConnMetadata) (libplugin.SkelPipeTo, error) { + user := conn.User() + + matched := s.from.Username == user + targetuser := s.to.Username + + if targetuser == "" { + targetuser = user + } + + if s.from.UsernameRegexMatch { + re, err := regexp.Compile(s.from.Username) + if err != nil { + return nil, err + } + + matched = re.MatchString(user) + + if matched { + targetuser = re.ReplaceAllString(user, s.to.Username) + } + } + + if matched { + return &skelpipeToWrapper{ + plugin: s.plugin, + username: targetuser, + to: s.to, + }, nil + } + + return nil, nil +} + +func (s *skelpipePasswordWrapper) TestPassword(conn libplugin.ConnMetadata, password []byte) (bool, error) { + return true, nil // yaml do not test input password +} + +func (s *skelpipePublicKeyWrapper) AuthorizedKeys(conn libplugin.ConnMetadata) ([]byte, error) { + return s.plugin.loadFileOrDecodeMany(s.from.AuthorizedKeys, s.from.AuthorizedKeysData, map[string]string{ + "DOWNSTREAM_USER": conn.User(), + }) +} + +func (s *skelpipePublicKeyWrapper) TrustedUserCAKeys(conn libplugin.ConnMetadata) ([]byte, error) { + return s.plugin.loadFileOrDecodeMany(s.from.TrustedUserCAKeys, s.from.TrustedUserCAKeysData, map[string]string{ + "DOWNSTREAM_USER": conn.User(), + }) +} + +func (s *skelpipeToWrapper) PrivateKey(conn libplugin.ConnMetadata) ([]byte, []byte, error) { + p, err := s.plugin.loadFileOrDecode(s.to.PrivateKey, s.to.PrivateKeyData, map[string]string{ + "DOWNSTREAM_USER": conn.User(), + "UPSTREAM_USER": s.username, + }) + + if err != nil { + return nil, nil, err + } + + return p, nil, nil +} + +func (s *skelpipeToWrapper) OverridePassword(conn libplugin.ConnMetadata) ([]byte, error) { + return nil, nil +} + +func (p *plugin) listPipe(_ libplugin.ConnMetadata) ([]libplugin.SkelPipe, error) { + config, err := p.loadConfig() + if err != nil { + return nil, err + } + + var pipes []libplugin.SkelPipe + for _, pipe := range config.Pipes { + wrapper := &skelpipeWrapper{ + plugin: p, + pipe: &pipe, + } + pipes = append(pipes, wrapper) + + } + + return pipes, nil +} diff --git a/plugin/yaml/yaml.go b/plugin/yaml/yaml.go index cb695346..9c065b6c 100644 --- a/plugin/yaml/yaml.go +++ b/plugin/yaml/yaml.go @@ -4,21 +4,15 @@ package main import ( "bytes" - "crypto/subtle" "encoding/base64" "fmt" "os" "path/filepath" - "regexp" - "time" - "github.com/patrickmn/go-cache" - "github.com/tg123/sshpiper/libplugin" - "golang.org/x/crypto/ssh" "gopkg.in/yaml.v3" ) -type pipeConfigFrom struct { +type yamlPipeFrom struct { Username string `yaml:"username"` UsernameRegexMatch bool `yaml:"username_regex_match,omitempty"` AuthorizedKeys listOrString `yaml:"authorized_keys,omitempty"` @@ -27,7 +21,11 @@ type pipeConfigFrom struct { TrustedUserCAKeysData listOrString `yaml:"trusted_user_ca_keys_data,omitempty"` } -type pipeConfigTo struct { +func (f yamlPipeFrom) SupportPublicKey() bool { + return f.AuthorizedKeys.Any() || f.AuthorizedKeysData.Any() || f.TrustedUserCAKeys.Any() || f.TrustedUserCAKeysData.Any() +} + +type yamlPipeTo struct { Username string `yaml:"username,omitempty"` Host string `yaml:"host"` Password string `yaml:"password,omitempty"` @@ -70,27 +68,23 @@ func (l *listOrString) UnmarshalYAML(value *yaml.Node) error { return fmt.Errorf("Failed to unmarshal OneOfType") } -type pipeConfig struct { - From []pipeConfigFrom `yaml:"from,flow"` - To pipeConfigTo `yaml:"to,flow"` +type yamlPipe struct { + From []yamlPipeFrom `yaml:"from,flow"` + To yamlPipeTo `yaml:"to,flow"` } type piperConfig struct { - Version string `yaml:"version"` - Pipes []pipeConfig `yaml:"pipes,flow"` + Version string `yaml:"version"` + Pipes []yamlPipe `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), - } + return &plugin{} } func (p *plugin) checkPerm() error { @@ -191,199 +185,3 @@ func (p *plugin) loadFileOrDecodeMany(files listOrString, base64data listOrStrin return bytes.Join(byteSlices, []byte("\n")), 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.Any() || from.AuthorizedKeysData.Any() || from.TrustedUserCAKeys.Any() || from.TrustedUserCAKeysData.Any() { - 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.loadFileOrDecodeMany(to.KnownHosts, to.KnownHostsData, map[string]string{ - "DOWNSTREAM_USER": conn.User(), - "UPSTREAM_USER": to.Username, - }) - if err != nil { - return err - } - - return libplugin.VerifyHostKeyFromKnownHosts(bytes.NewBuffer(data), hostname, netaddr, key) -} - -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)) - p.cache.Set(conn.UniqueID(), &to, cache.DefaultExpiration) - return u, nil - } - - // 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) - p.cache.Set(conn.UniqueID(), &to, cache.DefaultExpiration) - return u, nil - } - - 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 - } - - var isCert bool - var pkcert *ssh.Certificate - - if publicKey != nil { - pubKey, err := ssh.ParsePublicKey(publicKey) - if err != nil { - return nil, err - } - - pkcert, isCert = pubKey.(*ssh.Certificate) - if isCert { - // ensure cert is valid first - - if pkcert.CertType != ssh.UserCert { - return nil, fmt.Errorf("only user certificates are supported, cert type: %v", pkcert.CertType) - } - - certChecker := ssh.CertChecker{} - if err := certChecker.CheckCert(conn.User(), pkcert); err != nil { - return nil, err - } - } - } - - for _, pipe := range config.Pipes { - for _, from := range pipe.From { - matched := from.Username == user - - if pipe.To.Username == "" { - pipe.To.Username = user - } - - if from.UsernameRegexMatch { - re, err := regexp.Compile(from.Username) - if err != nil { - return nil, err - } - - matched = re.MatchString(user) - - if matched { - pipe.To.Username = re.ReplaceAllString(user, pipe.To.Username) - } - } - - if !matched { - continue - } - - if publicKey == nil && password != "" { - return p.createUpstream(conn, pipe.To, password) - } - - if isCert { - rest, err := p.loadFileOrDecodeMany(from.TrustedUserCAKeys, from.TrustedUserCAKeysData, map[string]string{ - "DOWNSTREAM_USER": user, - }) - if err != nil { - return nil, err - } - - var trustedca ssh.PublicKey - for len(rest) > 0 { - trustedca, _, _, rest, err = ssh.ParseAuthorizedKey(rest) - if err != nil { - return nil, err - } - - if subtle.ConstantTimeCompare(trustedca.Marshal(), pkcert.SignatureKey.Marshal()) == 1 { - return p.createUpstream(conn, pipe.To, "") - } - } - } else { - rest, err := p.loadFileOrDecodeMany(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 subtle.ConstantTimeCompare(authedPubkey.Marshal(), publicKey) == 1 { - return p.createUpstream(conn, pipe.To, "") - } - } - } - - } - } - - return nil, fmt.Errorf("no matching pipe for username [%v] found", user) -}