Files
fail2ban-ui/internal/fail2ban/connector_ssh.go

510 lines
16 KiB
Go

package fail2ban
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
const sshEnsureActionScript = `python3 - <<'PY'
import base64
import pathlib
action_dir = pathlib.Path("/etc/fail2ban/action.d")
action_dir.mkdir(parents=True, exist_ok=True)
action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8")
(action_dir / "ui-custom-action.conf").write_text(action_cfg)
jail_file = pathlib.Path("/etc/fail2ban/jail.local")
if not jail_file.exists():
jail_file.write_text("[DEFAULT]\n")
lines = jail_file.read_text().splitlines()
already = any("Custom Fail2Ban action applied by fail2ban-ui" in line for line in lines)
if not already:
new_lines = []
inserted = False
for line in lines:
stripped = line.strip()
if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted:
if not stripped.startswith("#"):
new_lines.append("# " + line)
else:
new_lines.append(line)
new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui")
new_lines.append("action = %(action_mwlg)s")
inserted = True
continue
new_lines.append(line)
if not inserted:
insert_at = None
for idx, value in enumerate(new_lines):
if value.strip().startswith("[DEFAULT]"):
insert_at = idx + 1
break
if insert_at is None:
new_lines.append("[DEFAULT]")
insert_at = len(new_lines)
new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui")
new_lines.insert(insert_at + 1, "action = %(action_mwlg)s")
jail_file.write_text("\n".join(new_lines) + "\n")
PY`
// SSHConnector connects to a remote Fail2ban instance over SSH.
type SSHConnector struct {
server config.Fail2banServer
}
// NewSSHConnector creates a new SSH connector.
func NewSSHConnector(server config.Fail2banServer) (Connector, error) {
if server.Host == "" {
return nil, fmt.Errorf("host is required for ssh connector")
}
if server.SSHUser == "" {
return nil, fmt.Errorf("sshUser is required for ssh connector")
}
conn := &SSHConnector{server: server}
if err := conn.ensureAction(context.Background()); err != nil {
fmt.Printf("warning: failed to ensure remote fail2ban action for %s: %v\n", server.Name, err)
}
return conn, nil
}
func (sc *SSHConnector) ID() string {
return sc.server.ID
}
func (sc *SSHConnector) Server() config.Fail2banServer {
return sc.server
}
func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
jails, err := sc.getJails(ctx)
if err != nil {
return nil, err
}
// Use parallel execution for better performance
type jailResult struct {
jail JailInfo
err error
}
results := make(chan jailResult, len(jails))
var wg sync.WaitGroup
for _, jail := range jails {
wg.Add(1)
go func(j string) {
defer wg.Done()
ips, err := sc.GetBannedIPs(ctx, j)
if err != nil {
results <- jailResult{err: err}
return
}
results <- jailResult{
jail: JailInfo{
JailName: j,
TotalBanned: len(ips),
NewInLastHour: 0,
BannedIPs: ips,
Enabled: true,
},
}
}(jail)
}
go func() {
wg.Wait()
close(results)
}()
var infos []JailInfo
for result := range results {
if result.err != nil {
continue
}
infos = append(infos, result.jail)
}
sort.SliceStable(infos, func(i, j int) bool {
return infos[i].JailName < infos[j].JailName
})
return infos, nil
}
func (sc *SSHConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
out, err := sc.runFail2banCommand(ctx, "status", jail)
if err != nil {
return nil, err
}
var bannedIPs []string
lines := strings.Split(out, "\n")
for _, line := range lines {
if strings.Contains(line, "IP list:") {
parts := strings.Split(line, ":")
if len(parts) > 1 {
ips := strings.Fields(strings.TrimSpace(parts[1]))
bannedIPs = append(bannedIPs, ips...)
}
break
}
}
return bannedIPs, nil
}
func (sc *SSHConnector) UnbanIP(ctx context.Context, jail, ip string) error {
_, err := sc.runFail2banCommand(ctx, "set", jail, "unbanip", ip)
return err
}
func (sc *SSHConnector) Reload(ctx context.Context) error {
_, err := sc.runFail2banCommand(ctx, "reload")
return err
}
func (sc *SSHConnector) Restart(ctx context.Context) error {
_, err := sc.runRemoteCommand(ctx, []string{"sudo", "systemctl", "restart", "fail2ban"})
return err
}
func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
out, err := sc.runRemoteCommand(ctx, []string{"cat", path})
if err != nil {
return "", fmt.Errorf("failed to read remote filter config: %w", err)
}
return out, nil
}
func (sc *SSHConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", path, content)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
return err
}
func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
// Not available over SSH without copying logs; return empty slice.
return []BanEvent{}, nil
}
func (sc *SSHConnector) ensureAction(ctx context.Context) error {
callbackURL := config.GetCallbackURL()
actionConfig := config.BuildFail2banActionConfig(callbackURL, sc.server.ID)
payload := base64.StdEncoding.EncodeToString([]byte(actionConfig))
script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload)
// Base64 encode the entire script to avoid shell escaping issues
scriptB64 := base64.StdEncoding.EncodeToString([]byte(script))
// Use sh -s to read commands from stdin, then pass the base64 string via stdin
// This is the most reliable way to pass data via SSH
args := sc.buildSSHArgs([]string{"sh", "-s"})
cmd := exec.CommandContext(ctx, "ssh", args...)
// Create a script that reads the base64 string from stdin and pipes it through base64 -d | bash
// We use a here-document to pass the base64 string
scriptContent := fmt.Sprintf("cat <<'ENDBASE64' | base64 -d | bash\n%s\nENDBASE64\n", scriptB64)
cmd.Stdin = strings.NewReader(scriptContent)
settingSnapshot := config.GetSettings()
if settingSnapshot.Debug {
config.DebugLog("SSH ensureAction command [%s]: ssh %s (with here-doc via stdin)", sc.server.Name, strings.Join(args, " "))
}
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil {
config.DebugLog("Failed to ensure action file for server %s: %v (output: %s)", sc.server.Name, err, output)
return fmt.Errorf("failed to ensure action file on remote server %s: %w (remote output: %s)", sc.server.Name, err, output)
}
if output != "" {
config.DebugLog("Successfully ensured action file for server %s (output: %s)", sc.server.Name, output)
} else {
config.DebugLog("Successfully ensured action file for server %s (no output)", sc.server.Name)
}
return nil
}
func (sc *SSHConnector) getJails(ctx context.Context) ([]string, error) {
out, err := sc.runFail2banCommand(ctx, "status")
if err != nil {
return nil, err
}
var jails []string
lines := strings.Split(out, "\n")
for _, line := range lines {
if strings.Contains(line, "Jail list:") {
parts := strings.Split(line, ":")
if len(parts) > 1 {
raw := strings.TrimSpace(parts[1])
jails = strings.Split(raw, ",")
for i := range jails {
jails[i] = strings.TrimSpace(jails[i])
}
}
}
}
return jails, nil
}
func (sc *SSHConnector) runFail2banCommand(ctx context.Context, args ...string) (string, error) {
fail2banArgs := sc.buildFail2banArgs(args...)
cmdArgs := append([]string{"sudo", "fail2ban-client"}, fail2banArgs...)
return sc.runRemoteCommand(ctx, cmdArgs)
}
func (sc *SSHConnector) buildFail2banArgs(args ...string) []string {
if sc.server.SocketPath == "" {
return args
}
base := []string{"-s", sc.server.SocketPath}
return append(base, args...)
}
func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) (string, error) {
args := sc.buildSSHArgs(command)
cmd := exec.CommandContext(ctx, "ssh", args...)
settingSnapshot := config.GetSettings()
if settingSnapshot.Debug {
config.DebugLog("SSH command [%s]: ssh %s", sc.server.Name, strings.Join(args, " "))
}
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil {
if settingSnapshot.Debug {
config.DebugLog("SSH command error [%s]: %v | output: %s", sc.server.Name, err, output)
}
return output, fmt.Errorf("ssh command failed: %w (output: %s)", err, output)
}
if settingSnapshot.Debug {
config.DebugLog("SSH command output [%s]: %s", sc.server.Name, output)
}
return output, nil
}
func (sc *SSHConnector) buildSSHArgs(command []string) []string {
args := []string{"-o", "BatchMode=yes"}
// In containerized environments, disable strict host key checking
if _, container := os.LookupEnv("CONTAINER"); container {
args = append(args,
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
)
}
if sc.server.SSHKeyPath != "" {
args = append(args, "-i", sc.server.SSHKeyPath)
}
if sc.server.Port > 0 {
args = append(args, "-p", strconv.Itoa(sc.server.Port))
}
target := sc.server.Host
if sc.server.SSHUser != "" {
target = fmt.Sprintf("%s@%s", sc.server.SSHUser, target)
}
args = append(args, target)
args = append(args, command...)
return args
}
// GetAllJails implements Connector.
func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
// Read jail.local and jail.d files remotely
var allJails []JailInfo
// Parse jail.local
jailLocalContent, err := sc.runRemoteCommand(ctx, []string{"cat", "/etc/fail2ban/jail.local"})
if err == nil {
jails := parseJailConfigContent(jailLocalContent)
allJails = append(allJails, jails...)
}
// Parse jail.d directory
jailDCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f"
jailDList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDCmd})
if err == nil && jailDList != "" {
for _, file := range strings.Split(jailDList, "\n") {
file = strings.TrimSpace(file)
if file == "" {
continue
}
content, err := sc.runRemoteCommand(ctx, []string{"cat", file})
if err == nil {
jails := parseJailConfigContent(content)
allJails = append(allJails, jails...)
}
}
}
return allJails, nil
}
// UpdateJailEnabledStates implements Connector.
func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
// Read current jail.local
content, err := sc.runRemoteCommand(ctx, []string{"cat", "/etc/fail2ban/jail.local"})
if err != nil {
return fmt.Errorf("failed to read jail.local: %w", err)
}
// Update enabled states
lines := strings.Split(content, "\n")
var outputLines []string
var currentJail string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
currentJail = strings.Trim(trimmed, "[]")
outputLines = append(outputLines, line)
} else if strings.HasPrefix(trimmed, "enabled") {
if val, ok := updates[currentJail]; ok {
outputLines = append(outputLines, fmt.Sprintf("enabled = %t", val))
delete(updates, currentJail)
} else {
outputLines = append(outputLines, line)
}
} else {
outputLines = append(outputLines, line)
}
}
// Write back
newContent := strings.Join(outputLines, "\n")
cmd := fmt.Sprintf("cat <<'EOF' | tee /etc/fail2ban/jail.local >/dev/null\n%s\nEOF", newContent)
_, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
return err
}
// GetFilters implements Connector.
func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) {
// Use find to list filter files
list, err := sc.runRemoteCommand(ctx, []string{"find", "/etc/fail2ban/filter.d", "-maxdepth", "1", "-type", "f"})
if err != nil {
return nil, fmt.Errorf("failed to list filters: %w", err)
}
// Filter for .conf files and extract names in Go
var filters []string
seen := make(map[string]bool) // Avoid duplicates
for _, line := range strings.Split(list, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Only process .conf files - be strict about the extension
if !strings.HasSuffix(line, ".conf") {
continue
}
// Exclude backup files and other non-filter files
if strings.HasSuffix(line, ".conf.bak") ||
strings.HasSuffix(line, ".conf~") ||
strings.HasSuffix(line, ".conf.old") ||
strings.HasSuffix(line, ".conf.rpmnew") ||
strings.HasSuffix(line, ".conf.rpmsave") ||
strings.Contains(line, "README") {
continue
}
parts := strings.Split(line, "/")
if len(parts) > 0 {
filename := parts[len(parts)-1]
// Double-check it ends with .conf
if !strings.HasSuffix(filename, ".conf") {
continue
}
name := strings.TrimSuffix(filename, ".conf")
if name != "" && !seen[name] {
seen[name] = true
filters = append(filters, name)
}
}
}
return filters, nil
}
// TestFilter implements Connector.
func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) {
if len(logLines) == 0 {
return []string{}, nil
}
// Sanitize filter name to prevent path traversal
filterName = strings.TrimSpace(filterName)
if filterName == "" {
return nil, fmt.Errorf("filter name cannot be empty")
}
// Remove any path components
filterName = strings.ReplaceAll(filterName, "/", "")
filterName = strings.ReplaceAll(filterName, "..", "")
// Use fail2ban-regex with filter name directly - it handles everything
// Format: fail2ban-regex "log line" /etc/fail2ban/filter.d/filter-name.conf
filterPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName)
var matches []string
for _, logLine := range logLines {
logLine = strings.TrimSpace(logLine)
if logLine == "" {
continue
}
// Use fail2ban-regex: log line as string, filter file path
escapedLine := strconv.Quote(logLine)
escapedPath := strconv.Quote(filterPath)
cmd := fmt.Sprintf("echo %s | fail2ban-regex - %s", escapedLine, escapedPath)
out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", cmd})
// fail2ban-regex returns success (exit 0) if the line matches
// Look for "Lines: 1 lines, 0 ignored, 1 matched" or similar success indicators
if err == nil {
// Check if output indicates a match
output := strings.ToLower(out)
if strings.Contains(output, "matched") ||
strings.Contains(output, "success") ||
strings.Contains(output, "1 matched") {
matches = append(matches, logLine)
}
}
}
return matches, nil
}
// parseJailConfigContent parses jail configuration content and returns JailInfo slice.
func parseJailConfigContent(content string) []JailInfo {
var jails []JailInfo
scanner := bufio.NewScanner(strings.NewReader(content))
var currentJail string
enabled := true
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
if currentJail != "" && currentJail != "DEFAULT" {
jails = append(jails, JailInfo{
JailName: currentJail,
Enabled: enabled,
})
}
currentJail = strings.Trim(line, "[]")
enabled = true
} else if strings.HasPrefix(strings.ToLower(line), "enabled") {
parts := strings.Split(line, "=")
if len(parts) == 2 {
value := strings.TrimSpace(parts[1])
enabled = strings.EqualFold(value, "true")
}
}
}
if currentJail != "" && currentJail != "DEFAULT" {
jails = append(jails, JailInfo{
JailName: currentJail,
Enabled: enabled,
})
}
return jails
}