diff --git a/cmd/server/main.go b/cmd/server/main.go index 6539f74..79bd5a0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -43,9 +43,6 @@ func main() { web.SetBasePathFromEnv() auth.SetSessionCookiePath(web.CookiePath()) - if err := storage.Init(""); err != nil { - log.Fatalf("Failed to initialise storage: %v", err) - } defer func() { if err := storage.Close(); err != nil { log.Printf("warning: failed to close storage: %v", err) diff --git a/deployment/container/README.md b/deployment/container/README.md index 168866a..c5306dd 100644 --- a/deployment/container/README.md +++ b/deployment/container/README.md @@ -338,6 +338,11 @@ semodule -i fail2ban-container-ui.pp semodule -i fail2ban-container-client.pp ``` +What the modules cover: + +- `fail2ban-container-ui`: lets the container write the managed files under `/etc/fail2ban` and stat/read `/var/log/fail2ban.log`. The latter is required as soon as a jail uses Fail2Ban's own log as `logpath` (e.g. the `recidive` jail): `fail2ban-client reload` stats every logpath while parsing the config, and a denial surfaces as `Have not found any log file for recidive jail`. +- `fail2ban-container-client`: lets the container connect to the host Fail2Ban socket and read the monitored logs (`httpd_log_t`, `var_log_t`). + ### Compile and install the policies manually To modify or rebuild the SELinux rules: @@ -413,8 +418,10 @@ Symptoms: empty dashboard, no servers visible. - sudo access for `fail2ban-client` and `systemctl restart fail2ban` (via sudoers) - filesystem ACLs on `/etc/fail2ban` for configuration file access - see [docs/security.md](../../docs/security.md#ssh-connector-hardening) for the recommended service-account setup -3. Check the key location: SSH keys belong in `/config/.ssh` inside the container, with permissions `600`. -4. Enable debug mode under **Settings** for detailed error messages. +3. Check the key location: SSH keys belong in `/config/.ssh` inside the container, with permissions `600` (and the `.ssh` directory itself `700`). +4. Host keys: the connector uses `StrictHostKeyChecking=accept-new` with a persistent `known_hosts` next to the configured SSH key (e.g. `/config/.ssh/known_hosts`). The first connection records the remote host key; if the remote key ever CHANGES, connections fail on purpose - verify the remote host and remove the stale entry with `ssh-keygen -R -f /config/.ssh/known_hosts` to re-trust it. (Earlier versions disabled host-key verification entirely inside containers.) +5. ControlMaster sockets live in a private `ctl/` directory next to the SSH key (e.g. `/config/.ssh/ctl/`), not in `/tmp`. Stale sockets are cleaned up automatically. +6. Enable debug mode under **Settings** for detailed error messages. 5. Verify network connectivity: the container needs network access to the remote SSH servers. Check whether `--network=host` is in use, or configure the appropriate port mappings. ### Permission denied errors diff --git a/deployment/container/SELinux/fail2ban-container-ui.pp b/deployment/container/SELinux/fail2ban-container-ui.pp index f102b53..142c8a4 100644 Binary files a/deployment/container/SELinux/fail2ban-container-ui.pp and b/deployment/container/SELinux/fail2ban-container-ui.pp differ diff --git a/deployment/container/SELinux/fail2ban-container-ui.te b/deployment/container/SELinux/fail2ban-container-ui.te index 78f663f..9c41a1b 100644 --- a/deployment/container/SELinux/fail2ban-container-ui.te +++ b/deployment/container/SELinux/fail2ban-container-ui.te @@ -1,13 +1,17 @@ -module fail2ban-container-ui 1.0; +module fail2ban-container-ui 1.1; require { type fail2ban_log_t; type etc_t; type container_t; - class file { open read write }; + class file { getattr open read write }; } #============= container_t ============== allow container_t etc_t:file write; -allow container_t fail2ban_log_t:file { open read }; +# getattr is required in addition to open/read: fail2ban-client stats every +# jail logpath while parsing the config, so without it a jail reading +# /var/log/fail2ban.log (e.g. recidive) fails the client-side reload with +# "Have not found any log file for jail". +allow container_t fail2ban_log_t:file { getattr open read }; diff --git a/docs/configuration.md b/docs/configuration.md index f7a6f12..df70e5c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,9 +68,13 @@ With a subpath: ### Reverse SSH tunnel for callbacks -SSH-connected servers can enable **reverse tunnel for events** (server form). The UI then opens a reverse tunnel (`ssh -R :localhost:`) alongside the SSH control connection so callbacks reach the UI even when the managed host cannot connect to it directly (NAT, firewall). The port is derived from `CALLBACK_URL` (explicit port, otherwise 443/80 by scheme). +SSH-connected servers can enable **reverse tunnel for events** (server form). The UI then opens a reverse tunnel (`ssh -R :localhost:`) alongside the SSH control connection so callbacks reach the UI even when the managed host cannot connect to it directly (NAT, firewall, isolated DMZ without outbound access). Two ports are involved: the **tunnel port** is where the remote host listens locally for the fail2ban callbacks, and the **server port** is where the UI's HTTP API listens -> the tunnel carries connections from the former to the latter. -The tunnel is only used if `CALLBACK_URL` points to `localhost`/`127.0.0.1` - the remote Fail2Ban sends its callbacks to that URL, which the tunnel forwards to the UI. With a public callback URL the callbacks bypass the tunnel; the UI logs a warning in that case. Note that a localhost callback URL applies globally, so mixing tunneled and non-tunneled remote servers is not possible. +Each server has its own tunnel port setting. Leave it empty to use the UI bind port (`PORT`, default 8080). If set, it must be between 1024 and 65535: the unprivileged SSH service account on the managed host cannot bind ports below 1024, and `ssh` only reports such a failure as a stderr warning while the connection itself stays up. The same applies when the chosen port is already occupied on the remote host, pick a free unprivileged port in that case. The tunnel port only affects the remote side; the tunnel always forwards to the UI's configured server port, so a custom tunnel port does not need to match it. + +For tunneled servers, the UI writes `http://localhost:` into the remote action file, so the callbacks travel through the tunnel regardless of the global `CALLBACK_URL`. This means mixed setups work: keep `CALLBACK_URL` pointing at a public address for directly reachable servers while tunneled servers use the loopback URL. Plain `http` is correct inside the tunnel, the transport is already SSH-encrypted, so TLS (and `CALLBACK_INSECURE_TLS`) is irrelevant for tunneled servers. + +The UI checks every tunnel's SSH master connection every 45 seconds and automatically re-establishes it when it has died (for example after a short network outage), so callback delivery resumes without waiting for the next UI-triggered command. Changing a server's tunnel settings tears down the old connection and builds a new one immediately. ## Privacy and telemetry controls diff --git a/internal/config/fail2ban_bridge.go b/internal/config/fail2ban_bridge.go index f5f8cfb..d2caf7b 100644 --- a/internal/config/fail2ban_bridge.go +++ b/internal/config/fail2ban_bridge.go @@ -38,6 +38,12 @@ func (fail2banRuntime) CallbackSecret() string { return currentSettings.CallbackSecret } +func (fail2banRuntime) ServerPort() int { + settingsLock.RLock() + defer settingsLock.RUnlock() + return currentSettings.Port +} + func (fail2banRuntime) BuildFail2banActionConfig(callbackURL, serverID, secret string) string { return BuildFail2banActionConfig(callbackURL, serverID, secret) } diff --git a/internal/config/settings.go b/internal/config/settings.go index b4b7354..5370e14 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -505,6 +505,7 @@ func applyServerRecordsLocked(records []storage.ServerRecord) { IsDefault: rec.IsDefault, Enabled: rec.Enabled, ReverseTunnelEnabled: rec.ReverseTunnelEnabled, + TunnelPort: rec.TunnelPort, RestartNeeded: rec.NeedsRestart, CreatedAt: rec.CreatedAt, UpdatedAt: rec.UpdatedAt, @@ -630,6 +631,7 @@ func toServerRecordsLocked() ([]storage.ServerRecord, error) { IsDefault: srv.IsDefault, Enabled: srv.Enabled, ReverseTunnelEnabled: srv.ReverseTunnelEnabled, + TunnelPort: srv.TunnelPort, NeedsRestart: srv.RestartNeeded, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -1183,7 +1185,18 @@ func GetDefaultServer() Fail2banServer { return Fail2banServer{} } -// Adds or updates a Fail2ban server. +// ErrInvalidTunnelPort is returned when a reverse-tunnel port is outside the +// unprivileged range. Handlers match it to attach a localized message key. +var ErrInvalidTunnelPort = errors.New("tunnelPort must be between 1024 and 65535 (empty = server port)") + +// Reject reverse-tunnel ports outside the unprivileged range +func validateTunnelPort(port int) error { + if port == 0 || (port >= 1024 && port <= 65535) { + return nil + } + return fmt.Errorf("%w, got %d", ErrInvalidTunnelPort, port) +} + func UpsertServer(input Fail2banServer) (Fail2banServer, error) { settingsLock.Lock() defer settingsLock.Unlock() @@ -1218,6 +1231,13 @@ func UpsertServer(input Fail2banServer) (Fail2banServer, error) { input.SocketPath = normalizePathValue(input.SocketPath) input.ConfigPath = "" } + if input.Type == "ssh" { + if err := validateTunnelPort(input.TunnelPort); err != nil { + return Fail2banServer{}, err + } + } else { + input.TunnelPort = 0 + } if input.Name == "" { input.Name = "Fail2ban Server " + input.ID } diff --git a/internal/config/settings_validation_test.go b/internal/config/settings_validation_test.go index d6cce2b..622a795 100644 --- a/internal/config/settings_validation_test.go +++ b/internal/config/settings_validation_test.go @@ -124,3 +124,19 @@ func TestFail2banActionConfigEscapesPercent(t *testing.T) { } } } + +func TestValidateTunnelPort(t *testing.T) { + t.Parallel() + valid := []int{0, 1024, 8080, 65535} + for _, port := range valid { + if err := validateTunnelPort(port); err != nil { + t.Fatalf("validateTunnelPort(%d) = %v, want nil", port, err) + } + } + invalid := []int{-1, 1, 80, 443, 1023, 65536} + for _, port := range invalid { + if err := validateTunnelPort(port); err == nil { + t.Fatalf("validateTunnelPort(%d) = nil, want error", port) + } + } +} diff --git a/internal/fail2ban/banned_parser_test.go b/internal/fail2ban/banned_parser_test.go new file mode 100644 index 0000000..4dd9072 --- /dev/null +++ b/internal/fail2ban/banned_parser_test.go @@ -0,0 +1,163 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fail2ban + +import ( + "strings" + "testing" +) + +const realBannedOutput = `[{'apache-auth': ['102.220.160.200', '185.93.89.147']}, {'apache-overflows': []}, {'apache-nohome': []}, {'apache-fakegooglebot': ['11.10.99.99', '11.22.33.44']}, {'apache-shellshock': []}, {'swissmakers-apache-auth': []}, {'swissmakers-apache-badbots': ['135.237.127.94', '139.162.110.42', '172.105.196.91']}, {'swissmakers-apache-dos': []}, {'swissmakers-apache-exploit-attempts': ['104.208.94.19', '110.35.80.116']}, {'swissmakers-apache-scanner': ['110.35.80.116', '118.194.251.37', '119.179.253.111']}]` + +func TestParseBannedJails(t *testing.T) { + t.Run("real production output", func(t *testing.T) { + infos, err := parseBannedJails(realBannedOutput) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(infos) != 10 { + t.Fatalf("expected 10 jails, got %d: %+v", len(infos), infos) + } + // Results must be sorted by jail name + for i := 1; i < len(infos); i++ { + if infos[i-1].JailName > infos[i].JailName { + t.Fatalf("jails must be sorted, got %s before %s", infos[i-1].JailName, infos[i].JailName) + } + } + byName := map[string]JailInfo{} + for _, j := range infos { + byName[j.JailName] = j + } + if got := byName["apache-auth"]; got.TotalBanned != 2 || got.BannedIPs[0] != "102.220.160.200" { + t.Fatalf("apache-auth wrong: %+v", got) + } + if got := byName["apache-overflows"]; got.TotalBanned != 0 || got.BannedIPs == nil { + t.Fatalf("empty jail must have a non-nil empty list, got %+v", got) + } + if got := byName["swissmakers-apache-scanner"]; got.TotalBanned != 3 { + t.Fatalf("scanner count wrong: %+v", got) + } + // Every jail reported by banned is running + for _, j := range infos { + if !j.Enabled { + t.Fatalf("jail %s should be marked enabled: %+v", j.JailName, j) + } + if j.TotalBanned != len(j.BannedIPs) { + t.Fatalf("TotalBanned must match the IP list length for %s: %+v", j.JailName, j) + } + } + }) + + t.Run("no jails", func(t *testing.T) { + infos, err := parseBannedJails("[]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(infos) != 0 { + t.Fatalf("expected no jails, got %+v", infos) + } + }) + + t.Run("single jail with no bans", func(t *testing.T) { + infos, err := parseBannedJails("[{'apache-auth': []}]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(infos) != 1 || infos[0].JailName != "apache-auth" || infos[0].TotalBanned != 0 { + t.Fatalf("unexpected result: %+v", infos) + } + if infos[0].BannedIPs == nil { + t.Fatal("BannedIPs must be an empty slice, not nil (it is serialized to JSON)") + } + }) + + t.Run("IPv6 and CIDR entries", func(t *testing.T) { + infos, err := parseBannedJails("[{'sshd': ['2001:db8::1', '::1', '10.0.0.0/8']}]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"2001:db8::1", "::1", "10.0.0.0/8"} + if len(infos[0].BannedIPs) != len(want) { + t.Fatalf("got %+v", infos[0].BannedIPs) + } + for i := range want { + if infos[0].BannedIPs[i] != want[i] { + t.Fatalf("got %+v, want %+v", infos[0].BannedIPs, want) + } + } + }) + + t.Run("trailing newline and surrounding whitespace", func(t *testing.T) { + if _, err := parseBannedJails("\n [{'sshd': ['1.2.3.4']}] \n"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("double-quoted strings are accepted", func(t *testing.T) { + infos, err := parseBannedJails(`[{"sshd": ["1.2.3.4"]}]`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if infos[0].JailName != "sshd" || infos[0].BannedIPs[0] != "1.2.3.4" { + t.Fatalf("unexpected result: %+v", infos) + } + }) + + t.Run("escaped quote inside a name", func(t *testing.T) { + infos, err := parseBannedJails(`[{'we\'ird': []}]`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if infos[0].JailName != "we'ird" { + t.Fatalf("escape not handled: %q", infos[0].JailName) + } + }) + + // Malformed input must fail visible -> silently returning zero jails would + // render a dashboard claiming nothing is banned + t.Run("malformed input is an error, never an empty result", func(t *testing.T) { + cases := map[string]string{ + "empty": "", + "whitespace only": " \n ", + "fail2ban error text": "Sorry but the command 'xdspfkpw' is not supported", + "missing outer bracket": "{'sshd': []}", + "unterminated list": "[{'sshd': ['1.2.3.4']}", + "unterminated string": "[{'sshd: []}]", + "missing colon": "[{'sshd' []}]", + "unterminated inner list": "[{'sshd': ['1.2.3.4'}]", + "not a list of dicts": "['sshd']", + } + for name, in := range cases { + t.Run(name, func(t *testing.T) { + infos, err := parseBannedJails(in) + if err == nil { + t.Fatalf("expected an error, got %+v", infos) + } + }) + } + }) + + t.Run("parse errors stay readable for huge payloads", func(t *testing.T) { + _, err := parseBannedJails(strings.Repeat("x", 5000)) + if err == nil { + t.Fatal("expected an error") + } + if len(err.Error()) > 400 { + t.Fatalf("error message should be truncated, got %d bytes", len(err.Error())) + } + }) +} diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index c57dda9..8c75b3e 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -242,6 +242,18 @@ func (ac *AgentConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) return resp.Jails, nil } +func (ac *AgentConnector) GetJailSummary(ctx context.Context) (*JailSummary, error) { + infos, err := ac.GetJailInfos(ctx) + if err != nil { + return nil, err + } + exists, managed, err := ac.CheckJailLocalIntegrity(ctx) + if err != nil { + debugf("Warning: could not check jail.local integrity on %s: %v", ac.server.Name, err) + } + return &JailSummary{Jails: infos, JailLocalExists: exists, JailLocalManaged: managed}, nil +} + func (ac *AgentConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) { var resp struct { Jail string `json:"jail"` diff --git a/internal/fail2ban/connector_agent_test.go b/internal/fail2ban/connector_agent_test.go index d66a3db..d8c8de4 100644 --- a/internal/fail2ban/connector_agent_test.go +++ b/internal/fail2ban/connector_agent_test.go @@ -34,6 +34,7 @@ type testProvider struct{} func (testProvider) DebugLog(format string, v ...interface{}) {} func (testProvider) CallbackURL() string { return "http://127.0.0.1:8080" } func (testProvider) CallbackSecret() string { return "test-secret" } +func (testProvider) ServerPort() int { return 8080 } func (testProvider) BuildFail2banActionConfig(callbackURL, serverID, secret string) string { return "" } diff --git a/internal/fail2ban/connector_global.go b/internal/fail2ban/connector_global.go index f6508a6..1b74665 100644 --- a/internal/fail2ban/connector_global.go +++ b/internal/fail2ban/connector_global.go @@ -20,9 +20,9 @@ package fail2ban import ( "context" "fmt" + "path/filepath" "sort" "strings" - "sync" "github.com/swissmakers/fail2ban-ui/internal/shared" ) @@ -36,7 +36,7 @@ func ValidateIP(ip string) error { return shared.ValidateIP(ip) } -// Inspects fail2ban-client reload output for the markers that indicate the daemon reloaded but a jail/filter failed to apply. +// Inspects fail2ban-client reload output for the markers tha indicate the daemon reloaded but a jail/filter failed to apply func checkReloadOutput(output string) error { trimmed := strings.TrimSpace(output) if trimmed == "" || trimmed == "OK" { @@ -48,11 +48,201 @@ func checkReloadOutput(output string) error { return nil } +// Marker that identifies a jail.local as generated and owned by Fail2ban-UI +const managedJailLocalMarker = "ui-custom-action" + +// ========================================================================= +// Shared fail2ban-client Output Parsers +// ========================================================================= + +func parseBannedJails(out string) ([]JailInfo, error) { + s := &reprScanner{in: strings.TrimSpace(out)} + if s.rest() == "" { + return nil, fmt.Errorf("empty output from `fail2ban-client banned`") + } + if !s.accept('[') { + return nil, fmt.Errorf("unexpected output from `fail2ban-client banned`: %s", truncateForError(s.in)) + } + + infos := []JailInfo{} + s.skipSpace() + for !s.accept(']') { + if s.done() { + return nil, fmt.Errorf("unterminated jail list in `fail2ban-client banned` output") + } + if !s.accept('{') { + return nil, fmt.Errorf("expected a jail entry at offset %d in `fail2ban-client banned` output", s.pos) + } + name, err := s.quotedString() + if err != nil { + return nil, fmt.Errorf("bad jail name in `fail2ban-client banned` output: %w", err) + } + if !s.accept(':') { + return nil, fmt.Errorf("missing ':' after jail %q in `fail2ban-client banned` output", name) + } + ips, err := s.stringList() + if err != nil { + return nil, fmt.Errorf("bad banned IP list for jail %q: %w", name, err) + } + if !s.accept('}') { + return nil, fmt.Errorf("unterminated entry for jail %q in `fail2ban-client banned` output", name) + } + infos = append(infos, JailInfo{ + JailName: name, + TotalBanned: len(ips), + BannedIPs: ips, + Enabled: true, + }) + s.accept(',') + s.skipSpace() + } + + sort.SliceStable(infos, func(i, j int) bool { return infos[i].JailName < infos[j].JailName }) + return infos, nil +} + +// Keeps parse errors readable when the remote returns something unexpected. +func truncateForError(s string) string { + const limit = 120 + if len(s) <= limit { + return s + } + return s[:limit] + "..." +} + +// Minimal scanner for the subset of Python repr that fail2ban emits +type reprScanner struct { + in string + pos int +} + +func (s *reprScanner) rest() string { return s.in[s.pos:] } +func (s *reprScanner) done() bool { return s.pos >= len(s.in) } + +func (s *reprScanner) skipSpace() { + for s.pos < len(s.in) && (s.in[s.pos] == ' ' || s.in[s.pos] == '\t' || s.in[s.pos] == '\n' || s.in[s.pos] == '\r') { + s.pos++ + } +} + +func (s *reprScanner) accept(c byte) bool { + s.skipSpace() + if s.pos < len(s.in) && s.in[s.pos] == c { + s.pos++ + return true + } + return false +} + +// Reads a 'single' or "double" quoted string, honouring backslash escapes. +func (s *reprScanner) quotedString() (string, error) { + s.skipSpace() + if s.done() { + return "", fmt.Errorf("unexpected end of input") + } + quote := s.in[s.pos] + if quote != '\'' && quote != '"' { + return "", fmt.Errorf("expected a quoted string at offset %d", s.pos) + } + s.pos++ + var b strings.Builder + for s.pos < len(s.in) { + c := s.in[s.pos] + switch c { + case '\\': + if s.pos+1 >= len(s.in) { + return "", fmt.Errorf("dangling escape at offset %d", s.pos) + } + b.WriteByte(s.in[s.pos+1]) + s.pos += 2 + case quote: + s.pos++ + return b.String(), nil + default: + b.WriteByte(c) + s.pos++ + } + } + return "", fmt.Errorf("unterminated string starting at offset %d", s.pos) +} + +// Reads ['a', 'b'] into a slice -> an empty list yields a non-nil empty slice. +func (s *reprScanner) stringList() ([]string, error) { + if !s.accept('[') { + return nil, fmt.Errorf("expected a list at offset %d", s.pos) + } + items := []string{} + s.skipSpace() + for !s.accept(']') { + if s.done() { + return nil, fmt.Errorf("unterminated list") + } + item, err := s.quotedString() + if err != nil { + return nil, err + } + items = append(items, item) + s.accept(',') + s.skipSpace() + } + return items, nil +} + +func fail2banArgs(socketPath string, args ...string) []string { + if socketPath == "" { + return args + } + return append([]string{"-s", socketPath}, args...) +} + +func isNonConfigFile(filename string) bool { + return strings.Contains(filename, "README") || + strings.HasSuffix(filename, ".bak") || + strings.HasSuffix(filename, "~") || + strings.HasSuffix(filename, ".old") || + strings.HasSuffix(filename, ".rpmnew") || + strings.HasSuffix(filename, ".rpmsave") +} + +func dedupeConfigBaseNames(localFiles, confFiles []string) []string { + seen := make(map[string]bool) + var names []string + add := func(paths []string, suffix string) { + for _, p := range paths { + filename := filepath.Base(p) + if isNonConfigFile(filename) { + continue + } + base := strings.TrimSuffix(filename, suffix) + if base == "" || base == filename || seen[base] { + continue + } + seen[base] = true + names = append(names, base) + } + } + add(localFiles, ".local") + add(confFiles, ".conf") + sort.Strings(names) + return names +} + +func checkPingOutput(out string, err error, label string) error { + trimmed := strings.TrimSpace(out) + if err != nil { + return fmt.Errorf("%s ping error: %w (output: %s)", label, err, trimmed) + } + if !strings.Contains(strings.ToLower(trimmed), "pong") { + return fmt.Errorf("unexpected %s ping output: %s", label, trimmed) + } + return nil +} + // ========================================================================= // Types // ========================================================================= -// JailInfo holds summary data for a single Fail2ban jail. +// A single Fail2ban jail type JailInfo struct { JailName string `json:"jailName"` TotalBanned int `json:"totalBanned"` @@ -61,11 +251,17 @@ type JailInfo struct { Enabled bool `json:"enabled"` } +// Result of one summary fetch +type JailSummary struct { + Jails []JailInfo + JailLocalExists bool + JailLocalManaged bool +} + // ========================================================================= // Service Control // ========================================================================= -// RestartFail2ban restarts (or reloads) the Fail2ban service on the given server. func RestartFail2ban(serverID string) (string, error) { manager := GetManager() var ( @@ -90,74 +286,3 @@ func RestartFail2ban(serverID string) (string, error) { } return "restart", nil } - -// ========================================================================= -// Jail Info Collection -// ========================================================================= - -// bannedIPsFn is the signature used by any connector's GetBannedIPs method. -type bannedIPsFn func(ctx context.Context, jail string) ([]string, error) - -// Fans out to fetch banned IPs for each jail concurrently, then returns the results sorted alphabetically. (local connector) -func collectJailInfos(ctx context.Context, jails []string, getBannedIPs bannedIPsFn) ([]JailInfo, error) { - return collectJailInfosLimited(ctx, jails, getBannedIPs, 0) -} - -// Caps the number of concurrent getBannedIPs calls when maxConcurrent > 0. (SSH connector) -func collectJailInfosLimited(ctx context.Context, jails []string, getBannedIPs bannedIPsFn, maxConcurrent int) ([]JailInfo, error) { - type jailResult struct { - jail JailInfo - err error - } - results := make(chan jailResult, len(jails)) - var wg sync.WaitGroup - - var sem chan struct{} - if maxConcurrent > 0 { - sem = make(chan struct{}, maxConcurrent) - } - - for _, jail := range jails { - wg.Add(1) - go func(j string) { - defer wg.Done() - if sem != nil { - sem <- struct{}{} - defer func() { <-sem }() - } - ips, err := getBannedIPs(ctx, j) - if err != nil { - results <- jailResult{err: err} - return - } - totalBanned := len(ips) - results <- jailResult{ - jail: JailInfo{ - JailName: j, - TotalBanned: totalBanned, - BannedIPs: []string{}, - Enabled: true, - }, - } - }(jail) - } - - go func() { - wg.Wait() - close(results) - }() - - var infos []JailInfo - for r := range results { - if r.err != nil { - continue - } - infos = append(infos, r.jail) - } - - sort.SliceStable(infos, func(i, j int) bool { - return infos[i].JailName < infos[j].JailName - }) - - return infos, nil -} diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index cc9f2d8..ce3cd36 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -41,10 +41,6 @@ func NewLocalConnector(server shared.Fail2banServer) *LocalConnector { return &LocalConnector{server: server} } -func (lc *LocalConnector) ID() string { - return lc.server.ID -} - func (lc *LocalConnector) Server() shared.Fail2banServer { return lc.server } @@ -55,33 +51,43 @@ func (lc *LocalConnector) configPath() string { // Collects jail status for every active local jail. func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) { - jails, err := lc.getJails(ctx) + summary, err := lc.GetJailSummary(ctx) if err != nil { return nil, err } - return collectJailInfos(ctx, jails, lc.GetBannedIPs) + return summary.Jails, nil } -// Get banned IPs for a given jail. -func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) { - args := []string{"status", jail} - out, err := lc.runFail2banClient(ctx, args...) +func (lc *LocalConnector) GetJailSummary(ctx context.Context) (*JailSummary, error) { + out, err := lc.runFail2banClient(ctx, "banned") if err != nil { - return nil, fmt.Errorf("fail2ban-client status %s failed: %w", jail, err) - } - var bannedIPs []string - lines := strings.Split(out, "\n") - for _, line := range lines { - if strings.Contains(line, "IP list:") { - parts := strings.SplitN(line, ":", 2) - if len(parts) > 1 { - ips := strings.Fields(strings.TrimSpace(parts[1])) - bannedIPs = append(bannedIPs, ips...) - } - break + socketPath := lc.server.SocketPath + if strings.TrimSpace(socketPath) == "" { + socketPath = "default socket" } + return nil, fmt.Errorf("unable to retrieve jail information via socket %s. is your fail2ban service running? details: %w (output: %s)", + socketPath, err, strings.TrimSpace(out)) + } + infos, err := parseBannedJails(out) + if err != nil { + return nil, fmt.Errorf("%w (fail2ban 0.11 or newer is required)", err) + } + exists, managed, err := lc.CheckJailLocalIntegrity(ctx) + if err != nil { + debugf("Warning: could not check jail.local integrity on %s: %v", lc.server.Name, err) + } + return &JailSummary{Jails: infos, JailLocalExists: exists, JailLocalManaged: managed}, nil +} + +func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) { + if err := ValidateJailName(jail); err != nil { + return nil, err + } + out, err := lc.runFail2banClient(ctx, "get", jail, "banip") + if err != nil { + return nil, fmt.Errorf("fail2ban-client get %s banip failed: %w (output: %s)", jail, err, strings.TrimSpace(out)) } - return bannedIPs, nil + return strings.Fields(out), nil } // Unban an IP from a given jail. @@ -159,66 +165,19 @@ func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content str return SetFilterConfigLocal(jail, content, lc.configPath()) } -// Get all jails. -func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) { - out, err := lc.runFail2banClient(ctx, "status") - if err != nil { - socketPath := lc.server.SocketPath - if strings.TrimSpace(socketPath) == "" { - socketPath = "default socket" - } - trimmedOut := strings.TrimSpace(out) - if trimmedOut != "" { - return nil, fmt.Errorf("error: unable to retrieve jail information via socket %s. is your fail2ban service running? details: %w (output: %s)", socketPath, err, trimmedOut) - } - return nil, fmt.Errorf("error: unable to retrieve jail information via socket %s. is your fail2ban service running? details: %w", socketPath, err) - } - var jails []string - lines := strings.Split(out, "\n") - for _, line := range lines { - if strings.Contains(line, "Jail list:") { - parts := strings.SplitN(line, ":", 2) - if len(parts) > 1 { - raw := strings.TrimSpace(parts[1]) - jails = strings.Split(raw, ",") - for i := range jails { - jails[i] = strings.TrimSpace(jails[i]) - } - } - } - } - return jails, nil -} - // ========================================================================= // CLI Helpers // ========================================================================= func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) { - cmdArgs := lc.buildFail2banArgs(args...) - cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...) + cmd := exec.CommandContext(ctx, "fail2ban-client", fail2banArgs(lc.server.SocketPath, args...)...) out, err := cmd.CombinedOutput() return string(out), err } -func (lc *LocalConnector) buildFail2banArgs(args ...string) []string { - if lc.server.SocketPath == "" { - return args - } - base := []string{"-s", lc.server.SocketPath} - return append(base, args...) -} - func (lc *LocalConnector) checkFail2banHealthy(ctx context.Context) error { out, err := lc.runFail2banClient(ctx, "ping") - trimmed := strings.TrimSpace(out) - if err != nil { - return fmt.Errorf("fail2ban ping error: %w (output: %s)", err, trimmed) - } - if !strings.Contains(strings.ToLower(trimmed), "pong") { - return fmt.Errorf("unexpected fail2ban ping output: %s", trimmed) - } - return nil + return checkPingOutput(out, err, "fail2ban") } // ========================================================================= @@ -292,7 +251,7 @@ func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bo } return false, false, fmt.Errorf("failed to read jail.local: %w", err) } - hasUIAction := strings.Contains(string(content), "ui-custom-action") + hasUIAction := strings.Contains(string(content), managedJailLocalMarker) return true, hasUIAction, nil } diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 055f6e1..967547c 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -17,22 +17,13 @@ package fail2ban import ( - "bufio" - "bytes" "context" "encoding/base64" - "errors" "fmt" "log" - "net/url" - "os" - "os/exec" - "path/filepath" - "sort" - "strconv" "strings" "sync" - "syscall" + "sync/atomic" "time" "github.com/swissmakers/fail2ban-ui/internal/shared" @@ -42,13 +33,18 @@ import ( // Types and Constants // ========================================================================= -// SSHConnector talks to a remote Fail2ban instance over SSH. +// Talks like this to a remote Fail2ban instance over SSH type SSHConnector struct { server shared.Fail2banServer fail2banPath string - pathCached bool pathMutex sync.RWMutex tunnelPort int + forwardPort int + tunnelWasUp bool + closed atomic.Bool + masterMu sync.Mutex + masterUp atomic.Bool + sessionSem chan struct{} } const sshEnsureActionScript = `python3 - <<'PY' @@ -85,27 +81,18 @@ func NewSSHConnector(server shared.Fail2banServer) (Connector, error) { if server.SSHUser == "" { return nil, fmt.Errorf("sshUser is required for ssh connector") } - conn := &SSHConnector{server: server} + conn := &SSHConnector{ + server: server, + sessionSem: make(chan struct{}, sshMaxConcurrentSessions), + } - // Parse tunnel port from callback URL when reverse tunnel is enabled if server.ReverseTunnelEnabled { - callbackURL := mustProvider().CallbackURL() - parsedURL, err := url.Parse(callbackURL) - if err == nil { - conn.tunnelPort = callbackTunnelPort(parsedURL) - } - if conn.tunnelPort == 0 { - log.Printf("warning: reverse tunnel enabled for server %s but no usable port could be derived from callback URL %q - tunnel disabled", server.Name, callbackURL) - } else { - if host := parsedURL.Hostname(); host != "localhost" && host != "127.0.0.1" && host != "::1" { - log.Printf("warning: reverse tunnel for server %s forwards localhost:%d, but the callback URL points to host %q - the remote fail2ban will bypass the tunnel unless the callback URL host is localhost", server.Name, conn.tunnelPort, host) - } - debugf("Reverse tunnel enabled for server %s, will use -R %d:localhost:%d", server.Name, conn.tunnelPort, conn.tunnelPort) - } + conn.tunnelPort = resolveTunnelPort(server) + conn.forwardPort = uiServerPort() + debugf("Reverse tunnel enabled for server %s, will use -R %d:localhost:%d", server.Name, conn.tunnelPort, conn.forwardPort) } // Use a timeout context to prevent hanging if SSH server isn't ready yet - // The action file can be ensured later when actually needed ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -115,72 +102,58 @@ func NewSSHConnector(server shared.Fail2banServer) (Connector, error) { return conn, nil } -// callbackTunnelPort derives the port the reverse tunnel has to forward from -// the callback URL: the explicit port if present, otherwise the scheme default. -func callbackTunnelPort(u *url.URL) int { - if p, err := strconv.Atoi(u.Port()); err == nil && p > 0 && p <= 65535 { - return p - } - switch u.Scheme { - case "https": - return 443 - case "http": - return 80 - } - return 0 -} - // ========================================================================= // Connector Functions // ========================================================================= -func (sc *SSHConnector) ID() string { - return sc.server.ID -} - func (sc *SSHConnector) Server() shared.Fail2banServer { return sc.server } -// Caps how many concurrent SSH sessions the per-jail status fan-out may open over a single multiplexed master connection. (Keeping this below sshd's default MaxSessions (10) avoids "Session open refused by peer"). -const sshFanoutConcurrency = 4 - // Collects jail status for every active remote jail. func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) { - jails, err := sc.getJails(ctx) + summary, err := sc.GetJailSummary(ctx) if err != nil { return nil, err } - sc.warmControlMaster(ctx) - return collectJailInfosLimited(ctx, jails, sc.GetBannedIPs, sshFanoutConcurrency) + return summary.Jails, nil } -// Opens a single SSH session to warm up the ControlMaster socket so it is fully established before any concurrent commands run. -func (sc *SSHConnector) warmControlMaster(ctx context.Context) { - if _, err := sc.runRemoteCommand(ctx, []string{"true"}); err != nil { - debugf("SSH control master warm-up failed for %s: %v", sc.server.Name, err) +// GetJailSummary fetches every jail's banned IPs plus the jail.local integrity state in a single remote command +func (sc *SSHConnector) GetJailSummary(ctx context.Context) (*JailSummary, error) { + script, err := buildBannedSummaryScript(sc.server.SocketPath, JailLocal(sc.getFail2banPath(ctx))) + if err != nil { + return nil, err + } + out, err := sc.runRemoteCommand(ctx, []string{script}) + if err != nil { + return nil, fmt.Errorf("failed to read jail status from %s: %w", sc.server.Name, err) + } + + bannedOut, jailLocal, exists, err := splitBannedSummary(out) + if err != nil { + return nil, fmt.Errorf("%s: %w", sc.server.Name, err) } + infos, err := parseBannedJails(bannedOut) + if err != nil { + return nil, fmt.Errorf("%s: %w (fail2ban 0.11 or newer is required)", sc.server.Name, err) + } + return &JailSummary{ + Jails: infos, + JailLocalExists: exists, + JailLocalManaged: exists && strings.Contains(jailLocal, managedJailLocalMarker), + }, nil } -// Get banned IPs for a given jail. func (sc *SSHConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) { - out, err := sc.runFail2banCommand(ctx, "status", jail) - if err != nil { + if err := ValidateJailName(jail); err != nil { return nil, err } - var bannedIPs []string - lines := strings.Split(out, "\n") - for _, line := range lines { - if strings.Contains(line, "IP list:") { - parts := strings.SplitN(line, ":", 2) - if len(parts) > 1 { - ips := strings.Fields(strings.TrimSpace(parts[1])) - bannedIPs = append(bannedIPs, ips...) - } - break - } + out, err := sc.runFail2banCommand(ctx, "get", jail, "banip") + if err != nil { + return nil, err } - return bannedIPs, nil + return strings.Fields(out), nil } func (sc *SSHConnector) UnbanIP(ctx context.Context, jail, ip string) error { @@ -244,110 +217,33 @@ func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) { return "restart", fmt.Errorf("failed to restart fail2ban via systemd on remote: %w (output: %s)", err, out) } -func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) (string, string, error) { - filterName = strings.TrimSpace(filterName) - if err := ValidateFilterName(filterName); err != nil { - return "", "", err - } - - fail2banPath := sc.getFail2banPath(ctx) - // Try .local first, then fallback to .conf - localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") - confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") - - content, err := sc.readRemoteFile(ctx, localPath) - if err == nil { - return content, localPath, nil - } - - content, err = sc.readRemoteFile(ctx, confPath) - if err != nil { - return "", "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err) - } - return content, confPath, nil -} - -func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content string) error { - filterName = strings.TrimSpace(filterName) - if err := ValidateFilterName(filterName); err != nil { - return err - } - - fail2banPath := sc.getFail2banPath(ctx) - filterDPath := filepath.Join(fail2banPath, "filter.d") - - _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath}) - if err != nil { - return fmt.Errorf("failed to create filter.d directory: %w", err) - } - - // Ensure .local file exists (copy from .conf if needed) - if err := sc.ensureRemoteLocalFile(ctx, filterDPath, filterName); err != nil { - return fmt.Errorf("failed to ensure filter .local file: %w", err) - } - - localPath := filepath.Join(filterDPath, filterName+".local") - if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { - return fmt.Errorf("failed to write filter config: %w", err) - } - - return nil -} - func (sc *SSHConnector) ensureAction(ctx context.Context) error { p := mustProvider() - callbackURL := p.CallbackURL() - actionConfig := p.BuildFail2banActionConfig(callbackURL, sc.server.ID, p.CallbackSecret()) + actionConfig := p.BuildFail2banActionConfig(sc.actionCallbackURL(), sc.server.ID, p.CallbackSecret()) payload := base64.StdEncoding.EncodeToString([]byte(actionConfig)) script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload) scriptB64 := base64.StdEncoding.EncodeToString([]byte(script)) args := sc.buildSSHArgs([]string{"sh", "-s"}) - cmd := exec.CommandContext(ctx, "ssh", args...) - // Set process group to ensure all child processes (including SSH control master) are killed when the context is cancelled. - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setpgid: true, - Pgid: 0, - } - - // Create a script that reads the base64 string from stdin and pipes it through base64 -d | bash. + // The remote shell reads the base64 payload from stdin and pipes it through base64 -d | bash. scriptContent := fmt.Sprintf("cat <<'ENDBASE64' | base64 -d | bash\n%s\nENDBASE64\n", scriptB64) - cmd.Stdin = strings.NewReader(scriptContent) debugf("SSH ensureAction command [%s]: ssh %s (with here-doc via stdin)", sc.server.Name, strings.Join(args, " ")) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Start(); err != nil { - return fmt.Errorf("failed to start ssh command: %w", err) + sc.ensureMasterLazy(ctx) + if err := sc.acquireSession(ctx); err != nil { + return err } + defer sc.releaseSession() - // Monitor context cancellation and command completion - done := make(chan error, 1) - go func() { - done <- cmd.Wait() - }() - - var err error - select { - case err = <-done: - case <-ctx.Done(): - if cmd.Process != nil && cmd.Process.Pid > 0 { - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) - time.Sleep(100 * time.Millisecond) - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - _, _ = cmd.Process.Wait() - } + stdout, stderr, execErr := sc.execSSH(ctx, args, strings.NewReader(scriptContent)) + if execErr != nil && ctx.Err() != nil { return ctx.Err() } - - combinedOutput := append(stdout.Bytes(), stderr.Bytes()...) - output := strings.TrimSpace(string(combinedOutput)) + output, err := selectCommandOutput(stdout, stderr, execErr) if err != nil { - debugf("Failed to ensure action file for server %s: %v (output: %s)", sc.server.Name, err, output) - return fmt.Errorf("failed to ensure action file on remote server %s: %w (remote output: %s)", sc.server.Name, err, output) + debugf("Failed to ensure action file for server %s: %v", sc.server.Name, err) + return fmt.Errorf("failed to ensure action file on remote server %s: %w", sc.server.Name, err) } if marker := extractMissingToolsWarning(output); marker != "" { log.Printf("warning: managed host %s (%s) is missing required tool(s): %s - ban callbacks will arrive empty until installed", @@ -375,31 +271,69 @@ func extractMissingToolsWarning(output string) string { // SSH Helpers // ========================================================================= -func (sc *SSHConnector) getJails(ctx context.Context) ([]string, error) { - out, err := sc.runFail2banCommand(ctx, "status") +func buildBannedSummaryScript(socketPath, jailLocalPath string) (string, error) { + quotedJailLocal, err := quoteRemotePath(jailLocalPath) if err != nil { - return nil, err - } - var jails []string - lines := strings.Split(out, "\n") - for _, line := range lines { - if strings.Contains(line, "Jail list:") { - parts := strings.SplitN(line, ":", 2) - if len(parts) > 1 { - raw := strings.TrimSpace(parts[1]) - jails = strings.Split(raw, ",") - for i := range jails { - jails[i] = strings.TrimSpace(jails[i]) - } - } - } + return "", err } - return jails, nil + sockArg := "" + if socketPath != "" { + quotedSock, err := quoteRemotePath(socketPath) + if err != nil { + return "", err + } + sockArg = "-s " + quotedSock + " " + } + return fmt.Sprintf(`sudo fail2ban-client %sbanned +echo %s +if [ -f %s ]; then echo %s; cat %s; else echo %s; fi +echo %s +`, sockArg, + bannedSectionEnd, + quotedJailLocal, batchJailLocalBegin, quotedJailLocal, batchJailLocalMissing, + batchEnd), nil +} + +func splitBannedSummary(out string) (banned, jailLocal string, jailLocalExists bool, err error) { + var bannedBuf, jailLocalBuf strings.Builder + const ( + inBanned = iota + betweenSections + inJailLocal + ) + mode := inBanned + complete := false + + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + switch { + case trimmed == bannedSectionEnd: + mode = betweenSections + case trimmed == batchJailLocalBegin: + jailLocalExists = true + mode = inJailLocal + case trimmed == batchJailLocalMissing: + jailLocalExists = false + mode = betweenSections + case trimmed == batchEnd: + complete = true + mode = betweenSections + case mode == inBanned: + bannedBuf.WriteString(line) + bannedBuf.WriteString("\n") + case mode == inJailLocal: + jailLocalBuf.WriteString(line) + jailLocalBuf.WriteString("\n") + } + } + if !complete { + return "", "", false, fmt.Errorf("truncated summary output from the remote host") + } + return strings.TrimSpace(bannedBuf.String()), jailLocalBuf.String(), jailLocalExists, nil } func (sc *SSHConnector) runFail2banCommand(ctx context.Context, args ...string) (string, error) { - fail2banArgs := sc.buildFail2banArgs(args...) - cmdArgs := append([]string{"sudo", "fail2ban-client"}, fail2banArgs...) + cmdArgs := append([]string{"sudo", "fail2ban-client"}, fail2banArgs(sc.server.SocketPath, args...)...) return sc.runRemoteCommand(ctx, cmdArgs) } @@ -418,1288 +352,5 @@ func (sc *SSHConnector) isSystemctlUnavailable(output string, err error) bool { func (sc *SSHConnector) checkFail2banHealthyRemote(ctx context.Context) error { out, err := sc.runFail2banCommand(ctx, "ping") - trimmed := strings.TrimSpace(out) - if err != nil { - return fmt.Errorf("remote fail2ban ping error: %w (output: %s)", err, trimmed) - } - if !strings.Contains(strings.ToLower(trimmed), "pong") { - return fmt.Errorf("unexpected remote fail2ban ping output: %s", trimmed) - } - return nil -} - -func (sc *SSHConnector) buildFail2banArgs(args ...string) []string { - if sc.server.SocketPath == "" { - return args - } - base := []string{"-s", sc.server.SocketPath} - return append(base, args...) -} - -func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) (string, error) { - args := sc.buildSSHArgs(command) - cmd := exec.Command("ssh", args...) - cmd.SysProcAttr = &syscall.SysProcAttr{ - Setpgid: true, - Pgid: 0, - } - debugf("SSH command [%s]: ssh %s", sc.server.Name, strings.Join(args, " ")) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Start(); err != nil { - return "", fmt.Errorf("failed to start ssh command: %w", err) - } - done := make(chan error, 1) - go func() { - done <- cmd.Wait() - }() - select { - case err := <-done: - combinedOutput := append(stdout.Bytes(), stderr.Bytes()...) - output := strings.TrimSpace(string(combinedOutput)) - if err != nil { - debugf("SSH command error [%s]: %v | output: %s", sc.server.Name, err, output) - return output, fmt.Errorf("ssh command failed: %w (output: %s)", err, output) - } - debugf("SSH command output [%s]: %s", sc.server.Name, output) - return output, nil - case <-ctx.Done(): - if cmd.Process != nil && cmd.Process.Pid > 0 { - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) - time.Sleep(100 * time.Millisecond) - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - _, _ = cmd.Process.Wait() - } - return "", ctx.Err() - } -} - -func (sc *SSHConnector) buildSSHArgs(command []string) []string { - args := []string{"-o", "BatchMode=yes"} - args = append(args, - "-o", "ConnectTimeout=10", - "-o", "ServerAliveInterval=5", - "-o", "ServerAliveCountMax=2", - ) - if _, container := os.LookupEnv("CONTAINER"); container { - args = append(args, - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - ) - } - controlPath := fmt.Sprintf("/tmp/ssh_control_%s_%s", sc.server.ID, strings.ReplaceAll(sc.server.Host, ".", "_")) - // ControlPersist=0 keeps the master SSH process (and with it the reverse - // tunnel) alive indefinitely; without a tunnel it expires after 300s. - controlPersist := "ControlPersist=300" - if sc.tunnelPort > 0 { - controlPersist = "ControlPersist=0" - } - args = append(args, - "-o", "ControlMaster=auto", - "-o", fmt.Sprintf("ControlPath=%s", controlPath), - "-o", controlPersist, - ) - if sc.tunnelPort > 0 { - tunnelArg := fmt.Sprintf("%d:localhost:%d", sc.tunnelPort, sc.tunnelPort) - args = append(args, "-R", tunnelArg) - debugf("SSH reverse tunnel enabled: -R %s with %s (indefinite)", tunnelArg, controlPersist) - } - if sc.server.SSHKeyPath != "" { - args = append(args, "-i", sc.server.SSHKeyPath) - } - if sc.server.Port > 0 { - args = append(args, "-p", strconv.Itoa(sc.server.Port)) - } - target := sc.server.Host - if sc.server.SSHUser != "" { - target = fmt.Sprintf("%s@%s", sc.server.SSHUser, target) - } - args = append(args, target) - args = append(args, command...) - return args -} - -// ========================================================================= -// Remote File Operations -// ========================================================================= - -// List files in a remote directory using find. -func (sc *SSHConnector) listRemoteFiles(ctx context.Context, directory, pattern string) ([]string, error) { - cmd := fmt.Sprintf(`find "%s" -maxdepth 1 -type f -name "*%s" ! -name ".*" 2>/dev/null | sort`, directory, pattern) - - out, err := sc.runRemoteCommand(ctx, []string{cmd}) - if err != nil { - // If find fails (e.g., directory doesn't exist or permission denied), return empty list. - debugf("Find command failed for %s on server %s: %v, returning empty list", directory, sc.server.Name, err) - return []string{}, nil - } - - var files []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line == "" || line == "." || strings.HasPrefix(line, "./") { - continue - } - if strings.HasSuffix(line, pattern) { - if strings.HasPrefix(line, directory) { - files = append(files, line) - } else if !strings.HasPrefix(line, "/") { - fullPath := filepath.Join(directory, line) - files = append(files, fullPath) - } - } - } - - return files, nil -} - -func (sc *SSHConnector) readRemoteFile(ctx context.Context, filePath string) (string, error) { - content, err := sc.runRemoteCommand(ctx, []string{"cat", filePath}) - if err != nil { - return "", fmt.Errorf("failed to read remote file %s: %w", filePath, err) - } - return content, nil -} - -func (sc *SSHConnector) writeRemoteFile(ctx context.Context, filePath, content string) error { - escaped := strings.ReplaceAll(content, "'", "'\"'\"'") - - script := fmt.Sprintf(`cat > '%s' <<'REMOTEEOF' -%s -REMOTEEOF -`, filePath, escaped) - - _, err := sc.runRemoteCommand(ctx, []string{script}) - if err != nil { - return fmt.Errorf("failed to write remote file %s: %w", filePath, err) - } - return nil -} - -func (sc *SSHConnector) ensureRemoteLocalFile(ctx context.Context, basePath, name string) error { - localPath := fmt.Sprintf("%s/%s.local", basePath, name) - confPath := fmt.Sprintf("%s/%s.conf", basePath, name) - - if err := ValidateFilterName(name); err != nil { - return fmt.Errorf("invalid config name %q: %w", name, err) - } - - script := fmt.Sprintf(` - if [ ! -f "%s" ]; then - if [ -f "%s" ]; then - cp "%s" "%s" - else - # Create empty .local file if neither exists - touch "%s" - fi - fi - `, localPath, confPath, confPath, localPath, localPath) - - _, err := sc.runRemoteCommand(ctx, []string{script}) - if err != nil { - return fmt.Errorf("failed to ensure remote .local file %s: %w", localPath, err) - } - return nil -} - -// Returns /config/fail2ban for linuxserver images, /etc/fail2ban otherwise. Cached to avoid repeated SSH calls. -func (sc *SSHConnector) getFail2banPath(ctx context.Context) string { - sc.pathMutex.RLock() - if sc.pathCached { - path := sc.fail2banPath - sc.pathMutex.RUnlock() - return path - } - sc.pathMutex.RUnlock() - - sc.pathMutex.Lock() - defer sc.pathMutex.Unlock() - - if sc.pathCached { - return sc.fail2banPath - } - - checkCmd := `test -d "/config/fail2ban" && echo "/config/fail2ban" || (test -d "/etc/fail2ban" && echo "/etc/fail2ban" || echo "/etc/fail2ban")` - out, err := sc.runRemoteCommand(ctx, []string{checkCmd}) - if err == nil { - path := strings.TrimSpace(out) - if path != "" { - sc.fail2banPath = path - sc.pathCached = true - return path - } - } - sc.fail2banPath = "/etc/fail2ban" - sc.pathCached = true - return sc.fail2banPath -} - -// ========================================================================= -// Jail Operations -// ========================================================================= - -func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { - fail2banPath := sc.getFail2banPath(ctx) - jailDPath := filepath.Join(fail2banPath, "jail.d") - - var allJails []JailInfo - processedJails := make(map[string]bool) - - readAllScript := fmt.Sprintf(`python3 << 'PYEOF' -import os -import sys -import json - -jail_d_path = %q -files_data = {} - -# Read all .local files first -local_files = [] -if os.path.isdir(jail_d_path): - for filename in os.listdir(jail_d_path): - if filename.endswith('.local') and not filename.startswith('.'): - local_files.append(os.path.join(jail_d_path, filename)) - -# Process .local files -for filepath in sorted(local_files): - try: - filename = os.path.basename(filepath) - basename = filename[:-6] # Remove .local - if basename and basename not in files_data: - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - files_data[basename] = {'path': filepath, 'content': content, 'type': 'local'} - except Exception as e: - sys.stderr.write(f"Error reading {filepath}: {e}\n") - -# Read all .conf files that don't have corresponding .local files -conf_files = [] -if os.path.isdir(jail_d_path): - for filename in os.listdir(jail_d_path): - if filename.endswith('.conf') and not filename.startswith('.'): - basename = filename[:-5] # Remove .conf - if basename not in files_data: - conf_files.append(os.path.join(jail_d_path, filename)) - -# Process .conf files -for filepath in sorted(conf_files): - try: - filename = os.path.basename(filepath) - basename = filename[:-5] # Remove .conf - if basename: - with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: - content = f.read() - files_data[basename] = {'path': filepath, 'content': content, 'type': 'conf'} - except Exception as e: - sys.stderr.write(f"Error reading {filepath}: {e}\n") - -# Output files with a delimiter: FILE_START:path:type\ncontent\nFILE_END\n -for basename, data in sorted(files_data.items()): - print(f"FILE_START:{data['path']}:{data['type']}") - print(data['content'], end='') - print("FILE_END") -PYEOF`, jailDPath) - - output, err := sc.runRemoteCommand(ctx, []string{readAllScript}) - if err != nil { - debugf("Failed to read all jail files at once on server %s, falling back to individual reads: %v", sc.server.Name, err) - return sc.getAllJailsFallback(ctx, jailDPath) - } - var currentFile string - var currentContent strings.Builder - inFile := false - - lines := strings.Split(output, "\n") - for _, line := range lines { - if strings.HasPrefix(line, "FILE_START:") { - if inFile && currentFile != "" { - content := currentContent.String() - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } - } - parts := strings.SplitN(line, ":", 3) - if len(parts) == 3 { - currentFile = parts[1] - currentContent.Reset() - inFile = true - } - } else if line == "FILE_END" { - if inFile && currentFile != "" { - content := currentContent.String() - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } - } - inFile = false - currentFile = "" - currentContent.Reset() - } else if inFile { - if currentContent.Len() > 0 { - currentContent.WriteString("\n") - } - currentContent.WriteString(line) - } - } - - if inFile && currentFile != "" { - content := currentContent.String() - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } - } - - return allJails, nil -} - -func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath string) ([]JailInfo, error) { - var allJails []JailInfo - processedFiles := make(map[string]bool) - processedJails := make(map[string]bool) - - localFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".local") - if err != nil { - debugf("Failed to list .local files in jail.d on server %s: %v", sc.server.Name, err) - } else { - for _, filePath := range localFiles { - filename := filepath.Base(filePath) - baseName := strings.TrimSuffix(filename, ".local") - if baseName == "" || processedFiles[baseName] { - continue - } - processedFiles[baseName] = true - - content, err := sc.readRemoteFile(ctx, filePath) - if err != nil { - debugf("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err) - continue - } - - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } - } - } - - confFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".conf") - if err != nil { - debugf("Failed to list .conf files in jail.d on server %s: %v", sc.server.Name, err) - } else { - for _, filePath := range confFiles { - filename := filepath.Base(filePath) - baseName := strings.TrimSuffix(filename, ".conf") - if baseName == "" || processedFiles[baseName] { - continue - } - processedFiles[baseName] = true - - content, err := sc.readRemoteFile(ctx, filePath) - if err != nil { - debugf("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err) - continue - } - - jails := parseJailConfigContent(content) - for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { - allJails = append(allJails, jail) - processedJails[jail.JailName] = true - } - } - } - } - return allJails, nil -} - -func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { - fail2banPath := sc.getFail2banPath(ctx) - jailDPath := filepath.Join(fail2banPath, "jail.d") - - // Update each jail in its own .local file - for jailName, enabled := range updates { - jailName = strings.TrimSpace(jailName) - if jailName == "" { - debugf("Skipping empty jail name in updates map") - continue - } - - localPath := filepath.Join(jailDPath, jailName+".local") - confPath := filepath.Join(jailDPath, jailName+".conf") - - combinedScript := fmt.Sprintf(` - if [ ! -f "%s" ]; then - if [ -f "%s" ]; then - cp "%s" "%s" - else - echo "[%s]" > "%s" - fi - fi - cat "%s" - `, localPath, confPath, confPath, localPath, jailName, localPath, localPath) - - content, err := sc.runRemoteCommand(ctx, []string{combinedScript}) - if err != nil { - return fmt.Errorf("failed to ensure and read .local file for jail %s: %w", jailName, err) - } - - lines := strings.Split(content, "\n") - var outputLines []string - var foundEnabled bool - var currentJail string - - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - currentJail = strings.Trim(trimmed, "[]") - outputLines = append(outputLines, line) - } else if strings.HasPrefix(strings.ToLower(trimmed), "enabled") { - if currentJail == jailName { - outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) - foundEnabled = true - } else { - outputLines = append(outputLines, line) - } - } else { - outputLines = append(outputLines, line) - } - } - - if !foundEnabled { - var newLines []string - for i, line := range outputLines { - newLines = append(newLines, line) - if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { - newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) - if i+1 < len(outputLines) { - newLines = append(newLines, outputLines[i+1:]...) - } - break - } - } - if len(newLines) > len(outputLines) { - outputLines = newLines - } else { - outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) - } - } - - newContent := strings.Join(outputLines, "\n") - cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, newContent) - if _, err := sc.runRemoteCommand(ctx, []string{cmd}); err != nil { - return fmt.Errorf("failed to write jail .local file %s: %w", localPath, err) - } - } - return nil -} - -func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { - fail2banPath := sc.getFail2banPath(ctx) - filterDPath := filepath.Join(fail2banPath, "filter.d") - filterMap := make(map[string]bool) - processedFiles := make(map[string]bool) - shouldExclude := func(filename string) bool { - if strings.HasSuffix(filename, ".bak") || - strings.HasSuffix(filename, "~") || - strings.HasSuffix(filename, ".old") || - strings.HasSuffix(filename, ".rpmnew") || - strings.HasSuffix(filename, ".rpmsave") || - strings.Contains(filename, "README") { - return true - } - return false - } - - localFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".local") - if err != nil { - debugf("Failed to list .local filters on server %s: %v", sc.server.Name, err) - } else { - for _, filePath := range localFiles { - filename := filepath.Base(filePath) - if shouldExclude(filename) { - continue - } - baseName := strings.TrimSuffix(filename, ".local") - if baseName == "" || processedFiles[baseName] { - continue - } - processedFiles[baseName] = true - filterMap[baseName] = true - } - } - - confFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".conf") - if err != nil { - debugf("Failed to list .conf filters on server %s: %v", sc.server.Name, err) - } else { - for _, filePath := range confFiles { - filename := filepath.Base(filePath) - if shouldExclude(filename) { - continue - } - baseName := strings.TrimSuffix(filename, ".conf") - if baseName == "" || processedFiles[baseName] { - continue - } - processedFiles[baseName] = true - filterMap[baseName] = true - } - } - - var filters []string - for name := range filterMap { - filters = append(filters, name) - } - sort.Strings(filters) - - return filters, nil -} - -// ========================================================================= -// Filter Include Resolution -// ========================================================================= - -func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterContent string, filterDPath string, currentFilterName string) (string, error) { - lines := strings.Split(filterContent, "\n") - var beforeFiles []string - var afterFiles []string - var inIncludesSection bool - var mainContent strings.Builder - - // Parse the filter content to find [INCLUDES] section - for i, line := range lines { - trimmed := strings.TrimSpace(line) - - if strings.HasPrefix(trimmed, "[INCLUDES]") { - inIncludesSection = true - continue - } - - // Check for end of [INCLUDES] section (next section starts) - if inIncludesSection && strings.HasPrefix(trimmed, "[") { - inIncludesSection = false - } - - // Parse before and after directives - if inIncludesSection { - if strings.HasPrefix(strings.ToLower(trimmed), "before") { - parts := strings.SplitN(trimmed, "=", 2) - if len(parts) == 2 { - file := strings.TrimSpace(parts[1]) - if file != "" { - beforeFiles = append(beforeFiles, file) - } - } - continue - } - if strings.HasPrefix(strings.ToLower(trimmed), "after") { - parts := strings.SplitN(trimmed, "=", 2) - if len(parts) == 2 { - file := strings.TrimSpace(parts[1]) - if file != "" { - afterFiles = append(afterFiles, file) - } - } - continue - } - } - if !inIncludesSection { - if i > 0 { - mainContent.WriteString("\n") - } - mainContent.WriteString(line) - } - } - - // Extract variables from main filter content first - mainContentStr := mainContent.String() - mainVariables := extractVariablesFromContent(mainContentStr) - - // Build combined content: before files + main filter + after files - var combined strings.Builder - - // Helper function to read remote file - readRemoteFilterFile := func(baseName string) (string, error) { - localPath := filepath.Join(filterDPath, baseName+".local") - confPath := filepath.Join(filterDPath, baseName+".conf") - - content, err := sc.readRemoteFile(ctx, localPath) - if err == nil { - debugf("Loading included filter file from .local: %s", localPath) - return content, nil - } - - content, err = sc.readRemoteFile(ctx, confPath) - if err == nil { - debugf("Loading included filter file from .conf: %s", confPath) - return content, nil - } - - return "", fmt.Errorf("could not load included filter file '%s' or '%s'", localPath, confPath) - } - - for _, fileName := range beforeFiles { - // Remove any existing extension to get base name - baseName := fileName - if strings.HasSuffix(baseName, ".local") { - baseName = strings.TrimSuffix(baseName, ".local") - } else if strings.HasSuffix(baseName, ".conf") { - baseName = strings.TrimSuffix(baseName, ".conf") - } - - if baseName == currentFilterName { - debugf("Skipping self-inclusion of filter '%s' in before files", baseName) - continue - } - - contentStr, err := readRemoteFilterFile(baseName) - if err != nil { - debugf("Warning: %v", err) - continue - } - - cleanedContent := removeDuplicateVariables(contentStr, mainVariables) - combined.WriteString(cleanedContent) - if !strings.HasSuffix(cleanedContent, "\n") { - combined.WriteString("\n") - } - combined.WriteString("\n") - } - - combined.WriteString(mainContentStr) - if !strings.HasSuffix(mainContentStr, "\n") { - combined.WriteString("\n") - } - - for _, fileName := range afterFiles { - baseName := fileName - if strings.HasSuffix(baseName, ".local") { - baseName = strings.TrimSuffix(baseName, ".local") - } else if strings.HasSuffix(baseName, ".conf") { - baseName = strings.TrimSuffix(baseName, ".conf") - } - - // Note: Self-inclusion in "after" directive is intentional in fail2ban - // (e.g., after = apache-common.local is standard pattern for .local files) - - contentStr, err := readRemoteFilterFile(baseName) - if err != nil { - debugf("Warning: %v", err) - continue - } - - cleanedContent := removeDuplicateVariables(contentStr, mainVariables) - combined.WriteString("\n") - combined.WriteString(cleanedContent) - if !strings.HasSuffix(cleanedContent, "\n") { - combined.WriteString("\n") - } - } - - return combined.String(), nil -} - -func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { - cleaned := normalizeLogLines(logLines) - if len(cleaned) == 0 { - return "No log lines provided.\n", "", nil - } - - // Enforce the shared strict allowlist (blocks path traversal and shell - // metacharacters) rather than the previous ad-hoc string stripping. - filterName = strings.TrimSpace(filterName) - if err := ValidateFilterName(filterName); err != nil { - return "", "", err - } - - fail2banPath := sc.getFail2banPath(ctx) - localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") - confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") - - const heredocMarker = "F2B_FILTER_TEST_LOG" - const filterContentMarker = "F2B_FILTER_CONTENT" - logContent := strings.Join(cleaned, "\n") - - var script string - if filterContent != "" { - filterDPath := filepath.Join(fail2banPath, "filter.d") - resolvedContent, err := sc.resolveFilterIncludesRemote(ctx, filterContent, filterDPath, filterName) - if err != nil { - debugf("Warning: failed to resolve filter includes remotely, using original content: %v", err) - resolvedContent = filterContent - } - - // Ensure it ends with a newline. - if !strings.HasSuffix(resolvedContent, "\n") { - resolvedContent += "\n" - } - resolvedContentB64 := base64.StdEncoding.EncodeToString([]byte(resolvedContent)) - script = fmt.Sprintf(` -set -e -TMPFILTER=$(mktemp /tmp/fail2ban-filter-XXXXXX.conf) -trap 'rm -f "$TMPFILTER"' EXIT - -# Write resolved filter content to temp file using base64 decode -echo '%[1]s' | base64 -d > "$TMPFILTER" - -FILTER_PATH="$TMPFILTER" -echo "FILTER_PATH:$FILTER_PATH" -TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log) -trap 'rm -f "$TMPFILE" "$TMPFILTER"' EXIT -cat <<'%[2]s' > "$TMPFILE" -%[3]s -%[2]s -fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true -`, resolvedContentB64, heredocMarker, logContent) - } else { - script = fmt.Sprintf(` -set -e -LOCAL_PATH=%[1]q -CONF_PATH=%[2]q -FILTER_PATH="" -if [ -f "$LOCAL_PATH" ]; then - FILTER_PATH="$LOCAL_PATH" -elif [ -f "$CONF_PATH" ]; then - FILTER_PATH="$CONF_PATH" -else - echo "Filter not found: checked both $LOCAL_PATH and $CONF_PATH" >&2 - exit 1 -fi -echo "FILTER_PATH:$FILTER_PATH" -TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log) -trap 'rm -f "$TMPFILE"' EXIT -cat <<'%[3]s' > "$TMPFILE" -%[4]s -%[3]s -fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true -`, localPath, confPath, heredocMarker, logContent) - } - - out, err := sc.runRemoteCommand(ctx, []string{script}) - if err != nil { - return "", "", err - } - - // Extract filter path from output. - lines := strings.Split(out, "\n") - var filterPath string - var outputLines []string - foundPathMarker := false - - for _, line := range lines { - if strings.HasPrefix(line, "FILTER_PATH:") { - filterPath = strings.TrimPrefix(line, "FILTER_PATH:") - filterPath = strings.TrimSpace(filterPath) - foundPathMarker = true - continue - } - outputLines = append(outputLines, line) - } - - // If we didn't find FILTER_PATH marker, try to determine it - if !foundPathMarker || filterPath == "" { - // Check which file exists remotely - localOut, localErr := sc.runRemoteCommand(ctx, []string{"test", "-f", localPath, "&&", "echo", localPath, "||", "echo", ""}) - if localErr == nil && strings.TrimSpace(localOut) != "" { - filterPath = strings.TrimSpace(localOut) - } else { - filterPath = confPath - } - } - - output := strings.Join(outputLines, "\n") - return output, filterPath, nil -} - -func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) { - jail = strings.TrimSpace(jail) - if err := ValidateJailName(jail); err != nil { - return "", "", err - } - - fail2banPath := sc.getFail2banPath(ctx) - // Try .local first, then fallback to .conf - localPath := filepath.Join(fail2banPath, "jail.d", jail+".local") - confPath := filepath.Join(fail2banPath, "jail.d", jail+".conf") - - content, err := sc.readRemoteFile(ctx, localPath) - if err == nil { - return content, localPath, nil - } - - content, err = sc.readRemoteFile(ctx, confPath) - if err != nil { - return fmt.Sprintf("[%s]\n", jail), localPath, nil - } - return content, confPath, nil -} - -func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) error { - jail = strings.TrimSpace(jail) - if err := ValidateJailName(jail); err != nil { - return err - } - - fail2banPath := sc.getFail2banPath(ctx) - jailDPath := filepath.Join(fail2banPath, "jail.d") - _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath}) - if err != nil { - return fmt.Errorf("failed to create jail.d directory: %w", err) - } - if err := sc.ensureRemoteLocalFile(ctx, jailDPath, jail); err != nil { - return fmt.Errorf("failed to ensure .local file for jail %s: %w", jail, err) - } - localPath := filepath.Join(jailDPath, jail+".local") - if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { - return fmt.Errorf("failed to write jail config: %w", err) - } - - return nil -} - -func (sc *SSHConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) { - if logpath == "" { - return []string{}, nil - } - - logpath = strings.TrimSpace(logpath) - hasWildcard := strings.ContainsAny(logpath, "*?[") - - var script string - if hasWildcard { - script = fmt.Sprintf(` -set -e -LOGPATH=%q -# Use find for glob patterns -find $(dirname "$LOGPATH") -maxdepth 1 -path "$LOGPATH" -type f 2>/dev/null | sort -`, logpath) - } else { - script = fmt.Sprintf(` -set -e -LOGPATH=%q -if [ -d "$LOGPATH" ]; then - find "$LOGPATH" -maxdepth 1 -type f 2>/dev/null | sort -elif [ -f "$LOGPATH" ]; then - echo "$LOGPATH" -fi -`, logpath) - } - - out, err := sc.runRemoteCommand(ctx, []string{script}) - if err != nil { - return []string{}, nil - } - - var matches []string - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line != "" { - matches = append(matches, line) - } - } - return matches, nil -} - -func (sc *SSHConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) { - originalPath = strings.TrimSpace(logpath) - if originalPath == "" { - return originalPath, "", []string{}, nil - } - resolveScript := fmt.Sprintf(`python3 - <<'PYEOF' -import os -import re -import glob -from pathlib import Path - -def extract_variables(s): - """Extract all variable names from a string.""" - pattern = r'%%\(([^)]+)\)s' - return re.findall(pattern, s) - -def find_variable_definition(var_name, fail2ban_path="/etc/fail2ban"): - """Search for variable definition in all .conf files.""" - var_name_lower = var_name.lower() - - for conf_file in Path(fail2ban_path).rglob("*.conf"): - try: - with open(conf_file, 'r') as f: - current_var = None - current_value = [] - in_multiline = False - - for line in f: - original_line = line - line = line.strip() - - if not in_multiline: - if '=' in line and not line.startswith('#'): - parts = line.split('=', 1) - key = parts[0].strip() - value = parts[1].strip() - - if key.lower() == var_name_lower: - current_var = key - current_value = [value] - in_multiline = True - continue - else: - # Check if continuation or new variable/section - if line.startswith('[') or (not line.startswith(' ') and '=' in line and not line.startswith('\t')): - # End of multi-line - return ' '.join(current_value) - else: - # Continuation - current_value.append(line) - - if in_multiline and current_var: - return ' '.join(current_value) - except: - continue - - return None - -def resolve_variable_recursive(var_name, visited=None): - """Resolve variable recursively.""" - if visited is None: - visited = set() - - if var_name in visited: - raise ValueError(f"Circular reference detected for variable '{var_name}'") - - visited.add(var_name) - - try: - value = find_variable_definition(var_name) - if value is None: - raise ValueError(f"Variable '{var_name}' not found") - - # Check for nested variables - nested_vars = extract_variables(value) - if not nested_vars: - return value - - # Resolve nested variables - resolved = value - for nested_var in nested_vars: - nested_value = resolve_variable_recursive(nested_var, visited.copy()) - pattern = f'%%\\({re.escape(nested_var)}\\)s' - resolved = re.sub(pattern, nested_value, resolved) - - return resolved - finally: - visited.discard(var_name) - -def resolve_logpath(logpath): - """Resolve all variables in logpath.""" - variables = extract_variables(logpath) - if not variables: - return logpath - - resolved = logpath - for var_name in variables: - var_value = resolve_variable_recursive(var_name) - pattern = f'%%\\({re.escape(var_name)}\\)s' - resolved = re.sub(pattern, var_value, resolved) - - return resolved - -# Main -logpath = %q -try: - resolved = resolve_logpath(logpath) - print(f"RESOLVED:{resolved}") -except Exception as e: - print(f"ERROR:{str(e)}") - exit(1) -PYEOF -`, originalPath) - - resolveOut, err := sc.runRemoteCommand(ctx, []string{resolveScript}) - if err != nil { - return originalPath, "", nil, fmt.Errorf("failed to resolve variables: %w", err) - } - - resolveOut = strings.TrimSpace(resolveOut) - if strings.HasPrefix(resolveOut, "ERROR:") { - return originalPath, "", nil, errors.New(strings.TrimPrefix(resolveOut, "ERROR:")) - } - if strings.HasPrefix(resolveOut, "RESOLVED:") { - resolvedPath = strings.TrimPrefix(resolveOut, "RESOLVED:") - } else { - resolvedPath = originalPath - } - files, err = sc.TestLogpath(ctx, resolvedPath) - if err != nil { - return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err) - } - - return originalPath, resolvedPath, files, nil -} - -func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context) error { - return sc.EnsureJailLocalStructure(ctx) -} - -func (sc *SSHConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) { - jailLocalPath := sc.getFail2banPath(ctx) + "/jail.local" - output, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) - if err != nil { - if strings.Contains(err.Error(), "No such file") || strings.Contains(output, "No such file") { - return false, false, nil - } - return false, false, fmt.Errorf("failed to read jail.local on %s: %w", sc.server.Name, err) - } - hasUIAction := strings.Contains(output, "ui-custom-action") - return true, hasUIAction, nil -} - -func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { - jailLocalPath := sc.getFail2banPath(ctx) + "/jail.local" - - exists, hasUI, chkErr := sc.CheckJailLocalIntegrity(ctx) - if chkErr != nil { - debugf("Warning: could not check jail.local integrity on %s: %v", sc.server.Name, chkErr) - } - if exists && !hasUI { - debugf("jail.local on server %s exists but is not managed by Fail2ban-UI - skipping overwrite", sc.server.Name) - return nil - } - - // Run experimental migration if enabled - if isJailAutoMigrationEnabled() { - debugf("JAIL_AUTOMIGRATION=true: running experimental jail.local -> jail.d/ migration for SSH server %s", sc.server.Name) - if err := sc.MigrateJailsFromJailLocalRemote(ctx); err != nil { - return fmt.Errorf("failed to migrate legacy jails from jail.local on remote server %s: %w", sc.server.Name, err) - } - } - - // Build content using the shared helper. - content := mustProvider().BuildJailLocalContent() - - // Escape single quotes for safe use in a single-quoted heredoc - escaped := strings.ReplaceAll(content, "'", "'\"'\"'") - - // Write the rebuilt content via heredoc over SSH. The path is quoted so a - // resolved fail2ban root containing shell metacharacters cannot break out. - writeScript := fmt.Sprintf(`cat > '%s' <<'JAILLOCAL' -%s -JAILLOCAL -`, jailLocalPath, escaped) - - _, err := sc.runRemoteCommand(ctx, []string{writeScript}) - return err -} - -// Migrate jail.local to jail.d/*.local. EXPERIMENTAL, only when JAIL_AUTOMIGRATION=true. -func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) error { - jailLocalPath := "/etc/fail2ban/jail.local" - jailDPath := "/etc/fail2ban/jail.d" - - checkScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailLocalPath) - out, err := sc.runRemoteCommand(ctx, []string{checkScript}) - if err != nil || strings.TrimSpace(out) != "exists" { - debugf("No jails to migrate from jail.local on server %s (file does not exist)", sc.server.Name) - return nil - } - - content, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) - if err != nil { - return fmt.Errorf("failed to read jail.local on server %s: %w", sc.server.Name, err) - } - - sections, defaultContent, err := parseJailSectionsUncommented(content) - if err != nil { - return fmt.Errorf("failed to parse jail.local on server %s: %w", sc.server.Name, err) - } - - if len(sections) == 0 { - debugf("No jails to migrate from jail.local on remote system") - return nil - } - - backupPath := jailLocalPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix()) - backupScript := fmt.Sprintf("cp %s %s", jailLocalPath, backupPath) - if _, err := sc.runRemoteCommand(ctx, []string{backupScript}); err != nil { - return fmt.Errorf("failed to create backup on server %s: %w", sc.server.Name, err) - } - debugf("Created backup of jail.local at %s on server %s", backupPath, sc.server.Name) - - ensureDirScript := fmt.Sprintf("mkdir -p %s", jailDPath) - if _, err := sc.runRemoteCommand(ctx, []string{ensureDirScript}); err != nil { - return fmt.Errorf("failed to create jail.d directory on server %s: %w", sc.server.Name, err) - } - - migratedCount := 0 - for jailName, jailContent := range sections { - if jailName == "" { - continue - } - - jailFilePath := fmt.Sprintf("%s/%s.local", jailDPath, jailName) - - checkFileScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailFilePath) - fileOut, err := sc.runRemoteCommand(ctx, []string{checkFileScript}) - if err == nil && strings.TrimSpace(fileOut) == "exists" { - debugf("Skipping migration for jail %s on server %s: .local file already exists", jailName, sc.server.Name) - continue - } - - escapedContent := strings.ReplaceAll(jailContent, "'", "'\"'\"'") - writeScript := fmt.Sprintf(`cat > %s <<'JAILEOF' -%s -JAILEOF -`, jailFilePath, escapedContent) - if _, err := sc.runRemoteCommand(ctx, []string{writeScript}); err != nil { - return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) - } - debugf("Migrated jail %s to %s on server %s", jailName, jailFilePath, sc.server.Name) - migratedCount++ - } - - if migratedCount > 0 { - escapedDefault := strings.ReplaceAll(defaultContent, "'", "'\"'\"'") - writeLocalScript := fmt.Sprintf(`cat > %s <<'LOCALEOF' -%s -LOCALEOF -`, jailLocalPath, escapedDefault) - if _, err := sc.runRemoteCommand(ctx, []string{writeLocalScript}); err != nil { - return fmt.Errorf("failed to rewrite jail.local: %w", err) - } - debugf("Migration completed on server %s: moved %d jails to jail.d/", sc.server.Name, migratedCount) - } - - return nil -} - -func (sc *SSHConnector) CreateJail(ctx context.Context, jailName, content string) error { - if err := ValidateJailName(jailName); err != nil { - return err - } - fail2banPath := sc.getFail2banPath(ctx) - jailDPath := filepath.Join(fail2banPath, "jail.d") - - trimmed := strings.TrimSpace(content) - expectedSection := fmt.Sprintf("[%s]", jailName) - if !strings.HasPrefix(trimmed, expectedSection) { - content = expectedSection + "\n" + content - } - - localPath := filepath.Join(jailDPath, jailName+".local") - if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { - return fmt.Errorf("failed to create jail file: %w", err) - } - - return nil -} - -func (sc *SSHConnector) DeleteJail(ctx context.Context, jailName string) error { - if err := ValidateJailName(jailName); err != nil { - return err - } - fail2banPath := sc.getFail2banPath(ctx) - localPath := filepath.Join(fail2banPath, "jail.d", jailName+".local") - confPath := filepath.Join(fail2banPath, "jail.d", jailName+".conf") - - _, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath}) - if err != nil { - return fmt.Errorf("failed to delete jail files %s or %s: %w", localPath, confPath, err) - } - - return nil -} - -func (sc *SSHConnector) CreateFilter(ctx context.Context, filterName, content string) error { - if err := ValidateFilterName(filterName); err != nil { - return err - } - fail2banPath := sc.getFail2banPath(ctx) - filterDPath := filepath.Join(fail2banPath, "filter.d") - - _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", filterDPath}) - if err != nil { - return fmt.Errorf("failed to create filter.d directory: %w", err) - } - - localPath := filepath.Join(filterDPath, filterName+".local") - if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { - return fmt.Errorf("failed to create filter file: %w", err) - } - return nil -} - -func (sc *SSHConnector) DeleteFilter(ctx context.Context, filterName string) error { - if err := ValidateFilterName(filterName); err != nil { - return err - } - - fail2banPath := sc.getFail2banPath(ctx) - localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") - confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") - - _, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath}) - if err != nil { - return fmt.Errorf("failed to delete filter files %s or %s: %w", localPath, confPath, err) - } - return nil -} - -// ========================================================================= -// Config Parsing -// ========================================================================= - -func parseJailConfigContent(content string) []JailInfo { - var jails []JailInfo - scanner := bufio.NewScanner(strings.NewReader(content)) - var currentJail string - enabled := true - - ignoredSections := map[string]bool{ - "DEFAULT": true, - "INCLUDES": true, - } - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - if currentJail != "" && !ignoredSections[currentJail] { - jails = append(jails, JailInfo{ - JailName: currentJail, - Enabled: enabled, - }) - } - currentJail = strings.Trim(line, "[]") - enabled = true - } else if strings.HasPrefix(strings.ToLower(line), "enabled") { - parts := strings.Split(line, "=") - if len(parts) == 2 { - value := strings.TrimSpace(parts[1]) - enabled = strings.EqualFold(value, "true") - } - } - } - if currentJail != "" && !ignoredSections[currentJail] { - jails = append(jails, JailInfo{ - JailName: currentJail, - Enabled: enabled, - }) - } - return jails + return checkPingOutput(out, err, "remote fail2ban") } diff --git a/internal/fail2ban/connector_ssh_config.go b/internal/fail2ban/connector_ssh_config.go new file mode 100644 index 0000000..3d0ddbc --- /dev/null +++ b/internal/fail2ban/connector_ssh_config.go @@ -0,0 +1,521 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Remote Fail2ban configuration management: jails, filters, logpath and +// filter testing, and the managed jail.local structure. +package fail2ban + +import ( + "context" + "encoding/base64" + "fmt" + "path/filepath" + "strings" +) + +func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) (string, string, error) { + filterName = strings.TrimSpace(filterName) + if err := ValidateFilterName(filterName); err != nil { + return "", "", err + } + + fail2banPath := sc.getFail2banPath(ctx) + content, path, err := sc.readRemoteWithLocalFallback(ctx, FilterDir(fail2banPath), filterName) + if err != nil { + return "", "", fmt.Errorf("failed to read remote filter config (tried .local and .conf): %w", err) + } + return content, path, nil +} + +// Writes dir/name.local, seeding it from the shipped .conf first so the override keeps whatever the distribution provided +func (sc *SSHConnector) writeConfigOverride(ctx context.Context, dir, name, content, kind string) error { + if _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", dir}); err != nil { + return fmt.Errorf("failed to create %s directory: %w", filepath.Base(dir), err) + } + if err := sc.ensureRemoteLocalFile(ctx, dir, name); err != nil { + return fmt.Errorf("failed to ensure .local file for %s %s: %w", kind, name, err) + } + if err := sc.writeRemoteFile(ctx, filepath.Join(dir, name+".local"), content); err != nil { + return fmt.Errorf("failed to write %s config: %w", kind, err) + } + return nil +} + +func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content string) error { + filterName = strings.TrimSpace(filterName) + if err := ValidateFilterName(filterName); err != nil { + return err + } + return sc.writeConfigOverride(ctx, FilterDir(sc.getFail2banPath(ctx)), filterName, content, "filter") +} + +// ========================================================================= +// Jail Operations +// ========================================================================= + +// Accumulates jails parsed from jail.d file contents, letting .local definitions override .conf ones (and same-type re-definitions win). +type jailAccumulator struct { + jails []JailInfo + index map[string]int + source map[string]string +} + +func newJailAccumulator() *jailAccumulator { + return &jailAccumulator{index: make(map[string]int), source: make(map[string]string)} +} + +func (a *jailAccumulator) add(content, fileType string) { + for _, jail := range parseJailConfigContent(content) { + if jail.JailName == "" || jail.JailName == "DEFAULT" { + continue + } + idx, seen := a.index[jail.JailName] + switch { + case !seen: + a.index[jail.JailName] = len(a.jails) + a.source[jail.JailName] = fileType + a.jails = append(a.jails, jail) + case fileType == "local" || a.source[jail.JailName] == fileType: + a.jails[idx].Enabled = jail.Enabled + a.source[jail.JailName] = fileType + } + } +} + +func buildJailDirDumpScript(jailDPath string) (string, error) { + quotedDir, err := quoteRemotePath(jailDPath) + if err != nil { + return "", err + } + return fmt.Sprintf(`for f in %[1]s/*.local; do + if [ -f "$f" ]; then + echo "%[2]s$f" + cat "$f" + echo "%[3]s" + fi +done +for f in %[1]s/*.conf; do + if [ -f "$f" ] && [ ! -f "${f%%.conf}.local" ]; then + echo "%[2]s$f" + cat "$f" + echo "%[3]s" + fi +done +`, quotedDir, batchFileBegin, batchFileEnd), nil +} + +// Classifies a dumped jail.d file by extension for the .local-wins +func jailFileType(path string) string { + if strings.HasSuffix(path, ".local") { + return "local" + } + return "conf" +} + +func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { + jailDPath := JailDir(sc.getFail2banPath(ctx)) + script, err := buildJailDirDumpScript(jailDPath) + if err != nil { + return nil, err + } + output, err := sc.runRemoteCommand(ctx, []string{script}) + if err != nil { + return nil, fmt.Errorf("failed to read jail definitions from %s on %s: %w", jailDPath, sc.server.Name, err) + } + + acc := newJailAccumulator() + for _, file := range parseRemoteFileDump(output) { + acc.add(file.content, jailFileType(file.path)) + } + return acc.jails, nil +} + +func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { + fail2banPath := sc.getFail2banPath(ctx) + jailDPath := JailDir(fail2banPath) + + for jailName, enabled := range updates { + jailName = strings.TrimSpace(jailName) + if jailName == "" { + debugf("Skipping empty jail name in updates map") + continue + } + if err := ValidateJailName(jailName); err != nil { + return fmt.Errorf("invalid jail name in updates map: %w", err) + } + + localPath := filepath.Join(jailDPath, jailName+".local") + confPath := filepath.Join(jailDPath, jailName+".conf") + findScript := fmt.Sprintf(` + files=$(grep -lxF '[%s]' %s/*.local 2>/dev/null || true) + if [ -z "$files" ]; then + if [ -f "%s" ]; then + cp "%s" "%s" + else + echo "[%s]" > "%s" + fi + files="%s" + fi + for f in $files; do + echo "%s$f" + cat "$f" + echo "%s" + done + `, jailName, jailDPath, confPath, confPath, localPath, jailName, localPath, localPath, + batchFileBegin, batchFileEnd) + + dump, err := sc.runRemoteCommand(ctx, []string{findScript}) + if err != nil { + return fmt.Errorf("failed to locate .local files for jail %s: %w", jailName, err) + } + + for _, rf := range parseRemoteFileDump(dump) { + jailFilePath := rf.path + if !strings.HasPrefix(jailFilePath, jailDPath+"/") || !strings.HasSuffix(jailFilePath, ".local") { + debugf("Skipping unexpected jail file path from remote: %s", jailFilePath) + continue + } + content := rf.content + if !containsJailSection(content, jailName) { + return fmt.Errorf("refusing to rewrite %s: section [%s] not found in remote file content", jailFilePath, jailName) + } + newContent := rewriteJailEnabled(content, jailName, enabled) + if err := sc.writeRemoteFile(ctx, jailFilePath, newContent); err != nil { + return fmt.Errorf("failed to write jail .local file %s: %w", jailFilePath, err) + } + debugf("Updated jail %s: enabled = %t (file: %s)", jailName, enabled, jailFilePath) + } + } + return nil +} + +func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) { + filterDPath := FilterDir(sc.getFail2banPath(ctx)) + + localFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".local") + if err != nil { + debugf("Failed to list .local filters on server %s: %v", sc.server.Name, err) + } + confFiles, err := sc.listRemoteFiles(ctx, filterDPath, ".conf") + if err != nil { + debugf("Failed to list .conf filters on server %s: %v", sc.server.Name, err) + } + return dedupeConfigBaseNames(localFiles, confFiles), nil +} + +// ========================================================================= +// Filter Include Resolution +// ========================================================================= + +func (sc *SSHConnector) remoteFilterIncludeReader(ctx context.Context, filterDPath string) filterIncludeReader { + return func(baseName string) (string, string, error) { + if _, err := resolveWithinDir(filterDPath, baseName, ".local"); err != nil { + return "", "", fmt.Errorf("invalid include filter name %q: %w", baseName, err) + } + content, path, err := sc.readRemoteWithLocalFallback(ctx, filterDPath, baseName) + if err != nil { + return "", "", fmt.Errorf("could not load included filter file: %w", err) + } + return content, path, nil + } +} + +func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterContent string, filterDPath string, currentFilterName string) (string, error) { + return resolveFilterIncludesWith(filterContent, currentFilterName, sc.remoteFilterIncludeReader(ctx, filterDPath)) +} + +func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { + cleaned := normalizeLogLines(logLines) + if len(cleaned) == 0 { + return "No log lines provided.\n", "", nil + } + filterName = strings.TrimSpace(filterName) + if err := ValidateFilterName(filterName); err != nil { + return "", "", err + } + fail2banPath := sc.getFail2banPath(ctx) + localPath := filepath.Join(FilterDir(fail2banPath), filterName+".local") + confPath := filepath.Join(FilterDir(fail2banPath), filterName+".conf") + + const heredocMarker = "F2B_FILTER_TEST_LOG" + logContent := strings.Join(cleaned, "\n") + var prologue string + if filterContent != "" { + resolvedContent, err := sc.resolveFilterIncludesRemote(ctx, filterContent, FilterDir(fail2banPath), filterName) + if err != nil { + debugf("Warning: failed to resolve filter includes remotely, using original content: %v", err) + resolvedContent = filterContent + } + if !strings.HasSuffix(resolvedContent, "\n") { + resolvedContent += "\n" + } + prologue = fmt.Sprintf(`TMPFILTER=$(mktemp /tmp/fail2ban-filter-XXXXXX.conf) +trap 'rm -f "$TMPFILTER"' EXIT +echo '%s' | base64 -d > "$TMPFILTER" +FILTER_PATH="$TMPFILTER"`, base64.StdEncoding.EncodeToString([]byte(resolvedContent))) + } else { + prologue = fmt.Sprintf(`LOCAL_PATH=%[1]q +CONF_PATH=%[2]q +if [ -f "$LOCAL_PATH" ]; then + FILTER_PATH="$LOCAL_PATH" +elif [ -f "$CONF_PATH" ]; then + FILTER_PATH="$CONF_PATH" +else + echo "Filter not found: checked both $LOCAL_PATH and $CONF_PATH" >&2 + exit 1 +fi`, localPath, confPath) + } + + script := fmt.Sprintf(`set -e +%[1]s +echo "%[2]s$FILTER_PATH" +TMPFILE=$(mktemp /tmp/fail2ban-test-XXXXXX.log) +trap 'rm -f "$TMPFILE" ${TMPFILTER:+"$TMPFILTER"}' EXIT +cat <<'%[3]s' > "$TMPFILE" +%[4]s +%[3]s +fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true +`, prologue, filterPathMarker, heredocMarker, logContent) + + out, err := sc.runRemoteCommand(ctx, []string{script}) + if err != nil { + return "", "", err + } + + output, filterPath := splitFilterTestOutput(out) + if filterPath == "" { + filterPath = confPath + } + return output, filterPath, nil +} + +// Splits the marker line carrying the resolved filter path from the fail2ban-regex output +func splitFilterTestOutput(out string) (output, filterPath string) { + var outputLines []string + for _, line := range strings.Split(out, "\n") { + if rest, ok := strings.CutPrefix(line, filterPathMarker); ok { + filterPath = strings.TrimSpace(rest) + continue + } + outputLines = append(outputLines, line) + } + return strings.Join(outputLines, "\n"), filterPath +} + +func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) { + jail = strings.TrimSpace(jail) + if err := ValidateJailName(jail); err != nil { + return "", "", err + } + + fail2banPath := sc.getFail2banPath(ctx) + jailDPath := JailDir(fail2banPath) + content, path, err := sc.readRemoteWithLocalFallback(ctx, jailDPath, jail) + if err != nil { + return fmt.Sprintf("[%s]\n", jail), filepath.Join(jailDPath, jail+".local"), nil + } + return content, path, nil +} + +func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) error { + jail = strings.TrimSpace(jail) + if err := ValidateJailName(jail); err != nil { + return err + } + return sc.writeConfigOverride(ctx, JailDir(sc.getFail2banPath(ctx)), jail, content, "jail") +} + +const ( + logpathMarkerNoAccess = "F2BUI_NOACCESS" + logpathMarkerNoDir = "F2BUI_NODIR" +) + +func parseLogpathProbe(out string) ([]string, error) { + var matches []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + switch line { + case logpathMarkerNoAccess: + return nil, ErrLogpathInaccessible + case logpathMarkerNoDir: + return []string{}, nil + default: + matches = append(matches, line) + } + } + return matches, nil +} + +func (sc *SSHConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) { + if logpath == "" { + return []string{}, nil + } + + logpath = strings.TrimSpace(logpath) + hasWildcard := strings.ContainsAny(logpath, "*?[") + + var script string + if hasWildcard { + script = fmt.Sprintf(` +LOGPATH=%q +DIR=$(dirname "$LOGPATH") +if [ ! -d "$DIR" ]; then echo %s; exit 0; fi +if [ ! -r "$DIR" ] || [ ! -x "$DIR" ]; then echo %s; exit 0; fi +find "$DIR" -maxdepth 1 -path "$LOGPATH" -type f 2>/dev/null | sort +`, logpath, logpathMarkerNoDir, logpathMarkerNoAccess) + } else { + script = fmt.Sprintf(` +LOGPATH=%q +if [ -f "$LOGPATH" ]; then echo "$LOGPATH"; exit 0; fi +if [ -d "$LOGPATH" ]; then + if [ ! -r "$LOGPATH" ] || [ ! -x "$LOGPATH" ]; then echo %s; exit 0; fi + find "$LOGPATH" -maxdepth 1 -type f 2>/dev/null | sort + exit 0 +fi +DIR=$(dirname "$LOGPATH") +if [ -d "$DIR" ] && { [ ! -r "$DIR" ] || [ ! -x "$DIR" ]; }; then echo %s; exit 0; fi +echo %s +`, logpath, logpathMarkerNoAccess, logpathMarkerNoAccess, logpathMarkerNoDir) + } + + out, err := sc.runRemoteCommand(ctx, []string{script}) + if err != nil { + return nil, fmt.Errorf("failed to probe logpath on %s: %w", sc.server.Name, err) + } + + return parseLogpathProbe(out) +} + +func (sc *SSHConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) { + originalPath = strings.TrimSpace(logpath) + if originalPath == "" { + return originalPath, "", []string{}, nil + } + + if len(extractVariablesFromString(originalPath)) == 0 { + files, err = sc.TestLogpath(ctx, originalPath) + if err != nil { + return originalPath, originalPath, nil, fmt.Errorf("failed to test logpath: %w", err) + } + return originalPath, originalPath, files, nil + } + + configFiles, dumpErr := sc.dumpConfigTree(ctx) + if dumpErr != nil { + return originalPath, "", nil, fmt.Errorf("failed to read remote fail2ban configuration: %w", dumpErr) + } + resolvedPath, err = resolveLogpathVariablesFrom(originalPath, snapshotVariableSource{files: configFiles}) + if err != nil { + return originalPath, "", nil, fmt.Errorf("failed to resolve variables: %w", err) + } + + files, err = sc.TestLogpath(ctx, resolvedPath) + if err != nil { + return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err) + } + + return originalPath, resolvedPath, files, nil +} + +func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context) error { + return sc.EnsureJailLocalStructure(ctx) +} + +func (sc *SSHConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) { + jailLocalPath := JailLocal(sc.getFail2banPath(ctx)) + output, err := sc.runRemoteCommand(ctx, []string{"cat", jailLocalPath}) + if err != nil { + if strings.Contains(err.Error(), "No such file") || strings.Contains(output, "No such file") { + return false, false, nil + } + return false, false, fmt.Errorf("failed to read jail.local on %s: %w", sc.server.Name, err) + } + hasUIAction := strings.Contains(output, managedJailLocalMarker) + return true, hasUIAction, nil +} + +func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { + jailLocalPath := JailLocal(sc.getFail2banPath(ctx)) + + exists, hasUI, chkErr := sc.CheckJailLocalIntegrity(ctx) + if chkErr != nil { + debugf("Warning: could not check jail.local integrity on %s: %v", sc.server.Name, chkErr) + } + if exists && !hasUI { + debugf("jail.local on server %s exists but is not managed by Fail2ban-UI - skipping overwrite", sc.server.Name) + return nil + } + + content := mustProvider().BuildJailLocalContent() + return sc.writeRemoteFile(ctx, jailLocalPath, content) +} + +func (sc *SSHConnector) createConfigFile(ctx context.Context, dir, name, content, kind string) error { + if _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", dir}); err != nil { + return fmt.Errorf("failed to create %s directory: %w", filepath.Base(dir), err) + } + localPath := filepath.Join(dir, name+".local") + if err := sc.writeRemoteFile(ctx, localPath, content); err != nil { + return fmt.Errorf("failed to create %s file: %w", kind, err) + } + return nil +} + +// Removes both dir/name.local and dir/name.conf +func (sc *SSHConnector) deleteConfigFiles(ctx context.Context, dir, name, kind string) error { + localPath := filepath.Join(dir, name+".local") + confPath := filepath.Join(dir, name+".conf") + if _, err := sc.runRemoteCommand(ctx, []string{"rm", "-f", localPath, confPath}); err != nil { + return fmt.Errorf("failed to delete %s files %s or %s: %w", kind, localPath, confPath, err) + } + return nil +} + +func (sc *SSHConnector) CreateJail(ctx context.Context, jailName, content string) error { + if err := ValidateJailName(jailName); err != nil { + return err + } + // A jail file is only meaningful with its section header + expectedSection := fmt.Sprintf("[%s]", jailName) + if !strings.HasPrefix(strings.TrimSpace(content), expectedSection) { + content = expectedSection + "\n" + content + } + return sc.createConfigFile(ctx, JailDir(sc.getFail2banPath(ctx)), jailName, content, "jail") +} + +func (sc *SSHConnector) DeleteJail(ctx context.Context, jailName string) error { + if err := ValidateJailName(jailName); err != nil { + return err + } + return sc.deleteConfigFiles(ctx, JailDir(sc.getFail2banPath(ctx)), jailName, "jail") +} + +func (sc *SSHConnector) CreateFilter(ctx context.Context, filterName, content string) error { + if err := ValidateFilterName(filterName); err != nil { + return err + } + return sc.createConfigFile(ctx, FilterDir(sc.getFail2banPath(ctx)), filterName, content, "filter") +} + +func (sc *SSHConnector) DeleteFilter(ctx context.Context, filterName string) error { + if err := ValidateFilterName(filterName); err != nil { + return err + } + return sc.deleteConfigFiles(ctx, FilterDir(sc.getFail2banPath(ctx)), filterName, "filter") +} diff --git a/internal/fail2ban/connector_ssh_exec_test.go b/internal/fail2ban/connector_ssh_exec_test.go new file mode 100644 index 0000000..b0b49e5 --- /dev/null +++ b/internal/fail2ban/connector_ssh_exec_test.go @@ -0,0 +1,180 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fail2ban + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/swissmakers/fail2ban-ui/internal/shared" +) + +// The following tests run against a fake ssh rather than a real host +func withFakeSSH(t *testing.T, body string) { + t.Helper() + dir := t.TempDir() + script := "#!/bin/sh\n" + body + path := filepath.Join(dir, "ssh") + if err := os.WriteFile(path, []byte(script), 0o700); err != nil { + t.Fatalf("failed to write fake ssh: %v", err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func testSSHConnector() *SSHConnector { + return &SSHConnector{ + server: shared.Fail2banServer{Name: "test", Host: "10.0.0.1", SSHUser: "f2b"}, + sessionSem: make(chan struct{}, sshMaxConcurrentSessions), + } +} + +func TestExecSSHStreamsStaySeparate(t *testing.T) { + withFakeSSH(t, `echo "real output" +echo "mux noise" >&2 +exit 0 +`) + sc := testSSHConnector() + stdout, stderr, err := sc.execSSH(context.Background(), []string{"whatever"}, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.TrimSpace(stdout) != "real output" { + t.Fatalf("stdout = %q, want just the real output", stdout) + } + if !strings.Contains(stderr, "mux noise") { + t.Fatalf("stderr = %q, want the noise kept on stderr", stderr) + } + if strings.Contains(stdout, "mux noise") { + t.Fatalf("stderr must never leak into stdout, got %q", stdout) + } +} + +func TestExecSSHPropagatesExitStatus(t *testing.T) { + withFakeSSH(t, `echo "partial" +echo "failure detail" >&2 +exit 3 +`) + sc := testSSHConnector() + stdout, stderr, err := sc.execSSH(context.Background(), []string{"whatever"}, nil) + if err == nil { + t.Fatal("expected a non-zero exit to produce an error") + } + if !strings.Contains(stdout, "partial") || !strings.Contains(stderr, "failure detail") { + t.Fatalf("captured output must survive a failure: stdout=%q stderr=%q", stdout, stderr) + } +} + +func TestExecSSHTransportErrorDetection(t *testing.T) { + t.Run("ssh dying silently with 255 is a transport error", func(t *testing.T) { + withFakeSSH(t, "exit 255\n") + sc := testSSHConnector() + _, stderr, err := sc.execSSH(context.Background(), []string{"whatever"}, nil) + if err == nil { + t.Fatal("expected an error") + } + if !isSSHTransportError(err, stderr) { + t.Fatalf("exit 255 with no stderr must be a transport error, got %v", err) + } + }) + + t.Run("ssh connection failure is a transport error", func(t *testing.T) { + withFakeSSH(t, `echo "ssh: connect to host 10.0.0.1 port 22: Connection refused" >&2 +exit 255 +`) + sc := testSSHConnector() + _, stderr, err := sc.execSSH(context.Background(), []string{"whatever"}, nil) + if !isSSHTransportError(err, stderr) { + t.Fatalf("a refused connection must be a transport error, got stderr=%q err=%v", stderr, err) + } + }) + + t.Run("remote fail2ban error exiting 255 is NOT a transport error", func(t *testing.T) { + withFakeSSH(t, `echo "Sorry but the jail 'nosuchjail' does not exist" >&2 +exit 255 +`) + sc := testSSHConnector() + _, stderr, err := sc.execSSH(context.Background(), []string{"whatever"}, nil) + if err == nil { + t.Fatal("expected an error") + } + if isSSHTransportError(err, stderr) { + t.Fatalf("a remote command failure must not trigger a master re-dial: stderr=%q", stderr) + } + }) +} + +func TestExecSSHNonTransportExitIsNotRetried(t *testing.T) { + withFakeSSH(t, "exit 1\n") + sc := testSSHConnector() + _, stderr, err := sc.execSSH(context.Background(), []string{"whatever"}, nil) + if err == nil { + t.Fatal("expected an error") + } + if isSSHTransportError(err, stderr) { + t.Fatalf("exit 1 is a remote command failure, not a transport error: %v", err) + } +} + +func TestExecSSHCancellationDoesNotHang(t *testing.T) { + // Emits output, then sleeps far longer than the test is willing to wait + withFakeSSH(t, `echo "before sleep" +sleep 30 +`) + sc := testSSHConnector() + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + done := make(chan struct{}) + var stdout string + var err error + go func() { + stdout, _, err = sc.execSSH(ctx, []string{"whatever"}, nil) + close(done) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("execSSH did not return after context cancellation") + } + if err == nil { + t.Fatal("expected a cancellation error") + } + if !strings.Contains(err.Error(), "context deadline exceeded") { + t.Fatalf("expected the context error, got %v", err) + } + if !strings.Contains(stdout, "before sleep") { + t.Fatalf("output captured before cancellation must be returned, got %q", stdout) + } +} + +func TestExecSSHStdinIsDelivered(t *testing.T) { + withFakeSSH(t, "cat\n") + sc := testSSHConnector() + stdout, _, err := sc.execSSH(context.Background(), []string{"sh", "-s"}, strings.NewReader("piped payload\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout, "piped payload") { + t.Fatalf("stdin must reach the remote command, got %q", stdout) + } +} diff --git a/internal/fail2ban/connector_ssh_files.go b/internal/fail2ban/connector_ssh_files.go new file mode 100644 index 0000000..79dd142 --- /dev/null +++ b/internal/fail2ban/connector_ssh_files.go @@ -0,0 +1,245 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Remote file operations over SSH: reading, writing, listing, and the +// framed multi-file dump format shared by the batched fetches. +package fail2ban + +import ( + "context" + "fmt" + "path/filepath" + "strings" +) + +const ( + bannedSectionEnd = "F2BUI_BANNED_END" + batchJailLocalBegin = "F2BUI_JAILLOCAL_BEGIN" + batchJailLocalMissing = "F2BUI_JAILLOCAL_MISSING" + batchEnd = "F2BUI_BATCH_END" + batchFileBegin = "F2BUI_FILE_BEGIN:" + batchFileEnd = "F2BUI_FILE_END" + filterPathMarker = "FILTER_PATH:" +) + +type remoteFile struct { + path string + content string +} + +func parseRemoteFileDump(out string) []remoteFile { + var files []remoteFile + var current *remoteFile + var content strings.Builder + flush := func() { + if current == nil { + return + } + current.content = strings.TrimSuffix(content.String(), "\n") + files = append(files, *current) + current = nil + content.Reset() + } + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, batchFileBegin): + flush() + current = &remoteFile{path: strings.TrimPrefix(trimmed, batchFileBegin)} + case trimmed == batchFileEnd: + flush() + case current != nil && strings.HasSuffix(line, batchFileEnd): + content.WriteString(strings.TrimSuffix(line, batchFileEnd)) + content.WriteString("\n") + flush() + case current != nil: + content.WriteString(line) + content.WriteString("\n") + } + } + flush() + return files +} + +// ========================================================================= +// Remote File Operations +// ========================================================================= + +// List files in a remote directory using find. +func (sc *SSHConnector) listRemoteFiles(ctx context.Context, directory, pattern string) ([]string, error) { + cmd := fmt.Sprintf(`find "%s" -maxdepth 1 -type f -name "*%s" ! -name ".*" 2>/dev/null | sort`, directory, pattern) + + out, err := sc.runRemoteCommand(ctx, []string{cmd}) + if err != nil { + return nil, fmt.Errorf("failed to list files in %s: %w", directory, err) + } + + var files []string + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" || line == "." || strings.HasPrefix(line, "./") { + continue + } + if strings.HasSuffix(line, pattern) { + if strings.HasPrefix(line, directory) { + files = append(files, line) + } else if !strings.HasPrefix(line, "/") { + fullPath := filepath.Join(directory, line) + files = append(files, fullPath) + } + } + } + + return files, nil +} + +func quoteRemotePath(filePath string) (string, error) { + if strings.ContainsAny(filePath, "'\n") { + return "", fmt.Errorf("unsupported character in remote path %q", filePath) + } + return "'" + filePath + "'", nil +} + +func (sc *SSHConnector) readRemoteFile(ctx context.Context, filePath string) (string, error) { + quoted, err := quoteRemotePath(filePath) + if err != nil { + return "", err + } + content, err := sc.runRemoteCommand(ctx, []string{"cat " + quoted}) + if err != nil { + return "", fmt.Errorf("failed to read remote file %s: %w", filePath, err) + } + return content, nil +} + +func (sc *SSHConnector) readRemoteWithLocalFallback(ctx context.Context, dir, name string) (string, string, error) { + localPath := filepath.Join(dir, name+".local") + if content, err := sc.readRemoteFile(ctx, localPath); err == nil { + return content, localPath, nil + } + confPath := filepath.Join(dir, name+".conf") + content, err := sc.readRemoteFile(ctx, confPath) + if err != nil { + return "", "", fmt.Errorf("could not read '%s' or '%s': %w", localPath, confPath, err) + } + return content, confPath, nil +} + +const remoteWriteDelimiter = "F2BUI_REMOTE_EOF" + +func buildRemoteWriteScript(filePath, content string) (string, error) { + quoted, err := quoteRemotePath(filePath) + if err != nil { + return "", err + } + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) == remoteWriteDelimiter { + return "", fmt.Errorf("content contains the heredoc delimiter %q", remoteWriteDelimiter) + } + } + body := strings.TrimSuffix(content, "\n") + return fmt.Sprintf("cat > %s <<'%s'\n%s\n%s\n", quoted, remoteWriteDelimiter, body, remoteWriteDelimiter), nil +} + +func (sc *SSHConnector) writeRemoteFile(ctx context.Context, filePath, content string) error { + script, err := buildRemoteWriteScript(filePath, content) + if err != nil { + return fmt.Errorf("refusing to write remote file %s: %w", filePath, err) + } + if _, err := sc.runRemoteCommand(ctx, []string{script}); err != nil { + return fmt.Errorf("failed to write remote file %s: %w", filePath, err) + } + return nil +} + +func (sc *SSHConnector) ensureRemoteLocalFile(ctx context.Context, basePath, name string) error { + localPath := fmt.Sprintf("%s/%s.local", basePath, name) + confPath := fmt.Sprintf("%s/%s.conf", basePath, name) + + if err := ValidateFilterName(name); err != nil { + return fmt.Errorf("invalid config name %q: %w", name, err) + } + + script := fmt.Sprintf(` + if [ ! -f "%s" ]; then + if [ -f "%s" ]; then + cp "%s" "%s" + else + # Create empty .local file if neither exists + touch "%s" + fi + fi + `, localPath, confPath, confPath, localPath, localPath) + + _, err := sc.runRemoteCommand(ctx, []string{script}) + if err != nil { + return fmt.Errorf("failed to ensure remote .local file %s: %w", localPath, err) + } + return nil +} + +func (sc *SSHConnector) getFail2banPath(ctx context.Context) string { + sc.pathMutex.RLock() + path := sc.fail2banPath + sc.pathMutex.RUnlock() + if path != "" { + return path + } + + checkCmd := `test -d "/config/fail2ban" && echo "/config/fail2ban" || echo "/etc/fail2ban"` + out, err := sc.runRemoteCommand(ctx, []string{checkCmd}) + if err != nil { + debugf("fail2ban path probe failed for %s, assuming %s (will retry): %v", sc.server.Name, DefaultConfigRoot, err) + return DefaultConfigRoot + } + probed := strings.TrimSpace(out) + if probed == "" { + return DefaultConfigRoot + } + + sc.pathMutex.Lock() + defer sc.pathMutex.Unlock() + if sc.fail2banPath == "" { + sc.fail2banPath = probed + } + return sc.fail2banPath +} + +func buildConfigTreeDumpScript(configRoot string) (string, error) { + quotedRoot, err := quoteRemotePath(configRoot) + if err != nil { + return "", err + } + return fmt.Sprintf(`find %[1]s -type f \( -name '*.conf' -o -name '*.local' \) | sort | while IFS= read -r f; do + echo "%[2]s$f" + cat "$f" + echo "%[3]s" +done +`, quotedRoot, batchFileBegin, batchFileEnd), nil +} + +func (sc *SSHConnector) dumpConfigTree(ctx context.Context) ([]remoteFile, error) { + configRoot := sc.getFail2banPath(ctx) + script, err := buildConfigTreeDumpScript(configRoot) + if err != nil { + return nil, err + } + out, err := sc.runRemoteCommand(ctx, []string{script}) + if err != nil { + return nil, fmt.Errorf("failed to read config tree from %s on %s: %w", configRoot, sc.server.Name, err) + } + return parseRemoteFileDump(out), nil +} diff --git a/internal/fail2ban/connector_ssh_test.go b/internal/fail2ban/connector_ssh_test.go new file mode 100644 index 0000000..faaa1a4 --- /dev/null +++ b/internal/fail2ban/connector_ssh_test.go @@ -0,0 +1,527 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fail2ban + +import ( + "errors" + "strings" + "testing" + + "github.com/swissmakers/fail2ban-ui/internal/shared" +) + +func TestResolveTunnelPort(t *testing.T) { + SetProvider(testProvider{}) + defer SetProvider(noopProvider{}) + + cases := []struct { + name string + srv shared.Fail2banServer + want int + }{ + {"explicit port", shared.Fail2banServer{Name: "s", TunnelPort: 9443}, 9443}, + {"zero falls back to UI port", shared.Fail2banServer{Name: "s"}, 8080}, + {"privileged port falls back to UI port", shared.Fail2banServer{Name: "s", TunnelPort: 80}, 8080}, + {"out of range falls back to UI port", shared.Fail2banServer{Name: "s", TunnelPort: 70000}, 8080}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resolveTunnelPort(tc.srv); got != tc.want { + t.Fatalf("resolveTunnelPort(%+v) = %d, want %d", tc.srv.TunnelPort, got, tc.want) + } + }) + } +} + +func TestResolveTunnelPortNoopProviderFallback(t *testing.T) { + SetProvider(noopProvider{}) + defer SetProvider(noopProvider{}) + + if got := resolveTunnelPort(shared.Fail2banServer{Name: "s"}); got != 8080 { + t.Fatalf("resolveTunnelPort with noop provider = %d, want 8080", got) + } +} + +func TestActionCallbackURL(t *testing.T) { + SetProvider(testProvider{}) + defer SetProvider(noopProvider{}) + + tunneled := &SSHConnector{server: shared.Fail2banServer{Name: "s"}, tunnelPort: 9443} + if got := tunneled.actionCallbackURL(); got != "http://localhost:9443" { + t.Fatalf("tunneled actionCallbackURL = %q, want http://localhost:9443", got) + } + + direct := &SSHConnector{server: shared.Fail2banServer{Name: "s"}} + if got := direct.actionCallbackURL(); got != "http://127.0.0.1:8080" { + t.Fatalf("direct actionCallbackURL = %q, want provider callback URL", got) + } +} + +func TestBuildSSHArgsReverseTunnelForwardTarget(t *testing.T) { + SetProvider(testProvider{}) + defer SetProvider(noopProvider{}) + + findR := func(args []string) string { + for i, a := range args { + if a == "-R" && i+1 < len(args) { + return args[i+1] + } + } + return "" + } + + custom := &SSHConnector{ + server: shared.Fail2banServer{Host: "10.0.0.1", SSHUser: "f2b"}, + tunnelPort: 18080, + forwardPort: 8080, + } + if got := findR(custom.buildMasterSSHArgs([]string{"true"})); got != "18080:localhost:8080" { + t.Fatalf("master -R arg = %q, want 18080:localhost:8080", got) + } + + deflt := &SSHConnector{ + server: shared.Fail2banServer{Host: "10.0.0.1", SSHUser: "f2b"}, + tunnelPort: 8080, + forwardPort: 8080, + } + if got := findR(deflt.buildMasterSSHArgs([]string{"true"})); got != "8080:localhost:8080" { + t.Fatalf("master -R arg = %q, want 8080:localhost:8080", got) + } + + noTunnel := &SSHConnector{server: shared.Fail2banServer{Host: "10.0.0.1", SSHUser: "f2b"}} + if got := findR(noTunnel.buildMasterSSHArgs([]string{"true"})); got != "" { + t.Fatalf("unexpected -R arg %q for connector without tunnel", got) + } +} + +func TestBuildSSHArgsMasterSessionSplit(t *testing.T) { + SetProvider(testProvider{}) + defer SetProvider(noopProvider{}) + + hasOpt := func(args []string, opt string) bool { + for i, a := range args { + if a == "-o" && i+1 < len(args) && args[i+1] == opt { + return true + } + } + return false + } + countR := func(args []string) int { + n := 0 + for _, a := range args { + if a == "-R" { + n++ + } + } + return n + } + + tunneled := &SSHConnector{ + server: shared.Fail2banServer{Host: "10.0.0.1", SSHUser: "f2b"}, + tunnelPort: 9443, + forwardPort: 8080, + } + + session := tunneled.buildSSHArgs([]string{"true"}) + if countR(session) != 0 { + t.Fatalf("tunnel session args must not carry -R, got: %v", session) + } + if !hasOpt(session, "ControlMaster=no") { + t.Fatalf("tunnel session args must use ControlMaster=no, got: %v", session) + } + + master := tunneled.buildMasterSSHArgs([]string{"true"}) + if countR(master) != 1 { + t.Fatalf("master args must carry exactly one -R, got: %v", master) + } + if !hasOpt(master, "ControlMaster=auto") || !hasOpt(master, "ControlPersist=0") { + t.Fatalf("master args must use ControlMaster=auto + ControlPersist=0, got: %v", master) + } + + plain := &SSHConnector{server: shared.Fail2banServer{Host: "10.0.0.1", SSHUser: "f2b"}} + plainArgs := plain.buildSSHArgs([]string{"true"}) + if !hasOpt(plainArgs, "ControlMaster=auto") || !hasOpt(plainArgs, "ControlPersist=300") { + t.Fatalf("non-tunnel session args changed unexpectedly: %v", plainArgs) + } + if countR(plainArgs) != 0 { + t.Fatalf("non-tunnel args must not carry -R, got: %v", plainArgs) + } +} + +const sshMuxNoise = "mux_client_request_session: session request failed: Session open refused by peer\r\n" + + "ControlSocket /tmp/ssh_control_srv-04e7c5bf2beffe52_172_16_10_13 already exists, disabling multiplexing\n" + +func TestSelectCommandOutput(t *testing.T) { + jailFile := "[swissmakers-apache-scanner]\nenabled = true\n" + + t.Run("success returns stdout only, stderr noise dropped", func(t *testing.T) { + out, err := selectCommandOutput(jailFile, sshMuxNoise, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(out, "mux_client_request_session") || strings.Contains(out, "ControlSocket") { + t.Fatalf("stderr noise leaked into output: %q", out) + } + if out != strings.TrimSpace(jailFile) { + t.Fatalf("output = %q, want trimmed stdout", out) + } + }) + + t.Run("failure folds both streams into the error", func(t *testing.T) { + out, err := selectCommandOutput("partial", "sudo: a password is required", errors.New("exit status 1")) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "sudo: a password is required") || !strings.Contains(err.Error(), "partial") { + t.Fatalf("error should contain both streams, got: %v", err) + } + if !strings.Contains(out, "sudo: a password is required") { + t.Fatalf("returned output should keep error-path matching working, got: %q", out) + } + }) +} + +func TestBuildRemoteWriteScript(t *testing.T) { + t.Run("single quotes are preserved verbatim", func(t *testing.T) { + content := `ignoreregex = [^"]*(?:Let's Encrypt|Uptime)[^"]*` + "\n" + script, err := buildRemoteWriteScript("/etc/fail2ban/filter.d/test.local", content) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(script, "Let's Encrypt") { + t.Fatalf("content was altered: %q", script) + } + if strings.Contains(script, `'"'"'`) { + t.Fatalf("content must not be shell-escaped inside a quoted heredoc: %q", script) + } + }) + + t.Run("delimiter collision is rejected", func(t *testing.T) { + if _, err := buildRemoteWriteScript("/tmp/f", "a\n"+remoteWriteDelimiter+"\nb\n"); err == nil { + t.Fatal("expected error for content containing the heredoc delimiter") + } + }) + + t.Run("unsafe path is rejected", func(t *testing.T) { + if _, err := buildRemoteWriteScript("/tmp/f'oo", "x\n"); err == nil { + t.Fatal("expected error for path containing a single quote") + } + }) + + t.Run("exactly one trailing newline", func(t *testing.T) { + script, err := buildRemoteWriteScript("/tmp/f", "line1\nline2\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "cat > '/tmp/f' <<'" + remoteWriteDelimiter + "'\nline1\nline2\n" + remoteWriteDelimiter + "\n" + if script != want { + t.Fatalf("script = %q, want %q", script, want) + } + }) +} + +func TestParseLogpathProbe(t *testing.T) { + t.Run("NOACCESS marker -> inaccessible sentinel", func(t *testing.T) { + _, err := parseLogpathProbe(logpathMarkerNoAccess + "\n") + if !errors.Is(err, ErrLogpathInaccessible) { + t.Fatalf("want ErrLogpathInaccessible, got %v", err) + } + }) + t.Run("NODIR marker -> empty, no error", func(t *testing.T) { + files, err := parseLogpathProbe(logpathMarkerNoDir + "\n") + if err != nil || len(files) != 0 { + t.Fatalf("want empty/no-error, got files=%v err=%v", files, err) + } + }) + t.Run("file list parsed", func(t *testing.T) { + files, err := parseLogpathProbe("/var/log/httpd/access_log\n/var/log/httpd/ssl_access_log\n") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 2 || files[0] != "/var/log/httpd/access_log" { + t.Fatalf("unexpected files: %v", files) + } + }) + t.Run("empty output -> no files, no error", func(t *testing.T) { + files, err := parseLogpathProbe("\n \n") + if err != nil || len(files) != 0 { + t.Fatalf("want empty/no-error, got files=%v err=%v", files, err) + } + }) +} + +func TestParseRemoteFileDump(t *testing.T) { + t.Run("two files with trailing newlines", func(t *testing.T) { + out := batchFileBegin + "/etc/fail2ban/jail.d/a.local\n[a]\nenabled = true\n" + batchFileEnd + "\n" + + batchFileBegin + "/etc/fail2ban/jail.d/b.local\n[b]\nenabled = false\n" + batchFileEnd + "\n" + files := parseRemoteFileDump(out) + if len(files) != 2 { + t.Fatalf("want 2 files, got %+v", files) + } + if files[0].path != "/etc/fail2ban/jail.d/a.local" || files[0].content != "[a]\nenabled = true" { + t.Fatalf("file 0 wrong: %+v", files[0]) + } + if files[1].content != "[b]\nenabled = false" { + t.Fatalf("file 1 wrong: %+v", files[1]) + } + }) + + t.Run("file without trailing newline glues the end marker", func(t *testing.T) { + out := batchFileBegin + "/etc/fail2ban/jail.d/x.local\n[x]\nenabled = true" + batchFileEnd + "\n" + files := parseRemoteFileDump(out) + if len(files) != 1 || files[0].content != "[x]\nenabled = true" { + t.Fatalf("glued end marker not recovered: %+v", files) + } + }) + + t.Run("empty output", func(t *testing.T) { + if files := parseRemoteFileDump(""); len(files) != 0 { + t.Fatalf("want no files, got %+v", files) + } + }) +} + +func TestBuildJailDirDumpScript(t *testing.T) { + t.Run("emits framed local files then conf files without a local sibling", func(t *testing.T) { + script, err := buildJailDirDumpScript("/etc/fail2ban/jail.d") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(script, "'/etc/fail2ban/jail.d'/*.local") { + t.Fatalf("missing quoted .local glob: %s", script) + } + if !strings.Contains(script, `[ ! -f "${f%.conf}.local" ]`) { + t.Fatalf(".conf files must be skipped when a .local sibling exists: %s", script) + } + if !strings.Contains(script, batchFileBegin) || !strings.Contains(script, batchFileEnd) { + t.Fatalf("missing framing markers: %s", script) + } + if strings.Contains(script, "python") { + t.Fatalf("script must not depend on python: %s", script) + } + if strings.Contains(script, "2>&1") { + t.Fatalf("stderr must never be merged into stdout: %s", script) + } + }) + + t.Run("unsafe directory is rejected", func(t *testing.T) { + if _, err := buildJailDirDumpScript("/etc/fail2'ban/jail.d"); err == nil { + t.Fatal("expected an error for a path containing a single quote") + } + }) +} + +// Remote commands and their output can be enormous (generated scripts, whole +// config trees). Every log line is also broadcast to the browser console +// panel, so these must stay bounded. +func TestSummarizeRemoteCommand(t *testing.T) { + t.Run("single-line command is kept verbatim", func(t *testing.T) { + got := summarizeRemoteCommand([]string{"sudo", "fail2ban-client", "status"}) + if got != "sudo fail2ban-client status" { + t.Fatalf("got %q", got) + } + }) + + t.Run("multi-line script collapses to first line plus size", func(t *testing.T) { + script := "echo 'F2BUI_JAIL_BEGIN:sshd'\nsudo fail2ban-client status 'sshd'\necho done\n" + got := summarizeRemoteCommand([]string{script}) + if !strings.HasPrefix(got, "echo 'F2BUI_JAIL_BEGIN:sshd'") { + t.Fatalf("first line must be kept, got %q", got) + } + if strings.Contains(got, "fail2ban-client status") { + t.Fatalf("script body must not be logged, got %q", got) + } + if !strings.Contains(got, "3 lines") { + t.Fatalf("expected a line count, got %q", got) + } + if strings.Contains(got, "\n") { + t.Fatalf("summary must be a single line, got %q", got) + } + }) + + t.Run("huge single-line command is truncated", func(t *testing.T) { + got := summarizeRemoteCommand([]string{strings.Repeat("x", 10000)}) + if len(got) > maxLoggedOutputBytes+64 { + t.Fatalf("summary too long: %d bytes", len(got)) + } + if !strings.Contains(got, "truncated") { + t.Fatalf("expected a truncation marker, got %q", got[:80]) + } + }) +} + +func TestSummarizeSSHInvocation(t *testing.T) { + command := []string{"echo one\necho two\n"} + args := []string{"-o", "ControlMaster=no", "-R", "3082:localhost:3082", "user@host"} + args = append(args, command...) + + got := summarizeSSHInvocation(args, command) + // The ssh options are what matters when diagnosing connection problems. + if !strings.Contains(got, "ControlMaster=no") || !strings.Contains(got, "-R 3082:localhost:3082") { + t.Fatalf("ssh options must be preserved, got %q", got) + } + if strings.Contains(got, "echo two") { + t.Fatalf("script body must not be logged, got %q", got) + } + if strings.Contains(got, "\n") { + t.Fatalf("log line must be single-line, got %q", got) + } +} + +func TestTruncateForLog(t *testing.T) { + if got := truncateForLog("short", 100); got != "short" { + t.Fatalf("under-limit input must pass through, got %q", got) + } + got := truncateForLog(strings.Repeat("a", 500), 100) + if !strings.HasPrefix(got, strings.Repeat("a", 100)) { + t.Fatalf("head must be preserved, got %q", got) + } + if !strings.Contains(got, "500 bytes total") { + t.Fatalf("expected the original size in the marker, got %q", got) + } +} + +func TestSelectCommandOutputCapsErrorPayload(t *testing.T) { + huge := strings.Repeat("secret-config-line\n", 5000) + out, err := selectCommandOutput(huge, "", errors.New("exit status 1")) + if err == nil { + t.Fatal("expected an error") + } + if len(err.Error()) > maxLoggedOutputBytes+256 { + t.Fatalf("error message must be capped, got %d bytes", len(err.Error())) + } + // Callers pattern-match on the returned output, so it stays complete. + if len(out) < len(huge)-1 { + t.Fatalf("returned output must not be truncated, got %d of %d bytes", len(out), len(huge)) + } +} + +func TestSplitFilterTestOutput(t *testing.T) { + t.Run("marker extracted and stripped from output", func(t *testing.T) { + out, path := splitFilterTestOutput("Running tests\n" + filterPathMarker + "/etc/fail2ban/filter.d/sshd.conf\nLines: 1 matched\n") + if path != "/etc/fail2ban/filter.d/sshd.conf" { + t.Fatalf("filter path = %q", path) + } + if strings.Contains(out, filterPathMarker) { + t.Fatalf("marker must not remain in output: %q", out) + } + if !strings.Contains(out, "Lines: 1 matched") { + t.Fatalf("regex output must be preserved: %q", out) + } + }) + + t.Run("missing marker yields empty path", func(t *testing.T) { + out, path := splitFilterTestOutput("some failure\n") + if path != "" { + t.Fatalf("expected empty path, got %q", path) + } + if !strings.Contains(out, "some failure") { + t.Fatalf("output must be preserved: %q", out) + } + }) +} + +func TestBuildConfigTreeDumpScript(t *testing.T) { + script, err := buildConfigTreeDumpScript("/etc/fail2ban") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, want := range []string{"'/etc/fail2ban'", "-name '*.conf'", "-name '*.local'", batchFileBegin, batchFileEnd} { + if !strings.Contains(script, want) { + t.Fatalf("script missing %q: %s", want, script) + } + } + if strings.Contains(script, "python") { + t.Fatalf("config tree dump must not depend on python: %s", script) + } + if strings.Contains(script, "2>&1") { + t.Fatalf("stderr must never be merged into stdout: %s", script) + } + if _, err := buildConfigTreeDumpScript("/etc/fail2'ban"); err == nil { + t.Fatal("expected an error for a path containing a single quote") + } +} + +func TestJailFileType(t *testing.T) { + if got := jailFileType("/etc/fail2ban/jail.d/sshd.local"); got != "local" { + t.Fatalf("jailFileType(.local) = %q, want local", got) + } + if got := jailFileType("/etc/fail2ban/jail.d/sshd.conf"); got != "conf" { + t.Fatalf("jailFileType(.conf) = %q, want conf", got) + } +} + +// End-to-end of the GetAllJails parse path against output shaped exactly like +// the remote shell produces it, including a file with no trailing newline. +func TestJailDirDumpParsesIntoAccumulator(t *testing.T) { + out := batchFileBegin + "/etc/fail2ban/jail.d/noeol.local\n[noeol]\nenabled = true" + batchFileEnd + "\n" + + batchFileBegin + "/etc/fail2ban/jail.d/sshd.local\n[sshd]\nenabled = true\n" + batchFileEnd + "\n" + + batchFileBegin + "/etc/fail2ban/jail.d/nginx.conf\n[nginx]\nenabled = false\n" + batchFileEnd + "\n" + + acc := newJailAccumulator() + for _, f := range parseRemoteFileDump(out) { + acc.add(f.content, jailFileType(f.path)) + } + + byName := map[string]bool{} + for _, j := range acc.jails { + byName[j.JailName] = j.Enabled + } + if len(acc.jails) != 3 { + t.Fatalf("expected 3 jails, got %+v", acc.jails) + } + if !byName["noeol"] { + t.Fatalf("jail from a file without a trailing newline must parse as enabled: %+v", acc.jails) + } + if !byName["sshd"] || byName["nginx"] { + t.Fatalf("unexpected enabled states: %+v", acc.jails) + } +} + +func TestSSHTunnelConfigChanged(t *testing.T) { + SetProvider(testProvider{}) + defer SetProvider(noopProvider{}) + + base := shared.Fail2banServer{ + Type: "ssh", Host: "10.0.0.1", Port: 22, SSHUser: "f2b", SSHKeyPath: "/config/.ssh/id", + ReverseTunnelEnabled: true, TunnelPort: 9443, + } + tunneled := &SSHConnector{server: base, tunnelPort: 9443} + + cases := []struct { + name string + old *SSHConnector + mutate func(shared.Fail2banServer) shared.Fail2banServer + want bool + }{ + {"unchanged", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { return s }, false}, + {"no old tunnel, tunnel newly enabled", &SSHConnector{server: base}, func(s shared.Fail2banServer) shared.Fail2banServer { return s }, true}, + {"no old tunnel, tunnel stays off", &SSHConnector{server: base}, func(s shared.Fail2banServer) shared.Fail2banServer { s.ReverseTunnelEnabled = false; return s }, false}, + {"tunnel disabled", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.ReverseTunnelEnabled = false; return s }, true}, + {"type changed", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.Type = "agent"; return s }, true}, + {"port changed", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.TunnelPort = 9444; return s }, true}, + {"host changed", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.Host = "10.0.0.2"; return s }, true}, + {"ssh user changed", tunneled, func(s shared.Fail2banServer) shared.Fail2banServer { s.SSHUser = "other"; return s }, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := sshTunnelConfigChanged(tc.old, tc.mutate(base)); got != tc.want { + t.Fatalf("sshTunnelConfigChanged = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/fail2ban/connector_ssh_transport.go b/internal/fail2ban/connector_ssh_transport.go new file mode 100644 index 0000000..679f45c --- /dev/null +++ b/internal/fail2ban/connector_ssh_transport.go @@ -0,0 +1,429 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// SSH transport: process execution, ControlMaster lifecycle, +// reverse-tunnel health, and ssh argument construction. +package fail2ban + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/swissmakers/fail2ban-ui/internal/shared" +) + +// Return the UI's configured listen port (from "Server Port" setting) +func uiServerPort() int { + if p := mustProvider().ServerPort(); p > 0 { + return p + } + return 8080 +} + +// Return the port bound on the remote host for the reverse tunnel +func resolveTunnelPort(server shared.Fail2banServer) int { + if server.TunnelPort >= 1024 && server.TunnelPort <= 65535 { + return server.TunnelPort + } + if server.TunnelPort != 0 { + log.Printf("warning: invalid tunnelPort %d for server %s, falling back to the UI port", server.TunnelPort, server.Name) + } + return uiServerPort() +} + +const sshMaxConcurrentSessions = 4 + +// Establishes the ControlMaster (which owns the -R reverse forward) unless the connector has been closed +// Serialized so concurrent commands cannot race to remove and re-create the same control socket +func (sc *SSHConnector) ensureMaster(ctx context.Context) { + sc.masterMu.Lock() + defer sc.masterMu.Unlock() + if sc.closed.Load() { + return + } + if sc.checkMaster(ctx) { + sc.masterUp.Store(true) + return + } + sc.masterUp.Store(false) + if _, err := os.Stat(sc.controlPath()); err == nil { + _ = os.Remove(sc.controlPath()) + } + args := sc.buildMasterSSHArgs([]string{"true"}) + if _, _, err := sc.execSSH(ctx, args, nil); err != nil { + debugf("SSH control master establish failed for %s: %v", sc.server.Name, err) + return + } + sc.masterUp.Store(true) +} + +// Only tunnel servers need a dedicated master (it owns the -R forward) +func (sc *SSHConnector) ensureMasterLazy(ctx context.Context) { + if sc.tunnelPort == 0 { + return + } + if sc.masterUp.Load() { + if _, err := os.Stat(sc.controlPath()); err == nil { + return + } + sc.masterUp.Store(false) + } + sc.ensureMaster(ctx) +} + +func (sc *SSHConnector) checkMaster(ctx context.Context) bool { + check := exec.CommandContext(ctx, "ssh", "-O", "check", "-o", "ControlPath="+sc.controlPath(), sc.sshTarget()) + return check.Run() == nil +} + +// Verifies the SSH ControlMaster (and thus the reverse tunnel) and re-establishes it when it is down. +func (sc *SSHConnector) CheckTunnelHealth(ctx context.Context) { + if sc.tunnelPort == 0 || sc.closed.Load() { + return + } + + if sc.checkMaster(ctx) { + if !sc.tunnelWasUp { + log.Printf("reverse tunnel for server %s is up (port %d)", sc.server.Name, sc.tunnelPort) + } + sc.tunnelWasUp = true + sc.masterUp.Store(true) + return + } + + if sc.tunnelWasUp { + log.Printf("reverse tunnel master for server %s is down, re-establishing", sc.server.Name) + } + sc.tunnelWasUp = false + sc.ensureMaster(ctx) + if sc.closed.Load() { + return + } + if sc.checkMaster(ctx) { + log.Printf("reverse tunnel for server %s re-established (port %d)", sc.server.Name, sc.tunnelPort) + sc.tunnelWasUp = true + } else { + debugf("reverse tunnel for server %s still down after re-dial", sc.server.Name) + } +} + +// Terminates the SSH ControlMaster via its local control socket +func (sc *SSHConnector) exitControlMasterLocked() { + sc.masterUp.Store(false) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "ssh", "-O", "exit", "-o", "ControlPath="+sc.controlPath(), sc.sshTarget()) + out, err := cmd.CombinedOutput() + defer func() { _ = os.Remove(sc.controlPath()) }() + if err != nil { + msg := strings.ToLower(string(out)) + if !strings.Contains(msg, "no such file") && !strings.Contains(msg, "control socket connect") { + debugf("failed to close SSH control master for %s: %v (%s)", sc.server.Name, err, strings.TrimSpace(string(out))) + } + return + } + debugf("closed SSH control master for %s", sc.server.Name) +} + +func (sc *SSHConnector) Close() error { + sc.closed.Store(true) + sc.masterMu.Lock() + defer sc.masterMu.Unlock() + sc.exitControlMasterLocked() + return nil +} + +// Report whether the replacement server config requires tearing down the existing ControlMaster +func sshTunnelConfigChanged(old *SSHConnector, srv shared.Fail2banServer) bool { + newTunnel := srv.Type == "ssh" && srv.ReverseTunnelEnabled + if old.tunnelPort == 0 { + return newTunnel + } + if !newTunnel { + return true + } + if resolveTunnelPort(srv) != old.tunnelPort { + return true + } + o := old.server + return srv.Host != o.Host || srv.Port != o.Port || srv.SSHUser != o.SSHUser || srv.SSHKeyPath != o.SSHKeyPath +} + +// How long a cancelled ssh gets to exit after SIGTERM before it is killed +const sshCancelGrace = 100 * time.Millisecond + +// Runs one ssh process, keeping stdout and stderr strictly separate +func (sc *SSHConnector) execSSH(ctx context.Context, args []string, stdin io.Reader) (string, string, error) { + cmd := exec.CommandContext(ctx, "ssh", args...) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pgid: 0} + cmd.Cancel = func() error { + if cmd.Process == nil || cmd.Process.Pid <= 0 { + return nil + } + return syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) + } + cmd.WaitDelay = sshCancelGrace + + if stdin != nil { + cmd.Stdin = stdin + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + if ctx.Err() != nil && cmd.Process != nil && cmd.Process.Pid > 0 { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + if err != nil && ctx.Err() != nil { + return stdout.String(), stderr.String(), ctx.Err() + } + return stdout.String(), stderr.String(), err +} + +// Caps how much remote output is embedded in logs and error messages +const maxLoggedOutputBytes = 2048 + +// Shortens s for logging, keeping the head and noting what was dropped. +func truncateForLog(s string, limit int) string { + if len(s) <= limit { + return s + } + return s[:limit] + fmt.Sprintf("... (truncated, %d bytes total)", len(s)) +} + +func summarizeSSHInvocation(args, command []string) string { + sshOpts := args + if len(command) <= len(args) { + sshOpts = args[:len(args)-len(command)] + } + return "ssh " + strings.Join(sshOpts, " ") + " " + summarizeRemoteCommand(command) +} + +// Condenses a remote command to a single log-friendly line +func summarizeRemoteCommand(command []string) string { + joined := strings.Join(command, " ") + trimmed := strings.TrimLeft(joined, " \t\n") + first, _, multiline := strings.Cut(trimmed, "\n") + first = strings.TrimSpace(first) + if !multiline { + return truncateForLog(first, maxLoggedOutputBytes) + } + lines := strings.Count(strings.TrimRight(trimmed, "\n"), "\n") + 1 + return fmt.Sprintf("%s ... (script, %d lines, %d bytes)", truncateForLog(first, 200), lines, len(joined)) +} + +func selectCommandOutput(stdout, stderr string, err error) (string, error) { + if err != nil { + combined := strings.TrimSpace(strings.TrimSpace(stdout) + "\n" + strings.TrimSpace(stderr)) + return combined, fmt.Errorf("ssh command failed: %w (output: %s)", err, truncateForLog(combined, maxLoggedOutputBytes)) + } + return strings.TrimSpace(stdout), nil +} + +func (sc *SSHConnector) acquireSession(ctx context.Context) error { + if sc.sessionSem == nil { + return nil + } + select { + case sc.sessionSem <- struct{}{}: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (sc *SSHConnector) releaseSession() { + if sc.sessionSem != nil { + <-sc.sessionSem + } +} + +var sshTransportStderrMarkers = []string{ + "ssh:", + "connection closed", + "connection refused", + "connection reset", + "connection timed out", + "permission denied", + "host key verification failed", + "could not resolve hostname", + "no route to host", + "kex_exchange", + "broken pipe", + "control socket connect", +} + +// Reports whether err is an ssh transport-level failure rather than the remote command failing. +// ssh uses exit status 255 for its own errors, but it also forwards a remote exit status verbatim and fail2ban-client exits 255 for +// things like an unknown jail. Treating those as transport failures would pointlessly tear down and re-dial the ControlMaster (and with it the reverse +// tunnel), so require ssh-shaped stderr as well. +func isSSHTransportError(err error, stderr string) bool { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 255 { + return false + } + msg := strings.ToLower(strings.TrimSpace(stderr)) + if msg == "" { + return true + } + for _, marker := range sshTransportStderrMarkers { + if strings.Contains(msg, marker) { + return true + } + } + return false +} + +func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) (string, error) { + output, stderr, err := sc.runRemoteCommandOnce(ctx, command) + if err != nil && sc.tunnelPort > 0 && isSSHTransportError(err, stderr) && ctx.Err() == nil { + // the master (or its socket) is likely dead. + // Re-establish once and retry the command a single time. + debugf("SSH transport failure [%s], re-establishing master and retrying: %v", sc.server.Name, err) + sc.masterUp.Store(false) + sc.ensureMaster(ctx) + output, _, err = sc.runRemoteCommandOnce(ctx, command) + } + return output, err +} + +func (sc *SSHConnector) runRemoteCommandOnce(ctx context.Context, command []string) (string, string, error) { + sc.ensureMasterLazy(ctx) + if err := sc.acquireSession(ctx); err != nil { + return "", "", err + } + defer sc.releaseSession() + + args := sc.buildSSHArgs(command) + debugf("SSH command [%s]: %s", sc.server.Name, summarizeSSHInvocation(args, command)) + stdout, stderr, execErr := sc.execSSH(ctx, args, nil) + output, err := selectCommandOutput(stdout, stderr, execErr) + if err != nil { + debugf("SSH command error [%s]: %v", sc.server.Name, err) + return output, stderr, err + } + if s := strings.TrimSpace(stderr); s != "" { + debugf("SSH stderr ignored [%s]: %s", sc.server.Name, truncateForLog(s, maxLoggedOutputBytes)) + } + debugf("SSH command output [%s]: %s", sc.server.Name, truncateForLog(output, maxLoggedOutputBytes)) + return output, stderr, nil +} + +func (sc *SSHConnector) actionCallbackURL() string { + if sc.tunnelPort > 0 { + return fmt.Sprintf("http://localhost:%d", sc.tunnelPort) + } + return mustProvider().CallbackURL() +} + +func (sc *SSHConnector) sshControlDir() string { + var base string + if sc.server.SSHKeyPath != "" { + base = filepath.Join(filepath.Dir(sc.server.SSHKeyPath), "ctl") + } else if cache, err := os.UserCacheDir(); err == nil { + base = filepath.Join(cache, "fail2ban-ui", "ssh-ctl") + } + if base != "" { + if err := os.MkdirAll(base, 0o700); err == nil { + return base + } + } + return os.TempDir() +} + +func (sc *SSHConnector) controlPath() string { + name := fmt.Sprintf("ssh_control_%s_%s", sc.server.ID, strings.ReplaceAll(sc.server.Host, ".", "_")) + return filepath.Join(sc.sshControlDir(), name) +} + +func (sc *SSHConnector) sshTarget() string { + if sc.server.SSHUser != "" { + return fmt.Sprintf("%s@%s", sc.server.SSHUser, sc.server.Host) + } + return sc.server.Host +} + +func (sc *SSHConnector) buildSSHArgs(command []string) []string { + return sc.buildSSHArgsMode(command, false) +} + +func (sc *SSHConnector) buildMasterSSHArgs(command []string) []string { + return sc.buildSSHArgsMode(command, true) +} + +func (sc *SSHConnector) buildSSHArgsMode(command []string, forMaster bool) []string { + args := []string{"-o", "BatchMode=yes"} + args = append(args, + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=2", + ) + if _, container := os.LookupEnv("CONTAINER"); container { + knownHosts := "/config/.ssh/known_hosts" + if sc.server.SSHKeyPath != "" { + knownHosts = filepath.Join(filepath.Dir(sc.server.SSHKeyPath), "known_hosts") + } + args = append(args, + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UserKnownHostsFile="+knownHosts, + "-o", "LogLevel=ERROR", + ) + } + controlPath := fmt.Sprintf("ControlPath=%s", sc.controlPath()) + switch { + case sc.tunnelPort > 0 && forMaster: + args = append(args, + "-o", "ControlMaster=auto", + "-o", controlPath, + "-o", "ControlPersist=0", + ) + tunnelArg := fmt.Sprintf("%d:localhost:%d", sc.tunnelPort, sc.forwardPort) + args = append(args, "-R", tunnelArg) + debugf("SSH reverse tunnel enabled: -R %s with ControlPersist=0 (indefinite)", tunnelArg) + case sc.tunnelPort > 0: + args = append(args, + "-o", "ControlMaster=no", + "-o", controlPath, + ) + default: + args = append(args, + "-o", "ControlMaster=auto", + "-o", controlPath, + "-o", "ControlPersist=300", + ) + } + if sc.server.SSHKeyPath != "" { + args = append(args, "-i", sc.server.SSHKeyPath) + } + if sc.server.Port > 0 { + args = append(args, "-p", strconv.Itoa(sc.server.Port)) + } + args = append(args, sc.sshTarget()) + args = append(args, command...) + return args +} diff --git a/internal/fail2ban/filter_includes_test.go b/internal/fail2ban/filter_includes_test.go new file mode 100644 index 0000000..8ee5bce --- /dev/null +++ b/internal/fail2ban/filter_includes_test.go @@ -0,0 +1,364 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fail2ban + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Characterization tests for the filter [INCLUDES] resolution. The output of +// resolveFilterIncludes is fed straight into fail2ban-regex, so ordering and +// variable shadowing are load-bearing: these pin the behaviour so the shared +// implementation used by the local and SSH connectors cannot drift. + +func writeFilterFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } +} + +func indexOfOrFail(t *testing.T, haystack, needle, label string) int { + t.Helper() + idx := strings.Index(haystack, needle) + if idx < 0 { + t.Fatalf("%s (%q) missing from output:\n%s", label, needle, haystack) + } + return idx +} + +func TestResolveFilterIncludes(t *testing.T) { + t.Run("before content precedes main content", func(t *testing.T) { + dir := t.TempDir() + writeFilterFile(t, dir, "common.conf", "[INCLUDES]\n[Definition]\n__prefix_line = COMMON_PREFIX\n") + main := "[INCLUDES]\nbefore = common.conf\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + before := indexOfOrFail(t, out, "COMMON_PREFIX", "included content") + mainIdx := indexOfOrFail(t, out, "MAIN_REGEX", "main content") + if before > mainIdx { + t.Fatalf("before-include must precede main content, got:\n%s", out) + } + }) + + t.Run("after content follows main content", func(t *testing.T) { + dir := t.TempDir() + writeFilterFile(t, dir, "tail.conf", "[Definition]\nignoreregex = TAIL_IGNORE\n") + main := "[INCLUDES]\nafter = tail.conf\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + mainIdx := indexOfOrFail(t, out, "MAIN_REGEX", "main content") + after := indexOfOrFail(t, out, "TAIL_IGNORE", "after-include content") + if after < mainIdx { + t.Fatalf("after-include must follow main content, got:\n%s", out) + } + }) + + t.Run("before and after both applied in order", func(t *testing.T) { + dir := t.TempDir() + writeFilterFile(t, dir, "head.conf", "HEAD_MARKER\n") + writeFilterFile(t, dir, "tail.conf", "TAIL_MARKER\n") + main := "[INCLUDES]\nbefore = head.conf\nafter = tail.conf\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + h := indexOfOrFail(t, out, "HEAD_MARKER", "before content") + m := indexOfOrFail(t, out, "MAIN_REGEX", "main content") + tl := indexOfOrFail(t, out, "TAIL_MARKER", "after content") + if !(h < m && m < tl) { + t.Fatalf("expected before < main < after ordering, got h=%d m=%d t=%d:\n%s", h, m, tl, out) + } + }) + + t.Run("prefers .local over .conf", func(t *testing.T) { + dir := t.TempDir() + writeFilterFile(t, dir, "common.conf", "FROM_CONF\n") + writeFilterFile(t, dir, "common.local", "FROM_LOCAL\n") + main := "[INCLUDES]\nbefore = common.conf\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "FROM_LOCAL") || strings.Contains(out, "FROM_CONF") { + t.Fatalf(".local must win over .conf, got:\n%s", out) + } + }) + + t.Run("self-inclusion in before is skipped", func(t *testing.T) { + dir := t.TempDir() + writeFilterFile(t, dir, "sshd.conf", "SELF_CONTENT\n") + main := "[INCLUDES]\nbefore = sshd.conf\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(out, "SELF_CONTENT") { + t.Fatalf("self-inclusion in before must be skipped, got:\n%s", out) + } + }) + + t.Run("self-inclusion in after is allowed (fail2ban .local pattern)", func(t *testing.T) { + dir := t.TempDir() + writeFilterFile(t, dir, "sshd.local", "SELF_AFTER_CONTENT\n") + main := "[INCLUDES]\nafter = sshd.local\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "SELF_AFTER_CONTENT") { + t.Fatalf("self-inclusion in after must be kept, got:\n%s", out) + } + }) + + t.Run("missing include is skipped without error", func(t *testing.T) { + dir := t.TempDir() + main := "[INCLUDES]\nbefore = does-not-exist.conf\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("missing include must not error, got: %v", err) + } + if !strings.Contains(out, "MAIN_REGEX") { + t.Fatalf("main content must survive a missing include, got:\n%s", out) + } + }) + + t.Run("include name escaping the filter dir is skipped", func(t *testing.T) { + dir := t.TempDir() + outside := filepath.Join(filepath.Dir(dir), "escaped.conf") + if err := os.WriteFile(outside, []byte("ESCAPED_CONTENT\n"), 0o600); err != nil { + t.Fatalf("setup failed: %v", err) + } + defer func() { _ = os.Remove(outside) }() + + main := "[INCLUDES]\nbefore = ../escaped.conf\n\n[Definition]\nfailregex = MAIN_REGEX\n" + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(out, "ESCAPED_CONTENT") { + t.Fatalf("include name must not escape the filter directory, got:\n%s", out) + } + }) + + t.Run("main [DEFAULT] variables shadow the included ones", func(t *testing.T) { + dir := t.TempDir() + writeFilterFile(t, dir, "common.conf", "[DEFAULT]\n__prefix_line = INCLUDED_VALUE\nkeepme = KEEP_VALUE\n") + main := "[INCLUDES]\nbefore = common.conf\n\n[DEFAULT]\n__prefix_line = MAIN_VALUE\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(out, "INCLUDED_VALUE") { + t.Fatalf("shadowed variable must be removed from the include, got:\n%s", out) + } + if !strings.Contains(out, "MAIN_VALUE") { + t.Fatalf("main definition must survive, got:\n%s", out) + } + if !strings.Contains(out, "KEEP_VALUE") { + t.Fatalf("non-shadowed included variable must survive, got:\n%s", out) + } + }) + + t.Run("include without trailing newline still separates from main", func(t *testing.T) { + dir := t.TempDir() + writeFilterFile(t, dir, "common.conf", "NO_TRAILING_NEWLINE") + main := "[INCLUDES]\nbefore = common.conf\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "NO_TRAILING_NEWLINE\n") { + t.Fatalf("a newline must be inserted after an include lacking one, got:\n%q", out) + } + }) + + t.Run("[INCLUDES] directives are stripped from the emitted content", func(t *testing.T) { + dir := t.TempDir() + writeFilterFile(t, dir, "common.conf", "COMMON\n") + main := "[INCLUDES]\nbefore = common.conf\n\n[Definition]\nfailregex = MAIN_REGEX\n" + + out, err := resolveFilterIncludes(main, dir, "sshd") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(out, "[INCLUDES]") || strings.Contains(out, "before = common.conf") { + t.Fatalf("[INCLUDES] section must not be emitted, got:\n%s", out) + } + }) +} + +func TestDedupeConfigBaseNames(t *testing.T) { + t.Run("local shadows conf and result is sorted", func(t *testing.T) { + got := dedupeConfigBaseNames( + []string{"/f/sshd.local", "/f/zzz.local"}, + []string{"/f/sshd.conf", "/f/apache.conf"}, + ) + want := []string{"apache", "sshd", "zzz"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } + }) + + t.Run("documentation and backup files are excluded", func(t *testing.T) { + got := dedupeConfigBaseNames(nil, []string{ + "/f/README.conf", "/f/sshd.conf", "/f/old.conf.bak", "/f/x.conf.rpmnew", + }) + if len(got) != 1 || got[0] != "sshd" { + t.Fatalf("expected only sshd, got %v", got) + } + }) + + t.Run("empty input", func(t *testing.T) { + if got := dedupeConfigBaseNames(nil, nil); len(got) != 0 { + t.Fatalf("expected empty, got %v", got) + } + }) +} + +func TestExtractVariablesFromContent(t *testing.T) { + vars := extractVariablesFromContent("[DEFAULT]\nfoo = 1\n# comment = 2\nbar=3\n\n[Definition]\nbaz = 4\n") + if !vars["foo"] || !vars["bar"] { + t.Fatalf("expected foo and bar from [DEFAULT], got %v", vars) + } + if vars["baz"] { + t.Fatalf("variables outside [DEFAULT] must be ignored, got %v", vars) + } + if vars["# comment"] || vars["comment"] { + t.Fatalf("comments must be ignored, got %v", vars) + } +} + +func TestRemoveDuplicateVariables(t *testing.T) { + t.Run("removes only shadowed [DEFAULT] entries", func(t *testing.T) { + included := "[DEFAULT]\ndrop = 1\nkeep = 2\n\n[Definition]\ndrop = 3\n" + out := removeDuplicateVariables(included, map[string]bool{"drop": true}) + if strings.Contains(out, "drop = 1") { + t.Fatalf("shadowed [DEFAULT] entry must be removed, got:\n%s", out) + } + if !strings.Contains(out, "keep = 2") { + t.Fatalf("unshadowed entry must survive, got:\n%s", out) + } + if !strings.Contains(out, "drop = 3") { + t.Fatalf("entries outside [DEFAULT] must survive, got:\n%s", out) + } + }) + + t.Run("no shadowing leaves content intact apart from newline normalization", func(t *testing.T) { + included := "[DEFAULT]\na = 1\n" + out := removeDuplicateVariables(included, map[string]bool{}) + if !strings.Contains(out, "a = 1") || !strings.Contains(out, "[DEFAULT]") { + t.Fatalf("content must be preserved, got:\n%s", out) + } + }) +} + +func TestParseJailConfigContent(t *testing.T) { + t.Run("sections parsed with enabled state", func(t *testing.T) { + jails := parseJailConfigContent("[sshd]\nenabled = true\n\n[nginx]\nenabled = false\n") + if len(jails) != 2 { + t.Fatalf("expected 2 jails, got %+v", jails) + } + if jails[0].JailName != "sshd" || !jails[0].Enabled { + t.Fatalf("sshd should be enabled, got %+v", jails[0]) + } + if jails[1].JailName != "nginx" || jails[1].Enabled { + t.Fatalf("nginx should be disabled, got %+v", jails[1]) + } + }) + + t.Run("DEFAULT and INCLUDES are not jails", func(t *testing.T) { + jails := parseJailConfigContent("[DEFAULT]\nenabled = true\n[INCLUDES]\nbefore = x\n[real]\nenabled = true\n") + if len(jails) != 1 || jails[0].JailName != "real" { + t.Fatalf("expected only the real jail, got %+v", jails) + } + }) + + t.Run("missing enabled key defaults to enabled", func(t *testing.T) { + jails := parseJailConfigContent("[sshd]\nport = ssh\n") + if len(jails) != 1 || !jails[0].Enabled { + t.Fatalf("jail without an enabled key defaults to true, got %+v", jails) + } + }) + + t.Run("empty content yields no jails", func(t *testing.T) { + if jails := parseJailConfigContent(""); len(jails) != 0 { + t.Fatalf("expected no jails, got %+v", jails) + } + }) +} + +func TestJailAccumulator(t *testing.T) { + t.Run("local overrides conf", func(t *testing.T) { + acc := newJailAccumulator() + acc.add("[sshd]\nenabled = false\n", "conf") + acc.add("[sshd]\nenabled = true\n", "local") + if len(acc.jails) != 1 { + t.Fatalf("expected a single merged jail, got %+v", acc.jails) + } + if !acc.jails[0].Enabled { + t.Fatalf(".local must win over .conf, got %+v", acc.jails[0]) + } + }) + + t.Run("conf does not override local", func(t *testing.T) { + acc := newJailAccumulator() + acc.add("[sshd]\nenabled = true\n", "local") + acc.add("[sshd]\nenabled = false\n", "conf") + if !acc.jails[0].Enabled { + t.Fatalf(".conf must not override .local, got %+v", acc.jails[0]) + } + }) + + t.Run("same type re-definition wins", func(t *testing.T) { + acc := newJailAccumulator() + acc.add("[sshd]\nenabled = true\n", "local") + acc.add("[sshd]\nenabled = false\n", "local") + if acc.jails[0].Enabled { + t.Fatalf("later same-type definition must win, got %+v", acc.jails[0]) + } + }) + + t.Run("insertion order preserved and DEFAULT skipped", func(t *testing.T) { + acc := newJailAccumulator() + acc.add("[DEFAULT]\nenabled = true\n[b]\nenabled = true\n[a]\nenabled = true\n", "conf") + if len(acc.jails) != 2 || acc.jails[0].JailName != "b" || acc.jails[1].JailName != "a" { + t.Fatalf("expected [b a] in insertion order, got %+v", acc.jails) + } + }) +} diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index 2faa873..f4cded8 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -21,7 +21,6 @@ import ( "os" "os/exec" "path/filepath" - "sort" "strings" ) @@ -189,51 +188,16 @@ func DiscoverFiltersFromFiles(configPath string) ([]string, error) { return nil, err } - filterMap := make(map[string]bool) - processedFiles := make(map[string]bool) - + var localFiles, confFiles []string for _, filePath := range files { - if !strings.HasSuffix(filePath, ".local") { - continue - } - - filename := filepath.Base(filePath) - baseName := strings.TrimSuffix(filename, ".local") - if baseName == "" { - continue + switch { + case strings.HasSuffix(filePath, ".local"): + localFiles = append(localFiles, filePath) + case strings.HasSuffix(filePath, ".conf"): + confFiles = append(confFiles, filePath) } - - if processedFiles[baseName] { - continue - } - - processedFiles[baseName] = true - filterMap[baseName] = true } - - for _, filePath := range files { - if !strings.HasSuffix(filePath, ".conf") { - continue - } - filename := filepath.Base(filePath) - baseName := strings.TrimSuffix(filename, ".conf") - if baseName == "" { - continue - } - if processedFiles[baseName] { - continue - } - processedFiles[baseName] = true - filterMap[baseName] = true - } - - var filters []string - for name := range filterMap { - filters = append(filters, name) - } - sort.Strings(filters) - - return filters, nil + return dedupeConfigBaseNames(localFiles, confFiles), nil } // Creates a new filter at the given config path. @@ -359,7 +323,7 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b continue } - // Check for end of [DEFAULT] section (next section starts) + // Check for end of [DEFAULT] section if inDefaultSection && strings.HasPrefix(trimmed, "[") { inDefaultSection = false result.WriteString(originalLine) @@ -390,7 +354,46 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b return result.String() } +// Reads an included filter by base name, preferring .local over .conf. +type filterIncludeReader func(baseName string) (content string, path string, err error) + +func localFilterIncludeReader(filterDPath string) filterIncludeReader { + return func(baseName string) (string, string, error) { + localPath, err := resolveWithinDir(filterDPath, baseName, ".local") + if err != nil { + return "", "", fmt.Errorf("invalid include filter name %q: %w", baseName, err) + } + confPath, err := resolveWithinDir(filterDPath, baseName, ".conf") + if err != nil { + return "", "", fmt.Errorf("invalid include filter name %q: %w", baseName, err) + } + if content, err := os.ReadFile(localPath); err == nil { + return string(content), localPath, nil + } + content, err := os.ReadFile(confPath) + if err != nil { + return "", "", fmt.Errorf("could not load included filter file '%s' or '%s': %w", localPath, confPath, err) + } + return string(content), confPath, nil + } +} + +// Strips a .local/.conf extension to get the include's base name +func filterIncludeBaseName(fileName string) string { + if base, ok := strings.CutSuffix(fileName, ".local"); ok { + return base + } + if base, ok := strings.CutSuffix(fileName, ".conf"); ok { + return base + } + return fileName +} + func resolveFilterIncludes(filterContent string, filterDPath string, currentFilterName string) (string, error) { + return resolveFilterIncludesWith(filterContent, currentFilterName, localFilterIncludeReader(filterDPath)) +} + +func resolveFilterIncludesWith(filterContent string, currentFilterName string, readInclude filterIncludeReader) (string, error) { lines := strings.Split(filterContent, "\n") var beforeFiles []string var afterFiles []string @@ -448,44 +451,19 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt var combined strings.Builder for _, fileName := range beforeFiles { - baseName := fileName - if strings.HasSuffix(baseName, ".local") { - baseName = strings.TrimSuffix(baseName, ".local") - } else if strings.HasSuffix(baseName, ".conf") { - baseName = strings.TrimSuffix(baseName, ".conf") - } - + baseName := filterIncludeBaseName(fileName) if baseName == currentFilterName { debugf("Skipping self-inclusion of filter '%s' in before files", baseName) continue } - localPath, err := resolveWithinDir(filterDPath, baseName, ".local") - if err != nil { - debugf("Skipping invalid include filter name %q: %v", baseName, err) - continue - } - confPath, err := resolveWithinDir(filterDPath, baseName, ".conf") + contentStr, path, err := readInclude(baseName) if err != nil { - debugf("Skipping invalid include filter name %q: %v", baseName, err) - continue - } - - var content []byte - var filePath string - - if content, err = os.ReadFile(localPath); err == nil { - filePath = localPath - debugf("Loading included filter file from .local: %s", filePath) - } else if content, err = os.ReadFile(confPath); err == nil { - filePath = confPath - debugf("Loading included filter file from .conf: %s", filePath) - } else { - debugf("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err) + debugf("Warning: %v", err) continue } + debugf("Loading included filter file: %s", path) - contentStr := string(content) // Remove variables from included file that are defined in main filter. cleanedContent := removeDuplicateVariables(contentStr, mainVariables) combined.WriteString(cleanedContent) @@ -501,38 +479,15 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt } for _, fileName := range afterFiles { - baseName := fileName - if strings.HasSuffix(baseName, ".local") { - baseName = strings.TrimSuffix(baseName, ".local") - } else if strings.HasSuffix(baseName, ".conf") { - baseName = strings.TrimSuffix(baseName, ".conf") - } + baseName := filterIncludeBaseName(fileName) - localPath, err := resolveWithinDir(filterDPath, baseName, ".local") + contentStr, path, err := readInclude(baseName) if err != nil { - debugf("Skipping invalid include filter name %q: %v", baseName, err) - continue - } - confPath, err := resolveWithinDir(filterDPath, baseName, ".conf") - if err != nil { - debugf("Skipping invalid include filter name %q: %v", baseName, err) + debugf("Warning: %v", err) continue } + debugf("Loading included filter file: %s", path) - var content []byte - var filePath string - - if content, err = os.ReadFile(localPath); err == nil { - filePath = localPath - debugf("Loading included filter file from .local: %s", filePath) - } else if content, err = os.ReadFile(confPath); err == nil { - filePath = confPath - debugf("Loading included filter file from .conf: %s", filePath) - } else { - debugf("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err) - continue - } - contentStr := string(content) cleanedContent := removeDuplicateVariables(contentStr, mainVariables) combined.WriteString("\n") combined.WriteString(cleanedContent) diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index cf1d5da..fe25e9a 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -18,6 +18,7 @@ package fail2ban import ( "bufio" + "errors" "fmt" "os" "path/filepath" @@ -27,6 +28,8 @@ import ( "time" ) +var ErrLogpathInaccessible = errors.New("logpath directory not accessible to the connector") + func ensureJailLocalFile(jailName, configPath string) error { jailName = strings.TrimSpace(jailName) if jailName == "" { @@ -185,9 +188,12 @@ func DiscoverJailsFromFiles(configPath string) ([]JailInfo, error) { var allJails []JailInfo processedFiles := make(map[string]bool) + jailIndex := make(map[string]int) processedJails := make(map[string]bool) - // Parse .local files + // Parse .local files (sorted) + // Fail2ban reads jail.d lexically with last-wins semantics, so a jail defined in several .local files takes its + // state from the LAST file -> mirror that here or the UI would show a state the daemon does not have. for _, filePath := range files { if !strings.HasSuffix(filePath, ".local") { continue @@ -210,7 +216,13 @@ func DiscoverJailsFromFiles(configPath string) ([]JailInfo, error) { } for _, jail := range jails { - if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { + if jail.JailName == "" || jail.JailName == "DEFAULT" { + continue + } + if idx, seen := jailIndex[jail.JailName]; seen { + allJails[idx].Enabled = jail.Enabled + } else { + jailIndex[jail.JailName] = len(allJails) allJails = append(allJails, jail) processedJails[jail.JailName] = true } @@ -354,55 +366,45 @@ func GetAllJails(configPath string) ([]JailInfo, error) { } func parseJailConfigFile(path string) ([]JailInfo, error) { - var jails []JailInfo - file, err := os.Open(path) + content, err := os.ReadFile(path) if err != nil { return nil, err } - defer file.Close() + return parseJailConfigContent(string(content)), nil +} - scanner := bufio.NewScanner(file) +// Parses jail sections out of a jail.d file body. Shared by the local and SSH +func parseJailConfigContent(content string) []JailInfo { + var jails []JailInfo + scanner := bufio.NewScanner(strings.NewReader(content)) var currentJail string + enabled := true ignoredSections := map[string]bool{ "DEFAULT": true, "INCLUDES": true, } + flush := func() { + if currentJail != "" && !ignoredSections[currentJail] { + jails = append(jails, JailInfo{JailName: currentJail, Enabled: enabled}) + } + } - enabled := true for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - if currentJail != "" && !ignoredSections[currentJail] { - jails = append(jails, JailInfo{ - JailName: currentJail, - Enabled: enabled, - }) - } - currentJail = strings.TrimSpace(strings.Trim(line, "[]")) - if currentJail == "" { - currentJail = "" - enabled = true - continue - } + flush() + currentJail = strings.Trim(line, "[]") enabled = true } else if strings.HasPrefix(strings.ToLower(line), "enabled") { - if currentJail != "" { - parts := strings.Split(line, "=") - if len(parts) == 2 { - value := strings.TrimSpace(parts[1]) - enabled = strings.EqualFold(value, "true") - } + parts := strings.Split(line, "=") + if len(parts) == 2 { + enabled = strings.EqualFold(strings.TrimSpace(parts[1]), "true") } } } - if currentJail != "" && !ignoredSections[currentJail] { - jails = append(jails, JailInfo{ - JailName: currentJail, - Enabled: enabled, - }) - } - return jails, scanner.Err() + flush() + return jails } // ========================================================================= @@ -424,73 +426,136 @@ func UpdateJailEnabledStates(updates map[string]bool, configPath string) error { } debugf("Processing jail: %s, enabled: %t", jailName, enabled) - if err := ensureJailLocalFile(jailName, configPath); err != nil { - return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) - } - jailFilePath, err := resolveWithinDir(jailDPath, jailName, ".local") + definingFiles, err := jailFilesDefining(jailName, jailDPath) if err != nil { return err } - debugf("Jail file path: %s", jailFilePath) - content, err := os.ReadFile(jailFilePath) + if len(definingFiles) == 0 { + if err := ensureJailLocalFile(jailName, configPath); err != nil { + return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) + } + jailFilePath, err := resolveWithinDir(jailDPath, jailName, ".local") + if err != nil { + return err + } + definingFiles = []string{jailFilePath} + } + for _, jailFilePath := range definingFiles { + if err := setJailEnabledInFile(jailFilePath, jailName, enabled); err != nil { + return err + } + debugf("Updated jail %s: enabled = %t (file: %s)", jailName, enabled, jailFilePath) + } + } + return nil +} + +// Returns all jail.d/*.local files (sorted) containing a [jailName] section +func jailFilesDefining(jailName, jailDPath string) ([]string, error) { + entries, err := os.ReadDir(jailDPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read jail.d directory %s: %w", jailDPath, err) + } + sectionHeader := fmt.Sprintf("[%s]", jailName) + var files []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".local") { + continue + } + path := filepath.Join(jailDPath, entry.Name()) + content, err := os.ReadFile(path) if err != nil { - return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err) + debugf("Skipping unreadable jail file %s: %v", path, err) + continue } - var lines []string - if len(content) > 0 { - lines = strings.Split(string(content), "\n") - } else { - lines = []string{fmt.Sprintf("[%s]", jailName)} + for _, line := range strings.Split(string(content), "\n") { + if strings.TrimSpace(line) == sectionHeader { + files = append(files, path) + break + } } - var outputLines []string - var foundEnabled bool - var currentJail string + } + return files, nil +} - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { - currentJail = strings.Trim(trimmed, "[]") - outputLines = append(outputLines, line) - } else if strings.HasPrefix(strings.ToLower(trimmed), "enabled") { - if currentJail == jailName { - outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) - foundEnabled = true - } else { - outputLines = append(outputLines, line) - } +// Rewrites (or inserts) the enabled line of the [jailName] section in one file +func setJailEnabledInFile(jailFilePath, jailName string, enabled bool) error { + content, err := os.ReadFile(jailFilePath) + if err != nil { + return fmt.Errorf("failed to read jail .local file %s: %w", jailFilePath, err) + } + newContent := rewriteJailEnabled(string(content), jailName, enabled) + if err := os.WriteFile(jailFilePath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) + } + return nil +} + +func containsJailSection(content, jailName string) bool { + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) == "["+jailName+"]" { + return true + } + } + return false +} + +// Rewrites (or inserts) the enabled line of the [jailName] section in the given file content +// Shared by the local and SSH connectors +func rewriteJailEnabled(content, jailName string, enabled bool) string { + var lines []string + if len(content) > 0 { + lines = strings.Split(content, "\n") + } else { + lines = []string{fmt.Sprintf("[%s]", jailName)} + } + var outputLines []string + var foundEnabled bool + var currentJail string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + currentJail = strings.Trim(trimmed, "[]") + outputLines = append(outputLines, line) + } else if strings.HasPrefix(strings.ToLower(trimmed), "enabled") { + if currentJail == jailName { + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) + foundEnabled = true } else { outputLines = append(outputLines, line) } + } else { + outputLines = append(outputLines, line) } - if !foundEnabled { - var newLines []string - for i, line := range outputLines { - newLines = append(newLines, line) - if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { - // Insert enabled line after the section header - newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) - if i+1 < len(outputLines) { - newLines = append(newLines, outputLines[i+1:]...) - } - break + } + if !foundEnabled { + var newLines []string + for i, line := range outputLines { + newLines = append(newLines, line) + if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) { + // Insert enabled line after the section header + newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled)) + if i+1 < len(outputLines) { + newLines = append(newLines, outputLines[i+1:]...) } + break } - if len(newLines) > len(outputLines) { - outputLines = newLines - } else { - outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) - } - } - newContent := strings.Join(outputLines, "\n") - if !strings.HasSuffix(newContent, "\n") { - newContent += "\n" } - if err := os.WriteFile(jailFilePath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) + if len(newLines) > len(outputLines) { + outputLines = newLines + } else { + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled)) } - debugf("Updated jail %s: enabled = %t (file: %s)", jailName, enabled, jailFilePath) } - return nil + newContent := strings.Join(outputLines, "\n") + if !strings.HasSuffix(newContent, "\n") { + newContent += "\n" + } + return newContent } // Returns the full jail configuration from /etc/fail2ban/jail.d/{jailName}.local @@ -655,6 +720,11 @@ func TestLogpath(logpath string) ([]string, error) { if err != nil { return nil, fmt.Errorf("invalid glob pattern: %w", err) } + if len(matched) == 0 { + if _, statErr := os.ReadDir(filepath.Dir(logpath)); statErr != nil && os.IsPermission(statErr) { + return nil, ErrLogpathInaccessible + } + } matches = matched } else { info, err := os.Stat(logpath) @@ -662,12 +732,18 @@ func TestLogpath(logpath string) ([]string, error) { if os.IsNotExist(err) { return []string{}, nil } + if os.IsPermission(err) { + return nil, ErrLogpathInaccessible + } return nil, fmt.Errorf("failed to stat path: %w", err) } if info.IsDir() { entries, err := os.ReadDir(logpath) if err != nil { + if os.IsPermission(err) { + return nil, ErrLogpathInaccessible + } return nil, fmt.Errorf("failed to read directory: %w", err) } for _, entry := range entries { diff --git a/internal/fail2ban/jail_management_test.go b/internal/fail2ban/jail_management_test.go new file mode 100644 index 0000000..fe4063a --- /dev/null +++ b/internal/fail2ban/jail_management_test.go @@ -0,0 +1,137 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fail2ban + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A jail defined in two jail.d files, the UI's own .local (disabled) and a custom file (enabled) that fail2ban +// reads later (lexical last-wins). +func setupDuplicateJailDir(t *testing.T) string { + t.Helper() + configPath := t.TempDir() + jailD := filepath.Join(configPath, "jail.d") + if err := os.MkdirAll(jailD, 0o755); err != nil { + t.Fatal(err) + } + write := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(jailD, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + write("recidive.local", "[recidive]\nenabled = false\n") + write("swissmakers-recidive.local", "[recidive]\nenabled = true\nfilter = recidive\nlogpath = /var/log/fail2ban.log\n") + write("apache-auth.local", "[apache-auth]\nenabled = true\n") + return configPath +} + +func TestDiscoverJailsFromFilesLastWins(t *testing.T) { + configPath := setupDuplicateJailDir(t) + + jails, err := DiscoverJailsFromFiles(configPath) + if err != nil { + t.Fatalf("DiscoverJailsFromFiles: %v", err) + } + byName := map[string]bool{} + for _, j := range jails { + byName[j.JailName] = j.Enabled + } + // swissmakers-recidive.local is read after recidive.local by fail2ban, so + // the effective state is enabled -> the UI must report the same. + if enabled, ok := byName["recidive"]; !ok || !enabled { + t.Fatalf("recidive enabled = %v (found=%v), want true (last-wins)", enabled, ok) + } + if enabled, ok := byName["apache-auth"]; !ok || !enabled { + t.Fatalf("apache-auth enabled = %v (found=%v), want true", enabled, ok) + } +} + +func TestUpdateJailEnabledStatesWritesAllDefiningFiles(t *testing.T) { + configPath := setupDuplicateJailDir(t) + jailD := filepath.Join(configPath, "jail.d") + + if err := UpdateJailEnabledStates(map[string]bool{"recidive": false}, configPath); err != nil { + t.Fatalf("UpdateJailEnabledStates: %v", err) + } + + for _, file := range []string{"recidive.local", "swissmakers-recidive.local"} { + content, err := os.ReadFile(filepath.Join(jailD, file)) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + if !strings.Contains(string(content), "enabled = false") { + t.Fatalf("%s does not contain 'enabled = false':\n%s", file, content) + } + if strings.Contains(string(content), "enabled = true") { + t.Fatalf("%s still contains the old enabled = true line:\n%s", file, content) + } + } + // Other settings in the custom file must survive the rewrite. + content, _ := os.ReadFile(filepath.Join(jailD, "swissmakers-recidive.local")) + if !strings.Contains(string(content), "logpath = /var/log/fail2ban.log") { + t.Fatalf("swissmakers-recidive.local lost unrelated settings:\n%s", content) + } + + // The UI list must reflect the now truly disabled jail. + jails, err := DiscoverJailsFromFiles(configPath) + if err != nil { + t.Fatalf("DiscoverJailsFromFiles: %v", err) + } + for _, j := range jails { + if j.JailName == "recidive" && j.Enabled { + t.Fatal("recidive still reported enabled after disabling") + } + } +} + +func TestUpdateJailEnabledStatesCreatesFileWhenUndefined(t *testing.T) { + configPath := setupDuplicateJailDir(t) + jailD := filepath.Join(configPath, "jail.d") + + if err := UpdateJailEnabledStates(map[string]bool{"sshd": true}, configPath); err != nil { + t.Fatalf("UpdateJailEnabledStates: %v", err) + } + content, err := os.ReadFile(filepath.Join(jailD, "sshd.local")) + if err != nil { + t.Fatalf("sshd.local not created: %v", err) + } + if !strings.Contains(string(content), "[sshd]") || !strings.Contains(string(content), "enabled = true") { + t.Fatalf("sshd.local content unexpected:\n%s", content) + } +} + +func TestContainsJailSection(t *testing.T) { + content := "# comment\n[swissmakers-apache-scanner]\nenabled = true\n" + if !containsJailSection(content, "swissmakers-apache-scanner") { + t.Fatal("expected section to be found") + } + if containsJailSection(content, "other-jail") { + t.Fatal("did not expect section for other jail") + } + garbled := "mux_client_request_session: session request failed\nControlSocket /tmp/x already exists\n" + if containsJailSection(garbled, "swissmakers-apache-scanner") { + t.Fatal("garbled content must not contain the jail section") + } + if !containsJailSection(" [j1] \n", "j1") { + t.Fatal("expected trimmed section header to match") + } +} diff --git a/internal/fail2ban/local_managed_files.go b/internal/fail2ban/local_managed_files.go index c3b2177..72e24e3 100644 --- a/internal/fail2ban/local_managed_files.go +++ b/internal/fail2ban/local_managed_files.go @@ -58,7 +58,7 @@ func EnsureManagedJailLocal(configPath string, content []byte) error { existingContent = string(raw) fileExists = strings.TrimSpace(existingContent) != "" } - if fileExists && !strings.Contains(existingContent, "ui-custom-action") { + if fileExists && !strings.Contains(existingContent, managedJailLocalMarker) { debugf("jail.local file exists but is not managed by Fail2ban-UI - skipping overwrite") return nil } diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index 0c14c0b..6603806 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -32,10 +32,10 @@ import ( // Connector is the communication backend for a Fail2ban server. type Connector interface { - ID() string Server() shared.Fail2banServer GetJailInfos(ctx context.Context) ([]JailInfo, error) + GetJailSummary(ctx context.Context) (*JailSummary, error) GetBannedIPs(ctx context.Context, jail string) ([]string, error) UnbanIP(ctx context.Context, jail, ip string) error BanIP(ctx context.Context, jail, ip string) error @@ -55,7 +55,6 @@ type Connector interface { // Jail configuration operations GetJailConfig(ctx context.Context, jail string) (string, string, error) SetJailConfig(ctx context.Context, jail, content string) error - TestLogpath(ctx context.Context, logpath string) ([]string, error) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) // Default settings operations @@ -84,8 +83,11 @@ type Manager struct { mu sync.RWMutex connectors map[string]Connector defaultServerID string + tunnelMonStop chan struct{} } +const tunnelCheckInterval = 45 * time.Second + var ( managerOnce sync.Once managerInst *Manager @@ -105,6 +107,7 @@ func (m *Manager) ReloadFromServers(servers []shared.Fail2banServer) error { m.mu.Lock() defer m.mu.Unlock() + old := m.connectors connectors := make(map[string]Connector) defaultID := pickDefaultServerID(servers) @@ -112,6 +115,9 @@ func (m *Manager) ReloadFromServers(servers []shared.Fail2banServer) error { if !srv.Enabled { continue } + if oldSSH, ok := old[srv.ID].(*SSHConnector); ok && sshTunnelConfigChanged(oldSSH, srv) { + _ = oldSSH.Close() + } conn, err := newConnectorForServer(srv) if err != nil { return fmt.Errorf("failed to initialise connector for %s (%s): %w", srv.Name, srv.ID, err) @@ -119,11 +125,83 @@ func (m *Manager) ReloadFromServers(servers []shared.Fail2banServer) error { connectors[srv.ID] = conn } + // Tear down tunneled masters of servers that were removed or disabled. + for id, conn := range old { + if _, still := connectors[id]; still { + continue + } + if oldSSH, ok := conn.(*SSHConnector); ok && oldSSH.tunnelPort > 0 { + _ = oldSSH.Close() + } + } + m.connectors = connectors m.defaultServerID = defaultID + m.syncTunnelMonitorLocked() return nil } +// Starts or stops the tunnel health monitor +func (m *Manager) syncTunnelMonitorLocked() { + hasTunnels := false + for _, conn := range m.connectors { + if sc, ok := conn.(*SSHConnector); ok && sc.tunnelPort > 0 { + hasTunnels = true + break + } + } + switch { + case hasTunnels && m.tunnelMonStop == nil: + m.tunnelMonStop = make(chan struct{}) + go m.tunnelMonitorLoop(m.tunnelMonStop) + log.Printf("reverse tunnel health monitor started (interval %s)", tunnelCheckInterval) + case !hasTunnels && m.tunnelMonStop != nil: + close(m.tunnelMonStop) + m.tunnelMonStop = nil + log.Printf("reverse tunnel health monitor stopped (no tunneled servers)") + } +} + +// Periodically verifies reverse-tunnel SSH masters and re-establishes dead ones. +func (m *Manager) tunnelMonitorLoop(stop <-chan struct{}) { + ticker := time.NewTicker(tunnelCheckInterval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + m.CheckTunnels(ctx) + cancel() + } + } +} + +// Runs the reverse-tunnel health check for every SSH connector with an active tunnel +func (m *Manager) CheckTunnels(ctx context.Context) { + m.mu.RLock() + var tunneled []*SSHConnector + for _, conn := range m.connectors { + if sc, ok := conn.(*SSHConnector); ok && sc.tunnelPort > 0 { + tunneled = append(tunneled, sc) + } + } + m.mu.RUnlock() + + var wg sync.WaitGroup + for _, sc := range tunneled { + wg.Add(1) + go func(sc *SSHConnector) { + defer wg.Done() + checkCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + sc.CheckTunnelHealth(checkCtx) + }(sc) + } + wg.Wait() +} + func pickDefaultServerID(servers []shared.Fail2banServer) string { var fallback string for _, srv := range servers { diff --git a/internal/fail2ban/provider.go b/internal/fail2ban/provider.go index 0c25a04..664912f 100644 --- a/internal/fail2ban/provider.go +++ b/internal/fail2ban/provider.go @@ -23,6 +23,7 @@ type Provider interface { DebugLog(format string, v ...interface{}) CallbackURL() string CallbackSecret() string + ServerPort() int BuildFail2banActionConfig(callbackURL, serverID, secret string) string BuildJailLocalContent() string } @@ -61,6 +62,8 @@ func (noopProvider) CallbackURL() string { return "" } func (noopProvider) CallbackSecret() string { return "" } +func (noopProvider) ServerPort() int { return 0 } + func (noopProvider) BuildFail2banActionConfig(callbackURL, serverID, secret string) string { return "" } diff --git a/internal/fail2ban/variable_resolver.go b/internal/fail2ban/variable_resolver.go index 1a8f7b9..3c20c5a 100644 --- a/internal/fail2ban/variable_resolver.go +++ b/internal/fail2ban/variable_resolver.go @@ -19,6 +19,7 @@ package fail2ban import ( "bufio" "fmt" + "io" "os" "path/filepath" "regexp" @@ -56,8 +57,11 @@ func searchVariableInFile(filePath, varName string) (string, error) { return "", err } defer file.Close() + return searchVariableInReader(file, filePath, varName) +} - scanner := bufio.NewScanner(file) +func searchVariableInReader(r io.Reader, filePath, varName string) (string, error) { + scanner := bufio.NewScanner(r) var currentVar string var currentValue strings.Builder var inMultiLine bool @@ -169,6 +173,34 @@ func searchVariableInFile(filePath, varName string) (string, error) { return "", nil } +type variableSource interface { + findVariable(varName string) (string, error) +} +type fsVariableSource struct{ root string } + +func (s fsVariableSource) findVariable(varName string) (string, error) { + return findVariableDefinition(varName, s.root) +} + +type snapshotVariableSource struct{ files []remoteFile } + +func (s snapshotVariableSource) findVariable(varName string) (string, error) { + for _, suffix := range []string{".local", ".conf"} { + for _, f := range s.files { + if !strings.HasSuffix(strings.ToLower(f.path), suffix) { + continue + } + value, err := searchVariableInReader(strings.NewReader(f.content), f.path, varName) + if err != nil || value == "" { + continue + } + debugf("findVariable: '%s' = '%s' (from %s)", varName, value, f.path) + return value, nil + } + } + return "", fmt.Errorf("variable '%s' not found in Fail2Ban configuration files", varName) +} + // Searches for a variable definition in all .local files first, then .conf files under /etc/fail2ban/ and subdirectories. // Returns the FIRST value found (prioritizing .local over .conf). func findVariableDefinition(varName, fail2banPath string) (string, error) { @@ -249,7 +281,7 @@ func findVariableDefinition(varName, fail2banPath string) (string, error) { // Resolution // ========================================================================= -func resolveVariableRecursive(varName string, visited map[string]bool, fail2banPath string) (string, error) { +func resolveVariableRecursive(varName string, visited map[string]bool, src variableSource) (string, error) { if visited[varName] { return "", fmt.Errorf("circular reference detected for variable '%s'", varName) } @@ -257,7 +289,7 @@ func resolveVariableRecursive(varName string, visited map[string]bool, fail2banP visited[varName] = true defer delete(visited, varName) - value, err := findVariableDefinition(varName, fail2banPath) + value, err := src.findVariable(varName) if err != nil { return "", err } @@ -280,7 +312,7 @@ func resolveVariableRecursive(varName string, visited map[string]bool, fail2banP } debugf("resolveVariableRecursive: resolving nested variable '%s' for '%s'", nestedVar, varName) - nestedValue, err := resolveVariableRecursive(nestedVar, visited, fail2banPath) + nestedValue, err := resolveVariableRecursive(nestedVar, visited, src) if err != nil { return "", fmt.Errorf("failed to resolve variable '%s' in '%s': %w", nestedVar, varName, err) } @@ -311,8 +343,11 @@ func resolveVariableRecursive(varName string, visited map[string]bool, fail2banP return resolved, nil } -// Expands %(var)s patterns in logpath using fail2ban config path. func ResolveLogpathVariables(logpath, fail2banPath string) (string, error) { + return resolveLogpathVariablesFrom(logpath, fsVariableSource{root: fail2banPath}) +} + +func resolveLogpathVariablesFrom(logpath string, src variableSource) (string, error) { if logpath == "" { return "", nil } @@ -333,7 +368,7 @@ func ResolveLogpathVariables(logpath, fail2banPath string) (string, error) { visited := make(map[string]bool) for _, varName := range variables { debugf("ResolveLogpathVariables: resolving variable '%s' from string '%s'", varName, resolved) - varValue, err := resolveVariableRecursive(varName, visited, fail2banPath) + varValue, err := resolveVariableRecursive(varName, visited, src) if err != nil { return "", fmt.Errorf("failed to resolve variable '%s': %w", varName, err) } diff --git a/internal/fail2ban/variable_resolver_test.go b/internal/fail2ban/variable_resolver_test.go index c0131dc..245978e 100644 --- a/internal/fail2ban/variable_resolver_test.go +++ b/internal/fail2ban/variable_resolver_test.go @@ -19,6 +19,7 @@ package fail2ban import ( "os" "path/filepath" + "strings" "testing" ) @@ -39,3 +40,199 @@ func TestResolveLogpathVariablesAtPath_customRoot(t *testing.T) { t.Fatalf("resolved logpath: got %q", got) } } + +func writeConfigFile(t *testing.T, dir, name, content string) { + t.Helper() + full := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir for %s: %v", name, err) + } + if err := os.WriteFile(full, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} + +func TestResolveLogpathVariables(t *testing.T) { + t.Run("no variables passes through unchanged", func(t *testing.T) { + got, err := ResolveLogpathVariables("/var/log/auth.log", t.TempDir()) + if err != nil || got != "/var/log/auth.log" { + t.Fatalf("got %q, err %v", got, err) + } + }) + + t.Run("empty logpath", func(t *testing.T) { + got, err := ResolveLogpathVariables("", t.TempDir()) + if err != nil || got != "" { + t.Fatalf("got %q, err %v", got, err) + } + }) + + t.Run(".local shadows .conf", func(t *testing.T) { + root := t.TempDir() + writeConfigFile(t, root, "paths.conf", "logdir = /from-conf\n") + writeConfigFile(t, root, "paths.local", "logdir = /from-local\n") + + got, err := ResolveLogpathVariables("%(logdir)s/app.log", root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/from-local/app.log" { + t.Fatalf(".local must win over .conf, got %q", got) + } + }) + + t.Run("falls back to .conf when no .local defines it", func(t *testing.T) { + root := t.TempDir() + writeConfigFile(t, root, "paths.conf", "logdir = /from-conf\n") + + got, err := ResolveLogpathVariables("%(logdir)s/app.log", root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/from-conf/app.log" { + t.Fatalf("got %q", got) + } + }) + + t.Run("searches subdirectories", func(t *testing.T) { + root := t.TempDir() + writeConfigFile(t, root, "jail.d/custom.local", "deepvar = /deep/path\n") + + got, err := ResolveLogpathVariables("%(deepvar)s/x.log", root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/deep/path/x.log" { + t.Fatalf("got %q", got) + } + }) + + t.Run("nested variable references resolve", func(t *testing.T) { + root := t.TempDir() + writeConfigFile(t, root, "paths.local", "base = /var/log\nappdir = %(base)s/app\n") + + got, err := ResolveLogpathVariables("%(appdir)s/access.log", root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/var/log/app/access.log" { + t.Fatalf("got %q", got) + } + }) + + t.Run("multiple variables in one logpath", func(t *testing.T) { + root := t.TempDir() + writeConfigFile(t, root, "paths.local", "d1 = /a\nd2 = /b\n") + + got, err := ResolveLogpathVariables("%(d1)s/x %(d2)s/y", root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/a/x /b/y" { + t.Fatalf("got %q", got) + } + }) + + t.Run("multiline continuation is joined", func(t *testing.T) { + root := t.TempDir() + writeConfigFile(t, root, "paths.local", "multi = /one/a.log\n /two/b.log\n\nother = x\n") + + got, err := ResolveLogpathVariables("%(multi)s", root) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(got, "/one/a.log") || !strings.Contains(got, "/two/b.log") { + t.Fatalf("both continuation lines must be present, got %q", got) + } + }) + + t.Run("undefined variable is an error", func(t *testing.T) { + if _, err := ResolveLogpathVariables("%(nope)s/x.log", t.TempDir()); err == nil { + t.Fatal("expected an error for an undefined variable") + } + }) + + t.Run("circular reference is an error, not a hang", func(t *testing.T) { + root := t.TempDir() + writeConfigFile(t, root, "loop.local", "a = %(b)s\nb = %(a)s\n") + + if _, err := ResolveLogpathVariables("%(a)s", root); err == nil { + t.Fatal("expected an error for a circular reference") + } + }) + + t.Run("missing config root is an error", func(t *testing.T) { + if _, err := ResolveLogpathVariables("%(x)s", filepath.Join(t.TempDir(), "does-not-exist")); err == nil { + t.Fatal("expected an error when the config root is absent") + } + }) +} + +func TestSnapshotVariableSourceMatchesFilesystem(t *testing.T) { + files := []struct{ name, content string }{ + {"paths.conf", "logdir = /from-conf\nbase = /var/log\n"}, + {"paths.local", "logdir = /from-local\n"}, + {"jail.d/deep.local", "deepvar = %(base)s/deep\n"}, + {"multi.local", "multi = /one/a.log\n /two/b.log\n\nother = x\n"}, + } + + root := t.TempDir() + var snapshot []remoteFile + for _, f := range files { + writeConfigFile(t, root, f.name, f.content) + snapshot = append(snapshot, remoteFile{path: filepath.Join(root, f.name), content: f.content}) + } + + cases := []string{ + "%(logdir)s/app.log", + "%(base)s/x.log", + "%(deepvar)s/y.log", + "%(multi)s", + "/no/variables.log", + } + for _, logpath := range cases { + fsGot, fsErr := resolveLogpathVariablesFrom(logpath, fsVariableSource{root: root}) + snapGot, snapErr := resolveLogpathVariablesFrom(logpath, snapshotVariableSource{files: snapshot}) + if (fsErr == nil) != (snapErr == nil) { + t.Fatalf("%s: error mismatch fs=%v snapshot=%v", logpath, fsErr, snapErr) + } + if fsGot != snapGot { + t.Fatalf("%s: fs source gave %q, snapshot gave %q", logpath, fsGot, snapGot) + } + } + + got, err := resolveLogpathVariablesFrom("%(logdir)s/app.log", snapshotVariableSource{files: snapshot}) + if err != nil || got != "/from-local/app.log" { + t.Fatalf(".local must shadow .conf in the snapshot source, got %q (err %v)", got, err) + } +} + +func TestSnapshotVariableSourceUndefinedVariable(t *testing.T) { + src := snapshotVariableSource{files: []remoteFile{{path: "/etc/fail2ban/x.conf", content: "a = 1\n"}}} + if _, err := src.findVariable("missing"); err == nil { + t.Fatal("expected an error for an undefined variable") + } +} + +func TestExtractVariablesFromString(t *testing.T) { + cases := []struct { + in string + want []string + }{ + {"/var/log/auth.log", nil}, + {"%(a)s", []string{"a"}}, + {"%(a)s/%(b)s", []string{"a", "b"}}, + {"prefix-%(with_underscore)s-suffix", []string{"with_underscore"}}, + } + for _, tc := range cases { + got := extractVariablesFromString(tc.in) + if len(got) != len(tc.want) { + t.Fatalf("extractVariablesFromString(%q) = %v, want %v", tc.in, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("extractVariablesFromString(%q) = %v, want %v", tc.in, got, tc.want) + } + } + } +} diff --git a/internal/shared/server.go b/internal/shared/server.go index 116e586..8b6df7c 100644 --- a/internal/shared/server.go +++ b/internal/shared/server.go @@ -40,6 +40,7 @@ type Fail2banServer struct { IsDefault bool `json:"isDefault"` Enabled bool `json:"enabled"` ReverseTunnelEnabled bool `json:"reverseTunnelEnabled,omitempty"` + TunnelPort int `json:"tunnelPort,omitempty"` RestartNeeded bool `json:"restartNeeded"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 1791daf..9bceaa4 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -236,6 +236,7 @@ type ServerRecord struct { IsDefault bool Enabled bool ReverseTunnelEnabled bool + TunnelPort int NeedsRestart bool CreatedAt time.Time UpdatedAt time.Time @@ -592,7 +593,7 @@ func ListServers(ctx context.Context) ([]ServerRecord, error) { } rows, err := db.QueryContext(ctx, ` -SELECT id, name, type, host, port, socket_path, config_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, reverse_tunnel, needs_restart, created_at, updated_at +SELECT id, name, type, host, port, socket_path, config_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, reverse_tunnel, tunnel_port, needs_restart, created_at, updated_at FROM servers ORDER BY created_at`) if err != nil { @@ -606,7 +607,7 @@ ORDER BY created_at`) var host, socket, configPath, sshUser, sshKey, agentURL, agentSecret, hostname, tags sql.NullString var name, serverType sql.NullString var created, updated sql.NullString - var port sql.NullInt64 + var port, tunnelPort sql.NullInt64 var isDefault, enabled, reverseTunnel, needsRestart sql.NullInt64 if err := rows.Scan( @@ -626,6 +627,7 @@ ORDER BY created_at`) &isDefault, &enabled, &reverseTunnel, + &tunnelPort, &needsRestart, &created, &updated, @@ -648,6 +650,7 @@ ORDER BY created_at`) rec.IsDefault = intToBool(intFromNull(isDefault)) rec.Enabled = intToBool(intFromNull(enabled)) rec.ReverseTunnelEnabled = intToBool(intFromNull(reverseTunnel)) + rec.TunnelPort = intFromNull(tunnelPort) rec.NeedsRestart = intToBool(intFromNull(needsRestart)) if created.Valid { @@ -688,9 +691,9 @@ func ReplaceServers(ctx context.Context, servers []ServerRecord) error { stmt, err := tx.PrepareContext(ctx, ` INSERT INTO servers ( - id, name, type, host, port, socket_path, config_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, reverse_tunnel, needs_restart, created_at, updated_at + id, name, type, host, port, socket_path, config_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, reverse_tunnel, tunnel_port, needs_restart, created_at, updated_at ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )`) if err != nil { return err @@ -723,6 +726,7 @@ INSERT INTO servers ( boolToInt(srv.IsDefault), boolToInt(srv.Enabled), boolToInt(srv.ReverseTunnelEnabled), + srv.TunnelPort, boolToInt(srv.NeedsRestart), createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano), @@ -908,7 +912,31 @@ WHERE 1=1` return results, rows.Err() } -// GetBanEventByID returns a single ban event including the whois/logs fields. +// Returns the country and whois of the most recent ban event for the IP that carries any enrichment +func LatestBanEnrichmentForIP(ctx context.Context, ip string) (country, whois string, err error) { + if db == nil { + return "", "", errors.New("storage not initialised") + } + + const query = ` +SELECT country, whois +FROM ban_events +WHERE ip = ? AND event_type = 'ban' AND (COALESCE(country, '') != '' OR COALESCE(whois, '') != '') +ORDER BY occurred_at DESC, id DESC +LIMIT 1` + + var c, w sql.NullString + err = db.QueryRowContext(ctx, query, ip).Scan(&c, &w) + if errors.Is(err, sql.ErrNoRows) { + return "", "", nil + } + if err != nil { + return "", "", err + } + return stringFromNull(c), stringFromNull(w), nil +} + +// Returns a single ban event including the whois/logs fields. func GetBanEventByID(ctx context.Context, id int64) (BanEventRecord, bool, error) { if db == nil { return BanEventRecord{}, false, errors.New("storage not initialised") @@ -1028,32 +1056,6 @@ WHERE 1=1` return result, rows.Err() } -// Returns total number of ban events optionally filtered by time and server. -func CountBanEvents(ctx context.Context, since time.Time, serverID string) (int64, error) { - if db == nil { - return 0, errors.New("storage not initialised") - } - - query := ` -SELECT COUNT(*) -FROM ban_events -WHERE 1=1` - args := []any{} - - if serverID != "" { - query += " AND server_id = ?" - args = append(args, serverID) - } - - addOccurredAtSinceFilter(&query, &args, since) - - var total int64 - if err := db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil { - return 0, err - } - return total, nil -} - // Returns per-jail ban-event counts for one server since the provided timestamp, in a single query. func CountRecentBanEventsByJail(ctx context.Context, serverID string, since time.Time) (map[string]int, error) { if db == nil { @@ -1577,6 +1579,7 @@ CREATE TABLE IF NOT EXISTS servers ( is_default INTEGER, enabled INTEGER, reverse_tunnel INTEGER DEFAULT 0, + tunnel_port INTEGER DEFAULT 0, needs_restart INTEGER DEFAULT 0, created_at TEXT, updated_at TEXT @@ -1643,6 +1646,7 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_updated_at ON permanent_blocks(update `ALTER TABLE servers ADD COLUMN reverse_tunnel INTEGER DEFAULT 0`, `ALTER TABLE app_settings ADD COLUMN event_retention_days INTEGER DEFAULT 180`, `ALTER TABLE ban_events ADD COLUMN event_type TEXT NOT NULL DEFAULT 'ban'`, + `ALTER TABLE servers ADD COLUMN tunnel_port INTEGER DEFAULT 0`, } if _, err := db.ExecContext(ctx, createTables); err != nil { diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 336f469..65422f9 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -151,6 +151,7 @@ func TestReplaceServersRoundTrip(t *testing.T) { IsDefault: true, Enabled: true, ReverseTunnelEnabled: true, + TunnelPort: 8443, CreatedAt: time.Date(2026, 5, 27, 14, 30, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 5, 27, 15, 0, 0, 0, time.UTC), } @@ -681,3 +682,46 @@ func TestCountBanEventTotals(t *testing.T) { t.Fatalf("srv-1 totals = %d/%d/%d, want 3/1/2", overall, today, week) } } + +func TestLatestBanEnrichmentForIP(t *testing.T) { + initTestStorage(t) + ctx := context.Background() + + if _, err := RecordBanEvent(ctx, BanEventRecord{ + ServerID: "srv-1", ServerName: "s", Jail: "sshd", IP: "203.0.113.99", + Country: "DE", Whois: "old whois", EventType: "ban", + OccurredAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatalf("RecordBanEvent: %v", err) + } + if _, err := RecordBanEvent(ctx, BanEventRecord{ + ServerID: "srv-1", ServerName: "s", Jail: "sshd", IP: "203.0.113.99", + Country: "CH", Whois: "new whois", EventType: "ban", + OccurredAt: time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatalf("RecordBanEvent: %v", err) + } + if _, err := RecordBanEvent(ctx, BanEventRecord{ + ServerID: "srv-1", ServerName: "s", Jail: "sshd", IP: "203.0.113.99", + EventType: "unban", + OccurredAt: time.Date(2026, 7, 22, 0, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatalf("RecordBanEvent: %v", err) + } + + country, whois, err := LatestBanEnrichmentForIP(ctx, "203.0.113.99") + if err != nil { + t.Fatalf("LatestBanEnrichmentForIP: %v", err) + } + if country != "CH" || whois != "new whois" { + t.Fatalf("got country=%q whois=%q, want CH / new whois", country, whois) + } + + country, whois, err = LatestBanEnrichmentForIP(ctx, "198.51.100.1") + if err != nil { + t.Fatalf("LatestBanEnrichmentForIP miss: %v", err) + } + if country != "" || whois != "" { + t.Fatalf("miss returned country=%q whois=%q, want empty", country, whois) + } +} diff --git a/internal/version/version.go b/internal/version/version.go index 1352ee8..18d7d01 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -17,4 +17,4 @@ package version // Application version -const Version = "1.5.2" +const Version = "1.5.3" diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 3fff632..0e275dd 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -215,7 +215,8 @@ func buildErrorResponse(err error, fallbackKey string) gin.H { // Dashboard // ========================================================================= -// Returns a JSON summary of all jails for the selected server. +const summaryBannedPreviewLimit = 5 + func SummaryHandler(c *gin.Context) { conn, err := resolveConnector(c) if err != nil { @@ -223,39 +224,39 @@ func SummaryHandler(c *gin.Context) { return } - jailInfos, err := conn.GetJailInfos(c.Request.Context()) + summary, err := conn.GetJailSummary(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, buildErrorResponse(err, "dashboard.errors.summary_failed")) return } + resp := SummaryResponse{ServerID: conn.Server().ID, Jails: summary.Jails} + serverID := conn.Server().ID since := time.Now().UTC().Add(-1 * time.Hour) recentCounts, countErr := storage.CountRecentBanEventsByJail(c.Request.Context(), serverID, since) if countErr != nil { config.DebugLog("Warning: failed to count recent bans for server %s: %v", serverID, countErr) } - for i := range jailInfos { - jailInfos[i].NewInLastHour = recentCounts[jailInfos[i].JailName] - // Summary should only expose counters; banned IPs are loaded lazily via /api/jails/:jail/banned. - jailInfos[i].BannedIPs = []string{} - } - - resp := SummaryResponse{ - ServerID: serverID, - Jails: jailInfos, + for i := range resp.Jails { + resp.Jails[i].NewInLastHour = recentCounts[resp.Jails[i].JailName] + if len(resp.Jails[i].BannedIPs) > summaryBannedPreviewLimit { + resp.Jails[i].BannedIPs = resp.Jails[i].BannedIPs[:summaryBannedPreviewLimit] + } + if resp.Jails[i].BannedIPs == nil { + resp.Jails[i].BannedIPs = []string{} + } } - // Checks the jail.local integrity on every summary request to warn the user if not managed by Fail2ban-UI. - if exists, hasUI, chkErr := conn.CheckJailLocalIntegrity(c.Request.Context()); chkErr == nil { - if exists && !hasUI { - resp.JailLocalWarning = true - } else if !exists { - // File was removed (user finished migration) - initialize a fresh managed file - if err := conn.EnsureJailLocalStructure(c.Request.Context()); err != nil { - config.DebugLog("Warning: failed to initialize jail.local on summary request: %v", err) - } else { - config.DebugLog("Initialized fresh jail.local for server %s (file was missing)", conn.Server().Name) - } + // jail.local integrity comes back with the summary itself. + switch { + case summary.JailLocalExists && !summary.JailLocalManaged: + resp.JailLocalWarning = true + case !summary.JailLocalExists: + // The user finished a migration and removed it -> recreate a managed one. + if err := conn.EnsureJailLocalStructure(c.Request.Context()); err != nil { + config.DebugLog("Warning: failed to initialize jail.local on summary request: %v", err) + } else { + config.DebugLog("Initialized fresh jail.local for server %s (file was missing)", conn.Server().Name) } } @@ -309,9 +310,12 @@ func SearchBannedIPHandler(c *gin.Context) { if info.TotalBanned == 0 { continue } - banned, err := conn.GetBannedIPs(ctx, info.JailName) - if err != nil { - continue + banned := info.BannedIPs + if len(banned) == 0 { + banned, err = conn.GetBannedIPs(ctx, info.JailName) + if err != nil { + continue + } } if slices.Contains(banned, ip) { mu.Lock() @@ -1249,22 +1253,35 @@ func UpsertServerHandler(c *gin.Context) { server, err := config.UpsertServer(req) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + resp := gin.H{"error": err.Error()} + if errors.Is(err, config.ErrInvalidTunnelPort) { + resp["messageKey"] = "servers.errors.invalid_tunnel_port" + } + c.JSON(http.StatusBadRequest, resp) return } // Check if server was just enabled (transition from disabled to enabled) justEnabled := wasDisabled && server.Enabled + tunnelChanged := wasEnabled && oldServer.Enabled && server.Enabled && + (oldServer.ReverseTunnelEnabled != server.ReverseTunnelEnabled || oldServer.TunnelPort != server.TunnelPort) if err := config.ReloadFail2banManager(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - if justEnabled && (server.Type == "ssh" || server.Type == "agent") { + if (justEnabled || tunnelChanged) && (server.Type == "ssh" || server.Type == "agent") { if err := fail2ban.GetManager().UpdateActionFileForServer(c.Request.Context(), server.ID); err != nil { config.DebugLog("Warning: failed to update action file for server %s: %v", server.Name, err) } + if tunnelChanged { + if conn, err := fail2ban.GetManager().Connector(server.ID); err == nil { + if err := conn.Reload(c.Request.Context()); err != nil { + config.DebugLog("Warning: failed to reload fail2ban on server %s after tunnel change: %v", server.Name, err) + } + } + } } // Ensures the jail.local structure is properly initialized for newly enabled/added servers @@ -1553,7 +1570,18 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip // Records an unban event, broadcasts it via WebSocket, and sends an email alert if enabled. func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, whois, country string) error { settings := config.GetSettings() - country = resolveCountry(ip, country, settings) + if country == "" || whois == "" { + if storedCountry, storedWhois, err := storage.LatestBanEnrichmentForIP(ctx, ip); err == nil { + if country == "" { + country = storedCountry + } + if whois == "" { + whois = storedWhois + } + } else { + log.Printf("WARNING: failed to look up stored enrichment for IP %s: %v", ip, err) + } + } event := storage.BanEventRecord{ ServerID: server.ID, ServerName: server.Name, @@ -2501,6 +2529,18 @@ func TestLogpathHandler(c *gin.Context) { } _, resolvedPath, filesOnServer, err := conn.TestLogpathWithResolution(c.Request.Context(), logpathLine) if err != nil { + if errors.Is(err, fail2ban.ErrLogpathInaccessible) { + allResults = append(allResults, map[string]interface{}{ + "logpath": logpathLine, + "resolved_path": resolvedPath, + "found": false, + "inaccessible": true, + "files": []string{}, + "error": "", + "message": "Cannot verify: the log directory is not readable by the connector's SSH user. fail2ban runs as root and will read it, so the jail can still be enabled.", + }) + continue + } allResults = append(allResults, map[string]interface{}{ "logpath": logpathLine, "resolved_path": resolvedPath, @@ -2797,15 +2837,6 @@ func getJailNames(jails map[string]bool) []string { return names } -func contains(slice []string, item string) bool { - for _, s := range slice { - if s == item { - return true - } - } - return false -} - func splitLogpaths(raw string) []string { var out []string for line := range strings.SplitSeq(raw, "\n") { @@ -2819,17 +2850,19 @@ func splitLogpaths(raw string) []string { return out } -var jailErrorPattern = regexp.MustCompile(`Errors in jail '([^']+)'`) +var jailErrorPatterns = []*regexp.Regexp{ + regexp.MustCompile(`Errors in jail '([^']+)'`), + regexp.MustCompile(`Have not found any log file for (\S+) jail`), +} func parseJailErrorsFromReloadOutput(output string) []string { var problematicJails []string - lines := strings.Split(output, "\n") - - for _, line := range lines { - if strings.Contains(line, "Errors in jail") && strings.Contains(line, "Skipping") { - matches := jailErrorPattern.FindStringSubmatch(line) - if len(matches) > 1 { - problematicJails = append(problematicJails, matches[1]) + for _, line := range strings.Split(output, "\n") { + for _, pattern := range jailErrorPatterns { + for _, matches := range pattern.FindAllStringSubmatch(line, -1) { + if len(matches) > 1 { + problematicJails = append(problematicJails, matches[1]) + } } } } @@ -2913,10 +2946,15 @@ func UpdateJailManagementHandler(c *gin.Context) { } foundAnyFiles := false + inaccessible := false var checkErrors []string for _, lp := range paths { _, resolvedPath, filesOnServer, testErr := conn.TestLogpathWithResolution(c.Request.Context(), lp) if testErr != nil { + if errors.Is(testErr, fail2ban.ErrLogpathInaccessible) { + inaccessible = true + continue + } checkErrors = append(checkErrors, fmt.Sprintf("%s (%v)", lp, testErr)) continue } @@ -2931,10 +2969,15 @@ func UpdateJailManagementHandler(c *gin.Context) { } if !foundAnyFiles { - c.JSON(http.StatusOK, gin.H{ - "error": fmt.Sprintf("Jail '%s' cannot be enabled because no matching log files were found for its logpath(s): %s", jailName, strings.Join(checkErrors, "; ")), - }) - return + if inaccessible { + log.Printf("WARNING: cannot verify logpath(s) for jail %s on server %s (log directory not readable by the connector's user); enabling anyway and relying on fail2ban (root) to read them", + jailName, conn.Server().Name) + } else { + c.JSON(http.StatusOK, gin.H{ + "error": fmt.Sprintf("Jail '%s' cannot be enabled because no matching log files were found for its logpath(s): %s", jailName, strings.Join(checkErrors, "; ")), + }) + return + } } } @@ -2969,7 +3012,10 @@ func UpdateJailManagementHandler(c *gin.Context) { } } - // If problematic jails are found, disables them // TODO: @matthias we need to further enhance this + if detailedErrorOutput != "" { + errMsg = strings.TrimSpace(detailedErrorOutput) + } + if len(problematicJails) > 0 { config.DebugLog("Found %d problematic jail(s) in reload output: %v", len(problematicJails), problematicJails) @@ -2978,30 +3024,35 @@ func UpdateJailManagementHandler(c *gin.Context) { disableUpdate[jailName] = false } - for jailName := range enabledJails { - if contains(problematicJails, jailName) { - disableUpdate[jailName] = false - } - } - - if len(disableUpdate) > 0 { - if disableErr := conn.UpdateJailEnabledStates(c.Request.Context(), disableUpdate); disableErr != nil { - config.DebugLog("Error disabling problematic jails: %v", disableErr) - } else { - // Reload again after disabling - if reloadErr2 := conn.Reload(c.Request.Context()); reloadErr2 != nil { - config.DebugLog("Error: failed to reload fail2ban after disabling problematic jails: %v", reloadErr2) + if disableErr := conn.UpdateJailEnabledStates(c.Request.Context(), disableUpdate); disableErr != nil { + config.DebugLog("Error disabling problematic jails: %v", disableErr) + } else if reloadErr2 := conn.Reload(c.Request.Context()); reloadErr2 != nil { + config.DebugLog("Error: failed to reload fail2ban after disabling problematic jails: %v", reloadErr2) + } else { + // Recovered by disabling the offenders only + var revertedToggled []string + for _, jailName := range problematicJails { + if enabledJails[jailName] { + revertedToggled = append(revertedToggled, jailName) } } + if len(revertedToggled) > 0 { + c.JSON(http.StatusOK, gin.H{ + "error": fmt.Sprintf("Jail '%s' was enabled but caused a reload error: %s. It has been automatically disabled.", strings.Join(revertedToggled, "', '"), errMsg), + "autoDisabled": true, + "enabledJails": revertedToggled, + "disabledJails": problematicJails, + }) + return + } + config.DebugLog("Disabled unrelated broken jail(s) %v; requested change kept", problematicJails) + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Your change was applied. Unrelated jail '%s' has a broken configuration and was automatically disabled (%s).", strings.Join(problematicJails, "', '"), errMsg), + "messageKey": "jails.manage.offender_disabled", + "disabledJails": problematicJails, + }) + return } - - for _, jailName := range problematicJails { - enabledJails[jailName] = true - } - } - - if detailedErrorOutput != "" { - errMsg = strings.TrimSpace(detailedErrorOutput) } if len(enabledJails) > 0 { diff --git a/pkg/web/jail_reload_errors_test.go b/pkg/web/jail_reload_errors_test.go new file mode 100644 index 0000000..5ef8a8d --- /dev/null +++ b/pkg/web/jail_reload_errors_test.go @@ -0,0 +1,64 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/agpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "reflect" + "testing" +) + +func TestParseJailErrorsFromReloadOutput(t *testing.T) { + cases := []struct { + name string + output string + want []string + }{ + { + "missing log file (production case)", + "2026-07-25 10:59:46,905 fail2ban [399]: ERROR Failed during configuration: Have not found any log file for recidive jail", + []string{"recidive"}, + }, + { + "errors in jail with skipping (legacy format)", + "ERROR Errors in jail 'apache-badbots'. Skipping...", + []string{"apache-badbots"}, + }, + { + "errors in jail without skipping", + "ERROR Errors in jail 'sshd'.", + []string{"sshd"}, + }, + { + "multiple jails deduplicated", + "Have not found any log file for recidive jail\nErrors in jail 'recidive'. Skipping\nHave not found any log file for sshd jail", + []string{"recidive", "sshd"}, + }, + { + "no jail-specific errors", + "ERROR Unable to read the filter 'broken'", + []string{}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseJailErrorsFromReloadOutput(tc.output) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("parseJailErrorsFromReloadOutput = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/pkg/web/locales/ca.json b/pkg/web/locales/ca.json index 95801d8..887d44f 100644 --- a/pkg/web/locales/ca.json +++ b/pkg/web/locales/ca.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://amfitrió:9700", "servers.form.agent_secret": "Secret de l'Agent", "servers.form.agent_secret_placeholder": "token secret compartit", + "servers.form.reverse_tunnel": "Activa el túnel invers per als esdeveniments", + "servers.form.reverse_tunnel_help": "Crea automàticament un túnel SSH invers (-R :localhost:) perquè els esdeveniments de callback de fail2ban al servidor remot puguin arribar a aquesta interfície. El fitxer d'acció remot utilitza automàticament http://localhost:, de manera que l'URL de callback global pot continuar sent una adreça pública per als servidors accessibles directament.", + "servers.form.tunnel_port": "Port del túnel", + "servers.form.tunnel_port_placeholder": "buit = port del servidor", + "servers.form.tunnel_port_help": "Port on l'amfitrió remot escolta els callbacks (vinculat pel túnel). Deixeu-ho buit per utilitzar el port del servidor de la UI. Els callbacks entrants es reenvien a través del túnel al port del servidor de la UI. Ha d'estar entre 1024 i 65535: els usuaris remots sense privilegis no poden vincular ports per sota de 1024.", "servers.form.tags": "Etiquetes", "servers.form.tags_placeholder": "etiquetes,separades,per,comes", "servers.form.set_default": "Defineix com a servidor per defecte", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "L'URL de l'agent no és vàlida.", "servers.errors.agent_unreachable": "No es pot contactar amb el servei de l'agent.", "servers.errors.agent_request_failed": "La petició a l'agent ha fallat.", + "servers.errors.invalid_tunnel_port": "El port del túnel ha d'estar entre 1024 i 65535 (buit = port del servidor).", "servers.jail_local_warning": "Avís: jail.local no està gestionat per Fail2ban-UI. Moveu cada jail al seu propi fitxer a jail.d/ i suprimiu jail.local perquè Fail2ban-UI el pugui tornar a crear (premeu una vegada Desar a la pàgina de configuració per escriure el fitxer). Vegeu la documentació per a temes de permisos.", "servers.actions.restart": "Reinicia Fail2ban", "servers.actions.reload": "Recarrega Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Ja existeix un connector local amb aquesta ruta de socket.", "servers.validation.duplicate_config_path": "Ja existeix un connector local amb aquesta ruta de configuració.", "servers.validation.agent_required": "Per als servidors API Agent, l'URL de l'agent i el secret de l'agent són obligatoris.", + "servers.validation.tunnel_port_range": "El port del túnel ha d'estar entre 1024 i 65535.", "servers.card.socket_path": "Ruta del socket", "servers.card.config_path": "Ruta de configuració", "servers.card.server_id": "ID del servidor", @@ -753,5 +760,6 @@ "logs.timeline.series_unbans": "Desbans", "logs.timeline.suggestions_empty": "No s'han trobat períodes semblants.", "logs.timeline.suggestions_title": "Períodes passats semblants", - "logs.timeline.title": "Activitat de bans i desbans" + "logs.timeline.title": "Activitat de bans i desbans", + "jails.manage.offender_disabled": "El vostre canvi s'ha aplicat. El jail no relacionat '{jail}' té una configuració defectuosa i s'ha desactivat automàticament." } diff --git a/pkg/web/locales/de.json b/pkg/web/locales/de.json index 3ce5c00..98ba6d5 100644 --- a/pkg/web/locales/de.json +++ b/pkg/web/locales/de.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Agent-Secret", "servers.form.agent_secret_placeholder": "gemeinsames Geheimnis", + "servers.form.reverse_tunnel": "Reverse-Tunnel für Events aktivieren", + "servers.form.reverse_tunnel_help": "Erstellt automatisch einen Reverse-SSH-Tunnel (-R :localhost:), damit Fail2ban-Callback-Events vom Remote-Server diese UI erreichen. Die Action-Datei auf dem Remote-Server verwendet automatisch http://localhost:, die globale Callback-URL kann daher eine öffentliche Adresse für direkt erreichbare Server bleiben.", + "servers.form.tunnel_port": "Tunnel-Port", + "servers.form.tunnel_port_placeholder": "leer = Server-Port", + "servers.form.tunnel_port_help": "Port, auf dem der Remote-Host auf Callbacks lauscht (durch den Tunnel gebunden). Leer lassen, um den Server-Port der UI zu verwenden. Eingehende Callbacks werden durch den Tunnel an den Server-Port der UI weitergeleitet. Muss zwischen 1024 und 65535 liegen: unprivilegierte Benutzer können auf dem Remote-Host keine Ports unter 1024 binden.", "servers.form.tags": "Tags", "servers.form.tags_placeholder": "kommagetrennte Tags", "servers.form.set_default": "Als Standardserver setzen", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "Agent-URL ist ungültig.", "servers.errors.agent_unreachable": "Agent ist nicht erreichbar.", "servers.errors.agent_request_failed": "Agent-Anfrage ist fehlgeschlagen.", + "servers.errors.invalid_tunnel_port": "Der Tunnel-Port muss zwischen 1024 und 65535 liegen (leer = Server-Port).", "servers.jail_local_warning": "Warnung: jail.local wird nicht von Fail2ban-UI verwaltet. Verschiebe jeden Jail in eine eigene Datei unter jail.d/ und lösche jail.local, damit Fail2ban-UI sie neu erstellen kann (einmal auf der Einstellungen-Seite speichern, um die Datei zu schreiben). Siehe Dokumentation für Berechtigungen.", "servers.actions.restart": "Fail2ban neu starten", "servers.actions.reload": "Fail2ban neu laden", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Ein lokaler Connector mit diesem Socket-Pfad existiert bereits.", "servers.validation.duplicate_config_path": "Ein lokaler Connector mit diesem Konfigurationspfad existiert bereits.", "servers.validation.agent_required": "Für API-Agent-Server sind Agent-URL und Agent-Secret erforderlich.", + "servers.validation.tunnel_port_range": "Der Tunnel-Port muss zwischen 1024 und 65535 liegen.", "servers.card.socket_path": "Socket-Pfad", "servers.card.config_path": "Konfigurationspfad", "servers.card.server_id": "Server-ID", @@ -753,5 +760,6 @@ "logs.timeline.series_unbans": "Entsperrungen", "logs.timeline.suggestions_empty": "Keine ähnlichen Zeiträume gefunden.", "logs.timeline.suggestions_title": "Ähnliche frühere Zeiträume", - "logs.timeline.title": "Ban- & Unban-Aktivität" + "logs.timeline.title": "Ban- & Unban-Aktivität", + "jails.manage.offender_disabled": "Ihre Änderung wurde übernommen. Das nicht betroffene Jail '{jail}' hat eine fehlerhafte Konfiguration und wurde automatisch deaktiviert." } diff --git a/pkg/web/locales/de_ch.json b/pkg/web/locales/de_ch.json index 702cde0..da3129a 100644 --- a/pkg/web/locales/de_ch.json +++ b/pkg/web/locales/de_ch.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Agent-Secret", "servers.form.agent_secret_placeholder": "teilts Geheimnis", + "servers.form.reverse_tunnel": "Reverse-Tunnel für Events aktivierä", + "servers.form.reverse_tunnel_help": "Ersteut automatisch ä Reverse-SSH-Tunnu (-R :localhost:), damit Fail2ban-Callback-Events chöi vom Remote-Server z UI erreiche. D Action-Datei uf em Remote-Server verwändet automatisch http://localhost:, die globali Callback-URL cha daher ä öffentlichi Adresse für direkt erreichbare Server blibe.", + "servers.form.tunnel_port": "Tunnel-Port", + "servers.form.tunnel_port_placeholder": "läär = Server-Port", + "servers.form.tunnel_port_help": "Port, uf dem dr Remote-Host ufs Callbacks loost (düre Tunnel bunde). Läär la, um dr Server-Port vom UI z verwände. igehendi Callbacks wärde düre Tunneu a Server-Port vom UI weitergleitet. Mues zwüsche 1024 und 65535 lige: unprivilegierti Benutzer chöi ufem Remote-Host keini Ports unger 1024 binde.", "servers.form.tags": "Tags", "servers.form.tags_placeholder": "Komma-trennte Tags", "servers.form.set_default": "Als Standard-Server setze", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "D Agent-URL isch ungültig.", "servers.errors.agent_unreachable": "Dr Agent isch nid erreichbar.", "servers.errors.agent_request_failed": "Agent-Afrag isch fehlgschlage.", + "servers.errors.invalid_tunnel_port": "Dr Tunnel-Port muess zwüsche 1024 und 65535 sii (leer = Server-Port).", "servers.jail_local_warning": "Achtung: z jail.local wird nid vom Fail2ban-UI verwaltet. Verschieb jede Jail in ä eigeni Datei under jail.d/ und lösch z jail.local, damit Fail2ban-UI z file neu cha erstelle. Lueg d Doku a betreffend Berechtigunge.", "servers.actions.restart": "Fail2ban neu starte", "servers.actions.reload": "Fail2ban neu lade", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Es git scho en lokale Connector mit däm Socket-Pfad.", "servers.validation.duplicate_config_path": "Es git scho en lokale Connector mit däm Konfigurationspfad.", "servers.validation.agent_required": "Füre API-Agent-Server si d Agent-URL und z Agent-Secret erforderlich.", + "servers.validation.tunnel_port_range": "Dr Tunnel-Port muess zwüsche 1024 und 65535 sii.", "servers.card.socket_path": "Socket-Pfad", "servers.card.config_path": "Konfigurationspfad", "servers.card.server_id": "Server-ID", @@ -753,5 +760,6 @@ "logs.timeline.series_unbans": "Entsperrungen", "logs.timeline.suggestions_empty": "Keine ähnlichen Zeiträume gefunden.", "logs.timeline.suggestions_title": "Ähnliche frühere Zeiträume", - "logs.timeline.title": "Ban- & Unban-Aktivität" + "logs.timeline.title": "Ban- & Unban-Aktivität", + "jails.manage.offender_disabled": "Dini Änderig isch übernoh worde. Z unbeteiligte Jail '{jail}' het e fählerhafti Konfiguration und isch automatisch deaktiviert worde." } diff --git a/pkg/web/locales/en.json b/pkg/web/locales/en.json index 36a7b38..49b0207 100644 --- a/pkg/web/locales/en.json +++ b/pkg/web/locales/en.json @@ -568,6 +568,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Agent Secret", "servers.form.agent_secret_placeholder": "shared secret token", + "servers.form.reverse_tunnel": "Enable reverse tunnel for events", + "servers.form.reverse_tunnel_help": "Automatically creates a reverse SSH tunnel (-R :localhost:) so fail2ban callback events on the remote server can reach this UI server. The remote action file automatically posts to http://localhost:, so the global Callback URL can stay a public address for directly reachable servers.", + "servers.form.tunnel_port": "Tunnel Port", + "servers.form.tunnel_port_placeholder": "empty = server port", + "servers.form.tunnel_port_help": "Port the remote host listens on for callbacks (bound by the tunnel). Leave empty to use the UI server port. Incoming callbacks are forwarded through the tunnel to the UI server port. Must be 1024-65535: unprivileged remote users cannot bind ports below 1024.", "servers.form.tags": "Tags", "servers.form.tags_placeholder": "comma,separated,tags", "servers.form.set_default": "Set as default server", @@ -593,6 +598,7 @@ "servers.errors.agent_invalid_url": "Agent URL is invalid.", "servers.errors.agent_unreachable": "Cannot reach agent service.", "servers.errors.agent_request_failed": "Agent request failed.", + "servers.errors.invalid_tunnel_port": "Tunnel port must be between 1024 and 65535 (empty = server port).", "servers.jail_local_warning": "Warning: jail.local is not managed by Fail2ban-UI. Move each jail into its own file under jail.d/ and delete jail.local so Fail2ban-UI can recreate it (hit once save on the settings page to write the file). See docs for permissions.", "servers.actions.restart": "Restart Fail2ban", "servers.actions.reload": "Reload Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "A local connector with this socket path already exists.", "servers.validation.duplicate_config_path": "A local connector with this configuration path already exists.", "servers.validation.agent_required": "Agent URL and Agent Secret are required for API Agent servers.", + "servers.validation.tunnel_port_range": "Tunnel port must be between 1024 and 65535.", "servers.card.socket_path": "Socket path", "servers.card.config_path": "Configuration path", "servers.card.server_id": "Server-ID", @@ -753,5 +760,6 @@ "logs.timeline.series_unbans": "Unbans", "logs.timeline.suggestions_empty": "No similar periods found.", "logs.timeline.suggestions_title": "Similar past periods", - "logs.timeline.title": "Ban & unban activity" + "logs.timeline.title": "Ban & unban activity", + "jails.manage.offender_disabled": "Your change was applied. Unrelated jail '{jail}' has a broken configuration and was automatically disabled." } diff --git a/pkg/web/locales/es.json b/pkg/web/locales/es.json index 53d166a..570d8c1 100644 --- a/pkg/web/locales/es.json +++ b/pkg/web/locales/es.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Secreto del agente", "servers.form.agent_secret_placeholder": "token compartido", + "servers.form.reverse_tunnel": "Habilitar túnel inverso para eventos", + "servers.form.reverse_tunnel_help": "Crea automáticamente un túnel SSH inverso (-R :localhost:) para que los eventos de callback de fail2ban en el servidor remoto puedan llegar a esta interfaz. El archivo de acción remoto usa automáticamente http://localhost:, por lo que la URL de callback global puede seguir siendo una dirección pública para los servidores accesibles directamente.", + "servers.form.tunnel_port": "Puerto del túnel", + "servers.form.tunnel_port_placeholder": "vacío = puerto del servidor", + "servers.form.tunnel_port_help": "Puerto en el que el host remoto escucha los callbacks (vinculado por el túnel). Déjelo vacío para usar el puerto del servidor de la UI. Los callbacks entrantes se reenvían a través del túnel al puerto del servidor de la UI. Debe estar entre 1024 y 65535: los usuarios remotos sin privilegios no pueden vincular puertos por debajo de 1024.", "servers.form.tags": "Etiquetas", "servers.form.tags_placeholder": "etiquetas separadas por comas", "servers.form.set_default": "Establecer como servidor predeterminado", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "La URL del agente no es válida.", "servers.errors.agent_unreachable": "No se puede conectar con el servicio del agente.", "servers.errors.agent_request_failed": "La solicitud al agente falló.", + "servers.errors.invalid_tunnel_port": "El puerto del túnel debe estar entre 1024 y 65535 (vacío = puerto del servidor).", "servers.jail_local_warning": "Advertencia: jail.local no está gestionado por Fail2ban-UI. Mueva cada jail a su propio archivo en jail.d/ y elimine jail.local para que Fail2ban-UI pueda recrearlo (pulse una vez en la página de configuración para escribir el archivo). Consulte la documentación para permisos.", "servers.actions.restart": "Reiniciar Fail2ban", "servers.actions.reload": "Recargar Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Ya existe un conector local con esta ruta de socket.", "servers.validation.duplicate_config_path": "Ya existe un conector local con esta ruta de configuración.", "servers.validation.agent_required": "La URL del agente y el secreto del agente son obligatorios para servidores API Agent.", + "servers.validation.tunnel_port_range": "El puerto del túnel debe estar entre 1024 y 65535.", "servers.card.socket_path": "Ruta del socket", "servers.card.config_path": "Ruta de configuración", "servers.card.server_id": "ID del servidor", @@ -753,5 +760,6 @@ "logs.timeline.series_unbans": "Desbaneos", "logs.timeline.suggestions_empty": "No se encontraron períodos similares.", "logs.timeline.suggestions_title": "Períodos pasados similares", - "logs.timeline.title": "Actividad de baneos y desbaneos" + "logs.timeline.title": "Actividad de baneos y desbaneos", + "jails.manage.offender_disabled": "Su cambio se ha aplicado. El jail no relacionado '{jail}' tiene una configuración defectuosa y se ha desactivado automáticamente." } diff --git a/pkg/web/locales/fr.json b/pkg/web/locales/fr.json index ee715f2..9a88c34 100644 --- a/pkg/web/locales/fr.json +++ b/pkg/web/locales/fr.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Secret de l'agent", "servers.form.agent_secret_placeholder": "jeton partagé", + "servers.form.reverse_tunnel": "Activer le tunnel inverse pour les événements", + "servers.form.reverse_tunnel_help": "Crée automatiquement un tunnel SSH inverse (-R :localhost:) afin que les événements de callback de fail2ban sur le serveur distant puissent atteindre cette interface. Le fichier d'action distant utilise automatiquement http://localhost:, l'URL de callback globale peut donc rester une adresse publique pour les serveurs directement accessibles.", + "servers.form.tunnel_port": "Port du tunnel", + "servers.form.tunnel_port_placeholder": "vide = port du serveur", + "servers.form.tunnel_port_help": "Port sur lequel l'hôte distant écoute les callbacks (lié par le tunnel). Laisser vide pour utiliser le port du serveur de l'UI. Les callbacks entrants sont transférés à travers le tunnel vers le port du serveur de l'UI. Doit être compris entre 1024 et 65535 : les utilisateurs distants non privilégiés ne peuvent pas lier de ports inférieurs à 1024.", "servers.form.tags": "Étiquettes", "servers.form.tags_placeholder": "étiquettes séparées par des virgules", "servers.form.set_default": "Définir comme serveur par défaut", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "L'URL de l'agent est invalide.", "servers.errors.agent_unreachable": "Le service agent est inaccessible.", "servers.errors.agent_request_failed": "La requête vers l'agent a échoué.", + "servers.errors.invalid_tunnel_port": "Le port du tunnel doit être compris entre 1024 et 65535 (vide = port du serveur).", "servers.jail_local_warning": "Attention : jail.local n'est pas géré par Fail2ban-UI. Déplacez chaque jail dans son propre fichier sous jail.d/ et supprimez jail.local pour que Fail2ban-UI puisse le recréer (cliquez une fois sur la page de configuration pour écrire le fichier). Consultez la documentation pour les permissions.", "servers.actions.restart": "Redémarrer Fail2ban", "servers.actions.reload": "Recharger Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Un connecteur local avec ce chemin de socket existe déjà.", "servers.validation.duplicate_config_path": "Un connecteur local avec ce chemin de configuration existe déjà.", "servers.validation.agent_required": "L'URL de l'agent et le secret de l'agent sont requis pour les serveurs API Agent.", + "servers.validation.tunnel_port_range": "Le port du tunnel doit être compris entre 1024 et 65535.", "servers.card.socket_path": "Chemin du socket", "servers.card.config_path": "Chemin de configuration", "servers.card.server_id": "ID du serveur", @@ -753,5 +760,6 @@ "logs.timeline.series_unbans": "Débannissements", "logs.timeline.suggestions_empty": "Aucune période similaire trouvée.", "logs.timeline.suggestions_title": "Périodes passées similaires", - "logs.timeline.title": "Activité de bannissement et débannissement" + "logs.timeline.title": "Activité de bannissement et débannissement", + "jails.manage.offender_disabled": "Votre modification a été appliquée. Le jail non concerné '{jail}' a une configuration défectueuse et a été désactivé automatiquement." } diff --git a/pkg/web/locales/it.json b/pkg/web/locales/it.json index 9e4533a..a4260c8 100644 --- a/pkg/web/locales/it.json +++ b/pkg/web/locales/it.json @@ -565,6 +565,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "Segreto dell'agente", "servers.form.agent_secret_placeholder": "token condiviso", + "servers.form.reverse_tunnel": "Abilita tunnel inverso per gli eventi", + "servers.form.reverse_tunnel_help": "Crea automaticamente un tunnel SSH inverso (-R :localhost:) affinché gli eventi di callback di fail2ban sul server remoto possano raggiungere questa interfaccia. Il file di azione remoto usa automaticamente http://localhost:, quindi l'URL di callback globale può restare un indirizzo pubblico per i server raggiungibili direttamente.", + "servers.form.tunnel_port": "Porta del tunnel", + "servers.form.tunnel_port_placeholder": "vuoto = porta del server", + "servers.form.tunnel_port_help": "Porta su cui l'host remoto ascolta i callback (vincolata dal tunnel). Lasciare vuoto per usare la porta del server della UI. I callback in arrivo vengono inoltrati attraverso il tunnel alla porta del server della UI. Deve essere compresa tra 1024 e 65535: gli utenti remoti senza privilegi non possono vincolare porte inferiori a 1024.", "servers.form.tags": "Tag", "servers.form.tags_placeholder": "tag separati da virgole", "servers.form.set_default": "Imposta come server predefinito", @@ -590,6 +595,7 @@ "servers.errors.agent_invalid_url": "L'URL dell'agente non è valido.", "servers.errors.agent_unreachable": "Impossibile raggiungere il servizio agente.", "servers.errors.agent_request_failed": "La richiesta all'agente non è riuscita.", + "servers.errors.invalid_tunnel_port": "La porta del tunnel deve essere compresa tra 1024 e 65535 (vuoto = porta del server).", "servers.jail_local_warning": "Attenzione: jail.local non è gestito da Fail2ban-UI. Sposta ogni jail in un file proprio sotto jail.d/ ed elimina jail.local in modo che Fail2ban-UI possa ricrearlo (salva una volta la pagina delle impostazioni per scrivere il file). Consulta la documentazione per i permessi.", "servers.actions.restart": "Riavvia Fail2ban", "servers.actions.reload": "Ricarica Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "Esiste già un connettore locale con questo percorso socket.", "servers.validation.duplicate_config_path": "Esiste già un connettore locale con questo percorso di configurazione.", "servers.validation.agent_required": "Per i server API Agent sono obbligatori URL agente e secret agente.", + "servers.validation.tunnel_port_range": "La porta del tunnel deve essere compresa tra 1024 e 65535.", "servers.card.socket_path": "Percorso socket", "servers.card.config_path": "Percorso configurazione", "servers.card.server_id": "ID server", @@ -753,5 +760,6 @@ "logs.timeline.series_unbans": "Unban", "logs.timeline.suggestions_empty": "Nessun periodo simile trovato.", "logs.timeline.suggestions_title": "Periodi passati simili", - "logs.timeline.title": "Attività di ban e unban" + "logs.timeline.title": "Attività di ban e unban", + "jails.manage.offender_disabled": "La tua modifica è stata applicata. Il jail non correlato '{jail}' ha una configurazione difettosa ed è stato disabilitato automaticamente." } diff --git a/pkg/web/locales/ja.json b/pkg/web/locales/ja.json index 1cd4ac5..e4ba589 100644 --- a/pkg/web/locales/ja.json +++ b/pkg/web/locales/ja.json @@ -568,6 +568,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "エージェントシークレット", "servers.form.agent_secret_placeholder": "共有シークレットトークン", + "servers.form.reverse_tunnel": "イベント用リバーストンネルを有効化", + "servers.form.reverse_tunnel_help": "リバースSSHトンネル(-R <トンネルポート>:localhost:<サーバーポート>)を自動的に作成し、リモートサーバー上のfail2banコールバックイベントがこのUIサーバーに届くようにします。リモートのアクションファイルは自動的に http://localhost:<トンネルポート> へ送信するため、グローバルのコールバックURLは直接到達可能なサーバー向けの公開アドレスのままにできます。", + "servers.form.tunnel_port": "トンネルポート", + "servers.form.tunnel_port_placeholder": "空欄 = サーバーポート", + "servers.form.tunnel_port_help": "リモートホストがコールバックを受け付けるポートです(トンネルによってバインドされます)。空欄の場合はUIのサーバーポートを使用します。受信したコールバックはトンネル経由でUIのサーバーポートに転送されます。1024〜65535の範囲で指定してください。権限のないリモートユーザーは1024未満のポートをバインドできません。", "servers.form.tags": "タグ", "servers.form.tags_placeholder": "カンマ区切りのタグ", "servers.form.set_default": "デフォルトサーバーに設定", @@ -593,6 +598,7 @@ "servers.errors.agent_invalid_url": "エージェントURLが無効です。", "servers.errors.agent_unreachable": "エージェントサービスに接続できません。", "servers.errors.agent_request_failed": "エージェントへのリクエストに失敗しました。", + "servers.errors.invalid_tunnel_port": "トンネルポートは1024〜65535の範囲で指定してください(空欄 = サーバーポート)。", "servers.jail_local_warning": "警告: jail.localはFail2ban-UIで管理されていません。各Jailをjail.d/以下の個別ファイルに移動し、jail.localを削除してFail2ban-UIが再作成できるようにしてください(設定ページで一度保存してください)。権限についてはドキュメントを参照してください。", "servers.actions.restart": "Fail2banを再起動", "servers.actions.reload": "Fail2banをリロード", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "このソケットパスを持つローカルコネクタが既に存在します。", "servers.validation.duplicate_config_path": "この設定パスを持つローカルコネクタが既に存在します。", "servers.validation.agent_required": "APIエージェントサーバーにはエージェントURLとエージェントシークレットが必須です。", + "servers.validation.tunnel_port_range": "トンネルポートは1024〜65535の範囲で指定してください。", "servers.card.socket_path": "ソケットパス", "servers.card.config_path": "設定パス", "servers.card.server_id": "サーバーID", @@ -753,5 +760,6 @@ "logs.timeline.series_unbans": "BAN解除", "logs.timeline.suggestions_empty": "類似した期間は見つかりませんでした。", "logs.timeline.suggestions_title": "類似した過去の期間", - "logs.timeline.title": "BAN・BAN解除のアクティビティ" + "logs.timeline.title": "BAN・BAN解除のアクティビティ", + "jails.manage.offender_disabled": "変更は適用されました。無関係のジェイル「{jail}」の設定に問題があるため、自動的に無効化されました。" } diff --git a/pkg/web/locales/zh.json b/pkg/web/locales/zh.json index f8ce2be..26679da 100644 --- a/pkg/web/locales/zh.json +++ b/pkg/web/locales/zh.json @@ -568,6 +568,11 @@ "servers.form.agent_url_placeholder": "https://host:9700", "servers.form.agent_secret": "代理密钥", "servers.form.agent_secret_placeholder": "共享密钥令牌", + "servers.form.reverse_tunnel": "为事件启用反向隧道", + "servers.form.reverse_tunnel_help": "自动创建反向 SSH 隧道(-R <隧道端口>:localhost:<服务器端口>),使远程服务器上的 fail2ban 回调事件能够到达此 UI 服务器。远程操作文件会自动使用 http://localhost:<隧道端口>,因此全局回调 URL 可以继续保持为供可直接访问服务器使用的公共地址。", + "servers.form.tunnel_port": "隧道端口", + "servers.form.tunnel_port_placeholder": "留空 = 服务器端口", + "servers.form.tunnel_port_help": "远程主机监听回调的端口(由隧道绑定)。留空则使用 UI 的服务器端口。传入的回调会通过隧道转发到 UI 的服务器端口。必须在 1024-65535 之间:无特权的远程用户无法绑定 1024 以下的端口。", "servers.form.tags": "标签", "servers.form.tags_placeholder": "逗号,分隔,标签", "servers.form.set_default": "设为默认服务器", @@ -593,6 +598,7 @@ "servers.errors.agent_invalid_url": "Agent URL 无效。", "servers.errors.agent_unreachable": "无法连接到 Agent 服务。", "servers.errors.agent_request_failed": "Agent 请求失败。", + "servers.errors.invalid_tunnel_port": "隧道端口必须在 1024 至 65535 之间(留空 = 服务器端口)。", "servers.jail_local_warning": "警告:jail.local 不由 Fail2ban-UI 管理。将每个 jail 移动到 jail.d/ 下的独立文件中,并删除 jail.local,以便 Fail2ban-UI 可以重新创建它(在设置页面点击一次保存以写入文件)。请参阅文档了解权限要求。", "servers.actions.restart": "重启 Fail2ban", "servers.actions.reload": "重新加载 Fail2ban", @@ -608,6 +614,7 @@ "servers.validation.duplicate_socket": "已存在使用此 socket 路径的本地连接器。", "servers.validation.duplicate_config_path": "已存在使用此配置路径的本地连接器。", "servers.validation.agent_required": "API 代理服务器需要代理 URL 和代理密钥。", + "servers.validation.tunnel_port_range": "隧道端口必须在 1024 至 65535 之间。", "servers.card.socket_path": "Socket 路径", "servers.card.config_path": "配置路径", "servers.card.server_id": "服务器 ID", @@ -753,5 +760,6 @@ "logs.timeline.series_unbans": "解封", "logs.timeline.suggestions_empty": "未找到相似的时段。", "logs.timeline.suggestions_title": "相似的历史时段", - "logs.timeline.title": "封禁与解封活动" + "logs.timeline.title": "封禁与解封活动", + "jails.manage.offender_disabled": "您的更改已应用。无关的监狱 '{jail}' 配置有误,已被自动禁用。" } diff --git a/pkg/web/secret_masking.go b/pkg/web/secret_masking.go index 3f3ea05..fea4932 100644 --- a/pkg/web/secret_masking.go +++ b/pkg/web/secret_masking.go @@ -122,6 +122,7 @@ func stripServerConnectionDetails(servers []shared.Fail2banServer) []shared.Fail servers[i].SSHKeyPath = "" servers[i].AgentURL = "" servers[i].AgentSecret = "" + servers[i].TunnelPort = 0 } return servers } diff --git a/pkg/web/static/js/core.js b/pkg/web/static/js/core.js index afe9e56..02e54f6 100644 --- a/pkg/web/static/js/core.js +++ b/pkg/web/static/js/core.js @@ -83,7 +83,7 @@ function showBanEventToast(event) { var ip = event.ip || 'Unknown IP'; var jail = event.jail || 'Unknown Jail'; var server = event.serverName || event.serverId || 'Unknown Server'; - var country = event.country || 'UNKNOWN'; + var country = event.country || ''; var title = isUnban ? t('toast.unban.title', 'IP unblocked') : t('toast.ban.title', 'New block occurred'); var action = isUnban ? t('toast.unban.action', 'unblocked from') : t('toast.ban.action', 'banned in'); @@ -102,7 +102,7 @@ function showBanEventToast(event) { + ' ' + escapeHtml(jail) + '' + ' ' + '
' - + ' ' + escapeHtml(server) + ' - ' + escapeHtml(country) + + ' ' + escapeHtml(server) + (country ? ' - ' + escapeHtml(country) : '') + '
' + ' ' + '