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..83b103c 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -143,6 +143,14 @@ 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) + // 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) + } + } } 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..c3a640a 100644 --- a/internal/module/tenant_test.go +++ b/internal/module/tenant_test.go @@ -180,3 +180,17 @@ 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) + } + 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) + } + } +}