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:
2025-12-14 01:33:12 -05:00
parent cf0ec74888
commit d8b76233c0
19 changed files with 8800 additions and 0 deletions

View 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);
}
}

View 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;
}
}

View 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',
];
}
}

View 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();
}
}

View 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);
}
}

View 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;
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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);
}
}

View 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();
}
}