mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-19 06:53:14 +02:00
442 lines
13 KiB
Go
442 lines
13 KiB
Go
package fail2ban
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/swissmakers/fail2ban-ui/internal/config"
|
|
)
|
|
|
|
// =========================================================================
|
|
// Types
|
|
// =========================================================================
|
|
|
|
// Connector for a remote Fail2ban-Agent via HTTP API.
|
|
type AgentConnector struct {
|
|
server config.Fail2banServer
|
|
base *url.URL
|
|
client *http.Client
|
|
}
|
|
|
|
// =========================================================================
|
|
// Constructor
|
|
// =========================================================================
|
|
|
|
// Create a new AgentConnector for the given server config.
|
|
func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
|
|
if server.AgentURL == "" {
|
|
return nil, fmt.Errorf("agentUrl is required for agent connector")
|
|
}
|
|
if server.AgentSecret == "" {
|
|
return nil, fmt.Errorf("agentSecret is required for agent connector")
|
|
}
|
|
parsed, err := url.Parse(server.AgentURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid agentUrl: %w", err)
|
|
}
|
|
if parsed.Scheme == "" {
|
|
parsed.Scheme = "https"
|
|
}
|
|
client := &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
}
|
|
conn := &AgentConnector{
|
|
server: server,
|
|
base: parsed,
|
|
client: client,
|
|
}
|
|
if err := conn.ensureAction(context.Background()); err != nil {
|
|
fmt.Printf("warning: failed to ensure agent action for %s: %v\n", server.Name, err)
|
|
}
|
|
return conn, nil
|
|
}
|
|
|
|
// =========================================================================
|
|
// Connector Functions
|
|
// =========================================================================
|
|
|
|
func (ac *AgentConnector) ID() string {
|
|
return ac.server.ID
|
|
}
|
|
|
|
func (ac *AgentConnector) Server() config.Fail2banServer {
|
|
return ac.server
|
|
}
|
|
|
|
func (ac *AgentConnector) ensureAction(ctx context.Context) error {
|
|
settings := config.GetSettings()
|
|
payload := map[string]any{
|
|
"name": "ui-custom-action",
|
|
"config": config.BuildFail2banActionConfig(config.GetCallbackURL(), ac.server.ID, settings.CallbackSecret),
|
|
"callbackUrl": config.GetCallbackURL(),
|
|
"setDefault": true,
|
|
}
|
|
return ac.put(ctx, "/v1/actions/ui-custom", payload, nil)
|
|
}
|
|
|
|
func (ac *AgentConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
|
var resp struct {
|
|
Jails []JailInfo `json:"jails"`
|
|
}
|
|
if err := ac.get(ctx, "/v1/jails", &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Jails, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
|
var resp struct {
|
|
Jail string `json:"jail"`
|
|
BannedIPs []string `json:"bannedIPs"`
|
|
TotalBanned int `json:"totalBanned"`
|
|
}
|
|
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s", url.PathEscape(jail)), &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(resp.BannedIPs) > 0 {
|
|
return resp.BannedIPs, nil
|
|
}
|
|
return []string{}, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
|
payload := map[string]string{"ip": ip}
|
|
return ac.post(ctx, fmt.Sprintf("/v1/jails/%s/unban", url.PathEscape(jail)), payload, nil)
|
|
}
|
|
|
|
func (ac *AgentConnector) BanIP(ctx context.Context, jail, ip string) error {
|
|
payload := map[string]string{"ip": ip}
|
|
return ac.post(ctx, fmt.Sprintf("/v1/jails/%s/ban", url.PathEscape(jail)), payload, nil)
|
|
}
|
|
|
|
func (ac *AgentConnector) Reload(ctx context.Context) error {
|
|
return ac.post(ctx, "/v1/actions/reload", nil, nil)
|
|
}
|
|
|
|
func (ac *AgentConnector) Restart(ctx context.Context) error {
|
|
return ac.post(ctx, "/v1/actions/restart", nil, nil)
|
|
}
|
|
|
|
func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
|
|
if err := ac.Restart(ctx); err != nil {
|
|
return "restart", err
|
|
}
|
|
return "restart", nil
|
|
}
|
|
|
|
// =========================================================================
|
|
// Filter Operations
|
|
// =========================================================================
|
|
|
|
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
|
|
var resp struct {
|
|
Config string `json:"config"`
|
|
FilePath string `json:"filePath"`
|
|
}
|
|
if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
|
|
return "", "", err
|
|
}
|
|
filePath := resp.FilePath
|
|
if filePath == "" {
|
|
filePath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail)
|
|
}
|
|
return resp.Config, filePath, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
|
payload := map[string]string{"config": content}
|
|
return ac.put(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), payload, nil)
|
|
}
|
|
|
|
// =========================================================================
|
|
// HTTP Helpers
|
|
// =========================================================================
|
|
|
|
func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error {
|
|
req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ac.do(req, out)
|
|
}
|
|
|
|
func (ac *AgentConnector) post(ctx context.Context, endpoint string, payload any, out any) error {
|
|
req, err := ac.newRequest(ctx, http.MethodPost, endpoint, payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ac.do(req, out)
|
|
}
|
|
|
|
func (ac *AgentConnector) put(ctx context.Context, endpoint string, payload any, out any) error {
|
|
req, err := ac.newRequest(ctx, http.MethodPut, endpoint, payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ac.do(req, out)
|
|
}
|
|
|
|
func (ac *AgentConnector) delete(ctx context.Context, endpoint string, out any) error {
|
|
req, err := ac.newRequest(ctx, http.MethodDelete, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ac.do(req, out)
|
|
}
|
|
|
|
func (ac *AgentConnector) newRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) {
|
|
u := *ac.base
|
|
u.Path = path.Join(ac.base.Path, strings.TrimPrefix(endpoint, "/"))
|
|
|
|
var body io.Reader
|
|
if payload != nil {
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body = bytes.NewReader(data)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if payload != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("X-F2B-Token", ac.server.AgentSecret)
|
|
return req, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) do(req *http.Request, out any) error {
|
|
settingsSnapshot := config.GetSettings()
|
|
if settingsSnapshot.Debug {
|
|
config.DebugLog("Agent request [%s]: %s %s", ac.server.Name, req.Method, req.URL.String())
|
|
}
|
|
|
|
resp, err := ac.client.Do(req)
|
|
if err != nil {
|
|
if settingsSnapshot.Debug {
|
|
config.DebugLog("Agent request error [%s]: %v", ac.server.Name, err)
|
|
}
|
|
return fmt.Errorf("agent request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
trimmed := strings.TrimSpace(string(data))
|
|
|
|
if settingsSnapshot.Debug {
|
|
config.DebugLog("Agent response [%s]: %s | %s", ac.server.Name, resp.Status, trimmed)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return fmt.Errorf("agent request failed: %s (%s)", resp.Status, trimmed)
|
|
}
|
|
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
|
|
if len(trimmed) == 0 {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(data, out)
|
|
}
|
|
|
|
// =========================================================================
|
|
// Jail Operations
|
|
// =========================================================================
|
|
|
|
func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
|
|
var resp struct {
|
|
Jails []JailInfo `json:"jails"`
|
|
}
|
|
if err := ac.get(ctx, "/v1/jails/all", &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Jails, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
|
|
return ac.post(ctx, "/v1/jails/update-enabled", updates, nil)
|
|
}
|
|
|
|
func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
|
|
var resp struct {
|
|
Filters []string `json:"filters"`
|
|
}
|
|
if err := ac.get(ctx, "/v1/filters", &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Filters, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
|
|
payload := map[string]any{
|
|
"filterName": filterName,
|
|
"logLines": logLines,
|
|
}
|
|
if filterContent != "" {
|
|
payload["filterContent"] = filterContent
|
|
}
|
|
var resp struct {
|
|
Output string `json:"output"`
|
|
FilterPath string `json:"filterPath"`
|
|
}
|
|
if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil {
|
|
return "", "", err
|
|
}
|
|
filterPath := resp.FilterPath
|
|
if filterPath == "" {
|
|
filterPath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName)
|
|
}
|
|
return resp.Output, filterPath, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
|
|
var resp struct {
|
|
Config string `json:"config"`
|
|
FilePath string `json:"filePath"`
|
|
}
|
|
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil {
|
|
return "", "", err
|
|
}
|
|
filePath := resp.FilePath
|
|
if filePath == "" {
|
|
filePath = fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail)
|
|
}
|
|
return resp.Config, filePath, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) SetJailConfig(ctx context.Context, jail, content string) error {
|
|
payload := map[string]string{"config": content}
|
|
return ac.put(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), payload, nil)
|
|
}
|
|
|
|
// =========================================================================
|
|
// Logpath Operations
|
|
// =========================================================================
|
|
|
|
func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
|
|
payload := map[string]string{"logpath": logpath}
|
|
var resp struct {
|
|
Files []string `json:"files"`
|
|
}
|
|
if err := ac.post(ctx, "/v1/jails/test-logpath", payload, &resp); err != nil {
|
|
return []string{}, nil
|
|
}
|
|
return resp.Files, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
|
originalPath = strings.TrimSpace(logpath)
|
|
if originalPath == "" {
|
|
return originalPath, "", []string{}, nil
|
|
}
|
|
|
|
payload := map[string]string{"logpath": originalPath}
|
|
var resp struct {
|
|
OriginalLogpath string `json:"original_logpath"`
|
|
ResolvedLogpath string `json:"resolved_logpath"`
|
|
Files []string `json:"files"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Try new endpoint first, fallback to old endpoint
|
|
if err := ac.post(ctx, "/v1/jails/test-logpath-with-resolution", payload, &resp); err != nil {
|
|
// Fallback; use old endpoint if new endpoint fails and assume no resolution
|
|
files, err2 := ac.TestLogpath(ctx, originalPath)
|
|
if err2 != nil {
|
|
return originalPath, "", nil, fmt.Errorf("failed to test logpath: %w", err2)
|
|
}
|
|
return originalPath, originalPath, files, nil
|
|
}
|
|
|
|
if resp.Error != "" {
|
|
return originalPath, "", nil, fmt.Errorf("agent error: %s", resp.Error)
|
|
}
|
|
|
|
if resp.ResolvedLogpath == "" {
|
|
resp.ResolvedLogpath = resp.OriginalLogpath
|
|
}
|
|
if resp.OriginalLogpath == "" {
|
|
resp.OriginalLogpath = originalPath
|
|
}
|
|
|
|
return resp.OriginalLogpath, resp.ResolvedLogpath, resp.Files, nil
|
|
}
|
|
|
|
// =========================================================================
|
|
// Settings and Structure
|
|
// =========================================================================
|
|
|
|
func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
|
return ac.EnsureJailLocalStructure(ctx)
|
|
}
|
|
|
|
func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
|
|
var result struct {
|
|
Exists bool `json:"exists"`
|
|
HasUIAction bool `json:"hasUIAction"`
|
|
}
|
|
if err := ac.get(ctx, "/v1/jails/check-integrity", &result); err != nil {
|
|
// If the agent does not implement this endpoint, assume OK
|
|
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") {
|
|
return false, false, nil
|
|
}
|
|
return false, false, fmt.Errorf("failed to check jail.local integrity on %s: %w", ac.server.Name, err)
|
|
}
|
|
return result.Exists, result.HasUIAction, nil
|
|
}
|
|
|
|
func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
|
|
// If jail.local exists but is not managed by Fail2ban-UI, it belongs to the user, we do not overwrite it.
|
|
if exists, hasUI, err := ac.CheckJailLocalIntegrity(ctx); err == nil && exists && !hasUI {
|
|
config.DebugLog("jail.local on agent server %s exists but is not managed by Fail2ban-UI -- skipping overwrite", ac.server.Name)
|
|
return nil
|
|
}
|
|
|
|
return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil)
|
|
}
|
|
|
|
// =========================================================================
|
|
// Filter and Jail Management
|
|
// =========================================================================
|
|
|
|
func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content string) error {
|
|
payload := map[string]interface{}{
|
|
"name": jailName,
|
|
"content": content,
|
|
}
|
|
return ac.post(ctx, "/v1/jails", payload, nil)
|
|
}
|
|
|
|
func (ac *AgentConnector) DeleteJail(ctx context.Context, jailName string) error {
|
|
return ac.delete(ctx, fmt.Sprintf("/v1/jails/%s", jailName), nil)
|
|
}
|
|
|
|
func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content string) error {
|
|
payload := map[string]interface{}{
|
|
"name": filterName,
|
|
"content": content,
|
|
}
|
|
return ac.post(ctx, "/v1/filters", payload, nil)
|
|
}
|
|
|
|
func (ac *AgentConnector) DeleteFilter(ctx context.Context, filterName string) error {
|
|
return ac.delete(ctx, fmt.Sprintf("/v1/filters/%s", filterName), nil)
|
|
}
|