Add nodes management and initial setup pages
- Implemented nodes management functionality in `nodes.php` including create, update, and delete actions. - Added form validation and error handling for node operations. - Created a new setup page in `setup.php` for initial administrator account creation. - Included user feedback messages for successful operations and errors. - Designed user interface for both nodes management and setup processes.
This commit is contained in:
414
pathvector-admin/assets/css/style.css
Normal file
414
pathvector-admin/assets/css/style.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
74
pathvector-admin/config/config.php
Normal file
74
pathvector-admin/config/config.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Pathvector Admin Configuration
|
||||||
|
*
|
||||||
|
* Main configuration file for the Pathvector administration dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Application Settings
|
||||||
|
'app_name' => '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'],
|
||||||
|
],
|
||||||
|
];
|
||||||
538
pathvector-admin/index.php
Normal file
538
pathvector-admin/index.php
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Pathvector Admin Dashboard
|
||||||
|
* Main Entry Point and Router
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Error reporting
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
ini_set('log_errors', '1');
|
||||||
|
|
||||||
|
// Session configuration
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
require_once __DIR__ . '/config/config.php';
|
||||||
|
|
||||||
|
// Autoloader
|
||||||
|
spl_autoload_register(function ($class) {
|
||||||
|
$file = __DIR__ . '/lib/' . $class . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require_once $file;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize core classes
|
||||||
|
$db = new FlatFileDB(DATA_PATH);
|
||||||
|
$logger = new Logger($db);
|
||||||
|
$auth = new Auth($db);
|
||||||
|
|
||||||
|
// Check if installed (has at least one user)
|
||||||
|
$users = $db->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 '<input type="hidden" name="csrf_token" value="' . htmlspecialchars($auth->generateCsrfToken()) . '">';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark_dimmed">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= e($currentPage['title']) ?> - Pathvector Admin</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@primer/css@^20.2.4/dist/primer.css">
|
||||||
|
<link rel="stylesheet" href="assets/css/style.css">
|
||||||
|
<style>
|
||||||
|
/* Layout Styles */
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background: var(--color-canvas-subtle);
|
||||||
|
border-right: 1px solid var(--color-border-default);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid var(--color-border-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: var(--color-fg-default);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 2px 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--color-action-list-item-default-hover-bg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--color-action-list-item-default-selected-bg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item .octicon {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section {
|
||||||
|
padding: 8px 16px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 260px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--color-canvas-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 24px 32px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
background: var(--color-canvas-default);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-content {
|
||||||
|
padding: 24px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Styles */
|
||||||
|
.stat-card {
|
||||||
|
background: var(--color-canvas-subtle);
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Enhancements */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-default);
|
||||||
|
background: var(--color-canvas-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-border-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background: var(--color-action-list-item-default-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Indicators */
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.success { background: var(--color-success-fg); }
|
||||||
|
.status-dot.warning { background: var(--color-attention-fg); }
|
||||||
|
.status-dot.danger { background: var(--color-danger-fg); }
|
||||||
|
.status-dot.muted { background: var(--color-fg-muted); }
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input-wide {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Backdrop */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 999;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast Notifications */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
box-shadow: var(--color-shadow-large);
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Preview */
|
||||||
|
.code-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;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header,
|
||||||
|
.page-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<a href="?page=dashboard" class="d-flex flex-items-center text-decoration-none">
|
||||||
|
<svg class="octicon color-fg-accent" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="24" height="24">
|
||||||
|
<path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-2 f3 text-bold color-fg-default">Pathvector</span>
|
||||||
|
</a>
|
||||||
|
<div class="text-small color-fg-muted mt-1">BGP Administration</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav">
|
||||||
|
<div class="nav-section">Overview</div>
|
||||||
|
<a href="?page=dashboard" class="nav-item <?= $page === 'dashboard' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8.156 1.835a.25.25 0 00-.312 0l-5.25 4.2a.25.25 0 00-.094.196v7.019c0 .138.112.25.25.25H5.5V8.25a.75.75 0 01.75-.75h3.5a.75.75 0 01.75.75v5.25h2.75a.25.25 0 00.25-.25V6.23a.25.25 0 00-.094-.195l-5.25-4.2zM6.906.664a1.75 1.75 0 012.188 0l5.25 4.2c.415.332.656.835.656 1.367v7.019A1.75 1.75 0 0113.25 15h-2.5a.75.75 0 01-.75-.75V9H6v5.25a.75.75 0 01-.75.75h-2.5A1.75 1.75 0 011 13.25V6.23c0-.531.24-1.034.656-1.366l5.25-4.2z"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nav-section mt-3">Configuration</div>
|
||||||
|
<a href="?page=asns" class="nav-item <?= $page === 'asns' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 14.25c0 .138.112.25.25.25H4v-1.25a.75.75 0 01.75-.75h2.5a.75.75 0 01.75.75v1.25h2.25a.25.25 0 00.25-.25V1.75a.25.25 0 00-.25-.25h-8.5a.25.25 0 00-.25.25v12.5zM1.75 16A1.75 1.75 0 010 14.25V1.75C0 .784.784 0 1.75 0h8.5C11.216 0 12 .784 12 1.75v12.5c0 .085-.006.168-.018.25h2.268a.25.25 0 00.25-.25V8.285a.25.25 0 00-.111-.208l-1.055-.703a.75.75 0 11.832-1.248l1.055.703c.487.325.779.871.779 1.456v5.965A1.75 1.75 0 0114.25 16h-3.5a.75.75 0 01-.197-.026c-.099.017-.2.026-.303.026h-3a.75.75 0 01-.75-.75V14h-1v1.25a.75.75 0 01-.75.75h-3zM3 3.75A.75.75 0 013.75 3h.5a.75.75 0 010 1.5h-.5A.75.75 0 013 3.75zM3.75 6a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zM3 9.75A.75.75 0 013.75 9h.5a.75.75 0 010 1.5h-.5A.75.75 0 013 9.75zM7.75 9a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zM7 6.75A.75.75 0 017.75 6h.5a.75.75 0 010 1.5h-.5A.75.75 0 017 6.75zM7.75 3a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5z"/></svg>
|
||||||
|
ASNs
|
||||||
|
</a>
|
||||||
|
<a href="?page=nodes" class="nav-item <?= $page === 'nodes' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.75 1A1.75 1.75 0 000 2.75v4c0 .372.116.717.314 1a1.742 1.742 0 00-.314 1v4c0 .966.784 1.75 1.75 1.75h12.5A1.75 1.75 0 0016 12.75v-4c0-.372-.116-.717-.314-1 .198-.283.314-.628.314-1v-4A1.75 1.75 0 0014.25 1H1.75zm0 7.5a.25.25 0 00-.25.25v4c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25v-4a.25.25 0 00-.25-.25H1.75zM1.5 2.75a.25.25 0 01.25-.25h12.5a.25.25 0 01.25.25v4a.25.25 0 01-.25.25H1.75a.25.25 0 01-.25-.25v-4zm5.5 2A.75.75 0 017.75 4h4.5a.75.75 0 010 1.5h-4.5A.75.75 0 017 4.75zM7.75 10a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-4.5zM3 4.75A.75.75 0 013.75 4h.5a.75.75 0 010 1.5h-.5A.75.75 0 013 4.75zM3.75 10a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5z"/></svg>
|
||||||
|
Nodes
|
||||||
|
</a>
|
||||||
|
<a href="?page=peers" class="nav-item <?= $page === 'peers' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M11.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122V6A2.5 2.5 0 0110 8.5H6a1 1 0 00-1 1v1.128a2.251 2.251 0 11-1.5 0V5.372a2.25 2.25 0 111.5 0v1.836A2.492 2.492 0 016 7h4a1 1 0 001-1v-.628A2.25 2.25 0 019.5 3.25zM4.25 12a.75.75 0 100 1.5.75.75 0 000-1.5zM3.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0z"/></svg>
|
||||||
|
Peers
|
||||||
|
</a>
|
||||||
|
<a href="?page=templates" class="nav-item <?= $page === 'templates' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0114.25 15h-9a.75.75 0 010-1.5h9a.25.25 0 00.25-.25V6h-2.75A1.75 1.75 0 0110 4.25V1.5H5.75a.25.25 0 00-.25.25v2.5a.75.75 0 01-1.5 0v-2.5zm7.5-.188V4.25c0 .138.112.25.25.25h2.688a.252.252 0 00-.011-.013l-2.914-2.914a.272.272 0 00-.013-.011zM5.72 6.72a.75.75 0 000 1.06l1.47 1.47-1.47 1.47a.75.75 0 101.06 1.06l2-2a.75.75 0 000-1.06l-2-2a.75.75 0 00-1.06 0zM3.28 7.78a.75.75 0 00-1.06-1.06l-2 2a.75.75 0 000 1.06l2 2a.75.75 0 001.06-1.06L1.81 9.25l1.47-1.47z"/></svg>
|
||||||
|
Templates
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<?php if (hasPermission('manage_hosts')): ?>
|
||||||
|
<div class="nav-section mt-3">Operations</div>
|
||||||
|
<a href="?page=hosts" class="nav-item <?= $page === 'hosts' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25V2.75zm1.75-.25a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25H1.75zM7.25 8a.75.75 0 01-.22.53l-2.25 2.25a.75.75 0 11-1.06-1.06L5.44 8 3.72 6.28a.75.75 0 111.06-1.06l2.25 2.25c.141.14.22.331.22.53zm1.5 1.5a.75.75 0 000 1.5h3a.75.75 0 000-1.5h-3z"/></svg>
|
||||||
|
Hosts
|
||||||
|
</a>
|
||||||
|
<a href="?page=config" class="nav-item <?= $page === 'config' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.75 1.5a.25.25 0 00-.25.25v11.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25V6H9.75A1.75 1.75 0 018 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V1.75z"/></svg>
|
||||||
|
Config Preview
|
||||||
|
</a>
|
||||||
|
<a href="?page=execute" class="nav-item <?= $page === 'execute' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM6.379 5.227A.25.25 0 006 5.442v5.117a.25.25 0 00.379.214l4.264-2.559a.25.25 0 000-.428L6.379 5.227z"/></svg>
|
||||||
|
Execute
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="nav-section mt-3">System</div>
|
||||||
|
<a href="?page=logs" class="nav-item <?= $page === 'logs' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/></svg>
|
||||||
|
Logs
|
||||||
|
</a>
|
||||||
|
<?php if (hasPermission('manage_backups')): ?>
|
||||||
|
<a href="?page=backups" class="nav-item <?= $page === 'backups' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M3.5 8a.5.5 0 01-.5-.5v-5a.5.5 0 01.5-.5h9a.5.5 0 01.5.5v5a.5.5 0 01-.5.5h-9zm-2 .5A1.5 1.5 0 003 10v4.5a.5.5 0 00.5.5h9a.5.5 0 00.5-.5V10a1.5 1.5 0 001.5-1.5v-6A1.5 1.5 0 0013 1H3a1.5 1.5 0 00-1.5 1.5v6zM4.5 11a.5.5 0 000 1h7a.5.5 0 000-1h-7z"/></svg>
|
||||||
|
Backups
|
||||||
|
</a>
|
||||||
|
<a href="?page=users" class="nav-item <?= $page === 'users' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M5.5 3.5a2 2 0 100 4 2 2 0 000-4zM2 5.5a3.5 3.5 0 115.898 2.549 5.507 5.507 0 013.034 4.084.75.75 0 11-1.482.235 4.001 4.001 0 00-7.9 0 .75.75 0 01-1.482-.236A5.507 5.507 0 013.102 8.05 3.49 3.49 0 012 5.5zM11 4a.75.75 0 100 1.5 1.5 1.5 0 01.666 2.844.75.75 0 00-.416.672v.352a.75.75 0 00.574.73c1.2.289 2.162 1.2 2.522 2.372a.75.75 0 101.434-.44 5.01 5.01 0 00-2.56-3.012A3 3 0 0011 4z"/></svg>
|
||||||
|
Users
|
||||||
|
</a>
|
||||||
|
<a href="?page=settings" class="nav-item <?= $page === 'settings' ? 'active' : '' ?>">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.014.031.022.11-.059.19l-.815.806c-.411.406-.562.957-.53 1.456a4.588 4.588 0 010 .582c-.032.499.119 1.05.53 1.456l.815.806c.08.08.073.159.059.19a6.494 6.494 0 01-.573.99c-.02.029-.086.074-.195.045l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 01-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.492 6.492 0 01-.573-.989c-.014-.031-.022-.11.059-.19l.815-.806c.411-.406.562-.957.53-1.456a4.587 4.587 0 010-.582c.032-.499-.119-1.05-.53-1.456l-.815-.806c-.08-.08-.073-.159-.059-.19a6.44 6.44 0 01.573-.99c.02-.029.086-.074.195-.045l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 0c-.236 0-.47.01-.701.03-.743.065-1.29.615-1.458 1.261l-.29 1.106c-.017.066-.078.158-.211.224a5.994 5.994 0 00-.668.386c-.123.082-.233.09-.3.071L3.27 2.776c-.644-.177-1.392.02-1.82.63a7.977 7.977 0 00-.704 1.217c-.315.675-.111 1.422.363 1.891l.815.806c.05.048.098.147.088.294a6.084 6.084 0 000 .772c.01.147-.038.246-.088.294l-.815.806c-.474.469-.678 1.216-.363 1.891.2.428.436.835.704 1.218.428.609 1.176.806 1.82.63l1.103-.303c.066-.019.176-.011.299.071.213.143.436.272.668.386.133.066.194.158.212.224l.289 1.106c.169.646.715 1.196 1.458 1.26a8.094 8.094 0 001.402 0c.743-.064 1.29-.614 1.458-1.26l.29-1.106c.017-.066.078-.158.211-.224a5.98 5.98 0 00.668-.386c.123-.082.233-.09.3-.071l1.102.302c.644.177 1.392-.02 1.82-.63.268-.382.505-.789.704-1.217.315-.675.111-1.422-.364-1.891l-.814-.806c-.05-.048-.098-.147-.088-.294a6.1 6.1 0 000-.772c-.01-.147.039-.246.088-.294l.814-.806c.475-.469.679-1.216.364-1.891a7.992 7.992 0 00-.704-1.218c-.428-.609-1.176-.806-1.82-.63l-1.103.303c-.066.019-.176.011-.299-.071a5.991 5.991 0 00-.668-.386c-.133-.066-.194-.158-.212-.224L10.16 1.29C9.99.645 9.444.095 8.701.031A8.094 8.094 0 008 0zm1.5 8a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM11 8a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="d-flex flex-items-center">
|
||||||
|
<div class="avatar avatar-2 mr-2 color-bg-accent-emphasis color-fg-on-emphasis d-flex flex-items-center flex-justify-center" style="border-radius: 50%;">
|
||||||
|
<?= strtoupper(substr($currentUser['username'], 0, 1)) ?>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-width-0">
|
||||||
|
<div class="text-bold text-small"><?= e($currentUser['username']) ?></div>
|
||||||
|
<div class="text-small color-fg-muted"><?= ucfirst($currentUser['role']) ?></div>
|
||||||
|
</div>
|
||||||
|
<a href="?action=logout" class="btn-octicon" title="Logout">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M2 2.75C2 1.784 2.784 1 3.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5a.75.75 0 01-1.5 0v-5.5a.25.25 0 00-.25-.25h-8.5a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h8.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 0112.25 15h-8.5A1.75 1.75 0 012 13.25V2.75zm10.44 4.5H6.75a.75.75 0 000 1.5h5.69l-1.97 1.97a.75.75 0 101.06 1.06l3.25-3.25a.75.75 0 000-1.06l-3.25-3.25a.75.75 0 10-1.06 1.06l1.97 1.97z"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<?php
|
||||||
|
// Include the page file
|
||||||
|
$pageFile = __DIR__ . '/' . $currentPage['file'];
|
||||||
|
if (file_exists($pageFile)) {
|
||||||
|
include $pageFile;
|
||||||
|
} else {
|
||||||
|
?>
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="h2"><?= e($currentPage['title']) ?></h1>
|
||||||
|
</div>
|
||||||
|
<div class="page-content">
|
||||||
|
<div class="flash flash-error">
|
||||||
|
<p>Page not found or not yet implemented.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div id="toast-container" class="toast-container"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toast notification function
|
||||||
|
function showToast(message, type = 'default') {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
|
||||||
|
const typeClasses = {
|
||||||
|
'success': 'flash-success',
|
||||||
|
'error': 'flash-error',
|
||||||
|
'warning': 'flash-warn',
|
||||||
|
'default': ''
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.className = 'toast flash ' + (typeClasses[type] || '');
|
||||||
|
toast.innerHTML = message;
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm delete helper
|
||||||
|
function confirmDelete(message) {
|
||||||
|
return confirm(message || 'Are you sure you want to delete this item?');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation helper
|
||||||
|
function validateForm(form) {
|
||||||
|
const required = form.querySelectorAll('[required]');
|
||||||
|
let valid = true;
|
||||||
|
|
||||||
|
required.forEach(field => {
|
||||||
|
if (!field.value.trim()) {
|
||||||
|
field.classList.add('border-danger');
|
||||||
|
valid = false;
|
||||||
|
} else {
|
||||||
|
field.classList.remove('border-danger');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-dismiss flash messages
|
||||||
|
document.querySelectorAll('.flash[data-dismiss]').forEach(flash => {
|
||||||
|
setTimeout(() => {
|
||||||
|
flash.style.opacity = '0';
|
||||||
|
setTimeout(() => flash.remove(), 300);
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
446
pathvector-admin/lib/ASN.php
Normal file
446
pathvector-admin/lib/ASN.php
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* ASN Class
|
||||||
|
*
|
||||||
|
* Manages ASN (Autonomous System Number) configurations for multi-ASN deployments
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/FlatFileDB.php';
|
||||||
|
require_once __DIR__ . '/Logger.php';
|
||||||
|
require_once __DIR__ . '/Validator.php';
|
||||||
|
|
||||||
|
class ASN {
|
||||||
|
private FlatFileDB $db;
|
||||||
|
private Logger $logger;
|
||||||
|
private Validator $validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $dataFile Path to ASNs JSON file
|
||||||
|
* @param Logger $logger Logger instance
|
||||||
|
*/
|
||||||
|
public function __construct(string $dataFile, Logger $logger) {
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
428
pathvector-admin/lib/Auth.php
Normal file
428
pathvector-admin/lib/Auth.php
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Auth Class
|
||||||
|
*
|
||||||
|
* Handles authentication and authorization for the Pathvector admin dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/FlatFileDB.php';
|
||||||
|
require_once __DIR__ . '/Logger.php';
|
||||||
|
|
||||||
|
class Auth {
|
||||||
|
private FlatFileDB $db;
|
||||||
|
private Logger $logger;
|
||||||
|
private array $config;
|
||||||
|
private array $roles = [
|
||||||
|
'admin' => ['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;
|
||||||
|
}
|
||||||
|
}
|
||||||
457
pathvector-admin/lib/BirdConfig.php
Normal file
457
pathvector-admin/lib/BirdConfig.php
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* BirdConfig Class
|
||||||
|
*
|
||||||
|
* Helper class for BIRD 3 configuration parsing and generation
|
||||||
|
*/
|
||||||
|
|
||||||
|
class BirdConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse BIRD protocol status output
|
||||||
|
*
|
||||||
|
* @param string $output
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function parseProtocolStatus(string $output): array {
|
||||||
|
$protocols = [];
|
||||||
|
$lines = explode("\n", trim($output));
|
||||||
|
|
||||||
|
// Skip header lines
|
||||||
|
$dataStarted = false;
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
|
||||||
|
if (empty($line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip BIRD header
|
||||||
|
if (strpos($line, 'BIRD') === 0 || strpos($line, 'Name') === 0) {
|
||||||
|
$dataStarted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$dataStarted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse protocol line
|
||||||
|
// Format: Name Proto Table State Since Info
|
||||||
|
$parts = preg_split('/\s+/', $line, 6);
|
||||||
|
|
||||||
|
if (count($parts) >= 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
218
pathvector-admin/lib/FlatFileDB.php
Normal file
218
pathvector-admin/lib/FlatFileDB.php
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* FlatFileDB Class
|
||||||
|
*
|
||||||
|
* Handles all flat-file JSON storage operations for the Pathvector admin dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
class FlatFileDB {
|
||||||
|
private string $filePath;
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $filePath Path to the JSON file
|
||||||
|
*/
|
||||||
|
public function __construct(string $filePath) {
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
559
pathvector-admin/lib/Host.php
Normal file
559
pathvector-admin/lib/Host.php
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Host Class
|
||||||
|
*
|
||||||
|
* Manages execution hosts for Pathvector operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/FlatFileDB.php';
|
||||||
|
require_once __DIR__ . '/Logger.php';
|
||||||
|
require_once __DIR__ . '/Validator.php';
|
||||||
|
|
||||||
|
class Host {
|
||||||
|
private FlatFileDB $db;
|
||||||
|
private Logger $logger;
|
||||||
|
private Validator $validator;
|
||||||
|
|
||||||
|
// Execution methods
|
||||||
|
public const METHOD_LOCAL = 'local';
|
||||||
|
public const METHOD_SSH = 'ssh';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $dataFile Path to hosts JSON file
|
||||||
|
* @param Logger $logger Logger instance
|
||||||
|
*/
|
||||||
|
public function __construct(string $dataFile, Logger $logger) {
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
285
pathvector-admin/lib/Logger.php
Normal file
285
pathvector-admin/lib/Logger.php
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Logger Class
|
||||||
|
*
|
||||||
|
* Handles audit logging for the Pathvector admin dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/FlatFileDB.php';
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
private FlatFileDB $db;
|
||||||
|
private int $maxEntries;
|
||||||
|
private array $validLevels = ['info', 'warning', 'error', 'success'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $logFile Path to the log file
|
||||||
|
* @param int $maxEntries Maximum number of log entries to keep
|
||||||
|
*/
|
||||||
|
public function __construct(string $logFile, int $maxEntries = 1000) {
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
516
pathvector-admin/lib/Node.php
Normal file
516
pathvector-admin/lib/Node.php
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Node Class
|
||||||
|
*
|
||||||
|
* Manages router/node configurations within ASNs
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/FlatFileDB.php';
|
||||||
|
require_once __DIR__ . '/Logger.php';
|
||||||
|
require_once __DIR__ . '/Validator.php';
|
||||||
|
|
||||||
|
class Node {
|
||||||
|
private FlatFileDB $db;
|
||||||
|
private Logger $logger;
|
||||||
|
private Validator $validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $dataFile Path to nodes JSON file
|
||||||
|
* @param Logger $logger Logger instance
|
||||||
|
*/
|
||||||
|
public function __construct(string $dataFile, Logger $logger) {
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1046
pathvector-admin/lib/Pathvector.php
Normal file
1046
pathvector-admin/lib/Pathvector.php
Normal file
File diff suppressed because it is too large
Load Diff
689
pathvector-admin/lib/Peer.php
Normal file
689
pathvector-admin/lib/Peer.php
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Peer Class
|
||||||
|
*
|
||||||
|
* Manages BGP peer configurations with full Pathvector options support
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/FlatFileDB.php';
|
||||||
|
require_once __DIR__ . '/Logger.php';
|
||||||
|
require_once __DIR__ . '/Validator.php';
|
||||||
|
|
||||||
|
class Peer {
|
||||||
|
private FlatFileDB $db;
|
||||||
|
private Logger $logger;
|
||||||
|
private Validator $validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $dataFile Path to peers JSON file
|
||||||
|
* @param Logger $logger Logger instance
|
||||||
|
*/
|
||||||
|
public function __construct(string $dataFile, Logger $logger) {
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
562
pathvector-admin/lib/Template.php
Normal file
562
pathvector-admin/lib/Template.php
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Template Class
|
||||||
|
*
|
||||||
|
* Manages reusable templates for ASNs, nodes, and peers
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/FlatFileDB.php';
|
||||||
|
require_once __DIR__ . '/Logger.php';
|
||||||
|
require_once __DIR__ . '/Validator.php';
|
||||||
|
|
||||||
|
class Template {
|
||||||
|
private FlatFileDB $db;
|
||||||
|
private Logger $logger;
|
||||||
|
private Validator $validator;
|
||||||
|
|
||||||
|
// Template types
|
||||||
|
public const TYPE_PEER = 'peer';
|
||||||
|
public const TYPE_ASN = 'asn';
|
||||||
|
public const TYPE_NODE = 'node';
|
||||||
|
public const TYPE_POLICY = 'policy';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param string $dataFile Path to templates JSON file
|
||||||
|
* @param Logger $logger Logger instance
|
||||||
|
*/
|
||||||
|
public function __construct(string $dataFile, Logger $logger) {
|
||||||
|
$this->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('<pathvector.' . $key . '>', $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);
|
||||||
|
}
|
||||||
|
}
|
||||||
638
pathvector-admin/lib/Validator.php
Normal file
638
pathvector-admin/lib/Validator.php
Normal file
@@ -0,0 +1,638 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Validator Class
|
||||||
|
*
|
||||||
|
* Validates Pathvector configurations and ensures correctness before generating BIRD 3 config
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Validator {
|
||||||
|
private array $errors = [];
|
||||||
|
private array $warnings = [];
|
||||||
|
|
||||||
|
// Valid ASN ranges
|
||||||
|
private const ASN_MIN = 1;
|
||||||
|
private const ASN_MAX = 4294967295;
|
||||||
|
private const PRIVATE_ASN_START_16 = 64512;
|
||||||
|
private const PRIVATE_ASN_END_16 = 65534;
|
||||||
|
private const PRIVATE_ASN_START_32 = 4200000000;
|
||||||
|
private const PRIVATE_ASN_END_32 = 4294967294;
|
||||||
|
|
||||||
|
// Valid BGP roles (RFC 9234)
|
||||||
|
private const VALID_BGP_ROLES = ['provider', 'rs-server', 'rs-client', 'customer', 'peer'];
|
||||||
|
|
||||||
|
// Valid limit violation actions
|
||||||
|
private const VALID_LIMIT_ACTIONS = ['disable', 'restart', 'block', 'warn'];
|
||||||
|
|
||||||
|
// Transit-free ASNs (tier-1 networks)
|
||||||
|
private const TRANSIT_FREE_ASNS = [
|
||||||
|
174, 209, 286, 701, 1239, 1299, 2828, 2914, 3257, 3320, 3356, 3491,
|
||||||
|
5511, 6453, 6461, 6762, 6830, 7018, 12956
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get validation errors
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getErrors(): array {
|
||||||
|
return $this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
181
pathvector-admin/login.php
Normal file
181
pathvector-admin/login.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Pathvector Admin - Login Page
|
||||||
|
*/
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config/config.php';
|
||||||
|
require_once __DIR__ . '/lib/FlatFileDB.php';
|
||||||
|
require_once __DIR__ . '/lib/Logger.php';
|
||||||
|
require_once __DIR__ . '/lib/Auth.php';
|
||||||
|
|
||||||
|
$db = new FlatFileDB(DATA_PATH);
|
||||||
|
$logger = new Logger($db);
|
||||||
|
$auth = new Auth($db);
|
||||||
|
|
||||||
|
// Redirect if already logged in
|
||||||
|
if ($auth->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark_dimmed">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login - Pathvector Admin</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@primer/css@^20.2.4/dist/primer.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-canvas-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--color-accent-emphasis);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background: var(--color-canvas-default);
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="login-logo">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="h3">Pathvector Admin</h1>
|
||||||
|
<p class="color-fg-muted">BGP Configuration Dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-box">
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="flash flash-error mb-3">
|
||||||
|
<?= htmlspecialchars($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" action="">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= htmlspecialchars($username) ?>"
|
||||||
|
autocomplete="username"
|
||||||
|
autofocus
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<p>Pathvector Admin Dashboard</p>
|
||||||
|
<p>Need access? Contact your administrator.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
610
pathvector-admin/pages/asns.php
Normal file
610
pathvector-admin/pages/asns.php
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* ASNs Management Page
|
||||||
|
*/
|
||||||
|
|
||||||
|
$asnManager = new ASN($db);
|
||||||
|
$validator = new Validator();
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? 'list';
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
$message = '';
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
// Handle form submissions
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!$auth->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();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($action === 'list'): ?>
|
||||||
|
<!-- List View -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="d-flex flex-items-center flex-justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-1">ASNs</h1>
|
||||||
|
<p class="color-fg-muted mb-0">Manage your Autonomous System Numbers</p>
|
||||||
|
</div>
|
||||||
|
<?php if (hasPermission('create_peers')): ?>
|
||||||
|
<a href="?page=asns&action=create" class="btn btn-primary">
|
||||||
|
<svg class="octicon mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 110 1.5H8.5v4.25a.75.75 0 11-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||||
|
New ASN
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<?php if ($message): ?>
|
||||||
|
<div class="flash flash-success mb-3">
|
||||||
|
<?= e($message) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="flash flash-error mb-3">
|
||||||
|
<?= e($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (empty($asns)): ?>
|
||||||
|
<div class="blankslate">
|
||||||
|
<svg class="octicon blankslate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="24" height="24"><path fill-rule="evenodd" d="M1.5 14.25c0 .138.112.25.25.25H4v-1.25a.75.75 0 01.75-.75h2.5a.75.75 0 01.75.75v1.25h2.25a.25.25 0 00.25-.25V1.75a.25.25 0 00-.25-.25h-8.5a.25.25 0 00-.25.25v12.5z"/></svg>
|
||||||
|
<h3 class="blankslate-heading">No ASNs configured</h3>
|
||||||
|
<p>Get started by creating your first ASN.</p>
|
||||||
|
<?php if (hasPermission('create_peers')): ?>
|
||||||
|
<a href="?page=asns&action=create" class="btn btn-primary">Create ASN</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="Box">
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ASN</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Router ID</th>
|
||||||
|
<th>Nodes</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php
|
||||||
|
$nodeManager = new Node($db);
|
||||||
|
foreach ($asns as $asn):
|
||||||
|
$asnNodes = $nodeManager->getByAsn($asn['id']);
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="Label Label--accent">AS<?= e($asn['asn']) ?></span>
|
||||||
|
</td>
|
||||||
|
<td class="text-bold"><?= e($asn['name']) ?></td>
|
||||||
|
<td class="color-fg-muted"><?= e($asn['description'] ?? '-') ?></td>
|
||||||
|
<td>
|
||||||
|
<code><?= e($asn['defaults']['router_id'] ?? '-') ?></code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="Counter"><?= count($asnNodes) ?></span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<a href="?page=asns&action=edit&id=<?= e($asn['id']) ?>" class="btn btn-sm">Edit</a>
|
||||||
|
<?php if (hasPermission('delete_peers')): ?>
|
||||||
|
<form method="POST" class="d-inline" onsubmit="return confirmDelete('Are you sure you want to delete AS<?= e($asn['asn']) ?>?')">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="id" value="<?= e($asn['id']) ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php elseif ($action === 'create' || $action === 'edit'): ?>
|
||||||
|
<!-- Create/Edit Form -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="d-flex flex-items-center">
|
||||||
|
<a href="?page=asns" class="btn-octicon mr-2">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.78 12.53a.75.75 0 01-1.06 0L2.47 8.28a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L4.81 7h7.44a.75.75 0 010 1.5H4.81l2.97 2.97a.75.75 0 010 1.06z"/></svg>
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-1"><?= $action === 'edit' ? 'Edit ASN' : 'New ASN' ?></h1>
|
||||||
|
<p class="color-fg-muted mb-0">
|
||||||
|
<?= $action === 'edit' ? 'AS' . e($editAsn['asn']) . ' - ' . e($editAsn['name']) : 'Configure a new Autonomous System Number' ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="flash flash-error mb-3">
|
||||||
|
<?= e($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" action="">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="<?= $action === 'edit' ? 'update' : 'create' ?>">
|
||||||
|
<?php if ($editAsn): ?>
|
||||||
|
<input type="hidden" name="id" value="<?= e($editAsn['id']) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Basic Information</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="asn">ASN Number *</label>
|
||||||
|
<input type="number"
|
||||||
|
id="asn"
|
||||||
|
name="asn"
|
||||||
|
class="form-control"
|
||||||
|
style="max-width: 200px;"
|
||||||
|
value="<?= e($editAsn['asn'] ?? '') ?>"
|
||||||
|
min="1"
|
||||||
|
max="4294967295"
|
||||||
|
<?= $editAsn ? 'readonly' : 'required' ?>>
|
||||||
|
<p class="form-hint">Your Autonomous System Number (e.g., 65530)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="name">Name *</label>
|
||||||
|
<input type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
class="form-control form-input-wide"
|
||||||
|
value="<?= e($editAsn['name'] ?? '') ?>"
|
||||||
|
required>
|
||||||
|
<p class="form-hint">A friendly name for this ASN</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="description">Description</label>
|
||||||
|
<textarea id="description"
|
||||||
|
name="description"
|
||||||
|
class="form-control form-input-wide"
|
||||||
|
rows="2"><?= e($editAsn['description'] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Network Configuration</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 16px;">
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="router_id">Router ID</label>
|
||||||
|
<input type="text"
|
||||||
|
id="router_id"
|
||||||
|
name="router_id"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['router_id'] ?? '') ?>"
|
||||||
|
placeholder="e.g., 192.0.2.1">
|
||||||
|
<p class="form-hint">Default router ID for all nodes</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="source4">Source IPv4</label>
|
||||||
|
<input type="text"
|
||||||
|
id="source4"
|
||||||
|
name="source4"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['source4'] ?? '') ?>"
|
||||||
|
placeholder="e.g., 192.0.2.1">
|
||||||
|
<p class="form-hint">Default source address for IPv4</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="source6">Source IPv6</label>
|
||||||
|
<input type="text"
|
||||||
|
id="source6"
|
||||||
|
name="source6"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['source6'] ?? '') ?>"
|
||||||
|
placeholder="e.g., 2001:db8::1">
|
||||||
|
<p class="form-hint">Default source address for IPv6</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="prefixes4">IPv4 Prefixes</label>
|
||||||
|
<input type="text"
|
||||||
|
id="prefixes4"
|
||||||
|
name="prefixes4"
|
||||||
|
class="form-control form-input-wide"
|
||||||
|
value="<?= e(implode(', ', $editAsn['defaults']['prefixes'] ?? [])) ?>"
|
||||||
|
placeholder="192.0.2.0/24, 198.51.100.0/24">
|
||||||
|
<p class="form-hint">Comma-separated list of IPv4 prefixes you originate</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="prefixes6">IPv6 Prefixes</label>
|
||||||
|
<input type="text"
|
||||||
|
id="prefixes6"
|
||||||
|
name="prefixes6"
|
||||||
|
class="form-control form-input-wide"
|
||||||
|
value="<?= e(implode(', ', $editAsn['defaults']['prefixes6'] ?? [])) ?>"
|
||||||
|
placeholder="2001:db8::/32">
|
||||||
|
<p class="form-hint">Comma-separated list of IPv6 prefixes you originate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Filtering Options</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 16px;">
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="irr_server">IRR Server</label>
|
||||||
|
<input type="text"
|
||||||
|
id="irr_server"
|
||||||
|
name="irr_server"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['irr_server'] ?? 'rr.ntt.net') ?>"
|
||||||
|
placeholder="rr.ntt.net">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="rtr_server">RTR Server (RPKI)</label>
|
||||||
|
<input type="text"
|
||||||
|
id="rtr_server"
|
||||||
|
name="rtr_server"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['rtr_server'] ?? '') ?>"
|
||||||
|
placeholder="rtr.example.com:8282">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 24px;">
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="rpki_filter"
|
||||||
|
value="1"
|
||||||
|
<?= ($editAsn['defaults']['rpki_filter'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
Enable RPKI filtering
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="irr_filter"
|
||||||
|
value="1"
|
||||||
|
<?= ($editAsn['defaults']['irr_filter'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
Enable IRR filtering
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="keep_filtered"
|
||||||
|
value="1"
|
||||||
|
<?= ($editAsn['defaults']['keep_filtered'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
Keep filtered routes
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap mt-3" style="gap: 16px;">
|
||||||
|
<div class="form-group flex-1" style="min-width: 150px;">
|
||||||
|
<label class="form-label" for="default_route_limit4">Default Route Limit (IPv4)</label>
|
||||||
|
<input type="number"
|
||||||
|
id="default_route_limit4"
|
||||||
|
name="default_route_limit4"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['default_route_limit4'] ?? '') ?>"
|
||||||
|
min="0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 150px;">
|
||||||
|
<label class="form-label" for="default_route_limit6">Default Route Limit (IPv6)</label>
|
||||||
|
<input type="number"
|
||||||
|
id="default_route_limit6"
|
||||||
|
name="default_route_limit6"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['default_route_limit6'] ?? '') ?>"
|
||||||
|
min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Pathvector Paths</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 16px;">
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="bird_directory">BIRD Directory</label>
|
||||||
|
<input type="text"
|
||||||
|
id="bird_directory"
|
||||||
|
name="bird_directory"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['bird_directory'] ?? '/etc/bird') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="bird_socket">BIRD Socket</label>
|
||||||
|
<input type="text"
|
||||||
|
id="bird_socket"
|
||||||
|
name="bird_socket"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['bird_socket'] ?? '/run/bird/bird.ctl') ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 16px;">
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="cache_directory">Cache Directory</label>
|
||||||
|
<input type="text"
|
||||||
|
id="cache_directory"
|
||||||
|
name="cache_directory"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['cache_directory'] ?? '/var/cache/pathvector') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="bgpq_path">BGPQ4 Path</label>
|
||||||
|
<input type="text"
|
||||||
|
id="bgpq_path"
|
||||||
|
name="bgpq_path"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editAsn['defaults']['bgpq_path'] ?? 'bgpq4') ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Advanced Options</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 24px;">
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="merge_paths"
|
||||||
|
value="1"
|
||||||
|
<?= ($editAsn['defaults']['merge_paths'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
Enable merge paths (ADD-PATH)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="transit_locking"
|
||||||
|
value="1"
|
||||||
|
<?= ($editAsn['defaults']['transit_locking'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
Enable transit locking
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="graceful_shutdown"
|
||||||
|
value="1"
|
||||||
|
<?= ($editAsn['defaults']['graceful_shutdown'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
Enable graceful shutdown
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="no_announce"
|
||||||
|
value="1"
|
||||||
|
<?= ($editAsn['defaults']['no_announce'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
No announce (disable route announcements)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-items-center" style="gap: 8px;">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<?= $action === 'edit' ? 'Update ASN' : 'Create ASN' ?>
|
||||||
|
</button>
|
||||||
|
<a href="?page=asns" class="btn">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
314
pathvector-admin/pages/dashboard.php
Normal file
314
pathvector-admin/pages/dashboard.php
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Dashboard Page
|
||||||
|
* Main overview with statistics and quick actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initialize managers
|
||||||
|
$asnManager = new ASN($db);
|
||||||
|
$nodeManager = new Node($db);
|
||||||
|
$peerManager = new Peer($db);
|
||||||
|
$templateManager = new Template($db);
|
||||||
|
$hostManager = new Host($db);
|
||||||
|
|
||||||
|
// Get statistics
|
||||||
|
$asns = $asnManager->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);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="d-flex flex-items-center flex-justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-1">Dashboard</h1>
|
||||||
|
<p class="color-fg-muted mb-0">Overview of your BGP configuration</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<?php if (hasPermission('execute_commands')): ?>
|
||||||
|
<a href="?page=execute" class="btn btn-primary">
|
||||||
|
<svg class="octicon mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM6.379 5.227A.25.25 0 006 5.442v5.117a.25.25 0 00.379.214l4.264-2.559a.25.25 0 000-.428L6.379 5.227z"/></svg>
|
||||||
|
Execute
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 16px; margin-bottom: 24px;">
|
||||||
|
<div class="stat-card flex-1" style="min-width: 200px;">
|
||||||
|
<div class="d-flex flex-items-center">
|
||||||
|
<div class="mr-3">
|
||||||
|
<svg class="octicon color-fg-muted" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="32" height="32"><path fill-rule="evenodd" d="M1.5 14.25c0 .138.112.25.25.25H4v-1.25a.75.75 0 01.75-.75h2.5a.75.75 0 01.75.75v1.25h2.25a.25.25 0 00.25-.25V1.75a.25.25 0 00-.25-.25h-8.5a.25.25 0 00-.25.25v12.5zM1.75 16A1.75 1.75 0 010 14.25V1.75C0 .784.784 0 1.75 0h8.5C11.216 0 12 .784 12 1.75v12.5c0 .085-.006.168-.018.25h2.268a.25.25 0 00.25-.25V8.285a.25.25 0 00-.111-.208l-1.055-.703a.75.75 0 11.832-1.248l1.055.703c.487.325.779.871.779 1.456v5.965A1.75 1.75 0 0114.25 16h-3.5a.75.75 0 01-.197-.026c-.099.017-.2.026-.303.026h-3a.75.75 0 01-.75-.75V14h-1v1.25a.75.75 0 01-.75.75h-3z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value"><?= count($asns) ?></div>
|
||||||
|
<div class="stat-label">ASNs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card flex-1" style="min-width: 200px;">
|
||||||
|
<div class="d-flex flex-items-center">
|
||||||
|
<div class="mr-3">
|
||||||
|
<svg class="octicon color-fg-muted" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="32" height="32"><path fill-rule="evenodd" d="M1.75 1A1.75 1.75 0 000 2.75v4c0 .372.116.717.314 1a1.742 1.742 0 00-.314 1v4c0 .966.784 1.75 1.75 1.75h12.5A1.75 1.75 0 0016 12.75v-4c0-.372-.116-.717-.314-1 .198-.283.314-.628.314-1v-4A1.75 1.75 0 0014.25 1H1.75z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value"><?= count($nodes) ?></div>
|
||||||
|
<div class="stat-label">Nodes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card flex-1" style="min-width: 200px;">
|
||||||
|
<div class="d-flex flex-items-center">
|
||||||
|
<div class="mr-3">
|
||||||
|
<svg class="octicon color-fg-muted" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="32" height="32"><path fill-rule="evenodd" d="M11.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122V6A2.5 2.5 0 0110 8.5H6a1 1 0 00-1 1v1.128a2.251 2.251 0 11-1.5 0V5.372a2.25 2.25 0 111.5 0v1.836A2.492 2.492 0 016 7h4a1 1 0 001-1v-.628A2.25 2.25 0 019.5 3.25z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value"><?= count($peers) ?></div>
|
||||||
|
<div class="stat-label">Peers</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card flex-1" style="min-width: 200px;">
|
||||||
|
<div class="d-flex flex-items-center">
|
||||||
|
<div class="mr-3">
|
||||||
|
<svg class="octicon color-fg-muted" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="32" height="32"><path fill-rule="evenodd" d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0114.25 15h-9a.75.75 0 010-1.5h9a.25.25 0 00.25-.25V6h-2.75A1.75 1.75 0 0110 4.25V1.5H5.75a.25.25 0 00-.25.25v2.5a.75.75 0 01-1.5 0v-2.5z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value"><?= count($templates) ?></div>
|
||||||
|
<div class="stat-label">Templates</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card flex-1" style="min-width: 200px;">
|
||||||
|
<div class="d-flex flex-items-center">
|
||||||
|
<div class="mr-3">
|
||||||
|
<svg class="octicon color-fg-muted" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="32" height="32"><path fill-rule="evenodd" d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25V2.75z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value"><?= count($hosts) ?></div>
|
||||||
|
<div class="stat-label">Hosts</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 24px;">
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="flex-1" style="min-width: 300px;">
|
||||||
|
<div class="Box">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Quick Actions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="d-flex flex-column" style="gap: 8px;">
|
||||||
|
<?php if (hasPermission('create_peers')): ?>
|
||||||
|
<a href="?page=asns&action=create" class="btn btn-outline btn-block text-left">
|
||||||
|
<svg class="octicon mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 110 1.5H8.5v4.25a.75.75 0 11-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||||
|
Add New ASN
|
||||||
|
</a>
|
||||||
|
<a href="?page=nodes&action=create" class="btn btn-outline btn-block text-left">
|
||||||
|
<svg class="octicon mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 110 1.5H8.5v4.25a.75.75 0 11-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||||
|
Add New Node
|
||||||
|
</a>
|
||||||
|
<a href="?page=peers&action=create" class="btn btn-outline btn-block text-left">
|
||||||
|
<svg class="octicon mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 110 1.5H8.5v4.25a.75.75 0 11-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||||
|
Add New Peer
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a href="?page=config" class="btn btn-outline btn-block text-left">
|
||||||
|
<svg class="octicon mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.679 7.932c.412-.621 1.242-1.75 2.366-2.717C5.175 4.242 6.527 3.5 8 3.5c1.473 0 2.824.742 3.955 1.715 1.124.967 1.954 2.096 2.366 2.717a.119.119 0 010 .136c-.412.621-1.242 1.75-2.366 2.717C10.825 11.758 9.473 12.5 8 12.5c-1.473 0-2.824-.742-3.955-1.715C2.92 9.818 2.09 8.69 1.679 8.068a.119.119 0 010-.136zM8 2c-1.981 0-3.67.992-4.933 2.078C1.797 5.169.88 6.423.43 7.1a1.619 1.619 0 000 1.798c.45.678 1.367 1.932 2.637 3.024C4.329 13.008 6.019 14 8 14c1.981 0 3.67-.992 4.933-2.078 1.27-1.091 2.187-2.345 2.637-3.023a1.619 1.619 0 000-1.798c-.45-.678-1.367-1.932-2.637-3.023C11.671 2.992 9.981 2 8 2zm0 8a2 2 0 100-4 2 2 0 000 4z"/></svg>
|
||||||
|
Preview Configuration
|
||||||
|
</a>
|
||||||
|
<?php if (hasPermission('execute_commands')): ?>
|
||||||
|
<a href="?page=execute&action=validate" class="btn btn-outline btn-block text-left">
|
||||||
|
<svg class="octicon mr-2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/></svg>
|
||||||
|
Validate Configuration
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent ASNs -->
|
||||||
|
<div class="flex-1" style="min-width: 300px;">
|
||||||
|
<div class="Box">
|
||||||
|
<div class="Box-header d-flex flex-items-center flex-justify-between">
|
||||||
|
<h3 class="Box-title">ASNs</h3>
|
||||||
|
<a href="?page=asns" class="Link--primary text-small">View all</a>
|
||||||
|
</div>
|
||||||
|
<?php if (empty($asns)): ?>
|
||||||
|
<div class="Box-body color-fg-muted">
|
||||||
|
No ASNs configured yet.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="list-style-none">
|
||||||
|
<?php foreach (array_slice($asns, 0, 5) as $asn): ?>
|
||||||
|
<li class="Box-row d-flex flex-items-center">
|
||||||
|
<span class="Label Label--secondary mr-2">AS<?= e($asn['asn']) ?></span>
|
||||||
|
<span class="flex-1"><?= e($asn['name']) ?></span>
|
||||||
|
<a href="?page=asns&action=edit&id=<?= e($asn['id']) ?>" class="Link--secondary text-small">Edit</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap mt-4" style="gap: 24px;">
|
||||||
|
<!-- Recent Nodes -->
|
||||||
|
<div class="flex-1" style="min-width: 300px;">
|
||||||
|
<div class="Box">
|
||||||
|
<div class="Box-header d-flex flex-items-center flex-justify-between">
|
||||||
|
<h3 class="Box-title">Nodes</h3>
|
||||||
|
<a href="?page=nodes" class="Link--primary text-small">View all</a>
|
||||||
|
</div>
|
||||||
|
<?php if (empty($nodes)): ?>
|
||||||
|
<div class="Box-body color-fg-muted">
|
||||||
|
No nodes configured yet.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="list-style-none">
|
||||||
|
<?php foreach (array_slice($nodes, 0, 5) as $node): ?>
|
||||||
|
<?php
|
||||||
|
$nodeAsn = $asnManager->get($node['asn_id']);
|
||||||
|
$nodePeers = array_filter($peers, fn($p) => $p['node_id'] === $node['id']);
|
||||||
|
?>
|
||||||
|
<li class="Box-row d-flex flex-items-center">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="text-bold"><?= e($node['name']) ?></div>
|
||||||
|
<div class="text-small color-fg-muted">
|
||||||
|
<?= $nodeAsn ? 'AS' . e($nodeAsn['asn']) : 'Unknown ASN' ?> •
|
||||||
|
<?= count($nodePeers) ?> peers
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="?page=nodes&action=edit&id=<?= e($node['id']) ?>" class="Link--secondary text-small">Edit</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="flex-1" style="min-width: 300px;">
|
||||||
|
<div class="Box">
|
||||||
|
<div class="Box-header d-flex flex-items-center flex-justify-between">
|
||||||
|
<h3 class="Box-title">Recent Activity</h3>
|
||||||
|
<a href="?page=logs" class="Link--primary text-small">View all</a>
|
||||||
|
</div>
|
||||||
|
<?php if (empty($recentLogs)): ?>
|
||||||
|
<div class="Box-body color-fg-muted">
|
||||||
|
No recent activity.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="list-style-none">
|
||||||
|
<?php foreach (array_slice($recentLogs, 0, 5) as $log): ?>
|
||||||
|
<li class="Box-row">
|
||||||
|
<div class="d-flex flex-items-start">
|
||||||
|
<span class="status-dot <?= $log['level'] === 'error' ? 'danger' : ($log['level'] === 'warning' ? 'warning' : 'success') ?> mt-1"></span>
|
||||||
|
<div class="flex-1 min-width-0">
|
||||||
|
<div class="text-small text-truncate"><?= e($log['message']) ?></div>
|
||||||
|
<div class="text-small color-fg-muted">
|
||||||
|
<?= e($log['category']) ?> • <?= formatDate($log['timestamp']) ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peers Summary -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="Box">
|
||||||
|
<div class="Box-header d-flex flex-items-center flex-justify-between">
|
||||||
|
<h3 class="Box-title">Recent Peers</h3>
|
||||||
|
<a href="?page=peers" class="Link--primary text-small">View all</a>
|
||||||
|
</div>
|
||||||
|
<?php if (empty($peers)): ?>
|
||||||
|
<div class="Box-body color-fg-muted">
|
||||||
|
No peers configured yet.
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Peer Name</th>
|
||||||
|
<th>ASN</th>
|
||||||
|
<th>Neighbor</th>
|
||||||
|
<th>Node</th>
|
||||||
|
<th>Template</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach (array_slice($peers, 0, 10) as $peer): ?>
|
||||||
|
<?php
|
||||||
|
$peerNode = $nodeManager->get($peer['node_id']);
|
||||||
|
$peerAsn = $peerNode ? $asnManager->get($peerNode['asn_id']) : null;
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="text-bold"><?= e($peer['name']) ?></td>
|
||||||
|
<td><?= isset($peer['config']['asn']) ? 'AS' . e($peer['config']['asn']) : '-' ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if (!empty($peer['config']['neighbors'])): ?>
|
||||||
|
<?= e(implode(', ', array_slice($peer['config']['neighbors'], 0, 2))) ?>
|
||||||
|
<?php if (count($peer['config']['neighbors']) > 2): ?>
|
||||||
|
<span class="color-fg-muted">+<?= count($peer['config']['neighbors']) - 2 ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
-
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= $peerNode ? e($peerNode['name']) : '-' ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if (!empty($peer['config']['template'])): ?>
|
||||||
|
<span class="Label Label--secondary"><?= e($peer['config']['template']) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
-
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if ($peer['enabled'] ?? true): ?>
|
||||||
|
<span class="Label Label--success">Enabled</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="Label Label--secondary">Disabled</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="?page=peers&action=edit&id=<?= e($peer['id']) ?>" class="btn btn-sm">Edit</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
586
pathvector-admin/pages/nodes.php
Normal file
586
pathvector-admin/pages/nodes.php
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Nodes Management Page
|
||||||
|
*/
|
||||||
|
|
||||||
|
$asnManager = new ASN($db);
|
||||||
|
$nodeManager = new Node($db);
|
||||||
|
$validator = new Validator();
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? 'list';
|
||||||
|
$id = $_GET['id'] ?? null;
|
||||||
|
$message = '';
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
// Handle form submissions
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!$auth->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);
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if ($action === 'list'): ?>
|
||||||
|
<!-- List View -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="d-flex flex-items-center flex-justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-1">Nodes</h1>
|
||||||
|
<p class="color-fg-muted mb-0">Manage your router nodes</p>
|
||||||
|
</div>
|
||||||
|
<?php if (hasPermission('create_peers')): ?>
|
||||||
|
<a href="?page=nodes&action=create" class="btn btn-primary">
|
||||||
|
<svg class="octicon mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 110 1.5H8.5v4.25a.75.75 0 11-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
|
||||||
|
New Node
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<?php if ($message): ?>
|
||||||
|
<div class="flash flash-success mb-3">
|
||||||
|
<?= e($message) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="flash flash-error mb-3">
|
||||||
|
<?= e($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Filter -->
|
||||||
|
<?php if (!empty($asns)): ?>
|
||||||
|
<div class="mb-3">
|
||||||
|
<form method="GET" class="d-flex flex-items-center" style="gap: 8px;">
|
||||||
|
<input type="hidden" name="page" value="nodes">
|
||||||
|
<select name="asn_id" class="form-select" onchange="this.form.submit()">
|
||||||
|
<option value="">All ASNs</option>
|
||||||
|
<?php foreach ($asns as $asn): ?>
|
||||||
|
<option value="<?= e($asn['id']) ?>" <?= $filterAsnId === $asn['id'] ? 'selected' : '' ?>>
|
||||||
|
AS<?= e($asn['asn']) ?> - <?= e($asn['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (empty($nodes)): ?>
|
||||||
|
<div class="blankslate">
|
||||||
|
<svg class="octicon blankslate-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="24" height="24"><path fill-rule="evenodd" d="M1.75 1A1.75 1.75 0 000 2.75v4c0 .372.116.717.314 1a1.742 1.742 0 00-.314 1v4c0 .966.784 1.75 1.75 1.75h12.5A1.75 1.75 0 0016 12.75v-4c0-.372-.116-.717-.314-1 .198-.283.314-.628.314-1v-4A1.75 1.75 0 0014.25 1H1.75z"/></svg>
|
||||||
|
<h3 class="blankslate-heading">No nodes configured</h3>
|
||||||
|
<p>Get started by creating your first node.</p>
|
||||||
|
<?php if (hasPermission('create_peers')): ?>
|
||||||
|
<a href="?page=nodes&action=create" class="btn btn-primary">Create Node</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="Box">
|
||||||
|
<div class="overflow-auto">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>ASN</th>
|
||||||
|
<th>Router ID</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Peers</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php
|
||||||
|
$peerManager = new Peer($db);
|
||||||
|
foreach ($nodes as $node):
|
||||||
|
$nodeAsn = $asnManager->get($node['asn_id']);
|
||||||
|
$nodePeers = $peerManager->getByNode($node['id']);
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td class="text-bold"><?= e($node['name']) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($nodeAsn): ?>
|
||||||
|
<span class="Label Label--secondary">AS<?= e($nodeAsn['asn']) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="color-fg-muted">-</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code><?= e($node['config']['router_id'] ?? '-') ?></code>
|
||||||
|
</td>
|
||||||
|
<td class="color-fg-muted"><?= e($node['description'] ?? '-') ?></td>
|
||||||
|
<td>
|
||||||
|
<span class="Counter"><?= count($nodePeers) ?></span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<a href="?page=peers&node_id=<?= e($node['id']) ?>" class="btn btn-sm">View Peers</a>
|
||||||
|
<a href="?page=nodes&action=edit&id=<?= e($node['id']) ?>" class="btn btn-sm">Edit</a>
|
||||||
|
<?php if (hasPermission('delete_peers')): ?>
|
||||||
|
<form method="POST" class="d-inline" onsubmit="return confirmDelete('Are you sure you want to delete <?= e($node['name']) ?>?')">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="delete">
|
||||||
|
<input type="hidden" name="id" value="<?= e($node['id']) ?>">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php elseif ($action === 'create' || $action === 'edit'): ?>
|
||||||
|
<!-- Create/Edit Form -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="d-flex flex-items-center">
|
||||||
|
<a href="?page=nodes" class="btn-octicon mr-2">
|
||||||
|
<svg class="octicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M7.78 12.53a.75.75 0 01-1.06 0L2.47 8.28a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L4.81 7h7.44a.75.75 0 010 1.5H4.81l2.97 2.97a.75.75 0 010 1.06z"/></svg>
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1 class="h2 mb-1"><?= $action === 'edit' ? 'Edit Node' : 'New Node' ?></h1>
|
||||||
|
<p class="color-fg-muted mb-0">
|
||||||
|
<?= $action === 'edit' ? e($editNode['name']) : 'Configure a new router node' ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-content">
|
||||||
|
<?php if (empty($asns)): ?>
|
||||||
|
<div class="flash flash-warn mb-3">
|
||||||
|
<strong>No ASNs found.</strong> You need to create an ASN before creating nodes.
|
||||||
|
<a href="?page=asns&action=create" class="Link--primary">Create ASN</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="flash flash-error mb-3">
|
||||||
|
<?= e($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" action="">
|
||||||
|
<?= csrfField() ?>
|
||||||
|
<input type="hidden" name="action" value="<?= $action === 'edit' ? 'update' : 'create' ?>">
|
||||||
|
<?php if ($editNode): ?>
|
||||||
|
<input type="hidden" name="id" value="<?= e($editNode['id']) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Basic Information</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="asn_id">ASN *</label>
|
||||||
|
<select id="asn_id"
|
||||||
|
name="asn_id"
|
||||||
|
class="form-select"
|
||||||
|
style="max-width: 400px;"
|
||||||
|
<?= $editNode ? 'disabled' : 'required' ?>>
|
||||||
|
<option value="">Select an ASN</option>
|
||||||
|
<?php foreach ($asns as $asn): ?>
|
||||||
|
<option value="<?= e($asn['id']) ?>" <?= ($editNode['asn_id'] ?? '') === $asn['id'] ? 'selected' : '' ?>>
|
||||||
|
AS<?= e($asn['asn']) ?> - <?= e($asn['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<?php if ($editNode): ?>
|
||||||
|
<input type="hidden" name="asn_id" value="<?= e($editNode['asn_id']) ?>">
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="name">Name *</label>
|
||||||
|
<input type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
class="form-control form-input-wide"
|
||||||
|
value="<?= e($editNode['name'] ?? '') ?>"
|
||||||
|
placeholder="e.g., router1.example.com"
|
||||||
|
required>
|
||||||
|
<p class="form-hint">A unique name for this node (usually the hostname)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="description">Description</label>
|
||||||
|
<textarea id="description"
|
||||||
|
name="description"
|
||||||
|
class="form-control form-input-wide"
|
||||||
|
rows="2"><?= e($editNode['description'] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Network Configuration</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<p class="color-fg-muted mb-3">These settings override the ASN defaults for this specific node.</p>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 16px;">
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="router_id">Router ID</label>
|
||||||
|
<input type="text"
|
||||||
|
id="router_id"
|
||||||
|
name="router_id"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editNode['config']['router_id'] ?? '') ?>"
|
||||||
|
placeholder="e.g., 192.0.2.1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="hostname">Hostname</label>
|
||||||
|
<input type="text"
|
||||||
|
id="hostname"
|
||||||
|
name="hostname"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editNode['config']['hostname'] ?? '') ?>"
|
||||||
|
placeholder="router1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 16px;">
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="source4">Source IPv4</label>
|
||||||
|
<input type="text"
|
||||||
|
id="source4"
|
||||||
|
name="source4"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editNode['config']['source4'] ?? '') ?>"
|
||||||
|
placeholder="e.g., 192.0.2.1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="source6">Source IPv6</label>
|
||||||
|
<input type="text"
|
||||||
|
id="source6"
|
||||||
|
name="source6"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editNode['config']['source6'] ?? '') ?>"
|
||||||
|
placeholder="e.g., 2001:db8::1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="prefixes4">IPv4 Prefixes</label>
|
||||||
|
<input type="text"
|
||||||
|
id="prefixes4"
|
||||||
|
name="prefixes4"
|
||||||
|
class="form-control form-input-wide"
|
||||||
|
value="<?= e(implode(', ', $editNode['config']['prefixes'] ?? [])) ?>"
|
||||||
|
placeholder="192.0.2.0/24, 198.51.100.0/24">
|
||||||
|
<p class="form-hint">Node-specific IPv4 prefixes (overrides ASN defaults)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="prefixes6">IPv6 Prefixes</label>
|
||||||
|
<input type="text"
|
||||||
|
id="prefixes6"
|
||||||
|
name="prefixes6"
|
||||||
|
class="form-control form-input-wide"
|
||||||
|
value="<?= e(implode(', ', $editNode['config']['prefixes6'] ?? [])) ?>"
|
||||||
|
placeholder="2001:db8::/32">
|
||||||
|
<p class="form-hint">Node-specific IPv6 prefixes (overrides ASN defaults)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">BIRD Configuration</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 16px;">
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="bird_directory">BIRD Directory</label>
|
||||||
|
<input type="text"
|
||||||
|
id="bird_directory"
|
||||||
|
name="bird_directory"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editNode['config']['bird_directory'] ?? '') ?>"
|
||||||
|
placeholder="/etc/bird">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="bird_socket">BIRD Socket</label>
|
||||||
|
<input type="text"
|
||||||
|
id="bird_socket"
|
||||||
|
name="bird_socket"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editNode['config']['bird_socket'] ?? '') ?>"
|
||||||
|
placeholder="/run/bird/bird.ctl">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="cache_directory">Cache Directory</label>
|
||||||
|
<input type="text"
|
||||||
|
id="cache_directory"
|
||||||
|
name="cache_directory"
|
||||||
|
class="form-control"
|
||||||
|
style="max-width: 400px;"
|
||||||
|
value="<?= e($editNode['config']['cache_directory'] ?? '') ?>"
|
||||||
|
placeholder="/var/cache/pathvector">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Filtering Options</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 16px;">
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="irr_server">IRR Server</label>
|
||||||
|
<input type="text"
|
||||||
|
id="irr_server"
|
||||||
|
name="irr_server"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editNode['config']['irr_server'] ?? '') ?>"
|
||||||
|
placeholder="rr.ntt.net">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group flex-1" style="min-width: 200px;">
|
||||||
|
<label class="form-label" for="rtr_server">RTR Server (RPKI)</label>
|
||||||
|
<input type="text"
|
||||||
|
id="rtr_server"
|
||||||
|
name="rtr_server"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= e($editNode['config']['rtr_server'] ?? '') ?>"
|
||||||
|
placeholder="rtr.example.com:8282">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap" style="gap: 24px;">
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="rpki_filter"
|
||||||
|
value="1"
|
||||||
|
<?= ($editNode['config']['rpki_filter'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
Enable RPKI filtering
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="irr_filter"
|
||||||
|
value="1"
|
||||||
|
<?= ($editNode['config']['irr_filter'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
Enable IRR filtering
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
name="keep_filtered"
|
||||||
|
value="1"
|
||||||
|
<?= ($editNode['config']['keep_filtered'] ?? false) ? 'checked' : '' ?>>
|
||||||
|
Keep filtered routes
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="Box mb-4">
|
||||||
|
<div class="Box-header">
|
||||||
|
<h3 class="Box-title">Templates</h3>
|
||||||
|
</div>
|
||||||
|
<div class="Box-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="templates">Default Templates</label>
|
||||||
|
<input type="text"
|
||||||
|
id="templates"
|
||||||
|
name="templates"
|
||||||
|
class="form-control form-input-wide"
|
||||||
|
value="<?= e(implode(', ', $editNode['config']['templates'] ?? [])) ?>"
|
||||||
|
placeholder="upstream, peer, downstream">
|
||||||
|
<p class="form-hint">Comma-separated list of default templates to use for this node</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-items-center" style="gap: 8px;">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<?= $action === 'edit' ? 'Update Node' : 'Create Node' ?>
|
||||||
|
</button>
|
||||||
|
<a href="?page=nodes" class="btn">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
239
pathvector-admin/setup.php
Normal file
239
pathvector-admin/setup.php
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Pathvector Admin - Initial Setup Page
|
||||||
|
*/
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config/config.php';
|
||||||
|
require_once __DIR__ . '/lib/FlatFileDB.php';
|
||||||
|
require_once __DIR__ . '/lib/Logger.php';
|
||||||
|
require_once __DIR__ . '/lib/Auth.php';
|
||||||
|
|
||||||
|
$db = new FlatFileDB(DATA_PATH);
|
||||||
|
$logger = new Logger($db);
|
||||||
|
$auth = new Auth($db);
|
||||||
|
|
||||||
|
// Check if already setup
|
||||||
|
$users = $db->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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark_dimmed">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Setup - Pathvector Admin</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@primer/css@^20.2.4/dist/primer.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-canvas-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-logo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--color-accent-emphasis);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-logo svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
fill: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-box {
|
||||||
|
background: var(--color-canvas-default);
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-steps {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--color-canvas-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-steps h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-steps ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-fg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-steps li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="setup-container">
|
||||||
|
<div class="setup-header">
|
||||||
|
<div class="setup-logo">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zm.75 4.75a.75.75 0 00-1.5 0v2.5h-2.5a.75.75 0 000 1.5h2.5v2.5a.75.75 0 001.5 0v-2.5h2.5a.75.75 0 000-1.5h-2.5v-2.5z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="h3">Pathvector Admin Setup</h1>
|
||||||
|
<p class="color-fg-muted">Create your administrator account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setup-box">
|
||||||
|
<?php if ($success): ?>
|
||||||
|
<div class="flash flash-success mb-3">
|
||||||
|
<strong>Setup Complete!</strong> Your administrator account has been created.
|
||||||
|
</div>
|
||||||
|
<a href="login.php" class="btn btn-primary btn-block">Continue to Login</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php if ($error): ?>
|
||||||
|
<div class="flash flash-error mb-3">
|
||||||
|
<?= htmlspecialchars($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" action="">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Administrator Username</label>
|
||||||
|
<input type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
class="form-control"
|
||||||
|
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>"
|
||||||
|
autocomplete="username"
|
||||||
|
autofocus
|
||||||
|
required>
|
||||||
|
<p class="form-hint">Choose a username for the admin account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required>
|
||||||
|
<p class="form-hint">Minimum 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password">Confirm Password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
class="form-control"
|
||||||
|
autocomplete="new-password"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="submit" class="btn btn-primary btn-block">
|
||||||
|
Create Administrator Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="setup-steps">
|
||||||
|
<h3>Next Steps After Setup</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Configure your ASN(s)</li>
|
||||||
|
<li>Add router nodes</li>
|
||||||
|
<li>Create peer templates</li>
|
||||||
|
<li>Add BGP peers</li>
|
||||||
|
<li>Set up execution hosts</li>
|
||||||
|
<li>Generate and apply configurations</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user