mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-19 06:53:14 +02:00
Restructure an adding basic sections 2/2
This commit is contained in:
@@ -16,14 +16,22 @@ import (
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
// AgentConnector connects to a remote fail2ban-agent via HTTP API.
|
||||
// =========================================================================
|
||||
// Types
|
||||
// =========================================================================
|
||||
|
||||
// Connector for a remote Fail2ban-Agent via HTTP API.
|
||||
type AgentConnector struct {
|
||||
server config.Fail2banServer
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAgentConnector constructs a new AgentConnector.
|
||||
// =========================================================================
|
||||
// Constructor
|
||||
// =========================================================================
|
||||
|
||||
// Create a new AgentConnector for the given server config.
|
||||
func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
|
||||
if server.AgentURL == "" {
|
||||
return nil, fmt.Errorf("agentUrl is required for agent connector")
|
||||
@@ -52,6 +60,10 @@ func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Connector Functions
|
||||
// =========================================================================
|
||||
|
||||
func (ac *AgentConnector) ID() string {
|
||||
return ac.server.ID
|
||||
}
|
||||
@@ -114,8 +126,6 @@ func (ac *AgentConnector) Restart(ctx context.Context) error {
|
||||
return ac.post(ctx, "/v1/actions/restart", nil, nil)
|
||||
}
|
||||
|
||||
// RestartWithMode restarts the remote agent-managed Fail2ban service and
|
||||
// always reports mode "restart". Any error is propagated to the caller.
|
||||
func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
|
||||
if err := ac.Restart(ctx); err != nil {
|
||||
return "restart", err
|
||||
@@ -123,6 +133,10 @@ func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
|
||||
return "restart", nil
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Filter Operations
|
||||
// =========================================================================
|
||||
|
||||
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
|
||||
var resp struct {
|
||||
Config string `json:"config"`
|
||||
@@ -131,10 +145,8 @@ func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (str
|
||||
if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// If agent doesn't return filePath, construct it (agent should handle .local priority)
|
||||
filePath := resp.FilePath
|
||||
if filePath == "" {
|
||||
// Default to .local path (agent should handle .local priority on its side)
|
||||
filePath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail)
|
||||
}
|
||||
return resp.Config, filePath, nil
|
||||
@@ -184,6 +196,10 @@ func (ac *AgentConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HTTP Helpers
|
||||
// =========================================================================
|
||||
|
||||
func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error {
|
||||
req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
@@ -280,7 +296,10 @@ func (ac *AgentConnector) do(req *http.Request, out any) error {
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
// GetAllJails implements Connector.
|
||||
// =========================================================================
|
||||
// Jail Operations
|
||||
// =========================================================================
|
||||
|
||||
func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
|
||||
var resp struct {
|
||||
Jails []JailInfo `json:"jails"`
|
||||
@@ -291,12 +310,10 @@ func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
|
||||
return resp.Jails, nil
|
||||
}
|
||||
|
||||
// UpdateJailEnabledStates implements Connector.
|
||||
func (ac *AgentConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
|
||||
return ac.post(ctx, "/v1/jails/update-enabled", updates, nil)
|
||||
}
|
||||
|
||||
// GetFilters implements Connector.
|
||||
func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
|
||||
var resp struct {
|
||||
Filters []string `json:"filters"`
|
||||
@@ -307,7 +324,6 @@ func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
|
||||
return resp.Filters, nil
|
||||
}
|
||||
|
||||
// TestFilter implements Connector.
|
||||
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||
payload := map[string]any{
|
||||
"filterName": filterName,
|
||||
@@ -323,16 +339,13 @@ func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, log
|
||||
if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// If agent doesn't return filterPath, construct it (agent should handle .local priority)
|
||||
filterPath := resp.FilterPath
|
||||
if filterPath == "" {
|
||||
// Default to .conf path (agent should handle .local priority on its side)
|
||||
filterPath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName)
|
||||
}
|
||||
return resp.Output, filterPath, nil
|
||||
}
|
||||
|
||||
// GetJailConfig implements Connector.
|
||||
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
|
||||
var resp struct {
|
||||
Config string `json:"config"`
|
||||
@@ -341,35 +354,33 @@ func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (strin
|
||||
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
// If agent doesn't return filePath, construct it (agent should handle .local priority)
|
||||
filePath := resp.FilePath
|
||||
if filePath == "" {
|
||||
// Default to .local path (agent should handle .local priority on its side)
|
||||
filePath = fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail)
|
||||
}
|
||||
return resp.Config, filePath, nil
|
||||
}
|
||||
|
||||
// SetJailConfig implements Connector.
|
||||
func (ac *AgentConnector) SetJailConfig(ctx context.Context, jail, content string) error {
|
||||
payload := map[string]string{"config": content}
|
||||
return ac.put(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), payload, nil)
|
||||
}
|
||||
|
||||
// TestLogpath implements Connector.
|
||||
// =========================================================================
|
||||
// Logpath Operations
|
||||
// =========================================================================
|
||||
|
||||
func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
|
||||
payload := map[string]string{"logpath": logpath}
|
||||
var resp struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
if err := ac.post(ctx, "/v1/jails/test-logpath", payload, &resp); err != nil {
|
||||
return []string{}, nil // Return empty on error
|
||||
return []string{}, nil
|
||||
}
|
||||
return resp.Files, nil
|
||||
}
|
||||
|
||||
// TestLogpathWithResolution implements Connector.
|
||||
// Agent server should handle variable resolution.
|
||||
func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
||||
originalPath = strings.TrimSpace(logpath)
|
||||
if originalPath == "" {
|
||||
@@ -386,7 +397,7 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath
|
||||
|
||||
// Try new endpoint first, fallback to old endpoint
|
||||
if err := ac.post(ctx, "/v1/jails/test-logpath-with-resolution", payload, &resp); err != nil {
|
||||
// Fallback: use old endpoint and assume no resolution
|
||||
// Fallback; use old endpoint if new endpoint fails and assume no resolution
|
||||
files, err2 := ac.TestLogpath(ctx, originalPath)
|
||||
if err2 != nil {
|
||||
return originalPath, "", nil, fmt.Errorf("failed to test logpath: %w", err2)
|
||||
@@ -408,14 +419,14 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath
|
||||
return resp.OriginalLogpath, resp.ResolvedLogpath, resp.Files, nil
|
||||
}
|
||||
|
||||
// UpdateDefaultSettings implements Connector.
|
||||
// =========================================================================
|
||||
// Settings and Structure
|
||||
// =========================================================================
|
||||
|
||||
func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
||||
// Since the managed jail.local is fully owned by Fail2ban-UI, a complete
|
||||
// rewrite from current settings is always correct and self-healing.
|
||||
return ac.EnsureJailLocalStructure(ctx)
|
||||
}
|
||||
|
||||
// CheckJailLocalIntegrity implements Connector.
|
||||
func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
|
||||
var result struct {
|
||||
Exists bool `json:"exists"`
|
||||
@@ -431,10 +442,8 @@ func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bo
|
||||
return result.Exists, result.HasUIAction, nil
|
||||
}
|
||||
|
||||
// EnsureJailLocalStructure implements Connector.
|
||||
func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
||||
// Safety: if jail.local exists but is not managed by Fail2ban-UI,
|
||||
// it belongs to the user; never overwrite it.
|
||||
// If jail.local exists but is not managed by Fail2ban-UI, it belongs to the user, we do not overwrite it.
|
||||
if exists, hasUI, err := ac.CheckJailLocalIntegrity(ctx); err == nil && exists && !hasUI {
|
||||
config.DebugLog("jail.local on agent server %s exists but is not managed by Fail2ban-UI -- skipping overwrite", ac.server.Name)
|
||||
return nil
|
||||
@@ -443,7 +452,10 @@ func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
||||
return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil)
|
||||
}
|
||||
|
||||
// CreateJail implements Connector.
|
||||
// =========================================================================
|
||||
// Filter and Jail Management
|
||||
// =========================================================================
|
||||
|
||||
func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content string) error {
|
||||
payload := map[string]interface{}{
|
||||
"name": jailName,
|
||||
@@ -452,12 +464,10 @@ func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content stri
|
||||
return ac.post(ctx, "/v1/jails", payload, nil)
|
||||
}
|
||||
|
||||
// DeleteJail implements Connector.
|
||||
func (ac *AgentConnector) DeleteJail(ctx context.Context, jailName string) error {
|
||||
return ac.delete(ctx, fmt.Sprintf("/v1/jails/%s", jailName), nil)
|
||||
}
|
||||
|
||||
// CreateFilter implements Connector.
|
||||
func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content string) error {
|
||||
payload := map[string]interface{}{
|
||||
"name": filterName,
|
||||
@@ -466,7 +476,6 @@ func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content
|
||||
return ac.post(ctx, "/v1/filters", payload, nil)
|
||||
}
|
||||
|
||||
// DeleteFilter implements Connector.
|
||||
func (ac *AgentConnector) DeleteFilter(ctx context.Context, filterName string) error {
|
||||
return ac.delete(ctx, fmt.Sprintf("/v1/filters/%s", filterName), nil)
|
||||
}
|
||||
|
||||
@@ -14,46 +14,43 @@ import (
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
// LocalConnector interacts with a local fail2ban instance via fail2ban-client CLI.
|
||||
// Connector for a local Fail2ban instance via fail2ban-client CLI.
|
||||
type LocalConnector struct {
|
||||
server config.Fail2banServer
|
||||
}
|
||||
|
||||
// NewLocalConnector creates a new LocalConnector instance.
|
||||
// =========================================================================
|
||||
// Constructor
|
||||
// =========================================================================
|
||||
|
||||
// Create a new LocalConnector for the given server config.
|
||||
func NewLocalConnector(server config.Fail2banServer) *LocalConnector {
|
||||
return &LocalConnector{server: server}
|
||||
}
|
||||
|
||||
// ID implements Connector.
|
||||
func (lc *LocalConnector) ID() string {
|
||||
return lc.server.ID
|
||||
}
|
||||
|
||||
// Server implements Connector.
|
||||
func (lc *LocalConnector) Server() config.Fail2banServer {
|
||||
return lc.server
|
||||
}
|
||||
|
||||
// GetJailInfos implements Connector.
|
||||
// Get jail information.
|
||||
func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
||||
jails, err := lc.getJails(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logPath := lc.server.LogPath
|
||||
logPath := lc.server.LogPath // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS.
|
||||
if logPath == "" {
|
||||
logPath = "/var/log/fail2ban.log"
|
||||
}
|
||||
|
||||
banHistory, err := ParseBanLog(logPath)
|
||||
banHistory, err := ParseBanLog(logPath) // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS.
|
||||
if err != nil {
|
||||
banHistory = make(map[string][]BanEvent)
|
||||
}
|
||||
|
||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
// Use parallel execution for better performance
|
||||
type jailResult struct {
|
||||
jail JailInfo
|
||||
err error
|
||||
@@ -89,12 +86,10 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
|
||||
}
|
||||
}(jail)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
var finalResults []JailInfo
|
||||
for result := range results {
|
||||
if result.err != nil {
|
||||
@@ -102,14 +97,13 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
|
||||
}
|
||||
finalResults = append(finalResults, result.jail)
|
||||
}
|
||||
|
||||
sort.SliceStable(finalResults, func(i, j int) bool {
|
||||
return finalResults[i].JailName < finalResults[j].JailName
|
||||
})
|
||||
return finalResults, nil
|
||||
}
|
||||
|
||||
// GetBannedIPs implements Connector.
|
||||
// Get banned IPs for a given jail.
|
||||
func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
||||
args := []string{"status", jail}
|
||||
out, err := lc.runFail2banClient(ctx, args...)
|
||||
@@ -120,7 +114,6 @@ func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]stri
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "IP list:") {
|
||||
// Use SplitN to only split on the first colon, preserving IPv6 addresses
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) > 1 {
|
||||
ips := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
@@ -132,7 +125,7 @@ func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]stri
|
||||
return bannedIPs, nil
|
||||
}
|
||||
|
||||
// UnbanIP implements Connector.
|
||||
// Unban an IP from a given jail.
|
||||
func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
||||
args := []string{"set", jail, "unbanip", ip}
|
||||
if _, err := lc.runFail2banClient(ctx, args...); err != nil {
|
||||
@@ -141,7 +134,7 @@ func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BanIP implements Connector.
|
||||
// Ban an IP in a given jail.
|
||||
func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error {
|
||||
args := []string{"set", jail, "banip", ip}
|
||||
if _, err := lc.runFail2banClient(ctx, args...); err != nil {
|
||||
@@ -150,35 +143,25 @@ func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload implements Connector.
|
||||
// Reload the Fail2ban service.
|
||||
func (lc *LocalConnector) Reload(ctx context.Context) error {
|
||||
out, err := lc.runFail2banClient(ctx, "reload")
|
||||
if err != nil {
|
||||
// Include the output in the error message for better debugging
|
||||
return fmt.Errorf("fail2ban reload error: %w (output: %s)", err, strings.TrimSpace(out))
|
||||
}
|
||||
|
||||
// Check if output indicates success (fail2ban-client returns "OK" on success)
|
||||
// Check if fail2ban-client returns "OK"
|
||||
outputTrimmed := strings.TrimSpace(out)
|
||||
if outputTrimmed != "OK" && outputTrimmed != "" {
|
||||
config.DebugLog("fail2ban reload output: %s", out)
|
||||
|
||||
// Check for jail errors in output even when command succeeds
|
||||
// Look for patterns like "Errors in jail 'jailname'. Skipping..."
|
||||
if strings.Contains(out, "Errors in jail") || strings.Contains(out, "Unable to read the filter") {
|
||||
// Return an error that includes the output so handler can parse it
|
||||
return fmt.Errorf("fail2ban reload completed but with errors (output: %s)", strings.TrimSpace(out))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestartWithMode restarts (or reloads) the local Fail2ban instance and returns
|
||||
// a mode string describing what happened:
|
||||
// - "restart": systemd service was restarted and health check passed
|
||||
// - "reload": configuration was reloaded via fail2ban-client and pong check passed
|
||||
// Restart or reload the local Fail2ban instance; returns "restart" or "reload".
|
||||
func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
|
||||
// 1) Try systemd restart if systemctl is available.
|
||||
if _, err := exec.LookPath("systemctl"); err == nil {
|
||||
cmd := "systemctl restart fail2ban"
|
||||
out, err := executeShellCommand(ctx, cmd)
|
||||
@@ -191,9 +174,6 @@ func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
|
||||
}
|
||||
return "restart", nil
|
||||
}
|
||||
|
||||
// 2) Fallback: no systemctl in PATH (container image without systemd, or
|
||||
// non-systemd environment). Use fail2ban-client reload + ping.
|
||||
if err := lc.Reload(ctx); err != nil {
|
||||
return "reload", fmt.Errorf("failed to reload fail2ban via fail2ban-client (systemctl not available): %w", err)
|
||||
}
|
||||
@@ -203,23 +183,20 @@ func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
|
||||
return "reload", nil
|
||||
}
|
||||
|
||||
// Restart implements Connector.
|
||||
func (lc *LocalConnector) Restart(ctx context.Context) error {
|
||||
_, err := lc.RestartWithMode(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetFilterConfig implements Connector.
|
||||
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
|
||||
return GetFilterConfigLocal(jail)
|
||||
}
|
||||
|
||||
// SetFilterConfig implements Connector.
|
||||
func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
||||
return SetFilterConfigLocal(jail, content)
|
||||
}
|
||||
|
||||
// FetchBanEvents implements Connector.
|
||||
// REMOVE THIS FUNCTION
|
||||
func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
|
||||
logPath := lc.server.LogPath
|
||||
if logPath == "" {
|
||||
@@ -242,17 +219,16 @@ func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// Get all jails.
|
||||
func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
|
||||
out, err := lc.runFail2banClient(ctx, "status")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err)
|
||||
}
|
||||
|
||||
var jails []string
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Jail list:") {
|
||||
// Use SplitN to only split on the first colon
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) > 1 {
|
||||
raw := strings.TrimSpace(parts[1])
|
||||
@@ -266,6 +242,10 @@ func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
|
||||
return jails, nil
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// CLI Helpers
|
||||
// =========================================================================
|
||||
|
||||
func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) {
|
||||
cmdArgs := lc.buildFail2banArgs(args...)
|
||||
cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...)
|
||||
@@ -281,99 +261,84 @@ func (lc *LocalConnector) buildFail2banArgs(args ...string) []string {
|
||||
return append(base, args...)
|
||||
}
|
||||
|
||||
// checkFail2banHealthy runs a quick `fail2ban-client ping` via the existing
|
||||
// runFail2banClient helper and expects a successful pong reply.
|
||||
func (lc *LocalConnector) checkFail2banHealthy(ctx context.Context) error {
|
||||
out, err := lc.runFail2banClient(ctx, "ping")
|
||||
trimmed := strings.TrimSpace(out)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fail2ban ping error: %w (output: %s)", err, trimmed)
|
||||
}
|
||||
// Typical output is e.g. "Server replied: pong" – accept anything that
|
||||
// contains "pong" case-insensitively.
|
||||
if !strings.Contains(strings.ToLower(trimmed), "pong") {
|
||||
return fmt.Errorf("unexpected fail2ban ping output: %s", trimmed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllJails implements Connector.
|
||||
// =========================================================================
|
||||
// Delegated Operations
|
||||
// =========================================================================
|
||||
|
||||
func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
|
||||
return GetAllJails()
|
||||
}
|
||||
|
||||
// UpdateJailEnabledStates implements Connector.
|
||||
func (lc *LocalConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
|
||||
return UpdateJailEnabledStates(updates)
|
||||
}
|
||||
|
||||
// GetFilters implements Connector.
|
||||
func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) {
|
||||
return GetFiltersLocal()
|
||||
}
|
||||
|
||||
// TestFilter implements Connector.
|
||||
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||
return TestFilterLocal(filterName, logLines, filterContent)
|
||||
}
|
||||
|
||||
// GetJailConfig implements Connector.
|
||||
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
|
||||
return GetJailConfig(jail)
|
||||
}
|
||||
|
||||
// SetJailConfig implements Connector.
|
||||
func (lc *LocalConnector) SetJailConfig(ctx context.Context, jail, content string) error {
|
||||
return SetJailConfig(jail, content)
|
||||
}
|
||||
|
||||
// TestLogpath implements Connector.
|
||||
func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
|
||||
return TestLogpath(logpath)
|
||||
}
|
||||
|
||||
// TestLogpathWithResolution implements Connector.
|
||||
func (lc *LocalConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
||||
return TestLogpathWithResolution(logpath)
|
||||
}
|
||||
|
||||
// UpdateDefaultSettings implements Connector.
|
||||
func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
||||
return UpdateDefaultSettingsLocal(settings)
|
||||
}
|
||||
|
||||
// EnsureJailLocalStructure implements Connector.
|
||||
func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
||||
return config.EnsureJailLocalStructure()
|
||||
}
|
||||
|
||||
// CreateJail implements Connector.
|
||||
func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error {
|
||||
return CreateJail(jailName, content)
|
||||
}
|
||||
|
||||
// DeleteJail implements Connector.
|
||||
func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error {
|
||||
return DeleteJail(jailName)
|
||||
}
|
||||
|
||||
// CreateFilter implements Connector.
|
||||
func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error {
|
||||
return CreateFilter(filterName, content)
|
||||
}
|
||||
|
||||
// DeleteFilter implements Connector.
|
||||
func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error {
|
||||
return DeleteFilter(filterName)
|
||||
}
|
||||
|
||||
// CheckJailLocalIntegrity implements Connector.
|
||||
func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
|
||||
const jailLocalPath = "/etc/fail2ban/jail.local"
|
||||
content, err := os.ReadFile(jailLocalPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, false, nil // file does not exist; OK, will be created
|
||||
return false, false, nil
|
||||
}
|
||||
return false, false, fmt.Errorf("failed to read jail.local: %w", err)
|
||||
}
|
||||
@@ -381,6 +346,10 @@ func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bo
|
||||
return true, hasUIAction, nil
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shell Execution
|
||||
// =========================================================================
|
||||
|
||||
func executeShellCommand(ctx context.Context, command string) (string, error) {
|
||||
parts := strings.Fields(command)
|
||||
if len(parts) == 0 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
||||
//
|
||||
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
|
||||
// Copyright (C) 2026 Swissmakers GmbH (https://swissmakers.ch)
|
||||
//
|
||||
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
||||
// You may not use this file except in compliance with the License.
|
||||
@@ -29,8 +29,7 @@ import (
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
// GetFilterConfig returns the filter configuration using the default connector.
|
||||
// Returns (config, filePath, error)
|
||||
// Returns the filter configuration using the default connector.
|
||||
func GetFilterConfig(jail string) (string, string, error) {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
@@ -39,7 +38,7 @@ func GetFilterConfig(jail string) (string, string, error) {
|
||||
return conn.GetFilterConfig(context.Background(), jail)
|
||||
}
|
||||
|
||||
// SetFilterConfig writes the filter configuration using the default connector.
|
||||
// Writes the filter configuration using the default connector.
|
||||
func SetFilterConfig(jail, newContent string) error {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
@@ -48,10 +47,7 @@ func SetFilterConfig(jail, newContent string) error {
|
||||
return conn.SetFilterConfig(context.Background(), jail, newContent)
|
||||
}
|
||||
|
||||
// ensureFilterLocalFile ensures that a .local file exists for the given filter.
|
||||
// If .local doesn't exist, it copies from .conf if available, or creates an empty file.
|
||||
func ensureFilterLocalFile(filterName string) error {
|
||||
// Validate filter name - must not be empty
|
||||
filterName = strings.TrimSpace(filterName)
|
||||
if filterName == "" {
|
||||
return fmt.Errorf("filter name cannot be empty")
|
||||
@@ -61,13 +57,11 @@ func ensureFilterLocalFile(filterName string) error {
|
||||
localPath := filepath.Join(filterDPath, filterName+".local")
|
||||
confPath := filepath.Join(filterDPath, filterName+".conf")
|
||||
|
||||
// Check if .local already exists
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
config.DebugLog("Filter .local file already exists: %s", localPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to copy from .conf if it exists
|
||||
if _, err := os.Stat(confPath); err == nil {
|
||||
config.DebugLog("Copying filter config from .conf to .local: %s -> %s", confPath, localPath)
|
||||
content, err := os.ReadFile(confPath)
|
||||
@@ -81,7 +75,6 @@ func ensureFilterLocalFile(filterName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Neither exists, create empty .local file
|
||||
config.DebugLog("Neither .local nor .conf exists for filter %s, creating empty .local file", filterName)
|
||||
if err := os.WriteFile(localPath, []byte(""), 0644); err != nil {
|
||||
return fmt.Errorf("failed to create empty filter .local file %s: %w", localPath, err)
|
||||
@@ -90,26 +83,20 @@ func ensureFilterLocalFile(filterName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveComments removes all lines that start with # (comments) from filter content
|
||||
// and trims leading/trailing empty newlines
|
||||
// This is exported for use in handlers that need to display filter content without comments
|
||||
func RemoveComments(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
// Skip lines that start with # (comments)
|
||||
if !strings.HasPrefix(trimmed, "#") {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading empty lines
|
||||
for len(result) > 0 && strings.TrimSpace(result[0]) == "" {
|
||||
result = result[1:]
|
||||
}
|
||||
|
||||
// Remove trailing empty lines
|
||||
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
|
||||
result = result[:len(result)-1]
|
||||
}
|
||||
@@ -117,10 +104,8 @@ func RemoveComments(content string) string {
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf.
|
||||
// Returns (content, filePath, error)
|
||||
// Reads filter config from .local first, then falls back to .conf.
|
||||
func readFilterConfigWithFallback(filterName string) (string, string, error) {
|
||||
// Validate filter name - must not be empty
|
||||
filterName = strings.TrimSpace(filterName)
|
||||
if filterName == "" {
|
||||
return "", "", fmt.Errorf("filter name cannot be empty")
|
||||
@@ -130,37 +115,26 @@ func readFilterConfigWithFallback(filterName string) (string, string, error) {
|
||||
localPath := filepath.Join(filterDPath, filterName+".local")
|
||||
confPath := filepath.Join(filterDPath, filterName+".conf")
|
||||
|
||||
// Try .local first
|
||||
if content, err := os.ReadFile(localPath); err == nil {
|
||||
config.DebugLog("Reading filter config from .local: %s", localPath)
|
||||
return string(content), localPath, nil
|
||||
}
|
||||
|
||||
// Fallback to .conf
|
||||
if content, err := os.ReadFile(confPath); err == nil {
|
||||
config.DebugLog("Reading filter config from .conf: %s", confPath)
|
||||
return string(content), confPath, nil
|
||||
}
|
||||
|
||||
// Neither exists, return error with .local path (will be created on save)
|
||||
return "", localPath, fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath)
|
||||
}
|
||||
|
||||
// GetFilterConfigLocal reads a filter configuration from the local filesystem.
|
||||
// Prefers .local over .conf files.
|
||||
// Returns (content, filePath, error)
|
||||
func GetFilterConfigLocal(jail string) (string, string, error) {
|
||||
return readFilterConfigWithFallback(jail)
|
||||
}
|
||||
|
||||
// SetFilterConfigLocal writes the filter configuration to the local filesystem.
|
||||
// Always writes to .local file, ensuring it exists first by copying from .conf if needed.
|
||||
func SetFilterConfigLocal(jail, newContent string) error {
|
||||
// Ensure .local file exists (copy from .conf if needed)
|
||||
if err := ensureFilterLocalFile(jail); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
localPath := filepath.Join("/etc/fail2ban/filter.d", jail+".local")
|
||||
if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write filter .local file for %s: %w", jail, err)
|
||||
@@ -169,15 +143,13 @@ func SetFilterConfigLocal(jail, newContent string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateFilterName validates a filter name format.
|
||||
// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved).
|
||||
// Validates a filter name format.
|
||||
func ValidateFilterName(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("filter name cannot be empty")
|
||||
}
|
||||
|
||||
// Check for invalid characters (only alphanumeric, dash, underscore allowed)
|
||||
invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||
if invalidChars.MatchString(name) {
|
||||
return fmt.Errorf("filter name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name)
|
||||
@@ -186,8 +158,7 @@ func ValidateFilterName(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListFilterFiles lists all filter files in the specified directory.
|
||||
// Returns full paths to .local and .conf files.
|
||||
// Lists all filter files in the specified directory.
|
||||
func ListFilterFiles(directory string) ([]string, error) {
|
||||
var files []string
|
||||
|
||||
@@ -200,14 +171,10 @@ func ListFilterFiles(directory string) ([]string, error) {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
// Skip hidden files and invalid names
|
||||
if strings.HasPrefix(name, ".") {
|
||||
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)
|
||||
@@ -217,28 +184,22 @@ func ListFilterFiles(directory string) ([]string, error) {
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// DiscoverFiltersFromFiles discovers all filters from the filesystem.
|
||||
// Reads from /etc/fail2ban/filter.d/ directory, preferring .local files over .conf files.
|
||||
// Returns unique filter names.
|
||||
// Returns all filters from the filesystem.
|
||||
func DiscoverFiltersFromFiles() ([]string, error) {
|
||||
filterDPath := "/etc/fail2ban/filter.d"
|
||||
|
||||
// Check if directory exists
|
||||
if _, err := os.Stat(filterDPath); os.IsNotExist(err) {
|
||||
// Directory doesn't exist, return empty list
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
// List all filter files
|
||||
files, err := ListFilterFiles(filterDPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filterMap := make(map[string]bool) // Track unique filter names
|
||||
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
|
||||
filterMap := make(map[string]bool)
|
||||
processedFiles := make(map[string]bool)
|
||||
|
||||
// First pass: collect all .local files (these take precedence)
|
||||
for _, filePath := range files {
|
||||
if !strings.HasSuffix(filePath, ".local") {
|
||||
continue
|
||||
@@ -250,7 +211,6 @@ func DiscoverFiltersFromFiles() ([]string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we've already processed this base name
|
||||
if processedFiles[baseName] {
|
||||
continue
|
||||
}
|
||||
@@ -259,28 +219,22 @@ func DiscoverFiltersFromFiles() ([]string, error) {
|
||||
filterMap[baseName] = true
|
||||
}
|
||||
|
||||
// Second pass: collect .conf files that don't have corresponding .local files
|
||||
for _, filePath := range files {
|
||||
if !strings.HasSuffix(filePath, ".conf") {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := filepath.Base(filePath)
|
||||
baseName := strings.TrimSuffix(filename, ".conf")
|
||||
if baseName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if we've already processed a .local file with the same base name
|
||||
if processedFiles[baseName] {
|
||||
continue
|
||||
}
|
||||
|
||||
processedFiles[baseName] = true
|
||||
filterMap[baseName] = true
|
||||
}
|
||||
|
||||
// Convert map to sorted slice
|
||||
var filters []string
|
||||
for name := range filterMap {
|
||||
filters = append(filters, name)
|
||||
@@ -290,32 +244,24 @@ func DiscoverFiltersFromFiles() ([]string, error) {
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
// CreateFilter creates a new filter in filter.d/{name}.local.
|
||||
// If the filter already exists, it will be overwritten.
|
||||
// Creates a new filter.
|
||||
func CreateFilter(filterName, content string) error {
|
||||
if err := ValidateFilterName(filterName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filterDPath := "/etc/fail2ban/filter.d"
|
||||
localPath := filepath.Join(filterDPath, filterName+".local")
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(filterDPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create filter.d directory: %w", err)
|
||||
}
|
||||
|
||||
// Write the file
|
||||
if err := os.WriteFile(localPath, []byte(content), 0644); err != nil {
|
||||
return fmt.Errorf("failed to create filter file %s: %w", localPath, err)
|
||||
}
|
||||
|
||||
config.DebugLog("Created filter file: %s", localPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFilter deletes a filter's .local and .conf files from filter.d/ if they exist.
|
||||
// Both files are deleted to ensure complete removal of the filter configuration.
|
||||
// Deletes a filter's .local and .conf files from filter.d/ if they exist.
|
||||
func DeleteFilter(filterName string) error {
|
||||
if err := ValidateFilterName(filterName); err != nil {
|
||||
return err
|
||||
@@ -328,7 +274,6 @@ func DeleteFilter(filterName string) error {
|
||||
var deletedFiles []string
|
||||
var lastErr error
|
||||
|
||||
// Delete .local file if it exists
|
||||
if _, err := os.Stat(localPath); err == nil {
|
||||
if err := os.Remove(localPath); err != nil {
|
||||
lastErr = fmt.Errorf("failed to delete filter file %s: %w", localPath, err)
|
||||
@@ -337,8 +282,6 @@ func DeleteFilter(filterName string) error {
|
||||
config.DebugLog("Deleted filter file: %s", localPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete .conf file if it exists
|
||||
if _, err := os.Stat(confPath); err == nil {
|
||||
if err := os.Remove(confPath); err != nil {
|
||||
lastErr = fmt.Errorf("failed to delete filter file %s: %w", confPath, err)
|
||||
@@ -347,23 +290,15 @@ func DeleteFilter(filterName string) error {
|
||||
config.DebugLog("Deleted filter file: %s", confPath)
|
||||
}
|
||||
}
|
||||
|
||||
// If no files were deleted and no error occurred, it means neither file existed
|
||||
if len(deletedFiles) == 0 && lastErr == nil {
|
||||
return fmt.Errorf("filter file %s or %s does not exist", localPath, confPath)
|
||||
}
|
||||
|
||||
// Return the last error if any occurred
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFiltersLocal returns a list of filter names from /etc/fail2ban/filter.d
|
||||
// Returns unique filter names from both .conf and .local files (prefers .local if both exist)
|
||||
// This is the canonical implementation - now uses DiscoverFiltersFromFiles()
|
||||
func GetFiltersLocal() ([]string, error) {
|
||||
return DiscoverFiltersFromFiles()
|
||||
}
|
||||
@@ -380,7 +315,7 @@ func normalizeLogLines(logLines []string) []string {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// extractVariablesFromContent extracts variable names from [DEFAULT] section of filter content
|
||||
// Extracts variable names from [DEFAULT] section of filter content.
|
||||
func extractVariablesFromContent(content string) map[string]bool {
|
||||
variables := make(map[string]bool)
|
||||
lines := strings.Split(content, "\n")
|
||||
@@ -388,20 +323,15 @@ func extractVariablesFromContent(content string) map[string]bool {
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check for [DEFAULT] section
|
||||
if strings.HasPrefix(trimmed, "[DEFAULT]") {
|
||||
inDefaultSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for end of [DEFAULT] section (next section starts)
|
||||
if inDefaultSection && strings.HasPrefix(trimmed, "[") {
|
||||
inDefaultSection = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract variable name from [DEFAULT] section
|
||||
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
@@ -412,11 +342,9 @@ func extractVariablesFromContent(content string) map[string]bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variables
|
||||
}
|
||||
|
||||
// removeDuplicateVariables removes variable definitions from included content that already exist in main filter
|
||||
func removeDuplicateVariables(includedContent string, mainVariables map[string]bool) string {
|
||||
lines := strings.Split(includedContent, "\n")
|
||||
var result strings.Builder
|
||||
@@ -427,7 +355,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
|
||||
trimmed := strings.TrimSpace(line)
|
||||
originalLine := line
|
||||
|
||||
// Check for [DEFAULT] section
|
||||
if strings.HasPrefix(trimmed, "[DEFAULT]") {
|
||||
inDefaultSection = true
|
||||
result.WriteString(originalLine)
|
||||
@@ -443,13 +370,11 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
|
||||
continue
|
||||
}
|
||||
|
||||
// In [DEFAULT] section, check if variable already exists in main filter
|
||||
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
varName := strings.TrimSpace(parts[0])
|
||||
if mainVariables[varName] {
|
||||
// Skip this line - variable will be defined in main filter (takes precedence)
|
||||
removedCount++
|
||||
config.DebugLog("Removing variable '%s' from included file (will be overridden by main filter)", varName)
|
||||
continue
|
||||
@@ -468,11 +393,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// resolveFilterIncludes parses the filter content to find [INCLUDES] section
|
||||
// and loads the included files, combining them with the main filter content.
|
||||
// Returns: combined content with before files + main filter + after files
|
||||
// Duplicate variables in main filter are removed if they exist in included files
|
||||
// currentFilterName: name of the current filter being tested (to avoid self-inclusion)
|
||||
func resolveFilterIncludes(filterContent string, filterDPath string, currentFilterName string) (string, error) {
|
||||
lines := strings.Split(filterContent, "\n")
|
||||
var beforeFiles []string
|
||||
@@ -484,18 +404,15 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Check for [INCLUDES] section
|
||||
if strings.HasPrefix(trimmed, "[INCLUDES]") {
|
||||
inIncludesSection = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for end of [INCLUDES] section (next section starts)
|
||||
if inIncludesSection && strings.HasPrefix(trimmed, "[") {
|
||||
inIncludesSection = false
|
||||
}
|
||||
|
||||
// Parse before and after directives
|
||||
if inIncludesSection {
|
||||
if strings.HasPrefix(strings.ToLower(trimmed), "before") {
|
||||
parts := strings.SplitN(trimmed, "=", 2)
|
||||
@@ -519,7 +436,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
}
|
||||
}
|
||||
|
||||
// Collect main content (everything except [INCLUDES] section)
|
||||
if !inIncludesSection {
|
||||
if i > 0 {
|
||||
mainContent.WriteString("\n")
|
||||
@@ -532,12 +448,9 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
mainContentStr := mainContent.String()
|
||||
mainVariables := extractVariablesFromContent(mainContentStr)
|
||||
|
||||
// Build combined content: before files + main filter + after files
|
||||
var combined strings.Builder
|
||||
|
||||
// Load and append before files, removing duplicates that exist in main filter
|
||||
for _, fileName := range beforeFiles {
|
||||
// Remove any existing extension to get base name
|
||||
baseName := fileName
|
||||
if strings.HasSuffix(baseName, ".local") {
|
||||
baseName = strings.TrimSuffix(baseName, ".local")
|
||||
@@ -545,13 +458,11 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
baseName = strings.TrimSuffix(baseName, ".conf")
|
||||
}
|
||||
|
||||
// Skip if this is the same filter (avoid self-inclusion)
|
||||
if baseName == currentFilterName {
|
||||
config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName)
|
||||
continue
|
||||
}
|
||||
|
||||
// Always try .local first, then .conf (matching fail2ban's behavior)
|
||||
localPath := filepath.Join(filterDPath, baseName+".local")
|
||||
confPath := filepath.Join(filterDPath, baseName+".conf")
|
||||
|
||||
@@ -559,7 +470,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
var err error
|
||||
var filePath string
|
||||
|
||||
// Try .local first
|
||||
if content, err = os.ReadFile(localPath); err == nil {
|
||||
filePath = localPath
|
||||
config.DebugLog("Loading included filter file from .local: %s", filePath)
|
||||
@@ -568,11 +478,11 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
config.DebugLog("Loading included filter file from .conf: %s", filePath)
|
||||
} else {
|
||||
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err)
|
||||
continue // Skip if neither file exists
|
||||
continue
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
// Remove variables from included file that are defined in main filter (main filter takes precedence)
|
||||
// Remove variables from included file that are defined in main filter.
|
||||
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
|
||||
combined.WriteString(cleanedContent)
|
||||
if !strings.HasSuffix(cleanedContent, "\n") {
|
||||
@@ -581,15 +491,12 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
|
||||
// Append main filter content (unchanged - this is what the user is editing)
|
||||
combined.WriteString(mainContentStr)
|
||||
if !strings.HasSuffix(mainContentStr, "\n") {
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
|
||||
// Load and append after files, also removing duplicates that exist in main filter
|
||||
for _, fileName := range afterFiles {
|
||||
// Remove any existing extension to get base name
|
||||
baseName := fileName
|
||||
if strings.HasSuffix(baseName, ".local") {
|
||||
baseName = strings.TrimSuffix(baseName, ".local")
|
||||
@@ -597,11 +504,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
baseName = strings.TrimSuffix(baseName, ".conf")
|
||||
}
|
||||
|
||||
// Note: Self-inclusion in "after" directive is intentional in fail2ban
|
||||
// (e.g., after = apache-common.local is standard pattern for .local files)
|
||||
// So we always load it, even if it's the same filter name
|
||||
|
||||
// Always try .local first, then .conf (matching fail2ban's behavior)
|
||||
localPath := filepath.Join(filterDPath, baseName+".local")
|
||||
confPath := filepath.Join(filterDPath, baseName+".conf")
|
||||
|
||||
@@ -609,7 +511,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
var err error
|
||||
var filePath string
|
||||
|
||||
// Try .local first
|
||||
if content, err = os.ReadFile(localPath); err == nil {
|
||||
filePath = localPath
|
||||
config.DebugLog("Loading included filter file from .local: %s", filePath)
|
||||
@@ -618,11 +519,9 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
config.DebugLog("Loading included filter file from .conf: %s", filePath)
|
||||
} else {
|
||||
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err)
|
||||
continue // Skip if neither file exists
|
||||
continue
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
// Remove variables from included file that are defined in main filter (main filter takes precedence)
|
||||
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
|
||||
combined.WriteString("\n")
|
||||
combined.WriteString(cleanedContent)
|
||||
@@ -634,10 +533,10 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
||||
return combined.String(), nil
|
||||
}
|
||||
|
||||
// TestFilterLocal tests a filter against log lines using fail2ban-regex
|
||||
// Returns the full output of fail2ban-regex command and the filter path used
|
||||
// Uses .local file if it exists, otherwise falls back to .conf file
|
||||
// If filterContent is provided, it creates a temporary filter file and uses that instead
|
||||
// =========================================================================
|
||||
// Filter Testing
|
||||
// =========================================================================
|
||||
|
||||
func TestFilterLocal(filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||
cleaned := normalizeLogLines(logLines)
|
||||
if len(cleaned) == 0 {
|
||||
@@ -657,7 +556,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
|
||||
defer os.Remove(tempFilterFile.Name())
|
||||
defer tempFilterFile.Close()
|
||||
|
||||
// Resolve filter includes to get complete filter content with all dependencies
|
||||
filterDPath := "/etc/fail2ban/filter.d"
|
||||
contentToWrite, err := resolveFilterIncludes(filterContent, filterDPath, filterName)
|
||||
if err != nil {
|
||||
@@ -665,7 +563,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
|
||||
contentToWrite = filterContent
|
||||
}
|
||||
|
||||
// Ensure it ends with a newline for proper parsing
|
||||
if !strings.HasSuffix(contentToWrite, "\n") {
|
||||
contentToWrite += "\n"
|
||||
}
|
||||
@@ -674,7 +571,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
|
||||
return "", "", fmt.Errorf("failed to write temporary filter file: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the file is synced to disk
|
||||
if err := tempFilterFile.Sync(); err != nil {
|
||||
return "", "", fmt.Errorf("failed to sync temporary filter file: %w", err)
|
||||
}
|
||||
@@ -683,7 +579,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
|
||||
filterPath = tempFilterFile.Name()
|
||||
config.DebugLog("TestFilterLocal: using custom filter content from temporary file: %s (size: %d bytes, includes resolved: %v)", filterPath, len(contentToWrite), err == nil)
|
||||
} else {
|
||||
// Try .local first, then fallback to .conf
|
||||
localPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".local")
|
||||
confPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf")
|
||||
|
||||
@@ -706,7 +601,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Write all log lines to the temp file
|
||||
for _, logLine := range cleaned {
|
||||
if _, err := tmpFile.WriteString(logLine + "\n"); err != nil {
|
||||
return "", filterPath, fmt.Errorf("failed to write to temporary log file: %w", err)
|
||||
@@ -714,13 +608,9 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Run fail2ban-regex with the log file and filter config
|
||||
// Format: fail2ban-regex /path/to/logfile /etc/fail2ban/filter.d/filter-name.conf
|
||||
cmd := exec.Command("fail2ban-regex", tmpFile.Name(), filterPath)
|
||||
out, _ := cmd.CombinedOutput()
|
||||
output := string(out)
|
||||
|
||||
// Return the full output regardless of exit code (fail2ban-regex may exit non-zero for no matches)
|
||||
// The output contains useful information even when there are no matches
|
||||
return output, filterPath, nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user