diff --git a/e2e/failtoban_test.go b/e2e/failtoban_test.go index 65cda8f3..6b0e14c2 100644 --- a/e2e/failtoban_test.go +++ b/e2e/failtoban_test.go @@ -240,3 +240,235 @@ func TestFailtobanPipeCreateFail(t *testing.T) { } } } + +func TestFailtobanIgnoreIP(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", + "--ignore-ip", + "127.0.0.1", + ) + + if err != nil { + t.Errorf("failed to run sshpiperd: %v", err) + } + + defer killCmd(piper) + + waitForEndpointReady(piperaddr) + + { + 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", + "user", + "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-workingdir, %v", err) + } + + defer killCmd(c) + + enterPassword(stdin, stdout, "pass") + + time.Sleep(time.Second) // wait for file flush + + checkSharedFileContent(t, targetfie, randtext) + } + + // 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") + } + + { + 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", + "user", + "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-workingdir, %v", err) + } + + defer killCmd(c) + + enterPassword(stdin, stdout, "pass") + + time.Sleep(time.Second) // wait for file flush + + checkSharedFileContent(t, targetfie, randtext) + } +} + +func TestFailtobanIgnoreCIDR(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", + "--ignore-ip", + "127.0.0.1/8", + ) + + if err != nil { + t.Errorf("failed to run sshpiperd: %v", err) + } + + defer killCmd(piper) + + waitForEndpointReady(piperaddr) + + { + 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", + "user", + "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-workingdir, %v", err) + } + + defer killCmd(c) + + enterPassword(stdin, stdout, "pass") + + time.Sleep(time.Second) // wait for file flush + + checkSharedFileContent(t, targetfie, randtext) + } + + // 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") + } + + { + 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", + "user", + "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-workingdir, %v", err) + } + + defer killCmd(c) + + enterPassword(stdin, stdout, "pass") + + time.Sleep(time.Second) // wait for file flush + + checkSharedFileContent(t, targetfie, randtext) + } +} diff --git a/go.mod b/go.mod index e311b507..900ca853 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect go.opentelemetry.io/otel/metric v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/sync v0.12.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect diff --git a/go.sum b/go.sum index ffda6ef6..6e432b5a 100644 --- a/go.sum +++ b/go.sum @@ -174,6 +174,8 @@ go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/plugin/failtoban/main.go b/plugin/failtoban/main.go index 924211c6..c6a0a5a0 100644 --- a/plugin/failtoban/main.go +++ b/plugin/failtoban/main.go @@ -4,9 +4,12 @@ package main import ( "fmt" + "go4.org/netipx" "net" + "net/netip" "os" "os/signal" + "strings" "syscall" "time" @@ -40,14 +43,21 @@ func main() { EnvVars: []string{"SSHPIPERD_FAILTOBAN_LOG_ONLY"}, Value: false, }, + &cli.StringSliceFlag{ + Name: "ignore-ip", + Usage: "ignore ip, will not ban host matches from these ip addresses", + EnvVars: []string{"SSHPIPERD_FAILTOBAN_IGNORE_IP"}, + Value: cli.NewStringSlice(), + }, }, CreateConfig: func(c *cli.Context) (*libplugin.SshPiperPluginConfig, error) { maxFailures := c.Int("max-failures") banDuration := c.Duration("ban-duration") logOnly := c.Bool("log-only") - + ignoreIP := c.StringSlice("ignore-ip") cache := gocache.New(banDuration, banDuration/2*3) + whitelist := buildIPSet(ignoreIP) // register signal handler go func() { @@ -74,6 +84,12 @@ func main() { } ip, _, _ := net.SplitHostPort(conn.RemoteAddr()) + ip0, _ := netip.ParseAddr(ip) + + if whitelist.Contains(ip0) { + log.Debugf("failtoban: %v in whitelist, ignored.", ip0) + return nil + } failed, found := cache.Get(ip) if !found { @@ -89,11 +105,25 @@ func main() { }, UpstreamAuthFailureCallback: func(conn libplugin.ConnMetadata, method string, err error, allowmethods []string) { ip, _, _ := net.SplitHostPort(conn.RemoteAddr()) + ip0, _ := netip.ParseAddr(ip) + + if whitelist.Contains(ip0) { + log.Debugf("failtoban: %v in whitelist, ignored.", ip0) + return + } + failed, _ := cache.IncrementInt(ip, 1) log.Warnf("failtoban: %v auth failed. current status: fail %v times, max allowed %v", ip, failed, maxFailures) }, PipeCreateErrorCallback: func(remoteAddr string, err error) { ip, _, _ := net.SplitHostPort(remoteAddr) + ip0, _ := netip.ParseAddr(ip) + + if whitelist.Contains(ip0) { + log.Debugf("failtoban: %v in whitelist, ignored.", ip0) + return + } + failed, _ := cache.IncrementInt(ip, 1) log.Warnf("failtoban: %v pipe create failed, reason %v. current status: fail %v times, max allowed %v", ip, err, failed, maxFailures) }, @@ -101,3 +131,31 @@ func main() { }, }) } + +func buildIPSet(cidrs []string) *netipx.IPSet { + + var ipsetBuilder netipx.IPSetBuilder + + for _, cidr := range cidrs { + if strings.Contains(cidr, "/") { + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + log.Debugf("failtoban: error while parsing ignore IP: \n%v", err) + continue + } + ipsetBuilder.AddPrefix(prefix) + } else { + ip, err := netip.ParseAddr(cidr) + if err != nil { + log.Debugf("failtoban: error while parsing ignore IP: \n%v", err) + continue + } + ipsetBuilder.Add(ip) + } + } + ipset, err := ipsetBuilder.IPSet() + if err != nil { + log.Debugf("failtoban: error while getting IPSet: \n%v", err) + } + return ipset +}