merge totp to workingdir (#476)
* merge totp to workingdir * Refactor .goreleaser.yaml to remove plugin_totp build configuration
This commit is contained in:
parent
0e6168a6b8
commit
23e18f4f7c
11 changed files with 669 additions and 791 deletions
|
|
@ -116,20 +116,6 @@ builds:
|
|||
binary: plugins/failtoban
|
||||
tags:
|
||||
- full
|
||||
- id: plugin_totp
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
# - darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
main: ./plugin/totp
|
||||
binary: plugins/totp
|
||||
tags:
|
||||
- full
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
|
|
@ -154,8 +140,6 @@ archives:
|
|||
- plugin_failtoban
|
||||
- plugin_docker
|
||||
- plugin_kubernetes
|
||||
- plugin_workingdirbykey
|
||||
- plugin_totp
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "farmer1992/sshpiperd:v{{ .Version }}-amd64"
|
||||
|
|
@ -271,8 +255,6 @@ snapcrafts:
|
|||
- plugin_yaml
|
||||
- plugin_fixed
|
||||
- plugin_failtoban
|
||||
- plugin_workingdirbykey
|
||||
- plugin_totp
|
||||
name: sshpiperd
|
||||
name_template: "sshpiperd_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
summary: The missing reverse proxy for ssh scp
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@ Plugin list
|
|||
* [yaml](plugin/yaml/) 🔀: config routing with a single yaml file.
|
||||
* [docker](plugin/docker/) 🔀: pipe into docker containers.
|
||||
* [kubernetes](plugin/kubernetes/) 🔀: manage pipes via Kubernetes CRD.
|
||||
* [totp](plugin/totp/) 🔒: TOTP 2FA plugin. compatible with all [RFC6238](https://datatracker.ietf.org/doc/html/rfc6238) authenticator, for example: `google authenticator`, `azure authenticator`.
|
||||
* [azdevicecode](https://github.com/tg123/sshpiper-plugins/tree/main/azdevicecode) 🔒: ask user to enter [azure device code](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code) before login
|
||||
* [fixed](plugin/fixed/) 🔀: fixed targeting the dummy sshd server
|
||||
* [simplemath](plugin/simplemath/) 🔒: ask for very simple math question before login, demo purpose
|
||||
|
|
|
|||
|
|
@ -56,9 +56,6 @@ func (g *GrpcPlugin) InstallPiperConfig(config *GrpcPluginConfig) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// config.NextAuthMethods = g.NextAuthMethodsLocal
|
||||
// config.UpstreamAuthFailureCallback = g.UpstreamAuthFailureCallbackLocal
|
||||
|
||||
config.CreateChallengeContext = func(conn ssh.ConnMetadata) (ssh.ChallengeContext, error) {
|
||||
ctx, err := g.CreateChallengeContext(conn)
|
||||
if err != nil {
|
||||
|
|
@ -191,18 +188,6 @@ func (g *GrpcPlugin) NewConnection(meta *connMeta) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (g *GrpcPlugin) NextAuthMethodsLocal(conn ssh.ConnMetadata, challengeCtx ssh.ChallengeContext) ([]string, error) {
|
||||
var allow []string
|
||||
|
||||
for k, v := range g.allowedMethod {
|
||||
if v {
|
||||
allow = append(allow, k)
|
||||
}
|
||||
}
|
||||
|
||||
return allow, nil
|
||||
}
|
||||
|
||||
func toMeta(challengeCtx ssh.ChallengeContext, conn ssh.ConnMetadata) *libplugin.ConnMeta {
|
||||
switch meta := challengeCtx.(type) {
|
||||
case *connMeta:
|
||||
|
|
@ -284,6 +269,19 @@ func (g *GrpcPlugin) createUpstream(conn ssh.ConnMetadata, challengeCtx ssh.Chal
|
|||
|
||||
meta := toMeta(challengeCtx, conn)
|
||||
|
||||
// ugly way to support meta set
|
||||
if retry := upstream.GetRetryCurrentPlugin(); retry != nil {
|
||||
if meta.Metadata == nil {
|
||||
meta.Metadata = make(map[string]string)
|
||||
}
|
||||
|
||||
for k, v := range retry.Meta {
|
||||
meta.Metadata[k] = v
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("client retry requested")
|
||||
}
|
||||
|
||||
port := upstream.Port
|
||||
if port <= 0 {
|
||||
port = 22
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -23,6 +23,7 @@ message Upstream {
|
|||
UpstreamPrivateKeyAuth private_key = 102;
|
||||
UpstreamRemoteSignerAuth remote_signer = 103;
|
||||
UpstreamNextPluginAuth next_plugin = 200;
|
||||
UpstreamRetryCurrentPluginAuth retry_current_plugin = 201;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -47,6 +48,10 @@ message UpstreamNextPluginAuth {
|
|||
map<string, string> meta = 1;
|
||||
}
|
||||
|
||||
message UpstreamRetryCurrentPluginAuth {
|
||||
map<string, string> meta = 1;
|
||||
}
|
||||
|
||||
service SshPiperPlugin {
|
||||
rpc Logs(StartLogRequest) returns (stream Log) {}
|
||||
rpc ListCallbacks(ListCallbackRequest) returns (ListCallbackResponse) {}
|
||||
|
|
|
|||
|
|
@ -147,6 +147,14 @@ func CreateNextPluginAuth(meta map[string]string) *Upstream_NextPlugin {
|
|||
}
|
||||
}
|
||||
|
||||
func CreateRetryCurrentPluginAuth(meta map[string]string) *Upstream_RetryCurrentPlugin {
|
||||
return &Upstream_RetryCurrentPlugin{
|
||||
RetryCurrentPlugin: &UpstreamRetryCurrentPluginAuth{
|
||||
Meta: meta,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func VerifyHostKeyFromKnownHosts(knownhostsData io.Reader, hostname, netaddr string, key []byte) error {
|
||||
hostKeyCallback, err := knownhosts.NewFromReader(knownhostsData)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1,166 +0,0 @@
|
|||
package workingdir
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/tg123/sshpiper/libplugin"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type Workingdir struct {
|
||||
Path string
|
||||
NoCheckPerm bool
|
||||
Strict bool
|
||||
}
|
||||
|
||||
var (
|
||||
usernameRule *regexp.Regexp
|
||||
)
|
||||
|
||||
const (
|
||||
userAuthorizedKeysFile = "authorized_keys"
|
||||
userKeyFile = "id_rsa"
|
||||
userUpstreamFile = "sshpiper_upstream"
|
||||
userKnownHosts = "known_hosts"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// 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.Compile("^[a-z_][-a-z0-9_]{0,31}$")
|
||||
}
|
||||
|
||||
func IsUsernameSecure(user string) bool {
|
||||
return usernameRule.MatchString(user)
|
||||
}
|
||||
|
||||
func (w *Workingdir) Mapkey(pub []byte) ([]byte, error) {
|
||||
|
||||
var rest []byte
|
||||
rest, err := w.Readfile(userAuthorizedKeysFile)
|
||||
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(), pub) == 1 {
|
||||
log.Infof("found mapping key %v", w.fullpath(userKeyFile))
|
||||
return w.Readfile(userKeyFile)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no matching key found")
|
||||
}
|
||||
|
||||
func (w *Workingdir) CreateUpstream() (*libplugin.Upstream, error) {
|
||||
|
||||
data, err := w.Readfile(userUpstreamFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
host, port, user, err := parseUpstreamFile(string(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &libplugin.Upstream{
|
||||
Host: host,
|
||||
Port: int32(port),
|
||||
UserName: user,
|
||||
IgnoreHostKey: !w.Strict,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *Workingdir) VerifyHostKey(hostname, netaddr string, key []byte) error {
|
||||
if !w.Strict {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(w.fullpath(userKnownHosts))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return libplugin.VerifyHostKeyFromKnownHosts(f, hostname, netaddr, key)
|
||||
}
|
||||
|
||||
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 parseUpstreamFile(data string) (host string, port int, 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]
|
||||
}
|
||||
|
||||
host, port, err = libplugin.SplitHostPortForSSH(host)
|
||||
return
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# TOTP Working Directory plugin for sshpiperd
|
||||
|
||||
Add TOTP 2FA to ssh, compatible with all [RFC6238](https://datatracker.ietf.org/doc/html/rfc6238) authenticator, for example: `google authenticator`, `azure authenticator`.
|
||||
|
||||
the plugin is load `totp` in working directory defined in [workingdir](../workingdir/) plugin.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
./sshpiperd ./totp -- ./workingdir
|
||||
```
|
||||
|
||||
the secret should be stored in `totp` file in working directory.
|
||||
for example:
|
||||
|
||||
```
|
||||
/var/sshpiper/username/totp
|
||||
```
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
//go:build full
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/tg123/sshpiper/libplugin"
|
||||
"github.com/tg123/sshpiper/plugin/internal/workingdir"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
// type secretLoader struct {
|
||||
// }
|
||||
|
||||
// func (s *secretLoader) Load(user string) (string, error) {
|
||||
// return "", nil
|
||||
// }
|
||||
|
||||
// TODO remove dup code
|
||||
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{
|
||||
Name: "totp",
|
||||
Usage: "sshpiperd totp 2FA authentication, workingdir based",
|
||||
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"},
|
||||
},
|
||||
},
|
||||
CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) {
|
||||
return &libplugin.SshPiperPluginConfig{
|
||||
KeyboardInteractiveCallback: func(conn libplugin.ConnMetadata, client libplugin.KeyboardInteractiveChallenge) (*libplugin.Upstream, error) {
|
||||
|
||||
w, err := createWorkingdir(c, conn.User())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret, err := w.Readfile("totp")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for {
|
||||
|
||||
passcode, err := client("", "", "Authentication code:", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if totp.Validate(passcode, strings.TrimSpace(string(secret))) {
|
||||
return &libplugin.Upstream{
|
||||
Auth: libplugin.CreateNextPluginAuth(nil),
|
||||
}, nil
|
||||
}
|
||||
|
||||
_, _ = client("", "Wrong code, please try again", "", false)
|
||||
}
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -89,6 +89,16 @@ google.com:12345
|
|||
├── linode....
|
||||
```
|
||||
|
||||
## TOTP
|
||||
|
||||
`--check-totp` will check the TOTP 2FA before connecting to the upstream, compatible with all [RFC6238](https://datatracker.ietf.org/doc/html/rfc6238) authenticator, for example: `google authenticator`, `azure authenticator`.
|
||||
|
||||
the secret should be stored in `totp` file in working directory.
|
||||
for example:
|
||||
|
||||
```
|
||||
/var/sshpiper/username/totp
|
||||
```
|
||||
|
||||
## FAQ
|
||||
* Q: why sshpiperd still asks for password even I disabled password auth in upstream (different behavior from `v0`)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/tg123/sshpiper/libplugin"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
|
@ -42,6 +47,11 @@ func main() {
|
|||
Usage: "search subdirectories under user directory for upsteam",
|
||||
EnvVars: []string{"SSHPIPERD_WORKINGDIR_RECURSIVESEARCH"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "check-totp",
|
||||
Usage: "check totp code for 2FA, totp file should be in user directory named `totp`",
|
||||
EnvVars: []string{"SSHPIPERD_WORKINGDIR_CHECKTOTP"},
|
||||
},
|
||||
},
|
||||
CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) {
|
||||
|
||||
|
|
@ -54,15 +64,66 @@ func main() {
|
|||
recursiveSearch: c.Bool("recursive-search"),
|
||||
}
|
||||
|
||||
checktotp := c.Bool("check-totp")
|
||||
|
||||
skel := libplugin.NewSkelPlugin(fac.listPipe)
|
||||
config := skel.CreateConfig()
|
||||
config.NextAuthMethodsCallback = func(_ libplugin.ConnMetadata) ([]string, error) {
|
||||
if fac.noPasswordAuth {
|
||||
return []string{"publickey"}, nil
|
||||
config.NextAuthMethodsCallback = func(conn libplugin.ConnMetadata) ([]string, error) {
|
||||
|
||||
auth := []string{"publickey"}
|
||||
|
||||
if !fac.noPasswordAuth {
|
||||
auth = append(auth, "password")
|
||||
}
|
||||
|
||||
return []string{"password", "publickey"}, nil
|
||||
if checktotp {
|
||||
if conn.GetMeta("totp") != "checked" {
|
||||
auth = []string{"keyboard-interactive"}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
config.KeyboardInteractiveCallback = func(conn libplugin.ConnMetadata, client libplugin.KeyboardInteractiveChallenge) (*libplugin.Upstream, error) {
|
||||
user := conn.User()
|
||||
|
||||
if !fac.allowBadUsername {
|
||||
if !isUsernameSecure(user) {
|
||||
return nil, fmt.Errorf("bad username: %s", user)
|
||||
}
|
||||
}
|
||||
|
||||
w := &workingdir{
|
||||
Path: path.Join(fac.root, conn.User()),
|
||||
NoCheckPerm: fac.noCheckPerm,
|
||||
}
|
||||
|
||||
secret, err := w.Readfile("totp")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for {
|
||||
|
||||
passcode, err := client("", "", "Authentication code:", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if totp.Validate(passcode, strings.TrimSpace(string(secret))) {
|
||||
return &libplugin.Upstream{
|
||||
Auth: libplugin.CreateRetryCurrentPluginAuth(map[string]string{
|
||||
"totp": "checked",
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
_, _ = client("", "Wrong code, please try again", "", false)
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue