diff --git a/pathvector-admin/assets/css/style.css b/pathvector-admin/assets/css/style.css
new file mode 100644
index 0000000..fcc42a4
--- /dev/null
+++ b/pathvector-admin/assets/css/style.css
@@ -0,0 +1,414 @@
+/* Custom Styles for Pathvector Admin */
+
+/* Utility Classes */
+.min-width-0 {
+ min-width: 0;
+}
+
+.text-truncate {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.text-decoration-none {
+ text-decoration: none !important;
+}
+
+.cursor-pointer {
+ cursor: pointer;
+}
+
+/* Button Enhancements */
+.btn-block {
+ display: block;
+ width: 100%;
+}
+
+.btn-octicon {
+ padding: 8px;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ border-radius: 6px;
+ color: var(--color-fg-muted);
+}
+
+.btn-octicon:hover {
+ background: var(--color-action-list-item-default-hover-bg);
+ color: var(--color-fg-default);
+}
+
+/* Octicon sizing */
+.octicon {
+ display: inline-block;
+ vertical-align: text-bottom;
+ fill: currentColor;
+}
+
+/* Layout improvements */
+.flex-wrap {
+ flex-wrap: wrap;
+}
+
+.gap-1 { gap: 4px; }
+.gap-2 { gap: 8px; }
+.gap-3 { gap: 16px; }
+.gap-4 { gap: 24px; }
+
+/* Form improvements */
+.form-control:focus {
+ border-color: var(--color-accent-emphasis);
+ outline: none;
+ box-shadow: 0 0 0 3px var(--color-accent-muted);
+}
+
+.form-select {
+ display: block;
+ padding: 5px 30px 5px 12px;
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--color-fg-default);
+ vertical-align: middle;
+ background-color: var(--color-canvas-default);
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ background-size: 16px 16px;
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ outline: none;
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z' fill='%23656d76'/%3E%3C/svg%3E");
+}
+
+.form-select:focus {
+ border-color: var(--color-accent-emphasis);
+ box-shadow: 0 0 0 3px var(--color-accent-muted);
+}
+
+/* Toggle Switch */
+.form-switch {
+ position: relative;
+ display: inline-block;
+ width: 40px;
+ height: 20px;
+}
+
+.form-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.form-switch-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--color-neutral-muted);
+ transition: 0.2s;
+ border-radius: 20px;
+}
+
+.form-switch-slider:before {
+ position: absolute;
+ content: "";
+ height: 14px;
+ width: 14px;
+ left: 3px;
+ bottom: 3px;
+ background-color: white;
+ transition: 0.2s;
+ border-radius: 50%;
+}
+
+.form-switch input:checked + .form-switch-slider {
+ background-color: var(--color-success-emphasis);
+}
+
+.form-switch input:checked + .form-switch-slider:before {
+ transform: translateX(20px);
+}
+
+/* Collapsible sections */
+.collapsible-header {
+ cursor: pointer;
+ user-select: none;
+ padding: 12px 16px;
+ background: var(--color-canvas-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-weight: 600;
+}
+
+.collapsible-header:hover {
+ background: var(--color-neutral-muted);
+}
+
+.collapsible-content {
+ margin-top: 8px;
+ padding: 16px;
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ background: var(--color-canvas-default);
+}
+
+.collapsible-content.collapsed {
+ display: none;
+}
+
+.collapsible-icon {
+ transition: transform 0.2s;
+}
+
+.collapsible-icon.expanded {
+ transform: rotate(90deg);
+}
+
+/* Code/YAML display */
+.yaml-preview {
+ background: var(--color-canvas-subtle);
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ padding: 16px;
+ font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ overflow-x: auto;
+ white-space: pre;
+ color: var(--color-fg-default);
+}
+
+/* Tab Navigation */
+.tabnav-tabs {
+ display: flex;
+ margin-bottom: 16px;
+ border-bottom: 1px solid var(--color-border-default);
+}
+
+.tabnav-tab {
+ padding: 8px 16px;
+ margin-right: 8px;
+ color: var(--color-fg-default);
+ text-decoration: none;
+ border-bottom: 2px solid transparent;
+ font-weight: 500;
+}
+
+.tabnav-tab:hover {
+ color: var(--color-fg-default);
+ text-decoration: none;
+ border-bottom-color: var(--color-border-default);
+}
+
+.tabnav-tab.selected {
+ color: var(--color-fg-default);
+ border-bottom-color: var(--color-accent-fg);
+}
+
+/* Info boxes */
+.info-box {
+ padding: 12px;
+ border-radius: 6px;
+ margin-bottom: 16px;
+ display: flex;
+ align-items: flex-start;
+}
+
+.info-box-info {
+ background: var(--color-accent-subtle);
+ border: 1px solid var(--color-accent-muted);
+}
+
+.info-box-warning {
+ background: var(--color-attention-subtle);
+ border: 1px solid var(--color-attention-muted);
+}
+
+.info-box-icon {
+ margin-right: 8px;
+ flex-shrink: 0;
+}
+
+/* Loading spinner */
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.spinner {
+ border: 2px solid var(--color-border-default);
+ border-top: 2px solid var(--color-accent-fg);
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ animation: spin 1s linear infinite;
+}
+
+/* Diff display */
+.diff-addition {
+ background-color: var(--color-success-subtle);
+ color: var(--color-success-fg);
+}
+
+.diff-deletion {
+ background-color: var(--color-danger-subtle);
+ color: var(--color-danger-fg);
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--color-canvas-subtle);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--color-border-default);
+ border-radius: 6px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--color-fg-muted);
+}
+
+/* Print styles */
+@media print {
+ .sidebar,
+ .btn,
+ .flash {
+ display: none !important;
+ }
+
+ .main-content {
+ margin-left: 0 !important;
+ }
+}
+
+/* Responsive table */
+@media (max-width: 768px) {
+ .data-table {
+ display: block;
+ overflow-x: auto;
+ white-space: nowrap;
+ }
+}
+
+/* Animation utilities */
+.fade-in {
+ animation: fadeIn 0.3s ease-in;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+/* Multi-select tag input */
+.tag-input {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ min-height: 32px;
+}
+
+.tag-input:focus-within {
+ border-color: var(--color-accent-emphasis);
+ box-shadow: 0 0 0 3px var(--color-accent-muted);
+}
+
+.tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 8px;
+ background: var(--color-accent-subtle);
+ border: 1px solid var(--color-accent-muted);
+ border-radius: 4px;
+ font-size: 12px;
+}
+
+.tag-remove {
+ margin-left: 4px;
+ cursor: pointer;
+ color: var(--color-fg-muted);
+}
+
+.tag-remove:hover {
+ color: var(--color-danger-fg);
+}
+
+/* Modal improvements */
+.modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+
+.modal-dialog {
+ background: var(--color-canvas-default);
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ max-width: 600px;
+ width: 90%;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: var(--color-shadow-extra-large);
+}
+
+.modal-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--color-border-default);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.modal-body {
+ padding: 16px;
+}
+
+.modal-footer {
+ padding: 16px;
+ border-top: 1px solid var(--color-border-default);
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+/* Highlight current nav item on hover */
+.nav-item:active {
+ transform: scale(0.98);
+}
+
+/* Better focus indicators */
+*:focus-visible {
+ outline: 2px solid var(--color-accent-fg);
+ outline-offset: 2px;
+}
+
+/* Zebra striping for tables */
+.data-table tbody tr:nth-child(even) {
+ background: var(--color-canvas-subtle);
+}
diff --git a/pathvector-admin/config/config.php b/pathvector-admin/config/config.php
new file mode 100644
index 0000000..d7a6e6b
--- /dev/null
+++ b/pathvector-admin/config/config.php
@@ -0,0 +1,74 @@
+ 'Pathvector Admin',
+ 'app_version' => '1.0.0',
+ 'debug' => true,
+
+ // Security
+ 'session_lifetime' => 3600, // 1 hour
+ 'csrf_token_name' => 'pathvector_csrf_token',
+
+ // Data Storage
+ 'data_dir' => __DIR__ . '/../data',
+ 'asn_file' => __DIR__ . '/../data/asns.json',
+ 'node_file' => __DIR__ . '/../data/nodes.json',
+ 'peer_file' => __DIR__ . '/../data/peers.json',
+ 'template_file' => __DIR__ . '/../data/templates.json',
+ 'host_file' => __DIR__ . '/../data/hosts.json',
+ 'user_file' => __DIR__ . '/../data/users.json',
+ 'log_file' => __DIR__ . '/../data/logs.json',
+ 'backup_dir' => __DIR__ . '/../data/backups',
+
+ // Pathvector Settings
+ 'pathvector' => [
+ 'binary_path' => '/usr/local/bin/pathvector',
+ 'config_dir' => '/etc/pathvector',
+ 'default_config_path' => '/etc/pathvector/pathvector.yml',
+ 'default_output_path' => '/etc/bird/bird.conf',
+ 'bird_binary' => '/usr/sbin/bird',
+ 'bird_socket' => '/var/run/bird.ctl',
+ 'bird_reload_cmd' => 'birdc configure',
+ ],
+
+ // BIRD 3 Settings
+ 'bird' => [
+ 'version' => '3',
+ 'supported_protocols' => ['bgp', 'static', 'kernel', 'device', 'direct'],
+ 'table_types' => ['ipv4', 'ipv6', 'ipv4-multicast', 'ipv6-multicast'],
+ ],
+
+ // User Roles
+ 'roles' => [
+ 'admin' => 'Administrator',
+ 'operator' => 'Operator',
+ 'readonly' => 'Read Only',
+ ],
+
+ // Default Admin User (change on first login!)
+ 'default_admin' => [
+ 'username' => 'admin',
+ 'password' => 'pathvector', // CHANGE THIS!
+ 'role' => 'admin',
+ ],
+
+ // UI Settings
+ 'pagination' => [
+ 'per_page' => 25,
+ ],
+
+ // Primer CSS CDN
+ 'primer_css_url' => 'https://unpkg.com/@primer/css@^20.2.4/dist/primer.css',
+
+ // Log Settings
+ 'log' => [
+ 'max_entries' => 1000,
+ 'levels' => ['info', 'warning', 'error', 'success'],
+ ],
+];
diff --git a/pathvector-admin/index.php b/pathvector-admin/index.php
new file mode 100644
index 0000000..457b9ab
--- /dev/null
+++ b/pathvector-admin/index.php
@@ -0,0 +1,538 @@
+getAll('users');
+if (empty($users) && !isset($_GET['setup'])) {
+ header('Location: setup.php');
+ exit;
+}
+
+// Handle logout
+if (isset($_GET['action']) && $_GET['action'] === 'logout') {
+ $auth->logout();
+ header('Location: login.php');
+ exit;
+}
+
+// Check authentication
+if (!$auth->isLoggedIn()) {
+ header('Location: login.php');
+ exit;
+}
+
+// Get current user
+$currentUser = $auth->getCurrentUser();
+
+// Route handling
+$page = $_GET['page'] ?? 'dashboard';
+$action = $_GET['action'] ?? 'index';
+
+// Define available pages
+$pages = [
+ 'dashboard' => ['file' => 'pages/dashboard.php', 'title' => 'Dashboard', 'icon' => 'home', 'roles' => ['admin', 'operator', 'readonly']],
+ 'asns' => ['file' => 'pages/asns.php', 'title' => 'ASNs', 'icon' => 'organization', 'roles' => ['admin', 'operator', 'readonly']],
+ 'nodes' => ['file' => 'pages/nodes.php', 'title' => 'Nodes', 'icon' => 'server', 'roles' => ['admin', 'operator', 'readonly']],
+ 'peers' => ['file' => 'pages/peers.php', 'title' => 'Peers', 'icon' => 'git-branch', 'roles' => ['admin', 'operator', 'readonly']],
+ 'templates' => ['file' => 'pages/templates.php', 'title' => 'Templates', 'icon' => 'file-code', 'roles' => ['admin', 'operator', 'readonly']],
+ 'hosts' => ['file' => 'pages/hosts.php', 'title' => 'Hosts', 'icon' => 'terminal', 'roles' => ['admin', 'operator']],
+ 'config' => ['file' => 'pages/config.php', 'title' => 'Configuration', 'icon' => 'file', 'roles' => ['admin', 'operator', 'readonly']],
+ 'execute' => ['file' => 'pages/execute.php', 'title' => 'Execute', 'icon' => 'play', 'roles' => ['admin', 'operator']],
+ 'logs' => ['file' => 'pages/logs.php', 'title' => 'Logs', 'icon' => 'history', 'roles' => ['admin', 'operator', 'readonly']],
+ 'backups' => ['file' => 'pages/backups.php', 'title' => 'Backups', 'icon' => 'archive', 'roles' => ['admin']],
+ 'users' => ['file' => 'pages/users.php', 'title' => 'Users', 'icon' => 'people', 'roles' => ['admin']],
+ 'settings' => ['file' => 'pages/settings.php', 'title' => 'Settings', 'icon' => 'gear', 'roles' => ['admin']],
+];
+
+// Check if page exists
+if (!isset($pages[$page])) {
+ $page = 'dashboard';
+}
+
+// Check page access
+if (!in_array($currentUser['role'], $pages[$page]['roles'])) {
+ $page = 'dashboard';
+}
+
+$currentPage = $pages[$page];
+
+// Helper function to check permission
+function hasPermission(string $permission): bool {
+ global $auth;
+ return $auth->hasPermission($permission);
+}
+
+// Helper function to generate CSRF token field
+function csrfField(): string {
+ global $auth;
+ return '';
+}
+
+// Helper function to escape output
+function e($value): string {
+ return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
+}
+
+// Helper function to format date
+function formatDate($date): string {
+ if (empty($date)) return '-';
+ return date('Y-m-d H:i:s', strtotime($date));
+}
+
+// Helper function to format bytes
+function formatBytes(int $bytes, int $precision = 2): string {
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+
+ $bytes = max($bytes, 0);
+ $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
+ $pow = min($pow, count($units) - 1);
+
+ $bytes /= pow(1024, $pow);
+
+ return round($bytes, $precision) . ' ' . $units[$pow];
+}
+
+?>
+
+
+
+
+
+ = e($currentPage['title']) ?> - Pathvector Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Page not found or not yet implemented.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pathvector-admin/lib/ASN.php b/pathvector-admin/lib/ASN.php
new file mode 100644
index 0000000..dd730e1
--- /dev/null
+++ b/pathvector-admin/lib/ASN.php
@@ -0,0 +1,446 @@
+db = new FlatFileDB($dataFile);
+ $this->logger = $logger;
+ $this->validator = new Validator();
+
+ // Initialize structure
+ if (!$this->db->exists('asns')) {
+ $this->db->set('asns', []);
+ }
+ }
+
+ /**
+ * Get all ASNs
+ *
+ * @return array
+ */
+ public function getAll(): array {
+ return $this->db->get('asns') ?? [];
+ }
+
+ /**
+ * Get ASN by ID
+ *
+ * @param string $id ASN number as string key
+ * @return array|null
+ */
+ public function get(string $id): ?array {
+ $asns = $this->db->get('asns') ?? [];
+ return $asns[$id] ?? null;
+ }
+
+ /**
+ * Create new ASN
+ *
+ * @param array $data ASN data
+ * @return array ['success' => bool, 'message' => string, 'errors' => array]
+ */
+ public function create(array $data): array {
+ $this->validator->clear();
+
+ // Validate ASN number
+ if (!$this->validator->validateAsn($data['asn'] ?? null, 'asn')) {
+ return [
+ 'success' => false,
+ 'message' => 'Validation failed',
+ 'errors' => $this->validator->getErrors(),
+ ];
+ }
+
+ $asns = $this->db->get('asns') ?? [];
+ $id = (string) $data['asn'];
+
+ // Check if ASN already exists
+ if (isset($asns[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "ASN $id already exists",
+ 'errors' => ['asn' => 'ASN already exists'],
+ ];
+ }
+
+ // Build ASN entry
+ $asnData = [
+ 'asn' => (int) $data['asn'],
+ 'name' => $data['name'] ?? "AS{$data['asn']}",
+ 'description' => $data['description'] ?? '',
+ 'pathvector_defaults' => $this->buildPathvectorDefaults($data['pathvector_defaults'] ?? []),
+ 'templates' => $data['templates'] ?? [],
+ 'contacts' => $data['contacts'] ?? [],
+ 'metadata' => $data['metadata'] ?? [],
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ];
+
+ $asns[$id] = $asnData;
+
+ if ($this->db->set('asns', $asns)) {
+ $this->logger->success('asn', "Created ASN: $id ({$asnData['name']})");
+ return [
+ 'success' => true,
+ 'message' => "ASN $id created successfully",
+ 'data' => $asnData,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to save ASN',
+ 'errors' => ['database' => 'Failed to save ASN'],
+ ];
+ }
+
+ /**
+ * Update ASN
+ *
+ * @param string $id ASN ID
+ * @param array $data Updated data
+ * @return array
+ */
+ public function update(string $id, array $data): array {
+ $asns = $this->db->get('asns') ?? [];
+
+ if (!isset($asns[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "ASN $id not found",
+ 'errors' => ['asn' => 'ASN not found'],
+ ];
+ }
+
+ // Merge with existing data
+ $asnData = $asns[$id];
+
+ if (isset($data['name'])) {
+ $asnData['name'] = $data['name'];
+ }
+
+ if (isset($data['description'])) {
+ $asnData['description'] = $data['description'];
+ }
+
+ if (isset($data['pathvector_defaults'])) {
+ $asnData['pathvector_defaults'] = $this->buildPathvectorDefaults(
+ array_merge($asnData['pathvector_defaults'], $data['pathvector_defaults'])
+ );
+ }
+
+ if (isset($data['templates'])) {
+ $asnData['templates'] = $data['templates'];
+ }
+
+ if (isset($data['contacts'])) {
+ $asnData['contacts'] = $data['contacts'];
+ }
+
+ if (isset($data['metadata'])) {
+ $asnData['metadata'] = array_merge($asnData['metadata'] ?? [], $data['metadata']);
+ }
+
+ $asnData['updated_at'] = date('c');
+ $asns[$id] = $asnData;
+
+ if ($this->db->set('asns', $asns)) {
+ $this->logger->info('asn', "Updated ASN: $id");
+ return [
+ 'success' => true,
+ 'message' => "ASN $id updated successfully",
+ 'data' => $asnData,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to update ASN',
+ 'errors' => ['database' => 'Failed to save ASN'],
+ ];
+ }
+
+ /**
+ * Delete ASN
+ *
+ * @param string $id ASN ID
+ * @return array
+ */
+ public function delete(string $id): array {
+ $asns = $this->db->get('asns') ?? [];
+
+ if (!isset($asns[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "ASN $id not found",
+ ];
+ }
+
+ $asnName = $asns[$id]['name'];
+ unset($asns[$id]);
+
+ if ($this->db->set('asns', $asns)) {
+ $this->logger->warning('asn', "Deleted ASN: $id ($asnName)");
+ return [
+ 'success' => true,
+ 'message' => "ASN $id deleted successfully",
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to delete ASN',
+ ];
+ }
+
+ /**
+ * Build Pathvector defaults structure
+ *
+ * @param array $data
+ * @return array
+ */
+ private function buildPathvectorDefaults(array $data): array {
+ return [
+ // Global config
+ 'router_id' => $data['router_id'] ?? '',
+ 'source4' => $data['source4'] ?? '',
+ 'source6' => $data['source6'] ?? '',
+ 'prefixes' => $data['prefixes'] ?? [],
+ 'hostname' => $data['hostname'] ?? '',
+
+ // BIRD/Pathvector paths
+ 'bird_directory' => $data['bird_directory'] ?? '/etc/bird',
+ 'bird_binary' => $data['bird_binary'] ?? '/usr/sbin/bird',
+ 'bird_socket' => $data['bird_socket'] ?? '/var/run/bird.ctl',
+ 'cache_directory' => $data['cache_directory'] ?? '/var/cache/pathvector',
+
+ // PeeringDB settings
+ 'peeringdb_query_timeout' => $data['peeringdb_query_timeout'] ?? 10,
+ 'peeringdb_api_key' => $data['peeringdb_api_key'] ?? '',
+ 'peeringdb_cache' => $data['peeringdb_cache'] ?? true,
+ 'peeringdb_url' => $data['peeringdb_url'] ?? 'https://peeringdb.com/api/',
+
+ // IRR settings
+ 'irr_server' => $data['irr_server'] ?? 'rr.ntt.net',
+ 'irr_query_timeout' => $data['irr_query_timeout'] ?? 30,
+ 'bgpq_args' => $data['bgpq_args'] ?? '',
+
+ // RTR/RPKI settings
+ 'rtr_server' => $data['rtr_server'] ?? '',
+ 'rpki_enable' => $data['rpki_enable'] ?? true,
+
+ // Filtering defaults
+ 'keep_filtered' => $data['keep_filtered'] ?? false,
+ 'merge_paths' => $data['merge_paths'] ?? false,
+ 'default_route' => $data['default_route'] ?? false,
+ 'accept_default' => $data['accept_default'] ?? false,
+
+ // Communities
+ 'origin_communities' => $data['origin_communities'] ?? [],
+ 'local_communities' => $data['local_communities'] ?? [],
+ 'add_on_import' => $data['add_on_import'] ?? [],
+ 'add_on_export' => $data['add_on_export'] ?? [],
+
+ // Blocklist
+ 'blocklist' => $data['blocklist'] ?? [],
+ 'blocklist_urls' => $data['blocklist_urls'] ?? [],
+ 'blocklist_files' => $data['blocklist_files'] ?? [],
+
+ // Bogons
+ 'bogons4' => $data['bogons4'] ?? [],
+ 'bogons6' => $data['bogons6'] ?? [],
+ 'bogon_asns' => $data['bogon_asns'] ?? [],
+ 'blackhole_bogon_asns' => $data['blackhole_bogon_asns'] ?? false,
+
+ // Transit ASNs
+ 'transit_asns' => $data['transit_asns'] ?? [],
+
+ // Operation modes
+ 'no_announce' => $data['no_announce'] ?? false,
+ 'no_accept' => $data['no_accept'] ?? false,
+ 'stun' => $data['stun'] ?? false,
+
+ // Global config
+ 'global_config' => $data['global_config'] ?? '',
+
+ // Web UI
+ 'web_ui_file' => $data['web_ui_file'] ?? '',
+
+ // Log
+ 'log_file' => $data['log_file'] ?? 'syslog',
+
+ // Keepalived
+ 'keepalived_config' => $data['keepalived_config'] ?? '/etc/keepalived.conf',
+
+ // Authorized providers (ASPA)
+ 'authorized_providers' => $data['authorized_providers'] ?? [],
+ ];
+ }
+
+ /**
+ * Count ASNs
+ *
+ * @return int
+ */
+ public function count(): int {
+ $asns = $this->db->get('asns') ?? [];
+ return count($asns);
+ }
+
+ /**
+ * Search ASNs
+ *
+ * @param string $query
+ * @return array
+ */
+ public function search(string $query): array {
+ $asns = $this->db->get('asns') ?? [];
+ $query = strtolower($query);
+
+ return array_filter($asns, function($asn) use ($query) {
+ return strpos(strtolower((string) $asn['asn']), $query) !== false ||
+ strpos(strtolower($asn['name']), $query) !== false ||
+ strpos(strtolower($asn['description'] ?? ''), $query) !== false;
+ });
+ }
+
+ /**
+ * Export ASN to YAML format for Pathvector
+ *
+ * @param string $id
+ * @return string|null
+ */
+ public function exportYaml(string $id): ?string {
+ $asn = $this->get($id);
+
+ if (!$asn) {
+ return null;
+ }
+
+ $config = [];
+ $config['asn'] = $asn['asn'];
+
+ $defaults = $asn['pathvector_defaults'];
+
+ if (!empty($defaults['router_id'])) {
+ $config['router-id'] = $defaults['router_id'];
+ }
+
+ if (!empty($defaults['source4'])) {
+ $config['source4'] = $defaults['source4'];
+ }
+
+ if (!empty($defaults['source6'])) {
+ $config['source6'] = $defaults['source6'];
+ }
+
+ if (!empty($defaults['prefixes'])) {
+ $config['prefixes'] = $defaults['prefixes'];
+ }
+
+ if (!empty($defaults['hostname'])) {
+ $config['hostname'] = $defaults['hostname'];
+ }
+
+ // Add other non-empty defaults
+ foreach ($defaults as $key => $value) {
+ if ($value !== '' && $value !== [] && $value !== null && !isset($config[$key])) {
+ $yamlKey = str_replace('_', '-', $key);
+ $config[$yamlKey] = $value;
+ }
+ }
+
+ return $this->arrayToYaml($config);
+ }
+
+ /**
+ * Convert array to YAML string
+ *
+ * @param array $data
+ * @param int $indent
+ * @return string
+ */
+ private function arrayToYaml(array $data, int $indent = 0): string {
+ $yaml = '';
+ $prefix = str_repeat(' ', $indent);
+
+ foreach ($data as $key => $value) {
+ if (is_array($value)) {
+ if (empty($value)) {
+ continue;
+ }
+
+ // Check if it's a sequential array
+ if (array_keys($value) === range(0, count($value) - 1)) {
+ $yaml .= "$prefix$key:\n";
+ foreach ($value as $item) {
+ if (is_array($item)) {
+ $yaml .= "$prefix -\n" . $this->arrayToYaml($item, $indent + 2);
+ } else {
+ $yaml .= "$prefix - " . $this->formatYamlValue($item) . "\n";
+ }
+ }
+ } else {
+ $yaml .= "$prefix$key:\n" . $this->arrayToYaml($value, $indent + 1);
+ }
+ } else {
+ if ($value === '' || $value === null) {
+ continue;
+ }
+ $yaml .= "$prefix$key: " . $this->formatYamlValue($value) . "\n";
+ }
+ }
+
+ return $yaml;
+ }
+
+ /**
+ * Format value for YAML output
+ *
+ * @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 $value;
+ }
+
+ /**
+ * Backup ASN data
+ *
+ * @param string $backupDir
+ * @return string|false
+ */
+ public function backup(string $backupDir): string|false {
+ return $this->db->backup($backupDir);
+ }
+}
diff --git a/pathvector-admin/lib/Auth.php b/pathvector-admin/lib/Auth.php
new file mode 100644
index 0000000..61abeed
--- /dev/null
+++ b/pathvector-admin/lib/Auth.php
@@ -0,0 +1,428 @@
+ ['admin', 'operator', 'readonly'],
+ 'operator' => ['operator', 'readonly'],
+ 'readonly' => ['readonly'],
+ ];
+
+ /**
+ * Constructor
+ *
+ * @param string $userFile Path to users JSON file
+ * @param Logger $logger Logger instance
+ * @param array $config Application config
+ */
+ public function __construct(string $userFile, Logger $logger, array $config) {
+ $this->db = new FlatFileDB($userFile);
+ $this->logger = $logger;
+ $this->config = $config;
+
+ // Initialize users if empty
+ if (!$this->db->exists('users')) {
+ $this->initializeDefaultAdmin();
+ }
+ }
+
+ /**
+ * Initialize default admin user
+ *
+ * @return void
+ */
+ private function initializeDefaultAdmin(): void {
+ $defaultAdmin = $this->config['default_admin'] ?? [
+ 'username' => 'admin',
+ 'password' => 'pathvector',
+ 'role' => 'admin',
+ ];
+
+ $users = [
+ $defaultAdmin['username'] => [
+ 'username' => $defaultAdmin['username'],
+ 'password' => password_hash($defaultAdmin['password'], PASSWORD_DEFAULT),
+ 'role' => $defaultAdmin['role'],
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ 'last_login' => null,
+ 'is_active' => true,
+ ],
+ ];
+
+ $this->db->set('users', $users);
+ }
+
+ /**
+ * Start session if not already started
+ *
+ * @return void
+ */
+ public function startSession(): void {
+ if (session_status() === PHP_SESSION_NONE) {
+ session_start();
+ }
+ }
+
+ /**
+ * Generate CSRF token
+ *
+ * @return string
+ */
+ public function generateCsrfToken(): string {
+ $this->startSession();
+
+ if (!isset($_SESSION['csrf_token'])) {
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ }
+
+ return $_SESSION['csrf_token'];
+ }
+
+ /**
+ * Validate CSRF token
+ *
+ * @param string $token
+ * @return bool
+ */
+ public function validateCsrfToken(string $token): bool {
+ $this->startSession();
+ return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
+ }
+
+ /**
+ * Regenerate CSRF token
+ *
+ * @return string
+ */
+ public function regenerateCsrfToken(): string {
+ $this->startSession();
+ $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+ return $_SESSION['csrf_token'];
+ }
+
+ /**
+ * Authenticate user
+ *
+ * @param string $username
+ * @param string $password
+ * @return bool
+ */
+ public function login(string $username, string $password): bool {
+ $this->startSession();
+
+ $users = $this->db->get('users') ?? [];
+
+ if (!isset($users[$username])) {
+ $this->logger->warning('auth', "Failed login attempt for unknown user: $username");
+ return false;
+ }
+
+ $user = $users[$username];
+
+ if (!$user['is_active']) {
+ $this->logger->warning('auth', "Login attempt for inactive user: $username");
+ return false;
+ }
+
+ if (!password_verify($password, $user['password'])) {
+ $this->logger->warning('auth', "Failed login attempt for user: $username");
+ return false;
+ }
+
+ // Update last login
+ $users[$username]['last_login'] = date('c');
+ $this->db->set('users', $users);
+
+ // Set session
+ $_SESSION['user'] = [
+ 'username' => $user['username'],
+ 'role' => $user['role'],
+ 'logged_in_at' => date('c'),
+ ];
+
+ // Regenerate session ID for security
+ session_regenerate_id(true);
+
+ $this->logger->success('auth', "User logged in: $username");
+
+ return true;
+ }
+
+ /**
+ * Logout user
+ *
+ * @return void
+ */
+ public function logout(): void {
+ $this->startSession();
+
+ $username = $_SESSION['user']['username'] ?? 'unknown';
+ $this->logger->info('auth', "User logged out: $username");
+
+ $_SESSION = [];
+
+ if (ini_get('session.use_cookies')) {
+ $params = session_get_cookie_params();
+ setcookie(
+ session_name(),
+ '',
+ time() - 42000,
+ $params['path'],
+ $params['domain'],
+ $params['secure'],
+ $params['httponly']
+ );
+ }
+
+ session_destroy();
+ }
+
+ /**
+ * Check if user is logged in
+ *
+ * @return bool
+ */
+ public function isLoggedIn(): bool {
+ $this->startSession();
+ return isset($_SESSION['user']);
+ }
+
+ /**
+ * Get current user
+ *
+ * @return array|null
+ */
+ public function getCurrentUser(): ?array {
+ $this->startSession();
+ return $_SESSION['user'] ?? null;
+ }
+
+ /**
+ * Check if current user has role
+ *
+ * @param string $requiredRole
+ * @return bool
+ */
+ public function hasRole(string $requiredRole): bool {
+ $this->startSession();
+
+ if (!isset($_SESSION['user']['role'])) {
+ return false;
+ }
+
+ $userRole = $_SESSION['user']['role'];
+
+ return in_array($requiredRole, $this->roles[$userRole] ?? []);
+ }
+
+ /**
+ * Require login - redirect if not logged in
+ *
+ * @return void
+ */
+ public function requireLogin(): void {
+ if (!$this->isLoggedIn()) {
+ header('Location: login.php');
+ exit;
+ }
+ }
+
+ /**
+ * Require specific role
+ *
+ * @param string $role
+ * @return void
+ */
+ public function requireRole(string $role): void {
+ $this->requireLogin();
+
+ if (!$this->hasRole($role)) {
+ header('HTTP/1.1 403 Forbidden');
+ echo 'Access Denied: Insufficient permissions';
+ exit;
+ }
+ }
+
+ /**
+ * Create new user
+ *
+ * @param string $username
+ * @param string $password
+ * @param string $role
+ * @return bool
+ */
+ public function createUser(string $username, string $password, string $role = 'readonly'): bool {
+ $users = $this->db->get('users') ?? [];
+
+ if (isset($users[$username])) {
+ return false;
+ }
+
+ $users[$username] = [
+ 'username' => $username,
+ 'password' => password_hash($password, PASSWORD_DEFAULT),
+ 'role' => $role,
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ 'last_login' => null,
+ 'is_active' => true,
+ ];
+
+ $result = $this->db->set('users', $users);
+
+ if ($result) {
+ $this->logger->success('auth', "User created: $username with role: $role");
+ }
+
+ return $result;
+ }
+
+ /**
+ * Update user
+ *
+ * @param string $username
+ * @param array $data
+ * @return bool
+ */
+ public function updateUser(string $username, array $data): bool {
+ $users = $this->db->get('users') ?? [];
+
+ if (!isset($users[$username])) {
+ return false;
+ }
+
+ if (isset($data['password'])) {
+ $data['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
+ }
+
+ $data['updated_at'] = date('c');
+ $users[$username] = array_merge($users[$username], $data);
+
+ $result = $this->db->set('users', $users);
+
+ if ($result) {
+ $this->logger->info('auth', "User updated: $username");
+ }
+
+ return $result;
+ }
+
+ /**
+ * Delete user
+ *
+ * @param string $username
+ * @return bool
+ */
+ public function deleteUser(string $username): bool {
+ $users = $this->db->get('users') ?? [];
+
+ if (!isset($users[$username])) {
+ return false;
+ }
+
+ unset($users[$username]);
+
+ $result = $this->db->set('users', $users);
+
+ if ($result) {
+ $this->logger->info('auth', "User deleted: $username");
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get user by username
+ *
+ * @param string $username
+ * @return array|null
+ */
+ public function getUser(string $username): ?array {
+ $users = $this->db->get('users') ?? [];
+ return $users[$username] ?? null;
+ }
+
+ /**
+ * Get all users
+ *
+ * @return array
+ */
+ public function getAllUsers(): array {
+ $users = $this->db->get('users') ?? [];
+
+ // Remove password hashes for security
+ return array_map(function($user) {
+ unset($user['password']);
+ return $user;
+ }, $users);
+ }
+
+ /**
+ * Change user password
+ *
+ * @param string $username
+ * @param string $currentPassword
+ * @param string $newPassword
+ * @return bool
+ */
+ public function changePassword(string $username, string $currentPassword, string $newPassword): bool {
+ $users = $this->db->get('users') ?? [];
+
+ if (!isset($users[$username])) {
+ return false;
+ }
+
+ if (!password_verify($currentPassword, $users[$username]['password'])) {
+ $this->logger->warning('auth', "Password change failed for user: $username (wrong current password)");
+ return false;
+ }
+
+ $users[$username]['password'] = password_hash($newPassword, PASSWORD_DEFAULT);
+ $users[$username]['updated_at'] = date('c');
+
+ $result = $this->db->set('users', $users);
+
+ if ($result) {
+ $this->logger->success('auth', "Password changed for user: $username");
+ }
+
+ return $result;
+ }
+
+ /**
+ * Reset user password (admin function)
+ *
+ * @param string $username
+ * @param string $newPassword
+ * @return bool
+ */
+ public function resetPassword(string $username, string $newPassword): bool {
+ $users = $this->db->get('users') ?? [];
+
+ if (!isset($users[$username])) {
+ return false;
+ }
+
+ $users[$username]['password'] = password_hash($newPassword, PASSWORD_DEFAULT);
+ $users[$username]['updated_at'] = date('c');
+
+ $result = $this->db->set('users', $users);
+
+ if ($result) {
+ $this->logger->success('auth', "Password reset for user: $username by admin");
+ }
+
+ return $result;
+ }
+}
diff --git a/pathvector-admin/lib/BirdConfig.php b/pathvector-admin/lib/BirdConfig.php
new file mode 100644
index 0000000..04ca7e5
--- /dev/null
+++ b/pathvector-admin/lib/BirdConfig.php
@@ -0,0 +1,457 @@
+= 5) {
+ $protocols[] = [
+ 'name' => $parts[0],
+ 'proto' => $parts[1],
+ 'table' => $parts[2],
+ 'state' => $parts[3],
+ 'since' => $parts[4],
+ 'info' => $parts[5] ?? '',
+ ];
+ }
+ }
+
+ return $protocols;
+ }
+
+ /**
+ * Parse BIRD route count output
+ *
+ * @param string $output
+ * @return array
+ */
+ public static function parseRouteCount(string $output): array {
+ $counts = [
+ 'total' => 0,
+ 'primary' => 0,
+ 'filtered' => 0,
+ ];
+
+ // Format: X of Y routes for Z networks
+ if (preg_match('/(\d+)\s+of\s+(\d+)\s+routes\s+for\s+(\d+)\s+networks/', $output, $matches)) {
+ $counts['primary'] = (int) $matches[1];
+ $counts['total'] = (int) $matches[2];
+ $counts['networks'] = (int) $matches[3];
+ }
+
+ // Filtered routes
+ if (preg_match('/(\d+)\s+routes\s+\((\d+)\s+filtered\)/', $output, $matches)) {
+ $counts['total'] = (int) $matches[1];
+ $counts['filtered'] = (int) $matches[2];
+ }
+
+ return $counts;
+ }
+
+ /**
+ * Parse BIRD status output
+ *
+ * @param string $output
+ * @return array
+ */
+ public static function parseStatus(string $output): array {
+ $status = [
+ 'version' => '',
+ 'router_id' => '',
+ 'hostname' => '',
+ 'server_time' => '',
+ 'last_reboot' => '',
+ 'last_reconfiguration' => '',
+ ];
+
+ $lines = explode("\n", trim($output));
+
+ foreach ($lines as $line) {
+ $line = trim($line);
+
+ if (preg_match('/^BIRD\s+(.+)$/', $line, $matches)) {
+ $status['version'] = trim($matches[1]);
+ } elseif (preg_match('/^Router ID is\s+(.+)$/', $line, $matches)) {
+ $status['router_id'] = trim($matches[1]);
+ } elseif (preg_match('/^Hostname is\s+(.+)$/', $line, $matches)) {
+ $status['hostname'] = trim($matches[1]);
+ } elseif (preg_match('/^Current server time is\s+(.+)$/', $line, $matches)) {
+ $status['server_time'] = trim($matches[1]);
+ } elseif (preg_match('/^Last reboot on\s+(.+)$/', $line, $matches)) {
+ $status['last_reboot'] = trim($matches[1]);
+ } elseif (preg_match('/^Last reconfiguration on\s+(.+)$/', $line, $matches)) {
+ $status['last_reconfiguration'] = trim($matches[1]);
+ }
+ }
+
+ return $status;
+ }
+
+ /**
+ * Parse detailed protocol info
+ *
+ * @param string $output
+ * @return array
+ */
+ public static function parseProtocolDetail(string $output): array {
+ $info = [
+ 'name' => '',
+ 'description' => '',
+ 'state' => '',
+ 'table' => '',
+ 'neighbor_address' => '',
+ 'neighbor_as' => '',
+ 'local_as' => '',
+ 'neighbor_id' => '',
+ 'routes' => [
+ 'imported' => 0,
+ 'exported' => 0,
+ 'preferred' => 0,
+ 'filtered' => 0,
+ ],
+ 'uptime' => '',
+ 'hold_timer' => '',
+ 'keepalive_timer' => '',
+ 'last_error' => '',
+ 'channel' => [],
+ ];
+
+ $lines = explode("\n", trim($output));
+ $currentChannel = null;
+
+ foreach ($lines as $line) {
+ $line = trim($line);
+
+ // Protocol name
+ if (preg_match('/^(\S+)\s+BGP\s+/', $line, $matches)) {
+ $info['name'] = $matches[1];
+ }
+
+ // Description
+ if (preg_match('/Description:\s+(.+)$/', $line, $matches)) {
+ $info['description'] = trim($matches[1]);
+ }
+
+ // State
+ if (preg_match('/BGP state:\s+(\S+)/', $line, $matches)) {
+ $info['state'] = $matches[1];
+ }
+
+ // Neighbor address and AS
+ if (preg_match('/Neighbor address:\s+(\S+)/', $line, $matches)) {
+ $info['neighbor_address'] = $matches[1];
+ }
+
+ if (preg_match('/Neighbor AS:\s+(\d+)/', $line, $matches)) {
+ $info['neighbor_as'] = (int) $matches[1];
+ }
+
+ if (preg_match('/Local AS:\s+(\d+)/', $line, $matches)) {
+ $info['local_as'] = (int) $matches[1];
+ }
+
+ if (preg_match('/Neighbor ID:\s+(\S+)/', $line, $matches)) {
+ $info['neighbor_id'] = $matches[1];
+ }
+
+ // Routes
+ if (preg_match('/Routes:\s+(\d+)\s+imported,\s+(\d+)\s+exported,\s+(\d+)\s+preferred/', $line, $matches)) {
+ $info['routes']['imported'] = (int) $matches[1];
+ $info['routes']['exported'] = (int) $matches[2];
+ $info['routes']['preferred'] = (int) $matches[3];
+ }
+
+ if (preg_match('/(\d+)\s+filtered/', $line, $matches)) {
+ $info['routes']['filtered'] = (int) $matches[1];
+ }
+
+ // Hold timer
+ if (preg_match('/Hold timer:\s+(\S+)/', $line, $matches)) {
+ $info['hold_timer'] = $matches[1];
+ }
+
+ // Keepalive timer
+ if (preg_match('/Keepalive timer:\s+(\S+)/', $line, $matches)) {
+ $info['keepalive_timer'] = $matches[1];
+ }
+
+ // Last error
+ if (preg_match('/Last error:\s+(.+)$/', $line, $matches)) {
+ $info['last_error'] = trim($matches[1]);
+ }
+
+ // Channel info
+ if (preg_match('/Channel (ipv[46])/', $line, $matches)) {
+ $currentChannel = $matches[1];
+ $info['channel'][$currentChannel] = [];
+ }
+
+ if ($currentChannel && preg_match('/State:\s+(\S+)/', $line, $matches)) {
+ $info['channel'][$currentChannel]['state'] = $matches[1];
+ }
+
+ if ($currentChannel && preg_match('/Table:\s+(\S+)/', $line, $matches)) {
+ $info['channel'][$currentChannel]['table'] = $matches[1];
+ }
+ }
+
+ return $info;
+ }
+
+ /**
+ * Get state color class for UI
+ *
+ * @param string $state
+ * @return string
+ */
+ public static function getStateColorClass(string $state): string {
+ return match (strtolower($state)) {
+ 'established' => 'color-fg-success',
+ 'up' => 'color-fg-success',
+ 'start', 'connect', 'active', 'opensent', 'openconfirm' => 'color-fg-attention',
+ 'idle' => 'color-fg-muted',
+ 'down' => 'color-fg-danger',
+ default => 'color-fg-default',
+ };
+ }
+
+ /**
+ * Get state label for UI
+ *
+ * @param string $state
+ * @return string
+ */
+ public static function getStateLabel(string $state): string {
+ return match (strtolower($state)) {
+ 'established' => 'Label--success',
+ 'up' => 'Label--success',
+ 'start', 'connect', 'active', 'opensent', 'openconfirm' => 'Label--attention',
+ 'idle' => 'Label--secondary',
+ 'down' => 'Label--danger',
+ default => 'Label--primary',
+ };
+ }
+
+ /**
+ * Generate basic BIRD 3 configuration
+ *
+ * @param array $config
+ * @return string
+ */
+ public static function generateBasicConfig(array $config): string {
+ $bird = "# BIRD 3 Configuration\n";
+ $bird .= "# Generated by Pathvector Admin\n\n";
+
+ // Router ID
+ if (!empty($config['router_id'])) {
+ $bird .= "router id {$config['router_id']};\n\n";
+ }
+
+ // Log
+ $bird .= "log syslog all;\n\n";
+
+ // Debug
+ $bird .= "debug protocols all;\n\n";
+
+ // Timeformat
+ $bird .= "timeformat protocol iso long;\n\n";
+
+ // Protocol device
+ $bird .= "protocol device {\n";
+ $bird .= " scan time 10;\n";
+ $bird .= "}\n\n";
+
+ // Protocol direct
+ $bird .= "protocol direct {\n";
+ $bird .= " ipv4;\n";
+ $bird .= " ipv6;\n";
+ $bird .= "}\n\n";
+
+ // Protocol kernel
+ $bird .= "protocol kernel {\n";
+ $bird .= " ipv4 {\n";
+ $bird .= " export all;\n";
+ $bird .= " import all;\n";
+ $bird .= " };\n";
+ $bird .= "}\n\n";
+
+ $bird .= "protocol kernel {\n";
+ $bird .= " ipv6 {\n";
+ $bird .= " export all;\n";
+ $bird .= " import all;\n";
+ $bird .= " };\n";
+ $bird .= "}\n\n";
+
+ return $bird;
+ }
+
+ /**
+ * Validate BIRD config syntax (basic)
+ *
+ * @param string $config
+ * @return array
+ */
+ public static function validateSyntax(string $config): array {
+ $errors = [];
+ $warnings = [];
+
+ // Check for common issues
+ $lines = explode("\n", $config);
+ $braceCount = 0;
+ $inString = false;
+
+ foreach ($lines as $lineNum => $line) {
+ $lineNum++; // 1-indexed
+
+ // Skip comments
+ if (preg_match('/^\s*#/', $line)) {
+ continue;
+ }
+
+ // Count braces
+ for ($i = 0; $i < strlen($line); $i++) {
+ $char = $line[$i];
+
+ if ($char === '"' && ($i === 0 || $line[$i - 1] !== '\\')) {
+ $inString = !$inString;
+ }
+
+ if (!$inString) {
+ if ($char === '{') {
+ $braceCount++;
+ } elseif ($char === '}') {
+ $braceCount--;
+ }
+ }
+ }
+
+ // Check for missing semicolons (basic check)
+ $trimmed = trim($line);
+ if (!empty($trimmed) &&
+ !preg_match('/[{};#]$/', $trimmed) &&
+ !preg_match('/^\s*(protocol|filter|function|table|if|else|for|case|switch)/', $trimmed)) {
+ $warnings[] = "Line $lineNum: Possible missing semicolon";
+ }
+ }
+
+ if ($braceCount !== 0) {
+ $errors[] = "Unbalanced braces: " . ($braceCount > 0 ? "missing $braceCount closing braces" : "extra " . abs($braceCount) . " closing braces");
+ }
+
+ return [
+ 'valid' => empty($errors),
+ 'errors' => $errors,
+ 'warnings' => $warnings,
+ ];
+ }
+
+ /**
+ * Format BIRD configuration
+ *
+ * @param string $config
+ * @return string
+ */
+ public static function formatConfig(string $config): string {
+ $lines = explode("\n", $config);
+ $output = [];
+ $indent = 0;
+
+ foreach ($lines as $line) {
+ $trimmed = trim($line);
+
+ // Decrease indent for closing braces
+ if (preg_match('/^}/', $trimmed)) {
+ $indent = max(0, $indent - 1);
+ }
+
+ // Add line with proper indentation
+ if (!empty($trimmed)) {
+ $output[] = str_repeat("\t", $indent) . $trimmed;
+ } else {
+ $output[] = '';
+ }
+
+ // Increase indent for opening braces
+ if (preg_match('/{\s*$/', $trimmed)) {
+ $indent++;
+ }
+ }
+
+ return implode("\n", $output);
+ }
+
+ /**
+ * Get list of BIRD 3 protocol types
+ *
+ * @return array
+ */
+ public static function getProtocolTypes(): array {
+ return [
+ 'bgp' => 'Border Gateway Protocol',
+ 'ospf' => 'Open Shortest Path First',
+ 'rip' => 'Routing Information Protocol',
+ 'babel' => 'Babel Routing Protocol',
+ 'static' => 'Static Routes',
+ 'kernel' => 'Kernel Route Table',
+ 'device' => 'Device Protocol',
+ 'direct' => 'Direct Interfaces',
+ 'pipe' => 'Table Pipe',
+ 'bfd' => 'Bidirectional Forwarding Detection',
+ 'rpki' => 'Resource Public Key Infrastructure',
+ 'mrt' => 'MRT Table Dump',
+ ];
+ }
+
+ /**
+ * Get BGP session states
+ *
+ * @return array
+ */
+ public static function getBgpStates(): array {
+ return [
+ 'Idle' => 'Not running',
+ 'Connect' => 'Connecting to peer',
+ 'Active' => 'Trying to connect',
+ 'OpenSent' => 'Open message sent',
+ 'OpenConfirm' => 'Waiting for keepalive',
+ 'Established' => 'Session established',
+ ];
+ }
+}
diff --git a/pathvector-admin/lib/FlatFileDB.php b/pathvector-admin/lib/FlatFileDB.php
new file mode 100644
index 0000000..7129bf7
--- /dev/null
+++ b/pathvector-admin/lib/FlatFileDB.php
@@ -0,0 +1,218 @@
+filePath = $filePath;
+ $this->load();
+ }
+
+ /**
+ * Load data from JSON file
+ *
+ * @return void
+ */
+ private function load(): void {
+ if (file_exists($this->filePath)) {
+ $content = file_get_contents($this->filePath);
+ $this->data = json_decode($content, true) ?? [];
+ } else {
+ $this->data = [];
+ $this->save();
+ }
+ }
+
+ /**
+ * Save data to JSON file
+ *
+ * @return bool
+ */
+ public function save(): bool {
+ $dir = dirname($this->filePath);
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ $json = json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ return file_put_contents($this->filePath, $json) !== false;
+ }
+
+ /**
+ * Get all data
+ *
+ * @return array
+ */
+ public function getAll(): array {
+ return $this->data;
+ }
+
+ /**
+ * Get data by key
+ *
+ * @param string $key
+ * @return mixed
+ */
+ public function get(string $key) {
+ return $this->data[$key] ?? null;
+ }
+
+ /**
+ * Set data by key
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return bool
+ */
+ public function set(string $key, $value): bool {
+ $this->data[$key] = $value;
+ return $this->save();
+ }
+
+ /**
+ * Delete data by key
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function delete(string $key): bool {
+ if (isset($this->data[$key])) {
+ unset($this->data[$key]);
+ return $this->save();
+ }
+ return false;
+ }
+
+ /**
+ * Check if key exists
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function exists(string $key): bool {
+ return isset($this->data[$key]);
+ }
+
+ /**
+ * Get all keys
+ *
+ * @return array
+ */
+ public function keys(): array {
+ return array_keys($this->data);
+ }
+
+ /**
+ * Count all entries
+ *
+ * @return int
+ */
+ public function count(): int {
+ return count($this->data);
+ }
+
+ /**
+ * Clear all data
+ *
+ * @return bool
+ */
+ public function clear(): bool {
+ $this->data = [];
+ return $this->save();
+ }
+
+ /**
+ * Search data by callback function
+ *
+ * @param callable $callback
+ * @return array
+ */
+ public function search(callable $callback): array {
+ return array_filter($this->data, $callback, ARRAY_FILTER_USE_BOTH);
+ }
+
+ /**
+ * Update data by key with callback
+ *
+ * @param string $key
+ * @param callable $callback
+ * @return bool
+ */
+ public function update(string $key, callable $callback): bool {
+ if (isset($this->data[$key])) {
+ $this->data[$key] = $callback($this->data[$key]);
+ return $this->save();
+ }
+ return false;
+ }
+
+ /**
+ * Get file path
+ *
+ * @return string
+ */
+ public function getFilePath(): string {
+ return $this->filePath;
+ }
+
+ /**
+ * Reload data from file
+ *
+ * @return void
+ */
+ public function reload(): void {
+ $this->load();
+ }
+
+ /**
+ * Backup current data
+ *
+ * @param string $backupDir
+ * @return string|false Returns backup file path on success, false on failure
+ */
+ public function backup(string $backupDir): string|false {
+ if (!is_dir($backupDir)) {
+ mkdir($backupDir, 0755, true);
+ }
+
+ $timestamp = date('Y-m-d_H-i-s');
+ $filename = basename($this->filePath, '.json');
+ $backupPath = $backupDir . '/' . $filename . '_' . $timestamp . '.json';
+
+ $json = json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ return file_put_contents($backupPath, $json) !== false ? $backupPath : false;
+ }
+
+ /**
+ * Restore data from backup file
+ *
+ * @param string $backupPath
+ * @return bool
+ */
+ public function restore(string $backupPath): bool {
+ if (!file_exists($backupPath)) {
+ return false;
+ }
+
+ $content = file_get_contents($backupPath);
+ $data = json_decode($content, true);
+
+ if ($data === null) {
+ return false;
+ }
+
+ $this->data = $data;
+ return $this->save();
+ }
+}
diff --git a/pathvector-admin/lib/Host.php b/pathvector-admin/lib/Host.php
new file mode 100644
index 0000000..84503c2
--- /dev/null
+++ b/pathvector-admin/lib/Host.php
@@ -0,0 +1,559 @@
+db = new FlatFileDB($dataFile);
+ $this->logger = $logger;
+ $this->validator = new Validator();
+
+ if (!$this->db->exists('hosts')) {
+ $this->db->set('hosts', []);
+ $this->initializeLocalHost();
+ }
+ }
+
+ /**
+ * Initialize localhost entry
+ */
+ private function initializeLocalHost(): void {
+ $hosts = [
+ 'localhost' => [
+ 'id' => 'localhost',
+ 'name' => 'Local Host',
+ 'hostname' => 'localhost',
+ 'description' => 'Local machine',
+ 'execution' => [
+ 'method' => self::METHOD_LOCAL,
+ 'pathvector_bin' => '/usr/local/bin/pathvector',
+ 'bird_bin' => '/usr/sbin/bird',
+ 'birdc_bin' => '/usr/sbin/birdc',
+ 'bird_socket' => '/var/run/bird.ctl',
+ 'config_dir' => '/etc/pathvector',
+ 'bird_dir' => '/etc/bird',
+ 'sudo' => true,
+ ],
+ 'is_active' => true,
+ 'is_default' => true,
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ],
+ ];
+
+ $this->db->set('hosts', $hosts);
+ }
+
+ /**
+ * Get all hosts
+ *
+ * @return array
+ */
+ public function getAll(): array {
+ return $this->db->get('hosts') ?? [];
+ }
+
+ /**
+ * Get host by ID
+ *
+ * @param string $id
+ * @return array|null
+ */
+ public function get(string $id): ?array {
+ $hosts = $this->db->get('hosts') ?? [];
+ return $hosts[$id] ?? null;
+ }
+
+ /**
+ * Get default host
+ *
+ * @return array|null
+ */
+ public function getDefault(): ?array {
+ $hosts = $this->db->get('hosts') ?? [];
+
+ foreach ($hosts as $host) {
+ if ($host['is_default'] ?? false) {
+ return $host;
+ }
+ }
+
+ // Return first active host if no default
+ foreach ($hosts as $host) {
+ if ($host['is_active'] ?? false) {
+ return $host;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Create new host
+ *
+ * @param array $data
+ * @return array
+ */
+ public function create(array $data): array {
+ if (empty($data['id'])) {
+ return [
+ 'success' => false,
+ 'message' => 'Host ID is required',
+ 'errors' => ['id' => 'Host ID is required'],
+ ];
+ }
+
+ $hosts = $this->db->get('hosts') ?? [];
+ $id = $this->sanitizeId($data['id']);
+
+ if (isset($hosts[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Host $id already exists",
+ 'errors' => ['id' => 'Host ID already exists'],
+ ];
+ }
+
+ // Validate SSH settings if SSH method
+ $method = $data['execution']['method'] ?? self::METHOD_LOCAL;
+
+ if ($method === self::METHOD_SSH) {
+ if (empty($data['execution']['ssh_host'])) {
+ return [
+ 'success' => false,
+ 'message' => 'SSH host is required for SSH execution method',
+ 'errors' => ['ssh_host' => 'SSH host is required'],
+ ];
+ }
+ }
+
+ $hostData = [
+ 'id' => $id,
+ 'name' => $data['name'] ?? $id,
+ 'hostname' => $data['hostname'] ?? '',
+ 'description' => $data['description'] ?? '',
+ 'execution' => $this->buildExecutionConfig($data['execution'] ?? []),
+ 'metadata' => $data['metadata'] ?? [],
+ 'is_active' => $data['is_active'] ?? true,
+ 'is_default' => false, // New hosts are not default
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ];
+
+ $hosts[$id] = $hostData;
+
+ if ($this->db->set('hosts', $hosts)) {
+ $this->logger->success('host', "Created host: $id");
+ return [
+ 'success' => true,
+ 'message' => "Host $id created successfully",
+ 'data' => $hostData,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to save host',
+ 'errors' => ['database' => 'Failed to save host'],
+ ];
+ }
+
+ /**
+ * Update host
+ *
+ * @param string $id
+ * @param array $data
+ * @return array
+ */
+ public function update(string $id, array $data): array {
+ $hosts = $this->db->get('hosts') ?? [];
+
+ if (!isset($hosts[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Host $id not found",
+ 'errors' => ['id' => 'Host not found'],
+ ];
+ }
+
+ $hostData = $hosts[$id];
+
+ $allowedFields = ['name', 'hostname', 'description', 'metadata', 'is_active'];
+
+ foreach ($allowedFields as $field) {
+ if (isset($data[$field])) {
+ $hostData[$field] = $data[$field];
+ }
+ }
+
+ if (isset($data['execution'])) {
+ $hostData['execution'] = $this->buildExecutionConfig(
+ array_merge($hostData['execution'], $data['execution'])
+ );
+ }
+
+ $hostData['updated_at'] = date('c');
+ $hosts[$id] = $hostData;
+
+ if ($this->db->set('hosts', $hosts)) {
+ $this->logger->info('host', "Updated host: $id");
+ return [
+ 'success' => true,
+ 'message' => "Host $id updated successfully",
+ 'data' => $hostData,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to update host',
+ 'errors' => ['database' => 'Failed to save host'],
+ ];
+ }
+
+ /**
+ * Delete host
+ *
+ * @param string $id
+ * @return array
+ */
+ public function delete(string $id): array {
+ $hosts = $this->db->get('hosts') ?? [];
+
+ if (!isset($hosts[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Host $id not found",
+ ];
+ }
+
+ if ($id === 'localhost') {
+ return [
+ 'success' => false,
+ 'message' => 'Cannot delete localhost',
+ ];
+ }
+
+ if ($hosts[$id]['is_default'] ?? false) {
+ return [
+ 'success' => false,
+ 'message' => 'Cannot delete default host. Set another host as default first.',
+ ];
+ }
+
+ $hostName = $hosts[$id]['name'];
+ unset($hosts[$id]);
+
+ if ($this->db->set('hosts', $hosts)) {
+ $this->logger->warning('host', "Deleted host: $id ($hostName)");
+ return [
+ 'success' => true,
+ 'message' => "Host $id deleted successfully",
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to delete host',
+ ];
+ }
+
+ /**
+ * Set default host
+ *
+ * @param string $id
+ * @return array
+ */
+ public function setDefault(string $id): array {
+ $hosts = $this->db->get('hosts') ?? [];
+
+ if (!isset($hosts[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Host $id not found",
+ ];
+ }
+
+ // Unset previous default
+ foreach ($hosts as $hostId => $host) {
+ $hosts[$hostId]['is_default'] = ($hostId === $id);
+ }
+
+ if ($this->db->set('hosts', $hosts)) {
+ $this->logger->info('host', "Set default host: $id");
+ return [
+ 'success' => true,
+ 'message' => "Host $id set as default",
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to set default host',
+ ];
+ }
+
+ /**
+ * Build execution config structure
+ *
+ * @param array $data
+ * @return array
+ */
+ private function buildExecutionConfig(array $data): array {
+ return [
+ 'method' => $data['method'] ?? self::METHOD_LOCAL,
+
+ // Binary paths
+ 'pathvector_bin' => $data['pathvector_bin'] ?? '/usr/local/bin/pathvector',
+ 'bird_bin' => $data['bird_bin'] ?? '/usr/sbin/bird',
+ 'birdc_bin' => $data['birdc_bin'] ?? '/usr/sbin/birdc',
+
+ // Paths
+ 'bird_socket' => $data['bird_socket'] ?? '/var/run/bird.ctl',
+ 'config_dir' => $data['config_dir'] ?? '/etc/pathvector',
+ 'bird_dir' => $data['bird_dir'] ?? '/etc/bird',
+ 'cache_dir' => $data['cache_dir'] ?? '/var/cache/pathvector',
+
+ // Permissions
+ 'sudo' => $data['sudo'] ?? true,
+
+ // SSH settings (for SSH method)
+ 'ssh_host' => $data['ssh_host'] ?? '',
+ 'ssh_port' => $data['ssh_port'] ?? 22,
+ 'ssh_user' => $data['ssh_user'] ?? 'root',
+ 'ssh_key' => $data['ssh_key'] ?? '',
+ 'ssh_options' => $data['ssh_options'] ?? '-o StrictHostKeyChecking=no',
+
+ // Timeouts
+ 'timeout' => $data['timeout'] ?? 30,
+ 'connect_timeout' => $data['connect_timeout'] ?? 10,
+ ];
+ }
+
+ /**
+ * Test host connectivity
+ *
+ * @param string $id
+ * @return array
+ */
+ public function testConnection(string $id): array {
+ $host = $this->get($id);
+
+ if (!$host) {
+ return [
+ 'success' => false,
+ 'message' => "Host $id not found",
+ ];
+ }
+
+ $exec = $host['execution'];
+
+ if ($exec['method'] === self::METHOD_LOCAL) {
+ // Test local pathvector binary
+ $output = [];
+ $returnCode = 0;
+
+ $cmd = escapeshellcmd($exec['pathvector_bin']) . ' version';
+ if ($exec['sudo']) {
+ $cmd = 'sudo ' . $cmd;
+ }
+
+ exec($cmd . ' 2>&1', $output, $returnCode);
+
+ if ($returnCode === 0) {
+ return [
+ 'success' => true,
+ 'message' => 'Local host connection successful',
+ 'output' => implode("\n", $output),
+ ];
+ } else {
+ return [
+ 'success' => false,
+ 'message' => 'Failed to execute pathvector',
+ 'output' => implode("\n", $output),
+ ];
+ }
+ } elseif ($exec['method'] === self::METHOD_SSH) {
+ // Test SSH connection
+ $sshCmd = $this->buildSshCommand($exec, 'echo "Connection successful" && ' . escapeshellcmd($exec['pathvector_bin']) . ' version');
+
+ $output = [];
+ $returnCode = 0;
+ exec($sshCmd . ' 2>&1', $output, $returnCode);
+
+ if ($returnCode === 0) {
+ return [
+ 'success' => true,
+ 'message' => 'SSH connection successful',
+ 'output' => implode("\n", $output),
+ ];
+ } else {
+ return [
+ 'success' => false,
+ 'message' => 'SSH connection failed',
+ 'output' => implode("\n", $output),
+ ];
+ }
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Unknown execution method',
+ ];
+ }
+
+ /**
+ * Build SSH command
+ *
+ * @param array $exec Execution config
+ * @param string $remoteCmd Command to run on remote host
+ * @return string
+ */
+ public function buildSshCommand(array $exec, string $remoteCmd): string {
+ $ssh = 'ssh';
+
+ if (!empty($exec['ssh_key'])) {
+ $ssh .= ' -i ' . escapeshellarg($exec['ssh_key']);
+ }
+
+ if (!empty($exec['ssh_port']) && $exec['ssh_port'] != 22) {
+ $ssh .= ' -p ' . (int) $exec['ssh_port'];
+ }
+
+ if (!empty($exec['ssh_options'])) {
+ $ssh .= ' ' . $exec['ssh_options'];
+ }
+
+ $ssh .= ' -o ConnectTimeout=' . (int) ($exec['connect_timeout'] ?? 10);
+
+ $target = escapeshellarg($exec['ssh_user'] . '@' . $exec['ssh_host']);
+
+ if ($exec['sudo'] ?? false) {
+ $remoteCmd = 'sudo ' . $remoteCmd;
+ }
+
+ return $ssh . ' ' . $target . ' ' . escapeshellarg($remoteCmd);
+ }
+
+ /**
+ * Execute command on host
+ *
+ * @param string $id Host ID
+ * @param string $command Command to execute
+ * @return array
+ */
+ public function executeCommand(string $id, string $command): array {
+ $host = $this->get($id);
+
+ if (!$host) {
+ return [
+ 'success' => false,
+ 'message' => "Host $id not found",
+ 'output' => '',
+ ];
+ }
+
+ if (!($host['is_active'] ?? false)) {
+ return [
+ 'success' => false,
+ 'message' => "Host $id is not active",
+ 'output' => '',
+ ];
+ }
+
+ $exec = $host['execution'];
+ $output = [];
+ $returnCode = 0;
+
+ if ($exec['method'] === self::METHOD_LOCAL) {
+ $cmd = $command;
+ if ($exec['sudo'] ?? false) {
+ $cmd = 'sudo ' . $cmd;
+ }
+
+ exec($cmd . ' 2>&1', $output, $returnCode);
+ } elseif ($exec['method'] === self::METHOD_SSH) {
+ $sshCmd = $this->buildSshCommand($exec, $command);
+ exec($sshCmd . ' 2>&1', $output, $returnCode);
+ } else {
+ return [
+ 'success' => false,
+ 'message' => 'Unknown execution method',
+ 'output' => '',
+ ];
+ }
+
+ $this->logger->info('host', "Executed command on $id: $command", ['return_code' => $returnCode]);
+
+ return [
+ 'success' => $returnCode === 0,
+ 'message' => $returnCode === 0 ? 'Command executed successfully' : 'Command failed',
+ 'output' => implode("\n", $output),
+ 'return_code' => $returnCode,
+ ];
+ }
+
+ /**
+ * Sanitize host 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 hosts
+ *
+ * @return int
+ */
+ public function count(): int {
+ $hosts = $this->db->get('hosts') ?? [];
+ return count($hosts);
+ }
+
+ /**
+ * Get active hosts
+ *
+ * @return array
+ */
+ public function getActive(): array {
+ $hosts = $this->db->get('hosts') ?? [];
+ return array_filter($hosts, fn($h) => $h['is_active'] ?? false);
+ }
+
+ /**
+ * Backup hosts data
+ *
+ * @param string $backupDir
+ * @return string|false
+ */
+ public function backup(string $backupDir): string|false {
+ return $this->db->backup($backupDir);
+ }
+}
diff --git a/pathvector-admin/lib/Logger.php b/pathvector-admin/lib/Logger.php
new file mode 100644
index 0000000..cbdb573
--- /dev/null
+++ b/pathvector-admin/lib/Logger.php
@@ -0,0 +1,285 @@
+db = new FlatFileDB($logFile);
+ $this->maxEntries = $maxEntries;
+
+ // Initialize logs array if not exists
+ if (!$this->db->exists('logs')) {
+ $this->db->set('logs', []);
+ }
+ }
+
+ /**
+ * Add a log entry
+ *
+ * @param string $level Log level (info, warning, error, success)
+ * @param string $action Action performed
+ * @param string $message Log message
+ * @param string|null $user User who performed the action
+ * @param array $context Additional context data
+ * @return bool
+ */
+ public function log(string $level, string $action, string $message, ?string $user = null, array $context = []): bool {
+ if (!in_array($level, $this->validLevels)) {
+ $level = 'info';
+ }
+
+ $logs = $this->db->get('logs') ?? [];
+
+ $entry = [
+ 'id' => uniqid('log_'),
+ 'timestamp' => date('c'),
+ 'level' => $level,
+ 'action' => $action,
+ 'message' => $message,
+ 'user' => $user ?? ($_SESSION['user']['username'] ?? 'system'),
+ 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
+ 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
+ 'context' => $context,
+ ];
+
+ array_unshift($logs, $entry);
+
+ // Trim logs to max entries
+ if (count($logs) > $this->maxEntries) {
+ $logs = array_slice($logs, 0, $this->maxEntries);
+ }
+
+ return $this->db->set('logs', $logs);
+ }
+
+ /**
+ * Log info level message
+ *
+ * @param string $action
+ * @param string $message
+ * @param array $context
+ * @return bool
+ */
+ public function info(string $action, string $message, array $context = []): bool {
+ return $this->log('info', $action, $message, null, $context);
+ }
+
+ /**
+ * Log warning level message
+ *
+ * @param string $action
+ * @param string $message
+ * @param array $context
+ * @return bool
+ */
+ public function warning(string $action, string $message, array $context = []): bool {
+ return $this->log('warning', $action, $message, null, $context);
+ }
+
+ /**
+ * Log error level message
+ *
+ * @param string $action
+ * @param string $message
+ * @param array $context
+ * @return bool
+ */
+ public function error(string $action, string $message, array $context = []): bool {
+ return $this->log('error', $action, $message, null, $context);
+ }
+
+ /**
+ * Log success level message
+ *
+ * @param string $action
+ * @param string $message
+ * @param array $context
+ * @return bool
+ */
+ public function success(string $action, string $message, array $context = []): bool {
+ return $this->log('success', $action, $message, null, $context);
+ }
+
+ /**
+ * Get all logs
+ *
+ * @param int|null $limit
+ * @param int $offset
+ * @return array
+ */
+ public function getLogs(?int $limit = null, int $offset = 0): array {
+ $logs = $this->db->get('logs') ?? [];
+
+ if ($limit !== null) {
+ return array_slice($logs, $offset, $limit);
+ }
+
+ return $logs;
+ }
+
+ /**
+ * Get logs by level
+ *
+ * @param string $level
+ * @param int|null $limit
+ * @return array
+ */
+ public function getByLevel(string $level, ?int $limit = null): array {
+ $logs = $this->db->get('logs') ?? [];
+ $filtered = array_filter($logs, fn($log) => $log['level'] === $level);
+
+ if ($limit !== null) {
+ return array_slice($filtered, 0, $limit);
+ }
+
+ return array_values($filtered);
+ }
+
+ /**
+ * Get logs by user
+ *
+ * @param string $user
+ * @param int|null $limit
+ * @return array
+ */
+ public function getByUser(string $user, ?int $limit = null): array {
+ $logs = $this->db->get('logs') ?? [];
+ $filtered = array_filter($logs, fn($log) => $log['user'] === $user);
+
+ if ($limit !== null) {
+ return array_slice($filtered, 0, $limit);
+ }
+
+ return array_values($filtered);
+ }
+
+ /**
+ * Get logs by action
+ *
+ * @param string $action
+ * @param int|null $limit
+ * @return array
+ */
+ public function getByAction(string $action, ?int $limit = null): array {
+ $logs = $this->db->get('logs') ?? [];
+ $filtered = array_filter($logs, fn($log) => strpos($log['action'], $action) !== false);
+
+ if ($limit !== null) {
+ return array_slice($filtered, 0, $limit);
+ }
+
+ return array_values($filtered);
+ }
+
+ /**
+ * Get logs by date range
+ *
+ * @param string $startDate
+ * @param string $endDate
+ * @return array
+ */
+ public function getByDateRange(string $startDate, string $endDate): array {
+ $logs = $this->db->get('logs') ?? [];
+ $start = strtotime($startDate);
+ $end = strtotime($endDate);
+
+ return array_values(array_filter($logs, function($log) use ($start, $end) {
+ $logTime = strtotime($log['timestamp']);
+ return $logTime >= $start && $logTime <= $end;
+ }));
+ }
+
+ /**
+ * Search logs
+ *
+ * @param string $query
+ * @param int|null $limit
+ * @return array
+ */
+ public function search(string $query, ?int $limit = null): array {
+ $logs = $this->db->get('logs') ?? [];
+ $query = strtolower($query);
+
+ $filtered = array_filter($logs, function($log) use ($query) {
+ return strpos(strtolower($log['message']), $query) !== false ||
+ strpos(strtolower($log['action']), $query) !== false ||
+ strpos(strtolower($log['user']), $query) !== false;
+ });
+
+ if ($limit !== null) {
+ return array_slice($filtered, 0, $limit);
+ }
+
+ return array_values($filtered);
+ }
+
+ /**
+ * Get total log count
+ *
+ * @return int
+ */
+ public function count(): int {
+ $logs = $this->db->get('logs') ?? [];
+ return count($logs);
+ }
+
+ /**
+ * Clear all logs
+ *
+ * @return bool
+ */
+ public function clear(): bool {
+ return $this->db->set('logs', []);
+ }
+
+ /**
+ * Export logs to JSON
+ *
+ * @return string
+ */
+ public function exportJson(): string {
+ $logs = $this->db->get('logs') ?? [];
+ return json_encode($logs, JSON_PRETTY_PRINT);
+ }
+
+ /**
+ * Export logs to CSV
+ *
+ * @return string
+ */
+ public function exportCsv(): string {
+ $logs = $this->db->get('logs') ?? [];
+
+ $output = "ID,Timestamp,Level,Action,Message,User,IP\n";
+
+ foreach ($logs as $log) {
+ $output .= sprintf(
+ '"%s","%s","%s","%s","%s","%s","%s"' . "\n",
+ $log['id'],
+ $log['timestamp'],
+ $log['level'],
+ str_replace('"', '""', $log['action']),
+ str_replace('"', '""', $log['message']),
+ $log['user'],
+ $log['ip']
+ );
+ }
+
+ return $output;
+ }
+}
diff --git a/pathvector-admin/lib/Node.php b/pathvector-admin/lib/Node.php
new file mode 100644
index 0000000..a8c4988
--- /dev/null
+++ b/pathvector-admin/lib/Node.php
@@ -0,0 +1,516 @@
+db = new FlatFileDB($dataFile);
+ $this->logger = $logger;
+ $this->validator = new Validator();
+
+ if (!$this->db->exists('nodes')) {
+ $this->db->set('nodes', []);
+ }
+ }
+
+ /**
+ * Get all nodes
+ *
+ * @return array
+ */
+ public function getAll(): array {
+ return $this->db->get('nodes') ?? [];
+ }
+
+ /**
+ * Get nodes by ASN
+ *
+ * @param int $asn
+ * @return array
+ */
+ public function getByAsn(int $asn): array {
+ $nodes = $this->db->get('nodes') ?? [];
+ return array_filter($nodes, fn($node) => ($node['asn'] ?? 0) === $asn);
+ }
+
+ /**
+ * Get node by ID
+ *
+ * @param string $id
+ * @return array|null
+ */
+ public function get(string $id): ?array {
+ $nodes = $this->db->get('nodes') ?? [];
+ return $nodes[$id] ?? null;
+ }
+
+ /**
+ * Create new node
+ *
+ * @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' => 'Node ID is required',
+ 'errors' => ['id' => 'Node ID is required'],
+ ];
+ }
+
+ if (empty($data['asn'])) {
+ return [
+ 'success' => false,
+ 'message' => 'ASN is required',
+ 'errors' => ['asn' => 'ASN is required'],
+ ];
+ }
+
+ if (!$this->validator->validateAsn($data['asn'], 'asn')) {
+ return [
+ 'success' => false,
+ 'message' => 'Invalid ASN',
+ 'errors' => $this->validator->getErrors(),
+ ];
+ }
+
+ $nodes = $this->db->get('nodes') ?? [];
+ $id = $this->sanitizeId($data['id']);
+
+ if (isset($nodes[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Node $id already exists",
+ 'errors' => ['id' => 'Node ID already exists'],
+ ];
+ }
+
+ // Validate optional fields
+ if (!empty($data['router_id']) && !$this->validator->validateRouterId($data['router_id'], 'router_id')) {
+ return [
+ 'success' => false,
+ 'message' => 'Invalid router ID',
+ 'errors' => $this->validator->getErrors(),
+ ];
+ }
+
+ $nodeData = [
+ 'id' => $id,
+ 'asn' => (int) $data['asn'],
+ 'name' => $data['name'] ?? $id,
+ 'hostname' => $data['hostname'] ?? '',
+ 'router_id' => $data['router_id'] ?? '',
+ 'description' => $data['description'] ?? '',
+ 'location' => $data['location'] ?? '',
+ 'pathvector' => $this->buildPathvectorConfig($data['pathvector'] ?? []),
+ 'host' => $data['host'] ?? '',
+ 'templates' => $data['templates'] ?? [],
+ 'overrides' => $data['overrides'] ?? [],
+ 'metadata' => $data['metadata'] ?? [],
+ 'is_active' => $data['is_active'] ?? true,
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ];
+
+ $nodes[$id] = $nodeData;
+
+ if ($this->db->set('nodes', $nodes)) {
+ $this->logger->success('node', "Created node: $id for ASN {$nodeData['asn']}");
+ return [
+ 'success' => true,
+ 'message' => "Node $id created successfully",
+ 'data' => $nodeData,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to save node',
+ 'errors' => ['database' => 'Failed to save node'],
+ ];
+ }
+
+ /**
+ * Update node
+ *
+ * @param string $id
+ * @param array $data
+ * @return array
+ */
+ public function update(string $id, array $data): array {
+ $nodes = $this->db->get('nodes') ?? [];
+
+ if (!isset($nodes[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Node $id not found",
+ 'errors' => ['id' => 'Node not found'],
+ ];
+ }
+
+ $nodeData = $nodes[$id];
+
+ // Update allowed fields
+ $allowedFields = [
+ 'name', 'hostname', 'router_id', 'description', 'location',
+ 'host', 'templates', 'overrides', 'metadata', 'is_active'
+ ];
+
+ foreach ($allowedFields as $field) {
+ if (isset($data[$field])) {
+ $nodeData[$field] = $data[$field];
+ }
+ }
+
+ // Update Pathvector config
+ if (isset($data['pathvector'])) {
+ $nodeData['pathvector'] = $this->buildPathvectorConfig(
+ array_merge($nodeData['pathvector'], $data['pathvector'])
+ );
+ }
+
+ // Validate router_id if changed
+ if (!empty($nodeData['router_id']) && !$this->validator->validateRouterId($nodeData['router_id'], 'router_id')) {
+ return [
+ 'success' => false,
+ 'message' => 'Invalid router ID',
+ 'errors' => $this->validator->getErrors(),
+ ];
+ }
+
+ $nodeData['updated_at'] = date('c');
+ $nodes[$id] = $nodeData;
+
+ if ($this->db->set('nodes', $nodes)) {
+ $this->logger->info('node', "Updated node: $id");
+ return [
+ 'success' => true,
+ 'message' => "Node $id updated successfully",
+ 'data' => $nodeData,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to update node',
+ 'errors' => ['database' => 'Failed to save node'],
+ ];
+ }
+
+ /**
+ * Delete node
+ *
+ * @param string $id
+ * @return array
+ */
+ public function delete(string $id): array {
+ $nodes = $this->db->get('nodes') ?? [];
+
+ if (!isset($nodes[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Node $id not found",
+ ];
+ }
+
+ $nodeName = $nodes[$id]['name'];
+ $nodeAsn = $nodes[$id]['asn'];
+ unset($nodes[$id]);
+
+ if ($this->db->set('nodes', $nodes)) {
+ $this->logger->warning('node', "Deleted node: $id ($nodeName) from ASN $nodeAsn");
+ return [
+ 'success' => true,
+ 'message' => "Node $id deleted successfully",
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to delete node',
+ ];
+ }
+
+ /**
+ * Build Pathvector config structure for a node
+ *
+ * @param array $data
+ * @return array
+ */
+ private function buildPathvectorConfig(array $data): array {
+ return [
+ 'binary_path' => $data['binary_path'] ?? '/usr/local/bin/pathvector',
+ 'config_path' => $data['config_path'] ?? '/etc/pathvector/pathvector.yml',
+ 'output_path' => $data['output_path'] ?? '/etc/bird/bird.conf',
+ 'bird_reload_cmd' => $data['bird_reload_cmd'] ?? 'birdc configure',
+ 'bird_directory' => $data['bird_directory'] ?? '/etc/bird',
+ 'bird_socket' => $data['bird_socket'] ?? '/var/run/bird.ctl',
+ 'cache_directory' => $data['cache_directory'] ?? '/var/cache/pathvector',
+
+ // Node-specific overrides
+ 'source4' => $data['source4'] ?? '',
+ 'source6' => $data['source6'] ?? '',
+ 'prefixes' => $data['prefixes'] ?? [],
+
+ // RPKI settings
+ 'rpki_enable' => $data['rpki_enable'] ?? true,
+ 'rtr_server' => $data['rtr_server'] ?? '',
+
+ // PeeringDB settings
+ 'peeringdb_api_key' => $data['peeringdb_api_key'] ?? '',
+ 'peeringdb_cache' => $data['peeringdb_cache'] ?? true,
+
+ // IRR settings
+ 'irr_server' => $data['irr_server'] ?? 'rr.ntt.net',
+
+ // Kernel settings
+ 'kernel_table' => $data['kernel_table'] ?? null,
+ 'kernel_learn' => $data['kernel_learn'] ?? false,
+ 'kernel_export' => $data['kernel_export'] ?? [],
+
+ // Global config snippet
+ 'global_config' => $data['global_config'] ?? '',
+
+ // Web UI
+ 'web_ui_file' => $data['web_ui_file'] ?? '',
+
+ // Logging
+ 'log_file' => $data['log_file'] ?? 'syslog',
+ ];
+ }
+
+ /**
+ * Sanitize node ID
+ *
+ * @param string $id
+ * @return string
+ */
+ private function sanitizeId(string $id): string {
+ // Convert to lowercase, replace spaces with hyphens, remove invalid characters
+ $id = strtolower(trim($id));
+ $id = preg_replace('/\s+/', '-', $id);
+ $id = preg_replace('/[^a-z0-9-_]/', '', $id);
+ return $id;
+ }
+
+ /**
+ * Count nodes
+ *
+ * @return int
+ */
+ public function count(): int {
+ $nodes = $this->db->get('nodes') ?? [];
+ return count($nodes);
+ }
+
+ /**
+ * Count nodes by ASN
+ *
+ * @param int $asn
+ * @return int
+ */
+ public function countByAsn(int $asn): int {
+ return count($this->getByAsn($asn));
+ }
+
+ /**
+ * Search nodes
+ *
+ * @param string $query
+ * @return array
+ */
+ public function search(string $query): array {
+ $nodes = $this->db->get('nodes') ?? [];
+ $query = strtolower($query);
+
+ return array_filter($nodes, function($node) use ($query) {
+ return strpos(strtolower($node['id']), $query) !== false ||
+ strpos(strtolower($node['name']), $query) !== false ||
+ strpos(strtolower($node['hostname'] ?? ''), $query) !== false ||
+ strpos(strtolower($node['description'] ?? ''), $query) !== false ||
+ strpos(strtolower($node['location'] ?? ''), $query) !== false;
+ });
+ }
+
+ /**
+ * Get nodes by host
+ *
+ * @param string $hostId
+ * @return array
+ */
+ public function getByHost(string $hostId): array {
+ $nodes = $this->db->get('nodes') ?? [];
+ return array_filter($nodes, fn($node) => ($node['host'] ?? '') === $hostId);
+ }
+
+ /**
+ * Export node configuration to YAML
+ *
+ * @param string $id
+ * @param ASN $asnManager ASN manager for defaults
+ * @return string|null
+ */
+ public function exportYaml(string $id, $asnManager = null): ?string {
+ $node = $this->get($id);
+
+ if (!$node) {
+ return null;
+ }
+
+ $config = [];
+ $config['asn'] = $node['asn'];
+
+ if (!empty($node['router_id'])) {
+ $config['router-id'] = $node['router_id'];
+ }
+
+ if (!empty($node['hostname'])) {
+ $config['hostname'] = $node['hostname'];
+ }
+
+ // Pathvector settings
+ $pv = $node['pathvector'];
+
+ if (!empty($pv['source4'])) {
+ $config['source4'] = $pv['source4'];
+ }
+
+ if (!empty($pv['source6'])) {
+ $config['source6'] = $pv['source6'];
+ }
+
+ if (!empty($pv['prefixes'])) {
+ $config['prefixes'] = $pv['prefixes'];
+ }
+
+ if (!empty($pv['bird_directory'])) {
+ $config['bird-directory'] = $pv['bird_directory'];
+ }
+
+ if (!empty($pv['bird_socket'])) {
+ $config['bird-socket'] = $pv['bird_socket'];
+ }
+
+ if (!empty($pv['cache_directory'])) {
+ $config['cache-directory'] = $pv['cache_directory'];
+ }
+
+ if (isset($pv['rpki_enable'])) {
+ $config['rpki-enable'] = $pv['rpki_enable'];
+ }
+
+ if (!empty($pv['rtr_server'])) {
+ $config['rtr-server'] = $pv['rtr_server'];
+ }
+
+ if (!empty($pv['irr_server'])) {
+ $config['irr-server'] = $pv['irr_server'];
+ }
+
+ if (!empty($pv['global_config'])) {
+ $config['global-config'] = $pv['global_config'];
+ }
+
+ // Apply overrides
+ foreach ($node['overrides'] as $key => $value) {
+ if ($value !== '' && $value !== null) {
+ $config[str_replace('_', '-', $key)] = $value;
+ }
+ }
+
+ return $this->arrayToYaml($config);
+ }
+
+ /**
+ * Convert array to YAML string
+ *
+ * @param array $data
+ * @param int $indent
+ * @return string
+ */
+ private function arrayToYaml(array $data, int $indent = 0): string {
+ $yaml = '';
+ $prefix = str_repeat(' ', $indent);
+
+ foreach ($data as $key => $value) {
+ if (is_array($value)) {
+ if (empty($value)) {
+ continue;
+ }
+
+ if (array_keys($value) === range(0, count($value) - 1)) {
+ $yaml .= "$prefix$key:\n";
+ foreach ($value as $item) {
+ if (is_array($item)) {
+ $yaml .= "$prefix -\n" . $this->arrayToYaml($item, $indent + 2);
+ } else {
+ $yaml .= "$prefix - " . $this->formatYamlValue($item) . "\n";
+ }
+ }
+ } else {
+ $yaml .= "$prefix$key:\n" . $this->arrayToYaml($value, $indent + 1);
+ }
+ } else {
+ if ($value === '' || $value === null) {
+ continue;
+ }
+ $yaml .= "$prefix$key: " . $this->formatYamlValue($value) . "\n";
+ }
+ }
+
+ return $yaml;
+ }
+
+ /**
+ * Format value for YAML output
+ *
+ * @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 || strpos($value, "\n") !== false)) {
+ return '"' . addslashes($value) . '"';
+ }
+
+ return $value;
+ }
+
+ /**
+ * Backup nodes data
+ *
+ * @param string $backupDir
+ * @return string|false
+ */
+ public function backup(string $backupDir): string|false {
+ return $this->db->backup($backupDir);
+ }
+}
diff --git a/pathvector-admin/lib/Pathvector.php b/pathvector-admin/lib/Pathvector.php
new file mode 100644
index 0000000..ff102a7
--- /dev/null
+++ b/pathvector-admin/lib/Pathvector.php
@@ -0,0 +1,1046 @@
+config = $config;
+ $this->logger = $logger;
+ $this->asnManager = $asnManager;
+ $this->nodeManager = $nodeManager;
+ $this->peerManager = $peerManager;
+ $this->templateManager = $templateManager;
+ $this->hostManager = $hostManager;
+ }
+
+ /**
+ * Generate Pathvector YAML configuration for a node
+ *
+ * @param string $nodeId
+ * @return array
+ */
+ public function generateConfig(string $nodeId): array {
+ $node = $this->nodeManager->get($nodeId);
+
+ if (!$node) {
+ return [
+ 'success' => false,
+ 'message' => "Node $nodeId not found",
+ 'config' => '',
+ ];
+ }
+
+ $asn = $this->asnManager->get((string) $node['asn']);
+
+ if (!$asn) {
+ return [
+ 'success' => false,
+ 'message' => "ASN {$node['asn']} not found",
+ 'config' => '',
+ ];
+ }
+
+ $peers = $this->peerManager->getByNode($nodeId);
+
+ // Build configuration
+ $config = $this->buildGlobalConfig($asn, $node);
+ $config['templates'] = $this->buildTemplates();
+ $config['peers'] = $this->buildPeers($peers);
+
+ // Add optional sections
+ $this->addOptionalSections($config, $asn, $node);
+
+ $yaml = $this->configToYaml($config);
+
+ $this->logger->info('pathvector', "Generated config for node: $nodeId", [
+ 'peer_count' => count($peers),
+ ]);
+
+ return [
+ 'success' => true,
+ 'message' => 'Configuration generated successfully',
+ 'config' => $yaml,
+ 'peer_count' => count($peers),
+ ];
+ }
+
+ /**
+ * Build global config section
+ *
+ * @param array $asn
+ * @param array $node
+ * @return array
+ */
+ private function buildGlobalConfig(array $asn, array $node): array {
+ $defaults = $asn['pathvector_defaults'] ?? [];
+ $pv = $node['pathvector'] ?? [];
+
+ $config = [
+ 'asn' => $asn['asn'],
+ ];
+
+ // Router ID (node overrides ASN default)
+ $routerId = $node['router_id'] ?: ($defaults['router_id'] ?? '');
+ if ($routerId) {
+ $config['router-id'] = $routerId;
+ }
+
+ // Hostname
+ $hostname = $node['hostname'] ?: ($defaults['hostname'] ?? '');
+ if ($hostname) {
+ $config['hostname'] = $hostname;
+ }
+
+ // Source addresses
+ $source4 = $pv['source4'] ?: ($defaults['source4'] ?? '');
+ $source6 = $pv['source6'] ?: ($defaults['source6'] ?? '');
+ if ($source4) $config['source4'] = $source4;
+ if ($source6) $config['source6'] = $source6;
+
+ // Prefixes
+ $prefixes = !empty($pv['prefixes']) ? $pv['prefixes'] : ($defaults['prefixes'] ?? []);
+ if (!empty($prefixes)) {
+ $config['prefixes'] = $prefixes;
+ }
+
+ // BIRD paths
+ $birdDir = $pv['bird_directory'] ?: ($defaults['bird_directory'] ?? '/etc/bird');
+ $birdSocket = $pv['bird_socket'] ?: ($defaults['bird_socket'] ?? '/var/run/bird.ctl');
+ $cacheDir = $pv['cache_directory'] ?: ($defaults['cache_directory'] ?? '/var/cache/pathvector');
+
+ $config['bird-directory'] = $birdDir;
+ $config['bird-socket'] = $birdSocket;
+ $config['cache-directory'] = $cacheDir;
+
+ // IRR settings
+ $irrServer = $pv['irr_server'] ?: ($defaults['irr_server'] ?? 'rr.ntt.net');
+ $config['irr-server'] = $irrServer;
+
+ // RPKI settings
+ $rpkiEnable = $pv['rpki_enable'] ?? ($defaults['rpki_enable'] ?? true);
+ $config['rpki-enable'] = $rpkiEnable;
+
+ $rtrServer = $pv['rtr_server'] ?: ($defaults['rtr_server'] ?? '');
+ if ($rtrServer) {
+ $config['rtr-server'] = $rtrServer;
+ }
+
+ // PeeringDB settings
+ if (!empty($defaults['peeringdb_api_key'])) {
+ $config['peeringdb-api-key'] = $defaults['peeringdb_api_key'];
+ }
+
+ $peeringdbCache = $pv['peeringdb_cache'] ?? ($defaults['peeringdb_cache'] ?? true);
+ $config['peeringdb-cache'] = $peeringdbCache;
+
+ // Global filtering
+ if ($defaults['keep_filtered'] ?? false) {
+ $config['keep-filtered'] = true;
+ }
+
+ if ($defaults['merge_paths'] ?? false) {
+ $config['merge-paths'] = true;
+ }
+
+ if ($defaults['default_route'] ?? false) {
+ $config['default-route'] = true;
+ }
+
+ if ($defaults['accept_default'] ?? false) {
+ $config['accept-default'] = true;
+ }
+
+ // Communities
+ if (!empty($defaults['origin_communities'])) {
+ $config['origin-communities'] = $defaults['origin_communities'];
+ }
+
+ if (!empty($defaults['local_communities'])) {
+ $config['local-communities'] = $defaults['local_communities'];
+ }
+
+ if (!empty($defaults['add_on_import'])) {
+ $config['add-on-import'] = $defaults['add_on_import'];
+ }
+
+ if (!empty($defaults['add_on_export'])) {
+ $config['add-on-export'] = $defaults['add_on_export'];
+ }
+
+ // Blocklist
+ if (!empty($defaults['blocklist'])) {
+ $config['blocklist'] = $defaults['blocklist'];
+ }
+
+ if (!empty($defaults['blocklist_urls'])) {
+ $config['blocklist-urls'] = $defaults['blocklist_urls'];
+ }
+
+ // Bogons
+ if (!empty($defaults['bogons4'])) {
+ $config['bogons4'] = $defaults['bogons4'];
+ }
+
+ if (!empty($defaults['bogons6'])) {
+ $config['bogons6'] = $defaults['bogons6'];
+ }
+
+ if (!empty($defaults['bogon_asns'])) {
+ $config['bogon-asns'] = $defaults['bogon_asns'];
+ }
+
+ // Transit ASNs
+ if (!empty($defaults['transit_asns'])) {
+ $config['transit-asns'] = $defaults['transit_asns'];
+ }
+
+ // Operation modes
+ if ($defaults['no_announce'] ?? false) {
+ $config['no-announce'] = true;
+ }
+
+ if ($defaults['no_accept'] ?? false) {
+ $config['no-accept'] = true;
+ }
+
+ if ($defaults['stun'] ?? false) {
+ $config['stun'] = true;
+ }
+
+ // Global config snippet
+ $globalConfig = $pv['global_config'] ?: ($defaults['global_config'] ?? '');
+ if ($globalConfig) {
+ $config['global-config'] = $globalConfig;
+ }
+
+ // Web UI
+ $webUiFile = $pv['web_ui_file'] ?: ($defaults['web_ui_file'] ?? '');
+ if ($webUiFile) {
+ $config['web-ui-file'] = $webUiFile;
+ }
+
+ // Logging
+ $logFile = $pv['log_file'] ?: ($defaults['log_file'] ?? 'syslog');
+ if ($logFile !== 'syslog') {
+ $config['log-file'] = $logFile;
+ }
+
+ // ASPA
+ if (!empty($defaults['authorized_providers'])) {
+ $config['authorized-providers'] = $defaults['authorized_providers'];
+ }
+
+ return $config;
+ }
+
+ /**
+ * Build templates section
+ *
+ * @return array
+ */
+ private function buildTemplates(): array {
+ $templates = $this->templateManager->getAll(Template::TYPE_PEER);
+ $result = [];
+
+ foreach ($templates as $id => $template) {
+ $pv = $template['pathvector'] ?? [];
+ $templateConfig = [];
+
+ // Add all non-empty, non-default values
+ foreach ($pv as $key => $value) {
+ if ($value === null || $value === '' || $value === []) {
+ continue;
+ }
+
+ $yamlKey = str_replace('_', '-', $key);
+ $templateConfig[$yamlKey] = $value;
+ }
+
+ if (!empty($templateConfig)) {
+ $result[$id] = $templateConfig;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Build peers section
+ *
+ * @param array $peers
+ * @return array
+ */
+ private function buildPeers(array $peers): array {
+ $result = [];
+
+ foreach ($peers as $peer) {
+ if (!($peer['is_active'] ?? true)) {
+ continue;
+ }
+
+ $pv = $peer['pathvector'] ?? [];
+ $peerConfig = [
+ 'asn' => $peer['remote_asn'],
+ ];
+
+ // Template
+ if (!empty($peer['template'])) {
+ $peerConfig['template'] = $peer['template'];
+ }
+
+ // Neighbors
+ if (!empty($peer['neighbors'])) {
+ $peerConfig['neighbors'] = $peer['neighbors'];
+ }
+
+ // Description
+ if (!empty($peer['description'])) {
+ $peerConfig['description'] = $peer['description'];
+ }
+
+ // Apply template first
+ if (!empty($peer['template'])) {
+ $template = $this->templateManager->get($peer['template']);
+ if ($template) {
+ $templatePv = $template['pathvector'] ?? [];
+ // Template values can be overridden by peer-specific values
+ }
+ }
+
+ // Add all non-empty, non-default peer-specific values
+ $this->addPeerOptions($peerConfig, $pv);
+
+ $result[$peer['name']] = $peerConfig;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Add peer options to config
+ *
+ * @param array &$config
+ * @param array $pv
+ */
+ private function addPeerOptions(array &$config, array $pv): void {
+ $optionMappings = [
+ // Session control
+ 'disabled' => ['default' => false, 'yaml' => 'disabled'],
+ 'import' => ['default' => true, 'yaml' => 'import'],
+ 'export' => ['default' => true, 'yaml' => 'export'],
+
+ // BGP attributes
+ 'local_asn' => ['default' => null, 'yaml' => 'local-asn'],
+ 'prepends' => ['default' => 0, 'yaml' => 'prepends'],
+ 'prepend_path' => ['default' => [], 'yaml' => 'prepend-path'],
+ 'clear_path' => ['default' => false, 'yaml' => 'clear-path'],
+ 'local_pref' => ['default' => 100, 'yaml' => 'local-pref'],
+ 'local_pref4' => ['default' => null, 'yaml' => 'local-pref4'],
+ 'local_pref6' => ['default' => null, 'yaml' => 'local-pref6'],
+ 'set_local_pref' => ['default' => false, 'yaml' => 'set-local-pref'],
+ 'multihop' => ['default' => null, 'yaml' => 'multihop'],
+
+ // Listening
+ 'listen4' => ['default' => '', 'yaml' => 'listen4'],
+ 'listen6' => ['default' => '', 'yaml' => 'listen6'],
+ 'local_port' => ['default' => 179, 'yaml' => 'local-port'],
+ 'neighbor_port' => ['default' => 179, 'yaml' => 'neighbor-port'],
+ 'passive' => ['default' => false, 'yaml' => 'passive'],
+ 'direct' => ['default' => false, 'yaml' => 'direct'],
+
+ // Next-hop
+ 'next_hop_self' => ['default' => false, 'yaml' => 'next-hop-self'],
+ 'next_hop_self_ebgp' => ['default' => false, 'yaml' => 'next-hop-self-ebgp'],
+ 'next_hop_self_ibgp' => ['default' => false, 'yaml' => 'next-hop-self-ibgp'],
+ 'import_next_hop' => ['default' => '', 'yaml' => 'import-next-hop'],
+ 'export_next_hop' => ['default' => '', 'yaml' => 'export-next-hop'],
+ 'enforce_peer_nexthop' => ['default' => true, 'yaml' => 'enforce-peer-nexthop'],
+ 'force_peer_nexthop' => ['default' => false, 'yaml' => 'force-peer-nexthop'],
+
+ // BFD
+ 'bfd' => ['default' => false, 'yaml' => 'bfd'],
+
+ // Auth
+ 'password' => ['default' => '', 'yaml' => 'password'],
+
+ // Route server/reflector
+ 'rs_client' => ['default' => false, 'yaml' => 'rs-client'],
+ 'rr_client' => ['default' => false, 'yaml' => 'rr-client'],
+
+ // AS path
+ 'remove_private_asns' => ['default' => true, 'yaml' => 'remove-private-asns'],
+ 'allow_local_as' => ['default' => false, 'yaml' => 'allow-local-as'],
+ 'enforce_first_as' => ['default' => true, 'yaml' => 'enforce-first-as'],
+
+ // Multi-protocol
+ 'mp_unicast_46' => ['default' => false, 'yaml' => 'mp-unicast-46'],
+
+ // Add-path
+ 'add_path_tx' => ['default' => false, 'yaml' => 'add-path-tx'],
+ 'add_path_rx' => ['default' => false, 'yaml' => 'add-path-rx'],
+
+ // Confederation
+ 'confederation' => ['default' => null, 'yaml' => 'confederation'],
+ 'confederation_member' => ['default' => false, 'yaml' => 'confederation-member'],
+
+ // TTL
+ 'ttl_security' => ['default' => false, 'yaml' => 'ttl-security'],
+
+ // Limits
+ 'receive_limit4' => ['default' => null, 'yaml' => 'receive-limit4'],
+ 'receive_limit6' => ['default' => null, 'yaml' => 'receive-limit6'],
+ 'receive_limit_violation' => ['default' => 'disable', 'yaml' => 'receive-limit-violation'],
+ 'export_limit4' => ['default' => null, 'yaml' => 'export-limit4'],
+ 'export_limit6' => ['default' => null, 'yaml' => 'export-limit6'],
+ 'export_limit_violation' => ['default' => 'disable', 'yaml' => 'export-limit-violation'],
+
+ // Session options
+ 'interpret_communities' => ['default' => true, 'yaml' => 'interpret-communities'],
+ 'default_local_pref' => ['default' => null, 'yaml' => 'default-local-pref'],
+ 'advertise_hostname' => ['default' => false, 'yaml' => 'advertise-hostname'],
+ 'disable_after_error' => ['default' => false, 'yaml' => 'disable-after-error'],
+ 'prefer_older_routes' => ['default' => false, 'yaml' => 'prefer-older-routes'],
+ 'irr_accept_child_prefixes' => ['default' => false, 'yaml' => 'irr-accept-child-prefixes'],
+
+ // Communities
+ 'add_on_import' => ['default' => [], 'yaml' => 'add-on-import'],
+ 'add_on_export' => ['default' => [], 'yaml' => 'add-on-export'],
+ 'announce' => ['default' => [], 'yaml' => 'announce'],
+ 'remove_communities' => ['default' => [], 'yaml' => 'remove-communities'],
+ 'remove_all_communities' => ['default' => null, 'yaml' => 'remove-all-communities'],
+
+ // AS prefs
+ 'as_prefs' => ['default' => [], 'yaml' => 'as-prefs'],
+ 'community_prefs' => ['default' => [], 'yaml' => 'community-prefs'],
+ 'large_community_prefs' => ['default' => [], 'yaml' => 'large-community-prefs'],
+
+ // AS-SET
+ 'as_set' => ['default' => '', 'yaml' => 'as-set'],
+
+ // Blackhole
+ 'allow_blackhole_community' => ['default' => false, 'yaml' => 'allow-blackhole-community'],
+ 'blackhole_in' => ['default' => false, 'yaml' => 'blackhole-in'],
+ 'blackhole_out' => ['default' => false, 'yaml' => 'blackhole-out'],
+
+ // Filtering
+ 'filter_irr' => ['default' => false, 'yaml' => 'filter-irr'],
+ 'filter_rpki' => ['default' => true, 'yaml' => 'filter-rpki'],
+ 'strict_rpki' => ['default' => false, 'yaml' => 'strict-rpki'],
+ 'filter_max_prefix' => ['default' => true, 'yaml' => 'filter-max-prefix'],
+ 'filter_bogon_routes' => ['default' => true, 'yaml' => 'filter-bogon-routes'],
+ 'filter_bogon_asns' => ['default' => true, 'yaml' => 'filter-bogon-asns'],
+ 'filter_transit_asns' => ['default' => false, 'yaml' => 'filter-transit-asns'],
+ 'filter_prefix_length' => ['default' => true, 'yaml' => 'filter-prefix-length'],
+ 'filter_never_via_route_servers' => ['default' => false, 'yaml' => 'filter-never-via-route-servers'],
+ 'filter_as_set' => ['default' => false, 'yaml' => 'filter-as-set'],
+ 'filter_aspa' => ['default' => false, 'yaml' => 'filter-aspa'],
+ 'filter_blocklist' => ['default' => true, 'yaml' => 'filter-blocklist'],
+
+ // Transit lock
+ 'transit_lock' => ['default' => [], 'yaml' => 'transit-lock'],
+
+ // Announcement control
+ 'dont_announce' => ['default' => [], 'yaml' => 'dont-announce'],
+ 'only_announce' => ['default' => [], 'yaml' => 'only-announce'],
+ 'prefix_communities' => ['default' => [], 'yaml' => 'prefix-communities'],
+
+ // Auto-config
+ 'auto_import_limits' => ['default' => false, 'yaml' => 'auto-import-limits'],
+ 'auto_as_set' => ['default' => false, 'yaml' => 'auto-as-set'],
+ 'auto_as_set_members' => ['default' => false, 'yaml' => 'auto-as-set-members'],
+
+ // Graceful shutdown
+ 'honor_graceful_shutdown' => ['default' => true, 'yaml' => 'honor-graceful-shutdown'],
+
+ // Prefixes
+ 'prefixes' => ['default' => [], 'yaml' => 'prefixes'],
+ 'as_set_members' => ['default' => [], 'yaml' => 'as-set-members'],
+
+ // BGP Role
+ 'role' => ['default' => '', 'yaml' => 'role'],
+ 'require_roles' => ['default' => false, 'yaml' => 'require-roles'],
+
+ // Export options
+ 'announce_default' => ['default' => false, 'yaml' => 'announce-default'],
+ 'announce_originated' => ['default' => true, 'yaml' => 'announce-originated'],
+ 'announce_all' => ['default' => false, 'yaml' => 'announce-all'],
+
+ // Custom config
+ 'session_global' => ['default' => '', 'yaml' => 'session-global'],
+ 'pre_import_filter' => ['default' => '', 'yaml' => 'pre-import-filter'],
+ 'post_import_filter' => ['default' => '', 'yaml' => 'post-import-filter'],
+ 'pre_import_accept' => ['default' => '', 'yaml' => 'pre-import-accept'],
+ 'pre_export' => ['default' => '', 'yaml' => 'pre-export'],
+ 'pre_export_final' => ['default' => '', 'yaml' => 'pre-export-final'],
+
+ // Optimizer
+ 'probe_sources' => ['default' => [], 'yaml' => 'probe-sources'],
+ 'optimize_inbound' => ['default' => false, 'yaml' => 'optimize-inbound'],
+ ];
+
+ foreach ($optionMappings as $key => $mapping) {
+ $value = $pv[$key] ?? $mapping['default'];
+
+ if ($value === $mapping['default']) {
+ continue;
+ }
+
+ if ($value === null || $value === '' || $value === []) {
+ continue;
+ }
+
+ $config[$mapping['yaml']] = $value;
+ }
+ }
+
+ /**
+ * Add optional config sections
+ *
+ * @param array &$config
+ * @param array $asn
+ * @param array $node
+ */
+ private function addOptionalSections(array &$config, array $asn, array $node): void {
+ // Kernel settings
+ $pv = $node['pathvector'] ?? [];
+
+ if (!empty($pv['kernel_table']) || !empty($pv['kernel_learn']) || !empty($pv['kernel_export'])) {
+ $kernel = [];
+
+ if (!empty($pv['kernel_table'])) {
+ $kernel['table'] = $pv['kernel_table'];
+ }
+
+ if ($pv['kernel_learn'] ?? false) {
+ $kernel['learn'] = true;
+ }
+
+ if (!empty($pv['kernel_export'])) {
+ $kernel['export'] = $pv['kernel_export'];
+ }
+
+ if (!empty($kernel)) {
+ $config['kernel'] = $kernel;
+ }
+ }
+ }
+
+ /**
+ * Convert config array to YAML string
+ *
+ * @param array $config
+ * @return string
+ */
+ private function configToYaml(array $config): string {
+ $yaml = "# Pathvector Configuration\n";
+ $yaml .= "# Generated by Pathvector Admin Dashboard\n";
+ $yaml .= "# Generated at: " . date('c') . "\n\n";
+
+ // Global config first
+ $globalKeys = array_diff(array_keys($config), ['templates', 'peers', 'kernel', 'vrrp', 'bfd', 'mrt', 'optimizer']);
+
+ foreach ($globalKeys as $key) {
+ $yaml .= $this->formatYamlEntry($key, $config[$key], 0);
+ }
+
+ // Templates
+ if (!empty($config['templates'])) {
+ $yaml .= "\ntemplates:\n";
+ foreach ($config['templates'] as $name => $template) {
+ $yaml .= " $name:\n";
+ foreach ($template as $key => $value) {
+ $yaml .= $this->formatYamlEntry($key, $value, 2);
+ }
+ }
+ }
+
+ // Peers
+ if (!empty($config['peers'])) {
+ $yaml .= "\npeers:\n";
+ foreach ($config['peers'] as $name => $peer) {
+ $yaml .= " $name:\n";
+ foreach ($peer as $key => $value) {
+ $yaml .= $this->formatYamlEntry($key, $value, 2);
+ }
+ }
+ }
+
+ // Other sections
+ foreach (['kernel', 'vrrp', 'bfd', 'mrt', 'optimizer'] as $section) {
+ if (!empty($config[$section])) {
+ $yaml .= "\n$section:\n";
+ foreach ($config[$section] as $key => $value) {
+ $yaml .= $this->formatYamlEntry($key, $value, 1);
+ }
+ }
+ }
+
+ return $yaml;
+ }
+
+ /**
+ * 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) {
+ if (is_array($item)) {
+ $yaml .= "$prefix -\n";
+ foreach ($item as $k => $v) {
+ $yaml .= $this->formatYamlEntry($k, $v, $indent + 2);
+ }
+ } else {
+ $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)) {
+ if (strpos($value, ':') !== false || strpos($value, '#') !== false ||
+ strpos($value, '"') !== false || strpos($value, "'") !== false ||
+ strpos($value, "\n") !== false) {
+ return '"' . addslashes($value) . '"';
+ }
+ }
+
+ return (string) $value;
+ }
+
+ /**
+ * Validate configuration
+ *
+ * @param string $nodeId
+ * @param string $hostId
+ * @return array
+ */
+ public function validateConfig(string $nodeId, string $hostId = 'localhost'): array {
+ $result = $this->generateConfig($nodeId);
+
+ if (!$result['success']) {
+ return $result;
+ }
+
+ $config = $result['config'];
+ $host = $this->hostManager->get($hostId);
+
+ if (!$host) {
+ return [
+ 'success' => false,
+ 'message' => "Host $hostId not found",
+ ];
+ }
+
+ // Write config to temp file
+ $tempFile = tempnam(sys_get_temp_dir(), 'pathvector_');
+ file_put_contents($tempFile, $config);
+
+ // Run pathvector validate
+ $exec = $host['execution'];
+ $cmd = escapeshellcmd($exec['pathvector_bin']) . ' -c ' . escapeshellarg($tempFile) . ' validate';
+
+ $cmdResult = $this->hostManager->executeCommand($hostId, $cmd);
+
+ // Clean up temp file
+ unlink($tempFile);
+
+ $this->logger->info('pathvector', "Validated config for node: $nodeId", [
+ 'success' => $cmdResult['success'],
+ ]);
+
+ return [
+ 'success' => $cmdResult['success'],
+ 'message' => $cmdResult['success'] ? 'Configuration is valid' : 'Configuration validation failed',
+ 'output' => $cmdResult['output'],
+ 'config' => $config,
+ ];
+ }
+
+ /**
+ * Generate BIRD configuration (dry-run)
+ *
+ * @param string $nodeId
+ * @param string $hostId
+ * @return array
+ */
+ public function dryRun(string $nodeId, string $hostId = 'localhost'): array {
+ $result = $this->generateConfig($nodeId);
+
+ if (!$result['success']) {
+ return $result;
+ }
+
+ $config = $result['config'];
+ $host = $this->hostManager->get($hostId);
+
+ if (!$host) {
+ return [
+ 'success' => false,
+ 'message' => "Host $hostId not found",
+ ];
+ }
+
+ // Write config to temp file
+ $tempFile = tempnam(sys_get_temp_dir(), 'pathvector_');
+ file_put_contents($tempFile, $config);
+
+ // Run pathvector generate (dry-run)
+ $exec = $host['execution'];
+ $outputDir = tempnam(sys_get_temp_dir(), 'bird_');
+ unlink($outputDir);
+ mkdir($outputDir);
+
+ $cmd = escapeshellcmd($exec['pathvector_bin']) .
+ ' -c ' . escapeshellarg($tempFile) .
+ ' -o ' . escapeshellarg($outputDir) .
+ ' generate';
+
+ $cmdResult = $this->hostManager->executeCommand($hostId, $cmd);
+
+ // Read generated BIRD config
+ $birdConfig = '';
+ if ($cmdResult['success'] && file_exists($outputDir . '/bird.conf')) {
+ $birdConfig = file_get_contents($outputDir . '/bird.conf');
+ }
+
+ // Clean up
+ unlink($tempFile);
+ $this->recursiveDelete($outputDir);
+
+ $this->logger->info('pathvector', "Dry-run for node: $nodeId", [
+ 'success' => $cmdResult['success'],
+ ]);
+
+ return [
+ 'success' => $cmdResult['success'],
+ 'message' => $cmdResult['success'] ? 'BIRD configuration generated' : 'Generation failed',
+ 'output' => $cmdResult['output'],
+ 'pathvector_config' => $config,
+ 'bird_config' => $birdConfig,
+ ];
+ }
+
+ /**
+ * Apply configuration to node
+ *
+ * @param string $nodeId
+ * @param string $hostId
+ * @return array
+ */
+ public function applyConfig(string $nodeId, string $hostId = 'localhost'): array {
+ $node = $this->nodeManager->get($nodeId);
+
+ if (!$node) {
+ return [
+ 'success' => false,
+ 'message' => "Node $nodeId not found",
+ ];
+ }
+
+ $host = $this->hostManager->get($hostId);
+
+ if (!$host) {
+ return [
+ 'success' => false,
+ 'message' => "Host $hostId not found",
+ ];
+ }
+
+ // Generate config
+ $result = $this->generateConfig($nodeId);
+
+ if (!$result['success']) {
+ return $result;
+ }
+
+ $config = $result['config'];
+ $pv = $node['pathvector'];
+ $exec = $host['execution'];
+
+ // Write config to Pathvector config path
+ $configPath = $pv['config_path'] ?: '/etc/pathvector/pathvector.yml';
+
+ // For local execution, write directly
+ // For SSH, use scp or write via SSH
+ if ($exec['method'] === Host::METHOD_LOCAL) {
+ $dir = dirname($configPath);
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+ file_put_contents($configPath, $config);
+ } else {
+ // Write to temp file and copy via SSH
+ $tempFile = tempnam(sys_get_temp_dir(), 'pathvector_');
+ file_put_contents($tempFile, $config);
+
+ $scpCmd = 'scp';
+ if (!empty($exec['ssh_key'])) {
+ $scpCmd .= ' -i ' . escapeshellarg($exec['ssh_key']);
+ }
+ if (!empty($exec['ssh_port']) && $exec['ssh_port'] != 22) {
+ $scpCmd .= ' -P ' . (int) $exec['ssh_port'];
+ }
+
+ $scpCmd .= ' ' . escapeshellarg($tempFile) . ' ' .
+ escapeshellarg($exec['ssh_user'] . '@' . $exec['ssh_host'] . ':' . $configPath);
+
+ exec($scpCmd . ' 2>&1', $output, $returnCode);
+ unlink($tempFile);
+
+ if ($returnCode !== 0) {
+ return [
+ 'success' => false,
+ 'message' => 'Failed to copy config to remote host',
+ 'output' => implode("\n", $output),
+ ];
+ }
+ }
+
+ // Run pathvector generate
+ $cmd = escapeshellcmd($exec['pathvector_bin']) . ' -c ' . escapeshellarg($configPath) . ' generate';
+ $cmdResult = $this->hostManager->executeCommand($hostId, $cmd);
+
+ if (!$cmdResult['success']) {
+ return [
+ 'success' => false,
+ 'message' => 'Failed to generate BIRD config',
+ 'output' => $cmdResult['output'],
+ ];
+ }
+
+ // Reload BIRD
+ $reloadCmd = $pv['bird_reload_cmd'] ?: 'birdc configure';
+ $reloadResult = $this->hostManager->executeCommand($hostId, $reloadCmd);
+
+ $this->logger->success('pathvector', "Applied config to node: $nodeId on host: $hostId");
+
+ return [
+ 'success' => $reloadResult['success'],
+ 'message' => $reloadResult['success'] ? 'Configuration applied successfully' : 'BIRD reload failed',
+ 'generate_output' => $cmdResult['output'],
+ 'reload_output' => $reloadResult['output'],
+ ];
+ }
+
+ /**
+ * Reload BIRD on host
+ *
+ * @param string $hostId
+ * @param string $reloadCmd
+ * @return array
+ */
+ public function reloadBird(string $hostId = 'localhost', string $reloadCmd = 'birdc configure'): array {
+ $result = $this->hostManager->executeCommand($hostId, $reloadCmd);
+
+ $this->logger->info('pathvector', "Reloaded BIRD on host: $hostId", [
+ 'success' => $result['success'],
+ ]);
+
+ return $result;
+ }
+
+ /**
+ * Get BIRD status
+ *
+ * @param string $hostId
+ * @return array
+ */
+ public function getBirdStatus(string $hostId = 'localhost'): array {
+ $host = $this->hostManager->get($hostId);
+
+ if (!$host) {
+ return [
+ 'success' => false,
+ 'message' => "Host $hostId not found",
+ ];
+ }
+
+ $exec = $host['execution'];
+ $socket = $exec['bird_socket'] ?? '/var/run/bird.ctl';
+
+ $cmd = escapeshellcmd($exec['birdc_bin'] ?? 'birdc') . ' -s ' . escapeshellarg($socket) . ' show status';
+
+ return $this->hostManager->executeCommand($hostId, $cmd);
+ }
+
+ /**
+ * Get BGP protocol status
+ *
+ * @param string $hostId
+ * @param string|null $protocol Specific protocol name or null for all
+ * @return array
+ */
+ public function getProtocolStatus(string $hostId = 'localhost', ?string $protocol = null): array {
+ $host = $this->hostManager->get($hostId);
+
+ if (!$host) {
+ return [
+ 'success' => false,
+ 'message' => "Host $hostId not found",
+ ];
+ }
+
+ $exec = $host['execution'];
+ $socket = $exec['bird_socket'] ?? '/var/run/bird.ctl';
+ $birdc = escapeshellcmd($exec['birdc_bin'] ?? 'birdc') . ' -s ' . escapeshellarg($socket);
+
+ if ($protocol) {
+ $cmd = $birdc . ' show protocols all ' . escapeshellarg($protocol);
+ } else {
+ $cmd = $birdc . ' show protocols';
+ }
+
+ return $this->hostManager->executeCommand($hostId, $cmd);
+ }
+
+ /**
+ * Get route table summary
+ *
+ * @param string $hostId
+ * @param string $table ipv4 or ipv6
+ * @return array
+ */
+ public function getRouteCount(string $hostId = 'localhost', string $table = 'master4'): array {
+ $host = $this->hostManager->get($hostId);
+
+ if (!$host) {
+ return [
+ 'success' => false,
+ 'message' => "Host $hostId not found",
+ ];
+ }
+
+ $exec = $host['execution'];
+ $socket = $exec['bird_socket'] ?? '/var/run/bird.ctl';
+
+ $cmd = escapeshellcmd($exec['birdc_bin'] ?? 'birdc') .
+ ' -s ' . escapeshellarg($socket) .
+ ' show route count table ' . escapeshellarg($table);
+
+ return $this->hostManager->executeCommand($hostId, $cmd);
+ }
+
+ /**
+ * Get Pathvector version
+ *
+ * @param string $hostId
+ * @return array
+ */
+ public function getVersion(string $hostId = 'localhost'): array {
+ $host = $this->hostManager->get($hostId);
+
+ if (!$host) {
+ return [
+ 'success' => false,
+ 'message' => "Host $hostId not found",
+ ];
+ }
+
+ $exec = $host['execution'];
+ $cmd = escapeshellcmd($exec['pathvector_bin']) . ' version';
+
+ return $this->hostManager->executeCommand($hostId, $cmd);
+ }
+
+ /**
+ * Recursively delete directory
+ *
+ * @param string $dir
+ */
+ private function recursiveDelete(string $dir): void {
+ if (is_dir($dir)) {
+ $objects = scandir($dir);
+ foreach ($objects as $object) {
+ if ($object !== '.' && $object !== '..') {
+ $path = $dir . '/' . $object;
+ if (is_dir($path)) {
+ $this->recursiveDelete($path);
+ } else {
+ unlink($path);
+ }
+ }
+ }
+ rmdir($dir);
+ }
+ }
+}
diff --git a/pathvector-admin/lib/Peer.php b/pathvector-admin/lib/Peer.php
new file mode 100644
index 0000000..5263de8
--- /dev/null
+++ b/pathvector-admin/lib/Peer.php
@@ -0,0 +1,689 @@
+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);
+ }
+}
diff --git a/pathvector-admin/lib/Template.php b/pathvector-admin/lib/Template.php
new file mode 100644
index 0000000..74d7ac3
--- /dev/null
+++ b/pathvector-admin/lib/Template.php
@@ -0,0 +1,562 @@
+db = new FlatFileDB($dataFile);
+ $this->logger = $logger;
+ $this->validator = new Validator();
+
+ if (!$this->db->exists('templates')) {
+ $this->db->set('templates', []);
+ $this->initializeDefaultTemplates();
+ }
+ }
+
+ /**
+ * Initialize default peer templates
+ */
+ private function initializeDefaultTemplates(): void {
+ $defaults = [
+ 'upstream' => [
+ 'id' => 'upstream',
+ 'name' => 'Upstream Provider',
+ 'type' => self::TYPE_PEER,
+ 'description' => 'Template for upstream transit providers',
+ 'pathvector' => [
+ 'allow_local_as' => true,
+ 'local_pref' => 80,
+ 'remove_all_communities' => null,
+ 'filter_rpki' => true,
+ 'filter_bogon_routes' => true,
+ 'filter_bogon_asns' => true,
+ ],
+ 'is_builtin' => true,
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ],
+ 'downstream' => [
+ 'id' => 'downstream',
+ 'name' => 'Downstream Customer',
+ 'type' => self::TYPE_PEER,
+ 'description' => 'Template for downstream customers',
+ 'pathvector' => [
+ 'filter_irr' => true,
+ 'filter_rpki' => true,
+ 'filter_transit_asns' => true,
+ 'auto_import_limits' => true,
+ 'auto_as_set' => true,
+ 'allow_blackhole_community' => true,
+ 'announce_default' => true,
+ 'local_pref' => 200,
+ ],
+ 'is_builtin' => true,
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ],
+ 'peer' => [
+ 'id' => 'peer',
+ 'name' => 'Peering Partner',
+ 'type' => self::TYPE_PEER,
+ 'description' => 'Template for bilateral peering sessions',
+ 'pathvector' => [
+ 'filter_irr' => true,
+ 'filter_rpki' => true,
+ 'filter_transit_asns' => true,
+ 'auto_import_limits' => true,
+ 'auto_as_set' => true,
+ 'local_pref' => 100,
+ ],
+ 'is_builtin' => true,
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ],
+ 'routeserver' => [
+ 'id' => 'routeserver',
+ 'name' => 'Route Server',
+ 'type' => self::TYPE_PEER,
+ 'description' => 'Template for IXP route servers',
+ 'pathvector' => [
+ 'filter_transit_asns' => true,
+ 'auto_import_limits' => true,
+ 'enforce_peer_nexthop' => false,
+ 'enforce_first_as' => false,
+ 'local_pref' => 90,
+ ],
+ 'is_builtin' => true,
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ],
+ 'ibgp' => [
+ 'id' => 'ibgp',
+ 'name' => 'Internal BGP',
+ 'type' => self::TYPE_PEER,
+ 'description' => 'Template for iBGP sessions within the same AS',
+ 'pathvector' => [
+ 'next_hop_self_ibgp' => true,
+ 'filter_rpki' => false,
+ 'filter_bogon_routes' => false,
+ 'filter_bogon_asns' => false,
+ 'remove_private_asns' => false,
+ ],
+ 'is_builtin' => true,
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ],
+ ];
+
+ $this->db->set('templates', $defaults);
+ $this->logger->info('template', 'Initialized default templates');
+ }
+
+ /**
+ * Get all templates
+ *
+ * @param string|null $type Filter by type
+ * @return array
+ */
+ public function getAll(?string $type = null): array {
+ $templates = $this->db->get('templates') ?? [];
+
+ if ($type !== null) {
+ return array_filter($templates, fn($t) => ($t['type'] ?? '') === $type);
+ }
+
+ return $templates;
+ }
+
+ /**
+ * Get template by ID
+ *
+ * @param string $id
+ * @return array|null
+ */
+ public function get(string $id): ?array {
+ $templates = $this->db->get('templates') ?? [];
+ return $templates[$id] ?? null;
+ }
+
+ /**
+ * Create new template
+ *
+ * @param array $data
+ * @return array
+ */
+ public function create(array $data): array {
+ if (empty($data['id'])) {
+ return [
+ 'success' => false,
+ 'message' => 'Template ID is required',
+ 'errors' => ['id' => 'Template ID is required'],
+ ];
+ }
+
+ if (empty($data['type']) || !in_array($data['type'], [self::TYPE_PEER, self::TYPE_ASN, self::TYPE_NODE, self::TYPE_POLICY])) {
+ return [
+ 'success' => false,
+ 'message' => 'Valid template type is required',
+ 'errors' => ['type' => 'Template type must be: peer, asn, node, or policy'],
+ ];
+ }
+
+ $templates = $this->db->get('templates') ?? [];
+ $id = $this->sanitizeId($data['id']);
+
+ if (isset($templates[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Template $id already exists",
+ 'errors' => ['id' => 'Template ID already exists'],
+ ];
+ }
+
+ $templateData = [
+ 'id' => $id,
+ 'name' => $data['name'] ?? $id,
+ 'type' => $data['type'],
+ 'description' => $data['description'] ?? '',
+ 'parent' => $data['parent'] ?? null, // For inheritance
+ 'pathvector' => $data['pathvector'] ?? [],
+ 'variables' => $data['variables'] ?? [], // Variable substitution
+ 'is_builtin' => false,
+ 'created_at' => date('c'),
+ 'updated_at' => date('c'),
+ ];
+
+ $templates[$id] = $templateData;
+
+ if ($this->db->set('templates', $templates)) {
+ $this->logger->success('template', "Created template: $id ({$templateData['type']})");
+ return [
+ 'success' => true,
+ 'message' => "Template $id created successfully",
+ 'data' => $templateData,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to save template',
+ 'errors' => ['database' => 'Failed to save template'],
+ ];
+ }
+
+ /**
+ * Update template
+ *
+ * @param string $id
+ * @param array $data
+ * @return array
+ */
+ public function update(string $id, array $data): array {
+ $templates = $this->db->get('templates') ?? [];
+
+ if (!isset($templates[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Template $id not found",
+ 'errors' => ['id' => 'Template not found'],
+ ];
+ }
+
+ // Don't allow modifying builtin templates (only pathvector options)
+ if ($templates[$id]['is_builtin'] ?? false) {
+ $templateData = $templates[$id];
+
+ if (isset($data['pathvector'])) {
+ $templateData['pathvector'] = array_merge(
+ $templateData['pathvector'] ?? [],
+ $data['pathvector']
+ );
+ }
+
+ if (isset($data['description'])) {
+ $templateData['description'] = $data['description'];
+ }
+ } else {
+ $templateData = $templates[$id];
+
+ $allowedFields = ['name', 'description', 'parent', 'pathvector', 'variables'];
+
+ foreach ($allowedFields as $field) {
+ if (isset($data[$field])) {
+ if ($field === 'pathvector') {
+ $templateData[$field] = array_merge(
+ $templateData[$field] ?? [],
+ $data[$field]
+ );
+ } else {
+ $templateData[$field] = $data[$field];
+ }
+ }
+ }
+ }
+
+ $templateData['updated_at'] = date('c');
+ $templates[$id] = $templateData;
+
+ if ($this->db->set('templates', $templates)) {
+ $this->logger->info('template', "Updated template: $id");
+ return [
+ 'success' => true,
+ 'message' => "Template $id updated successfully",
+ 'data' => $templateData,
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to update template',
+ 'errors' => ['database' => 'Failed to save template'],
+ ];
+ }
+
+ /**
+ * Delete template
+ *
+ * @param string $id
+ * @return array
+ */
+ public function delete(string $id): array {
+ $templates = $this->db->get('templates') ?? [];
+
+ if (!isset($templates[$id])) {
+ return [
+ 'success' => false,
+ 'message' => "Template $id not found",
+ ];
+ }
+
+ if ($templates[$id]['is_builtin'] ?? false) {
+ return [
+ 'success' => false,
+ 'message' => 'Cannot delete builtin templates',
+ ];
+ }
+
+ $templateName = $templates[$id]['name'];
+ unset($templates[$id]);
+
+ if ($this->db->set('templates', $templates)) {
+ $this->logger->warning('template', "Deleted template: $id ($templateName)");
+ return [
+ 'success' => true,
+ 'message' => "Template $id deleted successfully",
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to delete template',
+ ];
+ }
+
+ /**
+ * Resolve template with inheritance
+ *
+ * @param string $id
+ * @return array
+ */
+ public function resolve(string $id): array {
+ $template = $this->get($id);
+
+ if (!$template) {
+ return [];
+ }
+
+ $resolved = $template['pathvector'] ?? [];
+
+ // Apply parent template if exists
+ if (!empty($template['parent'])) {
+ $parentResolved = $this->resolve($template['parent']);
+ $resolved = array_merge($parentResolved, $resolved);
+ }
+
+ return $resolved;
+ }
+
+ /**
+ * Apply template to peer data with variable substitution
+ *
+ * @param array $peerData
+ * @param string $templateId
+ * @param array $variables
+ * @return array
+ */
+ public function applyToPeer(array $peerData, string $templateId, array $variables = []): array {
+ $resolved = $this->resolve($templateId);
+
+ if (empty($resolved)) {
+ return $peerData;
+ }
+
+ // Apply variable substitution
+ $resolved = $this->substituteVariables($resolved, $variables);
+
+ // Merge template with peer data (peer data takes precedence)
+ $peerData['pathvector'] = array_merge(
+ $resolved,
+ $peerData['pathvector'] ?? []
+ );
+
+ return $peerData;
+ }
+
+ /**
+ * Substitute variables in template
+ *
+ * @param array $data
+ * @param array $variables
+ * @return array
+ */
+ private function substituteVariables(array $data, array $variables): array {
+ array_walk_recursive($data, function(&$value) use ($variables) {
+ if (is_string($value)) {
+ foreach ($variables as $key => $replacement) {
+ $value = str_replace('{{' . $key . '}}', $replacement, $value);
+ $value = str_replace('', $replacement, $value);
+ }
+ }
+ });
+
+ return $data;
+ }
+
+ /**
+ * Preview template as YAML
+ *
+ * @param string $id
+ * @return string|null
+ */
+ public function previewYaml(string $id): ?string {
+ $template = $this->get($id);
+
+ if (!$template || $template['type'] !== self::TYPE_PEER) {
+ return null;
+ }
+
+ $resolved = $this->resolve($id);
+
+ $yaml = "# Template: {$template['name']}\n";
+ $yaml .= "# Type: {$template['type']}\n";
+
+ if (!empty($template['description'])) {
+ $yaml .= "# {$template['description']}\n";
+ }
+
+ $yaml .= "\ntemplates:\n";
+ $yaml .= " {$template['id']}:\n";
+
+ foreach ($resolved as $key => $value) {
+ if ($value === null || $value === '' || $value === []) {
+ continue;
+ }
+
+ $yamlKey = str_replace('_', '-', $key);
+ $yaml .= $this->formatYamlEntry($yamlKey, $value, 2);
+ }
+
+ return $yaml;
+ }
+
+ /**
+ * 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)) {
+ $yaml = "$prefix$key:\n";
+ foreach ($value as $item) {
+ $yaml .= "$prefix - " . $this->formatYamlValue($item) . "\n";
+ }
+ return $yaml;
+ } else {
+ $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;
+ }
+
+ /**
+ * Sanitize template 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 templates
+ *
+ * @param string|null $type
+ * @return int
+ */
+ public function count(?string $type = null): int {
+ return count($this->getAll($type));
+ }
+
+ /**
+ * Clone template
+ *
+ * @param string $sourceId
+ * @param string $newId
+ * @param string $newName
+ * @return array
+ */
+ public function cloneTemplate(string $sourceId, string $newId, string $newName = ''): array {
+ $source = $this->get($sourceId);
+
+ if (!$source) {
+ return [
+ 'success' => false,
+ 'message' => "Source template $sourceId not found",
+ ];
+ }
+
+ $data = $source;
+ $data['id'] = $newId;
+ $data['name'] = $newName ?: $source['name'] . ' (Copy)';
+ $data['is_builtin'] = false;
+ $data['parent'] = $sourceId; // Inherit from source
+ unset($data['created_at'], $data['updated_at']);
+
+ return $this->create($data);
+ }
+
+ /**
+ * Backup templates data
+ *
+ * @param string $backupDir
+ * @return string|false
+ */
+ public function backup(string $backupDir): string|false {
+ return $this->db->backup($backupDir);
+ }
+}
diff --git a/pathvector-admin/lib/Validator.php b/pathvector-admin/lib/Validator.php
new file mode 100644
index 0000000..c51dc12
--- /dev/null
+++ b/pathvector-admin/lib/Validator.php
@@ -0,0 +1,638 @@
+errors;
+ }
+
+ /**
+ * Get validation warnings
+ *
+ * @return array
+ */
+ public function getWarnings(): array {
+ return $this->warnings;
+ }
+
+ /**
+ * Check if validation passed
+ *
+ * @return bool
+ */
+ public function isValid(): bool {
+ return empty($this->errors);
+ }
+
+ /**
+ * Clear errors and warnings
+ *
+ * @return void
+ */
+ public function clear(): void {
+ $this->errors = [];
+ $this->warnings = [];
+ }
+
+ /**
+ * Add error
+ *
+ * @param string $field
+ * @param string $message
+ * @return void
+ */
+ private function addError(string $field, string $message): void {
+ $this->errors[$field] = $message;
+ }
+
+ /**
+ * Add warning
+ *
+ * @param string $field
+ * @param string $message
+ * @return void
+ */
+ private function addWarning(string $field, string $message): void {
+ $this->warnings[$field] = $message;
+ }
+
+ /**
+ * Validate ASN
+ *
+ * @param mixed $asn
+ * @param string $field
+ * @return bool
+ */
+ public function validateAsn($asn, string $field = 'asn'): bool {
+ if (!is_numeric($asn)) {
+ $this->addError($field, 'ASN must be a number');
+ return false;
+ }
+
+ $asn = (int) $asn;
+
+ if ($asn < self::ASN_MIN || $asn > self::ASN_MAX) {
+ $this->addError($field, "ASN must be between " . self::ASN_MIN . " and " . self::ASN_MAX);
+ return false;
+ }
+
+ // Warn about reserved/private ASNs
+ if ($asn === 0) {
+ $this->addError($field, 'ASN 0 is reserved');
+ return false;
+ }
+
+ if ($asn === 23456) {
+ $this->addError($field, 'ASN 23456 is reserved for AS_TRANS (RFC 6793)');
+ return false;
+ }
+
+ if (($asn >= self::PRIVATE_ASN_START_16 && $asn <= self::PRIVATE_ASN_END_16) ||
+ ($asn >= self::PRIVATE_ASN_START_32 && $asn <= self::PRIVATE_ASN_END_32)) {
+ $this->addWarning($field, 'This is a private ASN');
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate IPv4 address
+ *
+ * @param string $ip
+ * @param string $field
+ * @return bool
+ */
+ public function validateIPv4(string $ip, string $field = 'ip'): bool {
+ if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ $this->addError($field, 'Invalid IPv4 address');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate IPv6 address
+ *
+ * @param string $ip
+ * @param string $field
+ * @return bool
+ */
+ public function validateIPv6(string $ip, string $field = 'ip'): bool {
+ if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
+ $this->addError($field, 'Invalid IPv6 address');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate IP address (IPv4 or IPv6)
+ *
+ * @param string $ip
+ * @param string $field
+ * @return bool
+ */
+ public function validateIP(string $ip, string $field = 'ip'): bool {
+ if (!filter_var($ip, FILTER_VALIDATE_IP)) {
+ $this->addError($field, 'Invalid IP address');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate IPv4 prefix
+ *
+ * @param string $prefix
+ * @param string $field
+ * @return bool
+ */
+ public function validateIPv4Prefix(string $prefix, string $field = 'prefix'): bool {
+ $parts = explode('/', $prefix);
+
+ if (count($parts) !== 2) {
+ $this->addError($field, 'Invalid prefix format (expected x.x.x.x/xx)');
+ return false;
+ }
+
+ [$ip, $length] = $parts;
+
+ if (!$this->validateIPv4($ip, $field)) {
+ return false;
+ }
+
+ $length = (int) $length;
+ if ($length < 0 || $length > 32) {
+ $this->addError($field, 'Prefix length must be between 0 and 32');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate IPv6 prefix
+ *
+ * @param string $prefix
+ * @param string $field
+ * @return bool
+ */
+ public function validateIPv6Prefix(string $prefix, string $field = 'prefix'): bool {
+ $parts = explode('/', $prefix);
+
+ if (count($parts) !== 2) {
+ $this->addError($field, 'Invalid prefix format (expected xxxx::/xx)');
+ return false;
+ }
+
+ [$ip, $length] = $parts;
+
+ if (!$this->validateIPv6($ip, $field)) {
+ return false;
+ }
+
+ $length = (int) $length;
+ if ($length < 0 || $length > 128) {
+ $this->addError($field, 'Prefix length must be between 0 and 128');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate prefix (IPv4 or IPv6)
+ *
+ * @param string $prefix
+ * @param string $field
+ * @return bool
+ */
+ public function validatePrefix(string $prefix, string $field = 'prefix'): bool {
+ if (strpos($prefix, ':') !== false) {
+ return $this->validateIPv6Prefix($prefix, $field);
+ }
+
+ return $this->validateIPv4Prefix($prefix, $field);
+ }
+
+ /**
+ * Validate router ID
+ *
+ * @param string $routerId
+ * @param string $field
+ * @return bool
+ */
+ public function validateRouterId(string $routerId, string $field = 'router_id'): bool {
+ return $this->validateIPv4($routerId, $field);
+ }
+
+ /**
+ * Validate BGP community (standard)
+ *
+ * @param string $community
+ * @param string $field
+ * @return bool
+ */
+ public function validateStandardCommunity(string $community, string $field = 'community'): bool {
+ // Format: ASN,value or ASN:value
+ $pattern = '/^(\d+)[,:](\d+)$/';
+
+ if (!preg_match($pattern, $community, $matches)) {
+ $this->addError($field, 'Invalid standard community format (expected ASN,value or ASN:value)');
+ return false;
+ }
+
+ $asn = (int) $matches[1];
+ $value = (int) $matches[2];
+
+ if ($asn > 65535 || $value > 65535) {
+ $this->addError($field, 'Standard community values must be <= 65535');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate BGP large community
+ *
+ * @param string $community
+ * @param string $field
+ * @return bool
+ */
+ public function validateLargeCommunity(string $community, string $field = 'community'): bool {
+ // Format: ASN:value1:value2
+ $pattern = '/^(\d+):(\d+):(\d+)$/';
+
+ if (!preg_match($pattern, $community, $matches)) {
+ $this->addError($field, 'Invalid large community format (expected ASN:value1:value2)');
+ return false;
+ }
+
+ $asn = (int) $matches[1];
+ $val1 = (int) $matches[2];
+ $val2 = (int) $matches[3];
+
+ if ($asn > self::ASN_MAX || $val1 > self::ASN_MAX || $val2 > self::ASN_MAX) {
+ $this->addError($field, 'Large community values must be <= 4294967295');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate community (standard or large)
+ *
+ * @param string $community
+ * @param string $field
+ * @return bool
+ */
+ public function validateCommunity(string $community, string $field = 'community'): bool {
+ // Try large community first (has 3 parts)
+ if (preg_match('/^\d+:\d+:\d+$/', $community)) {
+ return $this->validateLargeCommunity($community, $field);
+ }
+
+ return $this->validateStandardCommunity($community, $field);
+ }
+
+ /**
+ * Validate BGP role (RFC 9234)
+ *
+ * @param string $role
+ * @param string $field
+ * @return bool
+ */
+ public function validateBgpRole(string $role, string $field = 'role'): bool {
+ $role = strtolower(str_replace('_', '-', $role));
+
+ if (!in_array($role, self::VALID_BGP_ROLES)) {
+ $this->addError($field, 'Invalid BGP role. Must be one of: ' . implode(', ', self::VALID_BGP_ROLES));
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate TCP port
+ *
+ * @param mixed $port
+ * @param string $field
+ * @return bool
+ */
+ public function validatePort(mixed $port, string $field = 'port'): bool {
+ if (!is_numeric($port)) {
+ $this->addError($field, 'Port must be a number');
+ return false;
+ }
+
+ $port = (int) $port;
+
+ if ($port < 1 || $port > 65535) {
+ $this->addError($field, 'Port must be between 1 and 65535');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate limit violation action
+ *
+ * @param string $action
+ * @param string $field
+ * @return bool
+ */
+ public function validateLimitAction(string $action, string $field = 'action'): bool {
+ if (!in_array($action, self::VALID_LIMIT_ACTIONS)) {
+ $this->addError($field, 'Invalid limit action. Must be one of: ' . implode(', ', self::VALID_LIMIT_ACTIONS));
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate hostname
+ *
+ * @param string $hostname
+ * @param string $field
+ * @return bool
+ */
+ public function validateHostname(string $hostname, string $field = 'hostname'): bool {
+ $pattern = '/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/';
+
+ if (!preg_match($pattern, $hostname)) {
+ $this->addError($field, 'Invalid hostname format');
+ return false;
+ }
+
+ if (strlen($hostname) > 253) {
+ $this->addError($field, 'Hostname must not exceed 253 characters');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate AS-SET name
+ *
+ * @param string $asSet
+ * @param string $field
+ * @return bool
+ */
+ public function validateAsSet(string $asSet, string $field = 'as_set'): bool {
+ // AS-SET names typically start with AS and can contain alphanumeric chars, hyphens, and colons
+ $pattern = '/^(AS-?)?[A-Z0-9][A-Z0-9-:]*$/i';
+
+ if (!preg_match($pattern, $asSet)) {
+ $this->addError($field, 'Invalid AS-SET name format');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Validate peer configuration
+ *
+ * @param array $peer
+ * @return bool
+ */
+ public function validatePeerConfig(array $peer): bool {
+ $this->clear();
+
+ // Required fields
+ if (empty($peer['asn'])) {
+ $this->addError('asn', 'ASN is required');
+ } else {
+ $this->validateAsn($peer['asn'], 'asn');
+ }
+
+ if (empty($peer['neighbors']) || !is_array($peer['neighbors']) || count($peer['neighbors']) === 0) {
+ $this->addError('neighbors', 'At least one neighbor IP is required');
+ } else {
+ foreach ($peer['neighbors'] as $i => $neighbor) {
+ $this->validateIP($neighbor, "neighbors[$i]");
+ }
+ }
+
+ // Optional fields validation
+ if (!empty($peer['remote_asn'])) {
+ $this->validateAsn($peer['remote_asn'], 'remote_asn');
+ }
+
+ if (!empty($peer['local_asn'])) {
+ $this->validateAsn($peer['local_asn'], 'local_asn');
+ }
+
+ if (!empty($peer['local_port'])) {
+ $this->validatePort($peer['local_port'], 'local_port');
+ }
+
+ if (!empty($peer['neighbor_port'])) {
+ $this->validatePort($peer['neighbor_port'], 'neighbor_port');
+ }
+
+ if (!empty($peer['role'])) {
+ $this->validateBgpRole($peer['role'], 'role');
+ }
+
+ if (!empty($peer['router_id'])) {
+ $this->validateRouterId($peer['router_id'], 'router_id');
+ }
+
+ if (!empty($peer['receive_limit_violation'])) {
+ $this->validateLimitAction($peer['receive_limit_violation'], 'receive_limit_violation');
+ }
+
+ if (!empty($peer['export_limit_violation'])) {
+ $this->validateLimitAction($peer['export_limit_violation'], 'export_limit_violation');
+ }
+
+ // Validate communities
+ if (!empty($peer['add_on_import']) && is_array($peer['add_on_import'])) {
+ foreach ($peer['add_on_import'] as $i => $community) {
+ $this->validateCommunity($community, "add_on_import[$i]");
+ }
+ }
+
+ if (!empty($peer['add_on_export']) && is_array($peer['add_on_export'])) {
+ foreach ($peer['add_on_export'] as $i => $community) {
+ $this->validateCommunity($community, "add_on_export[$i]");
+ }
+ }
+
+ if (!empty($peer['announce']) && is_array($peer['announce'])) {
+ foreach ($peer['announce'] as $i => $community) {
+ $this->validateCommunity($community, "announce[$i]");
+ }
+ }
+
+ // Validate prefixes
+ if (!empty($peer['prefixes']) && is_array($peer['prefixes'])) {
+ foreach ($peer['prefixes'] as $i => $prefix) {
+ $this->validatePrefix($prefix, "prefixes[$i]");
+ }
+ }
+
+ // Validate as-set
+ if (!empty($peer['as_set'])) {
+ $this->validateAsSet($peer['as_set'], 'as_set');
+ }
+
+ // Logical validation
+ if (!empty($peer['only_announce']) && !empty($peer['announce_all']) && $peer['announce_all']) {
+ $this->addError('announce_all', 'only-announce and announce-all cannot both be true');
+ }
+
+ if (!empty($peer['default_local_pref']) && !empty($peer['optimize_inbound']) && $peer['optimize_inbound']) {
+ $this->addError('optimize_inbound', 'default-local-pref and optimize-inbound cannot both be set');
+ }
+
+ return $this->isValid();
+ }
+
+ /**
+ * Validate global config
+ *
+ * @param array $config
+ * @return bool
+ */
+ public function validateGlobalConfig(array $config): bool {
+ $this->clear();
+
+ // Required fields
+ if (empty($config['asn'])) {
+ $this->addError('asn', 'ASN is required');
+ } else {
+ $this->validateAsn($config['asn'], 'asn');
+ }
+
+ if (empty($config['router_id'])) {
+ $this->addError('router_id', 'Router ID is required');
+ } else {
+ $this->validateRouterId($config['router_id'], 'router_id');
+ }
+
+ // Optional fields
+ if (!empty($config['source4'])) {
+ $this->validateIPv4($config['source4'], 'source4');
+ }
+
+ if (!empty($config['source6'])) {
+ $this->validateIPv6($config['source6'], 'source6');
+ }
+
+ // Validate prefixes
+ if (!empty($config['prefixes']) && is_array($config['prefixes'])) {
+ foreach ($config['prefixes'] as $i => $prefix) {
+ $this->validatePrefix($prefix, "prefixes[$i]");
+ }
+ }
+
+ return $this->isValid();
+ }
+
+ /**
+ * Validate node configuration
+ *
+ * @param array $node
+ * @return bool
+ */
+ public function validateNodeConfig(array $node): bool {
+ $this->clear();
+
+ if (empty($node['asn'])) {
+ $this->addError('asn', 'ASN is required');
+ } else {
+ $this->validateAsn($node['asn'], 'asn');
+ }
+
+ if (!empty($node['router_id'])) {
+ $this->validateRouterId($node['router_id'], 'router_id');
+ }
+
+ if (!empty($node['hostname'])) {
+ $this->validateHostname($node['hostname'], 'hostname');
+ }
+
+ return $this->isValid();
+ }
+
+ /**
+ * Check if ASN is a transit-free network
+ *
+ * @param int $asn
+ * @return bool
+ */
+ public function isTransitFreeAsn(int $asn): bool {
+ return in_array($asn, self::TRANSIT_FREE_ASNS);
+ }
+
+ /**
+ * Check if ASN is private
+ *
+ * @param int $asn
+ * @return bool
+ */
+ public function isPrivateAsn(int $asn): bool {
+ return ($asn >= self::PRIVATE_ASN_START_16 && $asn <= self::PRIVATE_ASN_END_16) ||
+ ($asn >= self::PRIVATE_ASN_START_32 && $asn <= self::PRIVATE_ASN_END_32);
+ }
+
+ /**
+ * Validate required fields
+ *
+ * @param array $data
+ * @param array $requiredFields
+ * @return bool
+ */
+ public function validateRequired(array $data, array $requiredFields): bool {
+ foreach ($requiredFields as $field) {
+ if (!isset($data[$field]) || $data[$field] === '' || $data[$field] === null) {
+ $this->addError($field, ucfirst(str_replace('_', ' ', $field)) . ' is required');
+ }
+ }
+
+ return $this->isValid();
+ }
+}
diff --git a/pathvector-admin/login.php b/pathvector-admin/login.php
new file mode 100644
index 0000000..529276f
--- /dev/null
+++ b/pathvector-admin/login.php
@@ -0,0 +1,181 @@
+isLoggedIn()) {
+ header('Location: index.php');
+ exit;
+}
+
+$error = '';
+$username = '';
+
+// Handle login form submission
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $username = trim($_POST['username'] ?? '');
+ $password = $_POST['password'] ?? '';
+
+ if (empty($username) || empty($password)) {
+ $error = 'Please enter both username and password.';
+ } else {
+ $result = $auth->login($username, $password);
+
+ if ($result['success']) {
+ $logger->log('auth', 'User logged in', ['username' => $username]);
+ header('Location: index.php');
+ exit;
+ } else {
+ $error = $result['message'];
+ $logger->log('auth', 'Failed login attempt', ['username' => $username, 'error' => $result['message']], 'warning');
+ }
+ }
+}
+?>
+
+
+
+
+
+ Login - Pathvector Admin
+
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($error) ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/pathvector-admin/pages/asns.php b/pathvector-admin/pages/asns.php
new file mode 100644
index 0000000..fb690e1
--- /dev/null
+++ b/pathvector-admin/pages/asns.php
@@ -0,0 +1,610 @@
+verifyCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = 'Invalid security token. Please try again.';
+ } else {
+ $postAction = $_POST['action'] ?? '';
+
+ switch ($postAction) {
+ case 'create':
+ if (!hasPermission('create_peers')) {
+ $error = 'You do not have permission to create ASNs.';
+ break;
+ }
+
+ $asnNumber = trim($_POST['asn'] ?? '');
+ $name = trim($_POST['name'] ?? '');
+ $description = trim($_POST['description'] ?? '');
+
+ // Validate ASN
+ if (!$validator->validateASN($asnNumber)) {
+ $error = 'Invalid ASN number.';
+ break;
+ }
+
+ if (empty($name)) {
+ $error = 'Name is required.';
+ break;
+ }
+
+ // Build defaults from form
+ $defaults = buildDefaultsFromForm($_POST);
+
+ $result = $asnManager->create($asnNumber, $name, $description, $defaults);
+
+ if ($result['success']) {
+ $logger->log('asn', 'Created ASN', ['asn' => $asnNumber, 'name' => $name]);
+ header('Location: ?page=asns&message=ASN created successfully');
+ exit;
+ } else {
+ $error = $result['message'];
+ }
+ break;
+
+ case 'update':
+ if (!hasPermission('edit_peers')) {
+ $error = 'You do not have permission to edit ASNs.';
+ break;
+ }
+
+ $id = $_POST['id'] ?? '';
+ $name = trim($_POST['name'] ?? '');
+ $description = trim($_POST['description'] ?? '');
+
+ if (empty($name)) {
+ $error = 'Name is required.';
+ break;
+ }
+
+ $defaults = buildDefaultsFromForm($_POST);
+
+ $result = $asnManager->update($id, [
+ 'name' => $name,
+ 'description' => $description,
+ 'defaults' => $defaults,
+ ]);
+
+ if ($result['success']) {
+ $logger->log('asn', 'Updated ASN', ['id' => $id, 'name' => $name]);
+ header('Location: ?page=asns&message=ASN updated successfully');
+ exit;
+ } else {
+ $error = $result['message'];
+ }
+ break;
+
+ case 'delete':
+ if (!hasPermission('delete_peers')) {
+ $error = 'You do not have permission to delete ASNs.';
+ break;
+ }
+
+ $id = $_POST['id'] ?? '';
+ $asn = $asnManager->get($id);
+
+ if (!$asn) {
+ $error = 'ASN not found.';
+ break;
+ }
+
+ $result = $asnManager->delete($id);
+
+ if ($result['success']) {
+ $logger->log('asn', 'Deleted ASN', ['asn' => $asn['asn'], 'name' => $asn['name']]);
+ header('Location: ?page=asns&message=ASN deleted successfully');
+ exit;
+ } else {
+ $error = $result['message'];
+ }
+ break;
+ }
+ }
+}
+
+// Get message from query string
+if (isset($_GET['message'])) {
+ $message = $_GET['message'];
+}
+
+// Helper function to build defaults from form
+function buildDefaultsFromForm(array $post): array {
+ $defaults = [];
+
+ // String fields
+ $stringFields = [
+ 'router_id', 'source4', 'source6', 'irr_server', 'rtr_server',
+ 'bgpq_path', 'bgpq_args', 'bird_directory', 'bird_socket',
+ 'cache_directory', 'log_file', 'hostname', 'peeringdb_api_key',
+ 'peeringdb_cache_file', 'peeringdb_query_timeout', 'kernel_table',
+ ];
+
+ foreach ($stringFields as $field) {
+ if (isset($post[$field]) && $post[$field] !== '') {
+ $defaults[$field] = trim($post[$field]);
+ }
+ }
+
+ // Boolean fields
+ $booleanFields = [
+ 'keep_filtered', 'merge_paths', 'rpki_filter', 'irr_filter',
+ 'transit_locking', 'graceful_shutdown', 'no_announce',
+ ];
+
+ foreach ($booleanFields as $field) {
+ if (isset($post[$field])) {
+ $defaults[$field] = $post[$field] === '1';
+ }
+ }
+
+ // Integer fields
+ $intFields = [
+ 'default_route_limit4', 'default_route_limit6', 'pref_src4_placeholder',
+ 'pref_src6_placeholder', 'kernel_learn', 'kernel_export',
+ ];
+
+ foreach ($intFields as $field) {
+ if (isset($post[$field]) && $post[$field] !== '') {
+ $defaults[$field] = (int) $post[$field];
+ }
+ }
+
+ // Array fields (comma-separated)
+ $arrayFields = [
+ 'prefixes4' => 'prefixes4',
+ 'prefixes6' => 'prefixes6',
+ 'communities_blackhole' => 'blackhole',
+ 'communities_nopeer' => 'nopeer',
+ ];
+
+ foreach ($arrayFields as $postField => $defaultField) {
+ if (isset($post[$postField]) && $post[$postField] !== '') {
+ $values = array_filter(array_map('trim', explode(',', $post[$postField])));
+ if (!empty($values)) {
+ $defaults[$defaultField] = $values;
+ }
+ }
+ }
+
+ return $defaults;
+}
+
+// Get ASN for edit
+$editAsn = null;
+if ($action === 'edit' && $id) {
+ $editAsn = $asnManager->get($id);
+ if (!$editAsn) {
+ $error = 'ASN not found.';
+ $action = 'list';
+ }
+}
+
+// Get all ASNs for list view
+$asns = $asnManager->getAll();
+?>
+
+
+
+
+
+
+
+
+ = e($message) ?>
+
+
+
+
+
+ = e($error) ?>
+
+
+
+
+
+
+
No ASNs configured
+
Get started by creating your first ASN.
+
+
Create ASN
+
+
+
+
+
+
+
+
+ | ASN |
+ Name |
+ Description |
+ Router ID |
+ Nodes |
+ |
+
+
+
+ getByAsn($asn['id']);
+ ?>
+
+ |
+ AS= e($asn['asn']) ?>
+ |
+ = e($asn['name']) ?> |
+ = e($asn['description'] ?? '-') ?> |
+
+ = e($asn['defaults']['router_id'] ?? '-') ?>
+ |
+
+ = count($asnNodes) ?>
+ |
+
+ Edit
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = e($error) ?>
+
+
+
+
+
+
diff --git a/pathvector-admin/pages/dashboard.php b/pathvector-admin/pages/dashboard.php
new file mode 100644
index 0000000..30cde64
--- /dev/null
+++ b/pathvector-admin/pages/dashboard.php
@@ -0,0 +1,314 @@
+getAll();
+$nodes = $nodeManager->getAll();
+$peers = $peerManager->getAll();
+$templates = $templateManager->getAll();
+$hosts = $hostManager->getAll();
+
+// Count by status
+$peersByStatus = [];
+foreach ($peers as $peer) {
+ $status = $peer['enabled'] ?? true ? 'enabled' : 'disabled';
+ $peersByStatus[$status] = ($peersByStatus[$status] ?? 0) + 1;
+}
+
+// Recent activity
+$recentLogs = $logger->search([], 10);
+?>
+
+
+
+
+
+
+
+
+
+
+
= count($asns) ?>
+
ASNs
+
+
+
+
+
+
+
+
+
= count($nodes) ?>
+
Nodes
+
+
+
+
+
+
+
+
+
= count($peers) ?>
+
Peers
+
+
+
+
+
+
+
+
+
= count($templates) ?>
+
Templates
+
+
+
+
+
+
+
+
+
= count($hosts) ?>
+
Hosts
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No ASNs configured yet.
+
+
+
+
+ -
+ AS= e($asn['asn']) ?>
+ = e($asn['name']) ?>
+ Edit
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No nodes configured yet.
+
+
+
+
+ get($node['asn_id']);
+ $nodePeers = array_filter($peers, fn($p) => $p['node_id'] === $node['id']);
+ ?>
+ -
+
+
= e($node['name']) ?>
+
+ = $nodeAsn ? 'AS' . e($nodeAsn['asn']) : 'Unknown ASN' ?> •
+ = count($nodePeers) ?> peers
+
+
+ Edit
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No recent activity.
+
+
+
+
+ -
+
+
+
+
= e($log['message']) ?>
+
+ = e($log['category']) ?> • = formatDate($log['timestamp']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No peers configured yet.
+
+
+
+
+
+
+ | Peer Name |
+ ASN |
+ Neighbor |
+ Node |
+ Template |
+ Status |
+ |
+
+
+
+
+ get($peer['node_id']);
+ $peerAsn = $peerNode ? $asnManager->get($peerNode['asn_id']) : null;
+ ?>
+
+ | = e($peer['name']) ?> |
+ = isset($peer['config']['asn']) ? 'AS' . e($peer['config']['asn']) : '-' ?> |
+
+
+ = e(implode(', ', array_slice($peer['config']['neighbors'], 0, 2))) ?>
+ 2): ?>
+ += count($peer['config']['neighbors']) - 2 ?>
+
+
+ -
+
+ |
+ = $peerNode ? e($peerNode['name']) : '-' ?> |
+
+
+ = e($peer['config']['template']) ?>
+
+ -
+
+ |
+
+
+ Enabled
+
+ Disabled
+
+ |
+
+ Edit
+ |
+
+
+
+
+
+
+
+
+
diff --git a/pathvector-admin/pages/nodes.php b/pathvector-admin/pages/nodes.php
new file mode 100644
index 0000000..2dc4e25
--- /dev/null
+++ b/pathvector-admin/pages/nodes.php
@@ -0,0 +1,586 @@
+verifyCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = 'Invalid security token. Please try again.';
+ } else {
+ $postAction = $_POST['action'] ?? '';
+
+ switch ($postAction) {
+ case 'create':
+ if (!hasPermission('create_peers')) {
+ $error = 'You do not have permission to create nodes.';
+ break;
+ }
+
+ $asnId = $_POST['asn_id'] ?? '';
+ $name = trim($_POST['name'] ?? '');
+ $description = trim($_POST['description'] ?? '');
+
+ if (empty($asnId)) {
+ $error = 'Please select an ASN.';
+ break;
+ }
+
+ if (empty($name)) {
+ $error = 'Name is required.';
+ break;
+ }
+
+ $config = buildNodeConfigFromForm($_POST);
+
+ $result = $nodeManager->create($asnId, $name, $description, $config);
+
+ if ($result['success']) {
+ $logger->log('node', 'Created node', ['name' => $name, 'asn_id' => $asnId]);
+ header('Location: ?page=nodes&message=Node created successfully');
+ exit;
+ } else {
+ $error = $result['message'];
+ }
+ break;
+
+ case 'update':
+ if (!hasPermission('edit_peers')) {
+ $error = 'You do not have permission to edit nodes.';
+ break;
+ }
+
+ $id = $_POST['id'] ?? '';
+ $name = trim($_POST['name'] ?? '');
+ $description = trim($_POST['description'] ?? '');
+
+ if (empty($name)) {
+ $error = 'Name is required.';
+ break;
+ }
+
+ $config = buildNodeConfigFromForm($_POST);
+
+ $result = $nodeManager->update($id, [
+ 'name' => $name,
+ 'description' => $description,
+ 'config' => $config,
+ ]);
+
+ if ($result['success']) {
+ $logger->log('node', 'Updated node', ['id' => $id, 'name' => $name]);
+ header('Location: ?page=nodes&message=Node updated successfully');
+ exit;
+ } else {
+ $error = $result['message'];
+ }
+ break;
+
+ case 'delete':
+ if (!hasPermission('delete_peers')) {
+ $error = 'You do not have permission to delete nodes.';
+ break;
+ }
+
+ $id = $_POST['id'] ?? '';
+ $node = $nodeManager->get($id);
+
+ if (!$node) {
+ $error = 'Node not found.';
+ break;
+ }
+
+ $result = $nodeManager->delete($id);
+
+ if ($result['success']) {
+ $logger->log('node', 'Deleted node', ['name' => $node['name']]);
+ header('Location: ?page=nodes&message=Node deleted successfully');
+ exit;
+ } else {
+ $error = $result['message'];
+ }
+ break;
+ }
+ }
+}
+
+// Get message from query string
+if (isset($_GET['message'])) {
+ $message = $_GET['message'];
+}
+
+// Helper function to build config from form
+function buildNodeConfigFromForm(array $post): array {
+ $config = [];
+
+ // String fields
+ $stringFields = [
+ 'router_id', 'source4', 'source6', 'hostname', 'bird_directory',
+ 'bird_socket', 'cache_directory', 'irr_server', 'rtr_server',
+ ];
+
+ foreach ($stringFields as $field) {
+ if (isset($post[$field]) && $post[$field] !== '') {
+ $config[$field] = trim($post[$field]);
+ }
+ }
+
+ // Boolean fields
+ $booleanFields = [
+ 'keep_filtered', 'rpki_filter', 'irr_filter',
+ ];
+
+ foreach ($booleanFields as $field) {
+ if (isset($post[$field])) {
+ $config[$field] = $post[$field] === '1';
+ }
+ }
+
+ // Prefixes
+ if (!empty($post['prefixes4'])) {
+ $config['prefixes'] = array_filter(array_map('trim', explode(',', $post['prefixes4'])));
+ }
+
+ if (!empty($post['prefixes6'])) {
+ $config['prefixes6'] = array_filter(array_map('trim', explode(',', $post['prefixes6'])));
+ }
+
+ // Templates
+ if (!empty($post['templates'])) {
+ $config['templates'] = array_filter(array_map('trim', explode(',', $post['templates'])));
+ }
+
+ return $config;
+}
+
+// Get node for edit
+$editNode = null;
+if ($action === 'edit' && $id) {
+ $editNode = $nodeManager->get($id);
+ if (!$editNode) {
+ $error = 'Node not found.';
+ $action = 'list';
+ }
+}
+
+// Get all ASNs and nodes
+$asns = $asnManager->getAll();
+$nodes = $nodeManager->getAll();
+
+// Filter by ASN if specified
+$filterAsnId = $_GET['asn_id'] ?? '';
+if ($filterAsnId) {
+ $nodes = array_filter($nodes, fn($n) => $n['asn_id'] === $filterAsnId);
+}
+?>
+
+
+
+
+
+
+
+
+ = e($message) ?>
+
+
+
+
+
+ = e($error) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No nodes configured
+
Get started by creating your first node.
+
+
Create Node
+
+
+
+
+
+
+
+
+ | Name |
+ ASN |
+ Router ID |
+ Description |
+ Peers |
+ |
+
+
+
+ get($node['asn_id']);
+ $nodePeers = $peerManager->getByNode($node['id']);
+ ?>
+
+ | = e($node['name']) ?> |
+
+
+ AS= e($nodeAsn['asn']) ?>
+
+ -
+
+ |
+
+ = e($node['config']['router_id'] ?? '-') ?>
+ |
+ = e($node['description'] ?? '-') ?> |
+
+ = count($nodePeers) ?>
+ |
+
+ View Peers
+ Edit
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No ASNs found. You need to create an ASN before creating nodes.
+
Create ASN
+
+
+
+
+
+ = e($error) ?>
+
+
+
+
+
+
+
diff --git a/pathvector-admin/setup.php b/pathvector-admin/setup.php
new file mode 100644
index 0000000..8177509
--- /dev/null
+++ b/pathvector-admin/setup.php
@@ -0,0 +1,239 @@
+getAll('users');
+if (!empty($users)) {
+ header('Location: login.php');
+ exit;
+}
+
+$error = '';
+$success = false;
+
+// Handle setup form submission
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $username = trim($_POST['username'] ?? '');
+ $password = $_POST['password'] ?? '';
+ $confirmPassword = $_POST['confirm_password'] ?? '';
+
+ // Validation
+ if (empty($username)) {
+ $error = 'Username is required.';
+ } elseif (strlen($username) < 3) {
+ $error = 'Username must be at least 3 characters.';
+ } elseif (empty($password)) {
+ $error = 'Password is required.';
+ } elseif (strlen($password) < 8) {
+ $error = 'Password must be at least 8 characters.';
+ } elseif ($password !== $confirmPassword) {
+ $error = 'Passwords do not match.';
+ } else {
+ // Create admin user
+ $result = $auth->createUser($username, $password, 'admin');
+
+ if ($result['success']) {
+ $logger->log('setup', 'Initial setup completed', ['admin_user' => $username]);
+ $success = true;
+ } else {
+ $error = $result['message'];
+ }
+ }
+}
+?>
+
+
+
+
+
+ Setup - Pathvector Admin
+
+
+
+
+
+
+
+
+
+
+ Setup Complete! Your administrator account has been created.
+
+
Continue to Login
+
+
+
+ = htmlspecialchars($error) ?>
+
+
+
+
+
+
+
Next Steps After Setup
+
+ - Configure your ASN(s)
+ - Add router nodes
+ - Create peer templates
+ - Add BGP peers
+ - Set up execution hosts
+ - Generate and apply configurations
+
+
+
+
+
+
+