From ff582b6dcc8412efa99daf574c8d43d60c4ba58a Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 20 Oct 2026 11:14:05 +0900 Subject: [PATCH 1/2] feat(tenant): let failtoban ignore shared gateway addresses Users behind one VPN or NAT gateway share its address, so failtoban's per-address ban locked all of them out after one user's failures (seen on dgx02 through WARP). modules.tenant.sshpiper.failtoban_ignore passes --ignore-ip to the plugin. Claude-Session: https://claude.ai/code/session_0177Zk7to4EYohgM59v4Fcj7 --- docs/releases/v0.17.1.md | 9 +++++++++ docs/tenant.md | 3 ++- internal/config/config.go | 3 +++ internal/config/validate.go | 7 +++++++ internal/module/tenant_piper.go | 6 +++++- internal/module/tenant_test.go | 12 ++++++++++++ 6 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 docs/releases/v0.17.1.md diff --git a/docs/releases/v0.17.1.md b/docs/releases/v0.17.1.md new file mode 100644 index 0000000..a7eaccc --- /dev/null +++ b/docs/releases/v0.17.1.md @@ -0,0 +1,9 @@ +Shared-port tenant SSH can now exempt a VPN or NAT gateway from the failed-login ban, so one user's typos no longer lock out everyone behind it. + +### New +- `modules.tenant.sshpiper.failtoban_ignore` lists addresses or CIDRs that are never banned after failed logins on the shared port. (#82) + +### Action needed +- On hosts where many users reach the shared port through one gateway address, add it to `failtoban_ignore` and run `sudo rootfiles apply --module tenant` (or `rootfiles fleet apply`). The apply restarts sshpiper, which clears current bans and drops sessions open through it. (#82) + +**Full changelog**: https://github.com/STAIxBWLB/rootfiles/compare/v0.17.0...v0.17.1 diff --git a/docs/tenant.md b/docs/tenant.md index 0b01acc..2aef6ce 100644 --- a/docs/tenant.md +++ b/docs/tenant.md @@ -37,6 +37,7 @@ modules: enabled: true port: 2222 # default; must not be the host's SSH port # address: 0.0.0.0, version: v1.6.1 (pinned; the archive SHA-256 is checked) + # failtoban_ignore: [172.16.229.33/32] # shared VPN gateway, never banned ``` `sudo rootfiles apply --module tenant` installs sshpiperd with its yaml and failtoban plugins under `/usr/local/lib/rootfiles/sshpiper/` and runs `rootfiles-sshpiper.service`. Users then reach any tenant through one port by naming the tenant in the user name: @@ -48,7 +49,7 @@ ssh -p 2222 est.dev@dgx02 # user dev of tenant est - Passwords go to the tenant's own sshd, which checks them and enforces the change of a temporary password. - Keys: sshpiper checks the client key against the user's `~/.ssh/authorized_keys` inside the container (copied by the 2-minute sync; only plain key lines, options are dropped), then logs in to the container with a host-only key. A tenant created before sshpiper was enabled needs `rootfiles tenant apply ` and `rootfiles tenant restart ` once before key logins work. - sshpiper checks each container's host key against the keys rootfiles stores. Clients see sshpiper's own host key on port 2222. -- failtoban bans an address for an hour after 5 failed logins. +- failtoban bans an address for an hour after 5 failed logins. Users behind one VPN or NAT gateway share its address, so one user's typos would lock out all of them: list such gateways in `failtoban_ignore` (addresses or CIDRs). Applying a changed list restarts sshpiper, which also clears current bans and drops sessions open through it. - Firewall: with `modules.network.ufw`, the network module admits the port. If UFW is active without that, or its state cannot be read, `check` reports the port as blocked and `apply` stops before starting sshpiper. A port held by another process is refused the same way, also when changing the port of a running sshpiper. - The direct per-tenant ports keep working. Disabling sshpiper removes the service and the route files. - `rootfiles tenant status` and `create` print the routed command for each account. diff --git a/internal/config/config.go b/internal/config/config.go index 48881f4..2e1f25f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,9 @@ type SSHPiperConfig struct { Port int `yaml:"port,omitempty"` // default 2222 Address string `yaml:"address,omitempty"` // default all addresses Version string `yaml:"version,omitempty"` // default DefaultSSHPiperVersion + // FailtobanIgnore lists addresses or CIDRs never banned after failed + // logins, such as a VPN gateway that many users share. + FailtobanIgnore []string `yaml:"failtoban_ignore,omitempty"` } // DefaultSSHPiperVersion is the sshpiper release rootfiles installs. diff --git a/internal/config/validate.go b/internal/config/validate.go index 00a1c82..2b341b9 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -143,6 +143,13 @@ func (c *Config) Validate() error { add("modules.tenant.sshpiper.address: %q is not an IP address", sp.Address) } } + for _, ip := range sp.FailtobanIgnore { + _, perr := netip.ParsePrefix(ip) + _, aerr := netip.ParseAddr(ip) + if perr != nil && aerr != nil { + add("modules.tenant.sshpiper.failtoban_ignore: %q is not an address or CIDR", ip) + } + } } if g := c.Modules.Tenant.OpsGroup; g != "" && (!validAccountName.MatchString(g) || len(g) > 32) { add("modules.tenant.ops_group: invalid group name %q", g) diff --git a/internal/module/tenant_piper.go b/internal/module/tenant_piper.go index 278c8f3..b33d4ae 100644 --- a/internal/module/tenant_piper.go +++ b/internal/module/tenant_piper.go @@ -82,6 +82,10 @@ func piperUnitContent(rc *RunContext) string { if addr == "" { addr = "0.0.0.0" } + ignore := "" + for _, ip := range sp.FailtobanIgnore { + ignore += " --ignore-ip " + ip + } return `# Managed by rootfiles [Unit] Description=rootfiles sshpiper: one SSH port for tenant containers @@ -91,7 +95,7 @@ Wants=network-online.target [Service] ExecStart=` + d + `/sshpiperd --address ` + addr + ` --port ` + strconv.Itoa(sp.ListenPort()) + ` --server-key ` + piperConfDir + `/ssh_host_ed25519_key --server-key-generate-mode notexist ` + - d + `/plugins/yaml --config ` + piperConfDir + `/tenants/*.yaml -- ` + d + `/plugins/failtoban + d + `/plugins/yaml --config ` + piperConfDir + `/tenants/*.yaml -- ` + d + `/plugins/failtoban` + ignore + ` Restart=always RestartSec=5 diff --git a/internal/module/tenant_test.go b/internal/module/tenant_test.go index 96c6cab..7dce51a 100644 --- a/internal/module/tenant_test.go +++ b/internal/module/tenant_test.go @@ -180,3 +180,15 @@ func TestPiperBlock(t *testing.T) { t.Errorf("unknown firewall state not blocked: %q", b) } } + +func TestPiperUnitFailtobanIgnore(t *testing.T) { + rc := newDryRunRC(t) + rc.Config.Modules.Tenant.SSHPiper = config.SSHPiperConfig{Enabled: true, FailtobanIgnore: []string{"172.16.229.33/32", "10.0.0.1"}} + if u := piperUnitContent(rc); !strings.Contains(u, "/plugins/failtoban --ignore-ip 172.16.229.33/32 --ignore-ip 10.0.0.1\n") { + t.Errorf("unit lacks ignore list:\n%s", u) + } + rc.Config.Modules.Tenant.SSHPiper.FailtobanIgnore = []string{"not-an-ip"} + if err := rc.Config.Validate(); err == nil || !strings.Contains(err.Error(), "failtoban_ignore") { + t.Errorf("bad entry accepted: %v", err) + } +} From 5a06347d4d43ae80af8b72d385931dc9aa2ff218 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Tue, 20 Oct 2026 11:23:55 +0900 Subject: [PATCH 2/2] fix(tenant): reject zoned addresses in failtoban_ignore Claude-Session: https://claude.ai/code/session_0177Zk7to4EYohgM59v4Fcj7 --- internal/config/validate.go | 5 +++-- internal/module/tenant_test.go | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/config/validate.go b/internal/config/validate.go index 2b341b9..83b103c 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -146,8 +146,9 @@ func (c *Config) Validate() error { for _, ip := range sp.FailtobanIgnore { _, perr := netip.ParsePrefix(ip) _, aerr := netip.ParseAddr(ip) - if perr != nil && aerr != nil { - add("modules.tenant.sshpiper.failtoban_ignore: %q is not an address or CIDR", ip) + // A zone ("%eth0") would read as a systemd specifier in ExecStart. + if perr != nil && aerr != nil || strings.Contains(ip, "%") { + add("modules.tenant.sshpiper.failtoban_ignore: %q is not an address or CIDR without a zone", ip) } } } diff --git a/internal/module/tenant_test.go b/internal/module/tenant_test.go index 7dce51a..c3a640a 100644 --- a/internal/module/tenant_test.go +++ b/internal/module/tenant_test.go @@ -187,8 +187,10 @@ func TestPiperUnitFailtobanIgnore(t *testing.T) { if u := piperUnitContent(rc); !strings.Contains(u, "/plugins/failtoban --ignore-ip 172.16.229.33/32 --ignore-ip 10.0.0.1\n") { t.Errorf("unit lacks ignore list:\n%s", u) } - rc.Config.Modules.Tenant.SSHPiper.FailtobanIgnore = []string{"not-an-ip"} - if err := rc.Config.Validate(); err == nil || !strings.Contains(err.Error(), "failtoban_ignore") { - t.Errorf("bad entry accepted: %v", err) + for _, bad := range []string{"not-an-ip", "fe80::1%eth0"} { + rc.Config.Modules.Tenant.SSHPiper.FailtobanIgnore = []string{bad} + if err := rc.Config.Validate(); err == nil || !strings.Contains(err.Error(), "failtoban_ignore") { + t.Errorf("bad entry %q accepted: %v", bad, err) + } } }