diff --git a/.gitignore b/.gitignore index d73a333..3e0bab4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,3 @@ fail2ban-ui.db* node_modules/ package-lock.json .tailwind-build/ - -# Server specific test-files -server \ No newline at end of file diff --git a/README.md b/README.md index 57e3f00..62f286f 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Three alert providers, Email (SMTP), Webhook, and Elasticsearch, with country fi [![Global Settings](screenshots/4.4_Settings_GlobalSettings.png)](screenshots/4.4_Settings_GlobalSettings.png) -Global Fail2Ban defaults: `bantime`, `findtime`, `maxretry`, and the `banaction` backend (nftables, firewalld, iptables). +Global Fail2Ban defaults: `bantime`, `findtime`, `maxretry`, and the `banaction` backend (nftables, firewalld, iptables). When bantime increment is enabled, the escalation behavior can be tuned with `bantime.rndtime`, `bantime.maxtime` (cap for escalating bans), `bantime.factor` (escalation multiplier), and `bantime.overalljails` (count repeat offenses across all jails). ## Contributing diff --git a/cmd/server/main.go b/cmd/server/main.go index d1c1aad..66cafcd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,19 +2,22 @@ // // Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // -// Licensed under the PolyForm Shield License 1.0.0. +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// https://polyformproject.org/licenses/shield/1.0.0/ +// https://www.gnu.org/licenses/gpl-3.0.en.html // -// or in the LICENSE file in this repository. -// -// Required Notice: Copyright Swissmakers GmbH (https://swissmakers.ch) +// 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 main import ( + "context" "fmt" "log" "net" @@ -25,6 +28,7 @@ import ( "github.com/gin-gonic/gin" "github.com/swissmakers/fail2ban-ui/internal/auth" "github.com/swissmakers/fail2ban-ui/internal/config" + "github.com/swissmakers/fail2ban-ui/internal/fail2ban" "github.com/swissmakers/fail2ban-ui/internal/storage" "github.com/swissmakers/fail2ban-ui/pkg/web" ) @@ -36,11 +40,9 @@ import ( func main() { settings := config.GetSettings() - // Initialize base path web.SetBasePathFromEnv() auth.SetSessionCookiePath(web.CookiePath()) - // Initialize storage if err := storage.Init(""); err != nil { log.Fatalf("Failed to initialise storage: %v", err) } @@ -55,6 +57,41 @@ func main() { log.Fatalf("failed to initialise fail2ban connectors: %v", err) } + // Sync remote SSH/agent runtime config, then reload so action/callback and jail.local changes become active + go func() { + synced, failed := fail2ban.GetManager().SyncRemoteStartupConfig(context.Background(), 30*time.Second) + if synced+failed > 0 { + log.Printf("startup remote config sync complete: %d succeeded, %d failed", synced, failed) + } + }() + + // Prune ban events beyond the configured retention window once at startup and then daily + go func() { + pruneBanEvents := func() { + retentionDays := config.GetSettings().EventRetentionDays + if retentionDays <= 0 { + return + } + cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + deleted, err := storage.PruneBanEventsBefore(ctx, cutoff) + cancel() + if err != nil { + log.Printf("warning: failed to prune ban events older than %d days: %v", retentionDays, err) + return + } + if deleted > 0 { + log.Printf("Pruned %d ban events older than %d days", deleted, retentionDays) + } + } + pruneBanEvents() + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for range ticker.C { + pruneBanEvents() + } + }() + // Initialize OIDC authentication oidcConfig, err := config.GetOIDCConfigFromEnv() if err != nil { @@ -68,6 +105,8 @@ func main() { log.Fatalf("failed to initialize OIDC: %v", err) } log.Println("OIDC authentication enabled") + } else { + log.Println("WARNING: OIDC authentication is DISABLED -> Run this way only in a trusted network or behind an authenticating reverse proxy (see docs/security.md).") } // Set Gin mode diff --git a/docker-compose-allinone.example.yml b/docker-compose-allinone.example.yml index f28d34d..8e94fb2 100644 --- a/docker-compose-allinone.example.yml +++ b/docker-compose-allinone.example.yml @@ -113,6 +113,12 @@ services: # Optional: Username claim (default: preferred_username) # The claim to use as the username (e.g., email, preferred_username, sub) # - OIDC_USERNAME_CLAIM=preferred_username + # Optional: OIDC role-based access control + # If no admin/support roles are configured, all authenticated users have full access. + # Dot paths are supported for the role claim, e.g. realm_access.roles for Keycloak. + # - OIDC_ROLE_CLAIM=groups + # - OIDC_ADMIN_ROLES=fail2ban-admins + # - OIDC_SUPPORT_ROLES=fail2ban-support # Optional: Provider logout URL # If not set, the logout URL will be auto-constructed based on the provider: # Keycloak: {issuer}/protocol/openid-connect/logout diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 3dcac0b..34d56c0 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -94,6 +94,12 @@ services: # Optional: Username claim (default: preferred_username) # The claim to use as the username (e.g., email, preferred_username, sub) # - OIDC_USERNAME_CLAIM=preferred_username + # Optional: OIDC role-based access control + # If no admin/support roles are configured, all authenticated users have full access. + # Dot paths are supported for the role claim, e.g. realm_access.roles for Keycloak. + # - OIDC_ROLE_CLAIM=groups + # - OIDC_ADMIN_ROLES=fail2ban-admins + # - OIDC_SUPPORT_ROLES=fail2ban-support # Optional: Provider logout URL # If not set, the logout URL will be auto-constructed based on the provider: # Keycloak: {issuer}/protocol/openid-connect/logout diff --git a/docs/api.md b/docs/api.md index a41fe8d..b507262 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5,6 +5,7 @@ This is a practical endpoint index for operators. The web frontend uses these en ## Authentication * When OIDC is enabled, all `/api/*` endpoints, including the WebSocket, require an authenticated session - except the callback endpoints. +* Optional OIDC role-based access control can further restrict authenticated users. `admin` users can access everything; `support` users can view operational dashboard/event data and manually ban/unban IPs. * The callback endpoints (`/api/ban`, `/api/unban`) are authenticated through the `X-Callback-Secret` header. ## Input validation diff --git a/docs/configuration.md b/docs/configuration.md index 9f34985..9c2f83a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,6 +47,9 @@ Fail2Ban UI receives ban and unban callbacks at: |----------|-------------| | `CALLBACK_URL` | URL reachable from every managed Fail2Ban host: scheme, host, optional port, and `BASE_PATH` if used. No trailing slash. | | `CALLBACK_SECRET` | Shared secret validated through the `X-Callback-Secret` header. If unset, Fail2Ban UI generates one on first start. | +| `CALLBACK_INSECURE_TLS` | Default `false`. When `true` (or `1`/`yes`/`on`), the `curl` command in the generated ban action skips TLS certificate verification (`-k`) for an `https://` callback URL. Only enable this if the UI uses a self-signed certificate that the managed hosts do not trust. | + +> **Upgrade note:** older releases always passed `-k` for `https://` callback URLs. TLS verification is now on by default because the callback carries the shared secret. If your UI runs with a self-signed certificate, either install the certificate on every managed host or set `CALLBACK_INSECURE_TLS=true`, otherwise ban callbacks will fail silently after upgrading. The regenerated action file is pushed to managed hosts automatically at startup. Example: @@ -63,6 +66,12 @@ With a subpath: -e CALLBACK_SECRET='replace-with-a-random-secret' ``` +### 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). + +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. + ## Privacy and telemetry controls | Variable | Description | @@ -82,6 +91,19 @@ With a subpath: |----------|-------------| | `JAIL_AUTOMIGRATION=true` | Experimental migration from a monolithic `jail.local` to `jail.d/*.local`. On production systems, migrate manually instead. | +## Global Fail2Ban defaults (UI-managed) + +Configure under **Settings → Global Settings**. These values are written to the `[DEFAULT]` section of the managed `jail.local` and pushed to every managed host: + +* `bantime`, `findtime`, `maxretry`, `ignoreip`, `banaction` / `banaction_allports`, firewall `chain` +* Bantime increment escalation (optional, only emitted when set): + * `bantime.rndtime` — random jitter added to escalating bans + * `bantime.maxtime` — cap for escalating bans (e.g. `5w`) + * `bantime.factor` — escalation multiplier (Fail2Ban default: `1`) + * `bantime.overalljails` — count repeat offenses across all jails instead of per jail + +Duration fields accept plain seconds or Fail2Ban time suffixes (`3600`, `48h`, `5w`, `1d 12h`). `bantime` additionally accepts `-1` for permanent bans. + ## Alert settings (UI-managed) Configure under **Settings → Alert Settings**: @@ -91,6 +113,8 @@ Configure under **Settings → Alert Settings**: * Alert country filters * GeoIP provider and log-line limits +> **Privacy note on the `builtin` GeoIP provider:** it resolves countries via the free ip-api.com service, which means every enriched (banned) IP address is sent to a third party — and the free tier only supports plain HTTP, so the queries travel unencrypted. For privacy-sensitive deployments use the MaxMind provider with a local GeoLite2 database instead. + For provider behavior and payloads, see [alert-providers.md](alert-providers.md) and [webhooks.md](webhooks.md). ## Threat intelligence settings (UI-managed) @@ -133,6 +157,14 @@ Common optional variables: | `OIDC_SKIP_VERIFY` | `false` | Skips TLS verification toward the provider. Development only. | | `OIDC_SKIP_LOGINPAGE` | `false` | Skips the UI login page and redirects to the provider directly | +OIDC role-based access control is optional. When no role variables are set, every authenticated OIDC user keeps the previous full-access behavior. + +| Variable | Default | Description | +|----------|---------|-------------| +| `OIDC_ROLE_CLAIM` | `groups` | Claim containing roles/groups. Dot paths are supported, for example `realm_access.roles` for Keycloak. | +| `OIDC_ADMIN_ROLES` | empty | Comma-separated OIDC role/group names that grant full admin access. | +| `OIDC_SUPPORT_ROLES` | empty | Comma-separated OIDC role/group names that grant support access: dashboard/event reads plus manual ban/unban. | + Provider notes: * **Keycloak**: allow the redirect URI `{BASE_PATH}/auth/callback` (or `/auth/callback` at root) and the post-logout redirect `{BASE_PATH}/auth/login`. diff --git a/docs/security.md b/docs/security.md index fa2ac27..8df934f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -13,6 +13,14 @@ Fail2Ban UI performs security-sensitive operations: it bans addresses, changes f See [reverse-proxy.md](reverse-proxy.md) for hardened proxy examples and the WebSocket forwarding requirements. +## Authentication model + +Sessions are stateless encrypted cookies (AES-GCM), logout clears the cookie but cannot revoke an already-captured session token before its expiry (`OIDC_SESSION_MAX_AGE`, default 1 hour). Keep session lifetimes short and always serve the UI over TLS. + +When OIDC role-based access control is configured (`OIDC_ADMIN_ROLES` / `OIDC_SUPPORT_ROLES`), the user's roles and access level are captured at login and stored in the session cookie. Role changes at the identity provider only take effect after the user logs in again — another reason to keep session lifetimes short. Users whose roles match neither list can authenticate but are denied on every API endpoint. + +The debug console (Settings → Console Output) mirrors the complete server log to every connected UI client over the WebSocket. Log lines can include client IPs, email addresses, and configuration diagnostics — leave it disabled unless actively debugging. + ## Input validation All user-supplied IP addresses are validated with Go's `net.ParseIP` and `net.ParseCIDR` before they reach any integration, command, or database query. This applies to: @@ -39,6 +47,11 @@ Additional hardening: * Use a long, random secret and rotate it on suspected leakage. * Restrict network access so that only the managed Fail2Ban hosts can reach the callback endpoints. +* Serve the callback URL over `https://` with a certificate the managed hosts trust. The generated ban action verifies TLS certificates by default; `CALLBACK_INSECURE_TLS=true` disables verification and should only be used with self-signed certificates on trusted networks (see [configuration.md](configuration.md)). + +## Secrets at rest + +Secrets (callback secret, SMTP password, agent tokens, integration API keys) are stored in the SQLite database and embedded in the generated `action.d/ui-custom-action.conf`. Fail2Ban UI restricts both to file mode `0600` on startup. Read APIs never return stored secrets; the frontend receives a placeholder sentinel and unchanged saves keep the stored value. ## SSH connector hardening @@ -56,6 +69,7 @@ When using the firewall integrations (MikroTik, pfSense, OPNsense): * Use a dedicated service account on the firewall device with the minimum permissions needed: address-list management only on MikroTik; alias management only on pfSense and OPNsense. * For pfSense and OPNsense, use a dedicated API token with limited scope. * Restrict network access so the Fail2Ban UI host is the only source allowed to reach the firewall management interface. +* Configure the MikroTik SSH host-key fingerprint. When no fingerprint is set, the connector accepts any host key (MITM exposure); with one configured, it is verified with a constant-time comparison. ## Least privilege and file access diff --git a/go.mod b/go.mod index 5a2d99d..553f5dc 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,9 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/likexian/whois v1.15.7 github.com/oschwald/maxminddb-golang v1.13.1 - golang.org/x/crypto v0.48.0 + golang.org/x/crypto v0.52.0 golang.org/x/oauth2 v0.35.0 - golang.org/x/text v0.34.0 + golang.org/x/text v0.37.0 modernc.org/sqlite v1.46.0 ) @@ -47,8 +47,8 @@ require ( go.uber.org/mock v0.6.0 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect - golang.org/x/net v0.50.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.45.0 // indirect google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.68.0 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index d0ddbee..92b00d4 100644 --- a/go.sum +++ b/go.sum @@ -98,27 +98,27 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/auth/authorization_test.go b/internal/auth/authorization_test.go new file mode 100644 index 0000000..478b94e --- /dev/null +++ b/internal/auth/authorization_test.go @@ -0,0 +1,53 @@ +package auth + +import ( + "testing" + + "github.com/swissmakers/fail2ban-ui/internal/config" +) + +func TestAccessLevelForRoles(t *testing.T) { + cfg := &config.OIDCConfig{ + AuthorizationEnabled: true, + AdminRoles: []string{"fail2ban-admins"}, + SupportRoles: []string{"fail2ban-support"}, + } + + if got := accessLevelForRoles(cfg, []string{"fail2ban-admins"}); got != AccessLevelAdmin { + t.Fatalf("admin role access level = %q, want %q", got, AccessLevelAdmin) + } + if got := accessLevelForRoles(cfg, []string{"fail2ban-support"}); got != AccessLevelSupport { + t.Fatalf("support role access level = %q, want %q", got, AccessLevelSupport) + } + if got := accessLevelForRoles(cfg, []string{"other"}); got != "" { + t.Fatalf("unknown role access level = %q, want empty", got) + } +} + +func TestSessionHasPermission(t *testing.T) { + admin := &Session{AccessLevel: AccessLevelAdmin} + if !SessionHasPermission(admin, "admin") || !SessionHasPermission(admin, "ban") { + t.Fatal("admin should have all permissions") + } + + support := &Session{AccessLevel: AccessLevelSupport} + if !SessionHasPermission(support, "read") || !SessionHasPermission(support, "ban") { + t.Fatal("support should have read and ban permissions") + } + if SessionHasPermission(support, "admin") { + t.Fatal("support should not have admin permission") + } +} + +func TestClaimByPathAndStringSliceFromClaim(t *testing.T) { + claims := map[string]interface{}{ + "realm_access": map[string]interface{}{ + "roles": []interface{}{"fail2ban-admins", "offline_access"}, + }, + } + + roles := stringSliceFromClaim(claimByPath(claims, "realm_access.roles")) + if len(roles) != 2 || roles[0] != "fail2ban-admins" || roles[1] != "offline_access" { + t.Fatalf("roles = %#v, want nested role slice", roles) + } +} diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index 1297d2f..0ea5490 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -26,6 +26,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" "github.com/swissmakers/fail2ban-ui/internal/config" + "github.com/swissmakers/fail2ban-ui/internal/shared" "golang.org/x/oauth2" ) @@ -41,16 +42,81 @@ type OIDCClient struct { } type UserInfo struct { - ID string - Email string - Name string - Username string + ID string + Email string + Name string + Username string + Roles []string + AccessLevel string } var ( oidcClient *OIDCClient ) +const ( + AccessLevelAdmin = "admin" + AccessLevelSupport = "support" +) + +func AuthorizationEnabled() bool { + cfg := GetConfig() + return cfg != nil && cfg.AuthorizationEnabled +} + +func roleSet(values []string) map[string]struct{} { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + set[value] = struct{}{} + } + } + return set +} + +func hasAnyRole(userRoles []string, allowedRoles []string) bool { + allowed := roleSet(allowedRoles) + if len(allowed) == 0 { + return false + } + for _, role := range userRoles { + if _, ok := allowed[role]; ok { + return true + } + } + return false +} + +func accessLevelForRoles(cfg *config.OIDCConfig, roles []string) string { + if cfg == nil || !cfg.AuthorizationEnabled { + return AccessLevelAdmin + } + if hasAnyRole(roles, cfg.AdminRoles) { + return AccessLevelAdmin + } + if hasAnyRole(roles, cfg.SupportRoles) { + return AccessLevelSupport + } + return "" +} + +func SessionHasPermission(session *Session, permission string) bool { + if session == nil { + return false + } + if session.AccessLevel == AccessLevelAdmin { + return true + } + if session.AccessLevel == AccessLevelSupport { + switch permission { + case "read", "ban": + return true + } + } + return false +} + // ========================================================================= // Initialization // ========================================================================= @@ -155,6 +221,46 @@ func (c *OIDCClient) GetAuthURL(state string) string { return c.OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline) } +func claimByPath(claims map[string]interface{}, path string) interface{} { + if path == "" { + return nil + } + var current interface{} = claims + for _, part := range strings.Split(path, ".") { + m, ok := current.(map[string]interface{}) + if !ok { + return nil + } + current = m[part] + } + return current +} + +func stringSliceFromClaim(value interface{}) []string { + switch v := value.(type) { + case string: + return shared.SplitCommaList(v) + case []string: + out := make([]string, 0, len(v)) + for _, s := range v { + if trimmed := strings.TrimSpace(s); trimmed != "" { + out = append(out, trimmed) + } + } + return out + case []interface{}: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { + out = append(out, strings.TrimSpace(s)) + } + } + return out + default: + return nil + } +} + // Exchanges the authorization code for tokens. func (c *OIDCClient) ExchangeCode(ctx context.Context, code string) (*oauth2.Token, error) { if c.OAuth2Config == nil { @@ -197,10 +303,18 @@ func (c *OIDCClient) VerifyToken(ctx context.Context, token *oauth2.Token) (*Use return nil, fmt.Errorf("failed to extract claims: %w", err) } + allClaims := map[string]interface{}{} + if err := idToken.Claims(&allClaims); err != nil { + return nil, fmt.Errorf("failed to extract role claims: %w", err) + } + roles := stringSliceFromClaim(claimByPath(allClaims, c.Config.RoleClaim)) + userInfo := &UserInfo{ - ID: claims.Subject, - Email: claims.Email, - Name: claims.Name, + ID: claims.Subject, + Email: claims.Email, + Name: claims.Name, + Roles: roles, + AccessLevel: accessLevelForRoles(c.Config, roles), } switch c.Config.UsernameClaim { diff --git a/internal/auth/session.go b/internal/auth/session.go index 88799e3..2c9a22b 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -1,6 +1,6 @@ // Fail2ban UI - A Swiss made, management interface for Fail2ban. // -// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // // Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "crypto/sha256" "encoding/base64" "encoding/json" "fmt" @@ -34,11 +35,13 @@ import ( // ========================================================================= type Session struct { - UserID string `json:"userID"` - Email string `json:"email"` - Name string `json:"name"` - Username string `json:"username"` - ExpiresAt time.Time `json:"expiresAt"` + UserID string `json:"userID"` + Email string `json:"email"` + Name string `json:"name"` + Username string `json:"username"` + Roles []string `json:"roles,omitempty"` + AccessLevel string `json:"accessLevel,omitempty"` + ExpiresAt time.Time `json:"expiresAt"` } const ( @@ -82,7 +85,8 @@ func InitializeSessionSecret(secret string) error { if len(secret) < sessionKeyLength { return fmt.Errorf("session secret must be at least %d bytes", sessionKeyLength) } - sessionSecret = []byte(secret[:sessionKeyLength]) + sum := sha256.Sum256([]byte(secret)) + sessionSecret = sum[:] } else { if len(decoded) < sessionKeyLength { return fmt.Errorf("decoded session secret must be at least %d bytes", sessionKeyLength) @@ -96,11 +100,13 @@ func InitializeSessionSecret(secret string) error { // Creates a session cookie with the user info. func CreateSession(w http.ResponseWriter, r *http.Request, userInfo *UserInfo, maxAge int) error { session := &Session{ - UserID: userInfo.ID, - Email: userInfo.Email, - Name: userInfo.Name, - Username: userInfo.Username, - ExpiresAt: time.Now().Add(time.Duration(maxAge) * time.Second), + UserID: userInfo.ID, + Email: userInfo.Email, + Name: userInfo.Name, + Username: userInfo.Username, + Roles: userInfo.Roles, + AccessLevel: userInfo.AccessLevel, + ExpiresAt: time.Now().Add(time.Duration(maxAge) * time.Second), } sessionData, err := json.Marshal(session) diff --git a/internal/config/fail2ban_bridge.go b/internal/config/fail2ban_bridge.go index 56c10d0..be34c05 100644 --- a/internal/config/fail2ban_bridge.go +++ b/internal/config/fail2ban_bridge.go @@ -2,15 +2,17 @@ // // Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // -// Licensed under the PolyForm Shield License 1.0.0. +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// https://polyformproject.org/licenses/shield/1.0.0/ +// https://www.gnu.org/licenses/gpl-3.0.en.html // -// or in the LICENSE file in this repository. -// -// Required Notice: Copyright Swissmakers GmbH (https://swissmakers.ch) +// 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 config diff --git a/internal/config/logging.go b/internal/config/logging.go index 2fd69cc..1db7a1e 100644 --- a/internal/config/logging.go +++ b/internal/config/logging.go @@ -18,17 +18,22 @@ package config import ( "log" + "sync/atomic" ) // ========================================================================= // Debug Logging // ========================================================================= +var debugEnabled atomic.Bool + +func setDebugFlag(enabled bool) { + debugEnabled.Store(enabled) +} + // Prints debug messages if debug mode is enabled. func DebugLog(format string, v ...interface{}) { - debugEnabled := false - debugEnabled = currentSettings.Debug - if !debugEnabled { + if !debugEnabled.Load() { return } if len(v) > 0 { diff --git a/internal/config/settings.go b/internal/config/settings.go index eda5538..8706ea4 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -68,9 +68,13 @@ type AppSettings struct { BanactionAllports string `json:"banactionAllports"` Chain string `json:"chain"` BantimeRndtime string `json:"bantimeRndtime"` + BantimeMaxtime string `json:"bantimeMaxtime"` + BantimeFactor string `json:"bantimeFactor"` + BantimeOveralljails bool `json:"bantimeOveralljails"` GeoIPProvider string `json:"geoipProvider"` GeoIPDatabasePath string `json:"geoipDatabasePath"` MaxLogLines int `json:"maxLogLines"` + EventRetentionDays int `json:"eventRetentionDays"` EmailAlertsForBans bool `json:"emailAlertsForBans"` EmailAlertsForUnbans bool `json:"emailAlertsForUnbans"` AlertProvider string `json:"alertProvider"` @@ -149,19 +153,23 @@ type ThreatIntelSettings struct { } type OIDCConfig struct { - Enabled bool `json:"enabled"` - Provider string `json:"provider"` - IssuerURL string `json:"issuerURL"` - ClientID string `json:"clientID"` - ClientSecret string `json:"clientSecret"` - RedirectURL string `json:"redirectURL"` - Scopes []string `json:"scopes"` - SessionSecret string `json:"sessionSecret"` - SessionMaxAge int `json:"sessionMaxAge"` - SkipVerify bool `json:"skipVerify"` - UsernameClaim string `json:"usernameClaim"` - LogoutURL string `json:"logoutURL"` - SkipLoginPage bool `json:"skipLoginPage"` + Enabled bool `json:"enabled"` + Provider string `json:"provider"` + IssuerURL string `json:"issuerURL"` + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURL string `json:"redirectURL"` + Scopes []string `json:"scopes"` + SessionSecret string `json:"sessionSecret"` + SessionMaxAge int `json:"sessionMaxAge"` + SkipVerify bool `json:"skipVerify"` + UsernameClaim string `json:"usernameClaim"` + RoleClaim string `json:"roleClaim"` + AdminRoles []string `json:"adminRoles"` + SupportRoles []string `json:"supportRoles"` + AuthorizationEnabled bool `json:"authorizationEnabled"` + LogoutURL string `json:"logoutURL"` + SkipLoginPage bool `json:"skipLoginPage"` } func defaultAdvancedActionsConfig() AdvancedActionsConfig { @@ -225,15 +233,16 @@ norestored = 1 actionban = /usr/bin/curl__CURL_INSECURE_FLAG__ -X POST __CALLBACK_URL__/api/ban \ -H "Content-Type: application/json" \ -H "X-Callback-Secret: __CALLBACK_SECRET__" \ - -d "$(journalmatch=''; logpath=''; jq -n --arg serverId '__SERVER_ID__' \ + -d "$(logpath=''; \ + logs="$(tac $logpath 2>/dev/null | grep -a -wF '')"; \ + [ -z "$logs" ] && logs="$(journalctl --no-pager -r -o cat --since '-1 day' 2>/dev/null | grep -a -wF '')"; \ + logs="$(printf '%%s' "$logs" | LC_ALL=C tr -cd '\11\12\15\40-\176')"; \ + jq -n --arg serverId '__SERVER_ID__' \ --arg ip '' \ --arg jail '' \ --arg hostname '' \ --arg failures '' \ - --arg logs "$(if [ '' = 'systemd' ] && [ "$journalmatch" != '' ] && [ -n "$journalmatch" ]; \ - then journalctl -r $journalmatch 2>/dev/null; \ - else tac $logpath 2>/dev/null; \ - fi | grep -wF )" \ + --arg logs "$logs" \ '{serverId: $serverId, ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, logs: $logs}')" # Executes a cURL request to notify our API when an IP is unbanned. @@ -254,10 +263,6 @@ name = default # Path to log files containing relevant lines for the abuser IP logpath = /dev/null -# Default journal match -> empty for file backends so '' always -# resolves to a valid token; systemd-backed jails override this via their filter. -journalmatch = - # Number of log lines to include in the callback grepmax = 200 grepopts = -m ` @@ -338,6 +343,7 @@ func migrateLegacySettings() error { } settingsLock.Lock() currentSettings = legacy + setDebugFlag(currentSettings.Debug) settingsLock.Unlock() return nil } @@ -380,6 +386,7 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) { currentSettings.Language = rec.Language currentSettings.Port = rec.Port currentSettings.Debug = rec.Debug + setDebugFlag(rec.Debug) currentSettings.CallbackURL = rec.CallbackURL currentSettings.RestartNeeded = rec.RestartNeeded currentSettings.BantimeIncrement = rec.BantimeIncrement @@ -401,6 +408,9 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) { currentSettings.Chain = "INPUT" } currentSettings.BantimeRndtime = rec.BantimeRndtime + currentSettings.BantimeMaxtime = rec.BantimeMaxtime + currentSettings.BantimeFactor = rec.BantimeFactor + currentSettings.BantimeOveralljails = rec.BantimeOveralljails currentSettings.SMTP = SMTPSettings{ Host: rec.SMTPHost, Port: rec.SMTPPort, @@ -432,6 +442,7 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) { currentSettings.GeoIPProvider = rec.GeoIPProvider currentSettings.GeoIPDatabasePath = rec.GeoIPDatabasePath currentSettings.MaxLogLines = rec.MaxLogLines + currentSettings.EventRetentionDays = rec.EventRetentionDays currentSettings.CallbackSecret = rec.CallbackSecret currentSettings.EmailAlertsForBans = rec.EmailAlertsForBans currentSettings.EmailAlertsForUnbans = rec.EmailAlertsForUnbans @@ -478,25 +489,26 @@ func applyServerRecordsLocked(records []storage.ServerRecord) { _ = json.Unmarshal([]byte(rec.TagsJSON), &tags) } server := Fail2banServer{ - ID: rec.ID, - Name: rec.Name, - Type: rec.Type, - Host: rec.Host, - Port: rec.Port, - SocketPath: rec.SocketPath, - ConfigPath: rec.ConfigPath, - SSHUser: rec.SSHUser, - SSHKeyPath: rec.SSHKeyPath, - AgentURL: rec.AgentURL, - AgentSecret: rec.AgentSecret, - Hostname: rec.Hostname, - Tags: tags, - IsDefault: rec.IsDefault, - Enabled: rec.Enabled, - RestartNeeded: rec.NeedsRestart, - CreatedAt: rec.CreatedAt, - UpdatedAt: rec.UpdatedAt, - EnabledSet: true, + ID: rec.ID, + Name: rec.Name, + Type: rec.Type, + Host: rec.Host, + Port: rec.Port, + SocketPath: rec.SocketPath, + ConfigPath: rec.ConfigPath, + SSHUser: rec.SSHUser, + SSHKeyPath: rec.SSHKeyPath, + AgentURL: rec.AgentURL, + AgentSecret: rec.AgentSecret, + Hostname: rec.Hostname, + Tags: tags, + IsDefault: rec.IsDefault, + Enabled: rec.Enabled, + ReverseTunnelEnabled: rec.ReverseTunnelEnabled, + RestartNeeded: rec.NeedsRestart, + CreatedAt: rec.CreatedAt, + UpdatedAt: rec.UpdatedAt, + EnabledSet: true, } servers = append(servers, server) } @@ -566,10 +578,14 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) { BanactionAllports: currentSettings.BanactionAllports, Chain: currentSettings.Chain, BantimeRndtime: currentSettings.BantimeRndtime, + BantimeMaxtime: currentSettings.BantimeMaxtime, + BantimeFactor: currentSettings.BantimeFactor, + BantimeOveralljails: currentSettings.BantimeOveralljails, AdvancedActionsJSON: string(advancedBytes), GeoIPProvider: currentSettings.GeoIPProvider, GeoIPDatabasePath: currentSettings.GeoIPDatabasePath, MaxLogLines: currentSettings.MaxLogLines, + EventRetentionDays: currentSettings.EventRetentionDays, AlertProvider: alertProvider, WebhookJSON: string(webhookBytes), ElasticsearchJSON: string(esBytes), @@ -598,24 +614,25 @@ func toServerRecordsLocked() ([]storage.ServerRecord, error) { updatedAt = createdAt } records = append(records, storage.ServerRecord{ - ID: srv.ID, - Name: srv.Name, - Type: srv.Type, - Host: srv.Host, - Port: srv.Port, - SocketPath: srv.SocketPath, - ConfigPath: srv.ConfigPath, - SSHUser: srv.SSHUser, - SSHKeyPath: srv.SSHKeyPath, - AgentURL: srv.AgentURL, - AgentSecret: srv.AgentSecret, - Hostname: srv.Hostname, - TagsJSON: string(tagBytes), - IsDefault: srv.IsDefault, - Enabled: srv.Enabled, - NeedsRestart: srv.RestartNeeded, - CreatedAt: createdAt, - UpdatedAt: updatedAt, + ID: srv.ID, + Name: srv.Name, + Type: srv.Type, + Host: srv.Host, + Port: srv.Port, + SocketPath: srv.SocketPath, + ConfigPath: srv.ConfigPath, + SSHUser: srv.SSHUser, + SSHKeyPath: srv.SSHKeyPath, + AgentURL: srv.AgentURL, + AgentSecret: srv.AgentSecret, + Hostname: srv.Hostname, + TagsJSON: string(tagBytes), + IsDefault: srv.IsDefault, + Enabled: srv.Enabled, + ReverseTunnelEnabled: srv.ReverseTunnelEnabled, + NeedsRestart: srv.RestartNeeded, + CreatedAt: createdAt, + UpdatedAt: updatedAt, }) } return records, nil @@ -628,6 +645,7 @@ func setDefaults() { } func setDefaultsLocked() { + setDebugFlag(currentSettings.Debug) if currentSettings.Language == "" { currentSettings.Language = "en" } @@ -683,11 +701,16 @@ func setDefaultsLocked() { if currentSettings.SMTP.Port == 0 { currentSettings.SMTP.Port = 587 } - if currentSettings.SMTP.Username == "" { - currentSettings.SMTP.Username = "noreply@swissmakers.ch" - } - if currentSettings.SMTP.Password == "" { - currentSettings.SMTP.Password = "password" + if currentSettings.SMTP.AuthMethod == "none" { + currentSettings.SMTP.Username = "" + currentSettings.SMTP.Password = "" + } else { + if currentSettings.SMTP.Username == "" { + currentSettings.SMTP.Username = "noreply@swissmakers.ch" + } + if currentSettings.SMTP.Password == "" { + currentSettings.SMTP.Password = "password" + } } if currentSettings.SMTP.From == "" { currentSettings.SMTP.From = "noreply@swissmakers.ch" @@ -783,6 +806,15 @@ func initializeFromJailFile() error { if val, ok := settings["bantime.rndtime"]; ok && val != "" { currentSettings.BantimeRndtime = val } + if val, ok := settings["bantime.maxtime"]; ok && val != "" { + currentSettings.BantimeMaxtime = val + } + if val, ok := settings["bantime.factor"]; ok && val != "" { + currentSettings.BantimeFactor = val + } + if val, ok := settings["bantime.overalljails"]; ok && val != "" { + currentSettings.BantimeOveralljails = strings.EqualFold(val, "true") + } return nil } @@ -981,6 +1013,17 @@ chain = %s if settings.BantimeRndtime != "" { defaultSection += fmt.Sprintf("bantime.rndtime = %s\n", settings.BantimeRndtime) } + // bantime.maxtime caps how large escalating bans may grow when + // bantime.increment is enabled. Only emitted when the operator sets it. + if settings.BantimeMaxtime != "" { + defaultSection += fmt.Sprintf("bantime.maxtime = %s\n", settings.BantimeMaxtime) + } + if settings.BantimeFactor != "" { + defaultSection += fmt.Sprintf("bantime.factor = %s\n", settings.BantimeFactor) + } + if settings.BantimeOveralljails { + defaultSection += "bantime.overalljails = true\n" + } defaultSection += "\n" actionMwlgConfig := `# Custom Fail2Ban action for UI callbacks @@ -995,12 +1038,6 @@ action = %(action_mwlg)s return jailLocalBanner + defaultSection + actionMwlgConfig + actionOverride } -// Ensures that the managed jail.local file is valid and exists. (used by all connectors) -func EnsureJailLocalStructure(configPath string) error { - DebugLog("Running EnsureJailLocalStructure()") - return fail2ban.EnsureManagedJailLocal(configPath, []byte(BuildJailLocalContent())) -} - func cloneServer(src Fail2banServer) Fail2banServer { dst := src if src.Tags != nil { @@ -1027,7 +1064,7 @@ func BuildFail2banActionConfig(callbackURL, serverID, secret string) string { } } curlInsecureFlag := "" - if strings.HasPrefix(strings.ToLower(trimmed), "https://") { + if strings.HasPrefix(strings.ToLower(trimmed), "https://") && callbackInsecureTLSEnabled() { curlInsecureFlag = " -k" } config := strings.ReplaceAll(fail2banActionTemplate, actionCallbackPlaceholder, trimmed) @@ -1233,16 +1270,6 @@ func clearDefaultLocked() { } } -/*func setServerRestartFlagLocked(serverID string, value bool) bool { - for idx := range currentSettings.Servers { - if currentSettings.Servers[idx].ID == serverID { - currentSettings.Servers[idx].RestartNeeded = value - return true - } - } - return false -}*/ - func anyServerNeedsRestartLocked() bool { for _, srv := range currentSettings.Servers { if srv.RestartNeeded { @@ -1337,6 +1364,15 @@ func GetCallbackURLFromEnv() (string, bool) { return strings.TrimRight(v, "/"), true } +func callbackInsecureTLSEnabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("CALLBACK_INSECURE_TLS"))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + func GetBindAddressFromEnv() (string, bool) { bindAddrEnv := os.Getenv("BIND_ADDRESS") if bindAddrEnv == "" { @@ -1433,6 +1469,13 @@ func GetOIDCConfigFromEnv() (*OIDCConfig, error) { if config.UsernameClaim == "" { config.UsernameClaim = "preferred_username" } + config.RoleClaim = os.Getenv("OIDC_ROLE_CLAIM") + if config.RoleClaim == "" { + config.RoleClaim = "groups" + } + config.AdminRoles = shared.SplitCommaList(os.Getenv("OIDC_ADMIN_ROLES")) + config.SupportRoles = shared.SplitCommaList(os.Getenv("OIDC_SUPPORT_ROLES")) + config.AuthorizationEnabled = len(config.AdminRoles) > 0 || len(config.SupportRoles) > 0 config.LogoutURL = os.Getenv("OIDC_LOGOUT_URL") return config, nil } @@ -1444,51 +1487,6 @@ func GetSettings() AppSettings { return currentSettings } -// ========================================================================= -// Restart Tracking -// ========================================================================= - -// Marks the specified server as requiring a restart. -- currently not used -/*func MarkRestartNeeded(serverID string) error { - settingsLock.Lock() - defer settingsLock.Unlock() - - if serverID == "" { - return fmt.Errorf("server id must be provided") - } - - if !setServerRestartFlagLocked(serverID, true) { - return fmt.Errorf("server %s not found", serverID) - } - - updateGlobalRestartFlagLocked() - if err := persistServersLocked(); err != nil { - return err - } - return persistAppSettingsLocked() -} - -// Marks the specified server as no longer requiring a restart. -func MarkRestartDone(serverID string) error { - settingsLock.Lock() - defer settingsLock.Unlock() - - if serverID == "" { - return fmt.Errorf("server id must be provided") - } - - if !setServerRestartFlagLocked(serverID, false) { - return fmt.Errorf("server %s not found", serverID) - } - - updateGlobalRestartFlagLocked() - if err := persistServersLocked(); err != nil { - return err - } - return persistAppSettingsLocked() -} -*/ - func UpdateSettings(new AppSettings) (AppSettings, error) { settingsLock.Lock() defer settingsLock.Unlock() diff --git a/internal/config/settings_validation_test.go b/internal/config/settings_validation_test.go index 5efeff8..8122595 100644 --- a/internal/config/settings_validation_test.go +++ b/internal/config/settings_validation_test.go @@ -69,12 +69,11 @@ func TestValidateServerUniqueness(t *testing.T) { }) } -func TestFail2banActionTemplateQuotesUnresolvedTags(t *testing.T) { +func TestFail2banActionTemplateRobustness(t *testing.T) { t.Parallel() - if strings.Contains(fail2banActionTemplate, "journalctl -r ") { - t.Fatal("journalmatch must not be used directly in shell syntax; unresolved tags are parsed as redirections") - } + // Unresolved fail2ban tags must never appear bare in shell position (they get + // parsed as redirections/filenames). if strings.Contains(fail2banActionTemplate, "tac ") { t.Fatal("logpath must not be used directly in shell syntax; unresolved tags are parsed as redirections") } @@ -82,13 +81,46 @@ func TestFail2banActionTemplateQuotesUnresolvedTags(t *testing.T) { t.Fatal(`tac "$logpath" is quoted; globbed/multi-file logpaths will not expand and logs will be empty`) } for _, want := range []string{ - "journalmatch=''", "logpath=''", - `journalctl -r $journalmatch`, `tac $logpath`, + "grep -a", + "LC_ALL=C tr -cd '\\11\\12\\15\\40-\\176'", + "jq -n", + "--arg logs", + "journalctl", } { if !strings.Contains(fail2banActionTemplate, want) { t.Fatalf("action template missing %q", want) } } + + // The identity fields must be present so a ban is reported even with no logs. + for _, want := range []string{"--arg ip ''", "--arg jail ''"} { + if !strings.Contains(fail2banActionTemplate, want) { + t.Fatalf("action template missing identity field %q", want) + } + } +} + +func TestFail2banActionConfigEscapesPercent(t *testing.T) { + t.Parallel() + + content := BuildFail2banActionConfig("http://127.0.0.1:9999", "srv-test", "secret") + for i := 0; i < len(content); i++ { + if content[i] != '%' { + continue + } + if i+1 >= len(content) { + t.Fatalf("action config ends with a bare '%%' at offset %d", i) + } + switch content[i+1] { + case '%': + i++ + case '(': + + default: + t.Fatalf("action config contains bare '%%' followed by %q at offset %d: %q", + content[i+1], i, content[max(0, i-40):min(len(content), i+40)]) + } + } } diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go index 4c1a203..ff5c6fc 100644 --- a/internal/fail2ban/connector_agent.go +++ b/internal/fail2ban/connector_agent.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "io" + "log" "net" "net/http" "net/url" @@ -172,7 +173,7 @@ func NewAgentConnector(server shared.Fail2banServer) (Connector, error) { client: client, } if err := conn.ensureCallbackConfig(context.Background()); err != nil { - fmt.Printf("warning: failed to configure agent callback for %s: %v\n", server.Name, err) + log.Printf("warning: failed to configure agent callback for %s: %v", server.Name, err) } return conn, nil } @@ -255,11 +256,23 @@ func (ac *AgentConnector) GetBannedIPs(ctx context.Context, jail string) ([]stri } func (ac *AgentConnector) UnbanIP(ctx context.Context, jail, ip string) error { + if err := ValidateJailName(jail); err != nil { + return err + } + if err := ValidateIP(ip); err != nil { + return err + } payload := map[string]string{"ip": ip} return ac.post(ctx, fmt.Sprintf("/v1/jails/%s/unban", url.PathEscape(jail)), payload, nil) } func (ac *AgentConnector) BanIP(ctx context.Context, jail, ip string) error { + if err := ValidateJailName(jail); err != nil { + return err + } + if err := ValidateIP(ip); err != nil { + return err + } payload := map[string]string{"ip": ip} return ac.post(ctx, fmt.Sprintf("/v1/jails/%s/ban", url.PathEscape(jail)), payload, nil) } diff --git a/internal/fail2ban/connector_global.go b/internal/fail2ban/connector_global.go index e864206..b8cf51d 100644 --- a/internal/fail2ban/connector_global.go +++ b/internal/fail2ban/connector_global.go @@ -19,10 +19,35 @@ package fail2ban import ( "context" + "fmt" "sort" + "strings" "sync" + + "github.com/swissmakers/fail2ban-ui/internal/shared" ) +// ========================================================================= +// Validation +// ========================================================================= + +// Ensures an IP/CIDR is well-formed +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. +func checkReloadOutput(output string) error { + trimmed := strings.TrimSpace(output) + if trimmed == "" || trimmed == "OK" { + return nil + } + if strings.Contains(output, "Errors in jail") || strings.Contains(output, "Unable to read the filter") { + return fmt.Errorf("fail2ban reload completed but with errors (output: %s)", trimmed) + } + return nil +} + // ========================================================================= // Types // ========================================================================= diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go index 490ede6..e8c3be7 100644 --- a/internal/fail2ban/connector_local.go +++ b/internal/fail2ban/connector_local.go @@ -89,6 +89,9 @@ func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error { if err := ValidateJailName(jail); err != nil { return err } + if err := ValidateIP(ip); err != nil { + return err + } args := []string{"set", jail, "unbanip", ip} if _, err := lc.runFail2banClient(ctx, args...); err != nil { return fmt.Errorf("error unbanning IP %s from jail %s: %w", ip, jail, err) @@ -101,6 +104,9 @@ func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error { if err := ValidateJailName(jail); err != nil { return err } + if err := ValidateIP(ip); err != nil { + return err + } args := []string{"set", jail, "banip", ip} if _, err := lc.runFail2banClient(ctx, args...); err != nil { return fmt.Errorf("error banning IP %s in jail %s: %w", ip, jail, err) @@ -114,15 +120,7 @@ func (lc *LocalConnector) Reload(ctx context.Context) error { if err != nil { return fmt.Errorf("fail2ban reload error: %w (output: %s)", err, strings.TrimSpace(out)) } - // Check if fail2ban-client returns "OK" - outputTrimmed := strings.TrimSpace(out) - if outputTrimmed != "OK" && outputTrimmed != "" { - debugf("fail2ban reload output: %s", out) - if strings.Contains(out, "Errors in jail") || strings.Contains(out, "Unable to read the filter") { - return fmt.Errorf("fail2ban reload completed but with errors (output: %s)", strings.TrimSpace(out)) - } - } - return nil + return checkReloadOutput(out) } // Restart or reload the local Fail2ban instance; returns "restart" or "reload". diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index eb9ddd7..493c174 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -23,6 +23,8 @@ import ( "encoding/base64" "errors" "fmt" + "log" + "net/url" "os" "os/exec" "path/filepath" @@ -46,18 +48,26 @@ type SSHConnector struct { fail2banPath string pathCached bool pathMutex sync.RWMutex + tunnelPort int } const sshEnsureActionScript = `python3 - <<'PY' import base64 +import os import pathlib +import shutil import sys try: action_dir = pathlib.Path("/etc/fail2ban/action.d") action_dir.mkdir(parents=True, exist_ok=True) action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8") - (action_dir / "ui-custom-action.conf").write_text(action_cfg) + action_file = action_dir / "ui-custom-action.conf" + action_file.write_text(action_cfg) + os.chmod(action_file, 0o600) + missing = [t for t in ("jq", "curl") if shutil.which(t) is None] + if missing: + sys.stdout.write("F2BUI_MISSING_TOOLS:" + ",".join(missing) + "\n") except Exception as e: sys.stderr.write(f"Error: {e}\n") sys.exit(1) @@ -77,6 +87,23 @@ func NewSSHConnector(server shared.Fail2banServer) (Connector, error) { } conn := &SSHConnector{server: server} + // 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) + } + } + // 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) @@ -88,6 +115,21 @@ 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 // ========================================================================= @@ -145,6 +187,9 @@ func (sc *SSHConnector) UnbanIP(ctx context.Context, jail, ip string) error { if err := ValidateJailName(jail); err != nil { return err } + if err := ValidateIP(ip); err != nil { + return err + } _, err := sc.runFail2banCommand(ctx, "set", jail, "unbanip", ip) return err } @@ -153,13 +198,19 @@ func (sc *SSHConnector) BanIP(ctx context.Context, jail, ip string) error { if err := ValidateJailName(jail); err != nil { return err } + if err := ValidateIP(ip); err != nil { + return err + } _, err := sc.runFail2banCommand(ctx, "set", jail, "banip", ip) return err } func (sc *SSHConnector) Reload(ctx context.Context) error { - _, err := sc.runFail2banCommand(ctx, "reload") - return err + out, err := sc.runFail2banCommand(ctx, "reload") + if err != nil { + return err + } + return checkReloadOutput(out) } func (sc *SSHConnector) Restart(ctx context.Context) error { @@ -195,8 +246,8 @@ func (sc *SSHConnector) RestartWithMode(ctx context.Context) (string, error) { func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) (string, string, error) { filterName = strings.TrimSpace(filterName) - if filterName == "" { - return "", "", fmt.Errorf("filter name cannot be empty") + if err := ValidateFilterName(filterName); err != nil { + return "", "", err } fail2banPath := sc.getFail2banPath(ctx) @@ -218,8 +269,8 @@ func (sc *SSHConnector) GetFilterConfig(ctx context.Context, filterName string) func (sc *SSHConnector) SetFilterConfig(ctx context.Context, filterName, content string) error { filterName = strings.TrimSpace(filterName) - if filterName == "" { - return fmt.Errorf("filter name cannot be empty") + if err := ValidateFilterName(filterName); err != nil { + return err } fail2banPath := sc.getFail2banPath(ctx) @@ -284,7 +335,6 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error { var err error select { case err = <-done: - // Command completed normally case <-ctx.Done(): if cmd.Process != nil && cmd.Process.Pid > 0 { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) @@ -301,6 +351,10 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error { 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) } + 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", + sc.server.Name, sc.server.ID, marker) + } if output != "" { debugf("Successfully ensured action file for server %s (output: %s)", sc.server.Name, output) } else { @@ -309,6 +363,16 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error { return nil } +func extractMissingToolsWarning(output string) string { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if rest, ok := strings.CutPrefix(line, "F2BUI_MISSING_TOOLS:"); ok { + return strings.TrimSpace(rest) + } + } + return "" +} + // ========================================================================= // SSH Helpers // ========================================================================= @@ -387,7 +451,6 @@ func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) cmd.Stdout = &stdout cmd.Stderr = &stderr - // Start the command if err := cmd.Start(); err != nil { return "", fmt.Errorf("failed to start ssh command: %w", err) } @@ -431,11 +494,22 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { ) } 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=300", + "-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) } @@ -496,7 +570,7 @@ func (sc *SSHConnector) readRemoteFile(ctx context.Context, filePath string) (st func (sc *SSHConnector) writeRemoteFile(ctx context.Context, filePath, content string) error { escaped := strings.ReplaceAll(content, "'", "'\"'\"'") - script := fmt.Sprintf(`cat > %s <<'REMOTEEOF' + script := fmt.Sprintf(`cat > '%s' <<'REMOTEEOF' %s REMOTEEOF `, filePath, escaped) @@ -512,6 +586,10 @@ func (sc *SSHConnector) ensureRemoteLocalFile(ctx context.Context, basePath, nam 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 @@ -571,7 +649,6 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { jailDPath := filepath.Join(fail2banPath, "jail.d") var allJails []JailInfo - processedFiles := make(map[string]bool) processedJails := make(map[string]bool) readAllScript := fmt.Sprintf(`python3 << 'PYEOF' @@ -631,13 +708,11 @@ PYEOF`, jailDPath) output, err := sc.runRemoteCommand(ctx, []string{readAllScript}) if err != nil { - // Fallback to individual file reads if the script fails 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 - var currentType string inFile := false lines := strings.Split(output, "\n") @@ -656,19 +731,8 @@ PYEOF`, jailDPath) parts := strings.SplitN(line, ":", 3) if len(parts) == 3 { currentFile = parts[1] - currentType = parts[2] currentContent.Reset() inFile = true - filename := filepath.Base(currentFile) - var baseName string - if currentType == "local" { - baseName = strings.TrimSuffix(filename, ".local") - } else { - baseName = strings.TrimSuffix(filename, ".conf") - } - if baseName != "" { - processedFiles[baseName] = true - } } } else if line == "FILE_END" { if inFile && currentFile != "" { @@ -1064,13 +1128,12 @@ func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLi return "No log lines provided.\n", "", nil } - // Sanitize filter name to prevent path traversal + // Enforce the shared strict allowlist (blocks path traversal and shell + // metacharacters) rather than the previous ad-hoc string stripping. filterName = strings.TrimSpace(filterName) - if filterName == "" { - return "", "", fmt.Errorf("filter name cannot be empty") + if err := ValidateFilterName(filterName); err != nil { + return "", "", err } - filterName = strings.ReplaceAll(filterName, "/", "") - filterName = strings.ReplaceAll(filterName, "..", "") fail2banPath := sc.getFail2banPath(ctx) localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") @@ -1174,8 +1237,8 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) { jail = strings.TrimSpace(jail) - if jail == "" { - return "", "", fmt.Errorf("jail name cannot be empty") + if err := ValidateJailName(jail); err != nil { + return "", "", err } fail2banPath := sc.getFail2banPath(ctx) @@ -1197,8 +1260,8 @@ func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) error { jail = strings.TrimSpace(jail) - if jail == "" { - return fmt.Errorf("jail name cannot be empty") + if err := ValidateJailName(jail); err != nil { + return err } fail2banPath := sc.getFail2banPath(ctx) @@ -1403,7 +1466,7 @@ func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context) error { } func (sc *SSHConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) { - const jailLocalPath = "/etc/fail2ban/jail.local" + 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") { @@ -1416,14 +1479,13 @@ func (sc *SSHConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool } func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { - jailLocalPath := "/etc/fail2ban/jail.local" + 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 { - // The file belongs to the user; never overwrite it. debugf("jail.local on server %s exists but is not managed by Fail2ban-UI - skipping overwrite", sc.server.Name) return nil } @@ -1442,8 +1504,9 @@ func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error { // Escape single quotes for safe use in a single-quoted heredoc escaped := strings.ReplaceAll(content, "'", "'\"'\"'") - // Write the rebuilt content via heredoc over SSH - writeScript := fmt.Sprintf(`cat > %s <<'JAILLOCAL' + // 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) diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index 43608fc..b7868c2 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -17,34 +17,14 @@ package fail2ban import ( - "context" "fmt" "os" "os/exec" "path/filepath" - "regexp" "sort" "strings" ) -// Returns the filter configuration using the default connector. -func GetFilterConfig(jail string) (string, string, error) { - conn, err := GetManager().DefaultConnector() - if err != nil { - return "", "", err - } - return conn.GetFilterConfig(context.Background(), jail) -} - -// Writes the filter configuration using the default connector. -func SetFilterConfig(jail, newContent string) error { - conn, err := GetManager().DefaultConnector() - if err != nil { - return err - } - return conn.SetFilterConfig(context.Background(), jail, newContent) -} - func ensureFilterLocalFile(filterName, configPath string) error { filterName = strings.TrimSpace(filterName) if filterName == "" { @@ -159,11 +139,14 @@ func ValidateFilterName(name string) error { return fmt.Errorf("filter name cannot be empty") } - invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`) - if invalidChars.MatchString(name) { + if invalidNameChars.MatchString(name) { return fmt.Errorf("filter name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name) } + if name[0] == '-' { + return fmt.Errorf("filter name '%s' must not start with a dash", name) + } + return nil } diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go index f894f3f..5702527 100644 --- a/internal/fail2ban/jail_management.go +++ b/internal/fail2ban/jail_management.go @@ -111,6 +111,9 @@ func readJailConfigWithFallback(jailName, configPath string) (string, string, er // Validation // ========================================================================= +var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`) +var enabledTruePattern = regexp.MustCompile(`(?m)^\s*enabled\s*=\s*true\s*$`) + func ValidateJailName(name string) error { name = strings.TrimSpace(name) if name == "" { @@ -125,12 +128,15 @@ func ValidateJailName(name string) error { return fmt.Errorf("jail name '%s' is reserved and cannot be used", name) } - // Check for invalid characters (only alphanumeric, dash, underscore allowed) - invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`) - if invalidChars.MatchString(name) { + // Check for invalid characters + if invalidNameChars.MatchString(name) { return fmt.Errorf("jail name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name) } + if name[0] == '-' { + return fmt.Errorf("jail name '%s' must not start with a dash", name) + } + return nil } @@ -828,7 +834,7 @@ func MigrateJailsFromJailLocal(configPath string) error { } jailContent = modifiedContent } else { - jailContent = regexp.MustCompile(`(?m)^\s*enabled\s*=\s*true\s*$`).ReplaceAllString(jailContent, "enabled = false") + jailContent = enabledTruePattern.ReplaceAllString(jailContent, "enabled = false") } if err := os.WriteFile(jailFilePath, []byte(jailContent), 0644); err != nil { return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) diff --git a/internal/fail2ban/local_managed_files.go b/internal/fail2ban/local_managed_files.go index e9b5d9b..8dd34e3 100644 --- a/internal/fail2ban/local_managed_files.go +++ b/internal/fail2ban/local_managed_files.go @@ -80,16 +80,19 @@ func WriteLocalActionFile(configPath, callbackURL, serverID string) error { } secret := p.CallbackSecret() cfg := p.BuildFail2banActionConfig(callbackURL, serverID, secret) - if err := os.WriteFile(actionPath, []byte(cfg), 0644); err != nil { + if err := os.WriteFile(actionPath, []byte(cfg), 0600); err != nil { return fmt.Errorf("failed to write action file: %w", err) } + if err := os.Chmod(actionPath, 0600); err != nil { + return fmt.Errorf("failed to restrict action file permissions: %w", err) + } debugf("Custom-action file successfully written to %s\n", actionPath) return nil } // Ensures jail.local and the UI action file for a local tree. func EnsureLocalConnectorArtifacts(callbackURL, serverID, configPath string) error { - debugf("ensureFail2banActionFiles called") + debugf("Running EnsureLocalConnectorArtifacts()") jailPath := JailLocal(configPath) if _, err := os.Stat(filepath.Dir(jailPath)); os.IsNotExist(err) { rootDir := NormalizeConfigPath(configPath) diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go index 4bc3b3f..3618d4e 100644 --- a/internal/fail2ban/manager.go +++ b/internal/fail2ban/manager.go @@ -19,7 +19,9 @@ package fail2ban import ( "context" "fmt" + "log" "sync" + "time" "github.com/swissmakers/fail2ban-ui/internal/shared" ) @@ -195,7 +197,7 @@ func (m *Manager) UpdateActionFiles(ctx context.Context) error { var lastErr error for _, conn := range connectors { if err := updateConnectorAction(ctx, conn); err != nil { - fmt.Printf("warning: failed to update action file for server %s: %v\n", conn.Server().Name, err) + log.Printf("warning: failed to update action file for server %s: %v", conn.Server().Name, err) lastErr = err } } @@ -224,6 +226,65 @@ func updateConnectorAction(ctx context.Context, conn Connector) error { } } +// Pushes all runtime config needed by remote (SSH/agent) connectors, then reloads Fail2Ban so the daemon uses it immediately. +// Is intended to run once at startup after the connector registry was rebuilt. +func (m *Manager) SyncRemoteStartupConfig(ctx context.Context, perHostTimeout time.Duration) (synced int, failed int) { + m.mu.RLock() + connectors := make([]Connector, 0, len(m.connectors)) + for _, conn := range m.connectors { + switch conn.Server().Type { + case "ssh", "agent": + connectors = append(connectors, conn) + } + } + m.mu.RUnlock() + + if perHostTimeout <= 0 { + perHostTimeout = 30 * time.Second + } + + var ( + mu sync.Mutex + wg sync.WaitGroup + ) + for _, conn := range connectors { + wg.Add(1) + go func(conn Connector) { + defer wg.Done() + server := conn.Server() + + hostCtx, cancel := context.WithTimeout(ctx, perHostTimeout) + defer cancel() + + ok := true + if err := updateConnectorAction(hostCtx, conn); err != nil { + log.Printf("warning: failed to update startup action config for %s: %v", server.Name, err) + ok = false + } + if err := conn.EnsureJailLocalStructure(hostCtx); err != nil { + log.Printf("warning: failed to ensure startup jail.local on %s: %v", server.Name, err) + ok = false + } + if err := conn.Reload(hostCtx); err != nil { + log.Printf("warning: failed to reload fail2ban on %s after startup config sync: %v", server.Name, err) + ok = false + } else { + log.Printf("reloaded fail2ban on %s (%s) after startup config sync", server.Name, server.ID) + } + + mu.Lock() + if ok { + synced++ + } else { + failed++ + } + mu.Unlock() + }(conn) + } + wg.Wait() + return synced, failed +} + // ========================================================================= // Connector Factory // ========================================================================= diff --git a/internal/fail2ban/variable_resolver.go b/internal/fail2ban/variable_resolver.go index 8b8cd3d..ea2692c 100644 --- a/internal/fail2ban/variable_resolver.go +++ b/internal/fail2ban/variable_resolver.go @@ -1,16 +1,18 @@ // Fail2ban UI - A Swiss made, management interface for Fail2ban. // -// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // -// Licensed under the PolyForm Shield License 1.0.0. +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// https://polyformproject.org/licenses/shield/1.0.0/ +// https://www.gnu.org/licenses/gpl-3.0.en.html // -// or in the LICENSE file in this repository. -// -// Required Notice: Copyright Swissmakers GmbH (https://swissmakers.ch) +// 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 diff --git a/internal/fail2ban/variable_resolver_test.go b/internal/fail2ban/variable_resolver_test.go index 205480b..3a78726 100644 --- a/internal/fail2ban/variable_resolver_test.go +++ b/internal/fail2ban/variable_resolver_test.go @@ -2,15 +2,17 @@ // // Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // -// Licensed under the PolyForm Shield License 1.0.0. +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// https://polyformproject.org/licenses/shield/1.0.0/ +// https://www.gnu.org/licenses/gpl-3.0.en.html // -// or in the LICENSE file in this repository. -// -// Required Notice: Copyright Swissmakers GmbH (https://swissmakers.ch) +// 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 diff --git a/internal/integrations/opnsense.go b/internal/integrations/opnsense.go index a89fc64..d924e34 100644 --- a/internal/integrations/opnsense.go +++ b/internal/integrations/opnsense.go @@ -2,11 +2,9 @@ package integrations import ( "bytes" - "crypto/tls" "encoding/base64" "encoding/json" "fmt" - "io" "net/http" "strings" "time" @@ -90,14 +88,7 @@ func (o *opnsenseIntegration) callAPI(req Request, action, ip string) error { return fmt.Errorf("failed to encode OPNsense payload: %w", err) } - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - if cfg.SkipTLSVerify { - httpClient.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - } + httpClient := integrationHTTPClient(10*time.Second, cfg.SkipTLSVerify) reqLogger := "OPNsense" if req.Logger != nil { @@ -124,7 +115,7 @@ func (o *opnsenseIntegration) callAPI(req Request, action, ip string) error { return fmt.Errorf("OPNsense API request to %s failed: %w (check base URL, network connectivity, and API credentials)", apiURL, err) } defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, _ := readLimitedResponse(resp.Body) bodyStr := strings.TrimSpace(string(bodyBytes)) if resp.StatusCode >= 300 { diff --git a/internal/integrations/pfsense.go b/internal/integrations/pfsense.go index 0a5eeba..92af0ae 100644 --- a/internal/integrations/pfsense.go +++ b/internal/integrations/pfsense.go @@ -2,10 +2,8 @@ package integrations import ( "bytes" - "crypto/tls" "encoding/json" "fmt" - "io" "net/http" "net/url" "strings" @@ -97,14 +95,7 @@ func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string, } baseURL := strings.TrimSuffix(cfg.BaseURL, "/") - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - if cfg.SkipTLSVerify { - httpClient.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, - } - } + httpClient := integrationHTTPClient(10*time.Second, cfg.SkipTLSVerify) // GET the alias by name alias, err := p.getAliasByName(httpClient, baseURL, cfg.APIToken, cfg.Alias, req.Logger) @@ -229,7 +220,7 @@ func (p *pfSenseIntegration) getAliasByName(client *http.Client, baseURL, apiTok } defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, _ := readLimitedResponse(resp.Body) bodyStr := strings.TrimSpace(string(bodyBytes)) if resp.StatusCode != http.StatusOK { @@ -293,7 +284,7 @@ func (p *pfSenseIntegration) createAlias(client *http.Client, baseURL, apiToken } defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, _ := readLimitedResponse(resp.Body) bodyStr := strings.TrimSpace(string(bodyBytes)) if resp.StatusCode >= 300 { @@ -360,7 +351,7 @@ func (p *pfSenseIntegration) updateAlias(client *http.Client, baseURL, apiToken } defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, _ := readLimitedResponse(resp.Body) bodyStr := strings.TrimSpace(string(bodyBytes)) if resp.StatusCode >= 300 { @@ -400,7 +391,7 @@ func (p *pfSenseIntegration) applyFirewallChanges(client *http.Client, baseURL, } defer resp.Body.Close() - bodyBytes, _ := io.ReadAll(resp.Body) + bodyBytes, _ := readLimitedResponse(resp.Body) bodyStr := strings.TrimSpace(string(bodyBytes)) if resp.StatusCode >= 300 { diff --git a/internal/integrations/types.go b/internal/integrations/types.go index a28ff78..e13f8b0 100644 --- a/internal/integrations/types.go +++ b/internal/integrations/types.go @@ -2,13 +2,17 @@ package integrations import ( "context" + "crypto/tls" "fmt" - "net" + "io" + "net/http" "net/url" "regexp" "strings" + "time" "github.com/swissmakers/fail2ban-ui/internal/config" + "github.com/swissmakers/fail2ban-ui/internal/shared" ) // ========================================================================= @@ -32,18 +36,10 @@ type Request struct { // Matches only alphanumeric characters, hyphens, underscores and dots var safeIdentifier = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,128}$`) -// Validates that the string is a valid IPv4/IPv6 address or CIDR notation and contains no shell metacharacters +// Validates that the string is a valid IPv4/IPv6 address or CIDR notation and contains no shell metacharacters. +// Canonical implementation lives in shared (also used by the fail2ban connectors). func ValidateIP(ip string) error { - if ip == "" { - return fmt.Errorf("IP address is required") - } - if net.ParseIP(ip) != nil { - return nil - } - if _, _, err := net.ParseCIDR(ip); err == nil { - return nil - } - return fmt.Errorf("invalid IP address or CIDR: %q", ip) + return shared.ValidateIP(ip) } // Validates that an user-configured base URL is well-formed and uses an allowed scheme (http/https). @@ -70,6 +66,31 @@ func ValidateOutboundURL(rawURL, label string) error { return nil } +// Caps how much of a firewall API response is read into memory, so a hostile or broken endpoint cannot exhaust memory. +const maxIntegrationResponseBytes = 5 << 20 // 5 MiB + +// returns client for credential-bearing firewall API requests. Redirects are NOT followed, Go strips Authorization on cross-host +// redirects but not custom headers (x-api-key), so following one would replay credentials to the redirect target. +func integrationHTTPClient(timeout time.Duration, skipTLSVerify bool) *http.Client { + client := &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + if skipTLSVerify { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } + return client +} + +// Reads at most maxIntegrationResponseBytes from a body. +func readLimitedResponse(body io.Reader) ([]byte, error) { + return io.ReadAll(io.LimitReader(body, maxIntegrationResponseBytes)) +} + // Validates that a user-supplied name (address list, alias, etc.) contains only safe characters and cannot be used for injection attacks. func ValidateIdentifier(name, label string) error { if name == "" { diff --git a/internal/shared/server.go b/internal/shared/server.go index 2674021..b5f8df0 100644 --- a/internal/shared/server.go +++ b/internal/shared/server.go @@ -24,25 +24,26 @@ import ( // Describes a registered Fail2ban instance and how to reach it. // It lives in package shared so connector code does not import application config. type Fail2banServer struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Host string `json:"host,omitempty"` - Port int `json:"port,omitempty"` - SocketPath string `json:"socketPath,omitempty"` - ConfigPath string `json:"configPath,omitempty"` - SSHUser string `json:"sshUser,omitempty"` - SSHKeyPath string `json:"sshKeyPath,omitempty"` - AgentURL string `json:"agentUrl,omitempty"` - AgentSecret string `json:"agentSecret,omitempty"` - Hostname string `json:"hostname,omitempty"` - Tags []string `json:"tags,omitempty"` - IsDefault bool `json:"isDefault"` - Enabled bool `json:"enabled"` - RestartNeeded bool `json:"restartNeeded"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - EnabledSet bool `json:"-"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + SocketPath string `json:"socketPath,omitempty"` + ConfigPath string `json:"configPath,omitempty"` + SSHUser string `json:"sshUser,omitempty"` + SSHKeyPath string `json:"sshKeyPath,omitempty"` + AgentURL string `json:"agentUrl,omitempty"` + AgentSecret string `json:"agentSecret,omitempty"` + Hostname string `json:"hostname,omitempty"` + Tags []string `json:"tags,omitempty"` + IsDefault bool `json:"isDefault"` + Enabled bool `json:"enabled"` + ReverseTunnelEnabled bool `json:"reverseTunnelEnabled,omitempty"` + RestartNeeded bool `json:"restartNeeded"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + EnabledSet bool `json:"-"` } // Distinguishes explicit false for "enabled" from a missing key. diff --git a/internal/shared/validation.go b/internal/shared/validation.go new file mode 100644 index 0000000..b48af42 --- /dev/null +++ b/internal/shared/validation.go @@ -0,0 +1,52 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-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/gpl-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 shared + +import ( + "fmt" + "net" + "strings" +) + +// Ensures a value is a well-formed IPv4/IPv6 address or CIDR before it is handed to fail2ban-client +func ValidateIP(ip string) error { + if ip == "" { + return fmt.Errorf("IP address cannot be empty") + } + if net.ParseIP(ip) != nil { + return nil + } + if _, _, err := net.ParseCIDR(ip); err == nil { + return nil + } + return fmt.Errorf("invalid IP address or CIDR: %q", ip) +} + +// Splits a comma-separated string into trimmed, non-empty entries. +func SplitCommaList(value string) []string { + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 978b858..50fc3b6 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1,6 +1,6 @@ // Fail2ban UI - A Swiss made, management interface for Fail2ban. // -// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // // Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. @@ -80,38 +80,14 @@ func formatStorageTime(t time.Time) string { return t.UTC().Format(storageTimeFormat) } -func formatLegacyStorageTime(t time.Time) string { - return t.UTC().Format(legacyStorageTimeFormat) -} - -func parseStorageTime(value string) (time.Time, error) { - value = strings.TrimSpace(value) - if value == "" { - return time.Time{}, errors.New("empty timestamp") - } - formats := []string{ - storageTimeFormat, - time.RFC3339Nano, - time.RFC3339, - "2006-01-02 15:04:05.999999999 -0700 MST", - "2006-01-02 15:04:05 -0700 MST", - legacyStorageTimeFormat, - "2006-01-02 15:04:05", - } - for _, format := range formats { - if parsed, err := time.Parse(format, value); err == nil { - return parsed.UTC(), nil - } - } - return time.Time{}, fmt.Errorf("unsupported timestamp format %q", value) -} - +// All rows are RFC3339 (legacy formats are rewritten by migrateLegacyTimestamps), +// so a plain string comparison is correct and keeps the occurred_at indexes usable. func addOccurredAtSinceFilter(query *string, args *[]any, since time.Time) { if since.IsZero() { return } - *query += " AND (CAST(occurred_at AS TEXT) >= ? OR (instr(CAST(occurred_at AS TEXT), 'T') = 0 AND CAST(occurred_at AS TEXT) >= ?))" - *args = append(*args, formatStorageTime(since), formatLegacyStorageTime(since)) + *query += " AND occurred_at >= ?" + *args = append(*args, formatStorageTime(since)) } // ========================================================================= @@ -148,10 +124,14 @@ type AppSettingsRecord struct { BanactionAllports string Chain string BantimeRndtime string + BantimeMaxtime string + BantimeFactor string + BantimeOveralljails bool AdvancedActionsJSON string GeoIPProvider string GeoIPDatabasePath string MaxLogLines int + EventRetentionDays int AlertProvider string WebhookJSON string ElasticsearchJSON string @@ -159,39 +139,38 @@ type AppSettingsRecord struct { } type ServerRecord struct { - ID string - Name string - Type string - Host string - Port int - SocketPath string - ConfigPath string - SSHUser string - SSHKeyPath string - AgentURL string - AgentSecret string - Hostname string - TagsJSON string - IsDefault bool - Enabled bool - NeedsRestart bool - CreatedAt time.Time - UpdatedAt time.Time + ID string + Name string + Type string + Host string + Port int + SocketPath string + ConfigPath string + SSHUser string + SSHKeyPath string + AgentURL string + AgentSecret string + Hostname string + TagsJSON string + IsDefault bool + Enabled bool + ReverseTunnelEnabled bool + NeedsRestart bool + CreatedAt time.Time + UpdatedAt time.Time } type BanEventRecord struct { - ID int64 `json:"id"` - ServerID string `json:"serverId"` - ServerName string `json:"serverName"` - Jail string `json:"jail"` - IP string `json:"ip"` - Country string `json:"country"` - Hostname string `json:"hostname"` - Failures string `json:"failures"` - Whois string `json:"whois"` - Logs string `json:"logs"` - // HasWhois/HasLogs let the list view show the whois/logs buttons without - // shipping the heavy text; the content is fetched on demand by id. + ID int64 `json:"id"` + ServerID string `json:"serverId"` + ServerName string `json:"serverName"` + Jail string `json:"jail"` + IP string `json:"ip"` + Country string `json:"country"` + Hostname string `json:"hostname"` + Failures string `json:"failures"` + Whois string `json:"whois"` + Logs string `json:"logs"` HasWhois bool `json:"hasWhois"` HasLogs bool `json:"hasLogs"` EventType string `json:"eventType"` @@ -245,11 +224,49 @@ func Init(dbPath string) error { return } - initErr = ensureSchema(context.Background()) + restrictDatabasePermissions(dbPath) + + if initErr = ensureSchema(context.Background()); initErr != nil { + return + } + initErr = migrateLegacyTimestamps(context.Background()) }) return initErr } +// Rewrites legacy Go-default timestamps ("2006-01-02 15:04:05.999999999 +0000 UTC") +// to RFC3339 so occurred_at / created_at compare correctly as strings and the +// occurred_at indexes stay usable. +func migrateLegacyTimestamps(ctx context.Context) error { + for _, column := range []string{"occurred_at", "created_at"} { + query := fmt.Sprintf( + `UPDATE ban_events SET %[1]s = replace(substr(%[1]s, 1, length(%[1]s)-10), ' ', 'T') || 'Z' WHERE %[1]s LIKE '%% +0000 UTC'`, + column, + ) + res, err := db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to migrate legacy %s timestamps: %w", column, err) + } + if affected, err := res.RowsAffected(); err == nil && affected > 0 { + log.Printf("Migrated %d legacy %s timestamps to RFC3339", affected, column) + } + } + return nil +} + +// chmods the database to 0600: as a tiny security hardening. Runs on every boot so +// databases created 0644 by earlier versions are fixed too. +func restrictDatabasePermissions(dbPath string) { + if dbPath == ":memory:" { + return + } + for _, p := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { + if err := os.Chmod(p, 0o600); err != nil && !os.IsNotExist(err) { + log.Printf("Warning: failed to restrict permissions on %s: %v", p, err) + } + } +} + // Close the database. func Close() error { if db == nil { @@ -265,18 +282,18 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) { } row := db.QueryRowContext(ctx, ` -SELECT language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines, console_output, smtp_insecure_skip_verify, smtp_auth_method, chain, bantime_rndtime, alert_provider, webhook, elasticsearch, threat_intel +SELECT language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines, event_retention_days, console_output, smtp_insecure_skip_verify, smtp_auth_method, chain, bantime_rndtime, bantime_maxtime, bantime_factor, bantime_overalljails, alert_provider, webhook, elasticsearch, threat_intel FROM app_settings WHERE id = 1`) var ( - lang, callback, callbackSecret, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, chain, bantimeRndtime, advancedActions, geoipProvider, geoipDatabasePath, smtpAuthMethod sql.NullString - alertProvider, webhookJSON, elasticsearchJSON, threatIntelJSON sql.NullString - port, smtpPort, maxretry, maxLogLines sql.NullInt64 - debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn, emailAlertsForBans, emailAlertsForUnbans, consoleOutput, smtpInsecureSkipVerify sql.NullInt64 + lang, callback, callbackSecret, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, chain, bantimeRndtime, bantimeMaxtime, bantimeFactor, advancedActions, geoipProvider, geoipDatabasePath, smtpAuthMethod sql.NullString + alertProvider, webhookJSON, elasticsearchJSON, threatIntelJSON sql.NullString + port, smtpPort, maxretry, maxLogLines, eventRetentionDays sql.NullInt64 + debug, restartNeeded, smtpTLS, bantimeInc, bantimeOveralljails, defaultJailEn, emailAlertsForBans, emailAlertsForUnbans, consoleOutput, smtpInsecureSkipVerify sql.NullInt64 ) - err := row.Scan(&lang, &port, &debug, &restartNeeded, &callback, &callbackSecret, &alerts, &emailAlertsForBans, &emailAlertsForUnbans, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &defaultJailEn, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &banaction, &banactionAllports, &advancedActions, &geoipProvider, &geoipDatabasePath, &maxLogLines, &consoleOutput, &smtpInsecureSkipVerify, &smtpAuthMethod, &chain, &bantimeRndtime, &alertProvider, &webhookJSON, &elasticsearchJSON, &threatIntelJSON) + err := row.Scan(&lang, &port, &debug, &restartNeeded, &callback, &callbackSecret, &alerts, &emailAlertsForBans, &emailAlertsForUnbans, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &defaultJailEn, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &banaction, &banactionAllports, &advancedActions, &geoipProvider, &geoipDatabasePath, &maxLogLines, &eventRetentionDays, &consoleOutput, &smtpInsecureSkipVerify, &smtpAuthMethod, &chain, &bantimeRndtime, &bantimeMaxtime, &bantimeFactor, &bantimeOveralljails, &alertProvider, &webhookJSON, &elasticsearchJSON, &threatIntelJSON) if errors.Is(err, sql.ErrNoRows) { return AppSettingsRecord{}, false, nil } @@ -313,10 +330,14 @@ WHERE id = 1`) BanactionAllports: stringFromNull(banactionAllports), Chain: stringFromNull(chain), BantimeRndtime: stringFromNull(bantimeRndtime), + BantimeMaxtime: stringFromNull(bantimeMaxtime), + BantimeFactor: stringFromNull(bantimeFactor), + BantimeOveralljails: intToBool(intFromNull(bantimeOveralljails)), AdvancedActionsJSON: stringFromNull(advancedActions), GeoIPProvider: stringFromNull(geoipProvider), GeoIPDatabasePath: stringFromNull(geoipDatabasePath), MaxLogLines: intFromNull(maxLogLines), + EventRetentionDays: intFromNull(eventRetentionDays), AlertProvider: stringFromNull(alertProvider), WebhookJSON: stringFromNull(webhookJSON), ElasticsearchJSON: stringFromNull(elasticsearchJSON), @@ -333,9 +354,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error { } _, err := db.ExecContext(ctx, ` INSERT INTO app_settings ( - id, language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines, console_output, smtp_insecure_skip_verify, smtp_auth_method, chain, bantime_rndtime, alert_provider, webhook, elasticsearch, threat_intel + id, language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines, event_retention_days, console_output, smtp_insecure_skip_verify, smtp_auth_method, chain, bantime_rndtime, bantime_maxtime, bantime_factor, bantime_overalljails, alert_provider, webhook, elasticsearch, threat_intel ) VALUES ( - 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT(id) DO UPDATE SET language = excluded.language, port = excluded.port, @@ -365,11 +386,15 @@ INSERT INTO app_settings ( geoip_provider = excluded.geoip_provider, geoip_database_path = excluded.geoip_database_path, max_log_lines = excluded.max_log_lines, + event_retention_days = excluded.event_retention_days, console_output = excluded.console_output, smtp_insecure_skip_verify = excluded.smtp_insecure_skip_verify, smtp_auth_method = excluded.smtp_auth_method, chain = excluded.chain, bantime_rndtime = excluded.bantime_rndtime, + bantime_maxtime = excluded.bantime_maxtime, + bantime_factor = excluded.bantime_factor, + bantime_overalljails = excluded.bantime_overalljails, alert_provider = excluded.alert_provider, webhook = excluded.webhook, elasticsearch = excluded.elasticsearch, @@ -402,11 +427,15 @@ INSERT INTO app_settings ( rec.GeoIPProvider, rec.GeoIPDatabasePath, rec.MaxLogLines, + rec.EventRetentionDays, boolToInt(rec.ConsoleOutput), boolToInt(rec.SMTPInsecureSkipVerify), rec.SMTPAuthMethod, rec.Chain, rec.BantimeRndtime, + rec.BantimeMaxtime, + rec.BantimeFactor, + boolToInt(rec.BantimeOveralljails), rec.AlertProvider, rec.WebhookJSON, rec.ElasticsearchJSON, @@ -424,7 +453,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, 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, needs_restart, created_at, updated_at FROM servers ORDER BY created_at`) if err != nil { @@ -439,7 +468,7 @@ ORDER BY created_at`) var name, serverType sql.NullString var created, updated sql.NullString var port sql.NullInt64 - var isDefault, enabled, needsRestart sql.NullInt64 + var isDefault, enabled, reverseTunnel, needsRestart sql.NullInt64 if err := rows.Scan( &rec.ID, @@ -457,6 +486,7 @@ ORDER BY created_at`) &tags, &isDefault, &enabled, + &reverseTunnel, &needsRestart, &created, &updated, @@ -478,6 +508,7 @@ ORDER BY created_at`) rec.TagsJSON = stringFromNull(tags) rec.IsDefault = intToBool(intFromNull(isDefault)) rec.Enabled = intToBool(intFromNull(enabled)) + rec.ReverseTunnelEnabled = intToBool(intFromNull(reverseTunnel)) rec.NeedsRestart = intToBool(intFromNull(needsRestart)) if created.Valid { @@ -518,9 +549,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, 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, needs_restart, created_at, updated_at ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )`) if err != nil { return err @@ -552,6 +583,7 @@ INSERT INTO servers ( srv.TagsJSON, boolToInt(srv.IsDefault), boolToInt(srv.Enabled), + boolToInt(srv.ReverseTunnelEnabled), boolToInt(srv.NeedsRestart), createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano), @@ -626,69 +658,6 @@ INSERT INTO ban_events ( return nil } -// Returns ban events ordered by creation date descending. -func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) { - if db == nil { - return nil, errors.New("storage not initialised") - } - - if limit <= 0 || limit > 500 { - limit = 100 - } - - baseQuery := ` -SELECT id, server_id, server_name, jail, ip, country, hostname, failures, whois, logs, event_type, occurred_at, created_at -FROM ban_events -WHERE 1=1` - - args := []any{} - if serverID != "" { - baseQuery += " AND server_id = ?" - args = append(args, serverID) - } - addOccurredAtSinceFilter(&baseQuery, &args, since) - - baseQuery += " ORDER BY occurred_at DESC LIMIT ?" - args = append(args, limit) - - rows, err := db.QueryContext(ctx, baseQuery, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var results []BanEventRecord - for rows.Next() { - var rec BanEventRecord - var eventType sql.NullString - if err := rows.Scan( - &rec.ID, - &rec.ServerID, - &rec.ServerName, - &rec.Jail, - &rec.IP, - &rec.Country, - &rec.Hostname, - &rec.Failures, - &rec.Whois, - &rec.Logs, - &eventType, - &rec.OccurredAt, - &rec.CreatedAt, - ); err != nil { - return nil, err - } - if eventType.Valid { - rec.EventType = eventType.String - } else { - rec.EventType = "ban" - } - results = append(results, rec) - } - - return results, rows.Err() -} - const ( // MaxBanEventsLimit is the maximum number of events per API request (pagination page size). MaxBanEventsLimit = 50 @@ -927,54 +896,40 @@ WHERE 1=1` return total, nil } -// CountRecentBanEventsByJail returns ban events for one server and jail since the provided timestamp. -func CountRecentBanEventsByJail(ctx context.Context, serverID, jail string, since time.Time) (int, error) { +// 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 { - return 0, errors.New("storage not initialised") + return nil, errors.New("storage not initialised") } if serverID == "" { - return 0, errors.New("server id is required") - } - if jail == "" { - return 0, errors.New("jail is required") + return nil, errors.New("server id is required") } query := ` -SELECT occurred_at +SELECT jail, COUNT(*) FROM ban_events WHERE server_id = ? - AND jail = ? AND (event_type = 'ban' OR event_type IS NULL)` - rows, err := db.QueryContext(ctx, query, serverID, jail) + args := []any{serverID} + addOccurredAtSinceFilter(&query, &args, since) + query += " GROUP BY jail" + + rows, err := db.QueryContext(ctx, query, args...) if err != nil { - return 0, err + return nil, err } defer rows.Close() - since = since.UTC() - var total int + counts := make(map[string]int) for rows.Next() { - var rawOccurredAt string - if err := rows.Scan(&rawOccurredAt); err != nil { - return 0, err - } - if since.IsZero() { - total++ - continue - } - occurredAt, err := parseStorageTime(rawOccurredAt) - if err != nil { - log.Printf("WARNING: skipping ban event with unparsable occurred_at %q for server %s jail %s: %v", rawOccurredAt, serverID, jail, err) - continue - } - if !occurredAt.Before(since) { - total++ + var jail string + var count int + if err := rows.Scan(&jail, &count); err != nil { + return nil, err } + counts[jail] = count } - if err := rows.Err(); err != nil { - return 0, err - } - return total, nil + return counts, rows.Err() } // Returns total number of ban events for a specific IP and optional server. @@ -1010,9 +965,13 @@ func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID stri return nil, errors.New("storage not initialised") } + from := "FROM ban_events" + if !since.IsZero() { + from = "FROM ban_events INDEXED BY idx_ban_events_occurred_at_ip" + } query := ` SELECT COALESCE(country, '') AS country, COUNT(*) -FROM ban_events +` + from + ` WHERE 1=1` args := []any{} @@ -1056,6 +1015,27 @@ func ClearBanEvents(ctx context.Context) (int64, error) { return res.RowsAffected() } +// Deletes ban event records older than the cutoff (event retention) +func PruneBanEventsBefore(ctx context.Context, cutoff time.Time) (int64, error) { + if db == nil { + return 0, errors.New("storage not initialised") + } + res, err := db.ExecContext(ctx, `DELETE FROM ban_events WHERE occurred_at < ?`, formatStorageTime(cutoff)) + if err != nil { + return 0, err + } + deleted, err := res.RowsAffected() + if err != nil { + return 0, err + } + if deleted > 0 { + if _, err := db.ExecContext(ctx, `PRAGMA wal_checkpoint(TRUNCATE)`); err != nil { + log.Printf("Warning: wal_checkpoint after ban-event prune failed: %v", err) + } + } + return deleted, nil +} + // ========================================================================= // Recurring IP Statistics // ========================================================================= @@ -1073,9 +1053,13 @@ func ListRecurringIPStats(ctx context.Context, since time.Time, minCount, limit limit = 100 } + from := "FROM ban_events" + if !since.IsZero() { + from = "FROM ban_events INDEXED BY idx_ban_events_occurred_at_ip" + } query := ` SELECT ip, COALESCE(country, '') AS country, COUNT(*) AS cnt, MAX(occurred_at) AS last_seen -FROM ban_events +` + from + ` WHERE ip != '' AND (event_type = 'ban' OR event_type IS NULL)` args := []any{} @@ -1188,6 +1172,7 @@ CREATE TABLE IF NOT EXISTS app_settings ( geoip_provider TEXT, geoip_database_path TEXT, max_log_lines INTEGER, + event_retention_days INTEGER DEFAULT 180, -- Console output settings console_output INTEGER DEFAULT 0 ); @@ -1208,6 +1193,7 @@ CREATE TABLE IF NOT EXISTS servers ( tags TEXT, is_default INTEGER, enabled INTEGER, + reverse_tunnel INTEGER DEFAULT 0, needs_restart INTEGER DEFAULT 0, created_at TEXT, updated_at TEXT @@ -1233,6 +1219,7 @@ CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id); CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at); CREATE INDEX IF NOT EXISTS idx_ban_events_ip ON ban_events(ip); CREATE INDEX IF NOT EXISTS idx_ban_events_server_jail_occurred_at ON ban_events(server_id, jail, occurred_at); +CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at_ip ON ban_events(occurred_at, ip, country, event_type, server_id); CREATE TABLE IF NOT EXISTS permanent_blocks ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1278,6 +1265,21 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status); return err } } + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN bantime_maxtime TEXT DEFAULT ''`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN bantime_factor TEXT DEFAULT ''`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN bantime_overalljails INTEGER DEFAULT 0`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN alert_provider TEXT DEFAULT 'email'`); err != nil { if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { return err @@ -1303,6 +1305,16 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status); return err } } + if _, err := db.ExecContext(ctx, `ALTER TABLE servers ADD COLUMN reverse_tunnel INTEGER DEFAULT 0`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN event_retention_days INTEGER DEFAULT 180`); err != nil { + if !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } return nil } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index f3daae5..9f05fc7 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -1,6 +1,6 @@ // Fail2ban UI - A Swiss made, management interface for Fail2ban. // -// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // // Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. @@ -18,12 +18,28 @@ package storage import ( "context" + "os" "path/filepath" "sync" "testing" "time" ) +func TestRestrictDatabasePermissions(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "test.db") + if err := os.WriteFile(dbPath, nil, 0o644); err != nil { + t.Fatal(err) + } + restrictDatabasePermissions(dbPath) + info, err := os.Stat(dbPath) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("db perm = %o, want 600", perm) + } +} + func initTestStorage(t *testing.T) { t.Helper() @@ -49,6 +65,42 @@ func initTestStorage(t *testing.T) { }) } +func TestReplaceServersRoundTrip(t *testing.T) { + initTestStorage(t) + + ctx := context.Background() + want := ServerRecord{ + ID: "srv-1", + Name: "Remote via SSH", + Type: "ssh", + Host: "203.0.113.10", + Port: 22, + SSHUser: "fail2ban", + SSHKeyPath: "/config/.ssh/id_ed25519", + TagsJSON: `["prod"]`, + IsDefault: true, + Enabled: true, + ReverseTunnelEnabled: true, + CreatedAt: time.Date(2026, 5, 27, 14, 30, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 5, 27, 15, 0, 0, 0, time.UTC), + } + + if err := ReplaceServers(ctx, []ServerRecord{want}); err != nil { + t.Fatalf("ReplaceServers: %v", err) + } + records, err := ListServers(ctx) + if err != nil { + t.Fatalf("ListServers: %v", err) + } + if len(records) != 1 { + t.Fatalf("len(records)=%d want 1", len(records)) + } + got := records[0] + if got != want { + t.Fatalf("round trip mismatch:\n got %+v\nwant %+v", got, want) + } +} + func TestCountRecentBanEventsByJail(t *testing.T) { initTestStorage(t) @@ -69,12 +121,12 @@ func TestCountRecentBanEventsByJail(t *testing.T) { } } - got, err := CountRecentBanEventsByJail(ctx, "srv-1", "sshd", since) + got, err := CountRecentBanEventsByJail(ctx, "srv-1", since) if err != nil { t.Fatalf("CountRecentBanEventsByJail: %v", err) } - if got != 1 { - t.Fatalf("recent ban count=%d want 1", got) + if got["sshd"] != 1 { + t.Fatalf("recent ban count=%d want 1", got["sshd"]) } } @@ -103,12 +155,12 @@ func TestRecordBanEventUsesSortableStorageTime(t *testing.T) { t.Fatalf("occurred_at=%q want %q", rawOccurredAt, want) } - got, err := CountRecentBanEventsByJail(ctx, "srv-1", "sshd", occurredAt.Add(-time.Minute)) + got, err := CountRecentBanEventsByJail(ctx, "srv-1", occurredAt.Add(-time.Minute)) if err != nil { t.Fatalf("CountRecentBanEventsByJail: %v", err) } - if got != 1 { - t.Fatalf("recent ban count=%d want 1", got) + if got["sshd"] != 1 { + t.Fatalf("recent ban count=%d want 1", got["sshd"]) } } @@ -188,11 +240,10 @@ func TestListBanEventsFilteredOmitsHeavyFields(t *testing.T) { } } -func TestCountRecentBanEventsByJailSupportsLegacyTimestampText(t *testing.T) { +func TestMigrateLegacyTimestampsNormalizesAndFilters(t *testing.T) { initTestStorage(t) ctx := context.Background() - since := time.Date(2026, 5, 27, 13, 30, 0, 0, time.UTC) _, err := db.ExecContext(ctx, ` INSERT INTO ban_events (server_id, server_name, jail, ip, event_type, occurred_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, @@ -208,11 +259,33 @@ VALUES (?, ?, ?, ?, ?, ?, ?)`, t.Fatalf("insert legacy event: %v", err) } - got, err := CountRecentBanEventsByJail(ctx, "srv-1", "sshd", since) + if err := migrateLegacyTimestamps(ctx); err != nil { + t.Fatalf("migrateLegacyTimestamps: %v", err) + } + + var occurredAt, createdAt string + if err := db.QueryRowContext(ctx, `SELECT occurred_at, created_at FROM ban_events WHERE server_id = 'srv-1'`).Scan(&occurredAt, &createdAt); err != nil { + t.Fatalf("select migrated row: %v", err) + } + want := "2026-05-27T14:27:38.369492374Z" + if occurredAt != want || createdAt != want { + t.Fatalf("migrated timestamps = %q / %q, want %q", occurredAt, createdAt, want) + } + + // The migrated row must now be found by the plain since filter. + since := time.Date(2026, 5, 27, 13, 30, 0, 0, time.UTC) + got, err := CountRecentBanEventsByJail(ctx, "srv-1", since) + if err != nil { + t.Fatalf("CountRecentBanEventsByJail: %v", err) + } + if got["sshd"] != 1 { + t.Fatalf("recent ban count=%d want 1", got["sshd"]) + } + got, err = CountRecentBanEventsByJail(ctx, "srv-1", since.Add(2*time.Hour)) if err != nil { t.Fatalf("CountRecentBanEventsByJail: %v", err) } - if got != 1 { - t.Fatalf("recent ban count=%d want 1", got) + if got["sshd"] != 0 { + t.Fatalf("recent ban count=%d want 0 for later since", got["sshd"]) } } diff --git a/internal/version/version.go b/internal/version/version.go index 731dbb2..770eb8e 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,18 +1,20 @@ // Fail2ban UI - A Swiss made, management interface for Fail2ban. // -// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // -// Licensed under the PolyForm Shield License 1.0.0. +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// https://polyformproject.org/licenses/shield/1.0.0/ +// https://www.gnu.org/licenses/gpl-3.0.en.html // -// or in the LICENSE file in this repository. -// -// Required Notice: Copyright Swissmakers GmbH (https://swissmakers.ch) +// 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 version -// Current application version. -const Version = "1.5.0" +// Application version +const Version = "1.5.1" diff --git a/pkg/web/auth.go b/pkg/web/auth.go index 7abc2a8..e6b8416 100644 --- a/pkg/web/auth.go +++ b/pkg/web/auth.go @@ -53,6 +53,8 @@ func AuthMiddleware() gin.HandlerFunc { c.Set("userEmail", session.Email) c.Set("userName", session.Name) c.Set("username", session.Username) + c.Set("roles", session.Roles) + c.Set("accessLevel", session.AccessLevel) c.Next() } diff --git a/pkg/web/authorization.go b/pkg/web/authorization.go new file mode 100644 index 0000000..dad8ef2 --- /dev/null +++ b/pkg/web/authorization.go @@ -0,0 +1,57 @@ +package web + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/swissmakers/fail2ban-ui/internal/auth" +) + +const ( + PermissionRead = "read" + PermissionBan = "ban" + PermissionAdmin = "admin" +) + +func RequirePermission(permission string) gin.HandlerFunc { + return func(c *gin.Context) { + if !auth.IsEnabled() || !auth.AuthorizationEnabled() { + c.Next() + return + } + + sessionValue, exists := c.Get("session") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + c.Abort() + return + } + + session, ok := sessionValue.(*auth.Session) + if !ok || session == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + c.Abort() + return + } + + if !auth.SessionHasPermission(session, permission) { + c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) + c.Abort() + return + } + + c.Next() + } +} + +func userHasAdminAccess(c *gin.Context) bool { + if !auth.IsEnabled() || !auth.AuthorizationEnabled() { + return true + } + sessionValue, exists := c.Get("session") + if !exists { + return false + } + session, ok := sessionValue.(*auth.Session) + return ok && auth.SessionHasPermission(session, PermissionAdmin) +} diff --git a/pkg/web/basepath.go b/pkg/web/basepath.go index 63a48bf..fe380f2 100644 --- a/pkg/web/basepath.go +++ b/pkg/web/basepath.go @@ -2,15 +2,17 @@ // // Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // -// Licensed under the PolyForm Shield License 1.0.0. +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// https://polyformproject.org/licenses/shield/1.0.0/ +// https://www.gnu.org/licenses/gpl-3.0.en.html // -// or in the LICENSE file in this repository. -// -// Required Notice: Copyright Swissmakers GmbH (https://swissmakers.ch) +// 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 diff --git a/pkg/web/basepath_test.go b/pkg/web/basepath_test.go index e92a82e..06d87e9 100644 --- a/pkg/web/basepath_test.go +++ b/pkg/web/basepath_test.go @@ -2,15 +2,17 @@ // // Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // -// Licensed under the PolyForm Shield License 1.0.0. +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// https://polyformproject.org/licenses/shield/1.0.0/ +// https://www.gnu.org/licenses/gpl-3.0.en.html // -// or in the LICENSE file in this repository. -// -// Required Notice: Copyright Swissmakers GmbH (https://swissmakers.ch) +// 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 diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index a24d8aa..7d28e53 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -2,15 +2,17 @@ // // Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) // -// Licensed under the PolyForm Shield License 1.0.0. +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) // You may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// https://polyformproject.org/licenses/shield/1.0.0/ +// https://www.gnu.org/licenses/gpl-3.0.en.html // -// or in the LICENSE file in this repository. -// -// Required Notice: Copyright Swissmakers GmbH (https://swissmakers.ch) +// 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 @@ -35,6 +37,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "sort" "strconv" "strings" @@ -49,6 +52,7 @@ import ( "github.com/swissmakers/fail2ban-ui/internal/enrichment" "github.com/swissmakers/fail2ban-ui/internal/fail2ban" "github.com/swissmakers/fail2ban-ui/internal/integrations" + "github.com/swissmakers/fail2ban-ui/internal/shared" "github.com/swissmakers/fail2ban-ui/internal/storage" "github.com/swissmakers/fail2ban-ui/internal/version" ) @@ -65,6 +69,7 @@ func SetWebSocketHub(hub *Hub) { } type SummaryResponse struct { + ServerID string `json:"serverId"` Jails []fail2ban.JailInfo `json:"jails"` JailLocalWarning bool `json:"jailLocalWarning,omitempty"` } @@ -96,132 +101,6 @@ type settingsUpdateResponse struct { Warnings []string `json:"warnings,omitempty"` } -type settingsPatchRequest struct { - Language *string `json:"language"` - Port *int `json:"port"` - Debug *bool `json:"debug"` - AlertCountries *[]string `json:"alertCountries"` - SMTP *config.SMTPSettings `json:"smtp"` - CallbackURL *string `json:"callbackUrl"` - CallbackSecret *string `json:"callbackSecret"` - AdvancedActions *config.AdvancedActionsConfig `json:"advancedActions"` - Servers *[]config.Fail2banServer `json:"servers"` - BantimeIncrement *bool `json:"bantimeIncrement"` - DefaultJailEnable *bool `json:"defaultJailEnable"` - IgnoreIPs *[]string `json:"ignoreips"` - Bantime *string `json:"bantime"` - Findtime *string `json:"findtime"` - Maxretry *int `json:"maxretry"` - Destemail *string `json:"destemail"` - Banaction *string `json:"banaction"` - BanactionAllports *string `json:"banactionAllports"` - Chain *string `json:"chain"` - BantimeRndtime *string `json:"bantimeRndtime"` - GeoIPProvider *string `json:"geoipProvider"` - GeoIPDatabasePath *string `json:"geoipDatabasePath"` - MaxLogLines *int `json:"maxLogLines"` - EmailAlertsForBans *bool `json:"emailAlertsForBans"` - EmailAlertsForUnbans *bool `json:"emailAlertsForUnbans"` - AlertProvider *string `json:"alertProvider"` - Webhook *config.WebhookSettings `json:"webhook"` - Elasticsearch *config.ElasticsearchSettings `json:"elasticsearch"` - ThreatIntel *config.ThreatIntelSettings `json:"threatIntel"` - ConsoleOutput *bool `json:"consoleOutput"` -} - -func (p settingsPatchRequest) applyTo(base *config.AppSettings) { - if p.Language != nil { - base.Language = *p.Language - } - if p.Port != nil { - base.Port = *p.Port - } - if p.Debug != nil { - base.Debug = *p.Debug - } - if p.AlertCountries != nil { - base.AlertCountries = *p.AlertCountries - } - if p.SMTP != nil { - base.SMTP = *p.SMTP - } - if p.CallbackURL != nil { - base.CallbackURL = *p.CallbackURL - } - if p.CallbackSecret != nil { - base.CallbackSecret = *p.CallbackSecret - } - if p.AdvancedActions != nil { - base.AdvancedActions = *p.AdvancedActions - } - if p.Servers != nil { - base.Servers = *p.Servers - } - if p.BantimeIncrement != nil { - base.BantimeIncrement = *p.BantimeIncrement - } - if p.DefaultJailEnable != nil { - base.DefaultJailEnable = *p.DefaultJailEnable - } - if p.IgnoreIPs != nil { - base.IgnoreIPs = *p.IgnoreIPs - } - if p.Bantime != nil { - base.Bantime = *p.Bantime - } - if p.Findtime != nil { - base.Findtime = *p.Findtime - } - if p.Maxretry != nil { - base.Maxretry = *p.Maxretry - } - if p.Destemail != nil { - base.Destemail = *p.Destemail - } - if p.Banaction != nil { - base.Banaction = *p.Banaction - } - if p.BanactionAllports != nil { - base.BanactionAllports = *p.BanactionAllports - } - if p.Chain != nil { - base.Chain = *p.Chain - } - if p.BantimeRndtime != nil { - base.BantimeRndtime = *p.BantimeRndtime - } - if p.GeoIPProvider != nil { - base.GeoIPProvider = *p.GeoIPProvider - } - if p.GeoIPDatabasePath != nil { - base.GeoIPDatabasePath = *p.GeoIPDatabasePath - } - if p.MaxLogLines != nil { - base.MaxLogLines = *p.MaxLogLines - } - if p.EmailAlertsForBans != nil { - base.EmailAlertsForBans = *p.EmailAlertsForBans - } - if p.EmailAlertsForUnbans != nil { - base.EmailAlertsForUnbans = *p.EmailAlertsForUnbans - } - if p.AlertProvider != nil { - base.AlertProvider = *p.AlertProvider - } - if p.Webhook != nil { - base.Webhook = *p.Webhook - } - if p.Elasticsearch != nil { - base.Elasticsearch = *p.Elasticsearch - } - if p.ThreatIntel != nil { - base.ThreatIntel = *p.ThreatIntel - } - if p.ConsoleOutput != nil { - base.ConsoleOutput = *p.ConsoleOutput - } -} - type threatIntelCacheEntry struct { Body []byte CachedAt time.Time @@ -252,6 +131,27 @@ var ( threatIntelRetry = make(map[string]time.Time) ) +const maxThreatIntelEntries = 1000 + +func pruneThreatIntelCachesLocked(now time.Time) { + for k, entry := range threatIntelCache { + if now.After(entry.ExpiresAt) { + delete(threatIntelCache, k) + } + } + for k, retryUntil := range threatIntelRetry { + if now.After(retryUntil) { + delete(threatIntelRetry, k) + } + } + if len(threatIntelCache) > maxThreatIntelEntries { + threatIntelCache = make(map[string]threatIntelCacheEntry) + } + if len(threatIntelRetry) > maxThreatIntelEntries { + threatIntelRetry = make(map[string]time.Time) + } +} + // ========================================================================= // Request Helpers // ========================================================================= @@ -330,19 +230,19 @@ func SummaryHandler(c *gin.Context) { } 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 { - count, countErr := storage.CountRecentBanEventsByJail(c.Request.Context(), serverID, jailInfos[i].JailName, since) - if countErr != nil { - config.DebugLog("Warning: failed to count recent bans for server %s jail %s: %v", serverID, jailInfos[i].JailName, countErr) - } else { - jailInfos[i].NewInLastHour = count - } + 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{ - Jails: jailInfos, + ServerID: serverID, + Jails: jailInfos, } // Checks the jail.local integrity on every summary request to warn the user if not managed by Fail2ban-UI. @@ -362,6 +262,82 @@ func SummaryHandler(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// Searches all servers and jails for a live ban of the given IP via +// fail2ban-client, unlike the dashboard which only searches stored ban events. +func SearchBannedIPHandler(c *gin.Context) { + ip := c.Param("ip") + if err := integrations.ValidateIP(ip); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP: " + err.Error()}) + return + } + + type jailMatch struct { + ServerID string `json:"serverId"` + ServerName string `json:"serverName"` + Jail string `json:"jail"` + } + type serverError struct { + ServerID string `json:"serverId"` + ServerName string `json:"serverName"` + Error string `json:"error"` + } + + var ( + mu sync.Mutex + matches []jailMatch + errs []serverError + wg sync.WaitGroup + ) + + for _, conn := range fail2ban.GetManager().Connectors() { + wg.Add(1) + go func(conn fail2ban.Connector) { + defer wg.Done() + server := conn.Server() + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + infos, err := conn.GetJailInfos(ctx) + if err != nil { + mu.Lock() + errs = append(errs, serverError{ServerID: server.ID, ServerName: server.Name, Error: err.Error()}) + mu.Unlock() + return + } + for _, info := range infos { + if info.TotalBanned == 0 { + continue + } + banned, err := conn.GetBannedIPs(ctx, info.JailName) + if err != nil { + continue + } + if slices.Contains(banned, ip) { + mu.Lock() + matches = append(matches, jailMatch{ServerID: server.ID, ServerName: server.Name, Jail: info.JailName}) + mu.Unlock() + } + } + }(conn) + } + wg.Wait() + + sort.Slice(matches, func(i, j int) bool { + if matches[i].ServerName != matches[j].ServerName { + return matches[i].ServerName < matches[j].ServerName + } + return matches[i].Jail < matches[j].Jail + }) + + c.JSON(http.StatusOK, gin.H{ + "ip": ip, + "banned": len(matches) > 0, + "matches": matches, + "errors": errs, + }) +} + // Returns paginated banned IPs for a specific jail on the selected server. func ListJailBannedIPsHandler(c *gin.Context) { jail := c.Param("jail") @@ -738,49 +714,69 @@ func BanInsightsHandler(c *gin.Context) { } ctx := c.Request.Context() + now := time.Now().UTC() - countriesMap, err := storage.CountBanEventsByCountry(ctx, since, serverID) - if err != nil { + // The five queries are independent; run them concurrently. + var ( + countriesMap map[string]int64 + recurring []storage.RecurringIPStat + totalOverall, totalToday int64 + totalWeek int64 + countriesErr error + recurringErr error + overallErr error + todayErr error + weekErr error + wg sync.WaitGroup + ) + wg.Add(5) + go func() { + defer wg.Done() + countriesMap, countriesErr = storage.CountBanEventsByCountry(ctx, since, serverID) + }() + go func() { + defer wg.Done() + recurring, recurringErr = storage.ListRecurringIPStats(ctx, since, minCount, limit, serverID) + }() + go func() { + defer wg.Done() + totalOverall, overallErr = storage.CountBanEvents(ctx, time.Time{}, serverID) + }() + go func() { + defer wg.Done() + totalToday, todayErr = storage.CountBanEvents(ctx, now.Add(-24*time.Hour), serverID) + }() + go func() { + defer wg.Done() + totalWeek, weekErr = storage.CountBanEvents(ctx, now.Add(-7*24*time.Hour), serverID) + }() + wg.Wait() + + if countriesErr != nil { settings := config.GetSettings() - errorMsg := err.Error() + errorMsg := countriesErr.Error() if settings.Debug { - config.DebugLog("BanInsightsHandler: CountBanEventsByCountry error: %v", err) - errorMsg = fmt.Sprintf("CountBanEventsByCountry failed: %v", err) + config.DebugLog("BanInsightsHandler: CountBanEventsByCountry error: %v", countriesErr) + errorMsg = fmt.Sprintf("CountBanEventsByCountry failed: %v", countriesErr) } c.JSON(http.StatusInternalServerError, gin.H{"error": errorMsg}) return } - - recurring, err := storage.ListRecurringIPStats(ctx, since, minCount, limit, serverID) - if err != nil { + if recurringErr != nil { settings := config.GetSettings() - errorMsg := err.Error() + errorMsg := recurringErr.Error() if settings.Debug { - config.DebugLog("BanInsightsHandler: ListRecurringIPStats error: %v", err) - errorMsg = fmt.Sprintf("ListRecurringIPStats failed: %v", err) + config.DebugLog("BanInsightsHandler: ListRecurringIPStats error: %v", recurringErr) + errorMsg = fmt.Sprintf("ListRecurringIPStats failed: %v", recurringErr) } c.JSON(http.StatusInternalServerError, gin.H{"error": errorMsg}) return } - - totalOverall, err := storage.CountBanEvents(ctx, time.Time{}, serverID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - now := time.Now().UTC() - - totalToday, err := storage.CountBanEvents(ctx, now.Add(-24*time.Hour), serverID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - totalWeek, err := storage.CountBanEvents(ctx, now.Add(-7*24*time.Hour), serverID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return + for _, err := range []error{overallErr, todayErr, weekErr} { + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } } type countryStat struct { @@ -913,7 +909,7 @@ func ThreatIntelHandler(c *gin.Context) { req.Header.Set("Key", strings.TrimSpace(settings.ThreatIntel.AbuseIPDBAPIKey)) } - client := &http.Client{Timeout: 12 * time.Second} + client := newOutboundHTTPClient(12 * time.Second) resp, err := client.Do(req) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "failed to query threat-intel provider"}) @@ -921,7 +917,7 @@ func ThreatIntelHandler(c *gin.Context) { } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := readLimitedBody(resp.Body) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "failed to read threat-intel provider response"}) return @@ -947,6 +943,7 @@ func ThreatIntelHandler(c *gin.Context) { if resp.StatusCode == http.StatusOK { threatIntelMu.Lock() + pruneThreatIntelCachesLocked(now) threatIntelCache[cacheKey] = threatIntelCacheEntry{ Body: append([]byte(nil), responseBody...), CachedAt: now, @@ -959,6 +956,7 @@ func ThreatIntelHandler(c *gin.Context) { if resp.StatusCode == http.StatusTooManyRequests { retryUntil = now.Add(parseRetryAfter(resp.Header.Get("Retry-After"), 2*time.Minute)) threatIntelMu.Lock() + pruneThreatIntelCachesLocked(now) threatIntelRetry[cacheKey] = retryUntil cached, hasCached = threatIntelCache[cacheKey] threatIntelMu.Unlock() @@ -1013,7 +1011,11 @@ func parseRetryAfter(value string, fallback time.Duration) time.Duration { // Returns all configured Fail2ban servers. func ListServersHandler(c *gin.Context) { servers := config.ListServers() - c.JSON(http.StatusOK, gin.H{"servers": servers}) + masked := maskServerSecrets(servers) + if !userHasAdminAccess(c) { + masked = stripServerConnectionDetails(masked) + } + c.JSON(http.StatusOK, gin.H{"servers": masked}) } // Creates or updates a Fail2ban server configuration. @@ -1024,6 +1026,10 @@ func UpsertServerHandler(c *gin.Context) { return } + if existing, ok := config.GetServerByID(req.ID); ok { + req.AgentSecret = restoreSecret(req.AgentSecret, existing.AgentSecret) + } + switch strings.ToLower(req.Type) { case "", "local": req.Type = "local" @@ -1132,7 +1138,7 @@ func UpsertServerHandler(c *gin.Context) { } } - resp := gin.H{"server": server} + resp := gin.H{"server": maskServer(server)} if jailLocalWarning { resp["jailLocalWarning"] = true } @@ -1179,7 +1185,7 @@ func SetDefaultServerHandler(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"server": server}) + c.JSON(http.StatusOK, gin.H{"server": maskServer(server)}) } // Returns available SSH private keys from the host or container. @@ -1311,15 +1317,10 @@ func waitForConnectorReady(ctx context.Context, conn fail2ban.Connector, attempt // Notification Processing (Internal) // ========================================================================= -// Records a ban event, broadcasts it via WebSocket, -// evaluates advanced actions, and sends an email alert if enabled. -func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, failures, whois, logs string) error { - // Loads the settings to get alert countries and GeoIP provider from the database - settings := config.GetSettings() - var whoisData string - var err error - if whois == "" { +func resolveWhoisAndCountry(ip, providedWhois, providedCountry string, settings config.AppSettings) (whoisData, country string) { + if providedWhois == "" { log.Printf("Performing whois lookup for IP %s", ip) + var err error whoisData, err = lookupWhois(ip) if err != nil { log.Printf("⚠️ Whois lookup failed for IP %s: %v", ip, err) @@ -1327,27 +1328,30 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip } } else { log.Printf("Using provided whois data for IP %s", ip) - whoisData = whois + whoisData = providedWhois } - // Filters the logs for the email alert to show relevant lines - filteredLogs := filterRelevantLogs(logs, ip, settings.MaxLogLines) - - // Looks up the country for the given IP using the configured GeoIP provider - country, err := lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath) - if err != nil { - log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err) - if whoisData != "" { - country = extractCountryFromWhois(whoisData) - if country != "" { - log.Printf("Extracted country %s from whois data for IP %s", country, ip) + country = providedCountry + if country == "" { + var err error + country, err = lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath) + if err != nil { + log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err) + if whoisData != "" { + country = extractCountryFromWhois(whoisData) + if country != "" { + log.Printf("Extracted country %s from whois data for IP %s", country, ip) + } } } - if country == "" { - country = "" - } } + return whoisData, country +} +func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, failures, whois, logs string) error { + settings := config.GetSettings() + whoisData, country := resolveWhoisAndCountry(ip, whois, "", settings) + filteredLogs := filterRelevantLogs(logs, ip, settings.MaxLogLines) event := storage.BanEventRecord{ ServerID: server.ID, ServerName: server.Name, @@ -1395,35 +1399,7 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, whois, country string) error { // Loads the settings to get alert countries and GeoIP provider from the database settings := config.GetSettings() - var whoisData string - var err error - if whois == "" { - log.Printf("Performing whois lookup for IP %s", ip) - whoisData, err = lookupWhois(ip) - if err != nil { - log.Printf("⚠️ Whois lookup failed for IP %s: %v", ip, err) - whoisData = "" - } - } else { - log.Printf("Using provided whois data for IP %s", ip) - whoisData = whois - } - if country == "" { - country, err = lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath) - if err != nil { - log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err) - if whoisData != "" { - country = extractCountryFromWhois(whoisData) - if country != "" { - log.Printf("Extracted country %s from whois data for IP %s", country, ip) - } - } - if country == "" { - country = "" - } - } - } - + whoisData, country := resolveWhoisAndCountry(ip, whois, country, settings) event := storage.BanEventRecord{ ServerID: server.ID, ServerName: server.Name, @@ -1534,7 +1510,7 @@ func sendWebhookAlert(alertType, ip, jail, hostname, failures, whois, logs, coun req.Header.Set(k, v) } - client := &http.Client{Timeout: 15 * time.Second} + client := newOutboundHTTPClient(15 * time.Second) if cfg.SkipTLSVerify { client.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, @@ -1548,7 +1524,7 @@ func sendWebhookAlert(alertType, ip, jail, hostname, failures, whois, logs, coun defer resp.Body.Close() if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) + body, _ := readLimitedBody(resp.Body) return fmt.Errorf("webhook returned status %d: %s", resp.StatusCode, string(body)) } @@ -1617,7 +1593,7 @@ func sendElasticsearchAlert(alertType, ip, jail, hostname, failures, whois, logs req.SetBasicAuth(cfg.Username, cfg.Password) } - client := &http.Client{Timeout: 15 * time.Second} + client := newOutboundHTTPClient(15 * time.Second) if cfg.SkipTLSVerify { client.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, @@ -1631,7 +1607,7 @@ func sendElasticsearchAlert(alertType, ip, jail, hostname, failures, whois, logs defer resp.Body.Close() if resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) + body, _ := readLimitedBody(resp.Body) return fmt.Errorf("elasticsearch returned status %d: %s", resp.StatusCode, string(body)) } @@ -1695,6 +1671,30 @@ func lookupCountry(ip, provider, dbPath string) (string, error) { } } +var ( + geoIPMu sync.Mutex + geoIPDB *maxminddb.Reader + geoIPPath string +) + +func getGeoIPReader(dbPath string) (*maxminddb.Reader, error) { + geoIPMu.Lock() + defer geoIPMu.Unlock() + if geoIPDB != nil && geoIPPath == dbPath { + return geoIPDB, nil + } + db, err := maxminddb.Open(dbPath) + if err != nil { + return nil, err + } + if geoIPDB != nil { + geoIPDB.Close() + } + geoIPDB = db + geoIPPath = dbPath + return db, nil +} + // Looks up the country ISO code using MaxMind GeoLite2 database. func lookupCountryMaxMind(ip, dbPath string) (string, error) { parsedIP := net.ParseIP(ip) @@ -1702,11 +1702,10 @@ func lookupCountryMaxMind(ip, dbPath string) (string, error) { return "", fmt.Errorf("invalid IP address: %s", ip) } - db, err := maxminddb.Open(dbPath) + db, err := getGeoIPReader(dbPath) if err != nil { return "", fmt.Errorf("failed to open GeoIP database at %s: %w", dbPath, err) } - defer db.Close() var record struct { Country struct { @@ -1813,21 +1812,13 @@ func filterRelevantLogs(logs, ip string, maxLines int) string { index: i, } } - for i := 0; i < len(scored)-1; i++ { - for j := i + 1; j < len(scored); j++ { - if scored[i].score < scored[j].score { - scored[i], scored[j] = scored[j], scored[i] - } - } - } + sort.SliceStable(scored, func(i, j int) bool { + return scored[i].score > scored[j].score + }) selected := scored[:maxLines] - for i := 0; i < len(selected)-1; i++ { - for j := i + 1; j < len(selected); j++ { - if selected[i].index > selected[j].index { - selected[i], selected[j] = selected[j], selected[i] - } - } - } + sort.SliceStable(selected, func(i, j int) bool { + return selected[i].index < selected[j].index + }) result := make([]string, len(selected)) for i, s := range selected { result[i] = s.line @@ -2504,6 +2495,8 @@ func splitLogpaths(raw string) []string { return out } +var jailErrorPattern = regexp.MustCompile(`Errors in jail '([^']+)'`) + // Extracts problematic jail names from Fail2ban reload output. func parseJailErrorsFromReloadOutput(output string) []string { var problematicJails []string @@ -2511,14 +2504,11 @@ func parseJailErrorsFromReloadOutput(output string) []string { for _, line := range lines { if strings.Contains(line, "Errors in jail") && strings.Contains(line, "Skipping") { - re := regexp.MustCompile(`Errors in jail '([^']+)'`) - matches := re.FindStringSubmatch(line) + matches := jailErrorPattern.FindStringSubmatch(line) if len(matches) > 1 { problematicJails = append(problematicJails, matches[1]) } } - // Also checks for filter errors that might indicate jail problems - _ = strings.Contains(line, "Unable to read the filter") } seen := make(map[string]bool) @@ -2784,7 +2774,16 @@ func CreateJailHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' created successfully", req.JailName)}) + // The new jail file is on disk but inactive until fail2ban re-reads its config. + if err := conn.Reload(c.Request.Context()); err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Jail '%s' created, but fail2ban reload reported a problem", req.JailName), + "warning": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' created and applied successfully", req.JailName)}) } // Removes a jail and its config file. @@ -2814,7 +2813,16 @@ func DeleteJailHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' deleted successfully", jailName)}) + // Reload so the removed jail is actually stopped on the daemon. + if err := conn.Reload(c.Request.Context()); err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Jail '%s' deleted, but fail2ban reload reported a problem", jailName), + "warning": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' deleted and applied successfully", jailName)}) } // ========================================================================= @@ -2826,22 +2834,29 @@ func GetSettingsHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("GetSettingsHandler called (handlers.go)") s := config.GetSettings() + isAdmin := userHasAdminAccess(c) + if !isAdmin { + s = config.AppSettings{ + Language: s.Language, + AlertCountries: s.AlertCountries, + } + } envPort, envPortSet := config.GetPortFromEnv() envCallbackURL, envCallbackURLSet := config.GetCallbackURLFromEnv() - response := appSettingsResponse{ - AppSettings: s, - PortFromEnv: envPort, - PortEnvSet: envPortSet, - CallbackUrlEnvSet: envCallbackURLSet, - CallbackUrlFromEnv: envCallbackURL, + response := appSettingsResponse{AppSettings: maskAppSettingsSecrets(s)} + if isAdmin { + response.PortFromEnv = envPort + response.PortEnvSet = envPortSet + response.CallbackUrlEnvSet = envCallbackURLSet + response.CallbackUrlFromEnv = envCallbackURL } - if envPortSet { + if isAdmin && envPortSet { response.Port = envPort } - if envCallbackURLSet { + if isAdmin && envCallbackURLSet { response.CallbackURL = envCallbackURL } @@ -2859,7 +2874,45 @@ func applyEnvLockedSettings(req *config.AppSettings) { } } +var fail2banDurationPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?( ?[0-9]*[smhdwy][a-z]*)*$`) +var fail2banNumberPattern = regexp.MustCompile(`^[0-9]+(\.[0-9]+)?$`) + +func validateFail2banDurationField(name, value string) error { + if value == "" { + return nil + } + if !fail2banDurationPattern.MatchString(value) { + return fmt.Errorf("%s has an invalid time format: %q (examples: 3600, 48h, 5w)", name, value) + } + return nil +} + func normalizeAndValidateSettingsRequest(req *config.AppSettings) error { + req.Bantime = strings.ToLower(strings.TrimSpace(req.Bantime)) + req.Findtime = strings.ToLower(strings.TrimSpace(req.Findtime)) + req.BantimeRndtime = strings.ToLower(strings.TrimSpace(req.BantimeRndtime)) + req.BantimeMaxtime = strings.ToLower(strings.TrimSpace(req.BantimeMaxtime)) + req.BantimeFactor = strings.TrimSpace(req.BantimeFactor) + bantimeMagnitude := strings.TrimPrefix(req.Bantime, "-") + if req.Bantime != "" && bantimeMagnitude == "" { + return fmt.Errorf("bantime has an invalid time format: %q (examples: 3600, 48h, -1)", req.Bantime) + } + if err := validateFail2banDurationField("bantime", bantimeMagnitude); err != nil { + return err + } + for name, value := range map[string]string{ + "findtime": req.Findtime, + "bantime.rndtime": req.BantimeRndtime, + "bantime.maxtime": req.BantimeMaxtime, + } { + if err := validateFail2banDurationField(name, value); err != nil { + return err + } + } + if req.BantimeFactor != "" && !fail2banNumberPattern.MatchString(req.BantimeFactor) { + return fmt.Errorf("bantime.factor must be a number, got %q", req.BantimeFactor) + } + req.AlertProvider = strings.ToLower(strings.TrimSpace(req.AlertProvider)) if req.AlertProvider == "" { req.AlertProvider = "email" @@ -2926,6 +2979,7 @@ func normalizeAndValidateSettingsRequest(req *config.AppSettings) error { func applySettingsUpdate(c *gin.Context, req config.AppSettings) { applyEnvLockedSettings(&req) + restoreMaskedSecrets(&req, config.GetSettings()) if err := normalizeAndValidateSettingsRequest(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -2941,14 +2995,16 @@ func applySettingsUpdate(c *gin.Context, req config.AppSettings) { config.DebugLog("Settings updated successfully (handlers.go)") callbackURLChanged := oldSettings.CallbackURL != newSettings.CallbackURL + callbackSecretChanged := oldSettings.CallbackSecret != newSettings.CallbackSecret + callbackChanged := callbackURLChanged || callbackSecretChanged if err := config.ReloadFail2banManager(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload fail2ban connectors: " + err.Error()}) return } - if callbackURLChanged { - config.DebugLog("Callback URL changed, updating action files and reloading fail2ban on all servers") + if callbackChanged { + config.DebugLog("Callback URL or secret changed, updating action files and reloading fail2ban on all servers") if err := fail2ban.GetManager().UpdateActionFiles(c.Request.Context()); err != nil { config.DebugLog("Warning: failed to update some remote action files: %v", err) @@ -2958,7 +3014,7 @@ func applySettingsUpdate(c *gin.Context, req config.AppSettings) { for _, conn := range connectors { server := conn.Server() if (server.Type == "ssh" || server.Type == "agent") && server.Enabled { - config.DebugLog("Reloading fail2ban on %s after callback URL change", server.Name) + config.DebugLog("Reloading fail2ban on %s after callback change", server.Name) if err := conn.Reload(c.Request.Context()); err != nil { config.DebugLog("Warning: failed to reload fail2ban on %s after updating action file: %v", server.Name, err) } else { @@ -2974,7 +3030,7 @@ func applySettingsUpdate(c *gin.Context, req config.AppSettings) { config.DebugLog("Warning: failed to update local action file: %v", err) } else { if conn, err := fail2ban.GetManager().Connector(server.ID); err == nil { - config.DebugLog("Reloading local fail2ban after callback URL change") + config.DebugLog("Reloading local fail2ban after callback change") if reloadErr := conn.Reload(c.Request.Context()); reloadErr != nil { config.DebugLog("Warning: failed to reload local fail2ban after updating action file: %v", reloadErr) } else { @@ -3033,25 +3089,6 @@ func applySettingsUpdate(c *gin.Context, req config.AppSettings) { }) } -// Partially updates settings while preserving omitted fields. (not just yet implemented in the frontend) -func PatchSettingsHandler(c *gin.Context) { - config.DebugLog("----------------------------") - config.DebugLog("PatchSettingsHandler called (handlers.go)") - - current := config.GetSettings() - req := current - var patch settingsPatchRequest - if err := c.ShouldBindJSON(&patch); err != nil { - c.JSON(http.StatusBadRequest, gin.H{ - "error": "invalid JSON", - "details": err.Error(), - }) - return - } - patch.applyTo(&req) - applySettingsUpdate(c, req) -} - // Saves new settings, pushes defaults to servers, and reloads. func UpdateSettingsHandler(c *gin.Context) { config.DebugLog("----------------------------") @@ -3202,7 +3239,16 @@ func CreateFilterHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' created successfully", req.FilterName)}) + // Reload so a jail referencing this filter can pick it up immediately. + if err := conn.Reload(c.Request.Context()); err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Filter '%s' created, but fail2ban reload reported a problem", req.FilterName), + "warning": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' created and applied successfully", req.FilterName)}) } // Removes a filter definition file. @@ -3232,7 +3278,16 @@ func DeleteFilterHandler(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' deleted successfully", filterName)}) + // Reload so fail2ban notices the removal (and reports if a jail still needs it). + if err := conn.Reload(c.Request.Context()); err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Filter '%s' deleted, but fail2ban reload reported a problem", filterName), + "warning": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' deleted and applied successfully", filterName)}) } // ========================================================================= @@ -3244,24 +3299,10 @@ func RestartFail2banHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("RestartFail2banHandler called (handlers.go)") - serverID := c.Query("serverId") - var conn fail2ban.Connector - var err error - - if serverID != "" { - manager := fail2ban.GetManager() - conn, err = manager.Connector(serverID) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Server not found: " + err.Error()}) - return - } - } else { - // Uses the default connector from the context - conn, err = resolveConnector(c) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } + conn, err := resolveConnector(c) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return } server := conn.Server() @@ -3273,11 +3314,6 @@ func RestartFail2banHandler(c *gin.Context) { return } - // Only calls MarkRestartDone if the service was successfully restarted - //if err := config.MarkRestartDone(server.ID); err != nil { - // c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - // return - //} msg := "Fail2ban service restarted successfully" if mode == "reload" { msg = "Fail2ban configuration reloaded successfully (no systemd service restart)" @@ -3285,7 +3321,7 @@ func RestartFail2banHandler(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": msg, "mode": mode, - "server": server, + "server": maskServer(server), }) } @@ -3373,13 +3409,27 @@ func sanitizeEmailHeader(value string) string { // Connects to the SMTP server and delivers a single HTML message. func sendEmail(to, subject, body string, settings config.AppSettings) error { - // Skips sending if the destination email is still the default placeholder - if strings.EqualFold(strings.TrimSpace(to), "alerts@example.com") { - log.Printf("⚠️ sendEmail skipped: destination email is still the default placeholder (alerts@example.com). Please update the 'Destination Email' in Settings → Alert Settings.") + recipients := shared.SplitCommaList(to) + if len(recipients) == 0 { + log.Printf("⚠️ sendEmail skipped: no recipients provided.") + return nil + } + + // Skips sending if every recipient is still the default placeholder + allPlaceholder := true + for _, r := range recipients { + if !strings.EqualFold(r, "alerts@example.com") { + allPlaceholder = false + break + } + } + if allPlaceholder { + log.Printf("⚠️ sendEmail skipped: all recipients are still the default placeholder (alerts@example.com). Please update the 'Destination Email' in Settings → Alert Settings.") return nil } - if settings.SMTP.Host == "" || settings.SMTP.Username == "" || settings.SMTP.Password == "" || settings.SMTP.From == "" { + needsAuth := settings.SMTP.AuthMethod != "none" + if settings.SMTP.Host == "" || settings.SMTP.From == "" || (needsAuth && (settings.SMTP.Username == "" || settings.SMTP.Password == "")) { err := errors.New("SMTP settings are incomplete. Please configure all required fields") log.Printf("❌ sendEmail validation failed: %v (Host: %q, Username: %q, From: %q)", err, settings.SMTP.Host, settings.SMTP.Username, settings.SMTP.From) return err @@ -3392,7 +3442,8 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error { } fromHeader := sanitizeEmailHeader(settings.SMTP.From) - toHeader := sanitizeEmailHeader(to) + // Build the To header with all recipients, comma-space separated + toHeader := sanitizeEmailHeader(strings.Join(recipients, ", ")) msgID := sanitizeEmailHeader(fmt.Sprintf("<%d.%s@fail2ban-ui>", time.Now().UnixNano(), settings.SMTP.From)) subjectHeader := mime.QEncoding.Encode("UTF-8", subject) message := "From: " + fromHeader + "\r\n" + @@ -3474,23 +3525,25 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error { log.Printf("📧 sendEmail: SMTP authentication successful") } - err = sendSMTPMessage(client, settings.SMTP.From, to, msg) + err = sendSMTPMessage(client, settings.SMTP.From, recipients, msg) if err != nil { log.Printf("❌ sendEmail: Failed to send message: %v", err) return err } - log.Printf("📧 sendEmail: Successfully sent email to %s", to) + log.Printf("📧 sendEmail: Successfully sent email to %s", strings.Join(recipients, ", ")) return nil } // Sends the actual message // Performs the MAIL/RCPT/DATA sequence on an open SMTP connection. -func sendSMTPMessage(client *smtp.Client, from, to string, msg []byte) error { +func sendSMTPMessage(client *smtp.Client, from string, recipients []string, msg []byte) error { if err := client.Mail(from); err != nil { return fmt.Errorf("failed to set sender: %w", err) } - if err := client.Rcpt(to); err != nil { - return fmt.Errorf("failed to set recipient: %w", err) + for _, r := range recipients { + if err := client.Rcpt(r); err != nil { + return fmt.Errorf("failed to set recipient %q: %w", r, err) + } } wc, err := client.Data() if err != nil { @@ -4105,10 +4158,13 @@ func TestEmailHandler(c *gin.Context) { // Returns the SMTP auth mechanism based on authMethod ("auto", "login", "plain", "cram-md5"). func getSMTPAuth(username, password, authMethod, host string) (smtp.Auth, error) { + authMethod = strings.ToLower(strings.TrimSpace(authMethod)) + if authMethod == "none" { + return nil, nil + } if username == "" || password == "" { return nil, nil } - authMethod = strings.ToLower(strings.TrimSpace(authMethod)) if authMethod == "" || authMethod == "auto" { // Auto-detect: prefers LOGIN for Office365/Gmail, falls back to PLAIN (default) authMethod = "login" @@ -4121,7 +4177,7 @@ func getSMTPAuth(username, password, authMethod, host string) (smtp.Auth, error) case "cram-md5": return smtp.CRAMMD5Auth(username, password), nil default: - return nil, fmt.Errorf("unsupported auth method: %s (supported: login, plain, cram-md5)", authMethod) + return nil, fmt.Errorf("unsupported auth method: %s (supported: none, login, plain, cram-md5)", authMethod) } } @@ -4135,7 +4191,9 @@ func LoginAuth(username, password string) smtp.Auth { } func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { - return "LOGIN", []byte(a.username), nil + // No initial response; let the server challenge with "Username:" first. + // Some SMTP servers reject the non-standard initial-response variant of LOGIN. + return "LOGIN", nil, nil } func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { @@ -4156,72 +4214,41 @@ func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { // Auth Handlers // ========================================================================= -// Initiates the OIDC login flow or renders the login page. -func LoginHandler(c *gin.Context) { - oidcClient := auth.GetOIDCClient() - if oidcClient == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC authentication is not configured"}) +func redirectToOIDCProvider(c *gin.Context, oidcClient *auth.OIDCClient) { + stateBytes := make([]byte, 32) + if _, err := rand.Read(stateBytes); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state parameter"}) return } - oidcConfig := auth.GetConfig() - if oidcConfig != nil && oidcConfig.SkipLoginPage { - stateBytes := make([]byte, 32) - if _, err := rand.Read(stateBytes); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state parameter"}) - return - } - state := base64.URLEncoding.EncodeToString(stateBytes) + state := base64.URLEncoding.EncodeToString(stateBytes) + isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" - // Determine if we're using HTTPS (if not, the state cookie is not secure) - isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" + // Stores the state in a session cookie for validation + http.SetCookie(c.Writer, &http.Cookie{ + Name: "oidc_state", + Value: state, + Path: CookiePath(), + MaxAge: 600, + HttpOnly: true, + Secure: isSecure, + SameSite: http.SameSiteLaxMode, + }) + config.DebugLog("Set state cookie: %s (Secure: %v)", state, isSecure) - // Stores the state in a session cookie for validation - stateCookie := &http.Cookie{ - Name: "oidc_state", - Value: state, - Path: CookiePath(), - MaxAge: 600, - HttpOnly: true, - Secure: isSecure, - SameSite: http.SameSiteLaxMode, - } - http.SetCookie(c.Writer, stateCookie) - config.DebugLog("Set state cookie: %s (Secure: %v)", state, isSecure) + c.Redirect(http.StatusFound, oidcClient.GetAuthURL(state)) +} - // Gets the authorization URL and redirects to it - authURL := oidcClient.GetAuthURL(state) - c.Redirect(http.StatusFound, authURL) +func LoginHandler(c *gin.Context) { + oidcClient := auth.GetOIDCClient() + if oidcClient == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC authentication is not configured"}) return } + oidcConfig := auth.GetConfig() + skipLoginPage := oidcConfig != nil && oidcConfig.SkipLoginPage - // Checks if this is a redirect action (triggered by clicking the login button) - if c.Query("action") == "redirect" { - stateBytes := make([]byte, 32) - if _, err := rand.Read(stateBytes); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state parameter"}) - return - } - state := base64.URLEncoding.EncodeToString(stateBytes) - - // Determines if we're using HTTPS (if not, the state cookie is not secure) - isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" - - // Stores the state in a session cookie for validation - stateCookie := &http.Cookie{ - Name: "oidc_state", - Value: state, - Path: CookiePath(), - MaxAge: 600, - HttpOnly: true, - Secure: isSecure, - SameSite: http.SameSiteLaxMode, - } - http.SetCookie(c.Writer, stateCookie) - config.DebugLog("Set state cookie: %s (Secure: %v)", state, isSecure) - - // Get authorization URL and redirect - authURL := oidcClient.GetAuthURL(state) - c.Redirect(http.StatusFound, authURL) + if skipLoginPage || c.Query("action") == "redirect" { + redirectToOIDCProvider(c, oidcClient) return } renderIndexPage(c) @@ -4293,7 +4320,6 @@ func CallbackHandler(c *gin.Context) { // Clears the session and redirects to the OIDC provider logout. func LogoutHandler(c *gin.Context) { oidcClient := auth.GetOIDCClient() - // Clears the session auth.DeleteSession(c.Writer, c.Request) // If a provider logout URL is configured, redirects there // Otherwise, auto-constructs the logout URL for standard OIDC providers @@ -4362,14 +4388,17 @@ func AuthStatusHandler(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "enabled": true, - "authenticated": true, - "skipLoginPage": skipLoginPage, + "enabled": true, + "authenticated": true, + "skipLoginPage": skipLoginPage, + "authorizationEnabled": auth.AuthorizationEnabled(), "user": gin.H{ - "id": session.UserID, - "email": session.Email, - "name": session.Name, - "username": session.Username, + "id": session.UserID, + "email": session.Email, + "name": session.Name, + "username": session.Username, + "roles": session.Roles, + "accessLevel": session.AccessLevel, }, }) } @@ -4388,12 +4417,15 @@ func UserInfoHandler(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "authenticated": true, + "authenticated": true, + "authorizationEnabled": auth.AuthorizationEnabled(), "user": gin.H{ - "id": session.UserID, - "email": session.Email, - "name": session.Name, - "username": session.Username, + "id": session.UserID, + "email": session.Email, + "name": session.Name, + "username": session.Username, + "roles": session.Roles, + "accessLevel": session.AccessLevel, }, }) } diff --git a/pkg/web/locales/ca.json b/pkg/web/locales/ca.json index 1fd49cd..23462ee 100644 --- a/pkg/web/locales/ca.json +++ b/pkg/web/locales/ca.json @@ -252,10 +252,12 @@ "settings.callback_secret": "Secret de la URL de Callback de Fail2ban", "settings.callback_secret.show": "mostra el secret", "settings.callback_secret.hide": "amaga el secret", + "settings.callback_secret.hidden": "el secret es desa al servidor i no es mostra mai", "settings.callback_secret_placeholder": "Secret autogenerat de 42 caràcters", "settings.callback_secret.description": "Aquest secret s'utilitza per autenticar les peticions API de bloquejos. S'inclou automàticament a la configuració de l'acció de fail2ban.", - "settings.destination_email": "Correu de Destinació (Receptor d'Alertes)", - "settings.destination_email_placeholder": "alertes@exemple.cat", + "settings.destination_email": "Correus de Destinació (Receptors d'Alertes)", + "settings.destination_email_placeholder": "alertes@exemple.cat, admin@exemple.com", + "settings.destination_email_hint": "Es poden especificar múltiples adreces de correu separades per comes.", "settings.alert_countries": "Països d'Alerta", "settings.alert_countries.all": "TOTS (Tots els països)", "settings.alert_countries_description": "Seleccioneu els països per als quals voleu reenviar esdeveniments de bloqueig i/o desbloqueig amb el vostre proveïdor d'alertes seleccionat. Estableix-ho en TOTS per no filtrar les alertes per país.", @@ -349,6 +351,14 @@ "settings.bantime_rndtime": "Temps Aleatori (Bantime Rndtime)", "settings.bantime_rndtime.description": "Opcional. Segons aleatoris màxims afegits a la fórmula d'increment del temps de bloqueig (p. ex. 2048). Deixeu-ho en blanc per utilitzar el valor predeterminat de Fail2ban.", "settings.bantime_rndtime_placeholder": "p. ex., 2048", + "settings.bantime_maxtime": "Bantime Maxtime", + "settings.bantime_maxtime.description": "Opcional. Limita quant poden créixer els bloqueigs escalats quan l'increment de bantime està activat (p. ex. 5w). Deixeu-ho en blanc per utilitzar el valor predeterminat de Fail2ban (sense límit).", + "settings.bantime_maxtime_placeholder": "p. ex., 5w", + "settings.bantime_factor": "Factor de Bantime", + "settings.bantime_factor.description": "Opcional. Multiplicador de la fórmula d'increment de bantime; valors més alts escalen més ràpidament els bloqueigs dels reincidents (valor predeterminat de Fail2ban: 1). Deixeu-ho en blanc per utilitzar el valor predeterminat.", + "settings.bantime_factor_placeholder": "p. ex., 2", + "settings.bantime_overalljails": "Comptar reincidències a tots els jails", + "settings.bantime_overalljails.description": "Quan l'increment de bantime està activat, els bloqueigs anteriors de tots els jails (no només l'actual) es tenen en compte en escalar el temps de bloqueig d'una IP.", "settings.default_chain": "Cadena per Defecte (Chain)", "settings.default_chain.description": "Cadena iptables/nftables utilitzada per al bloqueig (p. ex. INPUT per a l'amfitrió, DOCKER-USER per als contenidors Docker).", "settings.chain_help_title": "Cadena per defecte", @@ -376,6 +386,8 @@ "settings.geoip_database_path.description": "Ruta al fitxer de la base de dades MaxMind GeoLite2-Country.", "settings.max_log_lines": "Màxim de Línies de Registre", "settings.max_log_lines.description": "Nombre màxim de línies de registre per incloure en les notificacions de bloqueig. Les línies més rellevants se seleccionen automàticament.", + "settings.event_retention_days": "Retenció d'esdeveniments (dies)", + "settings.event_retention_days.description": "Els esdeveniments de bloqueig/desbloqueig més antics s'eliminen automàticament un cop al dia. Estableix 0 per conservar els esdeveniments per sempre (la base de dades creixerà sense límit).", "settings.ignore_ips": "IP a Ignorar", "settings.ignore_ips.description": "Llista d'adreces IP, màscares CIDR o amfitrions DNS separats per espais. Fail2ban no bloquejarà un amfitrió que coincideixi amb una adreça d'aquesta llista.", "settings.ignore_ips_placeholder": "IPs a ignorar, separades per espais", @@ -438,6 +450,25 @@ "settings.save": "Desa", "settings.save_success_reloaded": "Paràmetres desats i fail2ban recarregat", "settings.save_success_restart_required": "Paràmetres desats. Cal reiniciar fail2ban.", + "settings.toast.load_error": "Error en carregar la configuració", + "settings.toast.fix_validation": "Corregiu els errors de validació abans de desar", + "settings.toast.smtp_port_invalid": "El port SMTP ha d'estar entre 1 i 65535", + "settings.toast.save_error": "Error en desar la configuració", + "settings.toast.saved_warnings": "Configuració desada amb avisos", + "settings.toast.test_email_error": "Error en enviar el correu de prova", + "settings.toast.test_email_success": "Correu de prova enviat correctament!", + "settings.toast.webhook_test_failed": "Ha fallat la prova del webhook", + "settings.toast.webhook_test_success": "Webhook de prova enviat correctament!", + "settings.toast.es_test_failed": "Ha fallat la prova d'Elasticsearch", + "settings.toast.es_test_success": "Document de prova indexat correctament!", + "settings.toast.copy_failed": "No s'ha pogut copiar al porta-retalls", + "settings.toast.block_log_error": "Error en carregar el registre de blocatges permanents", + "settings.toast.enter_ip": "Introduïu una adreça IP.", + "settings.toast.advanced_action_failed": "Ha fallat l'acció avançada", + "settings.toast.action_completed": "Acció completada", + "settings.toast.remove_ip_failed": "No s'ha pogut eliminar la IP", + "settings.toast.ip_removed": "IP eliminada", + "settings.toast.invalid_ignore_ip": "Adreça IP, CIDR o nom d'amfitrió no vàlids", "modal.filter_config": "Configuració de Filtre / Jail:", "modal.filter_config_edit": "Edita el Filtre / Jail", "modal.filter_config_label": "Configuració del Filtre", @@ -450,9 +481,29 @@ "modal.jail_filter": "Filtre (opcional)", "modal.jail_filter_hint": "Seleccionar un filtre emplenarà automàticament la configuració del jail.", "modal.jail_name": "Nom del Jail", + "modal.delete_jail": "Suprimeix el jail", "modal.jail_name_hint": "Només es permeten caràcters alfanumèrics, guions i guions baixos.", "modal.test_logpath": "Prova de la Ruta del Registre (Logpath)", "jails.logpath_test.found_files": "S'han trobat {count} fitxer(s)", + "jails.toast.name_required": "Cal un nom de jail", + "jails.toast.create_error": "Error en crear el jail", + "jails.toast.create_success": "Jail creat correctament", + "jails.toast.delete_error": "Error en suprimir el jail", + "jails.toast.delete_success": "Jail suprimit correctament", + "jails.toast.save_config_error": "Error en desar la configuració", + "jails.toast.save_settings_error": "Error en desar la configuració del jail", + "jails.toast.enabled_success": "Jail {jail} activat correctament", + "jails.toast.disabled_success": "Jail {jail} desactivat correctament", + "jails.confirm.delete": "Segur que voleu suprimir el jail \"{name}\"? Aquesta acció no es pot desfer.", + "jails.logpath_test.no_logpath": "No s'ha trobat cap logpath a la configuració del jail. Afegiu una línia logpath (p. ex. logpath = /var/log/example.log)", + "jails.logpath_test.testing": "S'està provant el logpath...", + "jails.logpath_test.no_entries": "No s'han trobat entrades logpath.", + "jails.logpath_test.entry": "Logpath {num}:", + "jails.logpath_test.resolved": "Resolt:", + "jails.logpath_test.in_container": "Al contenidor de fail2ban-ui:", + "jails.logpath_test.on_remote": "Al servidor remot:", + "jails.logpath_test.not_found_container": "No trobat (potser els registres no estan muntats al contenidor)", + "jails.logpath_test.not_found": "No trobat", "modal.local_server_logpath_note": "⚠️ Nota:", "modal.local_server_logpath_text_prefix": "Per a un servidor local de fail2ban (p. ex., instal·lat al sistema amfitrió del contenidor o en un contenidor al mateix amfitrió), els fitxers de registre també s'han de muntar al contenidor de fail2ban-ui (p. ex.,", "modal.local_server_logpath_text_suffix": ") això és necessari perquè el fail2ban-ui pugui verificar les variables logpath o rutes en actualitzar els jails.", @@ -466,7 +517,18 @@ "modal.close": "Tanca", "loading": "Carregant...", "dashboard.manage_jails": "Gestiona els Jails", + "dashboard.ban.confirm": "Voleu bloquejar la IP {ip} al jail {jail}?", + "dashboard.toast.block_error": "Error en bloquejar la IP", + "dashboard.toast.unban_error": "Error en desbloquejar la IP", "modal.manage_jails_title": "Gestiona els Jails", + "modal.toast.event_not_found": "No s'ha trobat l'esdeveniment", + "modal.toast.no_whois": "No hi ha dades whois per a aquest esdeveniment", + "modal.toast.whois_error": "Error en carregar les dades whois", + "modal.toast.no_logs": "No hi ha registres per a aquest esdeveniment", + "modal.toast.logs_error": "Error en carregar els registres", + "modal.toast.no_jails": "No s'han trobat jails per a aquest servidor.", + "modal.toast.fetch_jails_error": "Error en obtenir els jails", + "modal.toast.load_config_error": "Error en carregar la configuració", "servers.selector.label": "Servidor Actiu", "servers.selector.empty": "No hi ha servidors configurats", "servers.selector.none": "No hi ha cap servidor configurat. Si us plau, afegiu un servidor Fail2ban.", @@ -515,6 +577,7 @@ "servers.badge.restart_needed": "Requereix reinici", "servers.actions.edit": "Edita", "servers.actions.set_default": "Defineix per defecte", + "servers.actions.set_default_success": "Servidor establert com a predeterminat", "servers.actions.enable": "Habilita", "servers.actions.disable": "Deshabilita", "servers.actions.test": "Prova la connexió", @@ -531,6 +594,7 @@ "servers.actions.reload_tooltip": "Per a connectors locals, només és possible recarregar la configuració mitjançant la connexió del sòcol (socket). El contenidor no pot reiniciar el servei Fail2ban utilitzant systemctl. Per fer un reinici complet, executeu 'systemctl restart fail2ban' directament al sistema amfitrió.", "servers.actions.delete": "Suprimeix", "servers.actions.delete_confirm": "Voleu suprimir aquesta entrada del servidor?", + "servers.actions.delete_success": "Servidor eliminat", "servers.form.select_key": "Seleccioneu la Clau Privada", "servers.form.select_key_placeholder": "Entrada manual", "servers.form.no_keys": "No s'han trobat claus SSH; introduïu la ruta manualment", @@ -545,6 +609,14 @@ "servers.card.socket_path": "Ruta del socket", "servers.card.config_path": "Ruta de configuració", "servers.card.server_id": "ID del servidor", + "servers.toast.save_error": "Error en desar el servidor", + "servers.toast.delete_error": "Error en suprimir el servidor", + "servers.toast.set_default_error": "Error en establir el servidor predeterminat", + "servers.toast.none_selected": "Cap servidor seleccionat", + "servers.toast.restart_failed": "No s'ha pogut reiniciar Fail2ban", + "servers.confirm.reload_local": "Voleu recarregar ara la configuració de Fail2ban en aquest servidor? Es recarregarà la configuració sense reiniciar el servei.", + "servers.confirm.restart_remote": "Tingueu en compte que mentre fail2ban es reinicia no s'analitzen registres ni es bloquegen adreces IP. Voleu reiniciar ara fail2ban en aquest servidor? Trigarà una estona.", + "servers.confirm.restart": "Tingueu en compte que mentre fail2ban es reinicia no s'analitzen registres ni es bloquegen adreces IP. Voleu reiniciar ara fail2ban? Trigarà una estona.", "filter_debug.not_available": "La depuració de filtres només està disponible quan almenys un servidor Fail2ban registrat està habilitat.", "filter_debug.local_missing": "No s'ha trobat el directori local de filtres de Fail2ban en aquest amfitrió.", "filter_debug.save_success": "La configuració del filtre i el jail s'ha desat i recarregat", @@ -610,5 +682,22 @@ "auth.login_required": "Es requereix autenticació", "footer.version": "Fail2ban-UI v{version}", "footer.latest": "Més recent", - "footer.update_available": "Actualització disponible: v{version}" + "footer.update_available": "Actualització disponible: v{version}", + "common.error": "Error", + "common.copy": "Copia", + "common.copied": "Copiat!", + "filters.toast.name_required": "Cal un nom de filtre", + "filters.toast.create_error": "Error en crear el filtre", + "filters.toast.create_success": "Filtre creat correctament", + "filters.toast.load_error": "Error en carregar els filtres", + "filters.toast.load_content_error": "Error en carregar el contingut del filtre", + "filters.toast.select_delete": "Seleccioneu un filtre per suprimir", + "filters.toast.delete_error": "Error en suprimir el filtre", + "filters.toast.delete_success": "Filtre suprimit correctament", + "filters.toast.select_filter": "Seleccioneu un filtre.", + "filters.toast.enter_log_lines": "Introduïu almenys una línia de registre per provar.", + "filters.toast.test_error": "Error en provar el filtre", + "filters.confirm.delete": "Segur que voleu suprimir el filtre \"{name}\"? Aquesta acció no es pot desfer.", + "globe.bans_singular": "bloqueig", + "globe.bans_plural": "bloqueigs" } diff --git a/pkg/web/locales/de.json b/pkg/web/locales/de.json index 50f01cb..b21545e 100644 --- a/pkg/web/locales/de.json +++ b/pkg/web/locales/de.json @@ -252,10 +252,12 @@ "settings.callback_secret": "Fail2ban Callback-URL Secret", "settings.callback_secret.show": "Secret anzeigen", "settings.callback_secret.hide": "Secret ausblenden", + "settings.callback_secret.hidden": "Das Secret ist auf dem Server gespeichert und wird nie angezeigt", "settings.callback_secret_placeholder": "Automatisch generiertes 42-Zeichen-Secret", "settings.callback_secret.description": "Dieses Secret dient der Authentifizierung von Ban-API-Anfragen. Es wird automatisch in die Fail2ban-Action-Konfiguration eingefügt.", - "settings.destination_email": "Ziel-E-Mail (Alarmempfänger)", - "settings.destination_email_placeholder": "alerts@swissmakers.ch", + "settings.destination_email": "Ziel-E-Mails (Alarmempfänger)", + "settings.destination_email_placeholder": "alerts@swissmakers.ch, admin@example.com", + "settings.destination_email_hint": "Mehrere E-Mail-Adressen können durch Komma getrennt angegeben werden.", "settings.alert_countries": "Alarm-Länder", "settings.alert_countries.all": "ALLE (Jedes Land)", "settings.alert_countries_description": "Wählen Sie die Länder für die Sie Ban- und/oder Unban-Events mit Ihrem ausgewählten Alert-Provider erhalten möchten. Setzen Sie auf ALLE, um nicht nach Land zu filtern.", @@ -349,6 +351,14 @@ "settings.bantime_rndtime": "Bantime Rndtime", "settings.bantime_rndtime.description": "Optional. Maximale Zufallssekunden in der Bantime-Inkrement-Formel (z.B. 2048). Leer lassen für Fail2ban-Standard.", "settings.bantime_rndtime_placeholder": "z.B. 2048", + "settings.bantime_maxtime": "Bantime Maxtime", + "settings.bantime_maxtime.description": "Optional. Begrenzt, wie lange sich steigernde Sperren maximal werden können, wenn Bantime-Inkrement aktiviert ist (z.B. 5w). Leer lassen für Fail2ban-Standard (keine Begrenzung).", + "settings.bantime_maxtime_placeholder": "z.B. 5w", + "settings.bantime_factor": "Bantime Faktor", + "settings.bantime_factor.description": "Optional. Multiplikator der Bantime-Inkrement-Formel; höhere Werte steigern die Sperrzeit von Wiederholungstätern schneller (Fail2ban-Standard: 1). Leer lassen für den Standard.", + "settings.bantime_factor_placeholder": "z.B. 2", + "settings.bantime_overalljails": "Wiederholungstäter über alle Jails zählen", + "settings.bantime_overalljails.description": "Wenn Bantime-Inkrement aktiviert ist, werden frühere Sperren aus allen Jails (nicht nur dem aktuellen) bei der Eskalation der Sperrzeit einer IP berücksichtigt.", "settings.default_chain": "Standard-Chain", "settings.default_chain.description": "iptables/nftables-Chain für Bans (z.B. INPUT für Host, DOCKER-USER für Docker-Container).", "settings.chain_help_title": "Standard-Chain", @@ -376,6 +386,8 @@ "settings.geoip_database_path.description": "Pfad zur MaxMind GeoLite2-Country-Datenbankdatei.", "settings.max_log_lines": "Maximale Log-Zeilen", "settings.max_log_lines.description": "Maximale Anzahl von Log-Zeilen, die in Ban-Benachrichtigungen enthalten sein sollen. Die relevantesten Zeilen werden automatisch ausgewählt.", + "settings.event_retention_days": "Event-Retention (in Tage)", + "settings.event_retention_days.description": "Ban-/Unban-Events, die älter sind, werden einmal täglich automatisch gelöscht. 0 setzen, um Events für immer zu behalten.", "settings.ignore_ips": "IP-Adressen ignorieren", "settings.ignore_ips.description": "Durch Leerzeichen getrennte Liste von IP-Adressen, CIDR-Masken oder DNS-Hosts. Fail2ban wird keinen Host sperren, der mit einer Adresse in dieser Liste übereinstimmt.", "settings.ignore_ips_placeholder": "IP-Adressen, getrennt durch Leerzeichen", @@ -438,6 +450,25 @@ "settings.save": "Speichern", "settings.save_success_reloaded": "Einstellungen gespeichert und Fail2ban reloaded", "settings.save_success_restart_required": "Einstellungen gespeichert. Fail2ban-Neustart erforderlich.", + "settings.toast.load_error": "Fehler beim Laden der Einstellungen", + "settings.toast.fix_validation": "Bitte vor dem Speichern die Validierungsfehler beheben", + "settings.toast.smtp_port_invalid": "SMTP-Port muss zwischen 1 und 65535 liegen", + "settings.toast.save_error": "Fehler beim Speichern der Einstellungen", + "settings.toast.saved_warnings": "Einstellungen mit Warnungen gespeichert", + "settings.toast.test_email_error": "Fehler beim Senden der Test-E-Mail", + "settings.toast.test_email_success": "Test-E-Mail erfolgreich gesendet!", + "settings.toast.webhook_test_failed": "Webhook-Test fehlgeschlagen", + "settings.toast.webhook_test_success": "Test-Webhook erfolgreich gesendet!", + "settings.toast.es_test_failed": "Elasticsearch-Test fehlgeschlagen", + "settings.toast.es_test_success": "Testdokument erfolgreich indexiert!", + "settings.toast.copy_failed": "Kopieren in die Zwischenablage fehlgeschlagen", + "settings.toast.block_log_error": "Fehler beim Laden des permanenten Block-Logs", + "settings.toast.enter_ip": "Bitte eine IP-Adresse eingeben.", + "settings.toast.advanced_action_failed": "Erweiterte Aktion fehlgeschlagen", + "settings.toast.action_completed": "Aktion abgeschlossen", + "settings.toast.remove_ip_failed": "IP konnte nicht entfernt werden", + "settings.toast.ip_removed": "IP entfernt", + "settings.toast.invalid_ignore_ip": "Ungültige IP-Adresse, CIDR oder Hostname", "modal.filter_config": "Filter / Jail-Konfiguration:", "modal.filter_config_edit": "Filter / Jail bearbeiten", "modal.filter_config_label": "Filter-Konfiguration", @@ -450,9 +481,29 @@ "modal.jail_filter": "Filter (optional)", "modal.jail_filter_hint": "Die Auswahl eines Filters füllt die Jail-Konfiguration automatisch aus.", "modal.jail_name": "Jail-Name", + "modal.delete_jail": "Jail löschen", "modal.jail_name_hint": "Nur alphanumerische Zeichen, Bindestriche und Unterstriche sind erlaubt.", "modal.test_logpath": "Logpfad testen", "jails.logpath_test.found_files": "{count} Datei(en) gefunden", + "jails.toast.name_required": "Jail-Name ist erforderlich", + "jails.toast.create_error": "Fehler beim Erstellen des Jails", + "jails.toast.create_success": "Jail erfolgreich erstellt", + "jails.toast.delete_error": "Fehler beim Löschen des Jails", + "jails.toast.delete_success": "Jail erfolgreich gelöscht", + "jails.toast.save_config_error": "Fehler beim Speichern der Konfiguration", + "jails.toast.save_settings_error": "Fehler beim Speichern der Jail-Einstellungen", + "jails.toast.enabled_success": "Jail {jail} erfolgreich aktiviert", + "jails.toast.disabled_success": "Jail {jail} erfolgreich deaktiviert", + "jails.confirm.delete": "Soll das Jail \"{name}\" wirklich gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.", + "jails.logpath_test.no_logpath": "Kein logpath in der Jail-Konfiguration gefunden. Bitte eine logpath-Zeile hinzufügen (z.B. logpath = /var/log/example.log)", + "jails.logpath_test.testing": "Logpath wird getestet...", + "jails.logpath_test.no_entries": "Keine logpath-Einträge gefunden.", + "jails.logpath_test.entry": "Logpath {num}:", + "jails.logpath_test.resolved": "Aufgelöst:", + "jails.logpath_test.in_container": "Im fail2ban-ui-Container:", + "jails.logpath_test.on_remote": "Auf dem Remote-Server:", + "jails.logpath_test.not_found_container": "Nicht gefunden (Logs sind evtl. nicht in den Container gemountet)", + "jails.logpath_test.not_found": "Nicht gefunden", "modal.local_server_logpath_note": "⚠️ Hinweis:", "modal.local_server_logpath_text_prefix": "Für einen lokalen Fail2ban-Server (z.B. installiert auf dem Host-System oder in einem Container auf demselben Host), müssen Logdateien auch im fail2ban-ui Container gemountet werden (z.B.", "modal.local_server_logpath_text_suffix": "), damit fail2ban-ui Logpfad-Variablen oder -Pfade überprüfen kann, wenn Jails aktualisiert werden.", @@ -466,7 +517,18 @@ "modal.close": "Schließen", "loading": "Lade...", "dashboard.manage_jails": "Jails verwalten", + "dashboard.ban.confirm": "IP {ip} im Jail {jail} blockieren?", + "dashboard.toast.block_error": "Fehler beim Blockieren der IP", + "dashboard.toast.unban_error": "Fehler beim Entsperren der IP", "modal.manage_jails_title": "Jails verwalten", + "modal.toast.event_not_found": "Ereignis nicht gefunden", + "modal.toast.no_whois": "Keine Whois-Daten für dieses Ereignis verfügbar", + "modal.toast.whois_error": "Fehler beim Laden der Whois-Daten", + "modal.toast.no_logs": "Keine Logdaten für dieses Ereignis verfügbar", + "modal.toast.logs_error": "Fehler beim Laden der Logdaten", + "modal.toast.no_jails": "Keine Jails für diesen Server gefunden.", + "modal.toast.fetch_jails_error": "Fehler beim Abrufen der Jails", + "modal.toast.load_config_error": "Fehler beim Laden der Konfiguration", "servers.selector.label": "Aktiver Server", "servers.selector.empty": "Keine Server konfiguriert", "servers.selector.none": "Kein Server konfiguriert. Bitte füge einen Fail2ban-Server hinzu.", @@ -515,6 +577,7 @@ "servers.badge.restart_needed": "Neustart erforderlich", "servers.actions.edit": "Bearbeiten", "servers.actions.set_default": "Als Standard setzen", + "servers.actions.set_default_success": "Server als Standard festgelegt", "servers.actions.enable": "Aktivieren", "servers.actions.disable": "Deaktivieren", "servers.actions.test": "Verbindung testen", @@ -531,6 +594,7 @@ "servers.actions.reload_tooltip": "Für lokale Connectors ist nur ein Neuladen der Konfiguration über die Socket-Verbindung möglich. Der Container kann den Fail2ban-Dienst nicht mit systemctl neu starten. Für einen vollständigen Neustart führen Sie 'systemctl restart fail2ban' direkt auf dem Host-System aus.", "servers.actions.delete": "Löschen", "servers.actions.delete_confirm": "Diesen Servereintrag löschen?", + "servers.actions.delete_success": "Server entfernt", "servers.form.select_key": "Privaten Schlüssel auswählen", "servers.form.select_key_placeholder": "Manuelle Eingabe", "servers.form.no_keys": "Keine SSH-Schlüssel gefunden; Pfad manuell eingeben", @@ -545,6 +609,14 @@ "servers.card.socket_path": "Socket-Pfad", "servers.card.config_path": "Konfigurationspfad", "servers.card.server_id": "Server-ID", + "servers.toast.save_error": "Fehler beim Speichern des Servers", + "servers.toast.delete_error": "Fehler beim Löschen des Servers", + "servers.toast.set_default_error": "Fehler beim Festlegen des Standard-Servers", + "servers.toast.none_selected": "Kein Server ausgewählt", + "servers.toast.restart_failed": "Fail2ban konnte nicht neu gestartet werden", + "servers.confirm.reload_local": "Fail2ban-Konfiguration auf diesem Server jetzt neu laden? Die Konfiguration wird neu geladen, ohne den Dienst neu zu starten.", + "servers.confirm.restart_remote": "Beachten Sie: Während fail2ban neu startet, werden keine Logs ausgewertet und keine IP-Adressen blockiert. Fail2ban auf diesem Server jetzt neu starten? Das kann etwas dauern.", + "servers.confirm.restart": "Beachten Sie: Während fail2ban neu startet, werden keine Logs ausgewertet und keine IP-Adressen blockiert. Fail2ban jetzt neu starten? Das kann etwas dauern.", "filter_debug.not_available": "Filter-Debug ist nur verfügbar, wenn mindestens ein registrierter Fail2ban-Server aktiviert ist.", "filter_debug.local_missing": "Das lokale Fail2ban-Filterverzeichnis wurde auf diesem Host nicht gefunden.", "filter_debug.save_success": "Filter- und Jail-Konfiguration gespeichert und Fail2ban neu geladen", @@ -610,5 +682,22 @@ "auth.login_required": "Authentifizierung erforderlich", "footer.version": "Fail2ban-UI v{version}", "footer.latest": "Aktuell", - "footer.update_available": "Update verfügbar: v{version}" + "footer.update_available": "Update verfügbar: v{version}", + "common.error": "Fehler", + "common.copy": "Kopieren", + "common.copied": "Kopiert!", + "filters.toast.name_required": "Filtername ist erforderlich", + "filters.toast.create_error": "Fehler beim Erstellen des Filters", + "filters.toast.create_success": "Filter erfolgreich erstellt", + "filters.toast.load_error": "Fehler beim Laden der Filter", + "filters.toast.load_content_error": "Fehler beim Laden des Filterinhalts", + "filters.toast.select_delete": "Bitte einen Filter zum Löschen auswählen", + "filters.toast.delete_error": "Fehler beim Löschen des Filters", + "filters.toast.delete_success": "Filter erfolgreich gelöscht", + "filters.toast.select_filter": "Bitte einen Filter auswählen.", + "filters.toast.enter_log_lines": "Bitte mindestens eine Logzeile zum Testen eingeben.", + "filters.toast.test_error": "Fehler beim Testen des Filters", + "filters.confirm.delete": "Soll der Filter \"{name}\" wirklich gelöscht werden? Diese Aktion kann nicht rückgängig gemacht werden.", + "globe.bans_singular": "Sperre", + "globe.bans_plural": "Sperren" } diff --git a/pkg/web/locales/de_ch.json b/pkg/web/locales/de_ch.json index ee0f25c..86af071 100644 --- a/pkg/web/locales/de_ch.json +++ b/pkg/web/locales/de_ch.json @@ -252,10 +252,12 @@ "settings.callback_secret": "Fail2ban Callback-URL Secret", "settings.callback_secret.show": "Secret zeige", "settings.callback_secret.hide": "Secret verstecke", + "settings.callback_secret.hidden": "S Secret isch ufem Server gspeicheret und wird nie azeigt", "settings.callback_secret_placeholder": "Automatisch generierts 42-Zeiche-Secret", "settings.callback_secret.description": "Zur Authentifizierig vo de Callbacks. (Wird outomatisch id remote Fail2ban-Action-Konf synchronisiert.)", - "settings.destination_email": "Ziiu-Email (Alarmempfänger)", - "settings.destination_email_placeholder": "alerts@swissmakers.ch", + "settings.destination_email": "Ziiu-Emails (Alarmempfänger)", + "settings.destination_email_placeholder": "alerts@swissmakers.ch, admin@example.com", + "settings.destination_email_hint": "Mehreri E-Mail-Adrässe chönd mit Komma trännt aagäh werde.", "settings.alert_countries": "Alarm-Länder", "settings.alert_countries.all": "AUI (Jedes Land)", "settings.alert_countries_description": "Wähl d'Länder us, für diä du Ban- und/oder Unban-Ereigniss alerte möschtisch. Setz es uf AUI, um keine Warnungen nach Land use z fiutere.", @@ -349,6 +351,14 @@ "settings.bantime_rndtime": "Bantime Rndtime", "settings.bantime_rndtime.description": "Optionau. Maximali Zufallssekunde i dr Bantime-Inkrement-Formle (z.B. 2048). Läär la füre Fail2ban-Standard.", "settings.bantime_rndtime_placeholder": "z.B. 2048", + "settings.bantime_maxtime": "Bantime Maxtime", + "settings.bantime_maxtime.description": "Optionau. Begränzt, wie lang sich steigerndi Sperre maximau chöi wärde, wenn Bantime-Inkrement aktiviert isch (z.B. 5w). Läär la füre Fail2ban-Standard (kei Begränzig).", + "settings.bantime_maxtime_placeholder": "z.B. 5w", + "settings.bantime_factor": "Bantime Faktor", + "settings.bantime_factor.description": "Optionau. Multiplikator vo dr Bantime-Inkrement-Formle; höcheri Wärt steigere d Sperrzyt vo Widerholigstäter schnäuer (Fail2ban-Standard: 1). Läär la füre Standard.", + "settings.bantime_factor_placeholder": "z.B. 2", + "settings.bantime_overalljails": "Widerholigstäter über aui Jails zeue", + "settings.bantime_overalljails.description": "Wenn Bantime-Inkrement aktiviert isch, wärde früeneri Sperre us aune Jails (nid nume em aktuelle) bi dr Eskalation vo dr Sperrzyt vore IP berücksichtigt.", "settings.default_chain": "Standard-Chain", "settings.default_chain.description": "iptables/nftables-Chain für Bans (z.B. INPUT für Host, DOCKER-USER für Docker-Container).", "settings.chain_help_title": "Standard-Chain", @@ -376,6 +386,8 @@ "settings.geoip_database_path.description": "Pfad zur MaxMind GeoLite2-Country-Datebank.", "settings.max_log_lines": "Maximali Log-Zeile", "settings.max_log_lines.description": "Maximali Aazahl vo Log-Zeile, wo i Ban-Benachrichtigunge enthalte si söll. Di relevanteschte Zeile werdet automatisch usgwählt.", + "settings.event_retention_days": "Event-Ufbewahrig (Täg)", + "settings.event_retention_days.description": "Ban-/Unban-Events wo älter sind, werded eimal am Tag automatisch glöscht. 0 setze, zum d'Events für immer bhalte (d'Datebank wachst denn unbegränzt).", "settings.ignore_ips": "IPs ignorierä", "settings.ignore_ips.description": "Dur Leerzeichä trennti Lischte vo IP-Adrässe, CIDR-Maske oder DNS-Hosts. Fail2ban wird kei Host sperre, wo mit ere Adrässe i dere Lischte übereistimmt.", "settings.ignore_ips_placeholder": "IPs, getrennt dur e Leerzeichä", @@ -438,6 +450,25 @@ "settings.save": "Spicherä", "settings.save_success_reloaded": "Istellige gspeicheret und Fail2ban reloaded", "settings.save_success_restart_required": "Istellige gspeicheret. Fail2ban muess restarted werde.", + "settings.toast.load_error": "Fähler bim Lade vo de Iistellige", + "settings.toast.fix_validation": "Bitte vor em Speichere d Validierigsfähler behebe", + "settings.toast.smtp_port_invalid": "SMTP-Port mues zwüsche 1 und 65535 sii", + "settings.toast.save_error": "Fähler bim Speichere vo de Iistellige", + "settings.toast.saved_warnings": "Iistellige mit Warnige gspeicheret", + "settings.toast.test_email_error": "Fähler bim Sände vo dr Test-E-Mail", + "settings.toast.test_email_success": "Test-E-Mail erfolgriich gsändet!", + "settings.toast.webhook_test_failed": "Webhook-Test fählgschlage", + "settings.toast.webhook_test_success": "Test-Webhook erfolgriich gsändet!", + "settings.toast.es_test_failed": "Elasticsearch-Test fählgschlage", + "settings.toast.es_test_success": "Testdokumänt erfolgriich indexiert!", + "settings.toast.copy_failed": "Kopiere id Zwüscheablag fählgschlage", + "settings.toast.block_log_error": "Fähler bim Lade vom permanänte Block-Log", + "settings.toast.enter_ip": "Bitte e IP-Adrässe iigäh.", + "settings.toast.advanced_action_failed": "Erwiterti Aktion fählgschlage", + "settings.toast.action_completed": "Aktion abgschlosse", + "settings.toast.remove_ip_failed": "IP het nid chönne entfärnt wärde", + "settings.toast.ip_removed": "IP entfärnt", + "settings.toast.invalid_ignore_ip": "Ungültigi IP-Adrässe, CIDR oder Hostname", "modal.filter_config": "Filter / Jail-Konfiguration:", "modal.filter_config_edit": "Filter / Jail bearbeite", "modal.filter_config_label": "Filter-Konfiguration", @@ -450,9 +481,29 @@ "modal.jail_filter": "Filter (optional)", "modal.jail_filter_hint": "D Uswahl von ne Filter füllt d Jail-Konfiguration automatisch us.", "modal.jail_name": "Jail-Name", + "modal.delete_jail": "Jail lösche", "modal.jail_name_hint": "Nur alphanumerischi Zeiche, Bindestrich und Unterstriche si erlaubt.", "modal.test_logpath": "Logpfad teste", "jails.logpath_test.found_files": "{count} Datei(e) gfunde", + "jails.toast.name_required": "Jail-Name isch erforderlich", + "jails.toast.create_error": "Fähler bim Erstelle vom Jail", + "jails.toast.create_success": "Jail erfolgriich erstellt", + "jails.toast.delete_error": "Fähler bim Lösche vom Jail", + "jails.toast.delete_success": "Jail erfolgriich glöscht", + "jails.toast.save_config_error": "Fähler bim Speichere vo dr Konfiguration", + "jails.toast.save_settings_error": "Fähler bim Speichere vo de Jail-Iistellige", + "jails.toast.enabled_success": "Jail {jail} erfolgriich aktiviert", + "jails.toast.disabled_success": "Jail {jail} erfolgriich deaktiviert", + "jails.confirm.delete": "Söu s Jail \"{name}\" würklich glöscht wärde? Die Aktion cha nid rückgängig gmacht wärde.", + "jails.logpath_test.no_logpath": "Kei logpath i dr Jail-Konfiguration gfunde. Bitte e logpath-Ziile hinzuefüege (z.B. logpath = /var/log/example.log)", + "jails.logpath_test.testing": "Logpath wird testet...", + "jails.logpath_test.no_entries": "Kei logpath-Iiträg gfunde.", + "jails.logpath_test.entry": "Logpath {num}:", + "jails.logpath_test.resolved": "Ufglöst:", + "jails.logpath_test.in_container": "Im fail2ban-ui-Container:", + "jails.logpath_test.on_remote": "Ufem Remote-Server:", + "jails.logpath_test.not_found_container": "Nid gfunde (Logs si evtl. nid in Container gmountet)", + "jails.logpath_test.not_found": "Nid gfunde", "modal.local_server_logpath_note": "⚠️ Achtung:", "modal.local_server_logpath_text_prefix": "Für lokali Fail2ban-Server (z.B. installiert auf em Host-System oder ime Container ufem gliche Host), müesse Logdateie ou im fail2ban-ui Container gmountet wärde (z.B.", "modal.local_server_logpath_text_suffix": "), dass fail2ban-ui Log-Variable oder Pfäd überprüefe cha, wenn Jails aktualisiert wärde.", @@ -466,7 +517,18 @@ "modal.close": "Wider Schliesseä", "loading": "Lade...", "dashboard.manage_jails": "Jails ala oder absteue", + "dashboard.ban.confirm": "IP {ip} im Jail {jail} blockiere?", + "dashboard.toast.block_error": "Fähler bim Blockiere vo dr IP", + "dashboard.toast.unban_error": "Fähler bim Entsperre vo dr IP", "modal.manage_jails_title": "Jails ala oder absteue", + "modal.toast.event_not_found": "Ereignis nid gfunde", + "modal.toast.no_whois": "Kei Whois-Date für das Ereignis verfüegbar", + "modal.toast.whois_error": "Fähler bim Lade vo de Whois-Date", + "modal.toast.no_logs": "Kei Logdate für das Ereignis verfüegbar", + "modal.toast.logs_error": "Fähler bim Lade vo de Logdate", + "modal.toast.no_jails": "Kei Jails für dä Server gfunde.", + "modal.toast.fetch_jails_error": "Fähler bim Abrüefe vo de Jails", + "modal.toast.load_config_error": "Fähler bim Lade vo dr Konfiguration", "servers.selector.label": "Aktiver Server", "servers.selector.empty": "Kei Server konfiguriert", "servers.selector.none": "Kei Server konfiguriert. Bitte füeg ä Fail2ban-Server dezue.", @@ -515,6 +577,7 @@ "servers.badge.restart_needed": "Brucht ä Neustart", "servers.actions.edit": "Bearbeite", "servers.actions.set_default": "Als Standard setze", + "servers.actions.set_default_success": "Server als Standard festgleit", "servers.actions.enable": "Aktivierä", "servers.actions.disable": "Deaktivierä", "servers.actions.test": "Verbindig teste", @@ -531,6 +594,7 @@ "servers.actions.reload_tooltip": "Für lokali Connectors isch nur es Neulade vo de Konfiguration über d Socket-Verbindig müglech. Dr Container cha dr Fail2ban-Dienst nid mit systemctl neu starte. Für en vollständige Neustart füehre Sie 'systemctl restart fail2ban' direkt uf em Host-System us.", "servers.actions.delete": "Lösche", "servers.actions.delete_confirm": "Dä Servereintrag lösche?", + "servers.actions.delete_success": "Server entfernt", "servers.form.select_key": "Priväte Schlissel ufwähle", "servers.form.select_key_placeholder": "Manuäll igäh", "servers.form.no_keys": "Kei SSH-Schlüssel gfunde; Pfad selber igäh", @@ -545,6 +609,14 @@ "servers.card.socket_path": "Socket-Pfad", "servers.card.config_path": "Konfigurationspfad", "servers.card.server_id": "Server-ID", + "servers.toast.save_error": "Fähler bim Speichere vom Server", + "servers.toast.delete_error": "Fähler bim Lösche vom Server", + "servers.toast.set_default_error": "Fähler bim Festlege vom Standard-Server", + "servers.toast.none_selected": "Kei Server usgwählt", + "servers.toast.restart_failed": "Fail2ban het nid chönne neu gstartet wärde", + "servers.confirm.reload_local": "Fail2ban-Konfiguration uf däm Server jetz neu lade? D Konfiguration wird neu glade, ohni dr Dienst neu z starte.", + "servers.confirm.restart_remote": "Beacht: Während fail2ban neu startet, wärde kei Logs usgwärtet und kei IP-Adrässe blockiert. Fail2ban uf däm Server jetz neu starte? Das cha chli duure.", + "servers.confirm.restart": "Beacht: Während fail2ban neu startet, wärde kei Logs usgwärtet und kei IP-Adrässe blockiert. Fail2ban jetz neu starte? Das cha chli duure.", "filter_debug.not_available": "Filter-Debug funktioniert nur, we mindestens ei registrierte Fail2ban-Server aktiviert isch.", "filter_debug.local_missing": "S lokale Fail2ban-Filterverzeichnis isch uf däm Host nid gfunde worde.", "filter_debug.save_success": "Filter- und Jail-Konfiguration gspicheret und Fail2ban neu glade", @@ -610,5 +682,22 @@ "auth.login_required": "Authentifizierig erforderlich", "footer.version": "Fail2ban-UI v{version}", "footer.latest": "Aktuell", - "footer.update_available": "Update verfüegbar: v{version}" + "footer.update_available": "Update verfüegbar: v{version}", + "common.error": "Fähler", + "common.copy": "Kopiere", + "common.copied": "Kopiert!", + "filters.toast.name_required": "Filtername isch erforderlich", + "filters.toast.create_error": "Fähler bim Erstelle vom Filter", + "filters.toast.create_success": "Filter erfolgriich erstellt", + "filters.toast.load_error": "Fähler bim Lade vo de Filter", + "filters.toast.load_content_error": "Fähler bim Lade vom Filterinhalt", + "filters.toast.select_delete": "Bitte en Filter zum Lösche uswähle", + "filters.toast.delete_error": "Fähler bim Lösche vom Filter", + "filters.toast.delete_success": "Filter erfolgriich glöscht", + "filters.toast.select_filter": "Bitte en Filter uswähle.", + "filters.toast.enter_log_lines": "Bitte mindestens ei Logziile zum Teste iigäh.", + "filters.toast.test_error": "Fähler bim Teste vom Filter", + "filters.confirm.delete": "Söu dr Filter \"{name}\" würklich glöscht wärde? Die Aktion cha nid rückgängig gmacht wärde.", + "globe.bans_singular": "Sperri", + "globe.bans_plural": "Sperre" } diff --git a/pkg/web/locales/en.json b/pkg/web/locales/en.json index 847c97f..ba49e82 100644 --- a/pkg/web/locales/en.json +++ b/pkg/web/locales/en.json @@ -252,10 +252,12 @@ "settings.callback_secret": "Fail2ban Callback URL Secret", "settings.callback_secret.show": "show secret", "settings.callback_secret.hide": "hide secret", + "settings.callback_secret.hidden": "secret is stored on the server and never displayed", "settings.callback_secret_placeholder": "Auto-generated 42-character secret", "settings.callback_secret.description": "This secret is used to authenticate ban API requests. It is automatically included to the fail2ban action configuration.", - "settings.destination_email": "Destination Email (Alerts Receiver)", - "settings.destination_email_placeholder": "alerts@swissmakers.ch", + "settings.destination_email": "Destination Emails (Alerts Receivers)", + "settings.destination_email_placeholder": "alerts@swissmakers.ch, admin@example.com", + "settings.destination_email_hint": "Multiple email addresses can be specified using comma-separated values.", "settings.alert_countries": "Alert Countries", "settings.alert_countries.all": "ALL (Every Country)", "settings.alert_countries_description": "Select the countries for which you want to forward ban and/or unban events with your chosen alert provider. Set to ALL to not filter out alerts by country.", @@ -349,6 +351,14 @@ "settings.bantime_rndtime": "Bantime Rndtime", "settings.bantime_rndtime.description": "Optional. Max random seconds added in bantime increment formula (e.g. 2048). Leave empty to use Fail2ban default.", "settings.bantime_rndtime_placeholder": "e.g., 2048", + "settings.bantime_maxtime": "Bantime Maxtime", + "settings.bantime_maxtime.description": "Optional. Caps how long escalating bans may grow when Bantime Increment is enabled (e.g. 5w). Leave empty to use the Fail2ban default (no cap).", + "settings.bantime_maxtime_placeholder": "e.g., 5w", + "settings.bantime_factor": "Bantime Factor", + "settings.bantime_factor.description": "Optional. Multiplier used by the bantime increment formula; higher values escalate repeat-offender bans faster (Fail2ban default: 1). Leave empty to use the default.", + "settings.bantime_factor_placeholder": "e.g., 2", + "settings.bantime_overalljails": "Count Repeat Offenses Across All Jails", + "settings.bantime_overalljails.description": "When Bantime Increment is enabled, previous bans from every jail (not just the current one) are considered when escalating an IP's ban time.", "settings.default_chain": "Default Chain", "settings.default_chain.description": "iptables/nftables chain used for banning (e.g. INPUT for host, DOCKER-USER for Docker containers).", "settings.chain_help_title": "Default chain", @@ -376,6 +386,8 @@ "settings.geoip_database_path.description": "Path to the MaxMind GeoLite2-Country database file.", "settings.max_log_lines": "Maximum Log Lines", "settings.max_log_lines.description": "Maximum number of log lines to include in ban notifications. Most relevant lines are selected automatically.", + "settings.event_retention_days": "Event Retention (Days)", + "settings.event_retention_days.description": "Ban/unban events older than this are deleted automatically once per day. Set to 0 to keep events forever (the database will grow unbounded).", "settings.ignore_ips": "Ignore IPs", "settings.ignore_ips.description": "Space separated list of IP addresses, CIDR masks or DNS hosts. Fail2ban will not ban a host which matches an address in this list.", "settings.ignore_ips_placeholder": "IPs to ignore, separated by spaces", @@ -438,6 +450,25 @@ "settings.save": "Save", "settings.save_success_reloaded": "Settings saved and fail2ban reloaded", "settings.save_success_restart_required": "Settings saved. A restart of Fail2ban is required.", + "settings.toast.load_error": "Error loading settings", + "settings.toast.fix_validation": "Please fix validation errors before saving", + "settings.toast.smtp_port_invalid": "SMTP port must be between 1 and 65535", + "settings.toast.save_error": "Error saving settings", + "settings.toast.saved_warnings": "Settings saved with warnings", + "settings.toast.test_email_error": "Error sending test email", + "settings.toast.test_email_success": "Test email sent successfully!", + "settings.toast.webhook_test_failed": "Webhook test failed", + "settings.toast.webhook_test_success": "Test webhook sent successfully!", + "settings.toast.es_test_failed": "Elasticsearch test failed", + "settings.toast.es_test_success": "Test document indexed successfully!", + "settings.toast.copy_failed": "Failed to copy to clipboard", + "settings.toast.block_log_error": "Error loading permanent block log", + "settings.toast.enter_ip": "Please enter an IP address.", + "settings.toast.advanced_action_failed": "Advanced action failed", + "settings.toast.action_completed": "Action completed", + "settings.toast.remove_ip_failed": "Failed to remove IP", + "settings.toast.ip_removed": "IP removed", + "settings.toast.invalid_ignore_ip": "Invalid IP address, CIDR, or hostname", "modal.filter_config": "Filter / Jail Configuration:", "modal.filter_config_edit": "Edit Filter / Jail", "modal.filter_config_label": "Filter Configuration", @@ -450,9 +481,29 @@ "modal.jail_filter": "Filter (optional)", "modal.jail_filter_hint": "Selecting a filter will auto-populate the jail configuration.", "modal.jail_name": "Jail Name", + "modal.delete_jail": "Delete Jail", "modal.jail_name_hint": "Only alphanumeric characters, dashes, and underscores are allowed.", "modal.test_logpath": "Test Logpath", "jails.logpath_test.found_files": "Found {count} file(s)", + "jails.toast.name_required": "Jail name is required", + "jails.toast.create_error": "Error creating jail", + "jails.toast.create_success": "Jail created successfully", + "jails.toast.delete_error": "Error deleting jail", + "jails.toast.delete_success": "Jail deleted successfully", + "jails.toast.save_config_error": "Error saving config", + "jails.toast.save_settings_error": "Error saving jail settings", + "jails.toast.enabled_success": "Jail {jail} enabled successfully", + "jails.toast.disabled_success": "Jail {jail} disabled successfully", + "jails.confirm.delete": "Are you sure you want to delete the jail \"{name}\"? This action cannot be undone.", + "jails.logpath_test.no_logpath": "No logpath found in jail configuration. Please add a logpath line (e.g., logpath = /var/log/example.log)", + "jails.logpath_test.testing": "Testing logpath...", + "jails.logpath_test.no_entries": "No logpath entries found.", + "jails.logpath_test.entry": "Logpath {num}:", + "jails.logpath_test.resolved": "Resolved:", + "jails.logpath_test.in_container": "In fail2ban-ui Container:", + "jails.logpath_test.on_remote": "On Remote Server:", + "jails.logpath_test.not_found_container": "Not found (logs may not be mounted to container)", + "jails.logpath_test.not_found": "Not found", "modal.local_server_logpath_note": "⚠️ Note:", "modal.local_server_logpath_text_prefix": "For a local fail2ban server (e.g. installed on container host system or in a container on same host), log files must also be mounted to the fail2ban-ui container (e.g.,", "modal.local_server_logpath_text_suffix": ") this is required so that the fail2ban-ui can verify logpath variables or paths when updating jails.", @@ -466,7 +517,18 @@ "modal.close": "Close", "loading": "Loading...", "dashboard.manage_jails": "Manage Jails", + "dashboard.ban.confirm": "Block IP {ip} in jail {jail}?", + "dashboard.toast.block_error": "Error blocking IP", + "dashboard.toast.unban_error": "Error unbanning IP", "modal.manage_jails_title": "Manage Jails", + "modal.toast.event_not_found": "Event not found", + "modal.toast.no_whois": "No whois data available for this event", + "modal.toast.whois_error": "Error loading whois data", + "modal.toast.no_logs": "No logs data available for this event", + "modal.toast.logs_error": "Error loading logs data", + "modal.toast.no_jails": "No jails found for this server.", + "modal.toast.fetch_jails_error": "Error fetching jails", + "modal.toast.load_config_error": "Error loading config", "servers.selector.label": "Active Server", "servers.selector.empty": "No servers configured", "servers.selector.none": "No server configured. Please add a Fail2ban server.", @@ -518,6 +580,7 @@ "servers.badge.restart_needed": "Restart required", "servers.actions.edit": "Edit", "servers.actions.set_default": "Set default", + "servers.actions.set_default_success": "Server set as default", "servers.actions.enable": "Enable", "servers.actions.disable": "Disable", "servers.actions.test": "Test connection", @@ -534,6 +597,7 @@ "servers.actions.reload_tooltip": "For local connectors, only a configuration reload is possible via the socket connection. The container cannot restart the Fail2ban service using systemctl. To perform a full restart, run 'systemctl restart fail2ban' directly on the host system.", "servers.actions.delete": "Delete", "servers.actions.delete_confirm": "Delete this server entry?", + "servers.actions.delete_success": "Server removed", "servers.form.select_key": "Select Private Key", "servers.form.select_key_placeholder": "Manual entry", "servers.form.no_keys": "No SSH keys found; enter path manually", @@ -545,6 +609,14 @@ "servers.card.socket_path": "Socket path", "servers.card.config_path": "Configuration path", "servers.card.server_id": "Server-ID", + "servers.toast.save_error": "Error saving server", + "servers.toast.delete_error": "Error deleting server", + "servers.toast.set_default_error": "Error setting default server", + "servers.toast.none_selected": "No server selected", + "servers.toast.restart_failed": "Failed to restart Fail2ban", + "servers.confirm.reload_local": "Reload Fail2ban configuration on this server now? This will reload the configuration without restarting the service.", + "servers.confirm.restart_remote": "Keep in mind that while fail2ban is restarting, logs are not being parsed and no IP addresses are blocked. Restart fail2ban on this server now? This will take some time.", + "servers.confirm.restart": "Keep in mind that while fail2ban is restarting, logs are not being parsed and no IP addresses are blocked. Restart fail2ban now? This will take some time.", "filter_debug.not_available": "Filter debug is only available when at least one registered Fail2ban server is enabled.", "filter_debug.local_missing": "The local Fail2ban filter directory was not found on this host.", "filter_debug.save_success": "Filter and jail config saved and reloaded", @@ -610,5 +682,22 @@ "auth.login_required": "Authentication required", "footer.version": "Fail2ban-UI v{version}", "footer.latest": "Latest", - "footer.update_available": "Update available: v{version}" + "footer.update_available": "Update available: v{version}", + "common.error": "Error", + "common.copy": "Copy", + "common.copied": "Copied!", + "filters.toast.name_required": "Filter name is required", + "filters.toast.create_error": "Error creating filter", + "filters.toast.create_success": "Filter created successfully", + "filters.toast.load_error": "Error loading filters", + "filters.toast.load_content_error": "Error loading filter content", + "filters.toast.select_delete": "Please select a filter to delete", + "filters.toast.delete_error": "Error deleting filter", + "filters.toast.delete_success": "Filter deleted successfully", + "filters.toast.select_filter": "Please select a filter.", + "filters.toast.enter_log_lines": "Please enter at least one log line to test.", + "filters.toast.test_error": "Error testing filter", + "filters.confirm.delete": "Are you sure you want to delete the filter \"{name}\"? This action cannot be undone.", + "globe.bans_singular": "ban", + "globe.bans_plural": "bans" } diff --git a/pkg/web/locales/es.json b/pkg/web/locales/es.json index 1c4639a..445999c 100644 --- a/pkg/web/locales/es.json +++ b/pkg/web/locales/es.json @@ -252,10 +252,12 @@ "settings.callback_secret": "Secret de URL de Callback de Fail2ban", "settings.callback_secret.show": "mostrar secreto", "settings.callback_secret.hide": "ocultar secreto", + "settings.callback_secret.hidden": "el secreto se guarda en el servidor y nunca se muestra", "settings.callback_secret_placeholder": "Secret de 42 caracteres generado automáticamente", "settings.callback_secret.description": "Este secret se genera automáticamente y se utiliza para autenticar las solicitudes de notificación de bloqueo. Está incluido en la configuración de acción de fail2ban.", - "settings.destination_email": "Correo electrónico de destino (receptor de alertas)", - "settings.destination_email_placeholder": "alerts@swissmakers.ch", + "settings.destination_email": "Correos electrónicos de destino (receptores de alertas)", + "settings.destination_email_placeholder": "alerts@swissmakers.ch, admin@example.com", + "settings.destination_email_hint": "Se pueden especificar varias direcciones de correo electrónico separadas por comas.", "settings.alert_countries": "Países para alerta", "settings.alert_countries.all": "TODOS (Todos los países)", "settings.alert_countries_description": "Selecciona los países para los que quieres reenviar eventos de bloqueo y/o desbloqueo con tu proveedor de alertas seleccionado. Establece en TODOS para no filtrar las alertas por país.", @@ -349,6 +351,14 @@ "settings.bantime_rndtime": "Bantime Rndtime", "settings.bantime_rndtime.description": "Opcional. Segundos aleatorios máximos en la fórmula de incremento de bantime (p.ej. 2048). Dejar vacío para usar el valor por defecto de Fail2ban.", "settings.bantime_rndtime_placeholder": "p.ej., 2048", + "settings.bantime_maxtime": "Bantime Maxtime", + "settings.bantime_maxtime.description": "Opcional. Limita cuánto pueden crecer los bloqueos escalados cuando el incremento de bantime está activado (p.ej. 5w). Dejar vacío para usar el valor por defecto de Fail2ban (sin límite).", + "settings.bantime_maxtime_placeholder": "p.ej., 5w", + "settings.bantime_factor": "Factor de Bantime", + "settings.bantime_factor.description": "Opcional. Multiplicador de la fórmula de incremento de bantime; valores más altos escalan más rápido los bloqueos de reincidentes (valor por defecto de Fail2ban: 1). Dejar vacío para usar el valor por defecto.", + "settings.bantime_factor_placeholder": "p.ej., 2", + "settings.bantime_overalljails": "Contar reincidencias en todos los jails", + "settings.bantime_overalljails.description": "Cuando el incremento de bantime está activado, los bloqueos anteriores de todos los jails (no solo el actual) se consideran al escalar el tiempo de bloqueo de una IP.", "settings.default_chain": "Chain por defecto", "settings.default_chain.description": "Chain de iptables/nftables para baneos (p.ej. INPUT para host, DOCKER-USER para contenedores Docker).", "settings.chain_help_title": "Chain por defecto", @@ -376,6 +386,8 @@ "settings.geoip_database_path.description": "Ruta al archivo de base de datos MaxMind GeoLite2-Country.", "settings.max_log_lines": "Líneas de Log Máximas", "settings.max_log_lines.description": "Número máximo de líneas de log a incluir en las notificaciones de bloqueo. Las líneas más relevantes se seleccionan automáticamente.", + "settings.event_retention_days": "Retención de eventos (días)", + "settings.event_retention_days.description": "Los eventos de bloqueo/desbloqueo más antiguos se eliminan automáticamente una vez al día. Establece 0 para conservar los eventos para siempre (la base de datos crecerá sin límite).", "settings.ignore_ips": "Ignorar IPs", "settings.ignore_ips.description": "Lista separada por espacios de direcciones IP, máscaras CIDR o hosts DNS. Fail2ban no bloqueará un host que coincida con una dirección en esta lista.", "settings.ignore_ips_placeholder": "IPs a ignorar, separadas por espacios", @@ -438,6 +450,25 @@ "settings.save": "Guardar", "settings.save_success_reloaded": "Configuración guardada y fail2ban recargado", "settings.save_success_restart_required": "Configuración guardada. Se requiere reiniciar fail2ban.", + "settings.toast.load_error": "Error al cargar la configuración", + "settings.toast.fix_validation": "Corrija los errores de validación antes de guardar", + "settings.toast.smtp_port_invalid": "El puerto SMTP debe estar entre 1 y 65535", + "settings.toast.save_error": "Error al guardar la configuración", + "settings.toast.saved_warnings": "Configuración guardada con advertencias", + "settings.toast.test_email_error": "Error al enviar el correo de prueba", + "settings.toast.test_email_success": "¡Correo de prueba enviado correctamente!", + "settings.toast.webhook_test_failed": "Falló la prueba del webhook", + "settings.toast.webhook_test_success": "¡Webhook de prueba enviado correctamente!", + "settings.toast.es_test_failed": "Falló la prueba de Elasticsearch", + "settings.toast.es_test_success": "¡Documento de prueba indexado correctamente!", + "settings.toast.copy_failed": "No se pudo copiar al portapapeles", + "settings.toast.block_log_error": "Error al cargar el registro de bloqueos permanentes", + "settings.toast.enter_ip": "Introduzca una dirección IP.", + "settings.toast.advanced_action_failed": "Falló la acción avanzada", + "settings.toast.action_completed": "Acción completada", + "settings.toast.remove_ip_failed": "No se pudo eliminar la IP", + "settings.toast.ip_removed": "IP eliminada", + "settings.toast.invalid_ignore_ip": "Dirección IP, CIDR o nombre de host no válidos", "modal.filter_config": "Configuración del filtro / Jail:", "modal.filter_config_edit": "Editar filtro / Jail", "modal.filter_config_label": "Configuración del filtro", @@ -450,9 +481,29 @@ "modal.jail_filter": "Filtro (opcional)", "modal.jail_filter_hint": "La selección de un filtro completará automáticamente la configuración del jail.", "modal.jail_name": "Nombre del jail", + "modal.delete_jail": "Eliminar jail", "modal.jail_name_hint": "Solo se permiten caracteres alfanuméricos, guiones y guiones bajos.", "modal.test_logpath": "Probar ruta de registro", "jails.logpath_test.found_files": "{count} archivo(s) encontrado(s)", + "jails.toast.name_required": "El nombre del jail es obligatorio", + "jails.toast.create_error": "Error al crear el jail", + "jails.toast.create_success": "Jail creado correctamente", + "jails.toast.delete_error": "Error al eliminar el jail", + "jails.toast.delete_success": "Jail eliminado correctamente", + "jails.toast.save_config_error": "Error al guardar la configuración", + "jails.toast.save_settings_error": "Error al guardar la configuración del jail", + "jails.toast.enabled_success": "Jail {jail} activado correctamente", + "jails.toast.disabled_success": "Jail {jail} desactivado correctamente", + "jails.confirm.delete": "¿Seguro que desea eliminar el jail \"{name}\"? Esta acción no se puede deshacer.", + "jails.logpath_test.no_logpath": "No se encontró ningún logpath en la configuración del jail. Añada una línea logpath (p.ej. logpath = /var/log/example.log)", + "jails.logpath_test.testing": "Probando logpath...", + "jails.logpath_test.no_entries": "No se encontraron entradas logpath.", + "jails.logpath_test.entry": "Logpath {num}:", + "jails.logpath_test.resolved": "Resuelto:", + "jails.logpath_test.in_container": "En el contenedor de fail2ban-ui:", + "jails.logpath_test.on_remote": "En el servidor remoto:", + "jails.logpath_test.not_found_container": "No encontrado (puede que los registros no estén montados en el contenedor)", + "jails.logpath_test.not_found": "No encontrado", "modal.local_server_logpath_note": "⚠️ Nota:", "modal.local_server_logpath_text_prefix": "Para servidores locales Fail2ban (p. ej. instalado en el sistema contenedor host o en un contenedor en el mismo host), los archivos de registro también deben montarse en el contenedor fail2ban-ui (p. ej.,", "modal.local_server_logpath_text_suffix": ") esto es necesario para que fail2ban-ui pueda verificar variables o rutas de logpath cuando se actualizan jails.", @@ -466,7 +517,18 @@ "modal.close": "Cerrar", "loading": "Cargando...", "dashboard.manage_jails": "Administrar jails", + "dashboard.ban.confirm": "¿Bloquear la IP {ip} en el jail {jail}?", + "dashboard.toast.block_error": "Error al bloquear la IP", + "dashboard.toast.unban_error": "Error al desbloquear la IP", "modal.manage_jails_title": "Administrar jails", + "modal.toast.event_not_found": "Evento no encontrado", + "modal.toast.no_whois": "No hay datos whois disponibles para este evento", + "modal.toast.whois_error": "Error al cargar los datos whois", + "modal.toast.no_logs": "No hay registros disponibles para este evento", + "modal.toast.logs_error": "Error al cargar los registros", + "modal.toast.no_jails": "No se encontraron jails para este servidor.", + "modal.toast.fetch_jails_error": "Error al obtener los jails", + "modal.toast.load_config_error": "Error al cargar la configuración", "servers.selector.label": "Servidor activo", "servers.selector.empty": "No hay servidores configurados", "servers.selector.none": "No hay servidor configurado. Añade un servidor Fail2ban.", @@ -515,6 +577,7 @@ "servers.badge.restart_needed": "Reinicio requerido", "servers.actions.edit": "Editar", "servers.actions.set_default": "Establecer predeterminado", + "servers.actions.set_default_success": "Servidor establecido como predeterminado", "servers.actions.enable": "Habilitar", "servers.actions.disable": "Deshabilitar", "servers.actions.test": "Probar conexión", @@ -531,6 +594,7 @@ "servers.actions.reload_tooltip": "Para los conectores locales, solo es posible recargar la configuración a través de la conexión socket. El contenedor no puede reiniciar el servicio Fail2ban usando systemctl. Para realizar un reinicio completo, ejecute 'systemctl restart fail2ban' directamente en el sistema host.", "servers.actions.delete": "Eliminar", "servers.actions.delete_confirm": "¿Eliminar este servidor?", + "servers.actions.delete_success": "Servidor eliminado", "servers.form.select_key": "Seleccionar clave privada", "servers.form.select_key_placeholder": "Entrada manual", "servers.form.no_keys": "No se encontraron claves SSH; introduzca la ruta manualmente", @@ -545,6 +609,14 @@ "servers.card.socket_path": "Ruta del socket", "servers.card.config_path": "Ruta de configuración", "servers.card.server_id": "ID del servidor", + "servers.toast.save_error": "Error al guardar el servidor", + "servers.toast.delete_error": "Error al eliminar el servidor", + "servers.toast.set_default_error": "Error al establecer el servidor predeterminado", + "servers.toast.none_selected": "Ningún servidor seleccionado", + "servers.toast.restart_failed": "No se pudo reiniciar Fail2ban", + "servers.confirm.reload_local": "¿Recargar ahora la configuración de Fail2ban en este servidor? Se recargará la configuración sin reiniciar el servicio.", + "servers.confirm.restart_remote": "Tenga en cuenta que mientras fail2ban se reinicia no se analizan registros ni se bloquean direcciones IP. ¿Reiniciar ahora fail2ban en este servidor? Esto tardará un poco.", + "servers.confirm.restart": "Tenga en cuenta que mientras fail2ban se reinicia no se analizan registros ni se bloquean direcciones IP. ¿Reiniciar ahora fail2ban? Esto tardará un poco.", "filter_debug.not_available": "La depuración de filtros solo está disponible cuando al menos un servidor Fail2ban registrado está activado.", "filter_debug.local_missing": "No se encontró el directorio de filtros local de Fail2ban en este host.", "filter_debug.save_success": "Configuración de filtro y jail guardada y Fail2ban recargado", @@ -610,5 +682,22 @@ "auth.login_required": "Autenticación requerida", "footer.version": "Fail2ban-UI v{version}", "footer.latest": "Actual", - "footer.update_available": "Actualización disponible: v{version}" + "footer.update_available": "Actualización disponible: v{version}", + "common.error": "Error", + "common.copy": "Copiar", + "common.copied": "¡Copiado!", + "filters.toast.name_required": "El nombre del filtro es obligatorio", + "filters.toast.create_error": "Error al crear el filtro", + "filters.toast.create_success": "Filtro creado correctamente", + "filters.toast.load_error": "Error al cargar los filtros", + "filters.toast.load_content_error": "Error al cargar el contenido del filtro", + "filters.toast.select_delete": "Seleccione un filtro para eliminar", + "filters.toast.delete_error": "Error al eliminar el filtro", + "filters.toast.delete_success": "Filtro eliminado correctamente", + "filters.toast.select_filter": "Seleccione un filtro.", + "filters.toast.enter_log_lines": "Introduzca al menos una línea de registro para probar.", + "filters.toast.test_error": "Error al probar el filtro", + "filters.confirm.delete": "¿Seguro que desea eliminar el filtro \"{name}\"? Esta acción no se puede deshacer.", + "globe.bans_singular": "bloqueo", + "globe.bans_plural": "bloqueos" } diff --git a/pkg/web/locales/fr.json b/pkg/web/locales/fr.json index 79ce3a8..261104a 100644 --- a/pkg/web/locales/fr.json +++ b/pkg/web/locales/fr.json @@ -252,10 +252,12 @@ "settings.callback_secret": "Secret d'URL de Callback Fail2ban", "settings.callback_secret.show": "afficher le secret", "settings.callback_secret.hide": "masquer le secret", + "settings.callback_secret.hidden": "le secret est stocké sur le serveur et n'est jamais affiché", "settings.callback_secret_placeholder": "Secret de 42 caractères généré automatiquement", "settings.callback_secret.description": "Ce secret est généré automatiquement et utilisé pour authentifier les demandes de notification de bannissement. Il est inclus dans la configuration d'action de fail2ban.", - "settings.destination_email": "Email de destination (récepteur des alertes)", - "settings.destination_email_placeholder": "alerts@swissmakers.ch", + "settings.destination_email": "Emails de destination (récepteurs des alertes)", + "settings.destination_email_placeholder": "alerts@swissmakers.ch, admin@example.com", + "settings.destination_email_hint": "Plusieurs adresses email peuvent être spécifiées en les séparant par des virgules.", "settings.alert_countries": "Pays d'alerte", "settings.alert_countries.all": "TOUS (Tous les pays)", "settings.alert_countries_description": "Sélectionnez les pays pour lesquels vous souhaitez transmettre les événements de bannissement et/ou de débannissement avec votre fournisseur d'alerte sélectionné. Définissez sur TOUS pour ne pas filtrer les alertes par pays.", @@ -349,6 +351,14 @@ "settings.bantime_rndtime": "Bantime Rndtime", "settings.bantime_rndtime.description": "Optionnel. Nombre maximal de secondes aléatoires dans la formule d'incrément de bantime (ex. 2048). Laisser vide pour utiliser la valeur par défaut de Fail2ban.", "settings.bantime_rndtime_placeholder": "par ex., 2048", + "settings.bantime_maxtime": "Bantime Maxtime", + "settings.bantime_maxtime.description": "Optionnel. Limite la durée maximale des blocages progressifs lorsque l'incrément de bantime est activé (ex. 5w). Laisser vide pour utiliser la valeur par défaut de Fail2ban (aucune limite).", + "settings.bantime_maxtime_placeholder": "ex. 5w", + "settings.bantime_factor": "Facteur de Bantime", + "settings.bantime_factor.description": "Optionnel. Multiplicateur de la formule d'incrément de bantime ; des valeurs plus élevées font croître plus vite les blocages des récidivistes (valeur par défaut de Fail2ban : 1). Laisser vide pour utiliser la valeur par défaut.", + "settings.bantime_factor_placeholder": "ex. 2", + "settings.bantime_overalljails": "Compter les récidives sur toutes les jails", + "settings.bantime_overalljails.description": "Lorsque l'incrément de bantime est activé, les blocages précédents de toutes les jails (pas seulement la jail actuelle) sont pris en compte pour prolonger la durée de blocage d'une IP.", "settings.default_chain": "Chain par défaut", "settings.default_chain.description": "Chain iptables/nftables pour les bannissements (ex. INPUT pour l'hôte, DOCKER-USER pour les conteneurs Docker).", "settings.chain_help_title": "Chain par défaut", @@ -376,6 +386,8 @@ "settings.geoip_database_path.description": "Chemin vers le fichier de base de données MaxMind GeoLite2-Country.", "settings.max_log_lines": "Lignes de Log Maximales", "settings.max_log_lines.description": "Nombre maximal de lignes de log à inclure dans les notifications de bannissement. Les lignes les plus pertinentes sont sélectionnées automatiquement.", + "settings.event_retention_days": "Rétention des événements (jours)", + "settings.event_retention_days.description": "Les événements de bannissement/débannissement plus anciens sont supprimés automatiquement une fois par jour. Mettre 0 pour conserver les événements indéfiniment (la base de données grossira sans limite).", "settings.ignore_ips": "Ignorer les IPs", "settings.ignore_ips.description": "Liste séparée par des espaces d'adresses IP, de masques CIDR ou d'hôtes DNS. Fail2ban ne bannira pas un hôte qui correspond à une adresse de cette liste.", "settings.ignore_ips_placeholder": "IPs à ignorer, séparées par des espaces", @@ -438,6 +450,25 @@ "settings.save": "Enregistrer", "settings.save_success_reloaded": "Paramètres enregistrés et fail2ban rechargé", "settings.save_success_restart_required": "Paramètres enregistrés. Redémarrage de fail2ban requis.", + "settings.toast.load_error": "Erreur lors du chargement des paramètres", + "settings.toast.fix_validation": "Corrigez les erreurs de validation avant d'enregistrer", + "settings.toast.smtp_port_invalid": "Le port SMTP doit être compris entre 1 et 65535", + "settings.toast.save_error": "Erreur lors de l'enregistrement des paramètres", + "settings.toast.saved_warnings": "Paramètres enregistrés avec des avertissements", + "settings.toast.test_email_error": "Erreur lors de l'envoi de l'e-mail de test", + "settings.toast.test_email_success": "E-mail de test envoyé avec succès !", + "settings.toast.webhook_test_failed": "Échec du test du webhook", + "settings.toast.webhook_test_success": "Webhook de test envoyé avec succès !", + "settings.toast.es_test_failed": "Échec du test Elasticsearch", + "settings.toast.es_test_success": "Document de test indexé avec succès !", + "settings.toast.copy_failed": "Échec de la copie dans le presse-papiers", + "settings.toast.block_log_error": "Erreur lors du chargement du journal des blocages permanents", + "settings.toast.enter_ip": "Veuillez saisir une adresse IP.", + "settings.toast.advanced_action_failed": "Échec de l'action avancée", + "settings.toast.action_completed": "Action terminée", + "settings.toast.remove_ip_failed": "Échec de la suppression de l'IP", + "settings.toast.ip_removed": "IP supprimée", + "settings.toast.invalid_ignore_ip": "Adresse IP, CIDR ou nom d'hôte invalide", "modal.filter_config": "Configuration du filtre / Jail:", "modal.filter_config_edit": "Modifier le filtre / Jail", "modal.filter_config_label": "Configuration du filtre", @@ -450,9 +481,29 @@ "modal.jail_filter": "Filtre (optionnel)", "modal.jail_filter_hint": "La sélection d'un filtre remplira automatiquement la configuration du jail.", "modal.jail_name": "Nom du jail", + "modal.delete_jail": "Supprimer la jail", "modal.jail_name_hint": "Seuls les caractères alphanumériques, les tirets et les underscores sont autorisés.", "modal.test_logpath": "Tester le chemin de journal", "jails.logpath_test.found_files": "{count} fichier(s) trouvé(s)", + "jails.toast.name_required": "Le nom de la jail est requis", + "jails.toast.create_error": "Erreur lors de la création de la jail", + "jails.toast.create_success": "Jail créée avec succès", + "jails.toast.delete_error": "Erreur lors de la suppression de la jail", + "jails.toast.delete_success": "Jail supprimée avec succès", + "jails.toast.save_config_error": "Erreur lors de l'enregistrement de la configuration", + "jails.toast.save_settings_error": "Erreur lors de l'enregistrement des paramètres de la jail", + "jails.toast.enabled_success": "Jail {jail} activée avec succès", + "jails.toast.disabled_success": "Jail {jail} désactivée avec succès", + "jails.confirm.delete": "Voulez-vous vraiment supprimer la jail \"{name}\" ? Cette action est irréversible.", + "jails.logpath_test.no_logpath": "Aucun logpath trouvé dans la configuration de la jail. Ajoutez une ligne logpath (ex. logpath = /var/log/example.log)", + "jails.logpath_test.testing": "Test du logpath en cours...", + "jails.logpath_test.no_entries": "Aucune entrée logpath trouvée.", + "jails.logpath_test.entry": "Logpath {num} :", + "jails.logpath_test.resolved": "Résolu :", + "jails.logpath_test.in_container": "Dans le conteneur fail2ban-ui :", + "jails.logpath_test.on_remote": "Sur le serveur distant :", + "jails.logpath_test.not_found_container": "Introuvable (les journaux ne sont peut-être pas montés dans le conteneur)", + "jails.logpath_test.not_found": "Introuvable", "modal.local_server_logpath_note": "⚠️ Note:", "modal.local_server_logpath_text_prefix": "Pour les serveurs locaux Fail2ban (p. ex. installé sur le système hôte ou dans un conteneur sur le même hôte), les fichiers de journal doivent également être montés dans le conteneur fail2ban-ui (par ex.,", "modal.local_server_logpath_text_suffix": ") ceci est nécessaire pour que fail2ban-ui puisse vérifier les variables ou chemins de logpath lors de la mise à jour des jails.", @@ -466,7 +517,18 @@ "modal.close": "Fermer", "loading": "Chargement...", "dashboard.manage_jails": "Gérer les jails", + "dashboard.ban.confirm": "Bloquer l'IP {ip} dans la jail {jail} ?", + "dashboard.toast.block_error": "Erreur lors du blocage de l'IP", + "dashboard.toast.unban_error": "Erreur lors du déblocage de l'IP", "modal.manage_jails_title": "Gérer les jails", + "modal.toast.event_not_found": "Événement introuvable", + "modal.toast.no_whois": "Aucune donnée whois disponible pour cet événement", + "modal.toast.whois_error": "Erreur lors du chargement des données whois", + "modal.toast.no_logs": "Aucun journal disponible pour cet événement", + "modal.toast.logs_error": "Erreur lors du chargement des journaux", + "modal.toast.no_jails": "Aucune jail trouvée pour ce serveur.", + "modal.toast.fetch_jails_error": "Erreur lors de la récupération des jails", + "modal.toast.load_config_error": "Erreur lors du chargement de la configuration", "servers.selector.label": "Serveur actif", "servers.selector.empty": "Aucun serveur configuré", "servers.selector.none": "Aucun serveur configuré. Veuillez ajouter un serveur Fail2ban.", @@ -515,6 +577,7 @@ "servers.badge.restart_needed": "Redémarrage requis", "servers.actions.edit": "Modifier", "servers.actions.set_default": "Définir par défaut", + "servers.actions.set_default_success": "Serveur défini par défaut", "servers.actions.enable": "Activer", "servers.actions.disable": "Désactiver", "servers.actions.test": "Tester la connexion", @@ -531,6 +594,7 @@ "servers.actions.reload_tooltip": "Pour les connecteurs locaux, seul un rechargement de la configuration est possible via la connexion socket. Le conteneur ne peut pas redémarrer le service Fail2ban en utilisant systemctl. Pour effectuer un redémarrage complet, exécutez 'systemctl restart fail2ban' directement sur le système hôte.", "servers.actions.delete": "Supprimer", "servers.actions.delete_confirm": "Supprimer ce serveur ?", + "servers.actions.delete_success": "Serveur supprimé", "servers.form.select_key": "Sélectionner la clé privée", "servers.form.select_key_placeholder": "Saisie manuelle", "servers.form.no_keys": "Aucune clé SSH trouvée ; saisissez le chemin manuellement", @@ -545,6 +609,14 @@ "servers.card.socket_path": "Chemin du socket", "servers.card.config_path": "Chemin de configuration", "servers.card.server_id": "ID du serveur", + "servers.toast.save_error": "Erreur lors de l'enregistrement du serveur", + "servers.toast.delete_error": "Erreur lors de la suppression du serveur", + "servers.toast.set_default_error": "Erreur lors de la définition du serveur par défaut", + "servers.toast.none_selected": "Aucun serveur sélectionné", + "servers.toast.restart_failed": "Échec du redémarrage de Fail2ban", + "servers.confirm.reload_local": "Recharger la configuration de Fail2ban sur ce serveur maintenant ? La configuration sera rechargée sans redémarrer le service.", + "servers.confirm.restart_remote": "Notez que pendant le redémarrage de fail2ban, les journaux ne sont pas analysés et aucune adresse IP n'est bloquée. Redémarrer fail2ban sur ce serveur maintenant ? Cela prendra un peu de temps.", + "servers.confirm.restart": "Notez que pendant le redémarrage de fail2ban, les journaux ne sont pas analysés et aucune adresse IP n'est bloquée. Redémarrer fail2ban maintenant ? Cela prendra un peu de temps.", "filter_debug.not_available": "Le débogage des filtres n'est disponible que lorsqu'au moins un serveur Fail2ban enregistré est activé.", "filter_debug.local_missing": "Le répertoire de filtres Fail2ban local est introuvable sur cet hôte.", "filter_debug.save_success": "Configuration de filtre et jail enregistrée et Fail2ban rechargé", @@ -610,5 +682,22 @@ "auth.login_required": "Authentification requise", "footer.version": "Fail2ban-UI v{version}", "footer.latest": "À jour", - "footer.update_available": "Mise à jour disponible : v{version}" + "footer.update_available": "Mise à jour disponible : v{version}", + "common.error": "Erreur", + "common.copy": "Copier", + "common.copied": "Copié !", + "filters.toast.name_required": "Le nom du filtre est requis", + "filters.toast.create_error": "Erreur lors de la création du filtre", + "filters.toast.create_success": "Filtre créé avec succès", + "filters.toast.load_error": "Erreur lors du chargement des filtres", + "filters.toast.load_content_error": "Erreur lors du chargement du contenu du filtre", + "filters.toast.select_delete": "Veuillez sélectionner un filtre à supprimer", + "filters.toast.delete_error": "Erreur lors de la suppression du filtre", + "filters.toast.delete_success": "Filtre supprimé avec succès", + "filters.toast.select_filter": "Veuillez sélectionner un filtre.", + "filters.toast.enter_log_lines": "Veuillez saisir au moins une ligne de journal à tester.", + "filters.toast.test_error": "Erreur lors du test du filtre", + "filters.confirm.delete": "Voulez-vous vraiment supprimer le filtre \"{name}\" ? Cette action est irréversible.", + "globe.bans_singular": "blocage", + "globe.bans_plural": "blocages" } diff --git a/pkg/web/locales/it.json b/pkg/web/locales/it.json index de1474f..5ba8304 100644 --- a/pkg/web/locales/it.json +++ b/pkg/web/locales/it.json @@ -252,10 +252,12 @@ "settings.callback_secret": "Secret URL di Callback Fail2ban", "settings.callback_secret.show": "mostra segreto", "settings.callback_secret.hide": "nascondi segreto", + "settings.callback_secret.hidden": "il segreto è salvato sul server e non viene mai mostrato", "settings.callback_secret_placeholder": "Secret di 42 caratteri generato automaticamente", "settings.callback_secret.description": "Questo secret viene generato automaticamente e utilizzato per autenticare le richieste di notifica di ban. È incluso nella configurazione dell'azione di fail2ban.", - "settings.destination_email": "Email di destinazione (ricevente allarmi)", - "settings.destination_email_placeholder": "alerts@swissmakers.ch", + "settings.destination_email": "Email di destinazione (riceventi allarmi)", + "settings.destination_email_placeholder": "alerts@swissmakers.ch, admin@example.com", + "settings.destination_email_hint": "È possibile specificare più indirizzi email separandoli con virgole.", "settings.alert_countries": "Paesi per allarme", "settings.alert_countries.all": "TUTTI (Ogni paese)", "settings.alert_countries_description": "Seleziona i paesi per i quali vuoi inoltrare gli eventi di ban e/o unban con il tuo provider di allarmi selezionato. Imposta su TUTTI per non filtrare le alerta per paese.", @@ -349,6 +351,14 @@ "settings.bantime_rndtime": "Bantime Rndtime", "settings.bantime_rndtime.description": "Opzionale. Secondi casuali massimi nella formula di incremento bantime (es. 2048). Lasciare vuoto per usare il default di Fail2ban.", "settings.bantime_rndtime_placeholder": "es. 2048", + "settings.bantime_maxtime": "Bantime Maxtime", + "settings.bantime_maxtime.description": "Opzionale. Limita quanto possono crescere i blocchi progressivi quando l'incremento bantime è attivo (es. 5w). Lasciare vuoto per usare il default di Fail2ban (nessun limite).", + "settings.bantime_maxtime_placeholder": "es. 5w", + "settings.bantime_factor": "Fattore Bantime", + "settings.bantime_factor.description": "Opzionale. Moltiplicatore della formula di incremento bantime; valori più alti aumentano più rapidamente i blocchi dei recidivi (default di Fail2ban: 1). Lasciare vuoto per usare il default.", + "settings.bantime_factor_placeholder": "es. 2", + "settings.bantime_overalljails": "Conta le recidive su tutti i jail", + "settings.bantime_overalljails.description": "Quando l'incremento bantime è attivo, i blocchi precedenti di tutti i jail (non solo quello attuale) vengono considerati nell'aumentare il tempo di blocco di un IP.", "settings.default_chain": "Chain predefinita", "settings.default_chain.description": "Chain iptables/nftables per i ban (es. INPUT per l'host, DOCKER-USER per i container Docker).", "settings.chain_help_title": "Chain predefinita", @@ -376,6 +386,8 @@ "settings.geoip_database_path.description": "Percorso al file del database MaxMind GeoLite2-Country.", "settings.max_log_lines": "Righe di Log Massime", "settings.max_log_lines.description": "Numero massimo di righe di log da includere nelle notifiche di ban. Le righe più rilevanti vengono selezionate automaticamente.", + "settings.event_retention_days": "Conservazione eventi (giorni)", + "settings.event_retention_days.description": "Gli eventi di ban/unban più vecchi vengono eliminati automaticamente una volta al giorno. Impostare 0 per conservare gli eventi per sempre (il database crescerà senza limiti).", "settings.ignore_ips": "Ignora IP", "settings.ignore_ips.description": "Elenco separato da spazi di indirizzi IP, maschere CIDR o host DNS. Fail2ban non bannerà un host che corrisponde a un indirizzo in questo elenco.", "settings.ignore_ips_placeholder": "IP da ignorare, separate da spazi", @@ -438,6 +450,25 @@ "settings.save": "Salva", "settings.save_success_reloaded": "Impostazioni salvate e fail2ban ricaricato", "settings.save_success_restart_required": "Impostazioni salvate. Riavvio di fail2ban richiesto.", + "settings.toast.load_error": "Errore durante il caricamento delle impostazioni", + "settings.toast.fix_validation": "Correggere gli errori di validazione prima di salvare", + "settings.toast.smtp_port_invalid": "La porta SMTP deve essere compresa tra 1 e 65535", + "settings.toast.save_error": "Errore durante il salvataggio delle impostazioni", + "settings.toast.saved_warnings": "Impostazioni salvate con avvisi", + "settings.toast.test_email_error": "Errore durante l'invio dell'e-mail di prova", + "settings.toast.test_email_success": "E-mail di prova inviata con successo!", + "settings.toast.webhook_test_failed": "Test del webhook non riuscito", + "settings.toast.webhook_test_success": "Webhook di prova inviato con successo!", + "settings.toast.es_test_failed": "Test di Elasticsearch non riuscito", + "settings.toast.es_test_success": "Documento di prova indicizzato con successo!", + "settings.toast.copy_failed": "Copia negli appunti non riuscita", + "settings.toast.block_log_error": "Errore durante il caricamento del registro dei blocchi permanenti", + "settings.toast.enter_ip": "Inserire un indirizzo IP.", + "settings.toast.advanced_action_failed": "Azione avanzata non riuscita", + "settings.toast.action_completed": "Azione completata", + "settings.toast.remove_ip_failed": "Impossibile rimuovere l'IP", + "settings.toast.ip_removed": "IP rimosso", + "settings.toast.invalid_ignore_ip": "Indirizzo IP, CIDR o hostname non valido", "modal.filter_config": "Configurazione del filtro / Jail:", "modal.filter_config_edit": "Modifica filtro / Jail", "modal.filter_config_label": "Configurazione del filtro", @@ -450,9 +481,29 @@ "modal.jail_filter": "Filtro (opzionale)", "modal.jail_filter_hint": "La selezione di un filtro compila automaticamente la configurazione del jail.", "modal.jail_name": "Nome del jail", + "modal.delete_jail": "Elimina jail", "modal.jail_name_hint": "Sono consentiti solo caratteri alfanumerici, trattini e underscore.", "modal.test_logpath": "Testa il percorso del log", "jails.logpath_test.found_files": "Trovati {count} file", + "jails.toast.name_required": "Il nome del jail è obbligatorio", + "jails.toast.create_error": "Errore durante la creazione del jail", + "jails.toast.create_success": "Jail creato con successo", + "jails.toast.delete_error": "Errore durante l'eliminazione del jail", + "jails.toast.delete_success": "Jail eliminato con successo", + "jails.toast.save_config_error": "Errore durante il salvataggio della configurazione", + "jails.toast.save_settings_error": "Errore durante il salvataggio delle impostazioni del jail", + "jails.toast.enabled_success": "Jail {jail} attivato con successo", + "jails.toast.disabled_success": "Jail {jail} disattivato con successo", + "jails.confirm.delete": "Eliminare davvero il jail \"{name}\"? Questa azione non può essere annullata.", + "jails.logpath_test.no_logpath": "Nessun logpath trovato nella configurazione del jail. Aggiungere una riga logpath (es. logpath = /var/log/example.log)", + "jails.logpath_test.testing": "Test del logpath in corso...", + "jails.logpath_test.no_entries": "Nessuna voce logpath trovata.", + "jails.logpath_test.entry": "Logpath {num}:", + "jails.logpath_test.resolved": "Risolto:", + "jails.logpath_test.in_container": "Nel container fail2ban-ui:", + "jails.logpath_test.on_remote": "Sul server remoto:", + "jails.logpath_test.not_found_container": "Non trovato (i log potrebbero non essere montati nel container)", + "jails.logpath_test.not_found": "Non trovato", "modal.local_server_logpath_note": "⚠️ Nota:", "modal.local_server_logpath_text_prefix": "Per i server locali Fail2ban (ad es. installato sul sistema host o in un contenitore sullo stesso host), i file di log devono essere montati anche nel contenitore fail2ban-ui (ad es.,", "modal.local_server_logpath_text_suffix": ") questo è necessario per permettere a fail2ban-ui di verificare variabili o percorsi di logpath quando si aggiornano i jails.", @@ -466,7 +517,18 @@ "modal.close": "Chiudi", "loading": "Caricamento...", "dashboard.manage_jails": "Gestire i jails", + "dashboard.ban.confirm": "Bloccare l'IP {ip} nel jail {jail}?", + "dashboard.toast.block_error": "Errore durante il blocco dell'IP", + "dashboard.toast.unban_error": "Errore durante lo sblocco dell'IP", "modal.manage_jails_title": "Gestire i jails", + "modal.toast.event_not_found": "Evento non trovato", + "modal.toast.no_whois": "Nessun dato whois disponibile per questo evento", + "modal.toast.whois_error": "Errore durante il caricamento dei dati whois", + "modal.toast.no_logs": "Nessun log disponibile per questo evento", + "modal.toast.logs_error": "Errore durante il caricamento dei log", + "modal.toast.no_jails": "Nessun jail trovato per questo server.", + "modal.toast.fetch_jails_error": "Errore durante il recupero dei jail", + "modal.toast.load_config_error": "Errore durante il caricamento della configurazione", "servers.selector.label": "Server attivo", "servers.selector.empty": "Nessun server configurato", "servers.selector.none": "Nessun server configurato. Aggiungi un server Fail2ban.", @@ -515,6 +577,7 @@ "servers.badge.restart_needed": "Riavvio richiesto", "servers.actions.edit": "Modifica", "servers.actions.set_default": "Imposta predefinito", + "servers.actions.set_default_success": "Server impostato come predefinito", "servers.actions.enable": "Abilita", "servers.actions.disable": "Disabilita", "servers.actions.test": "Verifica connessione", @@ -531,6 +594,7 @@ "servers.actions.reload_tooltip": "Per i connettori locali, è possibile solo un ricaricamento della configurazione tramite la connessione socket. Il contenitore non può riavviare il servizio Fail2ban utilizzando systemctl. Per eseguire un riavvio completo, eseguire 'systemctl restart fail2ban' direttamente sul sistema host.", "servers.actions.delete": "Elimina", "servers.actions.delete_confirm": "Eliminare questo server?", + "servers.actions.delete_success": "Server rimosso", "servers.form.select_key": "Seleziona chiave privata", "servers.form.select_key_placeholder": "Inserimento manuale", "servers.form.no_keys": "Nessuna chiave SSH trovata; inserire il percorso manualmente", @@ -545,6 +609,14 @@ "servers.card.socket_path": "Percorso socket", "servers.card.config_path": "Percorso configurazione", "servers.card.server_id": "ID server", + "servers.toast.save_error": "Errore durante il salvataggio del server", + "servers.toast.delete_error": "Errore durante l'eliminazione del server", + "servers.toast.set_default_error": "Errore durante l'impostazione del server predefinito", + "servers.toast.none_selected": "Nessun server selezionato", + "servers.toast.restart_failed": "Impossibile riavviare Fail2ban", + "servers.confirm.reload_local": "Ricaricare ora la configurazione di Fail2ban su questo server? La configurazione verrà ricaricata senza riavviare il servizio.", + "servers.confirm.restart_remote": "Attenzione: durante il riavvio di fail2ban i log non vengono analizzati e nessun indirizzo IP viene bloccato. Riavviare ora fail2ban su questo server? Richiederà un po' di tempo.", + "servers.confirm.restart": "Attenzione: durante il riavvio di fail2ban i log non vengono analizzati e nessun indirizzo IP viene bloccato. Riavviare ora fail2ban? Richiederà un po' di tempo.", "filter_debug.not_available": "Il debug dei filtri è disponibile solo quando almeno un server Fail2ban registrato è attivato.", "filter_debug.local_missing": "La directory dei filtri Fail2ban locale non è stata trovata su questo host.", "filter_debug.save_success": "Configurazione di filtro e jail salvata e Fail2ban ricaricato", @@ -610,5 +682,22 @@ "auth.login_required": "Autenticazione richiesta", "footer.version": "Fail2ban-UI v{version}", "footer.latest": "Aggiornato", - "footer.update_available": "Aggiornamento disponibile: v{version}" + "footer.update_available": "Aggiornamento disponibile: v{version}", + "common.error": "Errore", + "common.copy": "Copia", + "common.copied": "Copiato!", + "filters.toast.name_required": "Il nome del filtro è obbligatorio", + "filters.toast.create_error": "Errore durante la creazione del filtro", + "filters.toast.create_success": "Filtro creato con successo", + "filters.toast.load_error": "Errore durante il caricamento dei filtri", + "filters.toast.load_content_error": "Errore durante il caricamento del contenuto del filtro", + "filters.toast.select_delete": "Selezionare un filtro da eliminare", + "filters.toast.delete_error": "Errore durante l'eliminazione del filtro", + "filters.toast.delete_success": "Filtro eliminato con successo", + "filters.toast.select_filter": "Selezionare un filtro.", + "filters.toast.enter_log_lines": "Inserire almeno una riga di log da testare.", + "filters.toast.test_error": "Errore durante il test del filtro", + "filters.confirm.delete": "Eliminare davvero il filtro \"{name}\"? Questa azione non può essere annullata.", + "globe.bans_singular": "blocco", + "globe.bans_plural": "blocchi" } diff --git a/pkg/web/locales/ja.json b/pkg/web/locales/ja.json index 537520d..3efe166 100644 --- a/pkg/web/locales/ja.json +++ b/pkg/web/locales/ja.json @@ -252,10 +252,12 @@ "settings.callback_secret": "Fail2ban コールバックURLシークレット", "settings.callback_secret.show": "シークレットを表示", "settings.callback_secret.hide": "シークレットを非表示", + "settings.callback_secret.hidden": "シークレットはサーバーに保存され、表示されることはありません", "settings.callback_secret_placeholder": "自動生成された42文字のシークレット", "settings.callback_secret.description": "このシークレットはブロックAPIリクエストの認証に使用されます。fail2banアクション設定に自動的に追加されます。", "settings.destination_email": "送信先メール(アラート受信者)", - "settings.destination_email_placeholder": "alerts@swissmakers.ch", + "settings.destination_email_placeholder": "alerts@swissmakers.ch, admin@example.com", + "settings.destination_email_hint": "カンマ区切りで複数のメールアドレスを指定できます。", "settings.alert_countries": "アラート対象国", "settings.alert_countries.all": "ALL(すべての国)", "settings.alert_countries_description": "選択したアラートプロバイダーに対してブロックおよび/またはアンブロックイベントを転送する国を選択します。ALLを選択すると国によるフィルタリングを無効にします。", @@ -349,6 +351,14 @@ "settings.bantime_rndtime": "バンタイムランダム時間", "settings.bantime_rndtime.description": "任意。バンタイム増加式に加算される最大ランダム秒数(例: 2048)。空欄にするとFail2banデフォルトを使用。", "settings.bantime_rndtime_placeholder": "例: 2048", + "settings.bantime_maxtime": "バンタイム最大値 (maxtime)", + "settings.bantime_maxtime.description": "任意。バンタイム増加が有効な場合に、段階的に延長されるブロックの上限を設定します(例: 5w)。空欄にするとFail2banデフォルト(上限なし)を使用。", + "settings.bantime_maxtime_placeholder": "例: 5w", + "settings.bantime_factor": "バンタイム係数 (factor)", + "settings.bantime_factor.description": "任意。バンタイム増加式で使用される乗数。値が大きいほど再犯IPのブロック時間が速く延長されます(Fail2banデフォルト: 1)。空欄にするとデフォルトを使用。", + "settings.bantime_factor_placeholder": "例: 2", + "settings.bantime_overalljails": "全Jailを対象に再犯をカウント", + "settings.bantime_overalljails.description": "バンタイム増加が有効な場合、IPのブロック時間を延長する際に、現在のJailだけでなくすべてのJailの過去のブロックが考慮されます。", "settings.default_chain": "デフォルトチェーン", "settings.default_chain.description": "ブロックに使用するiptables/nftablesチェーン(例: ホストの場合はINPUT、Dockerコンテナの場合はDOCKER-USER)。", "settings.chain_help_title": "デフォルトチェーン", @@ -376,6 +386,8 @@ "settings.geoip_database_path.description": "MaxMind GeoLite2-Countryデータベースファイルへのパス。", "settings.max_log_lines": "最大ログ行数", "settings.max_log_lines.description": "ブロック通知に含める最大ログ行数。最も関連性の高い行が自動的に選択されます。", + "settings.event_retention_days": "イベント保持期間(日数)", + "settings.event_retention_days.description": "これより古いブロック/ブロック解除イベントは1日1回自動的に削除されます。0に設定するとイベントを永久に保持します(データベースは無制限に増加します)。", "settings.ignore_ips": "無視するIP", "settings.ignore_ips.description": "スペース区切りのIPアドレス、CIDRマスク、またはDNSホストのリスト。このリストに一致するホストはFail2banでブロックされません。", "settings.ignore_ips_placeholder": "無視するIP(スペース区切り)", @@ -438,6 +450,25 @@ "settings.save": "保存", "settings.save_success_reloaded": "設定を保存し、fail2ban を再読み込みしました", "settings.save_success_restart_required": "設定を保存しました。fail2ban の再起動が必要です。", + "settings.toast.load_error": "設定の読み込みエラー", + "settings.toast.fix_validation": "保存する前に入力エラーを修正してください", + "settings.toast.smtp_port_invalid": "SMTPポートは1〜65535の範囲で指定してください", + "settings.toast.save_error": "設定の保存エラー", + "settings.toast.saved_warnings": "警告付きで設定を保存しました", + "settings.toast.test_email_error": "テストメールの送信エラー", + "settings.toast.test_email_success": "テストメールを送信しました!", + "settings.toast.webhook_test_failed": "Webhookテストに失敗しました", + "settings.toast.webhook_test_success": "テストWebhookを送信しました!", + "settings.toast.es_test_failed": "Elasticsearchテストに失敗しました", + "settings.toast.es_test_success": "テストドキュメントをインデックスしました!", + "settings.toast.copy_failed": "クリップボードへのコピーに失敗しました", + "settings.toast.block_log_error": "恒久ブロックログの読み込みエラー", + "settings.toast.enter_ip": "IPアドレスを入力してください。", + "settings.toast.advanced_action_failed": "高度なアクションに失敗しました", + "settings.toast.action_completed": "アクションが完了しました", + "settings.toast.remove_ip_failed": "IPの削除に失敗しました", + "settings.toast.ip_removed": "IPを削除しました", + "settings.toast.invalid_ignore_ip": "無効なIPアドレス、CIDR、またはホスト名", "modal.filter_config": "フィルタ / Jail設定:", "modal.filter_config_edit": "フィルタ / Jailを編集", "modal.filter_config_label": "フィルタ設定", @@ -450,9 +481,29 @@ "modal.jail_filter": "フィルタ(任意)", "modal.jail_filter_hint": "フィルタを選択するとJail設定が自動入力されます。", "modal.jail_name": "Jail名", + "modal.delete_jail": "Jailを削除", "modal.jail_name_hint": "英数字、ハイフン、アンダースコアのみ使用可能です。", "modal.test_logpath": "ログパスのテスト", "jails.logpath_test.found_files": "{count} 個のファイルが見つかりました", + "jails.toast.name_required": "Jail名は必須です", + "jails.toast.create_error": "Jailの作成エラー", + "jails.toast.create_success": "Jailを作成しました", + "jails.toast.delete_error": "Jailの削除エラー", + "jails.toast.delete_success": "Jailを削除しました", + "jails.toast.save_config_error": "設定の保存エラー", + "jails.toast.save_settings_error": "Jail設定の保存エラー", + "jails.toast.enabled_success": "Jail {jail} を有効にしました", + "jails.toast.disabled_success": "Jail {jail} を無効にしました", + "jails.confirm.delete": "Jail「{name}」を削除してもよろしいですか?この操作は取り消せません。", + "jails.logpath_test.no_logpath": "Jail設定にlogpathが見つかりません。logpath行を追加してください(例: logpath = /var/log/example.log)", + "jails.logpath_test.testing": "logpathをテスト中...", + "jails.logpath_test.no_entries": "logpathエントリが見つかりません。", + "jails.logpath_test.entry": "Logpath {num}:", + "jails.logpath_test.resolved": "解決結果:", + "jails.logpath_test.in_container": "fail2ban-uiコンテナ内:", + "jails.logpath_test.on_remote": "リモートサーバー上:", + "jails.logpath_test.not_found_container": "見つかりません(ログがコンテナにマウントされていない可能性があります)", + "jails.logpath_test.not_found": "見つかりません", "modal.local_server_logpath_note": "⚠️ 注意:", "modal.local_server_logpath_text_prefix": "ローカルのfail2banサーバー(例: コンテナホストシステムやコンテナ内にインストール)の場合、ログファイルもfail2ban-uiコンテナにマウントする必要があります(例:", "modal.local_server_logpath_text_suffix": ")これはJailを更新する際にfail2ban-uiがログパス変数やパスを確認できるようにするために必要です。", @@ -466,7 +517,18 @@ "modal.close": "閉じる", "loading": "読み込み中...", "dashboard.manage_jails": "Jailを管理", + "dashboard.ban.confirm": "Jail {jail} で IP {ip} をブロックしますか?", + "dashboard.toast.block_error": "IPのブロックエラー", + "dashboard.toast.unban_error": "IPのブロック解除エラー", "modal.manage_jails_title": "Jailを管理", + "modal.toast.event_not_found": "イベントが見つかりません", + "modal.toast.no_whois": "このイベントのwhoisデータはありません", + "modal.toast.whois_error": "whoisデータの読み込みエラー", + "modal.toast.no_logs": "このイベントのログデータはありません", + "modal.toast.logs_error": "ログデータの読み込みエラー", + "modal.toast.no_jails": "このサーバーのJailが見つかりません。", + "modal.toast.fetch_jails_error": "Jail一覧の取得エラー", + "modal.toast.load_config_error": "設定の読み込みエラー", "servers.selector.label": "アクティブサーバー", "servers.selector.empty": "サーバーが未設定", "servers.selector.none": "サーバーが設定されていません。Fail2banサーバーを追加してください。", @@ -518,6 +580,7 @@ "servers.badge.restart_needed": "再起動が必要", "servers.actions.edit": "編集", "servers.actions.set_default": "デフォルトに設定", + "servers.actions.set_default_success": "サーバーをデフォルトに設定しました", "servers.actions.enable": "有効化", "servers.actions.disable": "無効化", "servers.actions.test": "接続テスト", @@ -534,6 +597,7 @@ "servers.actions.reload_tooltip": "ローカルコネクタでは、ソケット接続経由での設定リロードのみ可能です。コンテナはsystemctlを使用してFail2banサービスを再起動できません。完全な再起動を行うには、ホストシステムで直接「systemctl restart fail2ban」を実行してください。", "servers.actions.delete": "削除", "servers.actions.delete_confirm": "このサーバーエントリを削除しますか?", + "servers.actions.delete_success": "サーバーを削除しました", "servers.form.select_key": "秘密鍵を選択", "servers.form.select_key_placeholder": "手動入力", "servers.form.no_keys": "SSHキーが見つかりません。パスを手動で入力してください", @@ -545,6 +609,14 @@ "servers.card.socket_path": "ソケットパス", "servers.card.config_path": "設定パス", "servers.card.server_id": "サーバーID", + "servers.toast.save_error": "サーバーの保存エラー", + "servers.toast.delete_error": "サーバーの削除エラー", + "servers.toast.set_default_error": "デフォルトサーバーの設定エラー", + "servers.toast.none_selected": "サーバーが選択されていません", + "servers.toast.restart_failed": "Fail2banの再起動に失敗しました", + "servers.confirm.reload_local": "このサーバーでFail2ban設定を今すぐ再読み込みしますか?サービスを再起動せずに設定が再読み込みされます。", + "servers.confirm.restart_remote": "fail2banの再起動中はログが解析されず、IPアドレスもブロックされません。このサーバーでfail2banを今すぐ再起動しますか?しばらく時間がかかります。", + "servers.confirm.restart": "fail2banの再起動中はログが解析されず、IPアドレスもブロックされません。fail2banを今すぐ再起動しますか?しばらく時間がかかります。", "filter_debug.not_available": "フィルタデバッグは少なくとも1つの登録済みFail2banサーバーが有効な場合にのみ利用できます。", "filter_debug.local_missing": "このホスト上でローカルFail2banフィルタディレクトリが見つかりませんでした。", "filter_debug.save_success": "フィルタとJail設定が保存され、リロードされました", @@ -610,5 +682,22 @@ "auth.login_required": "認証が必要です", "footer.version": "Fail2ban-UI v{version}", "footer.latest": "最新", - "footer.update_available": "アップデートあり: v{version}" + "footer.update_available": "アップデートあり: v{version}", + "common.error": "エラー", + "common.copy": "コピー", + "common.copied": "コピーしました!", + "filters.toast.name_required": "フィルター名は必須です", + "filters.toast.create_error": "フィルターの作成エラー", + "filters.toast.create_success": "フィルターを作成しました", + "filters.toast.load_error": "フィルターの読み込みエラー", + "filters.toast.load_content_error": "フィルター内容の読み込みエラー", + "filters.toast.select_delete": "削除するフィルターを選択してください", + "filters.toast.delete_error": "フィルターの削除エラー", + "filters.toast.delete_success": "フィルターを削除しました", + "filters.toast.select_filter": "フィルターを選択してください。", + "filters.toast.enter_log_lines": "テストするログ行を1行以上入力してください。", + "filters.toast.test_error": "フィルターのテストエラー", + "filters.confirm.delete": "フィルター「{name}」を削除してもよろしいですか?この操作は取り消せません。", + "globe.bans_singular": "件のブロック", + "globe.bans_plural": "件のブロック" } diff --git a/pkg/web/locales/zh.json b/pkg/web/locales/zh.json index 7e255b0..28f11e2 100644 --- a/pkg/web/locales/zh.json +++ b/pkg/web/locales/zh.json @@ -252,10 +252,12 @@ "settings.callback_secret": "Fail2ban 回调 URL 密钥", "settings.callback_secret.show": "显示密钥", "settings.callback_secret.hide": "隐藏密钥", + "settings.callback_secret.hidden": "密钥保存在服务器上,永远不会显示", "settings.callback_secret_placeholder": "自动生成的 42 位密钥", "settings.callback_secret.description": "此密钥用于验证封禁 API 请求。它会自动包含在 fail2ban 操作配置中。", "settings.destination_email": "目标邮箱(警报接收者)", - "settings.destination_email_placeholder": "alerts@swissmakers.ch", + "settings.destination_email_placeholder": "alerts@swissmakers.ch, admin@example.com", + "settings.destination_email_hint": "可以使用逗号分隔指定多个电子邮件地址。", "settings.alert_countries": "警报国家", "settings.alert_countries.all": "全部(所有国家)", "settings.alert_countries_description": "选择触发封禁时要接收邮件警报的国家。", @@ -349,6 +351,14 @@ "settings.bantime_rndtime": "封禁时间随机增量", "settings.bantime_rndtime.description": "可选。封禁时间递增公式中添加的最大随机秒数(例如 2048)。留空使用 Fail2ban 默认值。", "settings.bantime_rndtime_placeholder": "例如:2048", + "settings.bantime_maxtime": "封禁时间上限 (maxtime)", + "settings.bantime_maxtime.description": "可选。启用封禁时间递增时,限制递增封禁可增长的最长时间(例如 5w)。留空使用 Fail2ban 默认值(无上限)。", + "settings.bantime_maxtime_placeholder": "例如 5w", + "settings.bantime_factor": "封禁时间系数 (factor)", + "settings.bantime_factor.description": "可选。封禁时间递增公式使用的乘数;数值越大,重复违规 IP 的封禁时间增长越快(Fail2ban 默认值:1)。留空使用默认值。", + "settings.bantime_factor_placeholder": "例如 2", + "settings.bantime_overalljails": "跨所有 jail 统计重复违规", + "settings.bantime_overalljails.description": "启用封禁时间递增时,在延长某 IP 的封禁时间时会考虑所有 jail(而不仅是当前 jail)中的历史封禁记录。", "settings.default_chain": "默认链", "settings.default_chain.description": "用于封禁的 iptables/nftables 链(例如主机使用 INPUT,Docker 容器使用 DOCKER-USER)。", "settings.chain_help_title": "默认链", @@ -376,6 +386,8 @@ "settings.geoip_database_path.description": "MaxMind GeoLite2-Country 数据库文件的路径。", "settings.max_log_lines": "最大日志行数", "settings.max_log_lines.description": "封禁通知中包含的最大日志行数。系统会自动选择最相关的行。", + "settings.event_retention_days": "事件保留期(天)", + "settings.event_retention_days.description": "早于此期限的封禁/解封事件将每天自动删除一次。设置为 0 可永久保留事件(数据库将无限增长)。", "settings.ignore_ips": "忽略 IP", "settings.ignore_ips.description": "空格分隔的 IP 地址、CIDR 掩码或 DNS 主机列表。Fail2ban 不会封禁与此列表中地址匹配的主机。", "settings.ignore_ips_placeholder": "要忽略的 IP,用空格分隔", @@ -438,6 +450,25 @@ "settings.save": "保存", "settings.save_success_reloaded": "设置已保存,fail2ban 已重新加载", "settings.save_success_restart_required": "设置已保存。需要重启 Fail2ban。", + "settings.toast.load_error": "加载设置出错", + "settings.toast.fix_validation": "保存前请先修正校验错误", + "settings.toast.smtp_port_invalid": "SMTP 端口必须在 1 到 65535 之间", + "settings.toast.save_error": "保存设置出错", + "settings.toast.saved_warnings": "设置已保存,但有警告", + "settings.toast.test_email_error": "发送测试邮件出错", + "settings.toast.test_email_success": "测试邮件发送成功!", + "settings.toast.webhook_test_failed": "Webhook 测试失败", + "settings.toast.webhook_test_success": "测试 Webhook 发送成功!", + "settings.toast.es_test_failed": "Elasticsearch 测试失败", + "settings.toast.es_test_success": "测试文档索引成功!", + "settings.toast.copy_failed": "复制到剪贴板失败", + "settings.toast.block_log_error": "加载永久封禁日志出错", + "settings.toast.enter_ip": "请输入 IP 地址。", + "settings.toast.advanced_action_failed": "高级操作失败", + "settings.toast.action_completed": "操作已完成", + "settings.toast.remove_ip_failed": "移除 IP 失败", + "settings.toast.ip_removed": "IP 已移除", + "settings.toast.invalid_ignore_ip": "无效的 IP 地址、CIDR 或主机名", "modal.filter_config": "过滤器 / Jail 配置:", "modal.filter_config_edit": "编辑过滤器 / Jail", "modal.filter_config_label": "过滤器配置", @@ -450,9 +481,29 @@ "modal.jail_filter": "过滤器(可选)", "modal.jail_filter_hint": "选择过滤器将自动填充 jail 配置。", "modal.jail_name": "Jail 名称", + "modal.delete_jail": "删除 Jail", "modal.jail_name_hint": "只允许字母数字字符、破折号和下划线。", "modal.test_logpath": "测试日志路径", "jails.logpath_test.found_files": "找到 {count} 个文件", + "jails.toast.name_required": "Jail 名称为必填项", + "jails.toast.create_error": "创建 jail 出错", + "jails.toast.create_success": "Jail 创建成功", + "jails.toast.delete_error": "删除 jail 出错", + "jails.toast.delete_success": "Jail 删除成功", + "jails.toast.save_config_error": "保存配置出错", + "jails.toast.save_settings_error": "保存 jail 设置出错", + "jails.toast.enabled_success": "Jail {jail} 已成功启用", + "jails.toast.disabled_success": "Jail {jail} 已成功禁用", + "jails.confirm.delete": "确定要删除 jail\"{name}\"吗?此操作无法撤销。", + "jails.logpath_test.no_logpath": "在 jail 配置中未找到 logpath。请添加 logpath 行(例如 logpath = /var/log/example.log)", + "jails.logpath_test.testing": "正在测试 logpath...", + "jails.logpath_test.no_entries": "未找到 logpath 条目。", + "jails.logpath_test.entry": "Logpath {num}:", + "jails.logpath_test.resolved": "解析结果:", + "jails.logpath_test.in_container": "在 fail2ban-ui 容器中:", + "jails.logpath_test.on_remote": "在远程服务器上:", + "jails.logpath_test.not_found_container": "未找到(日志可能未挂载到容器中)", + "jails.logpath_test.not_found": "未找到", "modal.local_server_logpath_note": "⚠️ 注意:", "modal.local_server_logpath_text_prefix": "对于本地 fail2ban 服务器(例如安装在容器主机系统上或同一主机的容器中),日志文件也必须挂载到 fail2ban-ui 容器(例如:", "modal.local_server_logpath_text_suffix": ") 这是必需的,以便 fail2ban-ui 在更新 jails 时可以验证 logpath 变量或路径。", @@ -466,7 +517,18 @@ "modal.close": "关闭", "loading": "加载中...", "dashboard.manage_jails": "管理 Jails", + "dashboard.ban.confirm": "在 jail {jail} 中封禁 IP {ip}?", + "dashboard.toast.block_error": "封禁 IP 出错", + "dashboard.toast.unban_error": "解封 IP 出错", "modal.manage_jails_title": "管理 Jails", + "modal.toast.event_not_found": "未找到事件", + "modal.toast.no_whois": "此事件没有可用的 whois 数据", + "modal.toast.whois_error": "加载 whois 数据出错", + "modal.toast.no_logs": "此事件没有可用的日志数据", + "modal.toast.logs_error": "加载日志数据出错", + "modal.toast.no_jails": "未找到此服务器的 jail。", + "modal.toast.fetch_jails_error": "获取 jail 列表出错", + "modal.toast.load_config_error": "加载配置出错", "servers.selector.label": "活动服务器", "servers.selector.empty": "未配置服务器", "servers.selector.none": "未配置服务器。请添加 Fail2ban 服务器。", @@ -518,6 +580,7 @@ "servers.badge.restart_needed": "需要重启", "servers.actions.edit": "编辑", "servers.actions.set_default": "设为默认", + "servers.actions.set_default_success": "已将服务器设为默认", "servers.actions.enable": "启用", "servers.actions.disable": "禁用", "servers.actions.test": "测试连接", @@ -534,6 +597,7 @@ "servers.actions.reload_tooltip": "对于本地连接器,只能通过 socket 连接进行配置重新加载。容器无法使用 systemctl 重启 Fail2ban 服务。要执行完全重启,请在主机系统上直接运行 'systemctl restart fail2ban'。", "servers.actions.delete": "删除", "servers.actions.delete_confirm": "删除此服务器条目?", + "servers.actions.delete_success": "服务器已删除", "servers.form.select_key": "选择私钥", "servers.form.select_key_placeholder": "手动输入", "servers.form.no_keys": "未找到 SSH 密钥;请手动输入路径", @@ -545,6 +609,14 @@ "servers.card.socket_path": "Socket 路径", "servers.card.config_path": "配置路径", "servers.card.server_id": "服务器 ID", + "servers.toast.save_error": "保存服务器出错", + "servers.toast.delete_error": "删除服务器出错", + "servers.toast.set_default_error": "设置默认服务器出错", + "servers.toast.none_selected": "未选择服务器", + "servers.toast.restart_failed": "重启 Fail2ban 失败", + "servers.confirm.reload_local": "现在在此服务器上重新加载 Fail2ban 配置?将在不重启服务的情况下重新加载配置。", + "servers.confirm.restart_remote": "请注意,fail2ban 重启期间不会解析日志,也不会封禁任何 IP 地址。现在在此服务器上重启 fail2ban?这需要一些时间。", + "servers.confirm.restart": "请注意,fail2ban 重启期间不会解析日志,也不会封禁任何 IP 地址。现在重启 fail2ban?这需要一些时间。", "filter_debug.not_available": "过滤器调试仅在至少有一个已注册的 Fail2ban 服务器启用时可用。", "filter_debug.local_missing": "在此主机上未找到本地 Fail2ban 过滤器目录。", "filter_debug.save_success": "过滤器和 jail 配置已保存并重新加载", @@ -610,5 +682,22 @@ "auth.login_required": "需要身份验证", "footer.version": "Fail2ban-UI v{version}", "footer.latest": "最新版本", - "footer.update_available": "有更新可用:v{version}" -} \ No newline at end of file + "footer.update_available": "有更新可用:v{version}", + "common.error": "错误", + "common.copy": "复制", + "common.copied": "已复制!", + "filters.toast.name_required": "过滤器名称为必填项", + "filters.toast.create_error": "创建过滤器出错", + "filters.toast.create_success": "过滤器创建成功", + "filters.toast.load_error": "加载过滤器出错", + "filters.toast.load_content_error": "加载过滤器内容出错", + "filters.toast.select_delete": "请选择要删除的过滤器", + "filters.toast.delete_error": "删除过滤器出错", + "filters.toast.delete_success": "过滤器删除成功", + "filters.toast.select_filter": "请选择一个过滤器。", + "filters.toast.enter_log_lines": "请输入至少一行日志用于测试。", + "filters.toast.test_error": "测试过滤器出错", + "filters.confirm.delete": "确定要删除过滤器\"{name}\"吗?此操作无法撤销。", + "globe.bans_singular": "次封禁", + "globe.bans_plural": "次封禁" +} diff --git a/pkg/web/outbound_http.go b/pkg/web/outbound_http.go new file mode 100644 index 0000000..7007dba --- /dev/null +++ b/pkg/web/outbound_http.go @@ -0,0 +1,38 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-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/gpl-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 ( + "io" + "net/http" + "time" +) + +const maxOutboundResponseBytes = 5 << 20 + +func newOutboundHTTPClient(timeout time.Duration) *http.Client { + return &http.Client{ + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func readLimitedBody(body io.Reader) ([]byte, error) { + return io.ReadAll(io.LimitReader(body, maxOutboundResponseBytes)) +} diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 00c9f89..2f1b163 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -47,72 +47,74 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) { api := r.Group("/api") { // Internal call from frontend to the Fail2ban-UI backend to get the summary of the servers (banned IPs per active jail) - api.GET("/summary", SummaryHandler) + api.GET("/summary", RequirePermission(PermissionRead), SummaryHandler) // External API calls from Fail2ban servers that notify Fail2Ban-UI backend about ban/unban events that where triggered. api.POST("/ban", BanNotificationHandler) api.POST("/unban", UnbanNotificationHandler) // Internal API calls from frontend (e.g. manual actions) to backend to execute Ban / Unban - api.GET("/jails/:jail/banned", ListJailBannedIPsHandler) - api.POST("/jails/:jail/unban/:ip", UnbanIPHandler) - api.POST("/jails/:jail/ban/:ip", BanIPHandler) - - // Internal API calls for jail-filter management (TODO: rename API-call) - api.GET("/jails/:jail/config", GetJailFilterConfigHandler) - api.POST("/jails/:jail/config", SetJailFilterConfigHandler) - api.POST("/jails/:jail/logpath/test", TestLogpathHandler) - api.GET("/jails/manage", ManageJailsHandler) - api.POST("/jails/manage", UpdateJailManagementHandler) - api.POST("/jails", CreateJailHandler) - api.DELETE("/jails/:jail", DeleteJailHandler) + api.GET("/jails/:jail/banned", RequirePermission(PermissionRead), ListJailBannedIPsHandler) + api.POST("/jails/:jail/unban/:ip", RequirePermission(PermissionBan), UnbanIPHandler) + api.POST("/jails/:jail/ban/:ip", RequirePermission(PermissionBan), BanIPHandler) + + // Search which jails currently ban this IP -> searches on all servers + api.GET("/ips/:ip/search", RequirePermission(PermissionRead), SearchBannedIPHandler) + + // Internal API calls for jail-filter management + api.GET("/jails/:jail/config", RequirePermission(PermissionAdmin), GetJailFilterConfigHandler) + api.POST("/jails/:jail/config", RequirePermission(PermissionAdmin), SetJailFilterConfigHandler) + api.POST("/jails/:jail/logpath/test", RequirePermission(PermissionAdmin), TestLogpathHandler) + api.GET("/jails/manage", RequirePermission(PermissionAdmin), ManageJailsHandler) + api.POST("/jails/manage", RequirePermission(PermissionAdmin), UpdateJailManagementHandler) + api.POST("/jails", RequirePermission(PermissionAdmin), CreateJailHandler) + api.DELETE("/jails/:jail", RequirePermission(PermissionAdmin), DeleteJailHandler) // Internal API calls for filter management - api.GET("/filters", ListFiltersHandler) - api.GET("/filters/:filter/content", GetFilterContentHandler) - api.POST("/filters/test", TestFilterHandler) - api.POST("/filters", CreateFilterHandler) - api.DELETE("/filters/:filter", DeleteFilterHandler) + api.GET("/filters", RequirePermission(PermissionAdmin), ListFiltersHandler) + api.GET("/filters/:filter/content", RequirePermission(PermissionAdmin), GetFilterContentHandler) + api.POST("/filters/test", RequirePermission(PermissionAdmin), TestFilterHandler) + api.POST("/filters", RequirePermission(PermissionAdmin), CreateFilterHandler) + api.DELETE("/filters/:filter", RequirePermission(PermissionAdmin), DeleteFilterHandler) // Internal API calls for Fail2ban-UI settings - api.GET("/settings", GetSettingsHandler) - api.POST("/settings", UpdateSettingsHandler) - //api.PATCH("/settings", PatchSettingsHandler) - api.POST("/settings/test-email", TestEmailHandler) - api.POST("/settings/test-webhook", TestWebhookHandler) - api.POST("/settings/test-elasticsearch", TestElasticsearchHandler) + api.GET("/settings", RequirePermission(PermissionRead), GetSettingsHandler) + api.POST("/settings", RequirePermission(PermissionAdmin), UpdateSettingsHandler) + api.POST("/settings/test-email", RequirePermission(PermissionAdmin), TestEmailHandler) + api.POST("/settings/test-webhook", RequirePermission(PermissionAdmin), TestWebhookHandler) + api.POST("/settings/test-elasticsearch", RequirePermission(PermissionAdmin), TestElasticsearchHandler) // Internal API calls for advanced actions - api.GET("/advanced-actions/blocks", ListPermanentBlocksHandler) - api.DELETE("/advanced-actions/blocks", ClearPermanentBlocksHandler) - api.POST("/advanced-actions/test", AdvancedActionsTestHandler) + api.GET("/advanced-actions/blocks", RequirePermission(PermissionAdmin), ListPermanentBlocksHandler) + api.DELETE("/advanced-actions/blocks", RequirePermission(PermissionAdmin), ClearPermanentBlocksHandler) + api.POST("/advanced-actions/test", RequirePermission(PermissionAdmin), AdvancedActionsTestHandler) // Internal API calls for Fail2ban-UI server management - api.GET("/servers", ListServersHandler) - api.POST("/servers", UpsertServerHandler) - api.DELETE("/servers/:id", DeleteServerHandler) - api.POST("/servers/:id/default", SetDefaultServerHandler) - api.GET("/ssh/keys", ListSSHKeysHandler) - api.POST("/servers/:id/test", TestServerHandler) + api.GET("/servers", RequirePermission(PermissionRead), ListServersHandler) + api.POST("/servers", RequirePermission(PermissionAdmin), UpsertServerHandler) + api.DELETE("/servers/:id", RequirePermission(PermissionAdmin), DeleteServerHandler) + api.POST("/servers/:id/default", RequirePermission(PermissionAdmin), SetDefaultServerHandler) + api.GET("/ssh/keys", RequirePermission(PermissionAdmin), ListSSHKeysHandler) + api.POST("/servers/:id/test", RequirePermission(PermissionAdmin), TestServerHandler) // Internal API to restart Fail2ban - api.POST("/fail2ban/restart", RestartFail2banHandler) + api.POST("/fail2ban/restart", RequirePermission(PermissionAdmin), RestartFail2banHandler) // Internal API calls to get the stats and insights about bans - api.GET("/events/bans", ListBanEventsHandler) - api.DELETE("/events/bans", ClearBanEventsHandler) - api.GET("/events/bans/stats", BanStatisticsHandler) - api.GET("/events/bans/insights", BanInsightsHandler) - api.GET("/events/bans/:id", GetBanEventHandler) - api.GET("/threat-intel/:ip", ThreatIntelHandler) + api.GET("/events/bans", RequirePermission(PermissionRead), ListBanEventsHandler) + api.DELETE("/events/bans", RequirePermission(PermissionAdmin), ClearBanEventsHandler) + api.GET("/events/bans/stats", RequirePermission(PermissionRead), BanStatisticsHandler) + api.GET("/events/bans/insights", RequirePermission(PermissionRead), BanInsightsHandler) + api.GET("/events/bans/:id", RequirePermission(PermissionRead), GetBanEventHandler) + api.GET("/threat-intel/:ip", RequirePermission(PermissionRead), ThreatIntelHandler) // WebSocket endpoint - api.GET("/ws", WebSocketHandler(hub)) + api.GET("/ws", RequirePermission(PermissionRead), WebSocketHandler(hub)) // API to healthchecks (mainly used by agent) api.GET("/healthcheck/callback", HealthcheckCallbackSecret) // External API to get the version of the Fail2ban-UI and check for updates - api.GET("/version", GetVersionHandler) + api.GET("/version", RequirePermission(PermissionRead), GetVersionHandler) } } diff --git a/pkg/web/secret_masking.go b/pkg/web/secret_masking.go new file mode 100644 index 0000000..669b823 --- /dev/null +++ b/pkg/web/secret_masking.go @@ -0,0 +1,127 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-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/gpl-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 ( + "github.com/swissmakers/fail2ban-ui/internal/config" + "github.com/swissmakers/fail2ban-ui/internal/shared" +) + +// When the frontend saves settings unchanged, it POSTs this back and the save handlers restore the real stored value (see restoreMaskedSecrets). +// This keeps secrets out of GET /api/settings and /api/servers responses. +const secretMaskSentinel = "__f2bui_secret_unchanged__" + +func maskSecret(value string) string { + if value == "" { + return "" + } + return secretMaskSentinel +} + +func restoreSecret(incoming, stored string) string { + if incoming == secretMaskSentinel { + return stored + } + return incoming +} + +func maskAppSettingsSecrets(s config.AppSettings) config.AppSettings { + s.CallbackSecret = maskSecret(s.CallbackSecret) + s.SMTP.Password = maskSecret(s.SMTP.Password) + s.ThreatIntel.AlienVaultAPIKey = maskSecret(s.ThreatIntel.AlienVaultAPIKey) + s.ThreatIntel.AbuseIPDBAPIKey = maskSecret(s.ThreatIntel.AbuseIPDBAPIKey) + s.Elasticsearch.APIKey = maskSecret(s.Elasticsearch.APIKey) + s.Elasticsearch.Password = maskSecret(s.Elasticsearch.Password) + s.AdvancedActions.Mikrotik.Password = maskSecret(s.AdvancedActions.Mikrotik.Password) + s.AdvancedActions.PfSense.APIToken = maskSecret(s.AdvancedActions.PfSense.APIToken) + s.AdvancedActions.PfSense.APISecret = maskSecret(s.AdvancedActions.PfSense.APISecret) + s.AdvancedActions.OPNsense.APIKey = maskSecret(s.AdvancedActions.OPNsense.APIKey) + s.AdvancedActions.OPNsense.APISecret = maskSecret(s.AdvancedActions.OPNsense.APISecret) + + if len(s.Webhook.Headers) > 0 { + masked := make(map[string]string, len(s.Webhook.Headers)) + for k, v := range s.Webhook.Headers { + masked[k] = maskSecret(v) + } + s.Webhook.Headers = masked + } + + if len(s.Servers) > 0 { + s.Servers = maskServerSecrets(s.Servers) + } + return s +} + +func restoreMaskedSecrets(req *config.AppSettings, stored config.AppSettings) { + req.CallbackSecret = restoreSecret(req.CallbackSecret, stored.CallbackSecret) + req.SMTP.Password = restoreSecret(req.SMTP.Password, stored.SMTP.Password) + req.ThreatIntel.AlienVaultAPIKey = restoreSecret(req.ThreatIntel.AlienVaultAPIKey, stored.ThreatIntel.AlienVaultAPIKey) + req.ThreatIntel.AbuseIPDBAPIKey = restoreSecret(req.ThreatIntel.AbuseIPDBAPIKey, stored.ThreatIntel.AbuseIPDBAPIKey) + req.Elasticsearch.APIKey = restoreSecret(req.Elasticsearch.APIKey, stored.Elasticsearch.APIKey) + req.Elasticsearch.Password = restoreSecret(req.Elasticsearch.Password, stored.Elasticsearch.Password) + req.AdvancedActions.Mikrotik.Password = restoreSecret(req.AdvancedActions.Mikrotik.Password, stored.AdvancedActions.Mikrotik.Password) + req.AdvancedActions.PfSense.APIToken = restoreSecret(req.AdvancedActions.PfSense.APIToken, stored.AdvancedActions.PfSense.APIToken) + req.AdvancedActions.PfSense.APISecret = restoreSecret(req.AdvancedActions.PfSense.APISecret, stored.AdvancedActions.PfSense.APISecret) + req.AdvancedActions.OPNsense.APIKey = restoreSecret(req.AdvancedActions.OPNsense.APIKey, stored.AdvancedActions.OPNsense.APIKey) + req.AdvancedActions.OPNsense.APISecret = restoreSecret(req.AdvancedActions.OPNsense.APISecret, stored.AdvancedActions.OPNsense.APISecret) + + for k, v := range req.Webhook.Headers { + if v == secretMaskSentinel { + req.Webhook.Headers[k] = stored.Webhook.Headers[k] + } + } + + if len(req.Servers) > 0 { + storedByID := make(map[string]string, len(stored.Servers)) + for _, srv := range stored.Servers { + storedByID[srv.ID] = srv.AgentSecret + } + for i := range req.Servers { + req.Servers[i].AgentSecret = restoreSecret(req.Servers[i].AgentSecret, storedByID[req.Servers[i].ID]) + } + } +} + +func maskServer(server shared.Fail2banServer) shared.Fail2banServer { + server.AgentSecret = maskSecret(server.AgentSecret) + return server +} + +func maskServerSecrets(servers []shared.Fail2banServer) []shared.Fail2banServer { + out := make([]shared.Fail2banServer, len(servers)) + copy(out, servers) + for i := range out { + out[i].AgentSecret = maskSecret(out[i].AgentSecret) + } + return out +} + +// stripServerConnectionDetails removes connection information non-admin users +// have no need to see (support users only ban/unban through the UI). +func stripServerConnectionDetails(servers []shared.Fail2banServer) []shared.Fail2banServer { + for i := range servers { + servers[i].Host = "" + servers[i].Port = 0 + servers[i].SocketPath = "" + servers[i].ConfigPath = "" + servers[i].SSHUser = "" + servers[i].SSHKeyPath = "" + servers[i].AgentURL = "" + servers[i].AgentSecret = "" + } + return servers +} diff --git a/pkg/web/secret_masking_test.go b/pkg/web/secret_masking_test.go new file mode 100644 index 0000000..be720a1 --- /dev/null +++ b/pkg/web/secret_masking_test.go @@ -0,0 +1,150 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-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/gpl-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 ( + "testing" + + "github.com/swissmakers/fail2ban-ui/internal/config" +) + +func TestMaskAppSettingsSecrets(t *testing.T) { + s := config.AppSettings{CallbackSecret: "topsecret"} + s.SMTP.Password = "pw" + s.Webhook.Headers = map[string]string{"Authorization": "Bearer xyz"} + s.Servers = []config.Fail2banServer{{ID: "a", Type: "agent", AgentSecret: "agent-tok"}} + + masked := maskAppSettingsSecrets(s) + + if masked.CallbackSecret != secretMaskSentinel { + t.Errorf("callback secret not masked: %q", masked.CallbackSecret) + } + if masked.SMTP.Password != secretMaskSentinel { + t.Errorf("smtp password not masked: %q", masked.SMTP.Password) + } + if masked.Webhook.Headers["Authorization"] != secretMaskSentinel { + t.Errorf("webhook header not masked: %q", masked.Webhook.Headers["Authorization"]) + } + if masked.Servers[0].AgentSecret != secretMaskSentinel { + t.Errorf("embedded server agent secret not masked: %q", masked.Servers[0].AgentSecret) + } + // Masking must not mutate the original (value fields or the embedded slice). + if s.CallbackSecret != "topsecret" { + t.Errorf("original mutated: %q", s.CallbackSecret) + } + if s.Servers[0].AgentSecret != "agent-tok" { + t.Errorf("original server slice mutated (aliasing): %q", s.Servers[0].AgentSecret) + } +} + +func TestRestoreMaskedServerSecrets(t *testing.T) { + stored := config.AppSettings{ + Servers: []config.Fail2banServer{{ID: "a", AgentSecret: "stored-tok"}}, + } + req := config.AppSettings{ + Servers: []config.Fail2banServer{{ID: "a", AgentSecret: secretMaskSentinel}}, + } + restoreMaskedSecrets(&req, stored) + if req.Servers[0].AgentSecret != "stored-tok" { + t.Errorf("unchanged server secret should be restored, got %q", req.Servers[0].AgentSecret) + } +} + +func TestMaskEmptySecretStaysEmpty(t *testing.T) { + masked := maskAppSettingsSecrets(config.AppSettings{}) + if masked.CallbackSecret != "" { + t.Errorf("empty secret should stay empty, got %q", masked.CallbackSecret) + } +} + +func TestRestoreMaskedSecrets(t *testing.T) { + stored := config.AppSettings{CallbackSecret: "stored-secret"} + stored.SMTP.Password = "stored-pw" + stored.Webhook.Headers = map[string]string{"Authorization": "Bearer stored"} + + // Client leaves callback secret unchanged (sentinel) but rotates SMTP password. + req := config.AppSettings{CallbackSecret: secretMaskSentinel} + req.SMTP.Password = "new-pw" + req.Webhook.Headers = map[string]string{"Authorization": secretMaskSentinel} + + restoreMaskedSecrets(&req, stored) + + if req.CallbackSecret != "stored-secret" { + t.Errorf("unchanged secret should be restored, got %q", req.CallbackSecret) + } + if req.SMTP.Password != "new-pw" { + t.Errorf("changed secret should be kept, got %q", req.SMTP.Password) + } + if req.Webhook.Headers["Authorization"] != "Bearer stored" { + t.Errorf("unchanged header should be restored, got %q", req.Webhook.Headers["Authorization"]) + } +} + +func settingsWithAllSecrets() (config.AppSettings, map[string]func(*config.AppSettings) *string) { + var s config.AppSettings + fields := map[string]func(*config.AppSettings) *string{ + "CallbackSecret": func(s *config.AppSettings) *string { return &s.CallbackSecret }, + "SMTP.Password": func(s *config.AppSettings) *string { return &s.SMTP.Password }, + "ThreatIntel.AlienVault": func(s *config.AppSettings) *string { return &s.ThreatIntel.AlienVaultAPIKey }, + "ThreatIntel.AbuseIPDB": func(s *config.AppSettings) *string { return &s.ThreatIntel.AbuseIPDBAPIKey }, + "Elasticsearch.APIKey": func(s *config.AppSettings) *string { return &s.Elasticsearch.APIKey }, + "Elasticsearch.Password": func(s *config.AppSettings) *string { return &s.Elasticsearch.Password }, + "Mikrotik.Password": func(s *config.AppSettings) *string { return &s.AdvancedActions.Mikrotik.Password }, + "PfSense.APIToken": func(s *config.AppSettings) *string { return &s.AdvancedActions.PfSense.APIToken }, + "PfSense.APISecret": func(s *config.AppSettings) *string { return &s.AdvancedActions.PfSense.APISecret }, + "OPNsense.APIKey": func(s *config.AppSettings) *string { return &s.AdvancedActions.OPNsense.APIKey }, + "OPNsense.APISecret": func(s *config.AppSettings) *string { return &s.AdvancedActions.OPNsense.APISecret }, + } + for name, get := range fields { + *get(&s) = "secret-" + name + } + return s, fields +} + +func TestMaskAndRestoreCoverAllSecretFields(t *testing.T) { + stored, fields := settingsWithAllSecrets() + + masked := maskAppSettingsSecrets(stored) + for name, get := range fields { + if got := *get(&masked); got != secretMaskSentinel { + t.Errorf("%s not masked: %q", name, got) + } + } + + req := masked + restoreMaskedSecrets(&req, stored) + for name, get := range fields { + want := *get(&stored) + if got := *get(&req); got != want { + t.Errorf("%s not restored: got %q, want %q", name, got, want) + } + } +} + +func TestMaskServer(t *testing.T) { + server := config.Fail2banServer{ID: "a", Type: "agent", AgentSecret: "agent-tok"} + masked := maskServer(server) + if masked.AgentSecret != secretMaskSentinel { + t.Errorf("agent secret not masked: %q", masked.AgentSecret) + } + if server.AgentSecret != "agent-tok" { + t.Errorf("original mutated: %q", server.AgentSecret) + } + if empty := maskServer(config.Fail2banServer{ID: "b"}); empty.AgentSecret != "" { + t.Errorf("empty secret should stay empty, got %q", empty.AgentSecret) + } +} diff --git a/pkg/web/static/js/auth.js b/pkg/web/static/js/auth.js index 1fc3f38..d73eb4d 100644 --- a/pkg/web/static/js/auth.js +++ b/pkg/web/static/js/auth.js @@ -8,6 +8,7 @@ let authEnabled = false; let isAuthenticated = false; let currentUser = null; +let authorizationEnabled = false; // ========================================================================= // Check Authentication Status @@ -49,6 +50,7 @@ async function checkAuthStatus() { const data = await response.json(); authEnabled = data.enabled || false; isAuthenticated = data.authenticated || false; + authorizationEnabled = data.authorizationEnabled || false; const skipLoginPageFlag = data.skipLoginPage || false; if (authEnabled) { @@ -151,6 +153,27 @@ function showLoginPage() { } } +function hasAccess(requiredLevel) { + if (!authorizationEnabled || !authEnabled) return true; + if (!requiredLevel) return true; + const accessLevel = currentUser && currentUser.accessLevel ? currentUser.accessLevel : ''; + if (accessLevel === 'admin') return true; + return requiredLevel === 'support' && accessLevel === 'support'; +} + +function applyAuthorizationUI() { + document.querySelectorAll('[data-min-access]').forEach(function(el) { + const required = el.getAttribute('data-min-access'); + if (hasAccess(required)) { + el.classList.remove('hidden'); + el.removeAttribute('aria-hidden'); + } else { + el.classList.add('hidden'); + el.setAttribute('aria-hidden', 'true'); + } + }); +} + function showMainContent() { const loginPage = document.getElementById('loginPage'); const mainContent = document.getElementById('mainContent'); @@ -176,6 +199,7 @@ function showMainContent() { footer.style.display = 'block'; footer.classList.remove('hidden'); } + applyAuthorizationUI(); } function showAuthenticatedUI() { diff --git a/pkg/web/static/js/console.js b/pkg/web/static/js/console.js index d7b3edd..72de2e0 100644 --- a/pkg/web/static/js/console.js +++ b/pkg/web/static/js/console.js @@ -156,18 +156,8 @@ function appendConsoleLog(message, timestamp) { } catch (e) {} } - // Escape message to prevent XSS - let escapedMessage = message; - if (typeof escapeHtml === 'function') { - escapedMessage = escapeHtml(escapedMessage); - } else { - escapedMessage = escapedMessage - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } + // Escape message to prevent XSS (core.js always loads before this file) + let escapedMessage = escapeHtml(message); // Set different colors for different log levels using patterns below. // Default is green. diff --git a/pkg/web/static/js/core.js b/pkg/web/static/js/core.js index 826ef05..ad30247 100644 --- a/pkg/web/static/js/core.js +++ b/pkg/web/static/js/core.js @@ -120,7 +120,7 @@ function showBanEventToast(event) { // Click on toast body scrolls to ban events table toast.addEventListener('click', function() { - var logSection = document.getElementById('logOverviewSection'); + var logSection = document.getElementById('logOverview'); if (logSection) { logSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); } @@ -309,6 +309,9 @@ function updateRestartBanner() { } function showSection(sectionId) { + if ((sectionId === 'filterSection' || sectionId === 'settingsSection') && typeof hasAccess === 'function' && !hasAccess('admin')) { + sectionId = 'dashboardSection'; + } // hide all sections document.getElementById('dashboardSection').classList.add('hidden'); document.getElementById('filterSection').classList.add('hidden'); diff --git a/pkg/web/static/js/dashboard.js b/pkg/web/static/js/dashboard.js index 605f7da..ce5401d 100644 --- a/pkg/web/static/js/dashboard.js +++ b/pkg/web/static/js/dashboard.js @@ -88,16 +88,19 @@ function fetchSummaryData() { .then(function(data) { if (data && !data.error) { latestSummary = data; + latestSummaryServerId = data.serverId || currentServerId || null; latestSummaryError = null; jailLocalWarning = !!data.jailLocalWarning; } else { latestSummary = null; + latestSummaryServerId = null; latestSummaryError = formatApiError(data, 'dashboard.errors.summary_failed', 'Failed to load summary from server.'); jailLocalWarning = false; } }) .catch(function(err) { latestSummary = null; + latestSummaryServerId = null; latestSummaryError = err ? err.toString() : 'Unknown error'; jailLocalWarning = false; }); @@ -179,7 +182,17 @@ function updateSearchLoadingCounter(delta) { setJailsSearchLoadingState(bannedSearchPendingCount > 0); } +function summaryMatchesCurrentServer() { + if (!latestSummaryServerId || !currentServerId) { + return true; + } + return latestSummaryServerId === currentServerId; +} + function fetchJailBannedIPs(jailName, options) { + if (!summaryMatchesCurrentServer()) { + return Promise.resolve(); + } options = options || {}; var append = options.append === true; var searchToken = options.searchToken || null; @@ -233,6 +246,9 @@ function fetchJailBannedIPs(jailName, options) { } function loadInitialJailBannedPages(summary) { + if (!summaryMatchesCurrentServer()) { + return; + } var jails = summary && Array.isArray(summary.jails) ? summary.jails : []; jails.forEach(function(jail) { var jailName = jail && jail.jailName ? String(jail.jailName) : ''; @@ -332,7 +348,7 @@ function fetchBanEventsData(options) { function banIP(jail, ip) { const confirmMsg = isLOTRModeActive ? 'Banish ' + ip + ' from the realm in ' + jail + '?' - : 'Block IP ' + ip + ' in jail ' + jail + '?'; + : t('dashboard.ban.confirm', 'Block IP {ip} in jail {jail}?').replace('{ip}', ip).replace('{jail}', jail); if (!confirm(confirmMsg)) { return; } @@ -346,16 +362,15 @@ function banIP(jail, ip) { .then(function(data) { showLoading(false); if (data.error) { - showToast(formatApiError(data, '', 'Error blocking IP'), 'error'); + showToast(formatApiError(data, 'dashboard.toast.block_error', 'Error blocking IP'), 'error'); return; } showToast(t('dashboard.manual_block.success', 'IP blocked successfully'), 'success'); - // Refresh the affected sections in the background refreshAfterManualAction(jail); }) .catch(function(err) { showLoading(false); - showToast("Error: " + err, 'error'); + showToast(t('common.error', 'Error') + ': ' + err, 'error'); }); } @@ -399,14 +414,14 @@ function unbanIP(jail, ip) { .then(function(data) { showLoading(false); if (data.error) { - showToast(formatApiError(data, '', 'Error unbanning IP'), 'error'); + showToast(formatApiError(data, 'dashboard.toast.unban_error', 'Error unbanning IP'), 'error'); return; } refreshAfterManualAction(jail); }) .catch(function(err) { showLoading(false); - showToast("Error: " + err, 'error'); + showToast(t('common.error', 'Error') + ': ' + err, 'error'); }); } @@ -704,9 +719,7 @@ function renderLogOverviewContent() { var totalStored = totalStoredBans(); var todayCount = totalBansToday(); var weekCount = totalBansWeek(); - if (statsKeys.length === 0 && totalStored === 0) { - //html += '

No ban events recorded yet.

'; - } else { + if (statsKeys.length > 0 || totalStored > 0) { html += '' + '
' + '
' @@ -1170,6 +1183,22 @@ function buildBanEventsQuery(offset, append) { return appPath('/api/events/bans?' + params.join('&')); } +var DASHBOARD_REFRESH_MIN_INTERVAL_MS = 10000; +var dashboardRefreshTimer = null; +var lastDashboardRefreshAt = 0; + +function scheduleDashboardRefresh() { + if (dashboardRefreshTimer) { + return; + } + var elapsed = Date.now() - lastDashboardRefreshAt; + var delay = Math.max(DASHBOARD_REFRESH_MIN_INTERVAL_MS - elapsed, 0); + dashboardRefreshTimer = setTimeout(function() { + dashboardRefreshTimer = null; + refreshDashboardData(); + }, delay); +} + // Helper function to add a new ban event from the WebSocket to the dashboard. function addBanEventFromWebSocket(event) { var hasSearch = (banEventsFilterText || '').trim().length > 0; @@ -1177,7 +1206,7 @@ function addBanEventFromWebSocket(event) { if (typeof showBanEventToast === 'function') { showBanEventToast(event); } - refreshDashboardData(); + scheduleDashboardRefresh(); return; } var exists = false; @@ -1200,7 +1229,7 @@ function addBanEventFromWebSocket(event) { if (typeof showBanEventToast === 'function') { showBanEventToast(event); } - refreshDashboardData(); + scheduleDashboardRefresh(); } else { console.log('Skipping duplicate event:', event); } @@ -1208,6 +1237,7 @@ function addBanEventFromWebSocket(event) { // Helper function to refresh the dashboard data by fetching the summary and ban insights. function refreshDashboardData() { + lastDashboardRefreshAt = Date.now(); var enabledServers = serversCache.filter(function(s) { return s.enabled; }); var summaryPromise; if (serversCache.length && enabledServers.length && currentServerId) { diff --git a/pkg/web/static/js/filters.js b/pkg/web/static/js/filters.js index fcdf69f..53a0c05 100644 --- a/pkg/web/static/js/filters.js +++ b/pkg/web/static/js/filters.js @@ -10,7 +10,7 @@ function createFilter() { const content = document.getElementById('newFilterContent').value.trim(); if (!filterName) { - showToast('Filter name is required', 'error'); + showToast(t('filters.toast.name_required', 'Filter name is required'), 'error'); return; } @@ -33,16 +33,16 @@ function createFilter() { }) .then(function(data) { if (data.error) { - showToast('Error creating filter: ' + data.error, 'error'); + showToast(t('filters.toast.create_error', 'Error creating filter') + ': ' + data.error, 'error'); return; } closeModal('createFilterModal'); - showToast(data.message || 'Filter created successfully', 'success'); + showToast(data.message || t('filters.toast.create_success', 'Filter created successfully'), 'success'); loadFilters(); }) .catch(function(err) { console.error('Error creating filter:', err); - showToast('Error creating filter: ' + (err.message || err), 'error'); + showToast(t('filters.toast.create_error', 'Error creating filter') + ': ' + (err.message || err), 'error'); }) .finally(function() { showLoading(false); @@ -61,7 +61,7 @@ function loadFilters() { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Error loading filters: ' + data.error, 'error'); + showToast(t('filters.toast.load_error', 'Error loading filters') + ': ' + data.error, 'error'); return; } const select = document.getElementById('filterSelect'); @@ -117,7 +117,7 @@ function loadFilters() { } }) .catch(err => { - showToast('Error loading filters: ' + err, 'error'); + showToast(t('filters.toast.load_error', 'Error loading filters') + ': ' + err, 'error'); }) .finally(() => showLoading(false)); } @@ -134,7 +134,7 @@ function loadFilterContent(filterName) { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Error loading filter content: ' + data.error, 'error'); + showToast(t('filters.toast.load_content_error', 'Error loading filter content') + ': ' + data.error, 'error'); filterContentTextarea.value = ''; filterContentTextarea.readOnly = true; if (editBtn) editBtn.classList.add('hidden'); @@ -149,7 +149,7 @@ function loadFilterContent(filterName) { updateFilterContentHints(false); }) .catch(err => { - showToast('Error loading filter content: ' + err, 'error'); + showToast(t('filters.toast.load_content_error', 'Error loading filter content') + ': ' + err, 'error'); filterContentTextarea.value = ''; filterContentTextarea.readOnly = true; if (editBtn) editBtn.classList.add('hidden'); @@ -212,11 +212,11 @@ function updateFilterContentHints(isEditable) { function deleteFilter() { const filterName = document.getElementById('filterSelect').value; if (!filterName) { - showToast('Please select a filter to delete', 'info'); + showToast(t('filters.toast.select_delete', 'Please select a filter to delete'), 'info'); return; } - if (!confirm('Are you sure you want to delete the filter "' + escapeHtml(filterName) + '"? This action cannot be undone.')) { + if (!confirm(t('filters.confirm.delete', 'Are you sure you want to delete the filter "{name}"? This action cannot be undone.').replace('{name}', filterName))) { return; } showLoading(true); @@ -234,10 +234,10 @@ function deleteFilter() { }) .then(function(data) { if (data.error) { - showToast('Error deleting filter: ' + data.error, 'error'); + showToast(t('filters.toast.delete_error', 'Error deleting filter') + ': ' + data.error, 'error'); return; } - showToast(data.message || 'Filter deleted successfully', 'success'); + showToast(data.message || t('filters.toast.delete_success', 'Filter deleted successfully'), 'success'); loadFilters(); document.getElementById('testResults').innerHTML = ''; document.getElementById('testResults').classList.add('hidden'); @@ -255,7 +255,7 @@ function deleteFilter() { }) .catch(function(err) { console.error('Error deleting filter:', err); - showToast('Error deleting filter: ' + (err.message || err), 'error'); + showToast(t('filters.toast.delete_error', 'Error deleting filter') + ': ' + (err.message || err), 'error'); }) .finally(function() { showLoading(false); @@ -272,11 +272,11 @@ function testSelectedFilter() { const filterContentTextarea = document.getElementById('filterContentTextarea'); if (!filterName) { - showToast('Please select a filter.', 'info'); + showToast(t('filters.toast.select_filter', 'Please select a filter.'), 'info'); return; } if (lines.length === 0) { - showToast('Please enter at least one log line to test.', 'info'); + showToast(t('filters.toast.enter_log_lines', 'Please enter at least one log line to test.'), 'info'); return; } const testResultsEl = document.getElementById('testResults'); @@ -301,13 +301,13 @@ function testSelectedFilter() { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Error testing filter: ' + data.error, 'error'); + showToast(t('filters.toast.test_error', 'Error testing filter') + ': ' + data.error, 'error'); return; } renderTestResults(data.output || '', data.filterPath || ''); }) .catch(err => { - showToast('Error testing filter: ' + err, 'error'); + showToast(t('filters.toast.test_error', 'Error testing filter') + ': ' + err, 'error'); }) .finally(() => showLoading(false)); } diff --git a/pkg/web/static/js/globals.js b/pkg/web/static/js/globals.js index d90b0da..9e88d4d 100644 --- a/pkg/web/static/js/globals.js +++ b/pkg/web/static/js/globals.js @@ -6,6 +6,7 @@ var serversCache = []; var currentServerId = null; var currentServer = null; var latestSummary = null; +var latestSummaryServerId = null; var latestSummaryError = null; var latestBanStats = {}; var latestBanEvents = []; diff --git a/pkg/web/static/js/globe.js b/pkg/web/static/js/globe.js index 168f762..c537acf 100644 --- a/pkg/web/static/js/globe.js +++ b/pkg/web/static/js/globe.js @@ -141,7 +141,7 @@ function renderInsightsGlobe() { label: '
' + '' + escapeHtml(s.country || '??') + '
' + - formatNumber(s.count) + ' ban' + (s.count !== 1 ? 's' : '') + '
', + formatNumber(s.count) + ' ' + (s.count !== 1 ? t('globe.bans_plural', 'bans') : t('globe.bans_singular', 'ban')) + '
', country: s.country, count: s.count }); diff --git a/pkg/web/static/js/ignoreips.js b/pkg/web/static/js/ignoreips.js index f68a2ea..bacbced 100644 --- a/pkg/web/static/js/ignoreips.js +++ b/pkg/web/static/js/ignoreips.js @@ -23,7 +23,7 @@ function addIgnoreIPTag(ip) { const trimmedIP = ip.trim(); if (typeof isValidIP === 'function' && !isValidIP(trimmedIP)) { if (typeof showToast === 'function') { - showToast('Invalid IP address, CIDR, or hostname: ' + trimmedIP, 'error'); + showToast(t('settings.toast.invalid_ignore_ip', 'Invalid IP address, CIDR, or hostname') + ': ' + trimmedIP, 'error'); } return; } diff --git a/pkg/web/static/js/init.js b/pkg/web/static/js/init.js index 83fb725..bc85440 100644 --- a/pkg/web/static/js/init.js +++ b/pkg/web/static/js/init.js @@ -89,10 +89,12 @@ function initializeApp() { var updateHint = (typeof t === 'function' && translations && translations['footer.update_available']) ? translations['footer.update_available'].replace('{version}', data.latest_version || '') : ('Update available: v' + (data.latest_version || '')); + var safeHint = escapeHtml(updateHint); + var safeLabel = escapeHtml(latestLabel); if (data.update_available && data.latest_version) { - versionContainer.innerHTML = '' + updateHint + ''; + versionContainer.innerHTML = '' + safeHint + ''; } else { - versionContainer.innerHTML = '' + latestLabel + ''; + versionContainer.innerHTML = '' + safeLabel + ''; } }) .catch(function() { }); diff --git a/pkg/web/static/js/jails.js b/pkg/web/static/js/jails.js index 9441fa0..0c098f5 100644 --- a/pkg/web/static/js/jails.js +++ b/pkg/web/static/js/jails.js @@ -10,7 +10,7 @@ function createJail() { const content = document.getElementById('newJailContent').value.trim(); if (!jailName) { - showToast('Jail name is required', 'error'); + showToast(t('jails.toast.name_required', 'Jail name is required'), 'error'); return; } showLoading(true); @@ -32,16 +32,16 @@ function createJail() { }) .then(function(data) { if (data.error) { - showToast('Error creating jail: ' + data.error, 'error'); + showToast(t('jails.toast.create_error', 'Error creating jail') + ': ' + data.error, 'error'); return; } closeModal('createJailModal'); - showToast(data.message || 'Jail created successfully', 'success'); + showToast(data.message || t('jails.toast.create_success', 'Jail created successfully'), 'success'); openManageJailsModal(); }) .catch(function(err) { console.error('Error creating jail:', err); - showToast('Error creating jail: ' + (err.message || err), 'error'); + showToast(t('jails.toast.create_error', 'Error creating jail') + ': ' + (err.message || err), 'error'); }) .finally(function() { showLoading(false); @@ -74,7 +74,7 @@ function saveJailConfig() { }) .then(function(data) { if (data.error) { - showToast("Error saving config: " + data.error, 'error'); + showToast(t('jails.toast.save_config_error', 'Error saving config') + ': ' + data.error, 'error'); return; } closeModal('jailConfigModal'); @@ -94,7 +94,7 @@ function saveJailConfig() { }) .catch(function(err) { console.error("Error saving config:", err); - showToast("Error saving config: " + err.message, 'error'); + showToast(t('jails.toast.save_config_error', 'Error saving config') + ': ' + err.message, 'error'); }) .finally(function() { showLoading(false); @@ -209,7 +209,7 @@ function saveManageJailsSingle(checkbox) { } console.log('Jail state saved successfully:', data); - showToast(data.message || ('Jail ' + jailName + ' ' + (isEnabled ? 'enabled' : 'disabled') + ' successfully'), 'success'); + showToast(data.message || t(isEnabled ? 'jails.toast.enabled_success' : 'jails.toast.disabled_success', 'Jail {jail} ' + (isEnabled ? 'enabled' : 'disabled') + ' successfully').replace('{jail}', jailName), 'success'); return fetch(withServerParam('/api/jails/manage'), { headers: serverHeaders() }).then(function(res) { return res.json(); }) @@ -228,7 +228,7 @@ function saveManageJailsSingle(checkbox) { }) .catch(function(err) { console.error('Error saving jail settings:', err); - showToast("Error saving jail settings: " + (err.message || err), 'error'); + showToast(t('jails.toast.save_settings_error', 'Error saving jail settings') + ': ' + (err.message || err), 'error'); checkbox.checked = !isEnabled; }); } @@ -238,7 +238,7 @@ function saveManageJailsSingle(checkbox) { // ========================================================================= function deleteJail(jailName) { - if (!confirm('Are you sure you want to delete the jail "' + escapeHtml(jailName) + '"? This action cannot be undone.')) { + if (!confirm(t('jails.confirm.delete', 'Are you sure you want to delete the jail "{name}"? This action cannot be undone.').replace('{name}', jailName))) { return; } showLoading(true); @@ -256,16 +256,16 @@ function deleteJail(jailName) { }) .then(function(data) { if (data.error) { - showToast('Error deleting jail: ' + data.error, 'error'); + showToast(t('jails.toast.delete_error', 'Error deleting jail') + ': ' + data.error, 'error'); return; } - showToast(data.message || 'Jail deleted successfully', 'success'); + showToast(data.message || t('jails.toast.delete_success', 'Jail deleted successfully'), 'success'); openManageJailsModal(); refreshData({ silent: true }); }) .catch(function(err) { console.error('Error deleting jail:', err); - showToast('Error deleting jail: ' + (err.message || err), 'error'); + showToast(t('jails.toast.delete_error', 'Error deleting jail') + ': ' + (err.message || err), 'error'); }) .finally(function() { showLoading(false); @@ -356,11 +356,11 @@ function testLogpath() { var logpath = extractLogpathFromConfig(jailConfig); if (!logpath) { - showToast('No logpath found in jail configuration. Please add a logpath line (e.g., logpath = /var/log/example.log)', 'warning'); + showToast(t('jails.logpath_test.no_logpath', 'No logpath found in jail configuration. Please add a logpath line (e.g., logpath = /var/log/example.log)'), 'warning'); return; } var resultsDiv = document.getElementById('logpathResults'); - resultsDiv.textContent = 'Testing logpath...'; + resultsDiv.textContent = t('jails.logpath_test.testing', 'Testing logpath...'); resultsDiv.classList.remove('hidden'); resultsDiv.classList.remove('text-red-600', 'text-yellow-600'); showLoading(true); @@ -374,7 +374,7 @@ function testLogpath() { .then(function(data) { showLoading(false); if (data.error) { - resultsDiv.textContent = 'Error: ' + data.error; + resultsDiv.textContent = t('common.error', 'Error') + ': ' + data.error; resultsDiv.classList.add('text-red-600'); setTimeout(function() { resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -387,7 +387,7 @@ function testLogpath() { var output = ''; if (results.length === 0) { - output = '
No logpath entries found.
'; + output = '
' + t('jails.logpath_test.no_entries', 'No logpath entries found.') + '
'; resultsDiv.innerHTML = output; resultsDiv.classList.add('text-yellow-600'); return; @@ -405,23 +405,23 @@ function testLogpath() { } output += '
'; - output += '
Logpath ' + (idx + 1) + ':
'; + output += '
' + t('jails.logpath_test.entry', 'Logpath {num}:').replace('{num}', idx + 1) + '
'; output += '
' + escapeHtml(logpath) + '
'; if (resolvedPath && resolvedPath !== logpath) { - output += '
Resolved: ' + escapeHtml(resolvedPath) + '
'; + output += '
' + t('jails.logpath_test.resolved', 'Resolved:') + ' ' + escapeHtml(resolvedPath) + '
'; } output += '
'; output += '
'; output += '
'; if (isLocalServer) { - output += 'In fail2ban-ui Container:'; + output += '' + t('jails.logpath_test.in_container', 'In fail2ban-ui Container:') + ''; } else { - output += 'On Remote Server:'; + output += '' + t('jails.logpath_test.on_remote', 'On Remote Server:') + ''; } if (error) { output += ''; - output += 'Error: ' + escapeHtml(error) + ''; + output += '' + t('common.error', 'Error') + ': ' + escapeHtml(error) + ''; } else if (found) { output += ''; output += '' @@ -430,9 +430,9 @@ function testLogpath() { } else { output += ''; if (isLocalServer) { - output += 'Not found (logs may not be mounted to container)'; + output += '' + t('jails.logpath_test.not_found_container', 'Not found (logs may not be mounted to container)') + ''; } else { - output += 'Not found'; + output += '' + t('jails.logpath_test.not_found', 'Not found') + ''; } } output += '
'; diff --git a/pkg/web/static/js/modals.js b/pkg/web/static/js/modals.js index 14daefc..a912a61 100644 --- a/pkg/web/static/js/modals.js +++ b/pkg/web/static/js/modals.js @@ -75,7 +75,7 @@ function ensureBanEventDetail(event) { // Whois modal function openWhoisModal(eventIndex) { if (!latestBanEvents || !latestBanEvents[eventIndex]) { - showToast("Event not found", 'error'); + showToast(t('modal.toast.event_not_found', 'Event not found'), 'error'); return; } var event = latestBanEvents[eventIndex]; @@ -87,14 +87,14 @@ function openWhoisModal(eventIndex) { .then(function() { if (!event.whois || !event.whois.trim()) { closeModal('whoisModal'); - showToast("No whois data available for this event", 'info'); + showToast(t('modal.toast.no_whois', 'No whois data available for this event'), 'info'); return; } contentEl.textContent = event.whois; }) .catch(function(err) { closeModal('whoisModal'); - showToast("Error loading whois data: " + err, 'error'); + showToast(t('modal.toast.whois_error', 'Error loading whois data') + ': ' + err, 'error'); }); } @@ -133,7 +133,7 @@ function renderLogsModalContent(event) { // Logs modal function openLogsModal(eventIndex) { if (!latestBanEvents || !latestBanEvents[eventIndex]) { - showToast("Event not found", 'error'); + showToast(t('modal.toast.event_not_found', 'Event not found'), 'error'); return; } var event = latestBanEvents[eventIndex]; @@ -146,14 +146,14 @@ function openLogsModal(eventIndex) { .then(function() { if (!event.logs || !event.logs.trim()) { closeModal('logsModal'); - showToast("No logs data available for this event", 'info'); + showToast(t('modal.toast.no_logs', 'No logs data available for this event'), 'info'); return; } renderLogsModalContent(event); }) .catch(function(err) { closeModal('logsModal'); - showToast("Error loading logs data: " + err, 'error'); + showToast(t('modal.toast.logs_error', 'Error loading logs data') + ': ' + err, 'error'); }); } @@ -356,7 +356,7 @@ function openManageJailsModal() { .then(res => res.json()) .then(data => { if (!data.jails || !data.jails.length) { - showToast("No jails found for this server.", 'info'); + showToast(t('modal.toast.no_jails', 'No jails found for this server.'), 'info'); return; } @@ -419,7 +419,7 @@ function openManageJailsModal() { openModal('manageJailsModal'); }) - .catch(err => showToast("Error fetching jails: " + err, 'error')) + .catch(err => showToast(t('modal.toast.fetch_jails_error', 'Error fetching jails') + ': ' + err, 'error')) .finally(() => showLoading(false)); } @@ -497,7 +497,7 @@ function openJailConfigModal(jailName) { .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - showToast("Error loading config: " + data.error, 'error'); + showToast(t('modal.toast.load_config_error', 'Error loading config') + ': ' + data.error, 'error'); return; } filterTextArea.value = data.filter || ''; @@ -529,6 +529,7 @@ function openJailConfigModal(jailName) { localServerHint.classList.add('hidden'); } + jailTextArea.removeEventListener('input', updateLogpathButtonVisibility); jailTextArea.addEventListener('input', updateLogpathButtonVisibility); preventExtensionInterference(filterTextArea); @@ -541,7 +542,7 @@ function openJailConfigModal(jailName) { }, 200); }) .catch(function(err) { - showToast("Error: " + err, 'error'); + showToast(t('common.error', 'Error') + ': ' + err, 'error'); }) .finally(function() { showLoading(false); diff --git a/pkg/web/static/js/servers.js b/pkg/web/static/js/servers.js index 9d88ed2..268ec26 100644 --- a/pkg/web/static/js/servers.js +++ b/pkg/web/static/js/servers.js @@ -1,6 +1,38 @@ // Server management javascript functions for Fail2ban UI "use strict"; +// ========================================================================= +// "Selected server persistence" for the browser server-dropdown +// ========================================================================= + +var SELECTED_SERVER_KEY = 'fail2ban-ui.selectedServerId'; + +function getStoredServerId() { + try { + return window.localStorage.getItem(SELECTED_SERVER_KEY) || null; + } catch (e) { + return null; + } +} + +function setStoredServerId(id) { + try { + if (id) { + window.localStorage.setItem(SELECTED_SERVER_KEY, id); + } else { + window.localStorage.removeItem(SELECTED_SERVER_KEY); + } + } catch (e) { + } +} + +function clearStoredServerId() { + try { + window.localStorage.removeItem(SELECTED_SERVER_KEY); + } catch (e) { + } +} + // ========================================================================= // Server data loading // ========================================================================= @@ -16,6 +48,16 @@ function loadServers() { currentServer = null; } else { var desired = currentServerId; + if (!desired) { + var stored = getStoredServerId(); + if (stored) { + if (enabledServers.some(function(s) { return s.id === stored; })) { + desired = stored; + } else { + clearStoredServerId(); + } + } + } var selected = desired ? enabledServers.find(function(s) { return s.id === desired; }) : null; if (!selected) { var def = enabledServers.find(function(s) { return s.isDefault; }); @@ -185,7 +227,7 @@ function renderServerManagerList() { + (server.isDefault ? '' : '') + ' ' + (server.enabled ? (server.type === 'local' - ? '' + ? '' : '') : '') + ' ' + ' ' @@ -234,7 +276,16 @@ function setCurrentServer(serverId) { currentServer = next || null; currentServerId = currentServer ? currentServer.id : null; } + // We remember the manual choice so it can be autoselected after page reload + if (currentServerId) { + setStoredServerId(currentServerId); + } else { + clearStoredServerId(); + } jailBannedState = {}; + latestSummary = null; + latestSummaryServerId = null; + latestServerInsights = null; renderServerSelector(); renderServerSubtitle(); updateRestartBanner(); @@ -262,6 +313,7 @@ function resetServerForm() { document.getElementById('serverTags').value = ''; document.getElementById('serverDefault').checked = false; document.getElementById('serverEnabled').checked = false; + document.getElementById('serverReverseTunnel').checked = false; populateSSHKeySelect(sshKeysCache || [], ''); onServerTypeChange('local'); } @@ -285,6 +337,7 @@ function editServer(serverId) { document.getElementById('serverTags').value = (server.tags || []).join(','); document.getElementById('serverDefault').checked = !!server.isDefault; document.getElementById('serverEnabled').checked = !!server.enabled; + document.getElementById('serverReverseTunnel').checked = !!server.reverseTunnelEnabled; onServerTypeChange(server.type || 'local'); if ((server.type || 'local') === 'ssh') { loadSSHKeys().then(function(keys) { @@ -401,7 +454,8 @@ function submitServerForm(event) { tags: document.getElementById('serverTags').value ? document.getElementById('serverTags').value.split(',').map(function(tag) { return tag.trim(); }).filter(Boolean) : [], - enabled: document.getElementById('serverEnabled').checked + enabled: document.getElementById('serverEnabled').checked, + reverseTunnelEnabled: document.getElementById('serverReverseTunnel').checked }; var nameKey = normalizeNameForCompare(payload.name); if (!nameKey) { @@ -457,6 +511,7 @@ function submitServerForm(event) { if (payload.type !== 'ssh') { delete payload.sshUser; delete payload.sshKeyPath; + delete payload.reverseTunnelEnabled; } if (payload.type !== 'agent') { delete payload.agentUrl; @@ -480,7 +535,7 @@ function submitServerForm(event) { .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - showToast(formatApiError(data, '', 'Error saving server'), 'error'); + showToast(formatApiError(data, 'servers.toast.save_error', 'Error saving server'), 'error'); return; } showToast(t('servers.form.success', 'Server saved successfully.'), 'success'); @@ -508,7 +563,7 @@ function submitServerForm(event) { }); }) .catch(function(err) { - showToast('Error saving server: ' + err, 'error'); + showToast(t('servers.toast.save_error', 'Error saving server') + ': ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -617,12 +672,17 @@ function setServerEnabled(serverId, enabled) { .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - showToast(formatApiError(data, '', 'Error saving server'), 'error'); + showToast(formatApiError(data, 'servers.toast.save_error', 'Error saving server'), 'error'); return; } - if (!enabled && currentServerId === serverId) { - currentServerId = null; - currentServer = null; + if (!enabled) { + if (getStoredServerId() === serverId) { + clearStoredServerId(); + } + if (currentServerId === serverId) { + currentServerId = null; + currentServer = null; + } } if (data.jailLocalWarning) { showToast(t('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. See docs for permissions.'), 'warning', 12000); @@ -641,7 +701,7 @@ function setServerEnabled(serverId, enabled) { }); }) .catch(function(err) { - showToast('Error saving server: ' + err, 'error'); + showToast(t('servers.toast.save_error', 'Error saving server') + ': ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -680,9 +740,12 @@ function deleteServer(serverId) { .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - showToast(formatApiError(data, '', 'Error deleting server'), 'error'); + showToast(formatApiError(data, 'servers.toast.delete_error', 'Error deleting server'), 'error'); return; } + if (getStoredServerId() === serverId) { + clearStoredServerId(); + } if (currentServerId === serverId) { currentServerId = null; currentServer = null; @@ -697,7 +760,7 @@ function deleteServer(serverId) { }); }) .catch(function(err) { - showToast('Error deleting server: ' + err, 'error'); + showToast(t('servers.toast.delete_error', 'Error deleting server') + ': ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -710,7 +773,7 @@ function makeDefaultServer(serverId) { .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - showToast(formatApiError(data, '', 'Error setting default server'), 'error'); + showToast(formatApiError(data, 'servers.toast.set_default_error', 'Error setting default server'), 'error'); return; } currentServerId = data.server ? data.server.id : serverId; @@ -724,7 +787,7 @@ function makeDefaultServer(serverId) { }); }) .catch(function(err) { - showToast('Error setting default server: ' + err, 'error'); + showToast(t('servers.toast.set_default_error', 'Error setting default server') + ': ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -733,14 +796,14 @@ function makeDefaultServer(serverId) { function restartFail2banServer(serverId) { if (!serverId) { - showToast("No server selected", 'error'); + showToast(t('servers.toast.none_selected', 'No server selected'), 'error'); return; } var server = serversCache.find(function(s) { return s.id === serverId; }); var isLocal = server && server.type === 'local'; var confirmMsg = isLocal - ? "Reload Fail2ban configuration on this server now? This will reload the configuration without restarting the service." - : "Keep in mind that while fail2ban is restarting, logs are not being parsed and no IP addresses are blocked. Restart fail2ban on this server now? This will take some time."; + ? t('servers.confirm.reload_local', 'Reload Fail2ban configuration on this server now? This will reload the configuration without restarting the service.') + : t('servers.confirm.restart_remote', 'Keep in mind that while fail2ban is restarting, logs are not being parsed and no IP addresses are blocked. Restart fail2ban on this server now? This will take some time.'); if (!confirm(confirmMsg)) return; showLoading(true); fetch(appPath('/api/fail2ban/restart?serverId=' + encodeURIComponent(serverId)), { @@ -750,7 +813,7 @@ function restartFail2banServer(serverId) { .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { - showToast(formatApiError(data, '', 'Failed to restart Fail2ban'), 'error'); + showToast(formatApiError(data, 'servers.toast.restart_failed', 'Failed to restart Fail2ban'), 'error'); return; } var mode = data.mode || 'restart'; @@ -769,7 +832,7 @@ function restartFail2banServer(serverId) { }); }) .catch(function(err) { - showToast("Failed to restart Fail2ban: " + err, 'error'); + showToast(t('servers.toast.restart_failed', 'Failed to restart Fail2ban') + ': ' + err, 'error'); }) .finally(function() { showLoading(false); @@ -777,6 +840,6 @@ function restartFail2banServer(serverId) { } function restartFail2ban() { - if (!confirm("Keep in mind that while fail2ban is restarting, logs are not being parsed and no IP addresses are blocked. Restart fail2ban now? This will take some time.")) return; + if (!confirm(t('servers.confirm.restart', 'Keep in mind that while fail2ban is restarting, logs are not being parsed and no IP addresses are blocked. Restart fail2ban now? This will take some time.'))) return; restartFail2banServer(currentServerId); } diff --git a/pkg/web/static/js/settings.js b/pkg/web/static/js/settings.js index 8e616fd..9b43882 100644 --- a/pkg/web/static/js/settings.js +++ b/pkg/web/static/js/settings.js @@ -138,8 +138,12 @@ function loadSettings() { onGeoIPProviderChange(geoipProvider); document.getElementById('geoipDatabasePath').value = data.geoipDatabasePath || '/usr/share/GeoIP/GeoLite2-Country.mmdb'; document.getElementById('maxLogLines').value = data.maxLogLines || 50; + document.getElementById('eventRetentionDays').value = (typeof data.eventRetentionDays === 'number') ? data.eventRetentionDays : 180; document.getElementById('banTime').value = data.bantime || ''; document.getElementById('bantimeRndtime').value = data.bantimeRndtime || ''; + document.getElementById('bantimeMaxtime').value = data.bantimeMaxtime || ''; + document.getElementById('bantimeFactor').value = data.bantimeFactor || ''; + document.getElementById('bantimeOveralljails').checked = data.bantimeOveralljails || false; document.getElementById('findTime').value = data.findtime || ''; document.getElementById('maxRetry').value = data.maxretry || ''; document.getElementById('defaultChain').value = data.chain || 'INPUT'; @@ -153,7 +157,7 @@ function loadSettings() { loadPermanentBlockLog(); }) .catch(err => { - showToast('Error loading settings: ' + err, 'error'); + showToast(t('settings.toast.load_error', 'Error loading settings') + ': ' + err, 'error'); }) .finally(() => showLoading(false)); } @@ -166,7 +170,7 @@ function saveSettings(event) { event.preventDefault(); if (!validateAllSettings()) { - showToast('Please fix validation errors before saving', 'error'); + showToast(t('settings.toast.fix_validation', 'Please fix validation errors before saving'), 'error'); return; } @@ -174,24 +178,27 @@ function saveSettings(event) { const smtpPort = parseInt(document.getElementById('smtpPort').value, 10); if (isNaN(smtpPort) || smtpPort < 1 || smtpPort > 65535) { - showToast('SMTP port must be between 1 and 65535', 'error'); + showToast(t('settings.toast.smtp_port_invalid', 'SMTP port must be between 1 and 65535'), 'error'); showLoading(false); return; } + const authMethod = document.getElementById('smtpAuthMethod').value || 'auto'; + const isNone = authMethod === 'none'; const smtpSettings = { host: document.getElementById('smtpHost').value.trim(), port: smtpPort, - username: document.getElementById('smtpUsername').value.trim(), - password: document.getElementById('smtpPassword').value.trim(), + username: isNone ? '' : document.getElementById('smtpUsername').value.trim(), + password: isNone ? '' : document.getElementById('smtpPassword').value.trim(), from: document.getElementById('smtpFrom').value.trim(), useTLS: document.getElementById('smtpUseTLS').checked, insecureSkipVerify: document.getElementById('smtpInsecureSkipVerify').checked, - authMethod: document.getElementById('smtpAuthMethod').value || 'auto', + authMethod: authMethod, }; + const eventRetentionRaw = parseInt(document.getElementById('eventRetentionDays').value, 10); + const eventRetentionDays = isNaN(eventRetentionRaw) ? 180 : Math.max(eventRetentionRaw, 0); const selectedCountries = Array.from(document.getElementById('alertCountries').selectedOptions).map(opt => opt.value); - const callbackURLInput = document.getElementById('callbackURL'); let callbackUrl = callbackURLInput.value.trim(); const currentPort = parseInt(document.getElementById('uiPort').value, 10) || 8080; @@ -215,6 +222,9 @@ function saveSettings(event) { defaultJailEnable: document.getElementById('defaultJailEnable').checked, bantime: document.getElementById('banTime').value.trim(), bantimeRndtime: document.getElementById('bantimeRndtime').value.trim(), + bantimeMaxtime: document.getElementById('bantimeMaxtime').value.trim(), + bantimeFactor: document.getElementById('bantimeFactor').value.trim(), + bantimeOveralljails: document.getElementById('bantimeOveralljails').checked, findtime: document.getElementById('findTime').value.trim(), maxretry: parseInt(document.getElementById('maxRetry').value, 10) || 3, ignoreips: getIgnoreIPsArray(), @@ -224,6 +234,7 @@ function saveSettings(event) { geoipProvider: document.getElementById('geoipProvider').value || 'builtin', geoipDatabasePath: document.getElementById('geoipDatabasePath').value || '/usr/share/GeoIP/GeoLite2-Country.mmdb', maxLogLines: parseInt(document.getElementById('maxLogLines').value, 10) || 50, + eventRetentionDays: eventRetentionDays, alertProvider: document.getElementById('alertProvider').value || 'email', smtp: smtpSettings, webhook: collectWebhookSettings(), @@ -240,7 +251,7 @@ function saveSettings(event) { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Error saving settings: ' + (data.error + (data.details || '')), 'error'); + showToast(t('settings.toast.save_error', 'Error saving settings') + ': ' + (data.error + (data.details || '')), 'error'); } else { var selectedLang = $('#languageSelect').val(); loadTranslations(selectedLang); @@ -250,7 +261,7 @@ function saveSettings(event) { if (Array.isArray(data.warnings) && data.warnings.length > 0) { const warningPreview = data.warnings.slice(0, 2).join(' | '); - showToast('Settings saved with warnings: ' + warningPreview, 'info'); + showToast(t('settings.toast.saved_warnings', 'Settings saved with warnings') + ': ' + warningPreview, 'info'); console.warn('Settings warnings:', data.warnings); } if (data.restartNeeded) { @@ -263,7 +274,7 @@ function saveSettings(event) { } } }) - .catch(err => showToast('Error saving settings: ' + err, 'error')) + .catch(err => showToast(t('settings.toast.save_error', 'Error saving settings') + ': ' + err, 'error')) .finally(() => showLoading(false)); } @@ -281,6 +292,27 @@ function updateAlertProviderFields() { if (esDiv) esDiv.classList.toggle('hidden', selected !== 'elasticsearch'); } +function updateSmtpAuthOnChange() { + updateSmtpAuthFields(); +} + +// Wire up auth method change handler +document.addEventListener('DOMContentLoaded', function() { + const authSelect = document.getElementById('smtpAuthMethod'); + if (authSelect) { + authSelect.addEventListener('change', updateSmtpAuthOnChange); + } +}); + +function updateSmtpAuthFields() { + const authMethod = document.getElementById('smtpAuthMethod').value; + const isNone = authMethod === 'none'; + const smtpUsername = document.getElementById('smtpUsername'); + const smtpPassword = document.getElementById('smtpPassword'); + if (smtpUsername) smtpUsername.disabled = isNone; + if (smtpPassword) smtpPassword.disabled = isNone; +} + function updateAlertFieldsState() { const alertsForBans = document.getElementById('emailAlertsForBans').checked; const alertsForUnbans = document.getElementById('emailAlertsForUnbans').checked; @@ -302,6 +334,11 @@ function updateAlertFieldsState() { if (field) field.disabled = !alertsEnabled; }); + // Re-apply auth method field state after enabling + if (alertsEnabled) { + updateSmtpAuthFields(); + } + const providerSelect = document.getElementById('alertProvider'); if (providerSelect) providerSelect.disabled = !alertsEnabled; } @@ -323,12 +360,12 @@ function sendTestEmail() { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Error sending test email: ' + data.error, 'error'); + showToast(t('settings.toast.test_email_error', 'Error sending test email') + ': ' + data.error, 'error'); } else { - showToast('Test email sent successfully!', 'success'); + showToast(t('settings.toast.test_email_success', 'Test email sent successfully!'), 'success'); } }) - .catch(error => showToast('Error sending test email: ' + error, 'error')) + .catch(error => showToast(t('settings.toast.test_email_error', 'Error sending test email') + ': ' + error, 'error')) .finally(() => showLoading(false)); } @@ -377,12 +414,12 @@ function sendTestWebhook() { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Webhook test failed: ' + data.error, 'error'); + showToast(t('settings.toast.webhook_test_failed', 'Webhook test failed') + ': ' + data.error, 'error'); } else { - showToast('Test webhook sent successfully!', 'success'); + showToast(t('settings.toast.webhook_test_success', 'Test webhook sent successfully!'), 'success'); } }) - .catch(error => showToast('Webhook test failed: ' + error, 'error')) + .catch(error => showToast(t('settings.toast.webhook_test_failed', 'Webhook test failed') + ': ' + error, 'error')) .finally(() => showLoading(false)); } @@ -420,12 +457,12 @@ function sendTestElasticsearch() { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Elasticsearch test failed: ' + data.error, 'error'); + showToast(t('settings.toast.es_test_failed', 'Elasticsearch test failed') + ': ' + data.error, 'error'); } else { - showToast('Test document indexed successfully!', 'success'); + showToast(t('settings.toast.es_test_success', 'Test document indexed successfully!'), 'success'); } }) - .catch(error => showToast('Elasticsearch test failed: ' + error, 'error')) + .catch(error => showToast(t('settings.toast.es_test_failed', 'Elasticsearch test failed') + ': ' + error, 'error')) .finally(() => showLoading(false)); } @@ -475,11 +512,11 @@ function copyElasticsearchTemplate(btn) { navigator.clipboard.writeText(pre.textContent).then(() => { const label = btn.querySelector('span'); if (label) { - label.textContent = 'Copied!'; - setTimeout(() => { label.textContent = 'Copy'; }, 2000); + label.textContent = t('common.copied', 'Copied!'); + setTimeout(() => { label.textContent = t('common.copy', 'Copy'); }, 2000); } }).catch(() => { - showToast('Failed to copy to clipboard', 'error'); + showToast(t('settings.toast.copy_failed', 'Failed to copy to clipboard'), 'error'); }); } @@ -583,13 +620,13 @@ function loadPermanentBlockLog() { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Error loading permanent block log: ' + data.error, 'error'); + showToast(t('settings.toast.block_log_error', 'Error loading permanent block log') + ': ' + data.error, 'error'); return; } renderPermanentBlockLog(data.blocks || []); }) .catch(err => { - showToast('Error loading permanent block log: ' + err, 'error'); + showToast(t('settings.toast.block_log_error', 'Error loading permanent block log') + ': ' + err, 'error'); }); } @@ -700,7 +737,7 @@ function openAdvancedTestModal() { function submitAdvancedTest(action) { const ipValue = document.getElementById('advancedTestIP').value.trim(); if (!ipValue) { - showToast('Please enter an IP address.', 'info'); + showToast(t('settings.toast.enter_ip', 'Please enter an IP address.'), 'info'); return; } showLoading(true); @@ -712,13 +749,13 @@ function submitAdvancedTest(action) { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Advanced action failed: ' + data.error, 'error'); + showToast(t('settings.toast.advanced_action_failed', 'Advanced action failed') + ': ' + data.error, 'error'); } else { - showToast(data.message || 'Action completed', data.info ? 'info' : 'success'); + showToast(data.message || t('settings.toast.action_completed', 'Action completed'), data.info ? 'info' : 'success'); loadPermanentBlockLog(); } }) - .catch(err => showToast('Advanced action failed: ' + err, 'error')) + .catch(err => showToast(t('settings.toast.advanced_action_failed', 'Advanced action failed') + ': ' + err, 'error')) .finally(() => { showLoading(false); closeModal('advancedTestModal'); @@ -739,13 +776,13 @@ function advancedUnblockIP(ip, event) { .then(res => res.json()) .then(data => { if (data.error) { - showToast('Failed to remove IP: ' + data.error, 'error'); + showToast(t('settings.toast.remove_ip_failed', 'Failed to remove IP') + ': ' + data.error, 'error'); } else { - showToast(data.message || 'IP removed', 'success'); + showToast(data.message || t('settings.toast.ip_removed', 'IP removed'), 'success'); loadPermanentBlockLog(); } }) - .catch(err => showToast('Failed to remove IP: ' + err, 'error')); + .catch(err => showToast(t('settings.toast.remove_ip_failed', 'Failed to remove IP') + ': ' + err, 'error')); } // ========================================================================= @@ -769,9 +806,16 @@ if (threatIntelProviderSelect) { function toggleCallbackSecretVisibility() { const input = document.getElementById('callbackSecret'); const link = document.getElementById('toggleCallbackSecretLink'); - + if (!input || !link) return; - + + // The backend masks stored secrets with a sentinel; revealing it would only + // show the placeholder string, so explain instead of "revealing". + if (input.value === '__f2bui_secret_unchanged__') { + link.textContent = t('settings.callback_secret.hidden', 'secret is stored on the server and never displayed'); + return; + } + const isPassword = input.type === 'password'; input.type = isPassword ? 'text' : 'password'; link.textContent = isPassword diff --git a/pkg/web/static/js/threat-intel.js b/pkg/web/static/js/threat-intel.js index 2689484..3ededf2 100644 --- a/pkg/web/static/js/threat-intel.js +++ b/pkg/web/static/js/threat-intel.js @@ -205,43 +205,6 @@ function renderThreatIntelHero(opts) { return html; } -function buildThreatIntelCollapsibleList(items, selectedIP, suffix) { - var safeItems = Array.isArray(items) ? items : []; - if (!safeItems.length) { - return '

' + escapeHtml(t('threat.empty.list', 'No data available.')) + '

'; - } - - var visible = safeItems.slice(0, 5); - var hidden = safeItems.slice(5); - var hiddenId = 'threat-intel-list-hidden-' + suffix + '-' + tiSlug(selectedIP || 'ip'); - var toggleId = 'threat-intel-list-toggle-' + suffix + '-' + tiSlug(selectedIP || 'ip'); - var html = '
'; - - visible.forEach(function(item) { - html += threatIntelListRow(item.left, item.right, item.meta); - }); - html += '
'; - - if (hidden.length) { - html += ''; - var moreLabel = t('dashboard.banned.show_more', 'Show more') + ' +' + hidden.length; - var lessLabel = t('dashboard.banned.show_less', 'Hide extra'); - html += ''; - } - return html; -} - function buildThreatIntelCollapsibleEntries(entries, selectedIP, suffix, visibleCount) { var safeEntries = Array.isArray(entries) ? entries.filter(Boolean) : []; if (!safeEntries.length) { @@ -281,14 +244,6 @@ function buildThreatIntelCollapsibleEntries(entries, selectedIP, suffix, visible return html; } -function threatIntelListRow(left, right, meta) { - var html = '
' + escapeHtml(tiValue(left)) + '' + escapeHtml(tiValue(right)) + '
'; - if (meta) { - html += '

' + escapeHtml(meta) + '

'; - } - return html; -} - function threatIntelMetricCard(label, value, extraClass) { return '' + '
' @@ -322,14 +277,10 @@ function renderAbuseIpDbReportItem(report, selectedIP, idx) { var html = '
'; html += '
'; html += ' ' + escapeHtml(tiDate(report && report.reportedAt)) + ''; - //html += ' ' + escapeHtml(t('threat.field.categories', 'Categories')) + ': ' + escapeHtml(String(categoryLabels.length)) + ''; if (categoryLabels.length) { html += '' + escapeHtml(categoryLabels.join(', ')) + ''; } html += '
'; - //if (categoryLabels.length) { - // html += '

' + escapeHtml(categoryLabels.join(', ')) + '

'; - //} html += '

' + escapeHtml(metaLine) + '

'; html += commentHtml; html += '
'; diff --git a/pkg/web/static/js/validation.js b/pkg/web/static/js/validation.js index 07e7118..55d6455 100644 --- a/pkg/web/static/js/validation.js +++ b/pkg/web/static/js/validation.js @@ -31,11 +31,14 @@ function validateMaxRetry(value) { function validateEmail(value) { if (!value || !value.trim()) return { valid: true }; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailPattern.test(value.trim())) { - return { - valid: false, - message: 'Invalid email format' - }; + const emails = value.split(',').map(s => s.trim()).filter(s => s); + for (const email of emails) { + if (!emailPattern.test(email)) { + return { + valid: false, + message: 'Invalid email format: "' + email + '"' + }; + } } return { valid: true }; } diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 7edb1fa..193fd7c 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -123,8 +123,8 @@