db = new FlatFileDB($dataFile); $this->logger = $logger; $this->validator = new Validator(); if (!$this->db->exists('peers')) { $this->db->set('peers', []); } } /** * Get all peers * * @return array */ public function getAll(): array { return $this->db->get('peers') ?? []; } /** * Get peers by ASN * * @param int $asn * @return array */ public function getByAsn(int $asn): array { $peers = $this->db->get('peers') ?? []; return array_filter($peers, fn($peer) => ($peer['asn'] ?? 0) === $asn); } /** * Get peers by node * * @param string $nodeId * @return array */ public function getByNode(string $nodeId): array { $peers = $this->db->get('peers') ?? []; return array_filter($peers, fn($peer) => ($peer['node'] ?? '') === $nodeId); } /** * Get peer by ID * * @param string $id * @return array|null */ public function get(string $id): ?array { $peers = $this->db->get('peers') ?? []; return $peers[$id] ?? null; } /** * Create new peer * * @param array $data * @return array */ public function create(array $data): array { $this->validator->clear(); // Validate required fields if (empty($data['id'])) { return [ 'success' => false, 'message' => 'Peer ID is required', 'errors' => ['id' => 'Peer ID is required'], ]; } if (empty($data['asn'])) { return [ 'success' => false, 'message' => 'Local ASN is required', 'errors' => ['asn' => 'Local ASN is required'], ]; } if (empty($data['remote_asn'])) { return [ 'success' => false, 'message' => 'Remote ASN is required', 'errors' => ['remote_asn' => 'Remote ASN is required'], ]; } if (!$this->validator->validateAsn($data['asn'], 'asn') || !$this->validator->validateAsn($data['remote_asn'], 'remote_asn')) { return [ 'success' => false, 'message' => 'Invalid ASN', 'errors' => $this->validator->getErrors(), ]; } $peers = $this->db->get('peers') ?? []; $id = $this->sanitizeId($data['id']); if (isset($peers[$id])) { return [ 'success' => false, 'message' => "Peer $id already exists", 'errors' => ['id' => 'Peer ID already exists'], ]; } // Validate neighbors $neighbors = $data['neighbors'] ?? []; if (!is_array($neighbors)) { $neighbors = array_filter(array_map('trim', explode("\n", $neighbors))); } foreach ($neighbors as $neighbor) { if (!$this->validator->validateIP($neighbor, 'neighbors')) { return [ 'success' => false, 'message' => "Invalid neighbor IP: $neighbor", 'errors' => $this->validator->getErrors(), ]; } } $peerData = $this->buildPeerData($id, $data, $neighbors); $peers[$id] = $peerData; if ($this->db->set('peers', $peers)) { $this->logger->success('peer', "Created peer: $id (AS{$peerData['remote_asn']}) on node {$peerData['node']}"); return [ 'success' => true, 'message' => "Peer $id created successfully", 'data' => $peerData, ]; } return [ 'success' => false, 'message' => 'Failed to save peer', 'errors' => ['database' => 'Failed to save peer'], ]; } /** * Update peer * * @param string $id * @param array $data * @return array */ public function update(string $id, array $data): array { $peers = $this->db->get('peers') ?? []; if (!isset($peers[$id])) { return [ 'success' => false, 'message' => "Peer $id not found", 'errors' => ['id' => 'Peer not found'], ]; } $peerData = $peers[$id]; // Handle neighbors if (isset($data['neighbors'])) { $neighbors = $data['neighbors']; if (!is_array($neighbors)) { $neighbors = array_filter(array_map('trim', explode("\n", $neighbors))); } foreach ($neighbors as $neighbor) { if (!$this->validator->validateIP($neighbor, 'neighbors')) { return [ 'success' => false, 'message' => "Invalid neighbor IP: $neighbor", 'errors' => $this->validator->getErrors(), ]; } } $peerData['neighbors'] = array_values($neighbors); } // Update Pathvector options if (isset($data['pathvector'])) { $peerData['pathvector'] = $this->buildPathvectorOptions( array_merge($peerData['pathvector'], $data['pathvector']) ); } // Update basic fields $basicFields = [ 'name', 'description', 'remote_asn', 'node', 'template', 'afi', 'templates', 'is_active', 'tags' ]; foreach ($basicFields as $field) { if (isset($data[$field])) { $peerData[$field] = $data[$field]; } } $peerData['updated_at'] = date('c'); $peers[$id] = $peerData; if ($this->db->set('peers', $peers)) { $this->logger->info('peer', "Updated peer: $id"); return [ 'success' => true, 'message' => "Peer $id updated successfully", 'data' => $peerData, ]; } return [ 'success' => false, 'message' => 'Failed to update peer', 'errors' => ['database' => 'Failed to save peer'], ]; } /** * Delete peer * * @param string $id * @return array */ public function delete(string $id): array { $peers = $this->db->get('peers') ?? []; if (!isset($peers[$id])) { return [ 'success' => false, 'message' => "Peer $id not found", ]; } $peerName = $peers[$id]['name']; $peerAsn = $peers[$id]['remote_asn']; unset($peers[$id]); if ($this->db->set('peers', $peers)) { $this->logger->warning('peer', "Deleted peer: $id ($peerName - AS$peerAsn)"); return [ 'success' => true, 'message' => "Peer $id deleted successfully", ]; } return [ 'success' => false, 'message' => 'Failed to delete peer', ]; } /** * Build peer data structure * * @param string $id * @param array $data * @param array $neighbors * @return array */ private function buildPeerData(string $id, array $data, array $neighbors): array { return [ 'id' => $id, 'name' => $data['name'] ?? $id, 'description' => $data['description'] ?? '', 'asn' => (int) $data['asn'], 'remote_asn' => (int) $data['remote_asn'], 'node' => $data['node'] ?? '', 'neighbors' => array_values($neighbors), 'afi' => $data['afi'] ?? ['ipv4', 'ipv6'], 'template' => $data['template'] ?? '', 'templates' => $data['templates'] ?? [], 'tags' => $data['tags'] ?? [], 'pathvector' => $this->buildPathvectorOptions($data['pathvector'] ?? []), 'is_active' => $data['is_active'] ?? true, 'created_at' => date('c'), 'updated_at' => date('c'), ]; } /** * Build Pathvector options structure with ALL supported options * * @param array $data * @return array */ private function buildPathvectorOptions(array $data): array { return [ // Session control 'disabled' => $data['disabled'] ?? false, 'import' => $data['import'] ?? true, 'export' => $data['export'] ?? true, // BGP attributes 'local_asn' => $data['local_asn'] ?? null, 'prepends' => $data['prepends'] ?? 0, 'prepend_path' => $data['prepend_path'] ?? [], 'clear_path' => $data['clear_path'] ?? false, 'local_pref' => $data['local_pref'] ?? 100, 'local_pref4' => $data['local_pref4'] ?? null, 'local_pref6' => $data['local_pref6'] ?? null, 'set_local_pref' => $data['set_local_pref'] ?? false, 'multihop' => $data['multihop'] ?? null, // Listening 'listen4' => $data['listen4'] ?? '', 'listen6' => $data['listen6'] ?? '', 'local_port' => $data['local_port'] ?? 179, 'neighbor_port' => $data['neighbor_port'] ?? 179, 'passive' => $data['passive'] ?? false, 'direct' => $data['direct'] ?? false, // Next-hop 'next_hop_self' => $data['next_hop_self'] ?? false, 'next_hop_self_ebgp' => $data['next_hop_self_ebgp'] ?? false, 'next_hop_self_ibgp' => $data['next_hop_self_ibgp'] ?? false, 'import_next_hop' => $data['import_next_hop'] ?? '', 'export_next_hop' => $data['export_next_hop'] ?? '', 'enforce_peer_nexthop' => $data['enforce_peer_nexthop'] ?? true, 'force_peer_nexthop' => $data['force_peer_nexthop'] ?? false, // BFD 'bfd' => $data['bfd'] ?? false, // Authentication 'password' => $data['password'] ?? '', // Route server / reflector 'rs_client' => $data['rs_client'] ?? false, 'rr_client' => $data['rr_client'] ?? false, // AS path manipulation 'remove_private_asns' => $data['remove_private_asns'] ?? true, 'allow_local_as' => $data['allow_local_as'] ?? false, 'enforce_first_as' => $data['enforce_first_as'] ?? true, // Multi-protocol 'mp_unicast_46' => $data['mp_unicast_46'] ?? false, // Add-path 'add_path_tx' => $data['add_path_tx'] ?? false, 'add_path_rx' => $data['add_path_rx'] ?? false, // Confederation 'confederation' => $data['confederation'] ?? null, 'confederation_member' => $data['confederation_member'] ?? false, // TTL Security 'ttl_security' => $data['ttl_security'] ?? false, // Timers and limits 'receive_limit4' => $data['receive_limit4'] ?? null, 'receive_limit6' => $data['receive_limit6'] ?? null, 'receive_limit_violation' => $data['receive_limit_violation'] ?? 'disable', 'export_limit4' => $data['export_limit4'] ?? null, 'export_limit6' => $data['export_limit6'] ?? null, 'export_limit_violation' => $data['export_limit_violation'] ?? 'disable', // Session options 'interpret_communities' => $data['interpret_communities'] ?? true, 'default_local_pref' => $data['default_local_pref'] ?? null, 'advertise_hostname' => $data['advertise_hostname'] ?? false, 'disable_after_error' => $data['disable_after_error'] ?? false, 'prefer_older_routes' => $data['prefer_older_routes'] ?? false, // IRR 'irr_accept_child_prefixes' => $data['irr_accept_child_prefixes'] ?? false, // Communities 'add_on_import' => $data['add_on_import'] ?? [], 'add_on_export' => $data['add_on_export'] ?? [], 'announce' => $data['announce'] ?? [], 'remove_communities' => $data['remove_communities'] ?? [], 'remove_all_communities' => $data['remove_all_communities'] ?? null, // AS preferences 'as_prefs' => $data['as_prefs'] ?? [], 'community_prefs' => $data['community_prefs'] ?? [], 'large_community_prefs' => $data['large_community_prefs'] ?? [], // AS-SET 'as_set' => $data['as_set'] ?? '', // Blackhole 'allow_blackhole_community' => $data['allow_blackhole_community'] ?? false, 'blackhole_in' => $data['blackhole_in'] ?? false, 'blackhole_out' => $data['blackhole_out'] ?? false, // Filtering 'filter_irr' => $data['filter_irr'] ?? false, 'filter_rpki' => $data['filter_rpki'] ?? true, 'strict_rpki' => $data['strict_rpki'] ?? false, 'filter_max_prefix' => $data['filter_max_prefix'] ?? true, 'filter_bogon_routes' => $data['filter_bogon_routes'] ?? true, 'filter_bogon_asns' => $data['filter_bogon_asns'] ?? true, 'filter_transit_asns' => $data['filter_transit_asns'] ?? false, 'filter_prefix_length' => $data['filter_prefix_length'] ?? true, 'filter_never_via_route_servers' => $data['filter_never_via_route_servers'] ?? false, 'filter_as_set' => $data['filter_as_set'] ?? false, 'filter_aspa' => $data['filter_aspa'] ?? false, 'filter_blocklist' => $data['filter_blocklist'] ?? true, // Transit lock 'transit_lock' => $data['transit_lock'] ?? [], // Announcement control 'dont_announce' => $data['dont_announce'] ?? [], 'only_announce' => $data['only_announce'] ?? [], 'prefix_communities' => $data['prefix_communities'] ?? [], // Auto-config 'auto_import_limits' => $data['auto_import_limits'] ?? false, 'auto_as_set' => $data['auto_as_set'] ?? false, 'auto_as_set_members' => $data['auto_as_set_members'] ?? false, // Graceful shutdown 'honor_graceful_shutdown' => $data['honor_graceful_shutdown'] ?? true, // Prefixes 'prefixes' => $data['prefixes'] ?? [], 'as_set_members' => $data['as_set_members'] ?? [], // BGP Role (RFC 9234) 'role' => $data['role'] ?? '', 'require_roles' => $data['require_roles'] ?? false, // Export options 'announce_default' => $data['announce_default'] ?? false, 'announce_originated' => $data['announce_originated'] ?? true, 'announce_all' => $data['announce_all'] ?? false, // Custom config 'session_global' => $data['session_global'] ?? '', 'pre_import_filter' => $data['pre_import_filter'] ?? '', 'post_import_filter' => $data['post_import_filter'] ?? '', 'pre_import_accept' => $data['pre_import_accept'] ?? '', 'pre_export' => $data['pre_export'] ?? '', 'pre_export_final' => $data['pre_export_final'] ?? '', // Route optimization 'probe_sources' => $data['probe_sources'] ?? [], 'optimize_inbound' => $data['optimize_inbound'] ?? false, ]; } /** * Sanitize peer ID * * @param string $id * @return string */ private function sanitizeId(string $id): string { $id = strtolower(trim($id)); $id = preg_replace('/\s+/', '-', $id); $id = preg_replace('/[^a-z0-9-_]/', '', $id); return $id; } /** * Count peers * * @return int */ public function count(): int { $peers = $this->db->get('peers') ?? []; return count($peers); } /** * Search peers * * @param string $query * @return array */ public function search(string $query): array { $peers = $this->db->get('peers') ?? []; $query = strtolower($query); return array_filter($peers, function($peer) use ($query) { return strpos(strtolower($peer['id']), $query) !== false || strpos(strtolower($peer['name']), $query) !== false || strpos((string) $peer['remote_asn'], $query) !== false || strpos(strtolower($peer['description'] ?? ''), $query) !== false; }); } /** * Export peer to YAML format * * @param string $id * @return string|null */ public function exportYaml(string $id): ?string { $peer = $this->get($id); if (!$peer) { return null; } $pv = $peer['pathvector']; $config = []; // Required fields $config['asn'] = $peer['remote_asn']; if (!empty($peer['template'])) { $config['template'] = $peer['template']; } if (!empty($peer['neighbors'])) { $config['neighbors'] = $peer['neighbors']; } // Add description if set if (!empty($peer['description'])) { $config['description'] = $peer['description']; } // Add all non-default Pathvector options $this->addNonDefaultOptions($config, $pv); // Build YAML with peer name as key $peerName = $peer['name']; $yaml = " $peerName:\n"; foreach ($config as $key => $value) { $yamlKey = str_replace('_', '-', $key); $yaml .= $this->formatYamlEntry($yamlKey, $value, 2); } return $yaml; } /** * Add non-default options to config array * * @param array &$config * @param array $pv */ private function addNonDefaultOptions(array &$config, array $pv): void { $defaults = $this->buildPathvectorOptions([]); foreach ($pv as $key => $value) { // Skip if same as default if (isset($defaults[$key]) && $defaults[$key] === $value) { continue; } // Skip empty values if ($value === '' || $value === null || $value === []) { continue; } // Skip certain internal fields if (in_array($key, ['template', 'neighbors'])) { continue; } $config[$key] = $value; } } /** * Format YAML entry * * @param string $key * @param mixed $value * @param int $indent * @return string */ private function formatYamlEntry(string $key, $value, int $indent): string { $prefix = str_repeat(' ', $indent); if (is_array($value)) { if (empty($value)) { return ''; } if (array_keys($value) === range(0, count($value) - 1)) { // Sequential array $yaml = "$prefix$key:\n"; foreach ($value as $item) { $yaml .= "$prefix - " . $this->formatYamlValue($item) . "\n"; } return $yaml; } else { // Associative array $yaml = "$prefix$key:\n"; foreach ($value as $k => $v) { $yaml .= $this->formatYamlEntry($k, $v, $indent + 1); } return $yaml; } } return "$prefix$key: " . $this->formatYamlValue($value) . "\n"; } /** * Format value for YAML * * @param mixed $value * @return string */ private function formatYamlValue($value): string { if (is_bool($value)) { return $value ? 'true' : 'false'; } if (is_numeric($value)) { return (string) $value; } if (is_string($value) && (strpos($value, ':') !== false || strpos($value, '#') !== false)) { return '"' . addslashes($value) . '"'; } return (string) $value; } /** * Get peers grouped by type (based on template or ASN type) * * @return array */ public function getGroupedByType(): array { $peers = $this->db->get('peers') ?? []; $grouped = [ 'upstream' => [], 'downstream' => [], 'peer' => [], 'routeserver' => [], 'ibgp' => [], 'other' => [], ]; foreach ($peers as $id => $peer) { $template = strtolower($peer['template'] ?? ''); if (strpos($template, 'upstream') !== false) { $grouped['upstream'][$id] = $peer; } elseif (strpos($template, 'downstream') !== false) { $grouped['downstream'][$id] = $peer; } elseif (strpos($template, 'routeserver') !== false || strpos($template, 'rs') !== false) { $grouped['routeserver'][$id] = $peer; } elseif ($peer['asn'] === $peer['remote_asn']) { $grouped['ibgp'][$id] = $peer; } elseif (strpos($template, 'peer') !== false) { $grouped['peer'][$id] = $peer; } else { $grouped['other'][$id] = $peer; } } return $grouped; } /** * Backup peers data * * @param string $backupDir * @return string|false */ public function backup(string $backupDir): string|false { return $this->db->backup($backupDir); } }