Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
eb2f905
Remove second (fallback) storage init, this is no more used since the…
Jul 24, 2026
e798206
Add option to configure custom reverse tunnel port for ssh-connector
Jul 24, 2026
1c989f3
Add a health-check tunnel function and as well change detection handler
Jul 24, 2026
4a31ece
Include the custom reverse-tunnel port to the ssh-connector and imple…
Jul 24, 2026
16df114
Make ServerPort from settings via shared bridge available to our prov…
Jul 24, 2026
d8e2a90
Update reverse tunnel documentation and how the callback works with i…
Jul 24, 2026
b9cb0e1
Fix bug where one failed jail, that includes a filter shared with a o…
Jul 25, 2026
3a6489e
Reuse already done whois and geo-enrichment for unbans instead of re-…
Jul 25, 2026
2d1ee7d
Update SELinux module and include new also 'getattr' for the use of r…
Jul 25, 2026
c32b78a
Add jail-section check, that the new jail contains a valid section he…
Jul 25, 2026
042134e
Fix ssh connector problems and refactored the command output writing
Jul 25, 2026
54d4487
Remove old 'CountBanEvents' functions and unified the jail-parsing ss…
Jul 25, 2026
58d9f51
Expand connection-issue resolving steps in docs
Jul 25, 2026
b623ff4
Implement a option to skip fetching all eventdata, when only one sing…
Jul 25, 2026
0a2f720
Enhance logpath testing to also tell the user when there is a permiss…
Jul 25, 2026
8708e74
Include new test-files for testing planned splitting of the ssh-conne…
Jul 26, 2026
c5ea14c
Refactoring the large SSH connector into separate files for better ma…
Jul 26, 2026
c4f062b
Include new function for jail-config parsing and use the new unified …
Jul 26, 2026
a9c7a37
Also use the new unified getJailSummary for local connectors and impl…
Jul 26, 2026
34f023c
Unify the variable resolver for ssh and local connectors
Jul 26, 2026
0937976
Add a render-protection function to schedule render of the dashboard …
Jul 26, 2026
f641c17
Replaced the old GetJailInfos by GetJailSummary for defauld rendering
Jul 26, 2026
68c9f9d
Add the GetJailSummary to the connector and also remove hardcoded loc…
Jul 26, 2026
c4cad4f
We also use the unified GetJailSummary on the agent connector now
Jul 26, 2026
5948b08
Bump version to v1.5.3
Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,6 @@ func main() {
web.SetBasePathFromEnv()
auth.SetSessionCookiePath(web.CookiePath())

if err := storage.Init(""); err != nil {
log.Fatalf("Failed to initialise storage: %v", err)
}
defer func() {
if err := storage.Close(); err != nil {
log.Printf("warning: failed to close storage: %v", err)
Expand Down
11 changes: 9 additions & 2 deletions deployment/container/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ semodule -i fail2ban-container-ui.pp
semodule -i fail2ban-container-client.pp
```

What the modules cover:

- `fail2ban-container-ui`: lets the container write the managed files under `/etc/fail2ban` and stat/read `/var/log/fail2ban.log`. The latter is required as soon as a jail uses Fail2Ban's own log as `logpath` (e.g. the `recidive` jail): `fail2ban-client reload` stats every logpath while parsing the config, and a denial surfaces as `Have not found any log file for recidive jail`.
- `fail2ban-container-client`: lets the container connect to the host Fail2Ban socket and read the monitored logs (`httpd_log_t`, `var_log_t`).

### Compile and install the policies manually

To modify or rebuild the SELinux rules:
Expand Down Expand Up @@ -413,8 +418,10 @@ Symptoms: empty dashboard, no servers visible.
- sudo access for `fail2ban-client` and `systemctl restart fail2ban` (via sudoers)
- filesystem ACLs on `/etc/fail2ban` for configuration file access
- see [docs/security.md](../../docs/security.md#ssh-connector-hardening) for the recommended service-account setup
3. Check the key location: SSH keys belong in `/config/.ssh` inside the container, with permissions `600`.
4. Enable debug mode under **Settings** for detailed error messages.
3. Check the key location: SSH keys belong in `/config/.ssh` inside the container, with permissions `600` (and the `.ssh` directory itself `700`).
4. Host keys: the connector uses `StrictHostKeyChecking=accept-new` with a persistent `known_hosts` next to the configured SSH key (e.g. `/config/.ssh/known_hosts`). The first connection records the remote host key; if the remote key ever CHANGES, connections fail on purpose - verify the remote host and remove the stale entry with `ssh-keygen -R <host> -f /config/.ssh/known_hosts` to re-trust it. (Earlier versions disabled host-key verification entirely inside containers.)
5. ControlMaster sockets live in a private `ctl/` directory next to the SSH key (e.g. `/config/.ssh/ctl/`), not in `/tmp`. Stale sockets are cleaned up automatically.
6. Enable debug mode under **Settings** for detailed error messages.
5. Verify network connectivity: the container needs network access to the remote SSH servers. Check whether `--network=host` is in use, or configure the appropriate port mappings.

### Permission denied errors
Expand Down
Binary file modified deployment/container/SELinux/fail2ban-container-ui.pp
Binary file not shown.
10 changes: 7 additions & 3 deletions deployment/container/SELinux/fail2ban-container-ui.te
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@

module fail2ban-container-ui 1.0;
module fail2ban-container-ui 1.1;

require {
type fail2ban_log_t;
type etc_t;
type container_t;
class file { open read write };
class file { getattr open read write };
}

#============= container_t ==============
allow container_t etc_t:file write;
allow container_t fail2ban_log_t:file { open read };
# getattr is required in addition to open/read: fail2ban-client stats every
# jail logpath while parsing the config, so without it a jail reading
# /var/log/fail2ban.log (e.g. recidive) fails the client-side reload with
# "Have not found any log file for <jail> jail".
allow container_t fail2ban_log_t:file { getattr open read };
8 changes: 6 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,13 @@ With a subpath:

### Reverse SSH tunnel for callbacks

SSH-connected servers can enable **reverse tunnel for events** (server form). The UI then opens a reverse tunnel (`ssh -R <port>:localhost:<port>`) alongside the SSH control connection so callbacks reach the UI even when the managed host cannot connect to it directly (NAT, firewall). The port is derived from `CALLBACK_URL` (explicit port, otherwise 443/80 by scheme).
SSH-connected servers can enable **reverse tunnel for events** (server form). The UI then opens a reverse tunnel (`ssh -R <tunnel port>:localhost:<server port>`) alongside the SSH control connection so callbacks reach the UI even when the managed host cannot connect to it directly (NAT, firewall, isolated DMZ without outbound access). Two ports are involved: the **tunnel port** is where the remote host listens locally for the fail2ban callbacks, and the **server port** is where the UI's HTTP API listens -> the tunnel carries connections from the former to the latter.

The tunnel is only used if `CALLBACK_URL` points to `localhost`/`127.0.0.1` - the remote Fail2Ban sends its callbacks to that URL, which the tunnel forwards to the UI. With a public callback URL the callbacks bypass the tunnel; the UI logs a warning in that case. Note that a localhost callback URL applies globally, so mixing tunneled and non-tunneled remote servers is not possible.
Each server has its own tunnel port setting. Leave it empty to use the UI bind port (`PORT`, default 8080). If set, it must be between 1024 and 65535: the unprivileged SSH service account on the managed host cannot bind ports below 1024, and `ssh` only reports such a failure as a stderr warning while the connection itself stays up. The same applies when the chosen port is already occupied on the remote host, pick a free unprivileged port in that case. The tunnel port only affects the remote side; the tunnel always forwards to the UI's configured server port, so a custom tunnel port does not need to match it.

For tunneled servers, the UI writes `http://localhost:<tunnel port>` into the remote action file, so the callbacks travel through the tunnel regardless of the global `CALLBACK_URL`. This means mixed setups work: keep `CALLBACK_URL` pointing at a public address for directly reachable servers while tunneled servers use the loopback URL. Plain `http` is correct inside the tunnel, the transport is already SSH-encrypted, so TLS (and `CALLBACK_INSECURE_TLS`) is irrelevant for tunneled servers.

The UI checks every tunnel's SSH master connection every 45 seconds and automatically re-establishes it when it has died (for example after a short network outage), so callback delivery resumes without waiting for the next UI-triggered command. Changing a server's tunnel settings tears down the old connection and builds a new one immediately.

## Privacy and telemetry controls

Expand Down
6 changes: 6 additions & 0 deletions internal/config/fail2ban_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ func (fail2banRuntime) CallbackSecret() string {
return currentSettings.CallbackSecret
}

func (fail2banRuntime) ServerPort() int {
settingsLock.RLock()
defer settingsLock.RUnlock()
return currentSettings.Port
}

func (fail2banRuntime) BuildFail2banActionConfig(callbackURL, serverID, secret string) string {
return BuildFail2banActionConfig(callbackURL, serverID, secret)
}
Expand Down
22 changes: 21 additions & 1 deletion internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ func applyServerRecordsLocked(records []storage.ServerRecord) {
IsDefault: rec.IsDefault,
Enabled: rec.Enabled,
ReverseTunnelEnabled: rec.ReverseTunnelEnabled,
TunnelPort: rec.TunnelPort,
RestartNeeded: rec.NeedsRestart,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
Expand Down Expand Up @@ -630,6 +631,7 @@ func toServerRecordsLocked() ([]storage.ServerRecord, error) {
IsDefault: srv.IsDefault,
Enabled: srv.Enabled,
ReverseTunnelEnabled: srv.ReverseTunnelEnabled,
TunnelPort: srv.TunnelPort,
NeedsRestart: srv.RestartNeeded,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Expand Down Expand Up @@ -1183,7 +1185,18 @@ func GetDefaultServer() Fail2banServer {
return Fail2banServer{}
}

// Adds or updates a Fail2ban server.
// ErrInvalidTunnelPort is returned when a reverse-tunnel port is outside the
// unprivileged range. Handlers match it to attach a localized message key.
var ErrInvalidTunnelPort = errors.New("tunnelPort must be between 1024 and 65535 (empty = server port)")

// Reject reverse-tunnel ports outside the unprivileged range
func validateTunnelPort(port int) error {
if port == 0 || (port >= 1024 && port <= 65535) {
return nil
}
return fmt.Errorf("%w, got %d", ErrInvalidTunnelPort, port)
}

func UpsertServer(input Fail2banServer) (Fail2banServer, error) {
settingsLock.Lock()
defer settingsLock.Unlock()
Expand Down Expand Up @@ -1218,6 +1231,13 @@ func UpsertServer(input Fail2banServer) (Fail2banServer, error) {
input.SocketPath = normalizePathValue(input.SocketPath)
input.ConfigPath = ""
}
if input.Type == "ssh" {
if err := validateTunnelPort(input.TunnelPort); err != nil {
return Fail2banServer{}, err
}
} else {
input.TunnelPort = 0
}
if input.Name == "" {
input.Name = "Fail2ban Server " + input.ID
}
Expand Down
16 changes: 16 additions & 0 deletions internal/config/settings_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,19 @@ func TestFail2banActionConfigEscapesPercent(t *testing.T) {
}
}
}

func TestValidateTunnelPort(t *testing.T) {
t.Parallel()
valid := []int{0, 1024, 8080, 65535}
for _, port := range valid {
if err := validateTunnelPort(port); err != nil {
t.Fatalf("validateTunnelPort(%d) = %v, want nil", port, err)
}
}
invalid := []int{-1, 1, 80, 443, 1023, 65536}
for _, port := range invalid {
if err := validateTunnelPort(port); err == nil {
t.Fatalf("validateTunnelPort(%d) = nil, want error", port)
}
}
}
163 changes: 163 additions & 0 deletions internal/fail2ban/banned_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
//
// Licensed under the GNU Affero General Public License, Version 3 (AGPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.gnu.org/licenses/agpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package fail2ban

import (
"strings"
"testing"
)

const realBannedOutput = `[{'apache-auth': ['102.220.160.200', '185.93.89.147']}, {'apache-overflows': []}, {'apache-nohome': []}, {'apache-fakegooglebot': ['11.10.99.99', '11.22.33.44']}, {'apache-shellshock': []}, {'swissmakers-apache-auth': []}, {'swissmakers-apache-badbots': ['135.237.127.94', '139.162.110.42', '172.105.196.91']}, {'swissmakers-apache-dos': []}, {'swissmakers-apache-exploit-attempts': ['104.208.94.19', '110.35.80.116']}, {'swissmakers-apache-scanner': ['110.35.80.116', '118.194.251.37', '119.179.253.111']}]`

func TestParseBannedJails(t *testing.T) {
t.Run("real production output", func(t *testing.T) {
infos, err := parseBannedJails(realBannedOutput)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(infos) != 10 {
t.Fatalf("expected 10 jails, got %d: %+v", len(infos), infos)
}
// Results must be sorted by jail name
for i := 1; i < len(infos); i++ {
if infos[i-1].JailName > infos[i].JailName {
t.Fatalf("jails must be sorted, got %s before %s", infos[i-1].JailName, infos[i].JailName)
}
}
byName := map[string]JailInfo{}
for _, j := range infos {
byName[j.JailName] = j
}
if got := byName["apache-auth"]; got.TotalBanned != 2 || got.BannedIPs[0] != "102.220.160.200" {
t.Fatalf("apache-auth wrong: %+v", got)
}
if got := byName["apache-overflows"]; got.TotalBanned != 0 || got.BannedIPs == nil {
t.Fatalf("empty jail must have a non-nil empty list, got %+v", got)
}
if got := byName["swissmakers-apache-scanner"]; got.TotalBanned != 3 {
t.Fatalf("scanner count wrong: %+v", got)
}
// Every jail reported by banned is running
for _, j := range infos {
if !j.Enabled {
t.Fatalf("jail %s should be marked enabled: %+v", j.JailName, j)
}
if j.TotalBanned != len(j.BannedIPs) {
t.Fatalf("TotalBanned must match the IP list length for %s: %+v", j.JailName, j)
}
}
})

t.Run("no jails", func(t *testing.T) {
infos, err := parseBannedJails("[]")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(infos) != 0 {
t.Fatalf("expected no jails, got %+v", infos)
}
})

t.Run("single jail with no bans", func(t *testing.T) {
infos, err := parseBannedJails("[{'apache-auth': []}]")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(infos) != 1 || infos[0].JailName != "apache-auth" || infos[0].TotalBanned != 0 {
t.Fatalf("unexpected result: %+v", infos)
}
if infos[0].BannedIPs == nil {
t.Fatal("BannedIPs must be an empty slice, not nil (it is serialized to JSON)")
}
})

t.Run("IPv6 and CIDR entries", func(t *testing.T) {
infos, err := parseBannedJails("[{'sshd': ['2001:db8::1', '::1', '10.0.0.0/8']}]")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"2001:db8::1", "::1", "10.0.0.0/8"}
if len(infos[0].BannedIPs) != len(want) {
t.Fatalf("got %+v", infos[0].BannedIPs)
}
for i := range want {
if infos[0].BannedIPs[i] != want[i] {
t.Fatalf("got %+v, want %+v", infos[0].BannedIPs, want)
}
}
})

t.Run("trailing newline and surrounding whitespace", func(t *testing.T) {
if _, err := parseBannedJails("\n [{'sshd': ['1.2.3.4']}] \n"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

t.Run("double-quoted strings are accepted", func(t *testing.T) {
infos, err := parseBannedJails(`[{"sshd": ["1.2.3.4"]}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if infos[0].JailName != "sshd" || infos[0].BannedIPs[0] != "1.2.3.4" {
t.Fatalf("unexpected result: %+v", infos)
}
})

t.Run("escaped quote inside a name", func(t *testing.T) {
infos, err := parseBannedJails(`[{'we\'ird': []}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if infos[0].JailName != "we'ird" {
t.Fatalf("escape not handled: %q", infos[0].JailName)
}
})

// Malformed input must fail visible -> silently returning zero jails would
// render a dashboard claiming nothing is banned
t.Run("malformed input is an error, never an empty result", func(t *testing.T) {
cases := map[string]string{
"empty": "",
"whitespace only": " \n ",
"fail2ban error text": "Sorry but the command 'xdspfkpw' is not supported",
"missing outer bracket": "{'sshd': []}",
"unterminated list": "[{'sshd': ['1.2.3.4']}",
"unterminated string": "[{'sshd: []}]",
"missing colon": "[{'sshd' []}]",
"unterminated inner list": "[{'sshd': ['1.2.3.4'}]",
"not a list of dicts": "['sshd']",
}
for name, in := range cases {
t.Run(name, func(t *testing.T) {
infos, err := parseBannedJails(in)
if err == nil {
t.Fatalf("expected an error, got %+v", infos)
}
})
}
})

t.Run("parse errors stay readable for huge payloads", func(t *testing.T) {
_, err := parseBannedJails(strings.Repeat("x", 5000))
if err == nil {
t.Fatal("expected an error")
}
if len(err.Error()) > 400 {
t.Fatalf("error message should be truncated, got %d bytes", len(err.Error()))
}
})
}
12 changes: 12 additions & 0 deletions internal/fail2ban/connector_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,18 @@ func (ac *AgentConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
return resp.Jails, nil
}

func (ac *AgentConnector) GetJailSummary(ctx context.Context) (*JailSummary, error) {
infos, err := ac.GetJailInfos(ctx)
if err != nil {
return nil, err
}
exists, managed, err := ac.CheckJailLocalIntegrity(ctx)
if err != nil {
debugf("Warning: could not check jail.local integrity on %s: %v", ac.server.Name, err)
}
return &JailSummary{Jails: infos, JailLocalExists: exists, JailLocalManaged: managed}, nil
}

func (ac *AgentConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
var resp struct {
Jail string `json:"jail"`
Expand Down
1 change: 1 addition & 0 deletions internal/fail2ban/connector_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type testProvider struct{}
func (testProvider) DebugLog(format string, v ...interface{}) {}
func (testProvider) CallbackURL() string { return "http://127.0.0.1:8080" }
func (testProvider) CallbackSecret() string { return "test-secret" }
func (testProvider) ServerPort() int { return 8080 }
func (testProvider) BuildFail2banActionConfig(callbackURL, serverID, secret string) string {
return ""
}
Expand Down
Loading