feat(security): implement firewall integration (iptables/ufw)
This commit is contained in:
parent
5c74668413
commit
88bcb61c7b
@ -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);
|
||||
|
||||
138
src/security/firewall/iptables.ts
Normal file
138
src/security/firewall/iptables.ts
Normal file
@ -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<boolean> {
|
||||
try {
|
||||
await execAsync("which iptables");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureChain(): Promise<void> {
|
||||
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<string[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const blockedIps = await this.listBlockedIps();
|
||||
return blockedIps.includes(ip);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
212
src/security/firewall/manager.ts
Normal file
212
src/security/firewall/manager.ts
Normal file
@ -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<string[]> {
|
||||
if (!this.isEnabled() || !this.backend) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.backend.listBlockedIps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP is blocked
|
||||
*/
|
||||
async isIpBlocked(ip: string): Promise<boolean> {
|
||||
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> {
|
||||
firewallManager = new FirewallManager(config);
|
||||
await firewallManager.initialize();
|
||||
return firewallManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get firewall manager instance
|
||||
*/
|
||||
export function getFirewallManager(): FirewallManager | null {
|
||||
return firewallManager;
|
||||
}
|
||||
45
src/security/firewall/types.ts
Normal file
45
src/security/firewall/types.ts
Normal file
@ -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<boolean>;
|
||||
|
||||
/**
|
||||
* 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<string[]>;
|
||||
|
||||
/**
|
||||
* Check if an IP is blocked
|
||||
*/
|
||||
isIpBlocked(ip: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface FirewallManagerConfig {
|
||||
enabled: boolean;
|
||||
backend: FirewallBackend;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
102
src/security/firewall/ufw.ts
Normal file
102
src/security/firewall/ufw.ts
Normal file
@ -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<boolean> {
|
||||
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<string[]> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const blockedIps = await this.listBlockedIps();
|
||||
return blockedIps.includes(ip);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user