mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
1529 lines
45 KiB
Go
1529 lines
45 KiB
Go
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
|
//
|
|
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
|
|
//
|
|
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
|
// You may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.gnu.org/licenses/gpl-3.0.en.html
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package config
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/swissmakers/fail2ban-ui/internal/storage"
|
|
)
|
|
|
|
// SMTPSettings holds the SMTP server configuration for sending alert emails
|
|
type SMTPSettings struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
From string `json:"from"`
|
|
UseTLS bool `json:"useTLS"`
|
|
}
|
|
|
|
// AppSettings holds the main UI settings and Fail2ban configuration
|
|
type AppSettings struct {
|
|
Language string `json:"language"`
|
|
Port int `json:"port"`
|
|
Debug bool `json:"debug"`
|
|
RestartNeeded bool `json:"restartNeeded"`
|
|
AlertCountries []string `json:"alertCountries"`
|
|
SMTP SMTPSettings `json:"smtp"`
|
|
CallbackURL string `json:"callbackUrl"`
|
|
CallbackSecret string `json:"callbackSecret"`
|
|
AdvancedActions AdvancedActionsConfig `json:"advancedActions"`
|
|
|
|
Servers []Fail2banServer `json:"servers"`
|
|
|
|
// Fail2Ban [DEFAULT] section values from jail.local
|
|
BantimeIncrement bool `json:"bantimeIncrement"`
|
|
DefaultJailEnable bool `json:"defaultJailEnable"`
|
|
IgnoreIPs []string `json:"ignoreips"` // Changed from string to []string for individual IP management
|
|
Bantime string `json:"bantime"`
|
|
Findtime string `json:"findtime"`
|
|
Maxretry int `json:"maxretry"`
|
|
Destemail string `json:"destemail"`
|
|
Banaction string `json:"banaction"` // Default banning action
|
|
BanactionAllports string `json:"banactionAllports"` // Allports banning action
|
|
//Sender string `json:"sender"`
|
|
|
|
// GeoIP and Whois settings
|
|
GeoIPProvider string `json:"geoipProvider"` // "maxmind" or "builtin"
|
|
GeoIPDatabasePath string `json:"geoipDatabasePath"` // Path to MaxMind database (optional)
|
|
MaxLogLines int `json:"maxLogLines"` // Maximum log lines to include (default: 50)
|
|
|
|
// Email alert preferences
|
|
EmailAlertsForBans bool `json:"emailAlertsForBans"` // Enable email alerts for ban events (default: true)
|
|
EmailAlertsForUnbans bool `json:"emailAlertsForUnbans"` // Enable email alerts for unban events (default: false)
|
|
}
|
|
|
|
type AdvancedActionsConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Threshold int `json:"threshold"`
|
|
Integration string `json:"integration"`
|
|
Mikrotik MikrotikIntegrationSettings `json:"mikrotik"`
|
|
PfSense PfSenseIntegrationSettings `json:"pfSense"`
|
|
}
|
|
|
|
type MikrotikIntegrationSettings struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
SSHKeyPath string `json:"sshKeyPath"`
|
|
AddressList string `json:"addressList"`
|
|
}
|
|
|
|
type PfSenseIntegrationSettings struct {
|
|
BaseURL string `json:"baseUrl"`
|
|
APIToken string `json:"apiToken"`
|
|
APISecret string `json:"apiSecret"`
|
|
Alias string `json:"alias"`
|
|
SkipTLSVerify bool `json:"skipTLSVerify"`
|
|
}
|
|
|
|
func defaultAdvancedActionsConfig() AdvancedActionsConfig {
|
|
return AdvancedActionsConfig{
|
|
Enabled: false,
|
|
Threshold: 5,
|
|
Integration: "",
|
|
Mikrotik: MikrotikIntegrationSettings{
|
|
Port: 22,
|
|
AddressList: "fail2ban-permanent",
|
|
},
|
|
}
|
|
}
|
|
|
|
func normalizeAdvancedActionsConfig(cfg AdvancedActionsConfig) AdvancedActionsConfig {
|
|
if cfg.Threshold <= 0 {
|
|
cfg.Threshold = 5
|
|
}
|
|
if cfg.Mikrotik.Port <= 0 {
|
|
cfg.Mikrotik.Port = 22
|
|
}
|
|
if cfg.Mikrotik.AddressList == "" {
|
|
cfg.Mikrotik.AddressList = "fail2ban-permanent"
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// init paths to key-files
|
|
const (
|
|
settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started
|
|
jailFile = "/etc/fail2ban/jail.local"
|
|
actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf"
|
|
actionCallbackPlaceholder = "__CALLBACK_URL__"
|
|
actionServerIDPlaceholder = "__SERVER_ID__"
|
|
actionSecretPlaceholder = "__CALLBACK_SECRET__"
|
|
actionCurlInsecureFlag = "__CURL_INSECURE_FLAG__"
|
|
)
|
|
|
|
// jailLocalBanner is the standard banner for jail.local files
|
|
const jailLocalBanner = `################################################################################
|
|
# Fail2Ban-UI Managed Configuration
|
|
#
|
|
# WARNING: This file is automatically managed by Fail2Ban-UI.
|
|
# DO NOT EDIT THIS FILE MANUALLY - your changes will be overwritten.
|
|
#
|
|
# This file overrides settings from /etc/fail2ban/jail.conf
|
|
# Custom jail configurations should be placed in /etc/fail2ban/jail.d/
|
|
################################################################################
|
|
|
|
`
|
|
|
|
// JailLocalBanner returns the standard banner for jail.local files
|
|
func JailLocalBanner() string {
|
|
return jailLocalBanner
|
|
}
|
|
|
|
const fail2banActionTemplate = `[INCLUDES]
|
|
|
|
before = sendmail-common.conf
|
|
mail-whois-common.conf
|
|
helpers-common.conf
|
|
|
|
[Definition]
|
|
|
|
# Bypass ban/unban for restored tickets
|
|
norestored = 1
|
|
|
|
# Option: actionban
|
|
# This executes a cURL request to notify our API when an IP is banned.
|
|
|
|
actionban = /usr/bin/curl__CURL_INSECURE_FLAG__ -X POST __CALLBACK_URL__/api/ban \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-Callback-Secret: __CALLBACK_SECRET__" \
|
|
-d "$(jq -n --arg serverId '__SERVER_ID__' \
|
|
--arg ip '<ip>' \
|
|
--arg jail '<name>' \
|
|
--arg hostname '<fq-hostname>' \
|
|
--arg failures '<failures>' \
|
|
--arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \
|
|
'{serverId: $serverId, ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, logs: $logs}')"
|
|
|
|
# Option: actionunban
|
|
# This executes a cURL request to notify our API when an IP is unbanned.
|
|
|
|
actionunban = /usr/bin/curl__CURL_INSECURE_FLAG__ -X POST __CALLBACK_URL__/api/unban \
|
|
-H "Content-Type: application/json" \
|
|
-H "X-Callback-Secret: __CALLBACK_SECRET__" \
|
|
-d "$(jq -n --arg serverId '__SERVER_ID__' \
|
|
--arg ip '<ip>' \
|
|
--arg jail '<name>' \
|
|
--arg hostname '<fq-hostname>' \
|
|
'{serverId: $serverId, ip: $ip, jail: $jail, hostname: $hostname}')"
|
|
|
|
[Init]
|
|
|
|
# Default name of the chain
|
|
name = default
|
|
|
|
# Path to log files containing relevant lines for the abuser IP
|
|
logpath = /dev/null
|
|
|
|
# Number of log lines to include in the email
|
|
grepmax = 200
|
|
grepopts = -m <grepmax>`
|
|
|
|
// in-memory copy of settings
|
|
var (
|
|
currentSettings AppSettings
|
|
settingsLock sync.RWMutex
|
|
)
|
|
|
|
var (
|
|
errSettingsNotFound = errors.New("settings not found")
|
|
backgroundCtx = context.Background()
|
|
)
|
|
|
|
// Fail2banServer represents a Fail2ban instance the UI can manage.
|
|
type Fail2banServer struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"` // local, ssh, agent
|
|
Host string `json:"host,omitempty"`
|
|
Port int `json:"port,omitempty"`
|
|
SocketPath string `json:"socketPath,omitempty"`
|
|
LogPath string `json:"logPath,omitempty"`
|
|
SSHUser string `json:"sshUser,omitempty"`
|
|
SSHKeyPath string `json:"sshKeyPath,omitempty"`
|
|
AgentURL string `json:"agentUrl,omitempty"`
|
|
AgentSecret string `json:"agentSecret,omitempty"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
IsDefault bool `json:"isDefault"`
|
|
Enabled bool `json:"enabled"`
|
|
RestartNeeded bool `json:"restartNeeded"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
|
|
enabledSet bool
|
|
}
|
|
|
|
func (s *Fail2banServer) UnmarshalJSON(data []byte) error {
|
|
type Alias Fail2banServer
|
|
aux := &struct {
|
|
Enabled *bool `json:"enabled"`
|
|
*Alias
|
|
}{
|
|
Alias: (*Alias)(s),
|
|
}
|
|
if err := json.Unmarshal(data, &aux); err != nil {
|
|
return err
|
|
}
|
|
if aux.Enabled != nil {
|
|
s.Enabled = *aux.Enabled
|
|
s.enabledSet = true
|
|
} else {
|
|
s.enabledSet = false
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func init() {
|
|
if err := storage.Init(""); err != nil {
|
|
panic(fmt.Sprintf("failed to initialise storage: %v", err))
|
|
}
|
|
|
|
if err := loadSettingsFromStorage(); err != nil {
|
|
if !errors.Is(err, errSettingsNotFound) {
|
|
fmt.Println("Error loading settings from storage:", err)
|
|
}
|
|
|
|
if err := migrateLegacySettings(); err != nil {
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
fmt.Println("Error migrating legacy settings:", err)
|
|
}
|
|
fmt.Println("App settings not found, initializing from jail.local (if exist)")
|
|
if err := initializeFromJailFile(); err != nil {
|
|
fmt.Println("Error reading jail.local:", err)
|
|
}
|
|
setDefaults()
|
|
fmt.Println("Initialized with defaults.")
|
|
}
|
|
|
|
if err := persistAll(); err != nil {
|
|
fmt.Println("Failed to persist settings:", err)
|
|
}
|
|
} else {
|
|
if err := persistAll(); err != nil {
|
|
fmt.Println("Failed to persist settings:", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadSettingsFromStorage() error {
|
|
appRec, found, err := storage.GetAppSettings(backgroundCtx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
serverRecs, err := storage.ListServers(backgroundCtx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return errSettingsNotFound
|
|
}
|
|
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
applyAppSettingsRecordLocked(appRec)
|
|
applyServerRecordsLocked(serverRecs)
|
|
setDefaultsLocked()
|
|
return nil
|
|
}
|
|
|
|
func migrateLegacySettings() error {
|
|
data, err := os.ReadFile(settingsFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var legacy AppSettings
|
|
if err := json.Unmarshal(data, &legacy); err != nil {
|
|
return err
|
|
}
|
|
|
|
settingsLock.Lock()
|
|
currentSettings = legacy
|
|
settingsLock.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
func persistAll() error {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
setDefaultsLocked()
|
|
return persistAllLocked()
|
|
}
|
|
|
|
func persistAllLocked() error {
|
|
if err := persistAppSettingsLocked(); err != nil {
|
|
return err
|
|
}
|
|
return persistServersLocked()
|
|
}
|
|
|
|
func persistAppSettingsLocked() error {
|
|
rec, err := toAppSettingsRecordLocked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return storage.SaveAppSettings(backgroundCtx, rec)
|
|
}
|
|
|
|
func persistServersLocked() error {
|
|
records, err := toServerRecordsLocked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return storage.ReplaceServers(backgroundCtx, records)
|
|
}
|
|
|
|
func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) {
|
|
currentSettings.Language = rec.Language
|
|
currentSettings.Port = rec.Port
|
|
currentSettings.Debug = rec.Debug
|
|
currentSettings.CallbackURL = rec.CallbackURL
|
|
currentSettings.RestartNeeded = rec.RestartNeeded
|
|
currentSettings.BantimeIncrement = rec.BantimeIncrement
|
|
currentSettings.DefaultJailEnable = rec.DefaultJailEnable
|
|
// Convert IgnoreIP string to array (backward compatibility)
|
|
if rec.IgnoreIP != "" {
|
|
currentSettings.IgnoreIPs = strings.Fields(rec.IgnoreIP)
|
|
} else {
|
|
currentSettings.IgnoreIPs = []string{}
|
|
}
|
|
currentSettings.Bantime = rec.Bantime
|
|
currentSettings.Findtime = rec.Findtime
|
|
currentSettings.Maxretry = rec.MaxRetry
|
|
currentSettings.Destemail = rec.DestEmail
|
|
currentSettings.Banaction = rec.Banaction
|
|
currentSettings.BanactionAllports = rec.BanactionAllports
|
|
currentSettings.SMTP = SMTPSettings{
|
|
Host: rec.SMTPHost,
|
|
Port: rec.SMTPPort,
|
|
Username: rec.SMTPUsername,
|
|
Password: rec.SMTPPassword,
|
|
From: rec.SMTPFrom,
|
|
UseTLS: rec.SMTPUseTLS,
|
|
}
|
|
|
|
if rec.AlertCountriesJSON != "" {
|
|
var countries []string
|
|
if err := json.Unmarshal([]byte(rec.AlertCountriesJSON), &countries); err == nil {
|
|
currentSettings.AlertCountries = countries
|
|
}
|
|
}
|
|
if rec.AdvancedActionsJSON != "" {
|
|
var adv AdvancedActionsConfig
|
|
if err := json.Unmarshal([]byte(rec.AdvancedActionsJSON), &adv); err == nil {
|
|
currentSettings.AdvancedActions = adv
|
|
}
|
|
}
|
|
currentSettings.GeoIPProvider = rec.GeoIPProvider
|
|
currentSettings.GeoIPDatabasePath = rec.GeoIPDatabasePath
|
|
currentSettings.MaxLogLines = rec.MaxLogLines
|
|
currentSettings.CallbackSecret = rec.CallbackSecret
|
|
currentSettings.EmailAlertsForBans = rec.EmailAlertsForBans
|
|
currentSettings.EmailAlertsForUnbans = rec.EmailAlertsForUnbans
|
|
}
|
|
|
|
func applyServerRecordsLocked(records []storage.ServerRecord) {
|
|
servers := make([]Fail2banServer, 0, len(records))
|
|
for _, rec := range records {
|
|
var tags []string
|
|
if rec.TagsJSON != "" {
|
|
_ = json.Unmarshal([]byte(rec.TagsJSON), &tags)
|
|
}
|
|
server := Fail2banServer{
|
|
ID: rec.ID,
|
|
Name: rec.Name,
|
|
Type: rec.Type,
|
|
Host: rec.Host,
|
|
Port: rec.Port,
|
|
SocketPath: rec.SocketPath,
|
|
LogPath: rec.LogPath,
|
|
SSHUser: rec.SSHUser,
|
|
SSHKeyPath: rec.SSHKeyPath,
|
|
AgentURL: rec.AgentURL,
|
|
AgentSecret: rec.AgentSecret,
|
|
Hostname: rec.Hostname,
|
|
Tags: tags,
|
|
IsDefault: rec.IsDefault,
|
|
Enabled: rec.Enabled,
|
|
RestartNeeded: rec.NeedsRestart,
|
|
CreatedAt: rec.CreatedAt,
|
|
UpdatedAt: rec.UpdatedAt,
|
|
enabledSet: true,
|
|
}
|
|
servers = append(servers, server)
|
|
}
|
|
currentSettings.Servers = servers
|
|
}
|
|
|
|
func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
|
|
countries := currentSettings.AlertCountries
|
|
if countries == nil {
|
|
countries = []string{}
|
|
}
|
|
countryBytes, err := json.Marshal(countries)
|
|
if err != nil {
|
|
return storage.AppSettingsRecord{}, err
|
|
}
|
|
|
|
advancedBytes, err := json.Marshal(currentSettings.AdvancedActions)
|
|
if err != nil {
|
|
return storage.AppSettingsRecord{}, err
|
|
}
|
|
|
|
return storage.AppSettingsRecord{
|
|
// Basic app settings
|
|
Language: currentSettings.Language,
|
|
Port: currentSettings.Port,
|
|
Debug: currentSettings.Debug,
|
|
RestartNeeded: currentSettings.RestartNeeded,
|
|
// Callback settings
|
|
CallbackURL: currentSettings.CallbackURL,
|
|
CallbackSecret: currentSettings.CallbackSecret,
|
|
// Alert settings
|
|
AlertCountriesJSON: string(countryBytes),
|
|
EmailAlertsForBans: currentSettings.EmailAlertsForBans,
|
|
EmailAlertsForUnbans: currentSettings.EmailAlertsForUnbans,
|
|
// SMTP settings
|
|
SMTPHost: currentSettings.SMTP.Host,
|
|
SMTPPort: currentSettings.SMTP.Port,
|
|
SMTPUsername: currentSettings.SMTP.Username,
|
|
SMTPPassword: currentSettings.SMTP.Password,
|
|
SMTPFrom: currentSettings.SMTP.From,
|
|
SMTPUseTLS: currentSettings.SMTP.UseTLS,
|
|
// Fail2Ban DEFAULT settings
|
|
BantimeIncrement: currentSettings.BantimeIncrement,
|
|
DefaultJailEnable: currentSettings.DefaultJailEnable,
|
|
// Convert IgnoreIPs array to space-separated string for storage
|
|
IgnoreIP: strings.Join(currentSettings.IgnoreIPs, " "),
|
|
Bantime: currentSettings.Bantime,
|
|
Findtime: currentSettings.Findtime,
|
|
MaxRetry: currentSettings.Maxretry,
|
|
DestEmail: currentSettings.Destemail,
|
|
Banaction: currentSettings.Banaction,
|
|
BanactionAllports: currentSettings.BanactionAllports,
|
|
// Advanced features
|
|
AdvancedActionsJSON: string(advancedBytes),
|
|
GeoIPProvider: currentSettings.GeoIPProvider,
|
|
GeoIPDatabasePath: currentSettings.GeoIPDatabasePath,
|
|
MaxLogLines: currentSettings.MaxLogLines,
|
|
}, nil
|
|
}
|
|
|
|
func toServerRecordsLocked() ([]storage.ServerRecord, error) {
|
|
records := make([]storage.ServerRecord, 0, len(currentSettings.Servers))
|
|
for _, srv := range currentSettings.Servers {
|
|
tags := srv.Tags
|
|
if tags == nil {
|
|
tags = []string{}
|
|
}
|
|
tagBytes, err := json.Marshal(tags)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
createdAt := srv.CreatedAt
|
|
if createdAt.IsZero() {
|
|
createdAt = time.Now().UTC()
|
|
}
|
|
updatedAt := srv.UpdatedAt
|
|
if updatedAt.IsZero() {
|
|
updatedAt = createdAt
|
|
}
|
|
records = append(records, storage.ServerRecord{
|
|
ID: srv.ID,
|
|
Name: srv.Name,
|
|
Type: srv.Type,
|
|
Host: srv.Host,
|
|
Port: srv.Port,
|
|
SocketPath: srv.SocketPath,
|
|
LogPath: srv.LogPath,
|
|
SSHUser: srv.SSHUser,
|
|
SSHKeyPath: srv.SSHKeyPath,
|
|
AgentURL: srv.AgentURL,
|
|
AgentSecret: srv.AgentSecret,
|
|
Hostname: srv.Hostname,
|
|
TagsJSON: string(tagBytes),
|
|
IsDefault: srv.IsDefault,
|
|
Enabled: srv.Enabled,
|
|
NeedsRestart: srv.RestartNeeded,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
})
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
// setDefaults populates default values in currentSettings
|
|
func setDefaults() {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
setDefaultsLocked()
|
|
}
|
|
|
|
func setDefaultsLocked() {
|
|
if currentSettings.Language == "" {
|
|
currentSettings.Language = "en"
|
|
}
|
|
|
|
// Set email alert defaults
|
|
if !currentSettings.EmailAlertsForBans && !currentSettings.EmailAlertsForUnbans {
|
|
// Check if it is uninitialized by checking if we have other initialized values
|
|
// If we have a callback secret or port set, it means we've loaded from storage, so we don't override
|
|
if currentSettings.CallbackSecret == "" && currentSettings.Port == 0 {
|
|
// Uninitialized so we set defaults
|
|
currentSettings.EmailAlertsForBans = true
|
|
currentSettings.EmailAlertsForUnbans = false
|
|
}
|
|
}
|
|
|
|
// Check for PORT environment variable first - it always takes priority
|
|
if portEnv := os.Getenv("PORT"); portEnv != "" {
|
|
if port, err := strconv.Atoi(portEnv); err == nil && port > 0 && port <= 65535 {
|
|
currentSettings.Port = port
|
|
} else if currentSettings.Port == 0 {
|
|
currentSettings.Port = 8080
|
|
}
|
|
} else if currentSettings.Port == 0 {
|
|
currentSettings.Port = 8080
|
|
}
|
|
// Auto-update callback URL if it's empty or still using the old default localhost pattern
|
|
if currentSettings.CallbackURL == "" {
|
|
currentSettings.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d", currentSettings.Port)
|
|
} else {
|
|
// If callback URL matches the old default pattern, update it to match the current port
|
|
oldPattern := regexp.MustCompile(`^http://127\.0\.0\.1:\d+$`)
|
|
if oldPattern.MatchString(currentSettings.CallbackURL) {
|
|
currentSettings.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d", currentSettings.Port)
|
|
}
|
|
}
|
|
// Generate callback secret if not set (only generate once, never regenerate)
|
|
if currentSettings.CallbackSecret == "" {
|
|
currentSettings.CallbackSecret = generateCallbackSecret()
|
|
}
|
|
if currentSettings.AlertCountries == nil {
|
|
currentSettings.AlertCountries = []string{"ALL"}
|
|
}
|
|
if currentSettings.Bantime == "" {
|
|
currentSettings.Bantime = "48h"
|
|
}
|
|
if currentSettings.Findtime == "" {
|
|
currentSettings.Findtime = "30m"
|
|
}
|
|
if currentSettings.Maxretry == 0 {
|
|
currentSettings.Maxretry = 3
|
|
}
|
|
if currentSettings.Destemail == "" {
|
|
currentSettings.Destemail = "alerts@example.com"
|
|
}
|
|
if currentSettings.SMTP.Host == "" {
|
|
currentSettings.SMTP.Host = "smtp.office365.com"
|
|
}
|
|
if currentSettings.SMTP.Port == 0 {
|
|
currentSettings.SMTP.Port = 587
|
|
}
|
|
if currentSettings.SMTP.Username == "" {
|
|
currentSettings.SMTP.Username = "noreply@swissmakers.ch"
|
|
}
|
|
if currentSettings.SMTP.Password == "" {
|
|
currentSettings.SMTP.Password = "password"
|
|
}
|
|
if currentSettings.SMTP.From == "" {
|
|
currentSettings.SMTP.From = "noreply@swissmakers.ch"
|
|
}
|
|
if !currentSettings.SMTP.UseTLS {
|
|
currentSettings.SMTP.UseTLS = true
|
|
}
|
|
if len(currentSettings.IgnoreIPs) == 0 {
|
|
currentSettings.IgnoreIPs = []string{"127.0.0.1/8", "::1"}
|
|
}
|
|
if currentSettings.Banaction == "" {
|
|
currentSettings.Banaction = "iptables-multiport"
|
|
}
|
|
if currentSettings.BanactionAllports == "" {
|
|
currentSettings.BanactionAllports = "iptables-allports"
|
|
}
|
|
if currentSettings.GeoIPProvider == "" {
|
|
currentSettings.GeoIPProvider = "builtin"
|
|
}
|
|
if currentSettings.GeoIPDatabasePath == "" {
|
|
currentSettings.GeoIPDatabasePath = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
|
|
}
|
|
if currentSettings.MaxLogLines == 0 {
|
|
currentSettings.MaxLogLines = 50
|
|
}
|
|
|
|
if (currentSettings.AdvancedActions == AdvancedActionsConfig{}) {
|
|
currentSettings.AdvancedActions = defaultAdvancedActionsConfig()
|
|
}
|
|
currentSettings.AdvancedActions = normalizeAdvancedActionsConfig(currentSettings.AdvancedActions)
|
|
|
|
normalizeServersLocked()
|
|
}
|
|
|
|
// initializeFromJailFile reads Fail2ban jail.local and merges its settings into currentSettings.
|
|
func initializeFromJailFile() error {
|
|
file, err := os.Open(jailFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
scanner := bufio.NewScanner(file)
|
|
re := regexp.MustCompile(`^\s*(?P<key>[a-zA-Z0-9_]+)\s*=\s*(?P<value>.+)$`)
|
|
|
|
settings := map[string]string{}
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if matches := re.FindStringSubmatch(line); matches != nil {
|
|
key := strings.ToLower(matches[1])
|
|
value := matches[2]
|
|
settings[key] = value
|
|
}
|
|
}
|
|
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if val, ok := settings["bantime"]; ok {
|
|
currentSettings.Bantime = val
|
|
}
|
|
if val, ok := settings["findtime"]; ok {
|
|
currentSettings.Findtime = val
|
|
}
|
|
if val, ok := settings["maxretry"]; ok {
|
|
if maxRetry, err := strconv.Atoi(val); err == nil {
|
|
currentSettings.Maxretry = maxRetry
|
|
}
|
|
}
|
|
if val, ok := settings["ignoreip"]; ok {
|
|
// Convert space-separated string to array
|
|
if val != "" {
|
|
currentSettings.IgnoreIPs = strings.Fields(val)
|
|
} else {
|
|
currentSettings.IgnoreIPs = []string{}
|
|
}
|
|
}
|
|
if val, ok := settings["banaction"]; ok {
|
|
currentSettings.Banaction = val
|
|
}
|
|
if val, ok := settings["banaction_allports"]; ok {
|
|
currentSettings.BanactionAllports = val
|
|
}
|
|
if val, ok := settings["destemail"]; ok {
|
|
currentSettings.Destemail = val
|
|
}
|
|
/*if val, ok := settings["sender"]; ok {
|
|
currentSettings.Sender = val
|
|
}*/
|
|
|
|
return nil
|
|
}
|
|
|
|
func normalizeServersLocked() {
|
|
now := time.Now().UTC()
|
|
if len(currentSettings.Servers) == 0 {
|
|
hostname, _ := os.Hostname()
|
|
currentSettings.Servers = []Fail2banServer{{
|
|
ID: "local",
|
|
Name: "Fail2ban",
|
|
Type: "local",
|
|
SocketPath: "/var/run/fail2ban/fail2ban.sock",
|
|
LogPath: "/var/log/fail2ban.log",
|
|
Hostname: hostname,
|
|
IsDefault: false,
|
|
Enabled: false,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
enabledSet: true,
|
|
}}
|
|
return
|
|
}
|
|
|
|
hasDefault := false
|
|
for idx := range currentSettings.Servers {
|
|
server := ¤tSettings.Servers[idx]
|
|
if server.ID == "" {
|
|
server.ID = generateServerID()
|
|
}
|
|
if server.Name == "" {
|
|
server.Name = "Fail2ban Server " + server.ID
|
|
}
|
|
if server.Type == "" {
|
|
server.Type = "local"
|
|
}
|
|
if server.CreatedAt.IsZero() {
|
|
server.CreatedAt = now
|
|
}
|
|
if server.UpdatedAt.IsZero() {
|
|
server.UpdatedAt = now
|
|
}
|
|
if server.Type == "local" && server.SocketPath == "" {
|
|
server.SocketPath = "/var/run/fail2ban/fail2ban.sock"
|
|
}
|
|
if server.Type == "local" && server.LogPath == "" {
|
|
server.LogPath = "/var/log/fail2ban.log"
|
|
}
|
|
if !server.enabledSet {
|
|
if server.Type == "local" {
|
|
server.Enabled = false
|
|
} else {
|
|
server.Enabled = true
|
|
}
|
|
}
|
|
server.enabledSet = true
|
|
if !server.Enabled {
|
|
server.RestartNeeded = false
|
|
}
|
|
if server.IsDefault && !server.Enabled {
|
|
server.IsDefault = false
|
|
}
|
|
if server.IsDefault && server.Enabled {
|
|
hasDefault = true
|
|
}
|
|
}
|
|
|
|
if !hasDefault {
|
|
for idx := range currentSettings.Servers {
|
|
if currentSettings.Servers[idx].Enabled {
|
|
currentSettings.Servers[idx].IsDefault = true
|
|
hasDefault = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(currentSettings.Servers, func(i, j int) bool {
|
|
return currentSettings.Servers[i].CreatedAt.Before(currentSettings.Servers[j].CreatedAt)
|
|
})
|
|
|
|
updateGlobalRestartFlagLocked()
|
|
}
|
|
|
|
func generateServerID() string {
|
|
var b [8]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return fmt.Sprintf("srv-%d", time.Now().UnixNano())
|
|
}
|
|
return "srv-" + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// ensureFail2banActionFiles writes the local action files if Fail2ban is present.
|
|
func ensureFail2banActionFiles(callbackURL, serverID string) error {
|
|
DebugLog("----------------------------")
|
|
DebugLog("ensureFail2banActionFiles called (settings.go)")
|
|
|
|
if _, err := os.Stat(filepath.Dir(jailFile)); os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
|
|
// Ensure jail.local has proper structure (banner, DEFAULT, action_mwlg, action override)
|
|
if err := ensureJailLocalStructure(); err != nil {
|
|
return err
|
|
}
|
|
return writeFail2banAction(callbackURL, serverID)
|
|
}
|
|
|
|
// EnsureJailLocalStructure creates or updates jail.local with proper structure:
|
|
// This is exported so connectors can call it.
|
|
func EnsureJailLocalStructure() error {
|
|
return ensureJailLocalStructure()
|
|
}
|
|
|
|
// ensureJailLocalStructure creates or updates jail.local with proper structure:
|
|
// 1. Banner at top warning users not to edit manually
|
|
// 2. [DEFAULT] section with current UI settings
|
|
// 3. action_mwlg configuration
|
|
// 4. action = %(action_mwlg)s at the end
|
|
func ensureJailLocalStructure() error {
|
|
DebugLog("Running ensureJailLocalStructure()") // entry point
|
|
|
|
// Check if /etc/fail2ban directory exists (fail2ban must be installed)
|
|
if _, err := os.Stat(filepath.Dir(jailFile)); os.IsNotExist(err) {
|
|
return fmt.Errorf("fail2ban is not installed: /etc/fail2ban directory does not exist. Please install fail2ban package first")
|
|
}
|
|
|
|
// Get current settings
|
|
settings := GetSettings()
|
|
|
|
// Read existing jail.local content if it exists
|
|
var existingContent string
|
|
if content, err := os.ReadFile(jailFile); err == nil {
|
|
existingContent = string(content)
|
|
}
|
|
|
|
// Check if file already has our full banner (indicating it's already properly structured)
|
|
// Check for the complete banner pattern with hash line separators
|
|
hasFullBanner := strings.Contains(existingContent, "################################################################################") &&
|
|
strings.Contains(existingContent, "Fail2Ban-UI Managed Configuration") &&
|
|
strings.Contains(existingContent, "DO NOT EDIT THIS FILE MANUALLY")
|
|
hasActionMwlg := strings.Contains(existingContent, "action_mwlg") && strings.Contains(existingContent, "ui-custom-action")
|
|
hasActionOverride := strings.Contains(existingContent, "action = %(action_mwlg)s")
|
|
|
|
// If file is already properly structured, just ensure DEFAULT section is up to date
|
|
if hasFullBanner && hasActionMwlg && hasActionOverride {
|
|
DebugLog("jail.local already has proper structure, updating DEFAULT section if needed")
|
|
// Update DEFAULT section values without changing structure
|
|
return updateJailLocalDefaultSection(settings)
|
|
}
|
|
|
|
// Use the standard banner
|
|
banner := jailLocalBanner
|
|
|
|
// Build [DEFAULT] section
|
|
// Convert IgnoreIPs array to space-separated string
|
|
ignoreIPStr := strings.Join(settings.IgnoreIPs, " ")
|
|
if ignoreIPStr == "" {
|
|
ignoreIPStr = "127.0.0.1/8 ::1"
|
|
}
|
|
// Set default banaction values if not set
|
|
banaction := settings.Banaction
|
|
if banaction == "" {
|
|
banaction = "iptables-multiport"
|
|
}
|
|
banactionAllports := settings.BanactionAllports
|
|
if banactionAllports == "" {
|
|
banactionAllports = "iptables-allports"
|
|
}
|
|
defaultSection := fmt.Sprintf(`[DEFAULT]
|
|
enabled = %t
|
|
bantime.increment = %t
|
|
ignoreip = %s
|
|
bantime = %s
|
|
findtime = %s
|
|
maxretry = %d
|
|
destemail = %s
|
|
banaction = %s
|
|
banaction_allports = %s
|
|
|
|
`, settings.DefaultJailEnable, settings.BantimeIncrement, ignoreIPStr, settings.Bantime, settings.Findtime, settings.Maxretry, settings.Destemail, banaction, banactionAllports)
|
|
|
|
// Build action_mwlg configuration
|
|
// Note: action_mwlg depends on action_ which depends on banaction (now defined above)
|
|
// The multi-line format uses indentation for continuation
|
|
actionMwlgConfig := `# Custom Fail2Ban action using geo-filter for email alerts
|
|
action_mwlg = %(action_)s
|
|
ui-custom-action[sender="%(sender)s", dest="%(destemail)s", logpath="%(logpath)s", chain="%(chain)s"]
|
|
|
|
`
|
|
|
|
// Build action override (at the end as per user requirements)
|
|
actionOverride := `# Custom Fail2Ban action applied by fail2ban-ui
|
|
action = %(action_mwlg)s
|
|
`
|
|
|
|
// Combine all parts
|
|
newContent := banner + defaultSection + actionMwlgConfig + actionOverride
|
|
|
|
// Write the new content
|
|
err := os.WriteFile(jailFile, []byte(newContent), 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write jail.local: %v", err)
|
|
}
|
|
|
|
DebugLog("Created/updated jail.local with proper structure")
|
|
return nil
|
|
}
|
|
|
|
// updateJailLocalDefaultSection updates only the [DEFAULT] section values in jail.local
|
|
// while preserving the banner, action_mwlg, and action override
|
|
func updateJailLocalDefaultSection(settings AppSettings) error {
|
|
content, err := os.ReadFile(jailFile)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read jail.local: %w", err)
|
|
}
|
|
|
|
contentStr := string(content)
|
|
lines := strings.Split(contentStr, "\n")
|
|
var outputLines []string
|
|
inDefault := false
|
|
defaultUpdated := false
|
|
|
|
// Convert IgnoreIPs array to space-separated string
|
|
ignoreIPStr := strings.Join(settings.IgnoreIPs, " ")
|
|
if ignoreIPStr == "" {
|
|
ignoreIPStr = "127.0.0.1/8 ::1"
|
|
}
|
|
// Set default banaction values if not set
|
|
banaction := settings.Banaction
|
|
if banaction == "" {
|
|
banaction = "iptables-multiport"
|
|
}
|
|
banactionAllports := settings.BanactionAllports
|
|
if banactionAllports == "" {
|
|
banactionAllports = "iptables-allports"
|
|
}
|
|
// Keys to update
|
|
keysToUpdate := map[string]string{
|
|
"enabled": fmt.Sprintf("enabled = %t", settings.DefaultJailEnable),
|
|
"bantime.increment": fmt.Sprintf("bantime.increment = %t", settings.BantimeIncrement),
|
|
"ignoreip": fmt.Sprintf("ignoreip = %s", ignoreIPStr),
|
|
"bantime": fmt.Sprintf("bantime = %s", settings.Bantime),
|
|
"findtime": fmt.Sprintf("findtime = %s", settings.Findtime),
|
|
"maxretry": fmt.Sprintf("maxretry = %d", settings.Maxretry),
|
|
"destemail": fmt.Sprintf("destemail = %s", settings.Destemail),
|
|
"banaction": fmt.Sprintf("banaction = %s", banaction),
|
|
"banaction_allports": fmt.Sprintf("banaction_allports = %s", banactionAllports),
|
|
}
|
|
keysUpdated := make(map[string]bool)
|
|
|
|
// Always add the full banner at the start
|
|
outputLines = append(outputLines, strings.Split(strings.TrimRight(jailLocalBanner, "\n"), "\n")...)
|
|
|
|
// Skip everything before [DEFAULT] section (old banner, comments, empty lines)
|
|
foundSection := false
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
// Found a section - stop skipping and process this line
|
|
foundSection = true
|
|
}
|
|
if !foundSection {
|
|
// Skip lines before any section (old banner, comments, empty lines)
|
|
continue
|
|
}
|
|
|
|
// Process lines after we found a section
|
|
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
|
|
sectionName := strings.Trim(trimmed, "[]")
|
|
if sectionName == "DEFAULT" {
|
|
inDefault = true
|
|
outputLines = append(outputLines, line)
|
|
} else {
|
|
inDefault = false
|
|
outputLines = append(outputLines, line)
|
|
}
|
|
} else if inDefault {
|
|
// Check if this line is a key we need to update
|
|
keyUpdated := false
|
|
for key, newValue := range keysToUpdate {
|
|
keyPattern := "^\\s*" + regexp.QuoteMeta(key) + "\\s*="
|
|
if matched, _ := regexp.MatchString(keyPattern, trimmed); matched {
|
|
outputLines = append(outputLines, newValue)
|
|
keysUpdated[key] = true
|
|
keyUpdated = true
|
|
defaultUpdated = true
|
|
break
|
|
}
|
|
}
|
|
if !keyUpdated {
|
|
// Keep the line as-is
|
|
outputLines = append(outputLines, line)
|
|
}
|
|
} else {
|
|
// Keep lines outside DEFAULT section
|
|
outputLines = append(outputLines, line)
|
|
}
|
|
}
|
|
|
|
// Add any missing keys to the DEFAULT section
|
|
if inDefault {
|
|
for key, newValue := range keysToUpdate {
|
|
if !keysUpdated[key] {
|
|
// Find the DEFAULT section and insert after it
|
|
for i, outputLine := range outputLines {
|
|
if strings.TrimSpace(outputLine) == "[DEFAULT]" {
|
|
outputLines = append(outputLines[:i+1], append([]string{newValue}, outputLines[i+1:]...)...)
|
|
defaultUpdated = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if defaultUpdated {
|
|
newContent := strings.Join(outputLines, "\n")
|
|
if err := os.WriteFile(jailFile, []byte(newContent), 0644); err != nil {
|
|
return fmt.Errorf("failed to write jail.local: %w", err)
|
|
}
|
|
DebugLog("Updated DEFAULT section in jail.local")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// writeFail2banAction creates or updates the action file with the AlertCountries.
|
|
func writeFail2banAction(callbackURL, serverID string) error {
|
|
DebugLog("Running initial writeFail2banAction()") // entry point
|
|
DebugLog("----------------------------")
|
|
|
|
// Check if /etc/fail2ban/action.d directory exists (fail2ban must be installed)
|
|
if _, err := os.Stat(filepath.Dir(actionFile)); os.IsNotExist(err) {
|
|
return fmt.Errorf("fail2ban is not installed: /etc/fail2ban/action.d directory does not exist. Please install fail2ban package first")
|
|
}
|
|
|
|
settings := GetSettings()
|
|
actionConfig := BuildFail2banActionConfig(callbackURL, serverID, settings.CallbackSecret)
|
|
err := os.WriteFile(actionFile, []byte(actionConfig), 0644)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write action file: %w", err)
|
|
}
|
|
|
|
DebugLog("Custom-action file successfully written to %s\n", actionFile)
|
|
return nil
|
|
}
|
|
|
|
func cloneServer(src Fail2banServer) Fail2banServer {
|
|
dst := src
|
|
if src.Tags != nil {
|
|
dst.Tags = append([]string{}, src.Tags...)
|
|
}
|
|
dst.enabledSet = src.enabledSet
|
|
return dst
|
|
}
|
|
|
|
func BuildFail2banActionConfig(callbackURL, serverID, secret string) string {
|
|
trimmed := strings.TrimRight(strings.TrimSpace(callbackURL), "/")
|
|
if trimmed == "" {
|
|
trimmed = "http://127.0.0.1:8080"
|
|
}
|
|
if serverID == "" {
|
|
serverID = "local"
|
|
}
|
|
if secret == "" {
|
|
// If secret is empty, get it from settings (should be generated by setDefaultsLocked)
|
|
settings := GetSettings()
|
|
secret = settings.CallbackSecret
|
|
// Last resort: if still empty, generate one (shouldn't happen)
|
|
if secret == "" {
|
|
secret = generateCallbackSecret()
|
|
}
|
|
}
|
|
// Determine if we need to use -k flag for HTTPS with self-signed certificates
|
|
// This allows curl to work with self-signed, in-house CA certificates
|
|
// For HTTP URLs, we use a empty string.
|
|
curlInsecureFlag := ""
|
|
if strings.HasPrefix(strings.ToLower(trimmed), "https://") {
|
|
curlInsecureFlag = " -k"
|
|
}
|
|
|
|
config := strings.ReplaceAll(fail2banActionTemplate, actionCallbackPlaceholder, trimmed)
|
|
config = strings.ReplaceAll(config, actionServerIDPlaceholder, serverID)
|
|
config = strings.ReplaceAll(config, actionSecretPlaceholder, secret)
|
|
config = strings.ReplaceAll(config, actionCurlInsecureFlag, curlInsecureFlag)
|
|
return config
|
|
}
|
|
|
|
// generateCallbackSecret generates a 42-character random secret using crypto/rand.
|
|
func generateCallbackSecret() string {
|
|
// Generate 32 random bytes (256 bits of entropy)
|
|
bytes := make([]byte, 32)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
// Fallback to hex encoding if crypto/rand fails (shouldn't happen)
|
|
fallbackBytes := make([]byte, 21) // 21 bytes = 42 hex chars
|
|
if _, err := rand.Read(fallbackBytes); err != nil {
|
|
// Last resort: use time-based seed (not ideal but better than nothing)
|
|
return fmt.Sprintf("%042x", time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(fallbackBytes)
|
|
}
|
|
// Use base64 URL-safe encoding, which gives us 43 chars for 32 bytes
|
|
// We need exactly 42, so we'll truncate the last character (which is padding anyway)
|
|
encoded := base64.URLEncoding.EncodeToString(bytes)
|
|
// Base64 URL encoding of 32 bytes = 43 chars, take first 42
|
|
if len(encoded) >= 42 {
|
|
return encoded[:42]
|
|
}
|
|
// If somehow shorter, pad with random hex
|
|
return encoded + hex.EncodeToString(bytes)[:42-len(encoded)]
|
|
}
|
|
|
|
func getCallbackURLLocked() string {
|
|
url := strings.TrimSpace(currentSettings.CallbackURL)
|
|
if url == "" {
|
|
port := currentSettings.Port
|
|
if port == 0 {
|
|
port = 8080
|
|
}
|
|
url = fmt.Sprintf("http://127.0.0.1:%d", port)
|
|
}
|
|
return strings.TrimRight(url, "/")
|
|
}
|
|
|
|
// GetCallbackURL returns the callback URL used by Fail2ban agents.
|
|
func GetCallbackURL() string {
|
|
settingsLock.RLock()
|
|
defer settingsLock.RUnlock()
|
|
return getCallbackURLLocked()
|
|
}
|
|
|
|
// EnsureLocalFail2banAction ensures the local Fail2ban action files exist when the local connector is enabled.
|
|
func EnsureLocalFail2banAction(server Fail2banServer) error {
|
|
if !server.Enabled {
|
|
return nil
|
|
}
|
|
settingsLock.RLock()
|
|
callbackURL := getCallbackURLLocked()
|
|
settingsLock.RUnlock()
|
|
return ensureFail2banActionFiles(callbackURL, server.ID)
|
|
}
|
|
|
|
func serverByIDLocked(id string) (Fail2banServer, bool) {
|
|
for _, srv := range currentSettings.Servers {
|
|
if srv.ID == id {
|
|
return cloneServer(srv), true
|
|
}
|
|
}
|
|
return Fail2banServer{}, false
|
|
}
|
|
|
|
// ListServers returns a copy of the configured Fail2ban servers.
|
|
func ListServers() []Fail2banServer {
|
|
settingsLock.RLock()
|
|
defer settingsLock.RUnlock()
|
|
|
|
out := make([]Fail2banServer, len(currentSettings.Servers))
|
|
for idx, srv := range currentSettings.Servers {
|
|
out[idx] = cloneServer(srv)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetServerByID returns the server matching the supplied ID.
|
|
func GetServerByID(id string) (Fail2banServer, bool) {
|
|
settingsLock.RLock()
|
|
defer settingsLock.RUnlock()
|
|
srv, ok := serverByIDLocked(id)
|
|
if !ok {
|
|
return Fail2banServer{}, false
|
|
}
|
|
return cloneServer(srv), true
|
|
}
|
|
|
|
// GetServerByHostname returns the first server matching the hostname.
|
|
func GetServerByHostname(hostname string) (Fail2banServer, bool) {
|
|
settingsLock.RLock()
|
|
defer settingsLock.RUnlock()
|
|
for _, srv := range currentSettings.Servers {
|
|
if strings.EqualFold(srv.Hostname, hostname) {
|
|
return cloneServer(srv), true
|
|
}
|
|
}
|
|
return Fail2banServer{}, false
|
|
}
|
|
|
|
// GetDefaultServer returns the default server.
|
|
func GetDefaultServer() Fail2banServer {
|
|
settingsLock.RLock()
|
|
defer settingsLock.RUnlock()
|
|
|
|
for _, srv := range currentSettings.Servers {
|
|
if srv.IsDefault && srv.Enabled {
|
|
return cloneServer(srv)
|
|
}
|
|
}
|
|
for _, srv := range currentSettings.Servers {
|
|
if srv.Enabled {
|
|
return cloneServer(srv)
|
|
}
|
|
}
|
|
return Fail2banServer{}
|
|
}
|
|
|
|
// UpsertServer adds or updates a Fail2ban server and persists the settings.
|
|
func UpsertServer(input Fail2banServer) (Fail2banServer, error) {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
now := time.Now().UTC()
|
|
input.Type = strings.ToLower(strings.TrimSpace(input.Type))
|
|
if input.ID == "" {
|
|
input.ID = generateServerID()
|
|
input.CreatedAt = now
|
|
}
|
|
if input.CreatedAt.IsZero() {
|
|
input.CreatedAt = now
|
|
}
|
|
input.UpdatedAt = now
|
|
|
|
if input.Type == "" {
|
|
input.Type = "local"
|
|
}
|
|
if !input.enabledSet {
|
|
if input.Type == "local" {
|
|
input.Enabled = false
|
|
} else {
|
|
input.Enabled = true
|
|
}
|
|
input.enabledSet = true
|
|
}
|
|
if input.Type == "local" && input.SocketPath == "" {
|
|
input.SocketPath = "/var/run/fail2ban/fail2ban.sock"
|
|
}
|
|
if input.Type == "local" && input.LogPath == "" {
|
|
input.LogPath = "/var/log/fail2ban.log"
|
|
}
|
|
if input.Name == "" {
|
|
input.Name = "Fail2ban Server " + input.ID
|
|
}
|
|
replaced := false
|
|
for idx, srv := range currentSettings.Servers {
|
|
if srv.ID == input.ID {
|
|
if !input.enabledSet {
|
|
input.Enabled = srv.Enabled
|
|
input.enabledSet = true
|
|
}
|
|
if !input.Enabled {
|
|
input.IsDefault = false
|
|
}
|
|
if input.IsDefault {
|
|
clearDefaultLocked()
|
|
}
|
|
// preserve created timestamp if incoming zero
|
|
if input.CreatedAt.IsZero() {
|
|
input.CreatedAt = srv.CreatedAt
|
|
}
|
|
currentSettings.Servers[idx] = input
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !replaced {
|
|
if input.IsDefault {
|
|
clearDefaultLocked()
|
|
}
|
|
if len(currentSettings.Servers) == 0 && input.Enabled {
|
|
input.IsDefault = true
|
|
}
|
|
currentSettings.Servers = append(currentSettings.Servers, input)
|
|
}
|
|
|
|
normalizeServersLocked()
|
|
if err := persistServersLocked(); err != nil {
|
|
return Fail2banServer{}, err
|
|
}
|
|
srv, _ := serverByIDLocked(input.ID)
|
|
return cloneServer(srv), nil
|
|
}
|
|
|
|
func clearDefaultLocked() {
|
|
for idx := range currentSettings.Servers {
|
|
currentSettings.Servers[idx].IsDefault = false
|
|
}
|
|
}
|
|
|
|
func setServerRestartFlagLocked(serverID string, value bool) bool {
|
|
for idx := range currentSettings.Servers {
|
|
if currentSettings.Servers[idx].ID == serverID {
|
|
currentSettings.Servers[idx].RestartNeeded = value
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func anyServerNeedsRestartLocked() bool {
|
|
for _, srv := range currentSettings.Servers {
|
|
if srv.RestartNeeded {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func updateGlobalRestartFlagLocked() {
|
|
currentSettings.RestartNeeded = anyServerNeedsRestartLocked()
|
|
}
|
|
|
|
func markAllServersRestartLocked() {
|
|
for idx := range currentSettings.Servers {
|
|
currentSettings.Servers[idx].RestartNeeded = true
|
|
}
|
|
}
|
|
|
|
// DeleteServer removes a server by ID.
|
|
func DeleteServer(id string) error {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if len(currentSettings.Servers) == 0 {
|
|
return fmt.Errorf("no servers configured")
|
|
}
|
|
|
|
index := -1
|
|
for i, srv := range currentSettings.Servers {
|
|
if srv.ID == id {
|
|
index = i
|
|
break
|
|
}
|
|
}
|
|
if index == -1 {
|
|
return fmt.Errorf("server %s not found", id)
|
|
}
|
|
|
|
currentSettings.Servers = append(currentSettings.Servers[:index], currentSettings.Servers[index+1:]...)
|
|
normalizeServersLocked()
|
|
return persistServersLocked()
|
|
}
|
|
|
|
// SetDefaultServer marks the specified server as default.
|
|
func SetDefaultServer(id string) (Fail2banServer, error) {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
found := false
|
|
for idx := range currentSettings.Servers {
|
|
srv := ¤tSettings.Servers[idx]
|
|
if srv.ID == id {
|
|
found = true
|
|
srv.IsDefault = true
|
|
if !srv.Enabled {
|
|
srv.Enabled = true
|
|
srv.enabledSet = true
|
|
}
|
|
srv.UpdatedAt = time.Now().UTC()
|
|
} else {
|
|
srv.IsDefault = false
|
|
}
|
|
}
|
|
if !found {
|
|
return Fail2banServer{}, fmt.Errorf("server %s not found", id)
|
|
}
|
|
|
|
normalizeServersLocked()
|
|
if err := persistServersLocked(); err != nil {
|
|
return Fail2banServer{}, err
|
|
}
|
|
srv, _ := serverByIDLocked(id)
|
|
return cloneServer(srv), nil
|
|
}
|
|
|
|
// GetSettings returns a copy of the current settings
|
|
// GetPortFromEnv returns the PORT environment variable value if set, and whether it's set
|
|
func GetPortFromEnv() (int, bool) {
|
|
portEnv := os.Getenv("PORT")
|
|
if portEnv == "" {
|
|
return 0, false
|
|
}
|
|
if port, err := strconv.Atoi(portEnv); err == nil && port > 0 && port <= 65535 {
|
|
return port, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// GetBindAddressFromEnv returns the BIND_ADDRESS environment variable value if set, and whether it's set
|
|
// If not set, returns "0.0.0.0" as the default bind address
|
|
// Validates that the address is a valid IP address format
|
|
func GetBindAddressFromEnv() (string, bool) {
|
|
bindAddrEnv := os.Getenv("BIND_ADDRESS")
|
|
if bindAddrEnv == "" {
|
|
return "0.0.0.0", false
|
|
}
|
|
// Validate that it's a valid IP address format using net.ParseIP
|
|
if ip := net.ParseIP(bindAddrEnv); ip != nil {
|
|
return bindAddrEnv, true
|
|
}
|
|
// Invalid format, return default
|
|
return "0.0.0.0", false
|
|
}
|
|
|
|
func GetSettings() AppSettings {
|
|
settingsLock.RLock()
|
|
defer settingsLock.RUnlock()
|
|
return currentSettings
|
|
}
|
|
|
|
// MarkRestartNeeded marks the specified server as requiring a restart.
|
|
func MarkRestartNeeded(serverID string) error {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if serverID == "" {
|
|
return fmt.Errorf("server id must be provided")
|
|
}
|
|
|
|
if !setServerRestartFlagLocked(serverID, true) {
|
|
return fmt.Errorf("server %s not found", serverID)
|
|
}
|
|
|
|
updateGlobalRestartFlagLocked()
|
|
if err := persistServersLocked(); err != nil {
|
|
return err
|
|
}
|
|
return persistAppSettingsLocked()
|
|
}
|
|
|
|
// MarkRestartDone marks the specified server as no longer requiring a restart.
|
|
func MarkRestartDone(serverID string) error {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if serverID == "" {
|
|
return fmt.Errorf("server id must be provided")
|
|
}
|
|
|
|
if !setServerRestartFlagLocked(serverID, false) {
|
|
return fmt.Errorf("server %s not found", serverID)
|
|
}
|
|
|
|
updateGlobalRestartFlagLocked()
|
|
if err := persistServersLocked(); err != nil {
|
|
return err
|
|
}
|
|
return persistAppSettingsLocked()
|
|
}
|
|
|
|
// UpdateSettings merges new settings with old and sets restartNeeded if needed
|
|
func UpdateSettings(new AppSettings) (AppSettings, error) {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
DebugLog("--- Locked settings for update ---") // Log lock acquisition
|
|
|
|
old := currentSettings
|
|
|
|
// If certain fields change, we mark reload needed
|
|
// Compare IgnoreIPs arrays
|
|
ignoreIPsChanged := false
|
|
if len(old.IgnoreIPs) != len(new.IgnoreIPs) {
|
|
ignoreIPsChanged = true
|
|
} else {
|
|
for i := range old.IgnoreIPs {
|
|
if old.IgnoreIPs[i] != new.IgnoreIPs[i] {
|
|
ignoreIPsChanged = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
restartTriggered := old.BantimeIncrement != new.BantimeIncrement ||
|
|
old.DefaultJailEnable != new.DefaultJailEnable ||
|
|
ignoreIPsChanged ||
|
|
old.Bantime != new.Bantime ||
|
|
old.Findtime != new.Findtime ||
|
|
old.Maxretry != new.Maxretry
|
|
if restartTriggered {
|
|
new.RestartNeeded = true
|
|
} else {
|
|
new.RestartNeeded = anyServerNeedsRestartLocked()
|
|
}
|
|
|
|
new.CallbackURL = strings.TrimSpace(new.CallbackURL)
|
|
|
|
// Auto-update callback URL if port changed and callback URL is still using default localhost pattern
|
|
oldPort := currentSettings.Port
|
|
if new.Port != oldPort && new.Port > 0 {
|
|
// Check if callback URL matches the default localhost pattern
|
|
oldPattern := regexp.MustCompile(`^http://127\.0\.0\.1:\d+$`)
|
|
if oldPattern.MatchString(new.CallbackURL) || new.CallbackURL == "" {
|
|
// Update to match new port
|
|
new.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d", new.Port)
|
|
}
|
|
}
|
|
|
|
if len(new.Servers) == 0 && len(currentSettings.Servers) > 0 {
|
|
new.Servers = make([]Fail2banServer, len(currentSettings.Servers))
|
|
for i, srv := range currentSettings.Servers {
|
|
new.Servers[i] = cloneServer(srv)
|
|
}
|
|
}
|
|
currentSettings = new
|
|
setDefaultsLocked()
|
|
if currentSettings.RestartNeeded && restartTriggered {
|
|
markAllServersRestartLocked()
|
|
updateGlobalRestartFlagLocked()
|
|
}
|
|
DebugLog("New settings applied: %v", currentSettings) // Log settings applied
|
|
|
|
if err := persistAllLocked(); err != nil {
|
|
fmt.Println("Error saving settings:", err)
|
|
return currentSettings, err
|
|
}
|
|
return currentSettings, nil
|
|
}
|