Skip to main content
Glama

OPNSense MCP Server

rule.ts26.9 kB
// Firewall Rule Resource Implementation import { OPNSenseAPIClient } from '../../api/client.js'; import { InterfaceMapper } from '../../utils/interface-mapper.js'; export interface FirewallRule { uuid?: string; enabled: string; // '0' or '1' sequence?: string; // Rule order/priority action: string; // 'pass', 'block', 'reject' quick?: string; // '0' or '1' - stop processing after match interface: string; // Interface name or alias direction: string; // 'in' or 'out' ipprotocol: string; // 'inet' (IPv4), 'inet6' (IPv6), or 'inet46' (both) protocol: string; // 'any', 'tcp', 'udp', 'icmp', etc. source_net: string; // Source address/network or alias source_port?: string; // Source port or port range destination_net: string; // Destination address/network or alias destination_port?: string; // Destination port or port range gateway?: string; // Optional gateway for policy routing log?: string; // '0' or '1' - log packets matching this rule description?: string; // Rule description category?: string; // Rule category for organization } export class FirewallRuleResource { private client: OPNSenseAPIClient; private interfaceMapper: InterfaceMapper; private interfacesLoaded: boolean = false; private rulesCache: Map<string, FirewallRule> = new Map(); private debugMode: boolean = process.env.MCP_DEBUG === 'true' || process.env.DEBUG_FIREWALL === 'true'; constructor(client: OPNSenseAPIClient) { this.client = client; this.interfaceMapper = new InterfaceMapper(); } /** * List all firewall rules * PRIMARY METHOD: Uses getAllRules() which reliably fetches from filter.rules.rule */ async list(): Promise<FirewallRule[]> { if (this.debugMode) { console.log('[FirewallRuleResource] Starting list() operation'); } try { // ALWAYS use getAllRules() as the primary method // The searchRule endpoint is unreliable and often returns 0 results const allRules = await this.getAllRules(); if (this.debugMode) { console.log(`[FirewallRuleResource] getAllRules() returned ${allRules.length} rules`); } // Update cache with fetched rules allRules.forEach(rule => { if (rule.uuid) { this.rulesCache.set(rule.uuid, rule); } }); // Sort rules by sequence if available allRules.sort((a, b) => { const seqA = parseInt(a.sequence || '0', 10); const seqB = parseInt(b.sequence || '0', 10); return seqA - seqB; }); return allRules; } catch (error) { console.error('Error listing firewall rules:', error); // Return cached rules as last resort if (this.rulesCache.size > 0) { console.log('Returning cached rules due to API error'); return Array.from(this.rulesCache.values()); } } return []; } /** * Get a specific firewall rule by UUID */ async get(uuid: string): Promise<FirewallRule | null> { if (this.debugMode) { console.log(`[FirewallRuleResource] Getting rule ${uuid}`); } try { const response = await this.client.get(`/firewall/filter/getRule/${uuid}`); if (response?.rule) { if (this.debugMode) { console.log(`[FirewallRuleResource] Rule ${uuid} found:`, response.rule); } return { uuid, ...response.rule }; } } catch (error) { console.warn(`Could not get rule ${uuid}:`, error); } if (this.debugMode) { console.log(`[FirewallRuleResource] Rule ${uuid} not found`); } return null; } /** * Get all firewall rules using the get endpoint * This is the PRIMARY method that reliably fetches ALL rules including API-created ones */ async getAllRules(): Promise<FirewallRule[]> { if (this.debugMode) { console.log('[FirewallRuleResource] Fetching all rules via /firewall/filter/get'); } try { const response = await this.client.get('/firewall/filter/get'); if (this.debugMode) { console.log('[FirewallRuleResource] /firewall/filter/get response structure:', { hasFilter: !!response?.filter, hasRules: !!response?.filter?.rules, hasRulesRule: !!response?.filter?.rules?.rule, rulesType: typeof response?.filter?.rules, rulesRuleType: typeof response?.filter?.rules?.rule }); } // The rules are stored at filter.rules.rule as an object with UUID keys if (response?.filter?.rules?.rule) { const rulesObj = response.filter.rules.rule; // Convert object with UUIDs as keys to array if (typeof rulesObj === 'object' && !Array.isArray(rulesObj)) { const rulesArray = Object.entries(rulesObj).map(([uuid, rule]: [string, any]) => { // Extract simplified rule data from the complex structure return { uuid, enabled: rule.enabled || '0', sequence: rule.sequence, action: this.extractSelectedValue(rule.action) || 'block', quick: rule.quick || '1', interface: this.extractSelectedValue(rule.interface) || '', direction: this.extractSelectedValue(rule.direction) || 'in', ipprotocol: this.extractSelectedValue(rule.ipprotocol) || 'inet', protocol: this.extractSelectedValue(rule.protocol) || 'any', source_net: rule.source_net || 'any', source_port: rule.source_port || '', destination_net: rule.destination_net || 'any', destination_port: rule.destination_port || '', gateway: this.extractSelectedValue(rule.gateway) || '', log: rule.log || '0', description: rule.description || '', category: this.extractSelectedValue(rule.categories) || '' }; }); if (this.debugMode) { console.log(`[FirewallRuleResource] Converted ${rulesArray.length} rules from filter.rules.rule`); } return rulesArray; } // If already an array (unlikely), return it if (Array.isArray(rulesObj)) { if (this.debugMode) { console.log(`[FirewallRuleResource] Rules already in array format: ${rulesObj.length} rules`); } return rulesObj; } } // Fallback: check if rules are at filter.rules directly if (response?.filter?.rules && typeof response.filter.rules === 'object') { const rules = response.filter.rules; // Skip if it only contains 'rule' key (already handled above) if (Object.keys(rules).length === 1 && rules.rule) { return []; } // Convert object with UUIDs as keys to array const rulesArray = Object.entries(rules) .filter(([key]) => key.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)) .map(([uuid, rule]: [string, any]) => ({ uuid, ...rule })); if (rulesArray.length > 0) { if (this.debugMode) { console.log(`[FirewallRuleResource] Found ${rulesArray.length} rules at filter.rules`); } return rulesArray; } } } catch (error) { console.warn('Could not fetch all rules via get endpoint:', error); } return []; } /** * Helper to extract selected value from OPNsense option objects */ private extractSelectedValue(optionObj: any): string { if (!optionObj || typeof optionObj !== 'object') { return optionObj || ''; } // Find the selected option for (const [key, value] of Object.entries(optionObj)) { if (value && typeof value === 'object' && (value as any).selected === 1) { return key; } } return ''; } /** * Find rules by description */ async findByDescription(description: string): Promise<FirewallRule[]> { const rules = await this.list(); return rules.filter(r => r.description?.toLowerCase().includes(description.toLowerCase()) ); } /** * Load interface mappings from API */ private async ensureInterfacesLoaded(): Promise<void> { if (this.interfacesLoaded) return; try { // Get available options including interfaces const options = await this.client.get('/firewall/filter/getRule'); if (options?.rule?.interface?.values) { this.interfaceMapper = new InterfaceMapper(options.rule.interface.values); this.interfacesLoaded = true; } } catch (error) { console.warn('Failed to load interface mappings, using defaults'); } } /** * Create a new firewall rule */ async create(rule: FirewallRule): Promise<{ uuid: string; success: boolean }> { if (this.debugMode) { console.log('[FirewallRuleResource] Creating rule:', rule); } // Ensure interface mappings are loaded await this.ensureInterfacesLoaded(); // Map interface and protocol to OPNsense format const mappedRule = { ...rule }; const originalInterface = rule.interface; mappedRule.interface = this.interfaceMapper.mapInterface(rule.interface); mappedRule.protocol = InterfaceMapper.mapProtocol(rule.protocol); if (this.debugMode) { console.log('[FirewallRuleResource] Interface mapping:', { original: originalInterface, mapped: mappedRule.interface }); console.log('[FirewallRuleResource] Protocol mapping:', { original: rule.protocol, mapped: mappedRule.protocol }); } // Validate rule const errors = this.validateRule(mappedRule); if (errors.length > 0) { throw new Error(`Invalid rule: ${errors.join(', ')}`); } // Prepare rule data with defaults const ruleData = { enabled: mappedRule.enabled || '1', action: mappedRule.action, quick: mappedRule.quick || '1', interface: mappedRule.interface, direction: mappedRule.direction, ipprotocol: mappedRule.ipprotocol || 'inet', protocol: mappedRule.protocol, source_net: mappedRule.source_net, source_port: mappedRule.source_port || '', destination_net: mappedRule.destination_net, destination_port: mappedRule.destination_port || '', gateway: mappedRule.gateway || '', log: mappedRule.log || '0', description: mappedRule.description || '', category: mappedRule.category || '' }; // Add the rule if (this.debugMode) { console.log('[FirewallRuleResource] Sending addRule request:', { rule: ruleData }); } const response = await this.client.post('/firewall/filter/addRule', { rule: ruleData }); if (this.debugMode) { console.log('[FirewallRuleResource] addRule response:', response); } if (response.uuid) { // Store in cache immediately const createdRule: FirewallRule = { uuid: response.uuid, ...ruleData }; this.rulesCache.set(response.uuid, createdRule); // Apply changes and save configuration to persist await this.applyChanges(); // Force a refresh of the rules to ensure consistency // This helps ensure the rule is properly loaded in OPNsense's internal state try { const refreshedRules = await this.getAllRules(); if (this.debugMode) { console.log(`[FirewallRuleResource] After refresh, total rules: ${refreshedRules.length}`); const foundInList = refreshedRules.some(r => r.uuid === response.uuid); console.log(`[FirewallRuleResource] New rule ${response.uuid} found in list: ${foundInList}`); } } catch (refreshError) { console.warn('Could not refresh rules after creation:', refreshError); } // Verify the rule was created successfully const verifyRule = await this.get(response.uuid); if (verifyRule) { // Update cache with verified rule this.rulesCache.set(response.uuid, verifyRule); console.log(`Rule ${response.uuid} created and verified successfully`); // Double-check it appears in the list const allRules = await this.list(); const inList = allRules.some(r => r.uuid === response.uuid); if (!inList) { console.warn(`WARNING: Rule ${response.uuid} exists individually but not in list!`); console.warn('This may indicate a persistence issue. Attempting additional apply...'); // Try additional apply methods await this.forceApply(); } } else { console.warn(`Warning: Rule ${response.uuid} was created but could not be verified immediately`); } return { uuid: response.uuid, success: true }; } throw new Error('Failed to create firewall rule: No UUID returned'); } /** * Update a firewall rule */ async update(uuid: string, updates: Partial<FirewallRule>): Promise<boolean> { const existing = await this.get(uuid); if (!existing) { throw new Error(`Firewall rule ${uuid} not found`); } const updatedRule = { ...existing, ...updates, uuid: undefined // Remove UUID from data }; await this.client.post(`/firewall/filter/setRule/${uuid}`, { rule: updatedRule }); await this.applyChanges(); return true; } /** * Delete a firewall rule */ async delete(uuid: string): Promise<boolean> { const response = await this.client.post(`/firewall/filter/delRule/${uuid}`); if (response.result === 'deleted') { await this.applyChanges(); return true; } throw new Error(`Failed to delete rule ${uuid}`); } /** * Toggle rule enabled/disabled */ async toggle(uuid: string): Promise<boolean> { const rule = await this.get(uuid); if (!rule) { throw new Error(`Firewall rule ${uuid} not found`); } const newState = rule.enabled === '1' ? '0' : '1'; return this.update(uuid, { enabled: newState }); } /** * Apply firewall changes and save configuration */ async applyChanges(): Promise<any> { if (this.debugMode) { console.log('[FirewallRuleResource] Starting applyChanges()'); } try { // First apply the firewall changes const applyResponse = await this.client.post('/firewall/filter/apply'); if (this.debugMode) { console.log('[FirewallRuleResource] apply response:', applyResponse); } // Add a delay to allow changes to propagate await new Promise(resolve => setTimeout(resolve, 500)); // Save and reconfigure to ensure persistence // OPNsense requires both apply and reconfigure for full persistence try { // Try to reconfigure the filter service const reconfigureResponse = await this.client.post('/firewall/filter/reconfigure'); console.log('Firewall filter reconfigured:', reconfigureResponse); } catch (reconfigError) { if (this.debugMode) { console.log('[FirewallRuleResource] reconfigure failed, trying savepoint:', reconfigError); } // If reconfigure fails, try the savepoint approach try { const savepointResponse = await this.client.post('/firewall/filter/savepoint'); console.log('Firewall configuration saved via savepoint:', savepointResponse); } catch (savepointError) { console.warn('Could not save firewall configuration:', savepointError); } } // Additional delay for the reconfiguration to complete await new Promise(resolve => setTimeout(resolve, 1000)); return applyResponse; } catch (error) { console.error('Failed to apply firewall changes:', error); throw error; } } /** * Force apply changes using multiple methods */ async forceApply(): Promise<void> { if (this.debugMode) { console.log('[FirewallRuleResource] Starting forceApply() - trying all apply methods'); } const applyMethods = [ { endpoint: '/firewall/filter/apply', name: 'apply' }, { endpoint: '/firewall/filter/reconfigure', name: 'reconfigure' }, { endpoint: '/firewall/filter/savepoint', name: 'savepoint' }, { endpoint: '/firewall/filter/reload', name: 'reload' }, { endpoint: '/firewall/filter/commit', name: 'commit' } ]; for (const method of applyMethods) { try { const response = await this.client.post(method.endpoint); console.log(`[FirewallRuleResource] ${method.name} succeeded:`, response); await new Promise(resolve => setTimeout(resolve, 500)); } catch (error: any) { if (this.debugMode) { console.log(`[FirewallRuleResource] ${method.name} failed:`, error?.message || error); } } } // Final delay for all changes to propagate await new Promise(resolve => setTimeout(resolve, 2000)); } /** * Get available options for rule creation */ async getOptions(): Promise<any> { // This endpoint provides dropdowns for interfaces, protocols, etc. const response = await this.client.get('/firewall/filter/getRule'); return response?.rule || {}; } /** * Debug method to discover valid interface names */ async debugInterfaces(): Promise<void> { console.log('\n[FirewallRuleResource] Discovering interfaces...'); try { // Method 1: Get from rule options const options = await this.getOptions(); if (options?.interface?.values) { console.log('\nAvailable interfaces from getRule:'); Object.entries(options.interface.values).forEach(([key, value]: [string, any]) => { console.log(` ${key}: ${value.value}`); }); } // Method 2: Try common interface names const testInterfaces = [ 'lan', 'wan', 'opt1', 'opt2', 'opt3', 'dmz', 'DMZ', 'igc3_vlan6', 'igc3_vlan4', 'igc3_vlan2', 'vlan6', 'vlan4', 'vlan2' ]; console.log('\nTesting common interface names:'); for (const iface of testInterfaces) { const mapped = this.interfaceMapper.mapInterface(iface); if (mapped !== iface) { console.log(` ${iface} -> ${mapped}`); } } // Method 3: Get all current mappings console.log('\nCurrent interface mappings:'); const mappings = this.interfaceMapper.getMappings(); Object.entries(mappings).forEach(([friendly, internal]) => { console.log(` ${friendly} -> ${internal}`); }); } catch (error) { console.error('Error discovering interfaces:', error); } } /** * Test alternative API endpoints */ async testAlternativeEndpoints(): Promise<void> { console.log('\n[FirewallRuleResource] Testing alternative endpoints...'); const endpoints = [ '/firewall/filter/get', '/firewall/filter/listRules', '/firewall/filter/searchRule', '/firewall/filter/getAllRules', '/firewall/filter/status', '/firewall/filter/info' ]; for (const endpoint of endpoints) { try { const response = await this.client.get(endpoint); console.log(`\n${endpoint}:`); console.log(' Success - Response keys:', Object.keys(response || {})); // Check for rules in various places if (response?.rules) { console.log(' Found rules at .rules:', Array.isArray(response.rules) ? `Array(${response.rules.length})` : typeof response.rules); } if (response?.filter?.rules) { console.log(' Found rules at .filter.rules:', Array.isArray(response.filter.rules) ? `Array(${response.filter.rules.length})` : typeof response.filter.rules); } if (response?.rows) { console.log(' Found rules at .rows:', Array.isArray(response.rows) ? `Array(${response.rows.length})` : typeof response.rows); } } catch (error: any) { console.log(`\n${endpoint}:`); console.log(' Failed:', error?.message || error); } } } /** * Validate firewall rule */ validateRule(rule: FirewallRule): string[] { const errors: string[] = []; // Required fields if (!rule.action || !['pass', 'block', 'reject'].includes(rule.action)) { errors.push('Action must be pass, block, or reject'); } if (!rule.interface) { errors.push('Interface is required'); } if (!rule.direction || !['in', 'out'].includes(rule.direction)) { errors.push('Direction must be in or out'); } if (!rule.protocol) { errors.push('Protocol is required'); } if (!rule.source_net) { errors.push('Source network is required'); } if (!rule.destination_net) { errors.push('Destination network is required'); } // Normalize protocol for validation (handle both cases and TCP/UDP) const normalizedProtocol = rule.protocol.toLowerCase(); const isPortAllowedProtocol = ['tcp', 'udp', 'tcp/udp'].includes(normalizedProtocol); // Validate ports if specified if (rule.source_port && !isPortAllowedProtocol) { errors.push('Source port can only be specified for TCP, UDP, or TCP/UDP'); } if (rule.destination_port && !isPortAllowedProtocol) { errors.push('Destination port can only be specified for TCP, UDP, or TCP/UDP'); } return errors; } /** * Create a common firewall rule preset */ createPreset(preset: string, params: any = {}): Partial<FirewallRule> { switch (preset) { case 'allow-web': return { enabled: '1', action: 'pass', quick: '1', direction: 'in', ipprotocol: 'inet', protocol: 'tcp', source_net: 'any', destination_net: params.destination || 'any', destination_port: '80,443', description: params.description || 'Allow HTTP/HTTPS traffic' }; case 'allow-ssh': return { enabled: '1', action: 'pass', quick: '1', direction: 'in', ipprotocol: 'inet', protocol: 'tcp', source_net: params.source || 'any', destination_net: params.destination || 'any', destination_port: '22', description: params.description || 'Allow SSH access' }; case 'allow-minecraft': return { enabled: '1', action: 'pass', quick: '1', direction: 'in', ipprotocol: 'inet', protocol: 'tcp', source_net: 'any', destination_net: params.destination || 'any', destination_port: '25565', description: params.description || 'Allow Minecraft server' }; case 'block-all': return { enabled: '1', action: 'block', quick: '1', direction: 'in', ipprotocol: 'inet', protocol: 'any', source_net: 'any', destination_net: 'any', description: params.description || 'Block all traffic' }; case 'allow-nfs-tcp': return { enabled: '1', action: 'pass', quick: '1', direction: 'in', ipprotocol: 'inet', protocol: 'tcp', source_net: params.source || '10.0.6.0/24', destination_net: params.destination || '10.0.0.14', destination_port: '111,2049', description: params.description || 'Allow NFS TCP traffic (RPC portmapper and NFS)' }; case 'allow-nfs-udp': return { enabled: '1', action: 'pass', quick: '1', direction: 'in', ipprotocol: 'inet', protocol: 'udp', source_net: params.source || '10.0.6.0/24', destination_net: params.destination || '10.0.0.14', destination_port: '111,2049', description: params.description || 'Allow NFS UDP traffic (RPC portmapper and NFS)' }; default: throw new Error(`Unknown preset: ${preset}`); } } /** * Create NFS connectivity rules for DMZ to TrueNAS */ async createNFSRules(params: { interface: string; sourceNetwork?: string; truenasIP?: string; }): Promise<{ tcp: string; udp: string }> { const sourceNetwork = params.sourceNetwork || '10.0.6.0/24'; const truenasIP = params.truenasIP || '10.0.0.14'; console.log(`\n[FirewallRuleResource] Creating NFS rules:`); console.log(` Interface: ${params.interface}`); console.log(` Source: ${sourceNetwork}`); console.log(` Destination: ${truenasIP}`); // Create TCP rule for NFS const tcpRule = await this.create({ ...this.createPreset('allow-nfs-tcp', { source: sourceNetwork, destination: truenasIP }), interface: params.interface } as FirewallRule); // Create UDP rule for NFS const udpRule = await this.create({ ...this.createPreset('allow-nfs-udp', { source: sourceNetwork, destination: truenasIP }), interface: params.interface } as FirewallRule); console.log(`\nNFS Rules created:`); console.log(` TCP Rule UUID: ${tcpRule.uuid}`); console.log(` UDP Rule UUID: ${udpRule.uuid}`); return { tcp: tcpRule.uuid, udp: udpRule.uuid }; } /** * Validate NFS connectivity */ async validateNFSConnectivity(): Promise<{ rulesExist: boolean; details: any; }> { console.log('\n[FirewallRuleResource] Validating NFS connectivity rules...'); const rules = await this.list(); const nfsRules = rules.filter(r => r.description?.toLowerCase().includes('nfs') || (r.destination_port && (r.destination_port.includes('111') || r.destination_port.includes('2049'))) ); console.log(`Found ${nfsRules.length} NFS-related rules`); const details = { totalRules: rules.length, nfsRules: nfsRules.length, rules: nfsRules.map(r => ({ uuid: r.uuid, interface: r.interface, protocol: r.protocol, source: r.source_net, destination: r.destination_net, ports: r.destination_port, enabled: r.enabled === '1', description: r.description })) }; return { rulesExist: nfsRules.length > 0, details }; } } export default FirewallRuleResource;

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/vespo92/OPNSenseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server