Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/releases/v0.17.1.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion docs/tenant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<version>` and runs `rootfiles-sshpiper.service`. Users then reach any tenant through one port by naming the tenant in the user name:
Expand All @@ -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 <t>` and `rootfiles tenant restart <t>` 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.
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions internal/config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion internal/module/tenant_piper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
entelecheia marked this conversation as resolved.
}
return `# Managed by rootfiles
[Unit]
Description=rootfiles sshpiper: one SSH port for tenant containers
Expand All @@ -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

Expand Down
14 changes: 14 additions & 0 deletions internal/module/tenant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Loading