diff --git a/LICENSE b/LICENSE
index f288702..be3f7b2 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,5 +1,5 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
+our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
+software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
- "This License" refers to version 3 of the GNU General Public License.
+ "This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
- 13. Use with the GNU Affero General Public License.
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
+under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
+Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
+GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
+versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
+ it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
+ GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License
+ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
+For more information on this, and how to apply and follow the GNU AGPL, see
.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
diff --git a/README.md b/README.md
index 4314c53..40d21c5 100644
--- a/README.md
+++ b/README.md
@@ -2,15 +2,15 @@
**Enterprise-Grade Intrusion Detection System Management Platform**
-[](https://www.gnu.org/licenses/gpl-3.0)
+[](https://www.gnu.org/licenses/agpl-3.0)
[](https://golang.org/)
[](https://www.linux.org/)
Fail2Ban UI is a management platform for operating Fail2Ban across one or more Linux hosts. It provides a central place to review bans, search and unban IP addresses, manage jails and filters, and receive notifications.
-The project is maintained by Swissmakers GmbH and released under GPL-3.0.
+The project is maintained by Swissmakers GmbH and released under AGPL-3.0.
-[Quick start](#quick-start-container) • [Documentation](#documentation) • [Configuration reference](docs/configuration.md) • [Architecture](docs/architecture.md) • [Screenshots](#screenshots)
+[Quick start](#quick-start-container) - [Documentation](#documentation) - [Configuration reference](docs/configuration.md) - [Architecture](docs/architecture.md) - [Screenshots](#screenshots)
## What this project does
@@ -70,7 +70,7 @@ podman run -d --name fail2ban-ui --network=host \
### Verification
1. Open `http://localhost:8080`.
-2. In the UI, go to **Settings → Manage Servers**, enable the local connector, and click **Test connection**.
+2. In the UI, go to **Settings -> Manage Servers**, enable the local connector, and click **Test connection**.
### Next steps
@@ -140,7 +140,7 @@ When clicking on "Edit Filter / Jail" the Jail configuration editor is opened. I
#### Logpath Test

-Logpath testing functionality that verifies log file paths and checks if files are accessible. Shows test results with visual indicators (✓/✗) for each log path.
+Logpath testing functionality that verifies log file paths and checks if files are accessible. Shows test results with visual indicators (yes/no) for each log path.
#### Create new Filter

@@ -212,4 +212,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
## License
-GPL-3.0. See [LICENSE](LICENSE).
+AGPL-3.0. See [LICENSE](LICENSE).
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 66cafcd..6539f74 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -109,14 +109,12 @@ func main() {
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
if settings.Debug {
gin.SetMode(gin.DebugMode)
} else {
gin.SetMode(gin.ReleaseMode)
}
- // Initialize router
router := gin.Default()
serverPort := strconv.Itoa(int(settings.Port))
bindAddress, _ := config.GetBindAddressFromEnv()
@@ -135,7 +133,6 @@ func main() {
web.SetConsoleLogEnabled(enabled)
})
- // Register routes
web.RegisterRoutes(router, wsHub)
isLOTRMode := config.IsLOTRModeActive(settings.AlertCountries)
printWelcomeBanner(bindAddress, serverPort, isLOTRMode)
@@ -159,7 +156,6 @@ func main() {
}
}
-// Print welcome banner.
func printWelcomeBanner(bindAddress, appPort string, isLOTRMode bool) {
greeting := getGreeting()
diff --git a/deployment/container/README.md b/deployment/container/README.md
index c222ee0..168866a 100644
--- a/deployment/container/README.md
+++ b/deployment/container/README.md
@@ -73,6 +73,8 @@ podman build -t fail2ban-ui:v1.0.0 .
podman build --no-cache -t fail2ban-ui:dev .
```
+The image is the usable under the name: "`localhost/fail2ban-ui:dev`"
+
## Running the container
### Basic run command (local Fail2Ban instance)
@@ -177,7 +179,7 @@ For the full list, including `CALLBACK_URL`, `CALLBACK_SECRET`, `BASE_PATH`, and
### First launch
1. **Open the web interface** at `http://localhost:8080` (or your configured port).
-2. **Add your first server** under **Settings → Manage Servers**:
+2. **Add your first server** under **Settings -> Manage Servers**:
- Local server: enable the local connector if Fail2Ban runs on the same host.
- Remote server: add a server over SSH or through an API agent.
3. **Configure the settings**
@@ -269,10 +271,10 @@ Characteristics:
Volume structure:
```
-./fail2ban-config/fail2ban → /config/fail2ban (fail2ban container)
-./fail2ban-config/fail2ban → /etc/fail2ban (fail2ban-ui container)
-./f2b-run → /var/run/fail2ban (both containers)
-./config → /config (fail2ban-ui container)
+./fail2ban-config/fail2ban -> /config/fail2ban (fail2ban container)
+./fail2ban-config/fail2ban -> /etc/fail2ban (fail2ban-ui container)
+./f2b-run -> /var/run/fail2ban (both containers)
+./config -> /config (fail2ban-ui container)
```
**Important:**
@@ -295,7 +297,7 @@ Modern distributions - Rocky Linux 9+, RHEL 9+, Fedora 36+, Debian 12+ - use **n
Solution - switch Fail2Ban to nftables-based actions:
1. Open the Fail2Ban UI web interface.
-2. Go to **Settings → Fail2Ban Settings**.
+2. Go to **Settings -> Fail2Ban Settings**.
3. Change **Banaction** from `iptables-multiport` to `nftables-multiport`.
4. Change **Banaction Allports** from `iptables-allports` to `nftables-allports`.
5. Save; Fail2Ban reloads automatically.
@@ -396,7 +398,7 @@ If Fail2Ban fails to ban IPs with errors related to iptables or multiport extens
Symptoms: empty dashboard, no servers visible.
-1. Go to **Settings → Manage Servers** in the web UI.
+1. Go to **Settings -> Manage Servers** in the web UI.
2. Enable the **Local Connector** if Fail2Ban runs locally.
3. Add a remote server over SSH or an API agent.
4. Verify the server connection status.
diff --git a/development/oidc/README.md b/development/oidc/README.md
index 0090f5e..c772f7f 100644
--- a/development/oidc/README.md
+++ b/development/oidc/README.md
@@ -106,7 +106,7 @@ podman compose up -d
rm -rf config/keycloak-client-secret
podman compose up -d
```
- Alternatively, update the client manually in the Keycloak admin console: **Clients → fail2ban-ui**, then adjust "Valid redirect URIs" and "Valid post logout redirect URIs" and save.
+ Alternatively, update the client manually in the Keycloak admin console: **Clients -> fail2ban-ui**, then adjust "Valid redirect URIs" and "Valid post logout redirect URIs" and save.
## Setup
@@ -186,7 +186,7 @@ If the automatic configuration fails, create the client by hand:
podman compose restart fail2ban-ui
```
7. **Create a test user** (recommended):
- - **Users → Create new user**, set a username (for example `testuser`) and optionally an email. Click **Create**.
+ - **Users -> Create new user**, set a username (for example `testuser`) and optionally an email. Click **Create**.
- On the **Credentials** tab, set a password and toggle **Temporary** off, so no password reset is forced on first login. Save.
Then open `http://localhost:3080`: you are redirected to the Keycloak login, and after authentication back to Fail2Ban UI.
@@ -292,7 +292,7 @@ Provider-specific issuer URLs:
- Logs: `podman logs DEV_keycloak`
- Port mapping: `netstat -tlnp | grep 3000`
- The database must be ready first (its own health check).
-- Keycloak needs 30–60 seconds; wait for "Keycloak started" in the logs.
+- Keycloak needs 30-60 seconds; wait for "Keycloak started" in the logs.
- Keycloak 26+ serves health endpoints on port 9000:
```bash
# From the host (if port 9000 is exposed):
diff --git a/docs/alert-providers.md b/docs/alert-providers.md
index ccd6fb1..4397ec7 100644
--- a/docs/alert-providers.md
+++ b/docs/alert-providers.md
@@ -304,15 +304,15 @@ PUT _index_template/fail2ban
#### 2. Create an API key
-In Kibana: **Stack Management → API Keys → Create API key**. The key needs write access to the `fail2ban-events-`* indices.
+In Kibana: **Stack Management -> API Keys -> Create API key**. The key needs write access to the `fail2ban-events-`* indices.
#### 3. Configure Fail2Ban UI
-Enter the Elasticsearch URL, index name, and API key under **Settings → Alert Settings**. Save and click **Test Connection** to verify; the test creates the first document.
+Enter the Elasticsearch URL, index name, and API key under **Settings -> Alert Settings**. Save and click **Test Connection** to verify; the test creates the first document.
#### 4. Create a Kibana data view
-In Kibana: **Stack Management → Data Views → Create data view**. Use `fail2ban-events-`* as the name and index pattern, and select `@timestamp` as the time field.
+In Kibana: **Stack Management -> Data Views -> Create data view**. Use `fail2ban-events-`* as the name and index pattern, and select `@timestamp` as the time field.
#### 5. Explore in Discover
@@ -342,14 +342,14 @@ When a ban or unban event arrives through the Fail2Ban callback and payload vali
```
Ban/unban event
- → store in DB + WebSocket broadcast
- → alerts enabled for this event type?
- → country filter matches?
- → dispatch to provider:
- ├── email → sendBanAlert() → sendEmail() via SMTP
- ├── webhook → sendWebhookAlert() → HTTP POST/PUT
- └── elasticsearch → enrich logs (grok) + enrich whois (regex)
- → sendElasticsearchAlert() → POST //_doc
+ -> store in DB + WebSocket broadcast
+ -> alerts enabled for this event type?
+ -> country filter matches?
+ -> dispatch to provider:
+ +-- email -> sendBanAlert() -> sendEmail() via SMTP
+ +-- webhook -> sendWebhookAlert() -> HTTP POST/PUT
+ \-- elasticsearch -> enrich logs (grok) + enrich whois (regex)
+ -> sendElasticsearchAlert() -> POST //_doc
```
Switching providers does not affect event storage or WebSocket broadcasting; only the notification delivery channel changes.
diff --git a/docs/architecture.md b/docs/architecture.md
index f76407e..cfe6924 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -97,12 +97,12 @@ The browser communicates with the backend over HTTPS (REST) and a WebSocket conn
| Path | Protocol / port | Direction | Authentication |
| ------------------------- | ---------------------------------------------------------------- | ---------------- | ---------------------------------- |
-| Browser → Fail2ban-UI | HTTPS / WSS, port 8080 by default (place behind a reverse proxy) | inbound to Fail2ban-UI | OIDC session (optional) |
-| Fail2ban-UI → SSH-connected host | SSH, port 22 | outbound from Fail2ban-UI | SSH key, dedicated service account |
-| Fail2ban-UI → agent-connected host | HTTP(S), agent port | outbound from Fail2ban-UI | Agent token |
-| Fail2Ban host → Fail2ban-UI | HTTP(S) `POST /api/ban`, `/api/unban` | inbound to Fail2ban-UI | `X-Callback-Secret` header |
-| Fail2ban-UI → alert providers | SMTP / HTTPS / Elasticsearch API | outbound from Fail2ban-UI | Provider-specific |
-| Fail2ban-UI → edge firewall | SSH (MikroTik) or HTTPS (pfSense, OPNsense) | outbound from Fail2ban-UI | Device credentials / API token |
+| Browser -> Fail2ban-UI | HTTPS / WSS, port 8080 by default (place behind a reverse proxy) | inbound to Fail2ban-UI | OIDC session (optional) |
+| Fail2ban-UI -> SSH-connected host | SSH, port 22 | outbound from Fail2ban-UI | SSH key, dedicated service account |
+| Fail2ban-UI -> agent-connected host | HTTP(S), agent port | outbound from Fail2ban-UI | Agent token |
+| Fail2Ban host -> Fail2ban-UI | HTTP(S) `POST /api/ban`, `/api/unban` | inbound to Fail2ban-UI | `X-Callback-Secret` header |
+| Fail2ban-UI -> alert providers | SMTP / HTTPS / Elasticsearch API | outbound from Fail2ban-UI | Provider-specific |
+| Fail2ban-UI -> edge firewall | SSH (MikroTik) or HTTPS (pfSense, OPNsense) | outbound from Fail2ban-UI | Device credentials / API token |
**Important:** Do not expose the Fail2ban-UI directly to the public Internet. Place it behind a reverse proxy, VPN, or firewall rules, and enable OIDC where possible. The callback endpoint is the only path that managed hosts must be able to reach.
diff --git a/docs/configuration.md b/docs/configuration.md
index 9c2f83a..f7a6f12 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -70,7 +70,7 @@ With a subpath:
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.
+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
@@ -93,33 +93,33 @@ The tunnel is only used if `CALLBACK_URL` points to `localhost`/`127.0.0.1` —
## 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:
+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
+ * `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**:
+Configure under **Settings -> Alert Settings**:
* Provider: `email`, `webhook`, or `elasticsearch`
* Enable alerts for bans and/or unbans
* 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.
+> **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)
-Configure under **Settings → Alert Settings**:
+Configure under **Settings -> Alert Settings**:
* `threatIntel.provider`: `none`, `alienvault`, or `abuseipdb`
* `threatIntel.alienVaultApiKey` (for `alienvault`)
diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md
index 64c74ec..053ee72 100644
--- a/docs/reverse-proxy.md
+++ b/docs/reverse-proxy.md
@@ -34,13 +34,13 @@ For correct behavior, including WebSocket live updates, the proxy must:
2. Allow WebSocket upgrades on the real-time endpoint: `GET /api/ws` at the site root, or `GET {BASE_PATH}/api/ws` when using a subpath.
3. Forward client IP context: `X-Forwarded-For` and `X-Forwarded-Proto`.
-**Path-prefix handling:** If the proxy *strips* a path prefix before forwarding (external `/myf2b/` → upstream `/`), leave `BASE_PATH` unset and configure the application as if it lived at the root; only the public URLs change. If the application receives the *full* path including `/myf2b`, set `BASE_PATH=/myf2b` and forward the prefix unchanged.
+**Path-prefix handling:** If the proxy *strips* a path prefix before forwarding (external `/myf2b/` -> upstream `/`), leave `BASE_PATH` unset and configure the application as if it lived at the root; only the public URLs change. If the application receives the *full* path including `/myf2b`, set `BASE_PATH=/myf2b` and forward the prefix unchanged.
## Subpath deployment (`BASE_PATH`)
When Fail2Ban UI runs with `BASE_PATH=/myf2b` (see [configuration.md](configuration.md)):
-* The proxy `location` must match the prefix and pass the *same* path to the backend, with no strip: `https://host/myf2b/api/version` → upstream `http://127.0.0.1:8080/myf2b/api/version`.
+* The proxy `location` must match the prefix and pass the *same* path to the backend, with no strip: `https://host/myf2b/api/version` -> upstream `http://127.0.0.1:8080/myf2b/api/version`.
* The WebSocket URL in the browser becomes `wss://host/myf2b/api/ws`.
* `CALLBACK_URL` and `OIDC_REDIRECT_URL` must include `/myf2b`, as described in the configuration reference.
diff --git a/docs/security.md b/docs/security.md
index 8df934f..1e13974 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -17,9 +17,9 @@ See [reverse-proxy.md](reverse-proxy.md) for hardened proxy examples and the Web
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.
+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.
+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
diff --git a/docs/threat-intel.md b/docs/threat-intel.md
index 30e0ec0..f4c836a 100644
--- a/docs/threat-intel.md
+++ b/docs/threat-intel.md
@@ -23,7 +23,7 @@ Fail2Ban UI can query external threat-intelligence sources for suspicious IPs. T
## Configuration
-Under **Settings → Alert Settings**:
+Under **Settings -> Alert Settings**:
1. If you do not have an API key yet, create one with the selected provider.
2. Set **Threat Intel Provider** to `AlienVault OTX` or `AbuseIPDB`.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 969d0f5..7148b2c 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -92,7 +92,7 @@ cat /etc/fail2ban/action.d/ui-custom-action.conf
# to your callback URL, for example http://10.88.0.1:8080/api/ban
```
-If the file does not exist or looks wrong, go to **Settings → Manage Servers** in the UI, select the server, and click **Test connection**. The UI re-deploys the action file automatically for local connectors.
+If the file does not exist or looks wrong, go to **Settings -> Manage Servers** in the UI, select the server, and click **Test connection**. The UI re-deploys the action file automatically for local connectors.
### Step 2: Verify jail.local references the action
@@ -139,7 +139,7 @@ Then trigger a test ban and check `audit.log` or `sealert` again. This is the sa
### Step 4: Verify the callback secret
-Every callback must include the `X-Callback-Secret` header, and the value must match what the UI expects. The current secret is visible under **Settings → General Settings → Callback Secret** (with a show/hide toggle), or in the container environment.
+Every callback must include the `X-Callback-Secret` header, and the value must match what the UI expects. The current secret is visible under **Settings -> General Settings -> Callback Secret** (with a show/hide toggle), or in the container environment.
```bash
# Secret used by the action file:
@@ -253,7 +253,7 @@ With debug mode enabled in the UI settings, the raw JSON body of every incoming
The callback payload includes a `serverId`, which the UI uses to match the event to a configured server. If it matches no known server, the callback is rejected.
-Check that the `serverId` in the action file matches the server ID shown under **Settings → Manage Servers**:
+Check that the `serverId` in the action file matches the server ID shown under **Settings -> Manage Servers**:
```bash
curl -s http://$FAIL2BAN_UI_HOST:8080/api/servers \
@@ -264,15 +264,15 @@ curl -s http://$FAIL2BAN_UI_HOST:8080/api/servers \
```
Fail2Ban detects an intrusion
- → triggers actionban in ui-custom-action.conf
- → curl POST /api/ban with JSON payload + X-Callback-Secret header
- → Fail2Ban UI validates the secret
- → validates the IP format
- → resolves the server (by serverId)
- → stores the event in SQLite (ban_events)
- → broadcasts over WebSocket to all connected browsers
- → optional: dispatches an alert (Email / Webhook / Elasticsearch)
- → optional: evaluates advanced actions (recurring offenders)
+ -> triggers actionban in ui-custom-action.conf
+ -> curl POST /api/ban with JSON payload + X-Callback-Secret header
+ -> Fail2Ban UI validates the secret
+ -> validates the IP format
+ -> resolves the server (by serverId)
+ -> stores the event in SQLite (ban_events)
+ -> broadcasts over WebSocket to all connected browsers
+ -> optional: dispatches an alert (Email / Webhook / Elasticsearch)
+ -> optional: evaluates advanced actions (recurring offenders)
```
If any step fails, the chain stops and the event does not appear in the UI.
@@ -297,7 +297,7 @@ The symptom "unban works, but the UI only records unbans for *new* blocks" there
### Alerts not sent (any provider)
-1. Verify that alerts are enabled for the event type (ban and/or unban) under **Settings → Alert Settings**.
+1. Verify that alerts are enabled for the event type (ban and/or unban) under **Settings -> Alert Settings**.
2. Check which alert provider is selected, and re-check all settings of the active provider.
3. Check the country filter: if specific countries are selected, only IPs geolocated to those countries trigger alerts. Set it to `ALL` to alert on every event.
4. Confirm the dispatch in the Fail2Ban UI logs (enable debug logging for more detail):
@@ -343,7 +343,7 @@ curl -v -X POST https://your-webhook-url \
Common causes:
* **Connection refused / timeout.** Verify that the Elasticsearch URL is reachable from the Fail2Ban UI host.
-* **401 Unauthorized.** API key or credentials are incorrect. Verify the key in Kibana under **Stack Management → API Keys**.
+* **401 Unauthorized.** API key or credentials are incorrect. Verify the key in Kibana under **Stack Management -> API Keys**.
* **403 Forbidden.** The API key lacks write permissions on the target index. Create a key with `write` and `create_index` privileges for `fail2ban-events-*`.
* **Index template missing.** Without a template, Elasticsearch uses dynamic mapping, which may produce suboptimal field types. Create the template as described in [alert-providers.md](alert-providers.md#elasticsearch-setup).
@@ -391,7 +391,7 @@ podman logs fail2ban-ui
If real-time dashboard updates - ban and unban events appearing without a page refresh - do not work, check:
-* The browser console for WebSocket errors (F12 → Console).
+* The browser console for WebSocket errors (F12 -> Console).
* The WebSocket status indicator in the UI footer.
* The reverse proxy, if one is used, supports WebSocket upgrades.
diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go
index 0ea5490..008c492 100644
--- a/internal/auth/oidc.go
+++ b/internal/auth/oidc.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/auth/session.go b/internal/auth/session.go
index 2c9a22b..8035630 100644
--- a/internal/auth/session.go
+++ b/internal/auth/session.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/config/fail2ban_bridge.go b/internal/config/fail2ban_bridge.go
index be34c05..f5f8cfb 100644
--- a/internal/config/fail2ban_bridge.go
+++ b/internal/config/fail2ban_bridge.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/config/logging.go b/internal/config/logging.go
index 1db7a1e..73355ec 100644
--- a/internal/config/logging.go
+++ b/internal/config/logging.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/config/settings.go b/internal/config/settings.go
index 8706ea4..b4b7354 100644
--- a/internal/config/settings.go
+++ b/internal/config/settings.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/config/settings_validation_test.go b/internal/config/settings_validation_test.go
index 8122595..d6cce2b 100644
--- a/internal/config/settings_validation_test.go
+++ b/internal/config/settings_validation_test.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/enrichment/logparse.go b/internal/enrichment/logparse.go
index 537def0..5a43604 100644
--- a/internal/enrichment/logparse.go
+++ b/internal/enrichment/logparse.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -56,11 +56,11 @@ func compileAll(defs []PatternDef) []compiledPattern {
for _, d := range defs {
g := grok.New()
if err := g.AddPatterns(SubPatterns); err != nil {
- log.Printf("⚠️ enrichment failed to add sub-patterns for %s: %v", d.Name, err)
+ log.Printf("WARNING: enrichment failed to add sub-patterns for %s: %v", d.Name, err)
continue
}
if err := g.Compile(d.Pattern, true); err != nil {
- log.Printf("⚠️ enrichment failed to compile pattern %s: %v", d.Name, err)
+ log.Printf("WARNING: enrichment failed to compile pattern %s: %v", d.Name, err)
continue
}
out = append(out, compiledPattern{def: d, grok: g})
@@ -136,7 +136,6 @@ func ParseLogLines(logs, jail string) map[string]interface{} {
// Helper functions
// =========================================================================
-// Splits the raw log text
func splitAndClean(logs string) []string {
raw := strings.Split(logs, "\n")
var out []string
diff --git a/internal/enrichment/patterns.go b/internal/enrichment/patterns.go
index 4561ab8..499463a 100644
--- a/internal/enrichment/patterns.go
+++ b/internal/enrichment/patterns.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/enrichment/whoisparse.go b/internal/enrichment/whoisparse.go
index 04611e7..95aa044 100644
--- a/internal/enrichment/whoisparse.go
+++ b/internal/enrichment/whoisparse.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -111,7 +111,7 @@ func ParseWhois(whois string) map[string]interface{} {
seenKeys[outField] = true
}
- // Normalises the ASN: strips "AS" prefix if present (e.g. "AS200373" → "200373")
+ // Normalises the ASN: strips "AS" prefix if present (e.g. "AS200373" -> "200373")
if asn, ok := result["whois.asn"].(string); ok {
asn = strings.TrimSpace(asn)
if strings.HasPrefix(strings.ToUpper(asn), "AS") {
diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go
index ff5c6fc..2610b3c 100644
--- a/internal/fail2ban/connector_agent.go
+++ b/internal/fail2ban/connector_agent.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -395,7 +395,7 @@ func (ac *AgentConnector) do(req *http.Request, out any) error {
preview := trimmed
if len(preview) > 512 {
- preview = preview[:512] + "…"
+ preview = preview[:512] + "..."
}
debugf("Agent response [%s]: %s | %s", ac.server.Name, resp.Status, preview)
diff --git a/internal/fail2ban/connector_agent_test.go b/internal/fail2ban/connector_agent_test.go
index 36b0210..63795e3 100644
--- a/internal/fail2ban/connector_agent_test.go
+++ b/internal/fail2ban/connector_agent_test.go
@@ -1,3 +1,19 @@
+// Fail2ban UI - A Swiss made, management interface for Fail2ban.
+//
+// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
+//
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
+// You may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.gnu.org/licenses/agpl-3.0.en.html
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
package fail2ban
import (
diff --git a/internal/fail2ban/connector_global.go b/internal/fail2ban/connector_global.go
index b8cf51d..f6508a6 100644
--- a/internal/fail2ban/connector_global.go
+++ b/internal/fail2ban/connector_global.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go
index e8c3be7..cc9f2d8 100644
--- a/internal/fail2ban/connector_local.go
+++ b/internal/fail2ban/connector_local.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go
index 493c174..055f6e1 100644
--- a/internal/fail2ban/connector_ssh.go
+++ b/internal/fail2ban/connector_ssh.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -316,12 +316,10 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error {
debugf("SSH ensureAction command [%s]: ssh %s (with here-doc via stdin)", sc.server.Name, strings.Join(args, " "))
- // Capture stdout and stderr
var stdout, stderr bytes.Buffer
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)
}
@@ -405,7 +403,7 @@ func (sc *SSHConnector) runFail2banCommand(ctx context.Context, args ...string)
return sc.runRemoteCommand(ctx, cmdArgs)
}
-// Detects "no systemd” situations on the remote host or if an interactive authentication is required.
+// Detects "no systemd" situations on the remote host or if an interactive authentication is required.
func (sc *SSHConnector) isSystemctlUnavailable(output string, err error) bool {
msg := strings.ToLower(output + " " + err.Error())
return strings.Contains(msg, "command not found") ||
@@ -446,7 +444,6 @@ func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string)
Pgid: 0,
}
debugf("SSH command [%s]: ssh %s", sc.server.Name, strings.Join(args, " "))
- // Capture stdout and stderr
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
@@ -991,7 +988,6 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC
for i, line := range lines {
trimmed := strings.TrimSpace(line)
- // Check for [INCLUDES] section
if strings.HasPrefix(trimmed, "[INCLUDES]") {
inIncludesSection = true
continue
@@ -1103,7 +1099,6 @@ func (sc *SSHConnector) resolveFilterIncludesRemote(ctx context.Context, filterC
// Note: Self-inclusion in "after" directive is intentional in fail2ban
// (e.g., after = apache-common.local is standard pattern for .local files)
- // Self-inclusion in "after" is intentional (e.g. after = apache-common.local).
contentStr, err := readRemoteFilterFile(baseName)
if err != nil {
@@ -1145,7 +1140,6 @@ func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLi
var script string
if filterContent != "" {
- // Resolve filter includes locally.
filterDPath := filepath.Join(fail2banPath, "filter.d")
resolvedContent, err := sc.resolveFilterIncludesRemote(ctx, filterContent, filterDPath, filterName)
if err != nil {
@@ -1438,7 +1432,6 @@ except Exception as e:
PYEOF
`, originalPath)
- // Run resolution script
resolveOut, err := sc.runRemoteCommand(ctx, []string{resolveScript})
if err != nil {
return originalPath, "", nil, fmt.Errorf("failed to resolve variables: %w", err)
@@ -1492,7 +1485,7 @@ func (sc *SSHConnector) EnsureJailLocalStructure(ctx context.Context) error {
// Run experimental migration if enabled
if isJailAutoMigrationEnabled() {
- debugf("JAIL_AUTOMIGRATION=true: running experimental jail.local → jail.d/ migration for SSH server %s", sc.server.Name)
+ debugf("JAIL_AUTOMIGRATION=true: running experimental jail.local -> jail.d/ migration for SSH server %s", sc.server.Name)
if err := sc.MigrateJailsFromJailLocalRemote(ctx); err != nil {
return fmt.Errorf("failed to migrate legacy jails from jail.local on remote server %s: %w", sc.server.Name, err)
}
diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go
index b7868c2..2faa873 100644
--- a/internal/fail2ban/filter_management.go
+++ b/internal/fail2ban/filter_management.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go
index 5702527..cf1d5da 100644
--- a/internal/fail2ban/jail_management.go
+++ b/internal/fail2ban/jail_management.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -128,7 +128,6 @@ func ValidateJailName(name string) error {
return fmt.Errorf("jail name '%s' is reserved and cannot be used", 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)
}
@@ -162,7 +161,6 @@ func ListJailFiles(directory string) ([]string, error) {
continue
}
- // Only include .local and .conf files
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
fullPath := filepath.Join(directory, name)
files = append(files, fullPath)
@@ -340,7 +338,7 @@ func GetAllJails(configPath string) ([]JailInfo, error) {
// Run migration once if enabled (experimental, off by default)
if isJailAutoMigrationEnabled() {
migrationOnce.Do(func() {
- debugf("JAIL_AUTOMIGRATION=true: running experimental jail.local → jail.d/ migration")
+ debugf("JAIL_AUTOMIGRATION=true: running experimental jail.local -> jail.d/ migration")
if err := MigrateJailsFromJailLocal(configPath); err != nil {
debugf("Migration warning: %v", err)
}
@@ -426,7 +424,6 @@ func UpdateJailEnabledStates(updates map[string]bool, configPath string) error {
}
debugf("Processing jail: %s, enabled: %t", jailName, enabled)
- // Ensure .local file exists
if err := ensureJailLocalFile(jailName, configPath); err != nil {
return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err)
}
diff --git a/internal/fail2ban/local_managed_files.go b/internal/fail2ban/local_managed_files.go
index 8dd34e3..c3b2177 100644
--- a/internal/fail2ban/local_managed_files.go
+++ b/internal/fail2ban/local_managed_files.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -50,7 +50,7 @@ func EnsureManagedJailLocal(configPath string, content []byte) error {
jailPath := JailLocal(configPath)
rootDir := NormalizeConfigPath(configPath)
if _, err := os.Stat(filepath.Dir(jailPath)); os.IsNotExist(err) {
- return fmt.Errorf("fail2ban configuration directory does not exist at %s — install fail2ban or set the correct configuration path for this server", rootDir)
+ return fmt.Errorf("fail2ban configuration directory does not exist at %s - install fail2ban or set the correct configuration path for this server", rootDir)
}
var existingContent string
fileExists := false
@@ -76,7 +76,7 @@ func WriteLocalActionFile(configPath, callbackURL, serverID string) error {
actionPath := CustomActionFile(configPath)
actionDir := ActionDir(configPath)
if _, err := os.Stat(actionDir); os.IsNotExist(err) {
- return fmt.Errorf("fail2ban action.d directory does not exist at %s — install fail2ban or set the correct configuration path for this server", actionDir)
+ return fmt.Errorf("fail2ban action.d directory does not exist at %s - install fail2ban or set the correct configuration path for this server", actionDir)
}
secret := p.CallbackSecret()
cfg := p.BuildFail2banActionConfig(callbackURL, serverID, secret)
@@ -96,7 +96,7 @@ func EnsureLocalConnectorArtifacts(callbackURL, serverID, configPath string) err
jailPath := JailLocal(configPath)
if _, err := os.Stat(filepath.Dir(jailPath)); os.IsNotExist(err) {
rootDir := NormalizeConfigPath(configPath)
- return fmt.Errorf("fail2ban configuration directory does not exist at %s — install fail2ban or set the correct configuration path for this server", rootDir)
+ return fmt.Errorf("fail2ban configuration directory does not exist at %s - install fail2ban or set the correct configuration path for this server", rootDir)
}
actionDir := ActionDir(configPath)
if err := ensureWritableDirectory(actionDir, "fail2ban action.d directory"); err != nil {
diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go
index 3618d4e..0c14c0b 100644
--- a/internal/fail2ban/manager.go
+++ b/internal/fail2ban/manager.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -293,7 +293,7 @@ func newConnectorForServer(server shared.Fail2banServer) (Connector, error) {
switch server.Type {
case "local":
if isJailAutoMigrationEnabled() {
- debugf("JAIL_AUTOMIGRATION=true: running experimental jail.local → jail.d/ migration for local server %s", server.Name)
+ debugf("JAIL_AUTOMIGRATION=true: running experimental jail.local -> jail.d/ migration for local server %s", server.Name)
if err := MigrateJailsFromJailLocal(server.ConfigPath); err != nil {
return nil, fmt.Errorf("failed to initialise local fail2ban connector for %s: %w", server.Name, err)
}
diff --git a/internal/fail2ban/paths.go b/internal/fail2ban/paths.go
index 815777d..e8de2af 100644
--- a/internal/fail2ban/paths.go
+++ b/internal/fail2ban/paths.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/fail2ban/paths_test.go b/internal/fail2ban/paths_test.go
index 9a372ab..9994a72 100644
--- a/internal/fail2ban/paths_test.go
+++ b/internal/fail2ban/paths_test.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/fail2ban/provider.go b/internal/fail2ban/provider.go
index fbbd792..0c25a04 100644
--- a/internal/fail2ban/provider.go
+++ b/internal/fail2ban/provider.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/fail2ban/variable_resolver.go b/internal/fail2ban/variable_resolver.go
index ea2692c..1a8f7b9 100644
--- a/internal/fail2ban/variable_resolver.go
+++ b/internal/fail2ban/variable_resolver.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/fail2ban/variable_resolver_test.go b/internal/fail2ban/variable_resolver_test.go
index 3a78726..c0131dc 100644
--- a/internal/fail2ban/variable_resolver_test.go
+++ b/internal/fail2ban/variable_resolver_test.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/integrations/mikrotik.go b/internal/integrations/mikrotik.go
index c14eaab..0ef03dd 100644
--- a/internal/integrations/mikrotik.go
+++ b/internal/integrations/mikrotik.go
@@ -1,3 +1,19 @@
+// Fail2ban UI - A Swiss made, management interface for Fail2ban.
+//
+// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
+//
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
+// You may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.gnu.org/licenses/agpl-3.0.en.html
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
package integrations
import (
diff --git a/internal/integrations/opnsense.go b/internal/integrations/opnsense.go
index d924e34..43011d3 100644
--- a/internal/integrations/opnsense.go
+++ b/internal/integrations/opnsense.go
@@ -1,3 +1,19 @@
+// Fail2ban UI - A Swiss made, management interface for Fail2ban.
+//
+// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
+//
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
+// You may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.gnu.org/licenses/agpl-3.0.en.html
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
package integrations
import (
diff --git a/internal/integrations/pfsense.go b/internal/integrations/pfsense.go
index 92af0ae..ae9c724 100644
--- a/internal/integrations/pfsense.go
+++ b/internal/integrations/pfsense.go
@@ -1,3 +1,19 @@
+// Fail2ban UI - A Swiss made, management interface for Fail2ban.
+//
+// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
+//
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
+// You may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.gnu.org/licenses/agpl-3.0.en.html
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
package integrations
import (
diff --git a/internal/integrations/types.go b/internal/integrations/types.go
index e13f8b0..527e9fa 100644
--- a/internal/integrations/types.go
+++ b/internal/integrations/types.go
@@ -1,3 +1,19 @@
+// Fail2ban UI - A Swiss made, management interface for Fail2ban.
+//
+// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
+//
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
+// You may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.gnu.org/licenses/agpl-3.0.en.html
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
package integrations
import (
@@ -131,20 +147,3 @@ func Get(id string) (Integration, bool) {
return integration, ok
}
-// Returns the integration or panics.
-func MustGet(id string) Integration {
- integration, ok := Get(id)
- if !ok {
- panic(fmt.Sprintf("integration %s not registered", id))
- }
- return integration
-}
-
-// Returns all registered integration ids.
-func Supported() []string {
- keys := make([]string, 0, len(registry))
- for id := range registry {
- keys = append(keys, id)
- }
- return keys
-}
diff --git a/internal/integrations/types_test.go b/internal/integrations/types_test.go
index cee6b32..227d102 100644
--- a/internal/integrations/types_test.go
+++ b/internal/integrations/types_test.go
@@ -1,3 +1,19 @@
+// Fail2ban UI - A Swiss made, management interface for Fail2ban.
+//
+// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
+//
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
+// You may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.gnu.org/licenses/agpl-3.0.en.html
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
package integrations
import "testing"
diff --git a/internal/shared/server.go b/internal/shared/server.go
index b5f8df0..116e586 100644
--- a/internal/shared/server.go
+++ b/internal/shared/server.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/shared/validation.go b/internal/shared/validation.go
index b48af42..33c2144 100644
--- a/internal/shared/validation.go
+++ b/internal/shared/validation.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 50fc3b6..53ee49d 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -90,6 +90,32 @@ func addOccurredAtSinceFilter(query *string, args *[]any, since time.Time) {
*args = append(*args, formatStorageTime(since))
}
+// Turns user input into an FTS5 MATCH expression. -> each whitespace token becomes a quoted prefix phrase ("203.0.113"* matches 203.0.113.45), joined with implicit AND
+func buildFTSMatch(search string) string {
+ var parts []string
+ for _, token := range strings.Fields(search) {
+ token = strings.ReplaceAll(token, `"`, `""`)
+ parts = append(parts, `"`+token+`"*`)
+ }
+ return strings.Join(parts, " ")
+}
+
+func addSearchFilter(query *string, args *[]any, search string) {
+ if search == "" {
+ return
+ }
+ if ftsAvailable {
+ *query += " AND id IN (SELECT rowid FROM ban_events_fts WHERE ban_events_fts MATCH ?)"
+ *args = append(*args, buildFTSMatch(search))
+ return
+ }
+ *query += " AND (ip LIKE ? OR jail LIKE ? OR server_name LIKE ? OR COALESCE(hostname,'') LIKE ? OR COALESCE(country,'') LIKE ?)"
+ pattern := "%" + search + "%"
+ for i := 0; i < 5; i++ {
+ *args = append(*args, pattern)
+ }
+}
+
// =========================================================================
// Types
// =========================================================================
@@ -229,11 +255,69 @@ func Init(dbPath string) error {
if initErr = ensureSchema(context.Background()); initErr != nil {
return
}
- initErr = migrateLegacyTimestamps(context.Background())
+ if initErr = migrateLegacyTimestamps(context.Background()); initErr != nil {
+ return
+ }
+ ensureBanEventsFTS(context.Background())
+
+ // Refreshes planner statistics so index choices stay sane as the
+ // data distribution changes (recommended by the SQLite docs).
+ if _, err := db.ExecContext(context.Background(), `PRAGMA optimize`); err != nil {
+ log.Printf("Warning: PRAGMA optimize failed: %v", err)
+ }
})
return initErr
}
+// Is set when the ban_events_fts full-text index exists. -> search queries fall back to LIKE when it could not be created
+var ftsAvailable bool
+
+// Creates the FTS5 full-text index over the searchable ban_events
+func ensureBanEventsFTS(ctx context.Context) {
+ const ddl = `
+CREATE VIRTUAL TABLE IF NOT EXISTS ban_events_fts USING fts5(
+ ip, jail, server_name, hostname, country,
+ content='ban_events', content_rowid='id'
+);
+CREATE TRIGGER IF NOT EXISTS ban_events_fts_ai AFTER INSERT ON ban_events BEGIN
+ INSERT INTO ban_events_fts(rowid, ip, jail, server_name, hostname, country)
+ VALUES (new.id, new.ip, new.jail, new.server_name, new.hostname, new.country);
+END;
+CREATE TRIGGER IF NOT EXISTS ban_events_fts_ad AFTER DELETE ON ban_events BEGIN
+ INSERT INTO ban_events_fts(ban_events_fts, rowid, ip, jail, server_name, hostname, country)
+ VALUES ('delete', old.id, old.ip, old.jail, old.server_name, old.hostname, old.country);
+END;
+CREATE TRIGGER IF NOT EXISTS ban_events_fts_au AFTER UPDATE OF ip, jail, server_name, hostname, country ON ban_events BEGIN
+ INSERT INTO ban_events_fts(ban_events_fts, rowid, ip, jail, server_name, hostname, country)
+ VALUES ('delete', old.id, old.ip, old.jail, old.server_name, old.hostname, old.country);
+ INSERT INTO ban_events_fts(rowid, ip, jail, server_name, hostname, country)
+ VALUES (new.id, new.ip, new.jail, new.server_name, new.hostname, new.country);
+END;`
+ if _, err := db.ExecContext(ctx, ddl); err != nil {
+ log.Printf("WARNING: full-text search index unavailable, falling back to slow LIKE search: %v", err)
+ return
+ }
+
+ var indexedRows, eventRows int64
+ if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ban_events_fts_docsize`).Scan(&indexedRows); err != nil {
+ log.Printf("WARNING: full-text search index unreadable, falling back to slow LIKE search: %v", err)
+ return
+ }
+ if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ban_events`).Scan(&eventRows); err != nil {
+ log.Printf("WARNING: failed to count ban events for FTS backfill: %v", err)
+ return
+ }
+ if indexedRows != eventRows {
+ start := time.Now()
+ if _, err := db.ExecContext(ctx, `INSERT INTO ban_events_fts(ban_events_fts) VALUES('rebuild')`); err != nil {
+ log.Printf("WARNING: FTS rebuild failed, falling back to slow LIKE search: %v", err)
+ return
+ }
+ log.Printf("Built full-text search index for %d ban events in %s (had %d indexed)", eventRows, time.Since(start).Round(time.Millisecond), indexedRows)
+ }
+ ftsAvailable = true
+}
+
// 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.
@@ -596,26 +680,18 @@ INSERT INTO servers (
return err
}
-func DeleteServer(ctx context.Context, id string) error {
- if db == nil {
- return errors.New("storage not initialised")
- }
- _, err := db.ExecContext(ctx, `DELETE FROM servers WHERE id = ?`, id)
- return err
-}
-
// =========================================================================
// Ban Events Records
// =========================================================================
// Stores a ban/unban event into the database.
-func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
+func RecordBanEvent(ctx context.Context, record BanEventRecord) (int64, error) {
if db == nil {
- return errors.New("storage not initialised")
+ return 0, errors.New("storage not initialised")
}
if record.ServerID == "" {
- return errors.New("server id is required")
+ return 0, errors.New("server id is required")
}
now := time.Now().UTC()
if record.CreatedAt.IsZero() {
@@ -635,7 +711,7 @@ INSERT INTO ban_events (
server_id, server_name, jail, ip, country, hostname, failures, whois, logs, event_type, occurred_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
- _, err := db.ExecContext(
+ res, err := db.ExecContext(
ctx,
query,
record.ServerID,
@@ -652,10 +728,46 @@ INSERT INTO ban_events (
formatStorageTime(record.CreatedAt),
)
if err != nil {
- return err
+ return 0, err
}
- return nil
+ return res.LastInsertId()
+}
+
+// Fills in whois on an already stored event; used by the asynchronous enrichment after the callback has been answered.
+func UpdateBanEventEnrichment(ctx context.Context, id int64, whois, country string) error {
+ if db == nil {
+ return errors.New("storage not initialised")
+ }
+ if id <= 0 {
+ return errors.New("event id is required")
+ }
+ _, err := db.ExecContext(ctx,
+ `UPDATE ban_events SET whois = ?, country = CASE WHEN ? = '' THEN country ELSE ? END WHERE id = ?`,
+ whois, country, country, id)
+ return err
+}
+
+// Returns the distinct set of countries seen across all stored events
+func ListBanEventCountries(ctx context.Context) ([]string, error) {
+ if db == nil {
+ return nil, errors.New("storage not initialised")
+ }
+ rows, err := db.QueryContext(ctx, `SELECT DISTINCT COALESCE(country, '') FROM ban_events ORDER BY 1`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var countries []string
+ for rows.Next() {
+ var country string
+ if err := rows.Scan(&country); err != nil {
+ return nil, err
+ }
+ countries = append(countries, country)
+ }
+ return countries, rows.Err()
}
const (
@@ -679,12 +791,21 @@ func ListBanEventsFiltered(ctx context.Context, serverID string, limit, offset i
offset = 0
}
+ from := "FROM ban_events"
+ search = strings.TrimSpace(search)
+ if search != "" && ftsAvailable {
+ var matches int64
+ if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ban_events_fts WHERE ban_events_fts MATCH ?`, buildFTSMatch(search)).Scan(&matches); err == nil && matches > broadSearchThreshold {
+ from = "FROM ban_events INDEXED BY idx_ban_events_occurred_at"
+ }
+ }
+
baseQuery := `
SELECT id, server_id, server_name, jail, ip, country, hostname, failures,
(whois IS NOT NULL AND whois <> '') AS has_whois,
(logs IS NOT NULL AND logs <> '') AS has_logs,
event_type, occurred_at, created_at
-FROM ban_events
+` + from + `
WHERE 1=1`
args := []any{}
@@ -693,14 +814,7 @@ WHERE 1=1`
args = append(args, serverID)
}
addOccurredAtSinceFilter(&baseQuery, &args, since)
- search = strings.TrimSpace(search)
- if search != "" {
- baseQuery += " AND (ip LIKE ? OR jail LIKE ? OR server_name LIKE ? OR COALESCE(hostname,'') LIKE ? OR COALESCE(country,'') LIKE ?)"
- pattern := "%" + search + "%"
- for i := 0; i < 5; i++ {
- args = append(args, pattern)
- }
- }
+ addSearchFilter(&baseQuery, &args, search)
if country != "" && country != "all" {
if country == "__unknown__" {
baseQuery += " AND (country IS NULL OR country = '')"
@@ -797,37 +911,56 @@ WHERE id = ?`
return rec, true, nil
}
+// Upper bound for counting search matches
+const MaxSearchCount = 5000
+
+// Above this many FTS matches a search term counts as "broad": the list query
+// then walks the occurred_at index and probes the match set instead of sorting the whole match set.
+const broadSearchThreshold = 10000
+
// Returns the total count of ban events matching the same filters as ListBanEventsFiltered.
func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Time, search, country string) (int64, error) {
if db == nil {
return 0, errors.New("storage not initialised")
}
- query := `SELECT COUNT(*) FROM ban_events WHERE 1=1`
+ // A search without further filters is answered index-only by FTS.
+ search = strings.TrimSpace(search)
+ if search != "" && ftsAvailable && serverID == "" && since.IsZero() && (country == "" || country == "all") {
+ var total int64
+ if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM ban_events_fts WHERE ban_events_fts MATCH ?`, buildFTSMatch(search)).Scan(&total); err != nil {
+ return 0, err
+ }
+ if total > MaxSearchCount {
+ total = MaxSearchCount + 1
+ }
+ return total, nil
+ }
+
+ conditions := ""
args := []any{}
if serverID != "" {
- query += " AND server_id = ?"
+ conditions += " AND server_id = ?"
args = append(args, serverID)
}
- addOccurredAtSinceFilter(&query, &args, since)
+ addOccurredAtSinceFilter(&conditions, &args, since)
search = strings.TrimSpace(search)
- if search != "" {
- query += " AND (ip LIKE ? OR jail LIKE ? OR server_name LIKE ? OR COALESCE(hostname,'') LIKE ? OR COALESCE(country,'') LIKE ?)"
- pattern := "%" + search + "%"
- for i := 0; i < 5; i++ {
- args = append(args, pattern)
- }
- }
+ addSearchFilter(&conditions, &args, search)
if country != "" && country != "all" {
if country == "__unknown__" {
- query += " AND (country IS NULL OR country = '')"
+ conditions += " AND (country IS NULL OR country = '')"
} else {
- query += " AND LOWER(COALESCE(country,'')) = ?"
+ conditions += " AND LOWER(COALESCE(country,'')) = ?"
args = append(args, strings.ToLower(country))
}
}
+ query := `SELECT COUNT(*) FROM ban_events WHERE 1=1` + conditions
+ if search != "" {
+ query = fmt.Sprintf(`SELECT COUNT(*) FROM (SELECT 1 FROM ban_events WHERE 1=1%s LIMIT %d)`, conditions, MaxSearchCount+1)
+ }
+
var total int64
if err := db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
return 0, err
@@ -943,7 +1076,7 @@ func CountBanEventsByIP(ctx context.Context, ip, serverID string) (int64, error)
query := `
SELECT COUNT(*)
-FROM ban_events
+FROM ban_events INDEXED BY idx_ban_events_ip
WHERE ip = ? AND (event_type = 'ban' OR event_type IS NULL)`
args := []any{ip}
@@ -1220,6 +1353,8 @@ 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 INDEX IF NOT EXISTS idx_ban_events_server_occurred_at ON ban_events(server_id, occurred_at);
+CREATE INDEX IF NOT EXISTS idx_ban_events_country ON ban_events(country);
CREATE TABLE IF NOT EXISTS permanent_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1235,6 +1370,7 @@ CREATE TABLE IF NOT EXISTS permanent_blocks (
);
CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status);
+CREATE INDEX IF NOT EXISTS idx_perm_blocks_updated_at ON permanent_blocks(updated_at);
`
if _, err := db.ExecContext(ctx, createTable); err != nil {
diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go
index 9f05fc7..5cc58b7 100644
--- a/internal/storage/storage_test.go
+++ b/internal/storage/storage_test.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -101,6 +101,139 @@ func TestReplaceServersRoundTrip(t *testing.T) {
}
}
+func TestBanEventsFTSSearchAndEnrichment(t *testing.T) {
+ initTestStorage(t)
+ if !ftsAvailable {
+ t.Fatal("FTS index not available in fresh database")
+ }
+
+ ctx := context.Background()
+ id1, err := RecordBanEvent(ctx, BanEventRecord{
+ ServerID: "srv-1", ServerName: "web frontend", Jail: "swissmakers-nextcloud",
+ IP: "203.0.113.45", Country: "UA", EventType: "ban",
+ })
+ if err != nil || id1 <= 0 {
+ t.Fatalf("RecordBanEvent: id=%d err=%v", id1, err)
+ }
+ id2, err := RecordBanEvent(ctx, BanEventRecord{
+ ServerID: "srv-2", ServerName: "mail relay", Jail: "postfix-sasl",
+ IP: "198.51.100.7", EventType: "ban",
+ })
+ if err != nil || id2 <= 0 {
+ t.Fatalf("RecordBanEvent: id=%d err=%v", id2, err)
+ }
+
+ searchTotal := func(term string) int64 {
+ t.Helper()
+ total, err := CountBanEventsFiltered(ctx, "", time.Time{}, term, "")
+ if err != nil {
+ t.Fatalf("CountBanEventsFiltered(%q): %v", term, err)
+ }
+ return total
+ }
+
+ // Full word, word prefix, and IP fragment must all match via FTS
+ for term, want := range map[string]int64{
+ "nextcloud": 1,
+ "next": 1,
+ "203.0.113": 1,
+ "mail": 1,
+ "nosuchthing": 0,
+ } {
+ if got := searchTotal(term); got != want {
+ t.Fatalf("search %q total=%d want %d", term, got, want)
+ }
+ }
+
+ events, err := ListBanEventsFiltered(ctx, "", 10, 0, time.Time{}, "nextcloud", "")
+ if err != nil || len(events) != 1 || events[0].IP != "203.0.113.45" {
+ t.Fatalf("ListBanEventsFiltered(nextcloud) = %+v, err=%v", events, err)
+ }
+
+ // Enrichment fills whois and country; the country change must reach FTS.
+ if err := UpdateBanEventEnrichment(ctx, id2, "netname: EXAMPLE-NET", "BR"); err != nil {
+ t.Fatalf("UpdateBanEventEnrichment: %v", err)
+ }
+ rec, found, err := GetBanEventByID(ctx, id2)
+ if err != nil || !found || rec.Whois != "netname: EXAMPLE-NET" || rec.Country != "BR" {
+ t.Fatalf("enriched record = %+v found=%v err=%v", rec, found, err)
+ }
+ if got := searchTotal("BR"); got != 1 {
+ t.Fatalf("search after country update total=%d want 1", got)
+ }
+ // Empty country must not overwrite an existing one.
+ if err := UpdateBanEventEnrichment(ctx, id1, "whois text", ""); err != nil {
+ t.Fatalf("UpdateBanEventEnrichment: %v", err)
+ }
+ if rec, _, _ := GetBanEventByID(ctx, id1); rec.Country != "UA" {
+ t.Fatalf("country overwritten by empty enrichment: %q", rec.Country)
+ }
+
+ // Deletes (retention prune) must drop rows from the FTS index too.
+ if _, err := PruneBanEventsBefore(ctx, time.Now().UTC().Add(time.Hour)); err != nil {
+ t.Fatalf("PruneBanEventsBefore: %v", err)
+ }
+ if got := searchTotal("nextcloud"); got != 0 {
+ t.Fatalf("search after prune total=%d want 0", got)
+ }
+
+ countries, err := ListBanEventCountries(ctx)
+ if err != nil {
+ t.Fatalf("ListBanEventCountries: %v", err)
+ }
+ if len(countries) != 0 {
+ t.Fatalf("countries after prune = %v, want empty", countries)
+ }
+}
+
+// Reproduces the production scenario: ban_events already holds rows when the
+// FTS index is first created. The index must be backfilled (counting the fts
+// table itself reads the content table and always matches the docsize
+// shadow table is the real indicator), and deletes must not corrupt it.
+func TestBanEventsFTSBackfillsExistingRows(t *testing.T) {
+ initTestStorage(t)
+ ctx := context.Background()
+
+ // Simulate a pre-FTS database, drop the index and triggers, then insert.
+ for _, stmt := range []string{
+ `DROP TRIGGER ban_events_fts_ai`,
+ `DROP TRIGGER ban_events_fts_ad`,
+ `DROP TRIGGER ban_events_fts_au`,
+ `DROP TABLE ban_events_fts`,
+ } {
+ if _, err := db.ExecContext(ctx, stmt); err != nil {
+ t.Fatalf("%s: %v", stmt, err)
+ }
+ }
+ ftsAvailable = false
+ if _, err := RecordBanEvent(ctx, BanEventRecord{
+ ServerID: "srv-1", ServerName: "legacy box", Jail: "sshd",
+ IP: "192.0.2.99", Country: "DE", EventType: "ban",
+ OccurredAt: time.Now().UTC().Add(-time.Hour),
+ }); err != nil {
+ t.Fatalf("RecordBanEvent: %v", err)
+ }
+
+ ensureBanEventsFTS(ctx)
+ if !ftsAvailable {
+ t.Fatal("FTS not available after ensureBanEventsFTS")
+ }
+
+ total, err := CountBanEventsFiltered(ctx, "", time.Time{}, "legacy", "")
+ if err != nil || total != 1 {
+ t.Fatalf("search for pre-existing row: total=%d err=%v (backfill missing)", total, err)
+ }
+ // Deleting the backfilled row must work (this failed with CORRUPT_VTAB
+ // when the index was empty while the delete trigger fired).
+ deleted, err := PruneBanEventsBefore(ctx, time.Now().UTC())
+ if err != nil {
+ t.Fatalf("PruneBanEventsBefore on backfilled index: %v", err)
+ }
+ if deleted != 1 {
+ t.Fatalf("pruned %d rows, want 1", deleted)
+ }
+}
+
func TestCountRecentBanEventsByJail(t *testing.T) {
initTestStorage(t)
@@ -116,7 +249,7 @@ func TestCountRecentBanEventsByJail(t *testing.T) {
{ServerID: "srv-2", ServerName: "server 2", Jail: "sshd", IP: "192.0.2.14", EventType: "ban", OccurredAt: now.Add(-5 * time.Minute)},
}
for _, event := range events {
- if err := RecordBanEvent(ctx, event); err != nil {
+ if _, err := RecordBanEvent(ctx, event); err != nil {
t.Fatalf("RecordBanEvent: %v", err)
}
}
@@ -143,7 +276,7 @@ func TestRecordBanEventUsesSortableStorageTime(t *testing.T) {
EventType: "ban",
OccurredAt: occurredAt,
}
- if err := RecordBanEvent(ctx, event); err != nil {
+ if _, err := RecordBanEvent(ctx, event); err != nil {
t.Fatalf("RecordBanEvent: %v", err)
}
@@ -188,7 +321,7 @@ func TestListBanEventsFilteredOmitsHeavyFields(t *testing.T) {
OccurredAt: now.Add(-2 * time.Minute),
}
for _, event := range []BanEventRecord{withDetail, withoutDetail} {
- if err := RecordBanEvent(ctx, event); err != nil {
+ if _, err := RecordBanEvent(ctx, event); err != nil {
t.Fatalf("RecordBanEvent: %v", err)
}
}
diff --git a/internal/version/version.go b/internal/version/version.go
index 770eb8e..c88608d 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/pkg/web/advanced_actions.go b/pkg/web/advanced_actions.go
index d5ec814..dcbb2a2 100644
--- a/pkg/web/advanced_actions.go
+++ b/pkg/web/advanced_actions.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -42,7 +42,7 @@ func evaluateAdvancedActions(ctx context.Context, settings config.AppSettings, s
count, err := storage.CountBanEventsByIP(ctx, ip, server.ID)
if err != nil {
- log.Printf("⚠️ Failed to count ban events for %s: %v", ip, err)
+ log.Printf("WARNING: Failed to count ban events for %s: %v", ip, err)
return
}
if int(count) < cfg.Threshold {
@@ -51,7 +51,7 @@ func evaluateAdvancedActions(ctx context.Context, settings config.AppSettings, s
active, err := storage.IsPermanentBlockActive(ctx, ip, cfg.Integration)
if err != nil {
- log.Printf("⚠️ Failed to check permanent block for %s: %v", ip, err)
+ log.Printf("WARNING: Failed to check permanent block for %s: %v", ip, err)
return
}
if active {
@@ -64,7 +64,7 @@ func evaluateAdvancedActions(ctx context.Context, settings config.AppSettings, s
"count": count,
"threshold": cfg.Threshold,
}, false); err != nil {
- log.Printf("⚠️ Failed to permanently block %s: %v", ip, err)
+ log.Printf("WARNING: Failed to permanently block %s: %v", ip, err)
}
}
@@ -132,7 +132,7 @@ func runAdvancedIntegrationAction(ctx context.Context, action, ip string, settin
Details: string(detailsBytes),
}
if err2 := storage.UpsertPermanentBlock(ctx, rec); err2 != nil {
- log.Printf("⚠️ Failed to record permanent block entry: %v", err2)
+ log.Printf("WARNING: Failed to record permanent block entry: %v", err2)
}
}
diff --git a/pkg/web/assets.go b/pkg/web/assets.go
index 18e48be..fac6100 100644
--- a/pkg/web/assets.go
+++ b/pkg/web/assets.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/pkg/web/assets_test.go b/pkg/web/assets_test.go
index f7076ef..8403ff4 100644
--- a/pkg/web/assets_test.go
+++ b/pkg/web/assets_test.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/pkg/web/auth.go b/pkg/web/auth.go
index e6b8416..b6cf21d 100644
--- a/pkg/web/auth.go
+++ b/pkg/web/auth.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -24,8 +24,6 @@ import (
"github.com/swissmakers/fail2ban-ui/internal/auth"
)
-// If OIDC is enabled, this validates the session and redirects to login if not authenticated
-// If OIDC is disabled, it allows all requests
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !auth.IsEnabled() {
@@ -60,7 +58,6 @@ func AuthMiddleware() gin.HandlerFunc {
}
}
-// Checks if path is a public route (that does not require authentication)
func isPublicRoute(path string) bool {
publicRoutes := []string{
"/auth/login",
@@ -81,7 +78,6 @@ func isPublicRoute(path string) bool {
return false
}
-// Checks if the request is an API request
func isAPIRequest(c *gin.Context) bool {
accept := c.GetHeader("Accept")
return strings.Contains(accept, "application/json") || strings.HasPrefix(c.Request.URL.Path, "/api/")
diff --git a/pkg/web/authorization.go b/pkg/web/authorization.go
index dad8ef2..187412c 100644
--- a/pkg/web/authorization.go
+++ b/pkg/web/authorization.go
@@ -1,3 +1,19 @@
+// Fail2ban UI - A Swiss made, management interface for Fail2ban.
+//
+// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
+//
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
+// You may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// https://www.gnu.org/licenses/agpl-3.0.en.html
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
package web
import (
diff --git a/pkg/web/basepath.go b/pkg/web/basepath.go
index fe380f2..c1652d0 100644
--- a/pkg/web/basepath.go
+++ b/pkg/web/basepath.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -96,7 +96,6 @@ func ExternalPath(internal string) string {
}
// Wraps an HTTP handler and strips BasePath before route matching.
-// resolve against existing /static/* routes.
func StripBasePathHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
base := BasePath()
diff --git a/pkg/web/basepath_test.go b/pkg/web/basepath_test.go
index 06d87e9..a2ef860 100644
--- a/pkg/web/basepath_test.go
+++ b/pkg/web/basepath_test.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/pkg/web/callback_secret.go b/pkg/web/callback_secret.go
index a828693..c4c6a74 100644
--- a/pkg/web/callback_secret.go
+++ b/pkg/web/callback_secret.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -53,15 +53,15 @@ func validateCallbackSecret(c *gin.Context) bool {
settings := config.GetSettings()
switch classifyCallbackSecret(c.GetHeader("X-Callback-Secret"), settings.CallbackSecret) {
case callbackSecretNotConfigured:
- log.Printf("⚠️ Callback secret not configured, rejecting request from %s", c.ClientIP())
+ log.Printf("WARNING: Callback secret not configured, rejecting request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Callback secret not configured"})
return false
case callbackSecretMissingHeader:
- log.Printf("⚠️ Missing X-Callback-Secret header in request from %s", c.ClientIP())
+ log.Printf("WARNING: Missing X-Callback-Secret header in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Callback-Secret header"})
return false
case callbackSecretMismatch:
- log.Printf("⚠️ Invalid callback secret in request from %s", c.ClientIP())
+ log.Printf("WARNING: Invalid callback secret in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid callback secret"})
return false
default:
diff --git a/pkg/web/callback_secret_test.go b/pkg/web/callback_secret_test.go
index a5c3d4c..08d02bd 100644
--- a/pkg/web/callback_secret_test.go
+++ b/pkg/web/callback_secret_test.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/pkg/web/console_logger.go b/pkg/web/console_logger.go
index 0f57875..f4829f2 100644
--- a/pkg/web/console_logger.go
+++ b/pkg/web/console_logger.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/pkg/web/email_header_test.go b/pkg/web/email_header_test.go
index 27ea471..ae72876 100644
--- a/pkg/web/email_header_test.go
+++ b/pkg/web/email_header_test.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go
index 7d28e53..afa925a 100644
--- a/pkg/web/handlers.go
+++ b/pkg/web/handlers.go
@@ -2,11 +2,11 @@
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
-// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
+// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
-// https://www.gnu.org/licenses/gpl-3.0.en.html
+// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
@@ -250,7 +250,7 @@ func SummaryHandler(c *gin.Context) {
if exists && !hasUI {
resp.JailLocalWarning = true
} else if !exists {
- // File was removed (user finished migration) – initialize a fresh managed file
+ // File was removed (user finished migration) - initialize a fresh managed file
if err := conn.EnsureJailLocalStructure(c.Request.Context()); err != nil {
config.DebugLog("Warning: failed to initialize jail.local on summary request: %v", err)
} else {
@@ -510,22 +510,21 @@ func BanNotificationHandler(c *gin.Context) {
var verr validator.ValidationErrors
if errors.As(err, &verr) {
for _, fe := range verr {
- log.Printf("❌ Validation error: Field '%s' violated rule '%s'", fe.Field(), fe.ActualTag())
+ log.Printf("ERROR: Validation error: Field '%s' violated rule '%s'", fe.Field(), fe.ActualTag())
}
} else {
- log.Printf("❌ JSON parsing error -> Action will not be recorded! Details: %v", err)
+ log.Printf("ERROR: JSON parsing error -> Action will not be recorded! Details: %v", err)
}
config.DebugLog("Raw JSON that failed to parse: %s", string(body))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
- // Logs the parsed request
log.Printf("Parsed ban request successfully - IP: %s, Jail: %s, Hostname: %s, Failures: %s",
request.IP, request.Jail, request.Hostname, request.Failures)
if err := integrations.ValidateIP(request.IP); err != nil {
- log.Printf("⚠️ Invalid IP in ban notification: %s", request.IP)
+ log.Printf("WARNING: Invalid IP in ban notification: %s", request.IP)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP: " + err.Error()})
return
}
@@ -537,7 +536,7 @@ func BanNotificationHandler(c *gin.Context) {
}
if err := HandleBanNotification(c.Request.Context(), server, request.IP, request.Jail, request.Hostname, request.Failures, request.Whois, request.Logs); err != nil {
- log.Printf("❌ Failed to process ban notification: %v\n", err)
+ log.Printf("ERROR: Failed to process ban notification: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process ban notification: " + err.Error()})
return
}
@@ -567,10 +566,10 @@ func UnbanNotificationHandler(c *gin.Context) {
var verr validator.ValidationErrors
if errors.As(err, &verr) {
for _, fe := range verr {
- log.Printf("❌ Validation error: Field '%s' violated rule '%s'", fe.Field(), fe.ActualTag())
+ log.Printf("ERROR: Validation error: Field '%s' violated rule '%s'", fe.Field(), fe.ActualTag())
}
} else {
- log.Printf("❌ JSON parsing error -> Action will not be recorded! Details: %v", err)
+ log.Printf("ERROR: JSON parsing error -> Action will not be recorded! Details: %v", err)
}
log.Printf("Raw JSON: %s", string(body))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
@@ -581,7 +580,7 @@ func UnbanNotificationHandler(c *gin.Context) {
request.IP, request.Jail, request.Hostname)
if err := integrations.ValidateIP(request.IP); err != nil {
- log.Printf("⚠️ Invalid IP in unban notification: %s", request.IP)
+ log.Printf("WARNING: Invalid IP in unban notification: %s", request.IP)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP: " + err.Error()})
return
}
@@ -593,7 +592,7 @@ func UnbanNotificationHandler(c *gin.Context) {
}
if err := HandleUnbanNotification(c.Request.Context(), server, request.IP, request.Jail, request.Hostname, "", ""); err != nil {
- log.Printf("❌ Failed to process unban notification: %v\n", err)
+ log.Printf("ERROR: Failed to process unban notification: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process unban notification: " + err.Error()})
return
}
@@ -635,22 +634,50 @@ func ListBanEventsHandler(c *gin.Context) {
country := strings.TrimSpace(c.Query("country"))
ctx := c.Request.Context()
- events, err := storage.ListBanEventsFiltered(ctx, serverID, limit, offset, since, search, country)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+
+ var (
+ events []storage.BanEventRecord
+ listErr error
+ total int64
+ countErr error
+ wg sync.WaitGroup
+ )
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ events, listErr = storage.ListBanEventsFiltered(ctx, serverID, limit, offset, since, search, country)
+ }()
+ if offset == 0 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ total, countErr = storage.CountBanEventsFiltered(ctx, serverID, since, search, country)
+ }()
+ }
+ wg.Wait()
+
+ if listErr != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": listErr.Error()})
return
}
resp := gin.H{"events": events, "hasMore": len(events) == limit}
- if offset == 0 {
- total, errCount := storage.CountBanEventsFiltered(ctx, serverID, since, search, country)
- if errCount == nil {
- resp["total"] = total
- }
+ if offset == 0 && countErr == nil {
+ resp["total"] = total
}
c.JSON(http.StatusOK, resp)
}
+// Returns the distinct countries seen across all stored events
+func ListBanEventCountriesHandler(c *gin.Context) {
+ countries, err := storage.ListBanEventCountries(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"countries": countries})
+}
+
// Returns a single ban event including whois/logs fields.
func GetBanEventHandler(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -1112,7 +1139,7 @@ func UpsertServerHandler(c *gin.Context) {
// Checks the integrity AFTER ensuring structure so fresh servers don't trigger a false-positive warning.
if exists, hasUI, chkErr := conn.CheckJailLocalIntegrity(c.Request.Context()); chkErr == nil && exists && !hasUI {
jailLocalWarning = true
- log.Printf("⚠️ Server %s: jail.local is not managed by Fail2ban-UI. Please migrate your jail.local manually (see documentation).", server.Name)
+ log.Printf("WARNING: Server %s: jail.local is not managed by Fail2ban-UI. Please migrate your jail.local manually (see documentation).", server.Name)
}
// Tries to restart Fail2ban and performs a basic health check after the server was enabled
@@ -1317,40 +1344,25 @@ func waitForConnectorReady(ctx context.Context, conn fail2ban.Connector, attempt
// Notification Processing (Internal)
// =========================================================================
-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)
- whoisData = ""
- }
- } else {
- log.Printf("Using provided whois data for IP %s", ip)
- whoisData = providedWhois
+// Resolves the country via the configured GeoIP provider
+func resolveCountry(ip, providedCountry string, settings config.AppSettings) string {
+ if providedCountry != "" {
+ return providedCountry
}
-
- 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 settings.GeoIPProvider == "builtin" {
+ return ""
}
- return whoisData, country
+ country, err := lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath)
+ if err != nil {
+ log.Printf("WARNING: GeoIP lookup failed for IP %s: %v", ip, err)
+ return ""
+ }
+ return 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)
+ country := resolveCountry(ip, "", settings)
filteredLogs := filterRelevantLogs(logs, ip, settings.MaxLogLines)
event := storage.BanEventRecord{
ServerID: server.ID,
@@ -1360,14 +1372,16 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
Country: country,
Hostname: hostname,
Failures: failures,
- Whois: whoisData,
+ Whois: whois,
Logs: filteredLogs,
EventType: "ban",
OccurredAt: time.Now().UTC(),
}
- if err := storage.RecordBanEvent(ctx, event); err != nil {
- log.Printf("⚠️ Failed to record ban event: %v", err)
+ eventID, err := storage.RecordBanEvent(ctx, event)
+ if err != nil {
+ log.Printf("WARNING: Failed to record ban event: %v", err)
}
+ event.ID = eventID
// Broadcasts the ban event to WebSocket clients
if wsHub != nil {
@@ -1376,30 +1390,14 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
evaluateAdvancedActions(ctx, settings, server, ip)
- displayCountry := country
- if displayCountry == "" {
- displayCountry = "UNKNOWN"
- }
-
- if !shouldAlertForCountry(country, settings.AlertCountries) {
- log.Printf("❌ IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries)
- return nil
- }
-
- if !settings.EmailAlertsForBans {
- log.Printf("❌ Alerts for bans are disabled. No alert sent for IP %s", ip)
- return nil
- }
-
- dispatchAlertAsync("ban", ip, jail, hostname, failures, whoisData, filteredLogs, country, settings)
+ enrichAndAlertAsync(eventID, "ban", ip, jail, hostname, failures, filteredLogs, whois, country, settings)
return nil
}
// Records an unban event, broadcasts it via WebSocket, and sends an email alert if enabled.
func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, whois, country string) error {
- // Loads the settings to get alert countries and GeoIP provider from the database
settings := config.GetSettings()
- whoisData, country := resolveWhoisAndCountry(ip, whois, country, settings)
+ country = resolveCountry(ip, country, settings)
event := storage.BanEventRecord{
ServerID: server.ID,
ServerName: server.Name,
@@ -1408,36 +1406,23 @@ func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer,
Country: country,
Hostname: hostname,
Failures: "",
- Whois: whoisData,
+ Whois: whois,
Logs: "",
EventType: "unban",
OccurredAt: time.Now().UTC(),
}
- if err := storage.RecordBanEvent(ctx, event); err != nil {
- log.Printf("⚠️ Failed to record unban event: %v", err)
+ eventID, err := storage.RecordBanEvent(ctx, event)
+ if err != nil {
+ log.Printf("WARNING: Failed to record unban event: %v", err)
}
+ event.ID = eventID
// Broadcasts the unban event to WebSocket clients
if wsHub != nil {
wsHub.BroadcastUnbanEvent(event)
}
- if !settings.EmailAlertsForUnbans {
- log.Printf("Alerts for unbans are disabled. No alert sent for IP %s", ip)
- return nil
- }
-
- displayCountry := country
- if displayCountry == "" {
- displayCountry = "UNKNOWN"
- }
-
- if !shouldAlertForCountry(country, settings.AlertCountries) {
- log.Printf("IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries)
- return nil
- }
-
- dispatchAlertAsync("unban", ip, jail, hostname, "", whoisData, "", country, settings)
+ enrichAndAlertAsync(eventID, "unban", ip, jail, hostname, "", "", whois, country, settings)
return nil
}
@@ -1445,11 +1430,73 @@ func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer,
// Alert Dispatch
// =========================================================================
-// Sends the alert without blocking the caller. Alerts delivery (SMTP/webhook/Elasticsearch) is never hold the fail2ban callback.
-func dispatchAlertAsync(alertType, ip, jail, hostname, failures, whois, logs, country string, settings config.AppSettings) {
+// Completes whois enrichment and dispatches alerts in the background
+// Whois lookups can take up to 10 seconds and should never block the fail2ban callback response
+func enrichAndAlertAsync(eventID int64, alertType, ip, jail, hostname, failures, logs, providedWhois, country string, settings config.AppSettings) {
go func() {
- if err := dispatchAlert(alertType, ip, jail, hostname, failures, whois, logs, country, settings); err != nil {
- log.Printf("❌ Failed to send %s alert for IP %s: %v", alertType, ip, err)
+ whoisData := providedWhois
+ if whoisData == "" {
+ log.Printf("Performing whois lookup for IP %s", ip)
+ data, err := lookupWhois(ip)
+ if err != nil {
+ log.Printf("WARNING: Whois lookup failed for IP %s: %v", ip, err)
+ } else {
+ whoisData = data
+ }
+ }
+ if country == "" {
+ if resolved, err := lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath); err == nil {
+ country = resolved
+ } else {
+ log.Printf("WARNING: GeoIP lookup failed for IP %s: %v", ip, err)
+ }
+ }
+ if country == "" && whoisData != "" {
+ if extracted := extractCountryFromWhois(whoisData); extracted != "" {
+ country = extracted
+ log.Printf("Extracted country %s from whois data for IP %s", country, ip)
+ }
+ }
+ if eventID > 0 && (whoisData != "" || country != "") {
+ updateCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ if err := storage.UpdateBanEventEnrichment(updateCtx, eventID, whoisData, country); err != nil {
+ log.Printf("WARNING: Failed to store whois enrichment for event %d: %v", eventID, err)
+ } else if wsHub != nil {
+ if enriched, found, err := storage.GetBanEventByID(updateCtx, eventID); err == nil && found {
+ enriched.Whois = ""
+ enriched.Logs = ""
+ wsHub.BroadcastBanEventUpdate(enriched)
+ }
+ }
+ cancel()
+ }
+
+ displayCountry := country
+ if displayCountry == "" {
+ displayCountry = "UNKNOWN"
+ }
+ if alertType == "unban" {
+ if !settings.EmailAlertsForUnbans {
+ log.Printf("Alerts for unbans are disabled. No alert sent for IP %s", ip)
+ return
+ }
+ if !shouldAlertForCountry(country, settings.AlertCountries) {
+ log.Printf("IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries)
+ return
+ }
+ } else {
+ if !shouldAlertForCountry(country, settings.AlertCountries) {
+ log.Printf("ERROR: IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries)
+ return
+ }
+ if !settings.EmailAlertsForBans {
+ log.Printf("ERROR: Alerts for bans are disabled. No alert sent for IP %s", ip)
+ return
+ }
+ }
+
+ if err := dispatchAlert(alertType, ip, jail, hostname, failures, whoisData, logs, country, settings); err != nil {
+ log.Printf("ERROR: Failed to send %s alert for IP %s: %v", alertType, ip, err)
if wsHub != nil {
wsHub.BroadcastToast("error", fmt.Sprintf("Failed to send %s alert for %s: %v", alertType, ip, err))
}
@@ -1857,7 +1904,6 @@ func renderIndexPage(c *gin.Context) {
autoDark := os.Getenv("AUTODARK") == "true" || os.Getenv("AUTODARK") == "1"
languageOptions := listLocaleOptions()
- // Checks if OIDC is enabled and skip login page setting
oidcEnabled := auth.IsEnabled()
skipLoginPage := false
if oidcEnabled {
@@ -1867,7 +1913,6 @@ func renderIndexPage(c *gin.Context) {
}
}
- // Checks is a user wants to disable the github versioning check
updateCheckEnabled := os.Getenv("UPDATE_CHECK") != "false"
urlPrefix := BasePath()
@@ -2151,7 +2196,6 @@ func SetJailFilterConfigHandler(c *gin.Context) {
config.DebugLog("Found original filter directive in jail config: %s", originalFilterName)
}
- // Extracts the new filter name from the updated jail config
newFilterName := fail2ban.ExtractFilterFromJailConfig(req.Jail)
if newFilterName == "" {
newFilterName = jail
@@ -2188,14 +2232,13 @@ func SetJailFilterConfigHandler(c *gin.Context) {
config.DebugLog("No jail config provided, skipping")
}
- // Reloads Fail2ban
config.DebugLog("Reloading fail2ban")
if err := conn.Reload(c.Request.Context()); err != nil {
- log.Printf("⚠️ Config saved but fail2ban reload failed: %v", err)
+ log.Printf("WARNING: Config saved but fail2ban reload failed: %v", err)
// If reload fails, we automatically disable the jail so Fail2ban won't crash on next restart (invalid filter/jail config)
disableUpdate := map[string]bool{jail: false}
if disableErr := conn.UpdateJailEnabledStates(c.Request.Context(), disableUpdate); disableErr != nil {
- log.Printf("⚠️ Failed to auto-disable jail %s after reload failure: %v", jail, disableErr)
+ log.Printf("WARNING: Failed to auto-disable jail %s after reload failure: %v", jail, disableErr)
c.JSON(http.StatusOK, gin.H{
"message": "Config saved successfully, but fail2ban reload failed",
"warning": err.Error(),
@@ -2203,7 +2246,7 @@ func SetJailFilterConfigHandler(c *gin.Context) {
return
}
if reloadErr2 := conn.Reload(c.Request.Context()); reloadErr2 != nil {
- log.Printf("⚠️ Failed to reload fail2ban after auto-disabling jail %s: %v", jail, reloadErr2)
+ log.Printf("WARNING: Failed to reload fail2ban after auto-disabling jail %s: %v", jail, reloadErr2)
}
c.JSON(http.StatusOK, gin.H{
"message": "Config saved successfully, but fail2ban reload failed",
@@ -2246,12 +2289,10 @@ func TestLogpathHandler(c *gin.Context) {
var originalLogpath string
- // Checks if a logpath is provided in the request body
var reqBody struct {
Logpath string `json:"logpath"`
}
if err := c.ShouldBindJSON(&reqBody); err == nil && reqBody.Logpath != "" {
- // Uses the logpath from the request body (from textarea)
originalLogpath = strings.TrimSpace(reqBody.Logpath)
config.DebugLog("Using logpath from request body: %s", originalLogpath)
} else {
@@ -2262,7 +2303,6 @@ func TestLogpathHandler(c *gin.Context) {
return
}
- // Extracts the logpath from the jail config
originalLogpath = fail2ban.ExtractLogpathFromJailConfig(jailCfg)
if originalLogpath == "" {
c.JSON(http.StatusOK, gin.H{
@@ -2281,7 +2321,6 @@ func TestLogpathHandler(c *gin.Context) {
return
}
- // Gets the server type for response metadata
server := conn.Server()
isLocalServer := server.Type == "local"
@@ -2463,7 +2502,6 @@ func AdvancedActionsTestHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Action %s completed for %s", action, req.IP)})
}
-// Returns a sorted slice of jail names from the map.
func getJailNames(jails map[string]bool) []string {
names := make([]string, 0, len(jails))
for name := range jails {
@@ -2497,7 +2535,6 @@ func splitLogpaths(raw string) []string {
var jailErrorPattern = regexp.MustCompile(`Errors in jail '([^']+)'`)
-// Extracts problematic jail names from Fail2ban reload output.
func parseJailErrorsFromReloadOutput(output string) []string {
var problematicJails []string
lines := strings.Split(output, "\n")
@@ -2625,14 +2662,12 @@ func UpdateJailManagementHandler(c *gin.Context) {
// Reloads fail2ban to apply the changes
reloadErr := conn.Reload(c.Request.Context())
- // Checks for errors in reload output
var problematicJails []string
var detailedErrorOutput string
if reloadErr != nil {
errMsg := reloadErr.Error()
config.DebugLog("Error: failed to reload fail2ban after updating jail settings: %v", reloadErr)
- // Extracts the output from the error message
if strings.Contains(errMsg, "(output:") {
outputStart := strings.Index(errMsg, "(output:") + 8
outputEnd := strings.LastIndex(errMsg, ")")
@@ -3063,7 +3098,7 @@ func applySettingsUpdate(c *gin.Context, req config.AppSettings) {
config.DebugLog("Updating DEFAULT settings on server: %s (type: %s)", server.Name, server.Type)
if err := conn.UpdateDefaultSettings(c.Request.Context()); err != nil {
errorMsg := fmt.Sprintf("Failed to update DEFAULT settings on %s: %v", server.Name, err)
- log.Printf("⚠️ %s", errorMsg)
+ log.Printf("WARNING: %s", errorMsg)
updateErrors = append(updateErrors, errorMsg)
} else {
config.DebugLog("Successfully updated DEFAULT settings on %s", server.Name)
@@ -3233,7 +3268,6 @@ func CreateFilterHandler(c *gin.Context) {
req.Content = fmt.Sprintf("# Filter: %s\n", req.FilterName)
}
- // Create the filter
if err := conn.CreateFilter(c.Request.Context(), req.FilterName, req.Content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create filter: " + err.Error()})
return
@@ -3411,7 +3445,7 @@ func sanitizeEmailHeader(value string) string {
func sendEmail(to, subject, body string, settings config.AppSettings) error {
recipients := shared.SplitCommaList(to)
if len(recipients) == 0 {
- log.Printf("⚠️ sendEmail skipped: no recipients provided.")
+ log.Printf("WARNING: sendEmail skipped: no recipients provided.")
return nil
}
@@ -3424,20 +3458,20 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error {
}
}
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.")
+ log.Printf("WARNING: sendEmail skipped: all recipients are still the default placeholder (alerts@example.com). Please update the 'Destination Email' in Settings -> Alert Settings.")
return nil
}
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)
+ log.Printf("ERROR: sendEmail validation failed: %v (Host: %q, Username: %q, From: %q)", err, settings.SMTP.Host, settings.SMTP.Username, settings.SMTP.From)
return err
}
if settings.SMTP.Port <= 0 || settings.SMTP.Port > 65535 {
err := errors.New("SMTP port must be between 1 and 65535")
- log.Printf("❌ sendEmail validation failed: %v (Port: %d)", err, settings.SMTP.Port)
+ log.Printf("ERROR: sendEmail validation failed: %v (Port: %d)", err, settings.SMTP.Port)
return err
}
@@ -3471,10 +3505,10 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error {
}
auth, err := getSMTPAuth(settings.SMTP.Username, settings.SMTP.Password, authMethod, smtpHost)
if err != nil {
- log.Printf("❌ sendEmail: failed to create SMTP auth (method: %q): %v", authMethod, err)
+ log.Printf("ERROR: sendEmail: failed to create SMTP auth (method: %q): %v", authMethod, err)
return fmt.Errorf("failed to create SMTP auth: %w", err)
}
- log.Printf("📧 sendEmail: Using SMTP auth method: %q, host: %s, port: %d, useTLS: %v, insecureSkipVerify: %v", authMethod, smtpHost, smtpPort, settings.SMTP.UseTLS, settings.SMTP.InsecureSkipVerify)
+ log.Printf("sendEmail: Using SMTP auth method: %q, host: %s, port: %d, useTLS: %v, insecureSkipVerify: %v", authMethod, smtpHost, smtpPort, settings.SMTP.UseTLS, settings.SMTP.InsecureSkipVerify)
// Port 465 uses implicit TLS (SMTPS); all other ports use plain SMTP with optional STARTTLS.
useImplicitTLS, useSTARTTLS := smtpTLSMode(smtpPort, settings.SMTP.UseTLS)
@@ -3519,18 +3553,18 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error {
if auth != nil {
if err := client.Auth(auth); err != nil {
- log.Printf("❌ sendEmail: SMTP authentication failed: %v", err)
+ log.Printf("ERROR: sendEmail: SMTP authentication failed: %v", err)
return fmt.Errorf("SMTP authentication failed: %w", err)
}
- log.Printf("📧 sendEmail: SMTP authentication successful")
+ log.Printf("sendEmail: SMTP authentication successful")
}
err = sendSMTPMessage(client, settings.SMTP.From, recipients, msg)
if err != nil {
- log.Printf("❌ sendEmail: Failed to send message: %v", err)
+ log.Printf("ERROR: sendEmail: Failed to send message: %v", err)
return err
}
- log.Printf("📧 sendEmail: Successfully sent email to %s", strings.Join(recipients, ", "))
+ log.Printf("sendEmail: Successfully sent email to %s", strings.Join(recipients, ", "))
return nil
}
@@ -3620,16 +3654,16 @@ func buildClassicEmailBody(title, intro string, details []emailDetail, whoisHTML