diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 591f1fd06..ba26e0460 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -59,6 +59,7 @@ import { NodeRegistry } from "./node-registry.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; import { safeParseJson } from "./server-methods/nodes.helpers.js"; import { initSecurityShield } from "../security/shield.js"; +import { initFirewallManager } from "../security/firewall/manager.js"; import { loadGatewayPlugins } from "./server-plugins.js"; import { createGatewayReloadHandlers } from "./server-reload-handlers.js"; import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; @@ -220,6 +221,15 @@ export async function startGatewayServer( // Initialize security shield with configuration initSecurityShield(cfgAtStart.security?.shield); + // Initialize firewall integration + if (cfgAtStart.security?.shield?.ipManagement?.firewall?.enabled) { + await initFirewallManager({ + enabled: true, + backend: cfgAtStart.security.shield.ipManagement.firewall.backend ?? "iptables", + dryRun: false, + }); + } + initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId); diff --git a/src/security/firewall/iptables.ts b/src/security/firewall/iptables.ts new file mode 100644 index 000000000..7831dbb1d --- /dev/null +++ b/src/security/firewall/iptables.ts @@ -0,0 +1,138 @@ +/** + * iptables firewall backend + * Requires sudo/CAP_NET_ADMIN capability + */ + +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import type { FirewallBackendInterface } from "./types.js"; + +const execAsync = promisify(exec); + +const CHAIN_NAME = "OPENCLAW_BLOCKLIST"; +const COMMENT_PREFIX = "openclaw-block"; + +export class IptablesBackend implements FirewallBackendInterface { + private initialized = false; + + async isAvailable(): Promise { + try { + await execAsync("which iptables"); + return true; + } catch { + return false; + } + } + + private async ensureChain(): Promise { + if (this.initialized) return; + + try { + // Check if chain exists + await execAsync(`iptables -L ${CHAIN_NAME} -n 2>/dev/null`); + } catch { + // Create chain if it doesn't exist + try { + await execAsync(`iptables -N ${CHAIN_NAME}`); + // Insert chain into INPUT at the beginning + await execAsync(`iptables -I INPUT -j ${CHAIN_NAME}`); + } catch (err) { + throw new Error(`Failed to create iptables chain: ${String(err)}`); + } + } + + this.initialized = true; + } + + async blockIp(ip: string): Promise<{ ok: boolean; error?: string }> { + try { + await this.ensureChain(); + + // Check if already blocked + const alreadyBlocked = await this.isIpBlocked(ip); + if (alreadyBlocked) { + return { ok: true }; + } + + // Add block rule with comment + const comment = `${COMMENT_PREFIX}:${ip}`; + await execAsync( + `iptables -A ${CHAIN_NAME} -s ${ip} -j DROP -m comment --comment "${comment}"`, + ); + + return { ok: true }; + } catch (err) { + const error = String(err); + if (error.includes("Permission denied") || error.includes("Operation not permitted")) { + return { + ok: false, + error: "Insufficient permissions (requires sudo or CAP_NET_ADMIN)", + }; + } + return { ok: false, error }; + } + } + + async unblockIp(ip: string): Promise<{ ok: boolean; error?: string }> { + try { + await this.ensureChain(); + + // Delete all rules matching this IP + const comment = `${COMMENT_PREFIX}:${ip}`; + try { + await execAsync( + `iptables -D ${CHAIN_NAME} -s ${ip} -j DROP -m comment --comment "${comment}"`, + ); + } catch { + // Rule might not exist, that's okay + } + + return { ok: true }; + } catch (err) { + const error = String(err); + if (error.includes("Permission denied") || error.includes("Operation not permitted")) { + return { + ok: false, + error: "Insufficient permissions (requires sudo or CAP_NET_ADMIN)", + }; + } + return { ok: false, error }; + } + } + + async listBlockedIps(): Promise { + try { + await this.ensureChain(); + + const { stdout } = await execAsync(`iptables -L ${CHAIN_NAME} -n --line-numbers`); + const ips: string[] = []; + + // Parse iptables output + const lines = stdout.split("\n"); + for (const line of lines) { + // Look for DROP rules with our comment + if (line.includes("DROP") && line.includes(COMMENT_PREFIX)) { + const parts = line.trim().split(/\s+/); + // Source IP is typically in column 4 (after num, target, prot) + const sourceIp = parts[3]; + if (sourceIp && sourceIp !== "0.0.0.0/0" && sourceIp !== "anywhere") { + ips.push(sourceIp); + } + } + } + + return ips; + } catch { + return []; + } + } + + async isIpBlocked(ip: string): Promise { + try { + const blockedIps = await this.listBlockedIps(); + return blockedIps.includes(ip); + } catch { + return false; + } + } +} diff --git a/src/security/firewall/manager.ts b/src/security/firewall/manager.ts new file mode 100644 index 000000000..13351990e --- /dev/null +++ b/src/security/firewall/manager.ts @@ -0,0 +1,212 @@ +/** + * Firewall manager + * Coordinates firewall backends and integrates with IP manager + */ + +import os from "node:os"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { FirewallBackendInterface, FirewallManagerConfig } from "./types.js"; +import { IptablesBackend } from "./iptables.js"; +import { UfwBackend } from "./ufw.js"; + +const log = createSubsystemLogger("security:firewall"); + +export class FirewallManager { + private backend: FirewallBackendInterface | null = null; + private config: FirewallManagerConfig; + private backendAvailable = false; + + constructor(config: FirewallManagerConfig) { + this.config = config; + } + + /** + * Initialize firewall backend + */ + async initialize(): Promise<{ ok: boolean; error?: string }> { + // Only enable on Linux + if (os.platform() !== "linux") { + log.info("firewall integration only supported on Linux"); + return { ok: false, error: "unsupported_platform" }; + } + + if (!this.config.enabled) { + log.info("firewall integration disabled"); + return { ok: false, error: "disabled" }; + } + + // Create backend + if (this.config.backend === "iptables") { + this.backend = new IptablesBackend(); + } else if (this.config.backend === "ufw") { + this.backend = new UfwBackend(); + } else { + return { ok: false, error: `unknown backend: ${this.config.backend}` }; + } + + // Check availability + const available = await this.backend.isAvailable(); + if (!available) { + log.warn(`firewall backend ${this.config.backend} not available`); + return { ok: false, error: "backend_not_available" }; + } + + this.backendAvailable = true; + log.info(`firewall integration active (backend=${this.config.backend})`); + return { ok: true }; + } + + /** + * Check if firewall integration is enabled and available + */ + isEnabled(): boolean { + return this.config.enabled && this.backendAvailable && this.backend !== null; + } + + /** + * Block an IP address + */ + async blockIp(ip: string, reason: string): Promise<{ ok: boolean; error?: string }> { + if (!this.isEnabled() || !this.backend) { + return { ok: false, error: "firewall_not_enabled" }; + } + + if (this.config.dryRun) { + log.info(`[dry-run] would block IP ${ip} (reason: ${reason})`); + return { ok: true }; + } + + log.info(`blocking IP ${ip} via ${this.config.backend} (reason: ${reason})`); + const result = await this.backend.blockIp(ip); + + if (!result.ok) { + log.error(`failed to block IP ${ip}: ${result.error}`); + } + + return result; + } + + /** + * Unblock an IP address + */ + async unblockIp(ip: string): Promise<{ ok: boolean; error?: string }> { + if (!this.isEnabled() || !this.backend) { + return { ok: false, error: "firewall_not_enabled" }; + } + + if (this.config.dryRun) { + log.info(`[dry-run] would unblock IP ${ip}`); + return { ok: true }; + } + + log.info(`unblocking IP ${ip} via ${this.config.backend}`); + const result = await this.backend.unblockIp(ip); + + if (!result.ok) { + log.error(`failed to unblock IP ${ip}: ${result.error}`); + } + + return result; + } + + /** + * List all blocked IPs + */ + async listBlockedIps(): Promise { + if (!this.isEnabled() || !this.backend) { + return []; + } + + return await this.backend.listBlockedIps(); + } + + /** + * Check if an IP is blocked + */ + async isIpBlocked(ip: string): Promise { + if (!this.isEnabled() || !this.backend) { + return false; + } + + return await this.backend.isIpBlocked(ip); + } + + /** + * Synchronize blocklist with firewall + * Adds missing blocks and removes stale blocks + */ + async synchronize(blocklist: string[]): Promise<{ + added: number; + removed: number; + errors: string[]; + }> { + if (!this.isEnabled() || !this.backend) { + return { added: 0, removed: 0, errors: ["firewall_not_enabled"] }; + } + + const currentBlocks = await this.listBlockedIps(); + const desiredBlocks = new Set(blocklist); + const currentSet = new Set(currentBlocks); + + let added = 0; + let removed = 0; + const errors: string[] = []; + + // Add missing blocks + for (const ip of blocklist) { + if (!currentSet.has(ip)) { + const result = await this.blockIp(ip, "sync"); + if (result.ok) { + added++; + } else { + errors.push(`Failed to block ${ip}: ${result.error}`); + } + } + } + + // Remove stale blocks + for (const ip of currentBlocks) { + if (!desiredBlocks.has(ip)) { + const result = await this.unblockIp(ip); + if (result.ok) { + removed++; + } else { + errors.push(`Failed to unblock ${ip}: ${result.error}`); + } + } + } + + if (added > 0 || removed > 0) { + log.info(`firewall sync: added=${added} removed=${removed}`); + } + + if (errors.length > 0) { + log.error(`firewall sync errors: ${errors.join(", ")}`); + } + + return { added, removed, errors }; + } +} + +/** + * Singleton firewall manager + */ +let firewallManager: FirewallManager | null = null; + +/** + * Initialize firewall manager with config + */ +export async function initFirewallManager( + config: FirewallManagerConfig, +): Promise { + firewallManager = new FirewallManager(config); + await firewallManager.initialize(); + return firewallManager; +} + +/** + * Get firewall manager instance + */ +export function getFirewallManager(): FirewallManager | null { + return firewallManager; +} diff --git a/src/security/firewall/types.ts b/src/security/firewall/types.ts new file mode 100644 index 000000000..5e8978d9a --- /dev/null +++ b/src/security/firewall/types.ts @@ -0,0 +1,45 @@ +/** + * Firewall integration types + */ + +export type FirewallBackend = "iptables" | "ufw"; + +export interface FirewallRule { + ip: string; + action: "block" | "allow"; + reason: string; + createdAt: string; +} + +export interface FirewallBackendInterface { + /** + * Check if this backend is available on the system + */ + isAvailable(): Promise; + + /** + * Block an IP address + */ + blockIp(ip: string): Promise<{ ok: boolean; error?: string }>; + + /** + * Unblock an IP address + */ + unblockIp(ip: string): Promise<{ ok: boolean; error?: string }>; + + /** + * List all blocked IPs managed by this system + */ + listBlockedIps(): Promise; + + /** + * Check if an IP is blocked + */ + isIpBlocked(ip: string): Promise; +} + +export interface FirewallManagerConfig { + enabled: boolean; + backend: FirewallBackend; + dryRun?: boolean; +} diff --git a/src/security/firewall/ufw.ts b/src/security/firewall/ufw.ts new file mode 100644 index 000000000..63e7c4fa5 --- /dev/null +++ b/src/security/firewall/ufw.ts @@ -0,0 +1,102 @@ +/** + * ufw (Uncomplicated Firewall) backend + * Requires sudo capability + */ + +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import type { FirewallBackendInterface } from "./types.js"; + +const execAsync = promisify(exec); + +const RULE_COMMENT = "openclaw-blocklist"; + +export class UfwBackend implements FirewallBackendInterface { + async isAvailable(): Promise { + try { + await execAsync("which ufw"); + return true; + } catch { + return false; + } + } + + async blockIp(ip: string): Promise<{ ok: boolean; error?: string }> { + try { + // Check if already blocked + const alreadyBlocked = await this.isIpBlocked(ip); + if (alreadyBlocked) { + return { ok: true }; + } + + // Add deny rule with comment + await execAsync(`ufw insert 1 deny from ${ip} comment '${RULE_COMMENT}'`); + + return { ok: true }; + } catch (err) { + const error = String(err); + if (error.includes("Permission denied") || error.includes("need to be root")) { + return { + ok: false, + error: "Insufficient permissions (requires sudo)", + }; + } + return { ok: false, error }; + } + } + + async unblockIp(ip: string): Promise<{ ok: boolean; error?: string }> { + try { + // Delete deny rule + try { + await execAsync(`ufw delete deny from ${ip}`); + } catch { + // Rule might not exist, that's okay + } + + return { ok: true }; + } catch (err) { + const error = String(err); + if (error.includes("Permission denied") || error.includes("need to be root")) { + return { + ok: false, + error: "Insufficient permissions (requires sudo)", + }; + } + return { ok: false, error }; + } + } + + async listBlockedIps(): Promise { + try { + const { stdout } = await execAsync("ufw status numbered"); + const ips: string[] = []; + + // Parse ufw output + const lines = stdout.split("\n"); + for (const line of lines) { + // Look for DENY rules with our comment + if (line.includes("DENY") && line.includes(RULE_COMMENT)) { + // Extract IP from line like: "[ 1] DENY IN 192.168.1.100" + const match = line.match(/(\d+\.\d+\.\d+\.\d+)/); + if (match && match[1]) { + ips.push(match[1]); + } + } + } + + return ips; + } catch { + return []; + } + } + + async isIpBlocked(ip: string): Promise { + try { + const blockedIps = await this.listBlockedIps(); + return blockedIps.includes(ip); + } catch { + return false; + } + } +} diff --git a/src/security/ip-manager.ts b/src/security/ip-manager.ts index 6d4671670..157fcfb7c 100644 --- a/src/security/ip-manager.ts +++ b/src/security/ip-manager.ts @@ -9,6 +9,7 @@ import os from "node:os"; import { securityLogger } from "./events/logger.js"; import { SecurityActions } from "./events/schema.js"; +import { getFirewallManager } from "./firewall/manager.js"; const BLOCKLIST_FILE = "blocklist.json"; const SECURITY_DIR_NAME = "security"; @@ -241,6 +242,19 @@ export class IpManager { source, }, }); + + // Update firewall (async, fire-and-forget) + const firewall = getFirewallManager(); + if (firewall?.isEnabled()) { + firewall.blockIp(ip, reason).catch((err) => { + securityLogger.logIpManagement({ + action: "firewall_block_failed", + ip, + severity: "error", + details: { error: String(err) }, + }); + }); + } } /** @@ -260,6 +274,19 @@ export class IpManager { severity: "info", details: {}, }); + + // Update firewall (async, fire-and-forget) + const firewall = getFirewallManager(); + if (firewall?.isEnabled()) { + firewall.unblockIp(ip).catch((err) => { + securityLogger.logIpManagement({ + action: "firewall_unblock_failed", + ip, + severity: "error", + details: { error: String(err) }, + }); + }); + } } return removed;