diff --git a/README.md b/README.md index 6d8e21bf..c73c56b8 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Plugin list * [simplemath](plugin/simplemath/) 🔒: ask for very simple math question before login, demo purpose * [githubapp](https://github.com/tg123/sshpiper-gh) 🔀: login ssh with your github account * [restful](https://github.com/11notes/docker-sshpiper) by [@11notes](https://github.com/11notes) 🔀🔒: The rest plugin for sshpiperd is a simple plugin that allows you to use a restful backend for authentication and challenge. + * [failtoban](plugin/failtoban/) 🔒: ban ip after failed login attempts ## Screening recording diff --git a/cmd/sshpiperd/internal/plugin/chain.go b/cmd/sshpiperd/internal/plugin/chain.go index e392b1ba..2225327b 100644 --- a/cmd/sshpiperd/internal/plugin/chain.go +++ b/cmd/sshpiperd/internal/plugin/chain.go @@ -125,9 +125,10 @@ func (cp *ChainPlugins) InstallPiperConfig(config *GrpcPluginConfig) error { } config.UpstreamAuthFailureCallback = func(conn ssh.ConnMetadata, method string, err error, challengeCtx ssh.ChallengeContext) { - cur := cp.pluginsCallback[challengeCtx.(*chainConnMeta).current] - if cur.UpstreamAuthFailureCallback != nil { - cur.UpstreamAuthFailureCallback(conn, method, err, challengeCtx) + for _, p := range cp.pluginsCallback { + if p.UpstreamAuthFailureCallback != nil { + p.UpstreamAuthFailureCallback(conn, method, err, challengeCtx) + } } } @@ -141,16 +142,18 @@ func (cp *ChainPlugins) InstallPiperConfig(config *GrpcPluginConfig) error { } config.PipeStartCallback = func(conn ssh.ConnMetadata, challengeCtx ssh.ChallengeContext) { - cur := cp.pluginsCallback[challengeCtx.(*chainConnMeta).current] - if cur.PipeStartCallback != nil { - cur.PipeStartCallback(conn, challengeCtx) + for _, p := range cp.pluginsCallback { + if p.PipeStartCallback != nil { + p.PipeStartCallback(conn, challengeCtx) + } } } config.PipeErrorCallback = func(conn ssh.ConnMetadata, challengeCtx ssh.ChallengeContext, err error) { - cur := cp.pluginsCallback[challengeCtx.(*chainConnMeta).current] - if cur.PipeErrorCallback != nil { - cur.PipeErrorCallback(conn, challengeCtx, err) + for _, p := range cp.pluginsCallback { + if p.PipeErrorCallback != nil { + p.PipeErrorCallback(conn, challengeCtx, err) + } } } diff --git a/e2e/failtoban_test.go b/e2e/failtoban_test.go new file mode 100644 index 00000000..a1dcdc8c --- /dev/null +++ b/e2e/failtoban_test.go @@ -0,0 +1,86 @@ +package e2e_test + +import ( + "io" + "strings" + "testing" +) + +func TestFailtoban(t *testing.T) { + piperaddr, piperport := nextAvailablePiperAddress() + + piper, _, _, err := runCmd("/sshpiperd/sshpiperd", + "-p", + piperport, + "/sshpiperd/plugins/fixed", + "--target", + "host-password:2222", + "--", + "/sshpiperd/plugins/failtoban", + "--max-failures", + "3", + ) + + if err != nil { + t.Errorf("failed to run sshpiperd: %v", err) + } + + defer killCmd(piper) + + waitForEndpointReady(piperaddr) + + // run 3 times with wrong password + { + c, stdin, stdout, err := runCmd( + "ssh", + "-v", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + piperport, + "-l", + "user", + "127.0.0.1", + ) + if err != nil { + t.Errorf("failed to ssh to piper-fixed, %v", err) + } + + defer killCmd(c) + + enterPassword(stdin, stdout, "wrongpass1") + enterPassword(stdin, stdout, "wrongpass2") + enterPassword(stdin, stdout, "wrongpass3") + } + + { + c, _, stdout, err := runCmd( + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + piperport, + "-l", + "user", + "127.0.0.1", + ) + + if err != nil { + t.Errorf("failed to ssh to piper-fixed, %v", err) + } + + defer killCmd(c) + _ = c.Wait() + + s, _ := io.ReadAll(stdout) + + if !strings.Contains(string(s), "Connection closed by 127.0.0.1") { + t.Errorf("expected connection closed by") + } + } + +} diff --git a/plugin/failtoban/README.md b/plugin/failtoban/README.md new file mode 100644 index 00000000..8dbda5fb --- /dev/null +++ b/plugin/failtoban/README.md @@ -0,0 +1,17 @@ +# fail to ban for sshpiperd + +put ip to jail for a while after failed to login for several times. + +## Usage + +put this plugin after other plugins, like: + +``` +sshpiperd
-- failtoban +``` + + +## Configuration + + * max-failures: max failures before ban, default 5 + * ban-duration: ban duration, default 1h \ No newline at end of file diff --git a/plugin/failtoban/main.go b/plugin/failtoban/main.go new file mode 100644 index 00000000..c0fe4534 --- /dev/null +++ b/plugin/failtoban/main.go @@ -0,0 +1,72 @@ +//go:build full || e2e + +package main + +import ( + "fmt" + "net" + "time" + + gocache "github.com/patrickmn/go-cache" + log "github.com/sirupsen/logrus" + "github.com/tg123/sshpiper/libplugin" + "github.com/urfave/cli/v2" +) + +func main() { + + libplugin.CreateAndRunPluginTemplate(&libplugin.PluginTemplate{ + Name: "failtoban", + Usage: "sshpiperd fixed plugin, only password auth is supported", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "max-failures", + Usage: "max failures", + EnvVars: []string{"SSHPIPERD_FAILTOBAN_MAX_FAILURES"}, + Value: 5, + }, + &cli.DurationFlag{ + Name: "ban-duration", + Usage: "ban duration", + EnvVars: []string{"SSHPIPERD_FAILTOBAN_BAN_DURATION"}, + Value: 60 * time.Minute, + }, + }, + CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { + + maxFailures := c.Int("max-failures") + banDuration := c.Duration("ban-duration") + + cache := gocache.New(banDuration, banDuration/2*3) + + return &libplugin.SshPiperPluginConfig{ + NoClientAuthCallback: func(conn libplugin.ConnMetadata) (*libplugin.Upstream, error) { + // in case someone put the failtoban plugin before other plugins + return &libplugin.Upstream{ + Auth: libplugin.CreateNextPluginAuth(map[string]string{}), + }, nil + }, + NewConnectionCallback: func(conn libplugin.ConnMetadata) error { + ip, _, _ := net.SplitHostPort(conn.RemoteAddr()) + + failed, found := cache.Get(ip) + if !found { + // init + return cache.Add(ip, 0, banDuration) + } + + if failed.(int) >= maxFailures { + return fmt.Errorf("failtoban: ip %v too auth many failures", ip) + } + + return nil + }, + UpstreamAuthFailureCallback: func(conn libplugin.ConnMetadata, method string, err error, allowmethods []string) { + ip, _, _ := net.SplitHostPort(conn.RemoteAddr()) + failed, _ := cache.IncrementInt(ip, 1) + log.Debugf("failtoban: %v auth failed %v times, max allowed %v", ip, failed, maxFailures) + }, + }, nil + }, + }) +} diff --git a/version.json b/version.json index 848645b1..716bb743 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.0", + "version": "1.1", "publicReleaseRefSpec": [ "^refs/heads/master$", "^refs/tags/v\\d+\\.\\d+\\.\\d+"