mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 14:03:15 +02:00
Restructure an adding basic sections 2/2
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -16,14 +16,22 @@ import (
|
|||||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
"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 {
|
type AgentConnector struct {
|
||||||
server config.Fail2banServer
|
server config.Fail2banServer
|
||||||
base *url.URL
|
base *url.URL
|
||||||
client *http.Client
|
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) {
|
func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
|
||||||
if server.AgentURL == "" {
|
if server.AgentURL == "" {
|
||||||
return nil, fmt.Errorf("agentUrl is required for agent connector")
|
return nil, fmt.Errorf("agentUrl is required for agent connector")
|
||||||
@@ -52,6 +60,10 @@ func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Connector Functions
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (ac *AgentConnector) ID() string {
|
func (ac *AgentConnector) ID() string {
|
||||||
return ac.server.ID
|
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)
|
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) {
|
func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
|
||||||
if err := ac.Restart(ctx); err != nil {
|
if err := ac.Restart(ctx); err != nil {
|
||||||
return "restart", err
|
return "restart", err
|
||||||
@@ -123,6 +133,10 @@ func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
|
|||||||
return "restart", nil
|
return "restart", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Filter Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
|
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Config string `json:"config"`
|
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 {
|
if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
// If agent doesn't return filePath, construct it (agent should handle .local priority)
|
|
||||||
filePath := resp.FilePath
|
filePath := resp.FilePath
|
||||||
if 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)
|
filePath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail)
|
||||||
}
|
}
|
||||||
return resp.Config, filePath, nil
|
return resp.Config, filePath, nil
|
||||||
@@ -184,6 +196,10 @@ func (ac *AgentConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// HTTP Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error {
|
func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error {
|
||||||
req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil)
|
req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -280,7 +296,10 @@ func (ac *AgentConnector) do(req *http.Request, out any) error {
|
|||||||
return json.Unmarshal(data, out)
|
return json.Unmarshal(data, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllJails implements Connector.
|
// =========================================================================
|
||||||
|
// Jail Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
|
func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Jails []JailInfo `json:"jails"`
|
Jails []JailInfo `json:"jails"`
|
||||||
@@ -291,12 +310,10 @@ func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
|
|||||||
return resp.Jails, nil
|
return resp.Jails, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateJailEnabledStates implements Connector.
|
|
||||||
func (ac *AgentConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
|
func (ac *AgentConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
|
||||||
return ac.post(ctx, "/v1/jails/update-enabled", updates, nil)
|
return ac.post(ctx, "/v1/jails/update-enabled", updates, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFilters implements Connector.
|
|
||||||
func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
|
func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Filters []string `json:"filters"`
|
Filters []string `json:"filters"`
|
||||||
@@ -307,7 +324,6 @@ func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
|
|||||||
return resp.Filters, nil
|
return resp.Filters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFilter implements Connector.
|
|
||||||
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"filterName": filterName,
|
"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 {
|
if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
// If agent doesn't return filterPath, construct it (agent should handle .local priority)
|
|
||||||
filterPath := resp.FilterPath
|
filterPath := resp.FilterPath
|
||||||
if 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)
|
filterPath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName)
|
||||||
}
|
}
|
||||||
return resp.Output, filterPath, nil
|
return resp.Output, filterPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJailConfig implements Connector.
|
|
||||||
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
|
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Config string `json:"config"`
|
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 {
|
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
// If agent doesn't return filePath, construct it (agent should handle .local priority)
|
|
||||||
filePath := resp.FilePath
|
filePath := resp.FilePath
|
||||||
if 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)
|
filePath = fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail)
|
||||||
}
|
}
|
||||||
return resp.Config, filePath, nil
|
return resp.Config, filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetJailConfig implements Connector.
|
|
||||||
func (ac *AgentConnector) SetJailConfig(ctx context.Context, jail, content string) error {
|
func (ac *AgentConnector) SetJailConfig(ctx context.Context, jail, content string) error {
|
||||||
payload := map[string]string{"config": content}
|
payload := map[string]string{"config": content}
|
||||||
return ac.put(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), payload, nil)
|
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) {
|
func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
|
||||||
payload := map[string]string{"logpath": logpath}
|
payload := map[string]string{"logpath": logpath}
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Files []string `json:"files"`
|
Files []string `json:"files"`
|
||||||
}
|
}
|
||||||
if err := ac.post(ctx, "/v1/jails/test-logpath", payload, &resp); err != nil {
|
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
|
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) {
|
func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
||||||
originalPath = strings.TrimSpace(logpath)
|
originalPath = strings.TrimSpace(logpath)
|
||||||
if originalPath == "" {
|
if originalPath == "" {
|
||||||
@@ -386,7 +397,7 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath
|
|||||||
|
|
||||||
// Try new endpoint first, fallback to old endpoint
|
// Try new endpoint first, fallback to old endpoint
|
||||||
if err := ac.post(ctx, "/v1/jails/test-logpath-with-resolution", payload, &resp); err != nil {
|
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)
|
files, err2 := ac.TestLogpath(ctx, originalPath)
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
return originalPath, "", nil, fmt.Errorf("failed to test logpath: %w", err2)
|
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
|
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 {
|
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)
|
return ac.EnsureJailLocalStructure(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckJailLocalIntegrity implements Connector.
|
|
||||||
func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
|
func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
|
||||||
var result struct {
|
var result struct {
|
||||||
Exists bool `json:"exists"`
|
Exists bool `json:"exists"`
|
||||||
@@ -431,10 +442,8 @@ func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bo
|
|||||||
return result.Exists, result.HasUIAction, nil
|
return result.Exists, result.HasUIAction, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureJailLocalStructure implements Connector.
|
|
||||||
func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
||||||
// Safety: if jail.local exists but is not managed by Fail2ban-UI,
|
// If jail.local exists but is not managed by Fail2ban-UI, it belongs to the user, we do not overwrite it.
|
||||||
// it belongs to the user; never overwrite it.
|
|
||||||
if exists, hasUI, err := ac.CheckJailLocalIntegrity(ctx); err == nil && exists && !hasUI {
|
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)
|
config.DebugLog("jail.local on agent server %s exists but is not managed by Fail2ban-UI -- skipping overwrite", ac.server.Name)
|
||||||
return nil
|
return nil
|
||||||
@@ -443,7 +452,10 @@ func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
|||||||
return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil)
|
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 {
|
func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content string) error {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"name": jailName,
|
"name": jailName,
|
||||||
@@ -452,12 +464,10 @@ func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content stri
|
|||||||
return ac.post(ctx, "/v1/jails", payload, nil)
|
return ac.post(ctx, "/v1/jails", payload, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteJail implements Connector.
|
|
||||||
func (ac *AgentConnector) DeleteJail(ctx context.Context, jailName string) error {
|
func (ac *AgentConnector) DeleteJail(ctx context.Context, jailName string) error {
|
||||||
return ac.delete(ctx, fmt.Sprintf("/v1/jails/%s", jailName), nil)
|
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 {
|
func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content string) error {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"name": filterName,
|
"name": filterName,
|
||||||
@@ -466,7 +476,6 @@ func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content
|
|||||||
return ac.post(ctx, "/v1/filters", payload, nil)
|
return ac.post(ctx, "/v1/filters", payload, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteFilter implements Connector.
|
|
||||||
func (ac *AgentConnector) DeleteFilter(ctx context.Context, filterName string) error {
|
func (ac *AgentConnector) DeleteFilter(ctx context.Context, filterName string) error {
|
||||||
return ac.delete(ctx, fmt.Sprintf("/v1/filters/%s", filterName), nil)
|
return ac.delete(ctx, fmt.Sprintf("/v1/filters/%s", filterName), nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,46 +14,43 @@ import (
|
|||||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
"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 {
|
type LocalConnector struct {
|
||||||
server config.Fail2banServer
|
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 {
|
func NewLocalConnector(server config.Fail2banServer) *LocalConnector {
|
||||||
return &LocalConnector{server: server}
|
return &LocalConnector{server: server}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ID implements Connector.
|
|
||||||
func (lc *LocalConnector) ID() string {
|
func (lc *LocalConnector) ID() string {
|
||||||
return lc.server.ID
|
return lc.server.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server implements Connector.
|
|
||||||
func (lc *LocalConnector) Server() config.Fail2banServer {
|
func (lc *LocalConnector) Server() config.Fail2banServer {
|
||||||
return lc.server
|
return lc.server
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJailInfos implements Connector.
|
// Get jail information.
|
||||||
func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
||||||
jails, err := lc.getJails(ctx)
|
jails, err := lc.getJails(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
logPath := lc.server.LogPath // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS.
|
||||||
logPath := lc.server.LogPath
|
|
||||||
if logPath == "" {
|
if logPath == "" {
|
||||||
logPath = "/var/log/fail2ban.log"
|
logPath = "/var/log/fail2ban.log"
|
||||||
}
|
}
|
||||||
|
banHistory, err := ParseBanLog(logPath) // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS.
|
||||||
banHistory, err := ParseBanLog(logPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
banHistory = make(map[string][]BanEvent)
|
banHistory = make(map[string][]BanEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||||
|
|
||||||
// Use parallel execution for better performance
|
|
||||||
type jailResult struct {
|
type jailResult struct {
|
||||||
jail JailInfo
|
jail JailInfo
|
||||||
err error
|
err error
|
||||||
@@ -89,12 +86,10 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
|
|||||||
}
|
}
|
||||||
}(jail)
|
}(jail)
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
close(results)
|
close(results)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var finalResults []JailInfo
|
var finalResults []JailInfo
|
||||||
for result := range results {
|
for result := range results {
|
||||||
if result.err != nil {
|
if result.err != nil {
|
||||||
@@ -102,14 +97,13 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
|
|||||||
}
|
}
|
||||||
finalResults = append(finalResults, result.jail)
|
finalResults = append(finalResults, result.jail)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(finalResults, func(i, j int) bool {
|
sort.SliceStable(finalResults, func(i, j int) bool {
|
||||||
return finalResults[i].JailName < finalResults[j].JailName
|
return finalResults[i].JailName < finalResults[j].JailName
|
||||||
})
|
})
|
||||||
return finalResults, nil
|
return finalResults, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBannedIPs implements Connector.
|
// Get banned IPs for a given jail.
|
||||||
func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
||||||
args := []string{"status", jail}
|
args := []string{"status", jail}
|
||||||
out, err := lc.runFail2banClient(ctx, args...)
|
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")
|
lines := strings.Split(out, "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if strings.Contains(line, "IP list:") {
|
if strings.Contains(line, "IP list:") {
|
||||||
// Use SplitN to only split on the first colon, preserving IPv6 addresses
|
|
||||||
parts := strings.SplitN(line, ":", 2)
|
parts := strings.SplitN(line, ":", 2)
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
ips := strings.Fields(strings.TrimSpace(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
|
return bannedIPs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnbanIP implements Connector.
|
// Unban an IP from a given jail.
|
||||||
func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
||||||
args := []string{"set", jail, "unbanip", ip}
|
args := []string{"set", jail, "unbanip", ip}
|
||||||
if _, err := lc.runFail2banClient(ctx, args...); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BanIP implements Connector.
|
// Ban an IP in a given jail.
|
||||||
func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error {
|
func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error {
|
||||||
args := []string{"set", jail, "banip", ip}
|
args := []string{"set", jail, "banip", ip}
|
||||||
if _, err := lc.runFail2banClient(ctx, args...); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload implements Connector.
|
// Reload the Fail2ban service.
|
||||||
func (lc *LocalConnector) Reload(ctx context.Context) error {
|
func (lc *LocalConnector) Reload(ctx context.Context) error {
|
||||||
out, err := lc.runFail2banClient(ctx, "reload")
|
out, err := lc.runFail2banClient(ctx, "reload")
|
||||||
if err != nil {
|
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))
|
return fmt.Errorf("fail2ban reload error: %w (output: %s)", err, strings.TrimSpace(out))
|
||||||
}
|
}
|
||||||
|
// Check if fail2ban-client returns "OK"
|
||||||
// Check if output indicates success (fail2ban-client returns "OK" on success)
|
|
||||||
outputTrimmed := strings.TrimSpace(out)
|
outputTrimmed := strings.TrimSpace(out)
|
||||||
if outputTrimmed != "OK" && outputTrimmed != "" {
|
if outputTrimmed != "OK" && outputTrimmed != "" {
|
||||||
config.DebugLog("fail2ban reload output: %s", out)
|
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") {
|
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 fmt.Errorf("fail2ban reload completed but with errors (output: %s)", strings.TrimSpace(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RestartWithMode restarts (or reloads) the local Fail2ban instance and returns
|
// Restart or reload the local Fail2ban instance; returns "restart" or "reload".
|
||||||
// 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
|
|
||||||
func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
|
func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
|
||||||
// 1) Try systemd restart if systemctl is available.
|
|
||||||
if _, err := exec.LookPath("systemctl"); err == nil {
|
if _, err := exec.LookPath("systemctl"); err == nil {
|
||||||
cmd := "systemctl restart fail2ban"
|
cmd := "systemctl restart fail2ban"
|
||||||
out, err := executeShellCommand(ctx, cmd)
|
out, err := executeShellCommand(ctx, cmd)
|
||||||
@@ -191,9 +174,6 @@ func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
return "restart", nil
|
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 {
|
if err := lc.Reload(ctx); err != nil {
|
||||||
return "reload", fmt.Errorf("failed to reload fail2ban via fail2ban-client (systemctl not available): %w", err)
|
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
|
return "reload", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart implements Connector.
|
|
||||||
func (lc *LocalConnector) Restart(ctx context.Context) error {
|
func (lc *LocalConnector) Restart(ctx context.Context) error {
|
||||||
_, err := lc.RestartWithMode(ctx)
|
_, err := lc.RestartWithMode(ctx)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFilterConfig implements Connector.
|
|
||||||
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
|
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
|
||||||
return GetFilterConfigLocal(jail)
|
return GetFilterConfigLocal(jail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFilterConfig implements Connector.
|
|
||||||
func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
||||||
return SetFilterConfigLocal(jail, content)
|
return SetFilterConfigLocal(jail, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchBanEvents implements Connector.
|
// REMOVE THIS FUNCTION
|
||||||
func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
|
func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
|
||||||
logPath := lc.server.LogPath
|
logPath := lc.server.LogPath
|
||||||
if logPath == "" {
|
if logPath == "" {
|
||||||
@@ -242,17 +219,16 @@ func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE
|
|||||||
return all, nil
|
return all, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all jails.
|
||||||
func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
|
func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
|
||||||
out, err := lc.runFail2banClient(ctx, "status")
|
out, err := lc.runFail2banClient(ctx, "status")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err)
|
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var jails []string
|
var jails []string
|
||||||
lines := strings.Split(out, "\n")
|
lines := strings.Split(out, "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if strings.Contains(line, "Jail list:") {
|
if strings.Contains(line, "Jail list:") {
|
||||||
// Use SplitN to only split on the first colon
|
|
||||||
parts := strings.SplitN(line, ":", 2)
|
parts := strings.SplitN(line, ":", 2)
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
raw := strings.TrimSpace(parts[1])
|
raw := strings.TrimSpace(parts[1])
|
||||||
@@ -266,6 +242,10 @@ func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
|
|||||||
return jails, nil
|
return jails, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// CLI Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) {
|
func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) {
|
||||||
cmdArgs := lc.buildFail2banArgs(args...)
|
cmdArgs := lc.buildFail2banArgs(args...)
|
||||||
cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...)
|
cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...)
|
||||||
@@ -281,99 +261,84 @@ func (lc *LocalConnector) buildFail2banArgs(args ...string) []string {
|
|||||||
return append(base, args...)
|
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 {
|
func (lc *LocalConnector) checkFail2banHealthy(ctx context.Context) error {
|
||||||
out, err := lc.runFail2banClient(ctx, "ping")
|
out, err := lc.runFail2banClient(ctx, "ping")
|
||||||
trimmed := strings.TrimSpace(out)
|
trimmed := strings.TrimSpace(out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fail2ban ping error: %w (output: %s)", err, trimmed)
|
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") {
|
if !strings.Contains(strings.ToLower(trimmed), "pong") {
|
||||||
return fmt.Errorf("unexpected fail2ban ping output: %s", trimmed)
|
return fmt.Errorf("unexpected fail2ban ping output: %s", trimmed)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllJails implements Connector.
|
// =========================================================================
|
||||||
|
// Delegated Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
|
func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
|
||||||
return GetAllJails()
|
return GetAllJails()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateJailEnabledStates implements Connector.
|
|
||||||
func (lc *LocalConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
|
func (lc *LocalConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
|
||||||
return UpdateJailEnabledStates(updates)
|
return UpdateJailEnabledStates(updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFilters implements Connector.
|
|
||||||
func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) {
|
func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) {
|
||||||
return GetFiltersLocal()
|
return GetFiltersLocal()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFilter implements Connector.
|
|
||||||
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||||
return TestFilterLocal(filterName, logLines, filterContent)
|
return TestFilterLocal(filterName, logLines, filterContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJailConfig implements Connector.
|
|
||||||
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
|
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
|
||||||
return GetJailConfig(jail)
|
return GetJailConfig(jail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetJailConfig implements Connector.
|
|
||||||
func (lc *LocalConnector) SetJailConfig(ctx context.Context, jail, content string) error {
|
func (lc *LocalConnector) SetJailConfig(ctx context.Context, jail, content string) error {
|
||||||
return SetJailConfig(jail, content)
|
return SetJailConfig(jail, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLogpath implements Connector.
|
|
||||||
func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
|
func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
|
||||||
return TestLogpath(logpath)
|
return TestLogpath(logpath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLogpathWithResolution implements Connector.
|
|
||||||
func (lc *LocalConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
func (lc *LocalConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
||||||
return TestLogpathWithResolution(logpath)
|
return TestLogpathWithResolution(logpath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateDefaultSettings implements Connector.
|
|
||||||
func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
||||||
return UpdateDefaultSettingsLocal(settings)
|
return UpdateDefaultSettingsLocal(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureJailLocalStructure implements Connector.
|
|
||||||
func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
||||||
return config.EnsureJailLocalStructure()
|
return config.EnsureJailLocalStructure()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateJail implements Connector.
|
|
||||||
func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error {
|
func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error {
|
||||||
return CreateJail(jailName, content)
|
return CreateJail(jailName, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteJail implements Connector.
|
|
||||||
func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error {
|
func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error {
|
||||||
return DeleteJail(jailName)
|
return DeleteJail(jailName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFilter implements Connector.
|
|
||||||
func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error {
|
func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error {
|
||||||
return CreateFilter(filterName, content)
|
return CreateFilter(filterName, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteFilter implements Connector.
|
|
||||||
func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error {
|
func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error {
|
||||||
return DeleteFilter(filterName)
|
return DeleteFilter(filterName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckJailLocalIntegrity implements Connector.
|
|
||||||
func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
|
func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
|
||||||
const jailLocalPath = "/etc/fail2ban/jail.local"
|
const jailLocalPath = "/etc/fail2ban/jail.local"
|
||||||
content, err := os.ReadFile(jailLocalPath)
|
content, err := os.ReadFile(jailLocalPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
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)
|
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
|
return true, hasUIAction, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Shell Execution
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func executeShellCommand(ctx context.Context, command string) (string, error) {
|
func executeShellCommand(ctx context.Context, command string) (string, error) {
|
||||||
parts := strings.Fields(command)
|
parts := strings.Fields(command)
|
||||||
if len(parts) == 0 {
|
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.
|
// 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)
|
// 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 not use this file except in compliance with the License.
|
||||||
@@ -29,8 +29,7 @@ import (
|
|||||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetFilterConfig returns the filter configuration using the default connector.
|
// Returns the filter configuration using the default connector.
|
||||||
// Returns (config, filePath, error)
|
|
||||||
func GetFilterConfig(jail string) (string, string, error) {
|
func GetFilterConfig(jail string) (string, string, error) {
|
||||||
conn, err := GetManager().DefaultConnector()
|
conn, err := GetManager().DefaultConnector()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,7 +38,7 @@ func GetFilterConfig(jail string) (string, string, error) {
|
|||||||
return conn.GetFilterConfig(context.Background(), jail)
|
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 {
|
func SetFilterConfig(jail, newContent string) error {
|
||||||
conn, err := GetManager().DefaultConnector()
|
conn, err := GetManager().DefaultConnector()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,10 +47,7 @@ func SetFilterConfig(jail, newContent string) error {
|
|||||||
return conn.SetFilterConfig(context.Background(), jail, newContent)
|
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 {
|
func ensureFilterLocalFile(filterName string) error {
|
||||||
// Validate filter name - must not be empty
|
|
||||||
filterName = strings.TrimSpace(filterName)
|
filterName = strings.TrimSpace(filterName)
|
||||||
if filterName == "" {
|
if filterName == "" {
|
||||||
return fmt.Errorf("filter name cannot be empty")
|
return fmt.Errorf("filter name cannot be empty")
|
||||||
@@ -61,13 +57,11 @@ func ensureFilterLocalFile(filterName string) error {
|
|||||||
localPath := filepath.Join(filterDPath, filterName+".local")
|
localPath := filepath.Join(filterDPath, filterName+".local")
|
||||||
confPath := filepath.Join(filterDPath, filterName+".conf")
|
confPath := filepath.Join(filterDPath, filterName+".conf")
|
||||||
|
|
||||||
// Check if .local already exists
|
|
||||||
if _, err := os.Stat(localPath); err == nil {
|
if _, err := os.Stat(localPath); err == nil {
|
||||||
config.DebugLog("Filter .local file already exists: %s", localPath)
|
config.DebugLog("Filter .local file already exists: %s", localPath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to copy from .conf if it exists
|
|
||||||
if _, err := os.Stat(confPath); err == nil {
|
if _, err := os.Stat(confPath); err == nil {
|
||||||
config.DebugLog("Copying filter config from .conf to .local: %s -> %s", confPath, localPath)
|
config.DebugLog("Copying filter config from .conf to .local: %s -> %s", confPath, localPath)
|
||||||
content, err := os.ReadFile(confPath)
|
content, err := os.ReadFile(confPath)
|
||||||
@@ -81,7 +75,6 @@ func ensureFilterLocalFile(filterName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Neither exists, create empty .local file
|
|
||||||
config.DebugLog("Neither .local nor .conf exists for filter %s, creating empty .local file", filterName)
|
config.DebugLog("Neither .local nor .conf exists for filter %s, creating empty .local file", filterName)
|
||||||
if err := os.WriteFile(localPath, []byte(""), 0644); err != nil {
|
if err := os.WriteFile(localPath, []byte(""), 0644); err != nil {
|
||||||
return fmt.Errorf("failed to create empty filter .local file %s: %w", localPath, err)
|
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
|
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 {
|
func RemoveComments(content string) string {
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
var result []string
|
var result []string
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
// Skip lines that start with # (comments)
|
|
||||||
if !strings.HasPrefix(trimmed, "#") {
|
if !strings.HasPrefix(trimmed, "#") {
|
||||||
result = append(result, line)
|
result = append(result, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove leading empty lines
|
|
||||||
for len(result) > 0 && strings.TrimSpace(result[0]) == "" {
|
for len(result) > 0 && strings.TrimSpace(result[0]) == "" {
|
||||||
result = result[1:]
|
result = result[1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove trailing empty lines
|
|
||||||
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
|
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
|
||||||
result = result[:len(result)-1]
|
result = result[:len(result)-1]
|
||||||
}
|
}
|
||||||
@@ -117,10 +104,8 @@ func RemoveComments(content string) string {
|
|||||||
return strings.Join(result, "\n")
|
return strings.Join(result, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf.
|
// Reads filter config from .local first, then falls back to .conf.
|
||||||
// Returns (content, filePath, error)
|
|
||||||
func readFilterConfigWithFallback(filterName string) (string, string, error) {
|
func readFilterConfigWithFallback(filterName string) (string, string, error) {
|
||||||
// Validate filter name - must not be empty
|
|
||||||
filterName = strings.TrimSpace(filterName)
|
filterName = strings.TrimSpace(filterName)
|
||||||
if filterName == "" {
|
if filterName == "" {
|
||||||
return "", "", fmt.Errorf("filter name cannot be empty")
|
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")
|
localPath := filepath.Join(filterDPath, filterName+".local")
|
||||||
confPath := filepath.Join(filterDPath, filterName+".conf")
|
confPath := filepath.Join(filterDPath, filterName+".conf")
|
||||||
|
|
||||||
// Try .local first
|
|
||||||
if content, err := os.ReadFile(localPath); err == nil {
|
if content, err := os.ReadFile(localPath); err == nil {
|
||||||
config.DebugLog("Reading filter config from .local: %s", localPath)
|
config.DebugLog("Reading filter config from .local: %s", localPath)
|
||||||
return string(content), localPath, nil
|
return string(content), localPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to .conf
|
|
||||||
if content, err := os.ReadFile(confPath); err == nil {
|
if content, err := os.ReadFile(confPath); err == nil {
|
||||||
config.DebugLog("Reading filter config from .conf: %s", confPath)
|
config.DebugLog("Reading filter config from .conf: %s", confPath)
|
||||||
return string(content), confPath, nil
|
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)
|
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) {
|
func GetFilterConfigLocal(jail string) (string, string, error) {
|
||||||
return readFilterConfigWithFallback(jail)
|
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 {
|
func SetFilterConfigLocal(jail, newContent string) error {
|
||||||
// Ensure .local file exists (copy from .conf if needed)
|
|
||||||
if err := ensureFilterLocalFile(jail); err != nil {
|
if err := ensureFilterLocalFile(jail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
localPath := filepath.Join("/etc/fail2ban/filter.d", jail+".local")
|
localPath := filepath.Join("/etc/fail2ban/filter.d", jail+".local")
|
||||||
if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil {
|
if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write filter .local file for %s: %w", jail, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateFilterName validates a filter name format.
|
// Validates a filter name format.
|
||||||
// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved).
|
|
||||||
func ValidateFilterName(name string) error {
|
func ValidateFilterName(name string) error {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("filter name cannot be empty")
|
return fmt.Errorf("filter name cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for invalid characters (only alphanumeric, dash, underscore allowed)
|
|
||||||
invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
|
||||||
if invalidChars.MatchString(name) {
|
if invalidChars.MatchString(name) {
|
||||||
return fmt.Errorf("filter name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListFilterFiles lists all filter files in the specified directory.
|
// Lists all filter files in the specified directory.
|
||||||
// Returns full paths to .local and .conf files.
|
|
||||||
func ListFilterFiles(directory string) ([]string, error) {
|
func ListFilterFiles(directory string) ([]string, error) {
|
||||||
var files []string
|
var files []string
|
||||||
|
|
||||||
@@ -200,14 +171,10 @@ func ListFilterFiles(directory string) ([]string, error) {
|
|||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
// Skip hidden files and invalid names
|
|
||||||
if strings.HasPrefix(name, ".") {
|
if strings.HasPrefix(name, ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include .local and .conf files
|
|
||||||
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
|
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
|
||||||
fullPath := filepath.Join(directory, name)
|
fullPath := filepath.Join(directory, name)
|
||||||
files = append(files, fullPath)
|
files = append(files, fullPath)
|
||||||
@@ -217,28 +184,22 @@ func ListFilterFiles(directory string) ([]string, error) {
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiscoverFiltersFromFiles discovers all filters from the filesystem.
|
// Returns all filters from the filesystem.
|
||||||
// Reads from /etc/fail2ban/filter.d/ directory, preferring .local files over .conf files.
|
|
||||||
// Returns unique filter names.
|
|
||||||
func DiscoverFiltersFromFiles() ([]string, error) {
|
func DiscoverFiltersFromFiles() ([]string, error) {
|
||||||
filterDPath := "/etc/fail2ban/filter.d"
|
filterDPath := "/etc/fail2ban/filter.d"
|
||||||
|
|
||||||
// Check if directory exists
|
|
||||||
if _, err := os.Stat(filterDPath); os.IsNotExist(err) {
|
if _, err := os.Stat(filterDPath); os.IsNotExist(err) {
|
||||||
// Directory doesn't exist, return empty list
|
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all filter files
|
|
||||||
files, err := ListFilterFiles(filterDPath)
|
files, err := ListFilterFiles(filterDPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
filterMap := make(map[string]bool) // Track unique filter names
|
filterMap := make(map[string]bool)
|
||||||
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
|
processedFiles := make(map[string]bool)
|
||||||
|
|
||||||
// First pass: collect all .local files (these take precedence)
|
|
||||||
for _, filePath := range files {
|
for _, filePath := range files {
|
||||||
if !strings.HasSuffix(filePath, ".local") {
|
if !strings.HasSuffix(filePath, ".local") {
|
||||||
continue
|
continue
|
||||||
@@ -250,7 +211,6 @@ func DiscoverFiltersFromFiles() ([]string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if we've already processed this base name
|
|
||||||
if processedFiles[baseName] {
|
if processedFiles[baseName] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -259,28 +219,22 @@ func DiscoverFiltersFromFiles() ([]string, error) {
|
|||||||
filterMap[baseName] = true
|
filterMap[baseName] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: collect .conf files that don't have corresponding .local files
|
|
||||||
for _, filePath := range files {
|
for _, filePath := range files {
|
||||||
if !strings.HasSuffix(filePath, ".conf") {
|
if !strings.HasSuffix(filePath, ".conf") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := filepath.Base(filePath)
|
filename := filepath.Base(filePath)
|
||||||
baseName := strings.TrimSuffix(filename, ".conf")
|
baseName := strings.TrimSuffix(filename, ".conf")
|
||||||
if baseName == "" {
|
if baseName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if we've already processed a .local file with the same base name
|
|
||||||
if processedFiles[baseName] {
|
if processedFiles[baseName] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
processedFiles[baseName] = true
|
processedFiles[baseName] = true
|
||||||
filterMap[baseName] = true
|
filterMap[baseName] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert map to sorted slice
|
|
||||||
var filters []string
|
var filters []string
|
||||||
for name := range filterMap {
|
for name := range filterMap {
|
||||||
filters = append(filters, name)
|
filters = append(filters, name)
|
||||||
@@ -290,32 +244,24 @@ func DiscoverFiltersFromFiles() ([]string, error) {
|
|||||||
return filters, nil
|
return filters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateFilter creates a new filter in filter.d/{name}.local.
|
// Creates a new filter.
|
||||||
// If the filter already exists, it will be overwritten.
|
|
||||||
func CreateFilter(filterName, content string) error {
|
func CreateFilter(filterName, content string) error {
|
||||||
if err := ValidateFilterName(filterName); err != nil {
|
if err := ValidateFilterName(filterName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
filterDPath := "/etc/fail2ban/filter.d"
|
filterDPath := "/etc/fail2ban/filter.d"
|
||||||
localPath := filepath.Join(filterDPath, filterName+".local")
|
localPath := filepath.Join(filterDPath, filterName+".local")
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if err := os.MkdirAll(filterDPath, 0755); err != nil {
|
if err := os.MkdirAll(filterDPath, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create filter.d directory: %w", err)
|
return fmt.Errorf("failed to create filter.d directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the file
|
|
||||||
if err := os.WriteFile(localPath, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(localPath, []byte(content), 0644); err != nil {
|
||||||
return fmt.Errorf("failed to create filter file %s: %w", localPath, err)
|
return fmt.Errorf("failed to create filter file %s: %w", localPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
config.DebugLog("Created filter file: %s", localPath)
|
config.DebugLog("Created filter file: %s", localPath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteFilter deletes a filter's .local and .conf files from filter.d/ if they exist.
|
// 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.
|
|
||||||
func DeleteFilter(filterName string) error {
|
func DeleteFilter(filterName string) error {
|
||||||
if err := ValidateFilterName(filterName); err != nil {
|
if err := ValidateFilterName(filterName); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -328,7 +274,6 @@ func DeleteFilter(filterName string) error {
|
|||||||
var deletedFiles []string
|
var deletedFiles []string
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
|
||||||
// Delete .local file if it exists
|
|
||||||
if _, err := os.Stat(localPath); err == nil {
|
if _, err := os.Stat(localPath); err == nil {
|
||||||
if err := os.Remove(localPath); err != nil {
|
if err := os.Remove(localPath); err != nil {
|
||||||
lastErr = fmt.Errorf("failed to delete filter file %s: %w", localPath, err)
|
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)
|
config.DebugLog("Deleted filter file: %s", localPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete .conf file if it exists
|
|
||||||
if _, err := os.Stat(confPath); err == nil {
|
if _, err := os.Stat(confPath); err == nil {
|
||||||
if err := os.Remove(confPath); err != nil {
|
if err := os.Remove(confPath); err != nil {
|
||||||
lastErr = fmt.Errorf("failed to delete filter file %s: %w", confPath, err)
|
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)
|
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 {
|
if len(deletedFiles) == 0 && lastErr == nil {
|
||||||
return fmt.Errorf("filter file %s or %s does not exist", localPath, confPath)
|
return fmt.Errorf("filter file %s or %s does not exist", localPath, confPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the last error if any occurred
|
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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) {
|
func GetFiltersLocal() ([]string, error) {
|
||||||
return DiscoverFiltersFromFiles()
|
return DiscoverFiltersFromFiles()
|
||||||
}
|
}
|
||||||
@@ -380,7 +315,7 @@ func normalizeLogLines(logLines []string) []string {
|
|||||||
return cleaned
|
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 {
|
func extractVariablesFromContent(content string) map[string]bool {
|
||||||
variables := make(map[string]bool)
|
variables := make(map[string]bool)
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
@@ -388,20 +323,15 @@ func extractVariablesFromContent(content string) map[string]bool {
|
|||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
// Check for [DEFAULT] section
|
|
||||||
if strings.HasPrefix(trimmed, "[DEFAULT]") {
|
if strings.HasPrefix(trimmed, "[DEFAULT]") {
|
||||||
inDefaultSection = true
|
inDefaultSection = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for end of [DEFAULT] section (next section starts)
|
// Check for end of [DEFAULT] section (next section starts)
|
||||||
if inDefaultSection && strings.HasPrefix(trimmed, "[") {
|
if inDefaultSection && strings.HasPrefix(trimmed, "[") {
|
||||||
inDefaultSection = false
|
inDefaultSection = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract variable name from [DEFAULT] section
|
|
||||||
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
||||||
parts := strings.SplitN(trimmed, "=", 2)
|
parts := strings.SplitN(trimmed, "=", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
@@ -412,11 +342,9 @@ func extractVariablesFromContent(content string) map[string]bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return variables
|
return variables
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeDuplicateVariables removes variable definitions from included content that already exist in main filter
|
|
||||||
func removeDuplicateVariables(includedContent string, mainVariables map[string]bool) string {
|
func removeDuplicateVariables(includedContent string, mainVariables map[string]bool) string {
|
||||||
lines := strings.Split(includedContent, "\n")
|
lines := strings.Split(includedContent, "\n")
|
||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
@@ -427,7 +355,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
|
|||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
originalLine := line
|
originalLine := line
|
||||||
|
|
||||||
// Check for [DEFAULT] section
|
|
||||||
if strings.HasPrefix(trimmed, "[DEFAULT]") {
|
if strings.HasPrefix(trimmed, "[DEFAULT]") {
|
||||||
inDefaultSection = true
|
inDefaultSection = true
|
||||||
result.WriteString(originalLine)
|
result.WriteString(originalLine)
|
||||||
@@ -443,13 +370,11 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// In [DEFAULT] section, check if variable already exists in main filter
|
|
||||||
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
|
||||||
parts := strings.SplitN(trimmed, "=", 2)
|
parts := strings.SplitN(trimmed, "=", 2)
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
varName := strings.TrimSpace(parts[0])
|
varName := strings.TrimSpace(parts[0])
|
||||||
if mainVariables[varName] {
|
if mainVariables[varName] {
|
||||||
// Skip this line - variable will be defined in main filter (takes precedence)
|
|
||||||
removedCount++
|
removedCount++
|
||||||
config.DebugLog("Removing variable '%s' from included file (will be overridden by main filter)", varName)
|
config.DebugLog("Removing variable '%s' from included file (will be overridden by main filter)", varName)
|
||||||
continue
|
continue
|
||||||
@@ -468,11 +393,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
|
|||||||
return result.String()
|
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) {
|
func resolveFilterIncludes(filterContent string, filterDPath string, currentFilterName string) (string, error) {
|
||||||
lines := strings.Split(filterContent, "\n")
|
lines := strings.Split(filterContent, "\n")
|
||||||
var beforeFiles []string
|
var beforeFiles []string
|
||||||
@@ -484,18 +404,15 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
|||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
// Check for [INCLUDES] section
|
|
||||||
if strings.HasPrefix(trimmed, "[INCLUDES]") {
|
if strings.HasPrefix(trimmed, "[INCLUDES]") {
|
||||||
inIncludesSection = true
|
inIncludesSection = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for end of [INCLUDES] section (next section starts)
|
|
||||||
if inIncludesSection && strings.HasPrefix(trimmed, "[") {
|
if inIncludesSection && strings.HasPrefix(trimmed, "[") {
|
||||||
inIncludesSection = false
|
inIncludesSection = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse before and after directives
|
|
||||||
if inIncludesSection {
|
if inIncludesSection {
|
||||||
if strings.HasPrefix(strings.ToLower(trimmed), "before") {
|
if strings.HasPrefix(strings.ToLower(trimmed), "before") {
|
||||||
parts := strings.SplitN(trimmed, "=", 2)
|
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 !inIncludesSection {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
mainContent.WriteString("\n")
|
mainContent.WriteString("\n")
|
||||||
@@ -532,12 +448,9 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
|||||||
mainContentStr := mainContent.String()
|
mainContentStr := mainContent.String()
|
||||||
mainVariables := extractVariablesFromContent(mainContentStr)
|
mainVariables := extractVariablesFromContent(mainContentStr)
|
||||||
|
|
||||||
// Build combined content: before files + main filter + after files
|
|
||||||
var combined strings.Builder
|
var combined strings.Builder
|
||||||
|
|
||||||
// Load and append before files, removing duplicates that exist in main filter
|
|
||||||
for _, fileName := range beforeFiles {
|
for _, fileName := range beforeFiles {
|
||||||
// Remove any existing extension to get base name
|
|
||||||
baseName := fileName
|
baseName := fileName
|
||||||
if strings.HasSuffix(baseName, ".local") {
|
if strings.HasSuffix(baseName, ".local") {
|
||||||
baseName = strings.TrimSuffix(baseName, ".local")
|
baseName = strings.TrimSuffix(baseName, ".local")
|
||||||
@@ -545,13 +458,11 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
|||||||
baseName = strings.TrimSuffix(baseName, ".conf")
|
baseName = strings.TrimSuffix(baseName, ".conf")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if this is the same filter (avoid self-inclusion)
|
|
||||||
if baseName == currentFilterName {
|
if baseName == currentFilterName {
|
||||||
config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName)
|
config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always try .local first, then .conf (matching fail2ban's behavior)
|
|
||||||
localPath := filepath.Join(filterDPath, baseName+".local")
|
localPath := filepath.Join(filterDPath, baseName+".local")
|
||||||
confPath := filepath.Join(filterDPath, baseName+".conf")
|
confPath := filepath.Join(filterDPath, baseName+".conf")
|
||||||
|
|
||||||
@@ -559,7 +470,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
|||||||
var err error
|
var err error
|
||||||
var filePath string
|
var filePath string
|
||||||
|
|
||||||
// Try .local first
|
|
||||||
if content, err = os.ReadFile(localPath); err == nil {
|
if content, err = os.ReadFile(localPath); err == nil {
|
||||||
filePath = localPath
|
filePath = localPath
|
||||||
config.DebugLog("Loading included filter file from .local: %s", filePath)
|
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)
|
config.DebugLog("Loading included filter file from .conf: %s", filePath)
|
||||||
} else {
|
} else {
|
||||||
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err)
|
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)
|
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)
|
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
|
||||||
combined.WriteString(cleanedContent)
|
combined.WriteString(cleanedContent)
|
||||||
if !strings.HasSuffix(cleanedContent, "\n") {
|
if !strings.HasSuffix(cleanedContent, "\n") {
|
||||||
@@ -581,15 +491,12 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
|||||||
combined.WriteString("\n")
|
combined.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append main filter content (unchanged - this is what the user is editing)
|
|
||||||
combined.WriteString(mainContentStr)
|
combined.WriteString(mainContentStr)
|
||||||
if !strings.HasSuffix(mainContentStr, "\n") {
|
if !strings.HasSuffix(mainContentStr, "\n") {
|
||||||
combined.WriteString("\n")
|
combined.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load and append after files, also removing duplicates that exist in main filter
|
|
||||||
for _, fileName := range afterFiles {
|
for _, fileName := range afterFiles {
|
||||||
// Remove any existing extension to get base name
|
|
||||||
baseName := fileName
|
baseName := fileName
|
||||||
if strings.HasSuffix(baseName, ".local") {
|
if strings.HasSuffix(baseName, ".local") {
|
||||||
baseName = strings.TrimSuffix(baseName, ".local")
|
baseName = strings.TrimSuffix(baseName, ".local")
|
||||||
@@ -597,11 +504,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
|||||||
baseName = strings.TrimSuffix(baseName, ".conf")
|
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")
|
localPath := filepath.Join(filterDPath, baseName+".local")
|
||||||
confPath := filepath.Join(filterDPath, baseName+".conf")
|
confPath := filepath.Join(filterDPath, baseName+".conf")
|
||||||
|
|
||||||
@@ -609,7 +511,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
|||||||
var err error
|
var err error
|
||||||
var filePath string
|
var filePath string
|
||||||
|
|
||||||
// Try .local first
|
|
||||||
if content, err = os.ReadFile(localPath); err == nil {
|
if content, err = os.ReadFile(localPath); err == nil {
|
||||||
filePath = localPath
|
filePath = localPath
|
||||||
config.DebugLog("Loading included filter file from .local: %s", filePath)
|
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)
|
config.DebugLog("Loading included filter file from .conf: %s", filePath)
|
||||||
} else {
|
} else {
|
||||||
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err)
|
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)
|
contentStr := string(content)
|
||||||
// Remove variables from included file that are defined in main filter (main filter takes precedence)
|
|
||||||
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
|
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
|
||||||
combined.WriteString("\n")
|
combined.WriteString("\n")
|
||||||
combined.WriteString(cleanedContent)
|
combined.WriteString(cleanedContent)
|
||||||
@@ -634,10 +533,10 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
|
|||||||
return combined.String(), nil
|
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
|
// Filter Testing
|
||||||
// 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
|
|
||||||
func TestFilterLocal(filterName string, logLines []string, filterContent string) (string, string, error) {
|
func TestFilterLocal(filterName string, logLines []string, filterContent string) (string, string, error) {
|
||||||
cleaned := normalizeLogLines(logLines)
|
cleaned := normalizeLogLines(logLines)
|
||||||
if len(cleaned) == 0 {
|
if len(cleaned) == 0 {
|
||||||
@@ -657,7 +556,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
|
|||||||
defer os.Remove(tempFilterFile.Name())
|
defer os.Remove(tempFilterFile.Name())
|
||||||
defer tempFilterFile.Close()
|
defer tempFilterFile.Close()
|
||||||
|
|
||||||
// Resolve filter includes to get complete filter content with all dependencies
|
|
||||||
filterDPath := "/etc/fail2ban/filter.d"
|
filterDPath := "/etc/fail2ban/filter.d"
|
||||||
contentToWrite, err := resolveFilterIncludes(filterContent, filterDPath, filterName)
|
contentToWrite, err := resolveFilterIncludes(filterContent, filterDPath, filterName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -665,7 +563,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
|
|||||||
contentToWrite = filterContent
|
contentToWrite = filterContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure it ends with a newline for proper parsing
|
|
||||||
if !strings.HasSuffix(contentToWrite, "\n") {
|
if !strings.HasSuffix(contentToWrite, "\n") {
|
||||||
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)
|
return "", "", fmt.Errorf("failed to write temporary filter file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the file is synced to disk
|
|
||||||
if err := tempFilterFile.Sync(); err != nil {
|
if err := tempFilterFile.Sync(); err != nil {
|
||||||
return "", "", fmt.Errorf("failed to sync temporary filter file: %w", err)
|
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()
|
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)
|
config.DebugLog("TestFilterLocal: using custom filter content from temporary file: %s (size: %d bytes, includes resolved: %v)", filterPath, len(contentToWrite), err == nil)
|
||||||
} else {
|
} else {
|
||||||
// Try .local first, then fallback to .conf
|
|
||||||
localPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".local")
|
localPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".local")
|
||||||
confPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf")
|
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 os.Remove(tmpFile.Name())
|
||||||
defer tmpFile.Close()
|
defer tmpFile.Close()
|
||||||
|
|
||||||
// Write all log lines to the temp file
|
|
||||||
for _, logLine := range cleaned {
|
for _, logLine := range cleaned {
|
||||||
if _, err := tmpFile.WriteString(logLine + "\n"); err != nil {
|
if _, err := tmpFile.WriteString(logLine + "\n"); err != nil {
|
||||||
return "", filterPath, fmt.Errorf("failed to write to temporary log file: %w", err)
|
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()
|
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)
|
cmd := exec.Command("fail2ban-regex", tmpFile.Name(), filterPath)
|
||||||
out, _ := cmd.CombinedOutput()
|
out, _ := cmd.CombinedOutput()
|
||||||
output := string(out)
|
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
|
return output, filterPath, nil
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -83,7 +83,7 @@ func (o *opnsenseIntegration) callAPI(req Request, action, ip string) error {
|
|||||||
}
|
}
|
||||||
if cfg.SkipTLSVerify {
|
if cfg.SkipTLSVerify {
|
||||||
httpClient.Transport = &http.Transport{
|
httpClient.Transport = &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 - user controlled
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string,
|
|||||||
}
|
}
|
||||||
if cfg.SkipTLSVerify {
|
if cfg.SkipTLSVerify {
|
||||||
httpClient.Transport = &http.Transport{
|
httpClient.Transport = &http.Transport{
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 - user controlled
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Database Connection
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
var (
|
var (
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
initOnce sync.Once
|
initOnce sync.Once
|
||||||
@@ -22,6 +26,10 @@ var (
|
|||||||
defaultPath = "fail2ban-ui.db"
|
defaultPath = "fail2ban-ui.db"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Conversion Helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func boolToInt(b bool) int {
|
func boolToInt(b bool) int {
|
||||||
if b {
|
if b {
|
||||||
return 1
|
return 1
|
||||||
@@ -47,22 +55,21 @@ func intFromNull(ni sql.NullInt64) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Types
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
type AppSettingsRecord struct {
|
type AppSettingsRecord struct {
|
||||||
// Basic app settings
|
Language string
|
||||||
Language string
|
Port int
|
||||||
Port int
|
Debug bool
|
||||||
Debug bool
|
RestartNeeded bool
|
||||||
RestartNeeded bool
|
CallbackURL string
|
||||||
// Callback settings
|
CallbackSecret string
|
||||||
CallbackURL string
|
AlertCountriesJSON string
|
||||||
CallbackSecret string
|
EmailAlertsForBans bool
|
||||||
// Alert settings
|
EmailAlertsForUnbans bool
|
||||||
AlertCountriesJSON string
|
ConsoleOutput bool
|
||||||
EmailAlertsForBans bool
|
|
||||||
EmailAlertsForUnbans bool
|
|
||||||
// Console output settings
|
|
||||||
ConsoleOutput bool
|
|
||||||
// SMTP settings
|
|
||||||
SMTPHost string
|
SMTPHost string
|
||||||
SMTPPort int
|
SMTPPort int
|
||||||
SMTPUsername string
|
SMTPUsername string
|
||||||
@@ -71,23 +78,21 @@ type AppSettingsRecord struct {
|
|||||||
SMTPUseTLS bool
|
SMTPUseTLS bool
|
||||||
SMTPInsecureSkipVerify bool
|
SMTPInsecureSkipVerify bool
|
||||||
SMTPAuthMethod string
|
SMTPAuthMethod string
|
||||||
// Fail2Ban DEFAULT settings
|
BantimeIncrement bool
|
||||||
BantimeIncrement bool
|
DefaultJailEnable bool
|
||||||
DefaultJailEnable bool
|
IgnoreIP string
|
||||||
IgnoreIP string // Stored as space-separated string, converted to array in AppSettings
|
Bantime string
|
||||||
Bantime string
|
Findtime string
|
||||||
Findtime string
|
MaxRetry int
|
||||||
MaxRetry int
|
DestEmail string
|
||||||
DestEmail string
|
Banaction string
|
||||||
Banaction string
|
BanactionAllports string
|
||||||
BanactionAllports string
|
Chain string
|
||||||
Chain string
|
BantimeRndtime string
|
||||||
BantimeRndtime string
|
AdvancedActionsJSON string
|
||||||
// Advanced features
|
GeoIPProvider string
|
||||||
AdvancedActionsJSON string
|
GeoIPDatabasePath string
|
||||||
GeoIPProvider string
|
MaxLogLines int
|
||||||
GeoIPDatabasePath string
|
|
||||||
MaxLogLines int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerRecord struct {
|
type ServerRecord struct {
|
||||||
@@ -111,7 +116,6 @@ type ServerRecord struct {
|
|||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// BanEventRecord represents a single ban or unban event stored in the internal database.
|
|
||||||
type BanEventRecord struct {
|
type BanEventRecord struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
ServerID string `json:"serverId"`
|
ServerID string `json:"serverId"`
|
||||||
@@ -128,7 +132,6 @@ type BanEventRecord struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecurringIPStat represents aggregation info for repeatedly banned IPs.
|
|
||||||
type RecurringIPStat struct {
|
type RecurringIPStat struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
@@ -148,7 +151,7 @@ type PermanentBlockRecord struct {
|
|||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the internal storage. Safe to call multiple times.
|
// Initialize the database.
|
||||||
func Init(dbPath string) error {
|
func Init(dbPath string) error {
|
||||||
initOnce.Do(func() {
|
initOnce.Do(func() {
|
||||||
if dbPath == "" {
|
if dbPath == "" {
|
||||||
@@ -159,9 +162,7 @@ func Init(dbPath string) error {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure .ssh directory exists (for SSH key storage)
|
|
||||||
if err := ensureSSHDirectory(); err != nil {
|
if err := ensureSSHDirectory(); err != nil {
|
||||||
// Log but don't fail - .ssh directory creation is not critical
|
|
||||||
log.Printf("Warning: failed to ensure .ssh directory: %v", err)
|
log.Printf("Warning: failed to ensure .ssh directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ func Init(dbPath string) error {
|
|||||||
return initErr
|
return initErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the underlying database if it has been initialised.
|
// Close the database.
|
||||||
func Close() error {
|
func Close() error {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -190,6 +191,7 @@ func Close() error {
|
|||||||
return db.Close()
|
return db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the app settings.
|
||||||
func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
|
func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return AppSettingsRecord{}, false, errors.New("storage not initialised")
|
return AppSettingsRecord{}, false, errors.New("storage not initialised")
|
||||||
@@ -215,19 +217,15 @@ WHERE id = 1`)
|
|||||||
}
|
}
|
||||||
|
|
||||||
rec := AppSettingsRecord{
|
rec := AppSettingsRecord{
|
||||||
// Basic app settings
|
Language: stringFromNull(lang),
|
||||||
Language: stringFromNull(lang),
|
Port: intFromNull(port),
|
||||||
Port: intFromNull(port),
|
Debug: intToBool(intFromNull(debug)),
|
||||||
Debug: intToBool(intFromNull(debug)),
|
RestartNeeded: intToBool(intFromNull(restartNeeded)),
|
||||||
RestartNeeded: intToBool(intFromNull(restartNeeded)),
|
CallbackURL: stringFromNull(callback),
|
||||||
// Callback settings
|
CallbackSecret: stringFromNull(callbackSecret),
|
||||||
CallbackURL: stringFromNull(callback),
|
AlertCountriesJSON: stringFromNull(alerts),
|
||||||
CallbackSecret: stringFromNull(callbackSecret),
|
EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)),
|
||||||
// Alert settings
|
EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)),
|
||||||
AlertCountriesJSON: stringFromNull(alerts),
|
|
||||||
EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)),
|
|
||||||
EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)),
|
|
||||||
// SMTP settings
|
|
||||||
SMTPHost: stringFromNull(smtpHost),
|
SMTPHost: stringFromNull(smtpHost),
|
||||||
SMTPPort: intFromNull(smtpPort),
|
SMTPPort: intFromNull(smtpPort),
|
||||||
SMTPUsername: stringFromNull(smtpUser),
|
SMTPUsername: stringFromNull(smtpUser),
|
||||||
@@ -236,25 +234,22 @@ WHERE id = 1`)
|
|||||||
SMTPUseTLS: intToBool(intFromNull(smtpTLS)),
|
SMTPUseTLS: intToBool(intFromNull(smtpTLS)),
|
||||||
SMTPInsecureSkipVerify: intToBool(intFromNull(smtpInsecureSkipVerify)),
|
SMTPInsecureSkipVerify: intToBool(intFromNull(smtpInsecureSkipVerify)),
|
||||||
SMTPAuthMethod: stringFromNull(smtpAuthMethod),
|
SMTPAuthMethod: stringFromNull(smtpAuthMethod),
|
||||||
// Fail2Ban DEFAULT settings
|
BantimeIncrement: intToBool(intFromNull(bantimeInc)),
|
||||||
BantimeIncrement: intToBool(intFromNull(bantimeInc)),
|
DefaultJailEnable: intToBool(intFromNull(defaultJailEn)),
|
||||||
DefaultJailEnable: intToBool(intFromNull(defaultJailEn)),
|
IgnoreIP: stringFromNull(ignoreIP),
|
||||||
IgnoreIP: stringFromNull(ignoreIP),
|
Bantime: stringFromNull(bantime),
|
||||||
Bantime: stringFromNull(bantime),
|
Findtime: stringFromNull(findtime),
|
||||||
Findtime: stringFromNull(findtime),
|
MaxRetry: intFromNull(maxretry),
|
||||||
MaxRetry: intFromNull(maxretry),
|
DestEmail: stringFromNull(destemail),
|
||||||
DestEmail: stringFromNull(destemail),
|
Banaction: stringFromNull(banaction),
|
||||||
Banaction: stringFromNull(banaction),
|
BanactionAllports: stringFromNull(banactionAllports),
|
||||||
BanactionAllports: stringFromNull(banactionAllports),
|
Chain: stringFromNull(chain),
|
||||||
Chain: stringFromNull(chain),
|
BantimeRndtime: stringFromNull(bantimeRndtime),
|
||||||
BantimeRndtime: stringFromNull(bantimeRndtime),
|
AdvancedActionsJSON: stringFromNull(advancedActions),
|
||||||
// Advanced features
|
GeoIPProvider: stringFromNull(geoipProvider),
|
||||||
AdvancedActionsJSON: stringFromNull(advancedActions),
|
GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
|
||||||
GeoIPProvider: stringFromNull(geoipProvider),
|
MaxLogLines: intFromNull(maxLogLines),
|
||||||
GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
|
ConsoleOutput: intToBool(intFromNull(consoleOutput)),
|
||||||
MaxLogLines: intFromNull(maxLogLines),
|
|
||||||
// Console output settings
|
|
||||||
ConsoleOutput: intToBool(intFromNull(consoleOutput)),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rec, true, nil
|
return rec, true, nil
|
||||||
@@ -339,6 +334,10 @@ INSERT INTO app_settings (
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Servers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func ListServers(ctx context.Context) ([]ServerRecord, error) {
|
func ListServers(ctx context.Context) ([]ServerRecord, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, errors.New("storage not initialised")
|
return nil, errors.New("storage not initialised")
|
||||||
@@ -493,7 +492,11 @@ func DeleteServer(ctx context.Context, id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordBanEvent stores a ban event in the database.
|
// =========================================================================
|
||||||
|
// Ban Events Records
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Stores a ban/unban event into the database.
|
||||||
func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
|
func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return errors.New("storage not initialised")
|
return errors.New("storage not initialised")
|
||||||
@@ -509,8 +512,7 @@ func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
|
|||||||
if record.OccurredAt.IsZero() {
|
if record.OccurredAt.IsZero() {
|
||||||
record.OccurredAt = now
|
record.OccurredAt = now
|
||||||
}
|
}
|
||||||
|
// If the event type is not set, we set it to "ban" by default.
|
||||||
// Default to 'ban' if event type is not set
|
|
||||||
eventType := record.EventType
|
eventType := record.EventType
|
||||||
if eventType == "" {
|
if eventType == "" {
|
||||||
eventType = "ban"
|
eventType = "ban"
|
||||||
@@ -544,7 +546,7 @@ INSERT INTO ban_events (
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListBanEvents returns ban events ordered by creation date descending.
|
// Returns ban events ordered by creation date descending.
|
||||||
func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) {
|
func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, errors.New("storage not initialised")
|
return nil, errors.New("storage not initialised")
|
||||||
@@ -599,7 +601,6 @@ WHERE 1=1`
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Default to 'ban' if event_type is NULL (for backward compatibility)
|
|
||||||
if eventType.Valid {
|
if eventType.Valid {
|
||||||
rec.EventType = eventType.String
|
rec.EventType = eventType.String
|
||||||
} else {
|
} else {
|
||||||
@@ -618,7 +619,7 @@ const (
|
|||||||
MaxBanEventsOffset = 1000
|
MaxBanEventsOffset = 1000
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListBanEventsFiltered returns ban events with optional search and country filter, ordered by occurred_at DESC.
|
// Returns ban events with optional search and country filter, ordered by occurred_at DESC.
|
||||||
// search is applied as LIKE %search% on ip, jail, server_name, hostname, country.
|
// search is applied as LIKE %search% on ip, jail, server_name, hostname, country.
|
||||||
// limit is capped at MaxBanEventsLimit; offset is capped at MaxBanEventsOffset.
|
// limit is capped at MaxBanEventsLimit; offset is capped at MaxBanEventsOffset.
|
||||||
func ListBanEventsFiltered(ctx context.Context, serverID string, limit, offset int, since time.Time, search, country string) ([]BanEventRecord, error) {
|
func ListBanEventsFiltered(ctx context.Context, serverID string, limit, offset int, since time.Time, search, country string) ([]BanEventRecord, error) {
|
||||||
@@ -703,7 +704,7 @@ WHERE 1=1`
|
|||||||
return results, rows.Err()
|
return results, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountBanEventsFiltered returns the total count of ban events matching the same filters as ListBanEventsFiltered.
|
// Returns the total count of ban events matching the same filters as ListBanEventsFiltered.
|
||||||
func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Time, search, country string) (int64, error) {
|
func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Time, search, country string) (int64, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return 0, errors.New("storage not initialised")
|
return 0, errors.New("storage not initialised")
|
||||||
@@ -744,7 +745,7 @@ func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Tim
|
|||||||
return total, nil
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountBanEventsByServer returns simple aggregation per server.
|
// Returns simple aggregation per server.
|
||||||
func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) {
|
func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, errors.New("storage not initialised")
|
return nil, errors.New("storage not initialised")
|
||||||
@@ -782,7 +783,7 @@ WHERE 1=1`
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountBanEvents returns total number of ban events optionally filtered by time and server.
|
// Returns total number of ban events optionally filtered by time and server.
|
||||||
func CountBanEvents(ctx context.Context, since time.Time, serverID string) (int64, error) {
|
func CountBanEvents(ctx context.Context, since time.Time, serverID string) (int64, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return 0, errors.New("storage not initialised")
|
return 0, errors.New("storage not initialised")
|
||||||
@@ -811,7 +812,7 @@ WHERE 1=1`
|
|||||||
return total, nil
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountBanEventsByIP returns total number of ban events for a specific IP and optional server.
|
// Returns total number of ban events for a specific IP and optional server.
|
||||||
func CountBanEventsByIP(ctx context.Context, ip, serverID string) (int64, error) {
|
func CountBanEventsByIP(ctx context.Context, ip, serverID string) (int64, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return 0, errors.New("storage not initialised")
|
return 0, errors.New("storage not initialised")
|
||||||
@@ -838,7 +839,7 @@ WHERE ip = ? AND (event_type = 'ban' OR event_type IS NULL)`
|
|||||||
return total, nil
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountBanEventsByCountry returns aggregation per country code, optionally filtered by server.
|
// Returns aggregation per country code, optionally filtered by server.
|
||||||
func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID string) (map[string]int64, error) {
|
func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID string) (map[string]int64, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, errors.New("storage not initialised")
|
return nil, errors.New("storage not initialised")
|
||||||
@@ -881,7 +882,11 @@ WHERE 1=1`
|
|||||||
return result, rows.Err()
|
return result, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRecurringIPStats returns IPs that have been banned at least minCount times, optionally filtered by server.
|
// =========================================================================
|
||||||
|
// Recurring IP Statistics
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Returns IPs that have been banned at least minCount times, optionally filtered by server.
|
||||||
func ListRecurringIPStats(ctx context.Context, since time.Time, minCount, limit int, serverID string) ([]RecurringIPStat, error) {
|
func ListRecurringIPStats(ctx context.Context, since time.Time, minCount, limit int, serverID string) ([]RecurringIPStat, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, errors.New("storage not initialised")
|
return nil, errors.New("storage not initialised")
|
||||||
@@ -927,20 +932,14 @@ LIMIT ?`
|
|||||||
var results []RecurringIPStat
|
var results []RecurringIPStat
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var stat RecurringIPStat
|
var stat RecurringIPStat
|
||||||
// First, scan as string to see what format SQLite returns
|
|
||||||
// Then parse it properly
|
|
||||||
var lastSeenStr sql.NullString
|
var lastSeenStr sql.NullString
|
||||||
if err := rows.Scan(&stat.IP, &stat.Country, &stat.Count, &lastSeenStr); err != nil {
|
if err := rows.Scan(&stat.IP, &stat.Country, &stat.Count, &lastSeenStr); err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan row: %w", err)
|
return nil, fmt.Errorf("failed to scan row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if lastSeenStr.Valid && lastSeenStr.String != "" {
|
if lastSeenStr.Valid && lastSeenStr.String != "" {
|
||||||
// Try to parse the datetime string
|
|
||||||
// SQLite stores DATETIME as TEXT, format depends on how it was inserted
|
|
||||||
// The modernc.org/sqlite driver returns MAX(occurred_at) in format:
|
|
||||||
// "2006-01-02 15:04:05.999999999 -0700 MST" (e.g., "2025-11-22 12:17:24.697430041 +0000 UTC")
|
|
||||||
formats := []string{
|
formats := []string{
|
||||||
"2006-01-02 15:04:05.999999999 -0700 MST", // Format returned by MAX() in SQLite
|
"2006-01-02 15:04:05.999999999 -0700 MST",
|
||||||
time.RFC3339Nano,
|
time.RFC3339Nano,
|
||||||
time.RFC3339,
|
time.RFC3339,
|
||||||
"2006-01-02 15:04:05.999999999+00:00",
|
"2006-01-02 15:04:05.999999999+00:00",
|
||||||
@@ -952,20 +951,18 @@ LIMIT ?`
|
|||||||
"2006-01-02T15:04:05.999999999",
|
"2006-01-02T15:04:05.999999999",
|
||||||
"2006-01-02T15:04:05",
|
"2006-01-02T15:04:05",
|
||||||
}
|
}
|
||||||
parsed := time.Time{} // zero time
|
parsed := time.Time{}
|
||||||
for _, format := range formats {
|
for _, format := range formats {
|
||||||
if t, parseErr := time.Parse(format, lastSeenStr.String); parseErr == nil {
|
if t, parseErr := time.Parse(format, lastSeenStr.String); parseErr == nil {
|
||||||
parsed = t.UTC()
|
parsed = t.UTC()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If still zero, log the actual string for debugging
|
|
||||||
if parsed.IsZero() {
|
if parsed.IsZero() {
|
||||||
log.Printf("ERROR: Could not parse lastSeen datetime '%s' (length: %d) for IP %s. All format attempts failed.", lastSeenStr.String, len(lastSeenStr.String), stat.IP)
|
log.Printf("ERROR: Could not parse lastSeen datetime '%s' (length: %d) for IP %s. All format attempts failed.", lastSeenStr.String, len(lastSeenStr.String), stat.IP)
|
||||||
}
|
}
|
||||||
stat.LastSeen = parsed
|
stat.LastSeen = parsed
|
||||||
} else {
|
} else {
|
||||||
// Log when lastSeen is NULL or empty
|
|
||||||
log.Printf("WARNING: lastSeen is NULL or empty for IP %s", stat.IP)
|
log.Printf("WARNING: lastSeen is NULL or empty for IP %s", stat.IP)
|
||||||
}
|
}
|
||||||
results = append(results, stat)
|
results = append(results, stat)
|
||||||
@@ -974,6 +971,10 @@ LIMIT ?`
|
|||||||
return results, rows.Err()
|
return results, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Schema Management
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
func ensureSchema(ctx context.Context) error {
|
func ensureSchema(ctx context.Context) error {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return errors.New("storage not initialised")
|
return errors.New("storage not initialised")
|
||||||
@@ -1080,25 +1081,11 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status);
|
|||||||
if _, err := db.ExecContext(ctx, createTable); err != nil {
|
if _, err := db.ExecContext(ctx, createTable); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Database migrations for feature releases
|
|
||||||
// For this version, we start with a fresh schema. Future feature releases
|
|
||||||
// that require database schema changes should add migration logic here.
|
|
||||||
// Example migration pattern:
|
|
||||||
// if _, err := db.ExecContext(ctx, `ALTER TABLE table_name ADD COLUMN new_column TYPE DEFAULT value`); err != nil {
|
|
||||||
// if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Migration: Add console_output column if it doesn't exist
|
|
||||||
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN console_output INTEGER DEFAULT 0`); err != nil {
|
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN console_output INTEGER DEFAULT 0`); err != nil {
|
||||||
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migration: Add new SMTP columns if they don't exist
|
|
||||||
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN smtp_insecure_skip_verify INTEGER DEFAULT 0`); err != nil {
|
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN smtp_insecure_skip_verify INTEGER DEFAULT 0`); err != nil {
|
||||||
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
|
||||||
return err
|
return err
|
||||||
@@ -1119,8 +1106,6 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status);
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = strings.Contains // Keep strings import for migration example above
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1135,16 +1120,12 @@ func ensureDirectory(path string) error {
|
|||||||
return os.MkdirAll(dir, 0o755)
|
return os.MkdirAll(dir, 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureSSHDirectory ensures the .ssh directory exists for SSH key storage.
|
// Ensures .ssh exists for SSH key storage (/config/.ssh in container, ~/.ssh on host).
|
||||||
// In containers, this is /config/.ssh, on the host it's ~/.ssh
|
|
||||||
func ensureSSHDirectory() error {
|
func ensureSSHDirectory() error {
|
||||||
var sshDir string
|
var sshDir string
|
||||||
// Check if running inside a container
|
|
||||||
if _, container := os.LookupEnv("CONTAINER"); container {
|
if _, container := os.LookupEnv("CONTAINER"); container {
|
||||||
// In container, use /config/.ssh
|
|
||||||
sshDir = "/config/.ssh"
|
sshDir = "/config/.ssh"
|
||||||
} else {
|
} else {
|
||||||
// On host, use ~/.ssh
|
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get user home directory: %w", err)
|
return fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
@@ -1152,7 +1133,6 @@ func ensureSSHDirectory() error {
|
|||||||
sshDir = filepath.Join(home, ".ssh")
|
sshDir = filepath.Join(home, ".ssh")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create directory with proper permissions (0700 for .ssh)
|
|
||||||
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
if err := os.MkdirAll(sshDir, 0o700); err != nil {
|
||||||
return fmt.Errorf("failed to create .ssh directory at %s: %w", sshDir, err)
|
return fmt.Errorf("failed to create .ssh directory at %s: %w", sshDir, err)
|
||||||
}
|
}
|
||||||
@@ -1160,7 +1140,11 @@ func ensureSSHDirectory() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertPermanentBlock records or updates a permanent block entry.
|
// =========================================================================
|
||||||
|
// Permanent Blocks Records
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
// Stores or updates a permanent block entry.
|
||||||
func UpsertPermanentBlock(ctx context.Context, rec PermanentBlockRecord) error {
|
func UpsertPermanentBlock(ctx context.Context, rec PermanentBlockRecord) error {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return errors.New("storage not initialised")
|
return errors.New("storage not initialised")
|
||||||
@@ -1200,7 +1184,7 @@ ON CONFLICT(ip, integration) DO UPDATE SET
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPermanentBlock retrieves a permanent block entry.
|
// Returns a permanent block entry.
|
||||||
func GetPermanentBlock(ctx context.Context, ip, integration string) (PermanentBlockRecord, bool, error) {
|
func GetPermanentBlock(ctx context.Context, ip, integration string) (PermanentBlockRecord, bool, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return PermanentBlockRecord{}, false, errors.New("storage not initialised")
|
return PermanentBlockRecord{}, false, errors.New("storage not initialised")
|
||||||
@@ -1235,7 +1219,7 @@ WHERE ip = ? AND integration = ?`, ip, integration)
|
|||||||
return rec, true, nil
|
return rec, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPermanentBlocks returns recent permanent block entries.
|
// Returns recent permanent block entries.
|
||||||
func ListPermanentBlocks(ctx context.Context, limit int) ([]PermanentBlockRecord, error) {
|
func ListPermanentBlocks(ctx context.Context, limit int) ([]PermanentBlockRecord, error) {
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, errors.New("storage not initialised")
|
return nil, errors.New("storage not initialised")
|
||||||
@@ -1276,7 +1260,7 @@ LIMIT ?`, limit)
|
|||||||
return records, rows.Err()
|
return records, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsPermanentBlockActive returns true when IP is currently blocked by integration.
|
// Returns true when IP is currently blocked by integration.
|
||||||
func IsPermanentBlockActive(ctx context.Context, ip, integration string) (bool, error) {
|
func IsPermanentBlockActive(ctx context.Context, ip, integration string) (bool, error) {
|
||||||
rec, found, err := GetPermanentBlock(ctx, ip, integration)
|
rec, found, err := GetPermanentBlock(ctx, ip, integration)
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
<!--
|
<!--
|
||||||
Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
||||||
|
|
||||||
Copyright (C) 2025 Swissmakers GmbH
|
Copyright (C) 2026 Swissmakers GmbH
|
||||||
|
|
||||||
Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
||||||
You may not use this file except in compliance with the License.
|
You may not use this file except in compliance with the License.
|
||||||
@@ -22,21 +22,14 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
<title data-i18n="page.title">Fail2ban UI Dashboard</title>
|
<title data-i18n="page.title">Fail2ban UI Dashboard</title>
|
||||||
<!-- Prism.js for syntax highlighting -->
|
|
||||||
<link rel="stylesheet" href="/static/vendor/prism/prism-tomorrow.min.css?v={{.version}}" />
|
<link rel="stylesheet" href="/static/vendor/prism/prism-tomorrow.min.css?v={{.version}}" />
|
||||||
<script src="/static/vendor/prism/prism-core.min.js?v={{.version}}"></script>
|
<script src="/static/vendor/prism/prism-core.min.js?v={{.version}}"></script>
|
||||||
<script src="/static/vendor/prism/prism-autoloader.min.js?v={{.version}}"></script>
|
<script src="/static/vendor/prism/prism-autoloader.min.js?v={{.version}}"></script>
|
||||||
<!-- Tailwind CSS -->
|
|
||||||
<link rel="stylesheet" href="/static/tailwind.css?v={{.version}}">
|
<link rel="stylesheet" href="/static/tailwind.css?v={{.version}}">
|
||||||
<!-- Font Awesome for icons -->
|
|
||||||
<link rel="stylesheet" href="/static/vendor/fontawesome/all.min.css?v={{.version}}">
|
<link rel="stylesheet" href="/static/vendor/fontawesome/all.min.css?v={{.version}}">
|
||||||
<!-- Select2 CSS -->
|
|
||||||
<link rel="stylesheet" href="/static/vendor/select2/select2.min.css?v={{.version}}" />
|
<link rel="stylesheet" href="/static/vendor/select2/select2.min.css?v={{.version}}" />
|
||||||
<!-- Fail2ban UI CSS -->
|
|
||||||
<link rel="stylesheet" href="/static/fail2ban-ui.css?v={{.version}}">
|
<link rel="stylesheet" href="/static/fail2ban-ui.css?v={{.version}}">
|
||||||
<!-- LOTR Theme CSS (loaded conditionally) -->
|
|
||||||
<link rel="stylesheet" href="/static/lotr.css?v={{.version}}" id="lotr-css" disabled>
|
<link rel="stylesheet" href="/static/lotr.css?v={{.version}}" id="lotr-css" disabled>
|
||||||
<!-- Google Fonts for LOTR theme -->
|
|
||||||
<link rel="stylesheet" href="/static/vendor/fonts/google-fonts.css?v={{.version}}">
|
<link rel="stylesheet" href="/static/vendor/fonts/google-fonts.css?v={{.version}}">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -80,7 +73,6 @@
|
|||||||
<div id="clockDisplay" class="ml-4 text-sm font-mono">
|
<div id="clockDisplay" class="ml-4 text-sm font-mono">
|
||||||
<span id="clockTime">--:--:--</span>
|
<span id="clockTime">--:--:--</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- User info and logout (shown when authenticated) -->
|
|
||||||
<div id="userInfoContainer" class="hidden ml-4 flex items-center gap-3 border-l border-blue-500 pl-4">
|
<div id="userInfoContainer" class="hidden ml-4 flex items-center gap-3 border-l border-blue-500 pl-4">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button id="userMenuButton" onclick="toggleUserMenu()" class="flex items-center gap-2 px-3 py-2 rounded text-sm font-medium hover:bg-blue-700 transition-colors focus:outline-none">
|
<button id="userMenuButton" onclick="toggleUserMenu()" class="flex items-center gap-2 px-3 py-2 rounded text-sm font-medium hover:bg-blue-700 transition-colors focus:outline-none">
|
||||||
@@ -89,7 +81,6 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<!-- User dropdown menu -->
|
|
||||||
<div id="userMenuDropdown" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
|
<div id="userMenuDropdown" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
|
||||||
<div class="px-4 py-2 border-b border-gray-200">
|
<div class="px-4 py-2 border-b border-gray-200">
|
||||||
<div class="text-sm font-medium text-gray-900" id="userMenuDisplayName"></div>
|
<div class="text-sm font-medium text-gray-900" id="userMenuDisplayName"></div>
|
||||||
@@ -116,7 +107,6 @@
|
|||||||
<a href="#" onclick="showSection('dashboardSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.dashboard">Dashboard</a>
|
<a href="#" onclick="showSection('dashboardSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.dashboard">Dashboard</a>
|
||||||
<a href="#" onclick="showSection('filterSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.filter_debug">Filter Debug</a>
|
<a href="#" onclick="showSection('filterSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.filter_debug">Filter Debug</a>
|
||||||
<a href="#" onclick="showSection('settingsSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.settings">Settings</a>
|
<a href="#" onclick="showSection('settingsSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.settings">Settings</a>
|
||||||
<!-- User info and logout in mobile menu (shown when authenticated) -->
|
|
||||||
<div id="mobileUserInfoContainer" class="hidden border-t border-blue-500 mt-2 pt-2">
|
<div id="mobileUserInfoContainer" class="hidden border-t border-blue-500 mt-2 pt-2">
|
||||||
<div class="px-3 py-2">
|
<div class="px-3 py-2">
|
||||||
<div class="text-sm font-medium" id="mobileUserDisplayName"></div>
|
<div class="text-sm font-medium" id="mobileUserDisplayName"></div>
|
||||||
@@ -126,15 +116,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Mobile menu END -->
|
||||||
</nav>
|
</nav>
|
||||||
<!-- ************************ Navigation END *************************** -->
|
<!-- ************************ Navigation END *************************** -->
|
||||||
|
|
||||||
<!-- Login Page (hidden by default, shown only when OIDC enabled and not authenticated) -->
|
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
|
<!-- Login Page START -->
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
<div id="loginPage" class="hidden min-h-screen flex items-center justify-center bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
<div id="loginPage" class="hidden min-h-screen flex items-center justify-center bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-md w-full">
|
<div class="max-w-md w-full">
|
||||||
<!-- Login Card -->
|
|
||||||
<div class="bg-white rounded-lg shadow-lg p-8 border border-gray-200">
|
<div class="bg-white rounded-lg shadow-lg p-8 border border-gray-200">
|
||||||
<!-- Logo and Title -->
|
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-blue-600 mb-4">
|
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-blue-600 mb-4">
|
||||||
<svg class="h-10 w-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-10 w-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -144,8 +136,6 @@
|
|||||||
<h2 class="text-3xl font-bold text-gray-900 mb-2" data-i18n="auth.login_title">Sign in to Fail2ban UI</h2>
|
<h2 class="text-3xl font-bold text-gray-900 mb-2" data-i18n="auth.login_title">Sign in to Fail2ban UI</h2>
|
||||||
<p class="text-sm text-gray-600" data-i18n="auth.login_description">Please authenticate to access the management interface</p>
|
<p class="text-sm text-gray-600" data-i18n="auth.login_description">Please authenticate to access the management interface</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
|
||||||
<div id="loginError" class="hidden bg-red-50 border-l-4 border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
<div id="loginError" class="hidden bg-red-50 border-l-4 border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -158,8 +148,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Button -->
|
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<button type="button" onclick="handleLogin()" class="w-full flex justify-center items-center py-3 px-4 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
|
<button type="button" onclick="handleLogin()" class="w-full flex justify-center items-center py-3 px-4 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
|
||||||
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -167,8 +155,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span data-i18n="auth.login_button">Sign in with OIDC</span>
|
<span data-i18n="auth.login_button">Sign in with OIDC</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div id="loginLoading" class="hidden text-center py-4">
|
<div id="loginLoading" class="hidden text-center py-4">
|
||||||
<div class="inline-flex items-center">
|
<div class="inline-flex items-center">
|
||||||
<div class="h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mr-3"></div>
|
<div class="h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mr-3"></div>
|
||||||
@@ -176,8 +162,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer Info -->
|
|
||||||
<div class="pt-6 border-t border-gray-200">
|
<div class="pt-6 border-t border-gray-200">
|
||||||
<p class="text-xs text-center text-gray-500">
|
<p class="text-xs text-center text-gray-500">
|
||||||
Secure authentication via OpenID Connect
|
Secure authentication via OpenID Connect
|
||||||
@@ -186,12 +170,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- ********************** Login Page END ******************************* -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<main id="mainContent" class="hidden max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<main id="mainContent" class="hidden max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<!-- ******************************************************************* -->
|
|
||||||
<!-- Dashboard Page START -->
|
<!-- ******************************************************************* -->
|
||||||
<!-- ******************************************************************* -->
|
<!-- Dashboard Section START -->
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
<div id="dashboardSection">
|
<div id="dashboardSection">
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6">
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -220,15 +206,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="dashboard"></div>
|
||||||
<div id="dashboard"></div> <!-- Dynamic content from the API -->
|
|
||||||
</div>
|
</div>
|
||||||
<!-- ********************** Dashboard Page END ************************* -->
|
<!-- ********************** Dashboard Section END ********************** -->
|
||||||
|
|
||||||
|
|
||||||
<!-- ******************************************************************* -->
|
<!-- ******************************************************************* -->
|
||||||
<!-- Filter-Debug Page START -->
|
<!-- Filter-Debug Section START -->
|
||||||
<!-- ******************************************************************* -->
|
<!-- ******************************************************************* -->
|
||||||
<div id="filterSection" class="hidden">
|
<div id="filterSection" class="hidden">
|
||||||
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="filter_debug.title">Filter Debug</h2>
|
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="filter_debug.title">Filter Debug</h2>
|
||||||
|
|
||||||
@@ -246,8 +231,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Textarea for filter content (readonly by default, editable with Edit button) -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<label for="filterContentTextarea" class="block text-sm font-medium text-gray-700" data-i18n="filter_debug.filter_content">Filter Content</label>
|
<label for="filterContentTextarea" class="block text-sm font-medium text-gray-700" data-i18n="filter_debug.filter_content">Filter Content</label>
|
||||||
@@ -258,8 +241,6 @@
|
|||||||
<textarea id="filterContentTextarea" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40 font-mono text-sm bg-gray-50"
|
<textarea id="filterContentTextarea" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40 font-mono text-sm bg-gray-50"
|
||||||
placeholder="Filter content will appear here when a filter is selected..." readonly></textarea>
|
placeholder="Filter content will appear here when a filter is selected..." readonly></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Textarea for log lines to test -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="logLinesTextarea" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="filter_debug.log_lines">Log Lines</label>
|
<label for="logLinesTextarea" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="filter_debug.log_lines">Log Lines</label>
|
||||||
<textarea id="logLinesTextarea" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40"
|
<textarea id="logLinesTextarea" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40"
|
||||||
@@ -271,21 +252,19 @@
|
|||||||
|
|
||||||
<div id="testResults" class="hidden bg-gray-900 rounded-lg shadow p-6 text-white font-mono text-sm"></div>
|
<div id="testResults" class="hidden bg-gray-900 rounded-lg shadow p-6 text-white font-mono text-sm"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- ********************* Filter-Debug Page END *********************** -->
|
<!-- ********************* Filter-Debug Page END *********************** -->
|
||||||
|
|
||||||
|
|
||||||
<!-- ******************************************************************* -->
|
<!-- ******************************************************************* -->
|
||||||
<!-- Settings Page START -->
|
<!-- Settings Page START -->
|
||||||
<!-- ******************************************************************* -->
|
<!-- ******************************************************************* -->
|
||||||
<div id="settingsSection" class="hidden">
|
<div id="settingsSection" class="hidden">
|
||||||
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="settings.title">Settings</h2>
|
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="settings.title">Settings</h2>
|
||||||
|
|
||||||
<form onsubmit="saveSettings(event)" class="space-y-6">
|
<form onsubmit="saveSettings(event)" class="space-y-6">
|
||||||
<!-- General Settings Group -->
|
<!-- ========================= General Settings ========================= -->
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.general">General Settings</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.general">General Settings</h3>
|
||||||
|
|
||||||
<!-- Language Selection -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="languageSelect" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.language">Language</label>
|
<label for="languageSelect" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.language">Language</label>
|
||||||
<select id="languageSelect" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
<select id="languageSelect" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
@@ -297,8 +276,6 @@
|
|||||||
<option value="de_ch">Schwiizerdütsch</option>
|
<option value="de_ch">Schwiizerdütsch</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fail2Ban UI Port (server) -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="uiPort" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.server_port">Server Port</label>
|
<label for="uiPort" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.server_port">Server Port</label>
|
||||||
<input type="number" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="uiPort"
|
<input type="number" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="uiPort"
|
||||||
@@ -319,7 +296,6 @@
|
|||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-500 mt-1" id="callbackUrlDefaultHint" data-i18n="settings.callback_url_hint">This URL is used by all Fail2Ban instances to send ban alerts back to Fail2Ban UI. For local deployments, use the same port as Fail2Ban UI (e.g., http://127.0.0.1:8080). For reverse proxy setups, use your TLS-encrypted endpoint (e.g., https://fail2ban.example.com).</p>
|
<p class="text-xs text-gray-500 mt-1" id="callbackUrlDefaultHint" data-i18n="settings.callback_url_hint">This URL is used by all Fail2Ban instances to send ban alerts back to Fail2Ban UI. For local deployments, use the same port as Fail2Ban UI (e.g., http://127.0.0.1:8080). For reverse proxy setups, use your TLS-encrypted endpoint (e.g., https://fail2ban.example.com).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<label for="callbackSecret" class="block text-sm font-medium text-gray-700" data-i18n="settings.callback_secret">Fail2ban Callback URL Secret</label>
|
<label for="callbackSecret" class="block text-sm font-medium text-gray-700" data-i18n="settings.callback_secret">Fail2ban Callback URL Secret</label>
|
||||||
@@ -329,8 +305,6 @@
|
|||||||
data-i18n-placeholder="settings.callback_secret_placeholder" placeholder="Auto-generated 42-character secret" />
|
data-i18n-placeholder="settings.callback_secret_placeholder" placeholder="Auto-generated 42-character secret" />
|
||||||
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.callback_secret.description">This secret is automatically generated and used to authenticate ban notification requests. It is included in the fail2ban action configuration.</p>
|
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.callback_secret.description">This secret is automatically generated and used to authenticate ban notification requests. It is included in the fail2ban action configuration.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug Log Output -->
|
|
||||||
<div class="flex items-center gap-4 border border-gray-200 rounded-lg p-2 overflow-x-auto bg-gray-50">
|
<div class="flex items-center gap-4 border border-gray-200 rounded-lg p-2 overflow-x-auto bg-gray-50">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input type="checkbox" id="debugMode" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out">
|
<input type="checkbox" id="debugMode" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out">
|
||||||
@@ -341,8 +315,6 @@
|
|||||||
<label for="consoleOutput" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_console">Enable Console Output</label>
|
<label for="consoleOutput" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_console">Enable Console Output</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Console Output Window -->
|
|
||||||
<div id="consoleOutputContainer" class="hidden mt-4 border border-gray-700 rounded-lg bg-gray-900 shadow-lg overflow-hidden">
|
<div id="consoleOutputContainer" class="hidden mt-4 border border-gray-700 rounded-lg bg-gray-900 shadow-lg overflow-hidden">
|
||||||
<div class="flex items-center justify-between bg-gray-800 px-4 py-2 border-b border-gray-700">
|
<div class="flex items-center justify-between bg-gray-800 px-4 py-2 border-b border-gray-700">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -361,7 +333,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Advanced Actions -->
|
<!-- ========================= Advanced Actions ========================= -->
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -373,19 +345,16 @@
|
|||||||
<button type="button" class="px-3 py-2 text-sm rounded border border-blue-600 text-blue-600 hover:bg-blue-50" onclick="openAdvancedTestModal()" data-i18n="settings.advanced.test_button">Manually Block / Test</button>
|
<button type="button" class="px-3 py-2 text-sm rounded border border-blue-600 text-blue-600 hover:bg-blue-50" onclick="openAdvancedTestModal()" data-i18n="settings.advanced.test_button">Manually Block / Test</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 space-y-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<input type="checkbox" id="advancedActionsEnabled" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
|
<input type="checkbox" id="advancedActionsEnabled" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
|
||||||
<label for="advancedActionsEnabled" class="ml-2 text-sm text-gray-700" data-i18n="settings.advanced.enable">Enable automatic permanent blocking</label>
|
<label for="advancedActionsEnabled" class="ml-2 text-sm text-gray-700" data-i18n="settings.advanced.enable">Enable automatic permanent blocking</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="advancedThreshold" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.threshold">Threshold before permanent block</label>
|
<label for="advancedThreshold" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.threshold">Threshold before permanent block</label>
|
||||||
<input type="number" id="advancedThreshold" min="1" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="5">
|
<input type="number" id="advancedThreshold" min="1" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="5">
|
||||||
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.advanced.threshold_hint">If an IP is banned at least this many times it will be forwarded to the selected firewall integration.</p>
|
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.advanced.threshold_hint">If an IP is banned at least this many times it will be forwarded to the selected firewall integration.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="advancedIntegrationSelect" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.integration">Integration</label>
|
<label for="advancedIntegrationSelect" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.integration">Integration</label>
|
||||||
<select id="advancedIntegrationSelect" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
<select id="advancedIntegrationSelect" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
@@ -426,7 +395,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="advancedPfSenseFields" class="hidden border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50">
|
<div id="advancedPfSenseFields" class="hidden border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50">
|
||||||
<p class="text-sm text-gray-500 mb-3" data-i18n="settings.advanced.pfsense.note">Requires the pfSense REST API package. Enter the API key and alias to manage.</p>
|
<p class="text-sm text-gray-500 mb-3" data-i18n="settings.advanced.pfsense.note">Requires the pfSense REST API package. Enter the API key and alias to manage.</p>
|
||||||
<div class="mb-3 text-sm">
|
<div class="mb-3 text-sm">
|
||||||
@@ -454,7 +422,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="advancedOPNsenseFields" class="hidden border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50">
|
<div id="advancedOPNsenseFields" class="hidden border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50">
|
||||||
<p class="text-sm text-gray-500 mb-3" data-i18n="settings.advanced.opnsense.note">Enter the OPNsense API credentials and alias to manage.</p>
|
<p class="text-sm text-gray-500 mb-3" data-i18n="settings.advanced.opnsense.note">Enter the OPNsense API credentials and alias to manage.</p>
|
||||||
<div class="mb-3 text-sm">
|
<div class="mb-3 text-sm">
|
||||||
@@ -486,7 +453,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<h4 class="text-md font-semibold text-gray-800 mb-2" data-i18n="settings.advanced.log_title">Permanent Block Log</h4>
|
<h4 class="text-md font-semibold text-gray-800 mb-2" data-i18n="settings.advanced.log_title">Permanent Block Log</h4>
|
||||||
<div id="permanentBlockLog" class="overflow-x-auto border border-gray-200 rounded-md">
|
<div id="permanentBlockLog" class="overflow-x-auto border border-gray-200 rounded-md">
|
||||||
@@ -495,11 +461,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alert Settings Group -->
|
<!-- ========================= Alert Settings =========================== -->
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.alert">Alert Settings</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.alert">Alert Settings</h3>
|
||||||
|
|
||||||
<!-- Email Alert Preferences -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.email_alerts">Email Alert Preferences</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.email_alerts">Email Alert Preferences</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -513,15 +477,12 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4" id="emailFieldsContainer">
|
<div class="mb-4" id="emailFieldsContainer">
|
||||||
<label for="destEmail" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.destination_email">Destination Email (Alerts Receiver)</label>
|
<label for="destEmail" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.destination_email">Destination Email (Alerts Receiver)</label>
|
||||||
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="destEmail"
|
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="destEmail"
|
||||||
data-i18n-placeholder="settings.destination_email_placeholder" placeholder="alerts@swissmakers.ch" />
|
data-i18n-placeholder="settings.destination_email_placeholder" placeholder="alerts@swissmakers.ch" />
|
||||||
<p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p>
|
<p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GeoIP Provider -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="geoipProvider" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_provider">GeoIP Provider</label>
|
<label for="geoipProvider" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_provider">GeoIP Provider</label>
|
||||||
<select id="geoipProvider" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="onGeoIPProviderChange(this.value)">
|
<select id="geoipProvider" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="onGeoIPProviderChange(this.value)">
|
||||||
@@ -530,21 +491,16 @@
|
|||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_provider.description">Choose the GeoIP lookup provider. MaxMind requires a local database file, while Built-in uses a free online API.</p>
|
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_provider.description">Choose the GeoIP lookup provider. MaxMind requires a local database file, while Built-in uses a free online API.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- GeoIP Database Path (shown only for MaxMind) -->
|
|
||||||
<div id="geoipDatabasePathContainer" class="mb-4">
|
<div id="geoipDatabasePathContainer" class="mb-4">
|
||||||
<label for="geoipDatabasePath" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_database_path">GeoIP Database Path</label>
|
<label for="geoipDatabasePath" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_database_path">GeoIP Database Path</label>
|
||||||
<input type="text" id="geoipDatabasePath" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="/usr/share/GeoIP/GeoLite2-Country.mmdb">
|
<input type="text" id="geoipDatabasePath" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="/usr/share/GeoIP/GeoLite2-Country.mmdb">
|
||||||
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_database_path.description">Path to the MaxMind GeoLite2-Country database file.</p>
|
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_database_path.description">Path to the MaxMind GeoLite2-Country database file.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Max Log Lines -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="maxLogLines" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.max_log_lines">Maximum Log Lines</label>
|
<label for="maxLogLines" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.max_log_lines">Maximum Log Lines</label>
|
||||||
<input type="number" id="maxLogLines" min="1" max="500" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="50">
|
<input type="number" id="maxLogLines" min="1" max="500" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="50">
|
||||||
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.max_log_lines.description">Maximum number of log lines to include in ban notifications. Most relevant lines are selected automatically.</p>
|
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.max_log_lines.description">Maximum number of log lines to include in ban notifications. Most relevant lines are selected automatically.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="alertCountries" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.alert_countries">Alert Countries</label>
|
<label for="alertCountries" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.alert_countries">Alert Countries</label>
|
||||||
<p class="text-sm text-gray-500 mb-2" data-i18n="settings.alert_countries_description">
|
<p class="text-sm text-gray-500 mb-2" data-i18n="settings.alert_countries_description">
|
||||||
@@ -751,7 +707,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SMTP Configuration Group -->
|
<!-- ========================= SMTP Configuration ======================= -->
|
||||||
<div class="bg-white rounded-lg shadow p-6" id="smtpFieldsContainer">
|
<div class="bg-white rounded-lg shadow p-6" id="smtpFieldsContainer">
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.smtp">SMTP Configuration</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.smtp">SMTP Configuration</h3>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -805,12 +761,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fail2Ban Configuration Group -->
|
<!-- ========================= Fail2Ban Defaults ======================== -->
|
||||||
<div class="bg-white rounded-lg shadow p-6">
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.fail2ban">Global Default Fail2Ban Configurations</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.fail2ban">Global Default Fail2Ban Configurations</h3>
|
||||||
<p class="text-sm text-gray-600 mb-4" data-i18n="settings.fail2ban.description">These settings will be applied to all enabled Fail2Ban servers and stored in their jail.local [DEFAULT] section.</p>
|
<p class="text-sm text-gray-600 mb-4" data-i18n="settings.fail2ban.description">These settings will be applied to all enabled Fail2Ban servers and stored in their jail.local [DEFAULT] section.</p>
|
||||||
|
|
||||||
<!-- Bantime Increment -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<input type="checkbox" id="bantimeIncrement" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
|
<input type="checkbox" id="bantimeIncrement" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
|
||||||
@@ -818,8 +772,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 ml-9" data-i18n="settings.enable_bantime_increment.description">If set to true, the bantime will be calculated using the formula: bantime = findtime * (number of failures / maxretry) * (1 + bantime.rndtime).</p>
|
<p class="text-xs text-gray-500 ml-9" data-i18n="settings.enable_bantime_increment.description">If set to true, the bantime will be calculated using the formula: bantime = findtime * (number of failures / maxretry) * (1 + bantime.rndtime).</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Default Enabled -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex items-center mb-2">
|
<div class="flex items-center mb-2">
|
||||||
<input type="checkbox" id="defaultJailEnable" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
|
<input type="checkbox" id="defaultJailEnable" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
|
||||||
@@ -827,8 +779,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 ml-9" data-i18n="settings.default_jail_enable.description">If enabled, all jails will be enabled by default. When disabled, jails must be explicitly enabled.</p>
|
<p class="text-xs text-gray-500 ml-9" data-i18n="settings.default_jail_enable.description">If enabled, all jails will be enabled by default. When disabled, jails must be explicitly enabled.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bantime -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="banTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_bantime">Default Bantime</label>
|
<label for="banTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_bantime">Default Bantime</label>
|
||||||
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_bantime.description">The number of seconds that a host is banned. Time format: 1h = 1 hour, 1d = 1 day, 1w = 1 week, 1m = 1 month, 1y = 1 year.</p>
|
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_bantime.description">The number of seconds that a host is banned. Time format: 1h = 1 hour, 1d = 1 day, 1w = 1 week, 1m = 1 month, 1y = 1 year.</p>
|
||||||
@@ -836,16 +786,12 @@
|
|||||||
data-i18n-placeholder="settings.default_bantime_placeholder" placeholder="e.g., 48h" />
|
data-i18n-placeholder="settings.default_bantime_placeholder" placeholder="e.g., 48h" />
|
||||||
<p class="text-xs text-red-600 mt-1 hidden" id="banTimeError"></p>
|
<p class="text-xs text-red-600 mt-1 hidden" id="banTimeError"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bantime Rndtime (optional, for bantime increment formula) -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="bantimeRndtime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.bantime_rndtime">Bantime Rndtime</label>
|
<label for="bantimeRndtime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.bantime_rndtime">Bantime Rndtime</label>
|
||||||
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.bantime_rndtime.description">Optional. Max random seconds added in bantime increment formula (e.g. 2048). Leave empty to use Fail2ban default.</p>
|
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.bantime_rndtime.description">Optional. Max random seconds added in bantime increment formula (e.g. 2048). Leave empty to use Fail2ban default.</p>
|
||||||
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="bantimeRndtime"
|
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="bantimeRndtime"
|
||||||
data-i18n-placeholder="settings.bantime_rndtime_placeholder" placeholder="e.g., 2048" />
|
data-i18n-placeholder="settings.bantime_rndtime_placeholder" placeholder="e.g., 2048" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Banaction -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="banaction" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.banaction">Banaction</label>
|
<label for="banaction" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.banaction">Banaction</label>
|
||||||
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.banaction.description">Default banning action (e.g. nftables-multiport, nftables-allports, firewallcmd-rich-rules, etc). It is used to define action_* variables.</p>
|
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.banaction.description">Default banning action (e.g. nftables-multiport, nftables-allports, firewallcmd-rich-rules, etc). It is used to define action_* variables.</p>
|
||||||
@@ -881,8 +827,6 @@
|
|||||||
<option value="apf">apf</option>
|
<option value="apf">apf</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Banaction Allports -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="banactionAllports" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.banaction_allports">Banaction Allports</label>
|
<label for="banactionAllports" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.banaction_allports">Banaction Allports</label>
|
||||||
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.banaction_allports.description">Banning action for all ports (e.g. iptables-allports, firewallcmd-allports, etc). Used when a jail needs to ban all ports instead of specific ones.</p>
|
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.banaction_allports.description">Banning action for all ports (e.g. iptables-allports, firewallcmd-allports, etc). Used when a jail needs to ban all ports instead of specific ones.</p>
|
||||||
@@ -918,8 +862,6 @@
|
|||||||
<option value="apf">apf</option>
|
<option value="apf">apf</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Default Chain -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<label for="defaultChain" class="block text-sm font-medium text-gray-700" data-i18n="settings.default_chain">Default Chain</label>
|
<label for="defaultChain" class="block text-sm font-medium text-gray-700" data-i18n="settings.default_chain">Default Chain</label>
|
||||||
@@ -932,8 +874,6 @@
|
|||||||
<option value="FORWARD" data-i18n="settings.chain_forward">FORWARD</option>
|
<option value="FORWARD" data-i18n="settings.chain_forward">FORWARD</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Findtime -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="findTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_findtime">Default Findtime</label>
|
<label for="findTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_findtime">Default Findtime</label>
|
||||||
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_findtime.description">A host is banned if it has generated 'maxretry' failures during the last 'findtime' seconds. Time format: 1h = 1 hour, 1d = 1 day, 1w = 1 week, 1m = 1 month, 1y = 1 year.</p>
|
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_findtime.description">A host is banned if it has generated 'maxretry' failures during the last 'findtime' seconds. Time format: 1h = 1 hour, 1d = 1 day, 1w = 1 week, 1m = 1 month, 1y = 1 year.</p>
|
||||||
@@ -941,8 +881,6 @@
|
|||||||
data-i18n-placeholder="settings.default_findtime_placeholder" placeholder="e.g., 30m" />
|
data-i18n-placeholder="settings.default_findtime_placeholder" placeholder="e.g., 30m" />
|
||||||
<p class="text-xs text-red-600 mt-1 hidden" id="findTimeError"></p>
|
<p class="text-xs text-red-600 mt-1 hidden" id="findTimeError"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Max Retry -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="maxRetry" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_max_retry">Default Max Retry</label>
|
<label for="maxRetry" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_max_retry">Default Max Retry</label>
|
||||||
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_max_retry.description">Number of failures before a host gets banned.</p>
|
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_max_retry.description">Number of failures before a host gets banned.</p>
|
||||||
@@ -950,8 +888,6 @@
|
|||||||
data-i18n-placeholder="settings.default_max_retry_placeholder" placeholder="Enter maximum retries" min="1" />
|
data-i18n-placeholder="settings.default_max_retry_placeholder" placeholder="Enter maximum retries" min="1" />
|
||||||
<p class="text-xs text-red-600 mt-1 hidden" id="maxRetryError"></p>
|
<p class="text-xs text-red-600 mt-1 hidden" id="maxRetryError"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ignore IPs -->
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.ignore_ips">Ignore IPs</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.ignore_ips">Ignore IPs</label>
|
||||||
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.ignore_ips.description">Space separated list of IP addresses, CIDR masks or DNS hosts. Fail2ban will not ban a host which matches an address in this list.</p>
|
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.ignore_ips.description">Space separated list of IP addresses, CIDR masks or DNS hosts. Fail2ban will not ban a host which matches an address in this list.</p>
|
||||||
@@ -962,16 +898,16 @@
|
|||||||
<div id="ignoreIPsError" class="hidden text-red-600 text-sm mt-1"></div>
|
<div id="ignoreIPsError" class="hidden text-red-600 text-sm mt-1"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors" data-i18n="settings.save">Save</button>
|
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors" data-i18n="settings.save">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<!-- *********************** Settings Page END ************************* -->
|
<!-- *********************** Settings Page END ************************* -->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- ******************************************************************* -->
|
||||||
|
<!-- Footer -->
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
<footer id="footer" class="hidden bg-gray-100 py-4">
|
<footer id="footer" class="hidden bg-gray-100 py-4">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-600 text-sm">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-600 text-sm">
|
||||||
<p class="mb-0">
|
<p class="mb-0">
|
||||||
@@ -988,11 +924,12 @@
|
|||||||
<!-- ******************************************************************* -->
|
<!-- ******************************************************************* -->
|
||||||
<!-- Modal Templates START -->
|
<!-- Modal Templates START -->
|
||||||
<!-- ******************************************************************* -->
|
<!-- ******************************************************************* -->
|
||||||
<!-- Jail Config Modal -->
|
|
||||||
|
<!-- ========================= Jail Config Modal ========================= -->
|
||||||
<div id="jailConfigModal" class="hidden fixed inset-0 z-50 overflow-y-auto" style="z-index: 60;">
|
<div id="jailConfigModal" class="hidden fixed inset-0 z-50 overflow-y-auto" style="z-index: 60;">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-2 sm:p-4">
|
<div class="relative flex min-h-full w-full items-center justify-center p-2 sm:p-4">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all my-4 sm:my-8" style="max-width: 90vw; max-height: calc(100vh - 2rem); display: flex; flex-direction: column;">
|
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all my-4 sm:my-8" style="max-width: 90vw; max-height: calc(100vh - 2rem); display: flex; flex-direction: column;">
|
||||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4" style="flex: 1; overflow-y: auto; min-height: 0;">
|
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4" style="flex: 1; overflow-y: auto; min-height: 0;">
|
||||||
<div class="sm:flex sm:items-start">
|
<div class="sm:flex sm:items-start">
|
||||||
@@ -1008,7 +945,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 space-y-4">
|
<div class="mt-4 space-y-4">
|
||||||
<!-- Filter Configuration -->
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.filter_config_label">Filter Configuration</label>
|
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.filter_config_label">Filter Configuration</label>
|
||||||
@@ -1037,11 +973,7 @@
|
|||||||
onfocus="preventExtensionInterference(this);"></textarea>
|
onfocus="preventExtensionInterference(this);"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="border-t border-gray-300"></div>
|
<div class="border-t border-gray-300"></div>
|
||||||
|
|
||||||
<!-- Jail Configuration -->
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.jail_config_label">Jail Configuration</label>
|
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.jail_config_label">Jail Configuration</label>
|
||||||
@@ -1070,8 +1002,6 @@
|
|||||||
onfocus="preventExtensionInterference(this);"></textarea>
|
onfocus="preventExtensionInterference(this);"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Test Logpath Button (only shown if logpath is set) -->
|
|
||||||
<div id="testLogpathSection" class="hidden">
|
<div id="testLogpathSection" class="hidden">
|
||||||
<div id="localServerLogpathHint" class="mb-2 p-2 bg-blue-50 border border-blue-200 rounded-md text-xs text-blue-800 hidden">
|
<div id="localServerLogpathHint" class="mb-2 p-2 bg-blue-50 border border-blue-200 rounded-md text-xs text-blue-800 hidden">
|
||||||
<strong data-i18n="modal.local_server_logpath_note">ℹ️ Note:</strong> <span data-i18n="modal.local_server_logpath_text_prefix">For a local fail2ban server (e.g. installed on container host system or in a container on same host), log files must also be mounted to the fail2ban-ui container (e.g.,</span> <code class="font-mono">-v /var/log:/var/log:ro</code> <span data-i18n="modal.local_server_logpath_text_suffix">) this is required so that the fail2ban-ui can verify logpath variables or paths when updating jails.</span>
|
<strong data-i18n="modal.local_server_logpath_note">ℹ️ Note:</strong> <span data-i18n="modal.local_server_logpath_text_prefix">For a local fail2ban server (e.g. installed on container host system or in a container on same host), log files must also be mounted to the fail2ban-ui container (e.g.,</span> <code class="font-mono">-v /var/log:/var/log:ro</code> <span data-i18n="modal.local_server_logpath_text_suffix">) this is required so that the fail2ban-ui can verify logpath variables or paths when updating jails.</span>
|
||||||
@@ -1094,7 +1024,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manage Jails Modal -->
|
<!-- ========================= Manage Jails Modal ======================== -->
|
||||||
<div id="manageJailsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="manageJailsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
@@ -1122,7 +1052,6 @@
|
|||||||
<span data-i18n="modal.create_jail">Create New Jail</span>
|
<span data-i18n="modal.create_jail">Create New Jail</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Dynamically filled list of jails with toggle switches -->
|
|
||||||
<div id="jailsList" class="divide-y divide-gray-200"></div>
|
<div id="jailsList" class="divide-y divide-gray-200"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1135,7 +1064,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Jail Modal -->
|
<!-- ========================= Create Jail Modal ========================= -->
|
||||||
<div id="createJailModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="createJailModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
@@ -1182,7 +1111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Filter Modal -->
|
<!-- ========================= Create Filter Modal ======================= -->
|
||||||
<div id="createFilterModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="createFilterModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
@@ -1222,7 +1151,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Server Manager Modal -->
|
<!-- ========================= Server Manager Modal ====================== -->
|
||||||
<div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
@@ -1345,7 +1274,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Whois Modal -->
|
<!-- ========================= Whois Modal =============================== -->
|
||||||
<div id="whoisModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="whoisModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
@@ -1377,7 +1306,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Advanced Actions Test Modal -->
|
<!-- ========================= Advanced Actions Test Modal =============== -->
|
||||||
<div id="advancedTestModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="advancedTestModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
@@ -1411,7 +1340,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs Modal -->
|
<!-- ========================= Logs Modal ================================ -->
|
||||||
<div id="logsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="logsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
@@ -1446,7 +1375,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ban Insights Modal -->
|
<!-- ========================= Ban Insights Modal ======================== -->
|
||||||
<div id="banInsightsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="banInsightsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
@@ -1464,13 +1393,8 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-600 mb-4" data-i18n="logs.modal.insights_description">Country distribution and recurring offenders.</p>
|
<p class="text-sm text-gray-600 mb-4" data-i18n="logs.modal.insights_description">Country distribution and recurring offenders.</p>
|
||||||
|
|
||||||
<!-- Summary Cards -->
|
|
||||||
<div id="insightsSummary" class="grid gap-4 sm:grid-cols-3 mb-6"></div>
|
<div id="insightsSummary" class="grid gap-4 sm:grid-cols-3 mb-6"></div>
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
|
||||||
<div class="grid gap-6 lg:grid-cols-2">
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
<!-- Country Statistics -->
|
|
||||||
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -1481,8 +1405,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="countryStatsContainer" class="space-y-4 max-h-96 overflow-y-auto"></div>
|
<div id="countryStatsContainer" class="space-y-4 max-h-96 overflow-y-auto"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recurring IPs -->
|
|
||||||
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -1504,7 +1426,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chain Help Modal -->
|
<!-- ========================= Chain Help Modal ========================== -->
|
||||||
<div id="chainHelpModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
<div id="chainHelpModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||||
@@ -1547,12 +1469,11 @@
|
|||||||
|
|
||||||
<!-- ********************** Modal Templates END ************************ -->
|
<!-- ********************** Modal Templates END ************************ -->
|
||||||
|
|
||||||
<!-- jQuery (used by Select2) -->
|
<!-- ******************************************************************* -->
|
||||||
|
<!-- Script Includes -->
|
||||||
|
<!-- ******************************************************************* -->
|
||||||
<script src="/static/vendor/jquery/jquery-3.6.0.min.js?v={{.version}}"></script>
|
<script src="/static/vendor/jquery/jquery-3.6.0.min.js?v={{.version}}"></script>
|
||||||
<!-- Select2 JS -->
|
|
||||||
<script src="/static/vendor/select2/select2.min.js?v={{.version}}"></script>
|
<script src="/static/vendor/select2/select2.min.js?v={{.version}}"></script>
|
||||||
|
|
||||||
<!-- Fail2ban UI JavaScript Modules -->
|
|
||||||
<script src="/static/js/globals.js?v={{.version}}"></script>
|
<script src="/static/js/globals.js?v={{.version}}"></script>
|
||||||
<script src="/static/js/core.js?v={{.version}}"></script>
|
<script src="/static/js/core.js?v={{.version}}"></script>
|
||||||
<script src="/static/js/api.js?v={{.version}}"></script>
|
<script src="/static/js/api.js?v={{.version}}"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user