Created
December 8, 2025 08:44
-
-
Save md-riaz/a5e8a181511b14ba4302d70246583dd5 to your computer and use it in GitHub Desktop.
FusionPBX Single Domain Migration Tool
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env php | |
| <?php | |
| /** | |
| * FusionPBX Single Domain Migration Tool | |
| * | |
| * Features: | |
| * - Export one domain’s data (extensions, ring groups, IVRs, voicemail, destinations, gateways, etc.) | |
| * - Filter out default dialplans by app_uuid (read from FusionPBX XML templates) | |
| * - Import into a new domain, only changing domain_uuid | |
| * - Keeps all other UUIDs unchanged (extensions, gateways, ivrs, ring groups, dialplans, etc.) | |
| * | |
| * USAGE: | |
| * Export on source server: | |
| * php domain_migrate.php export \ | |
| * --domain=old.example.com \ | |
| * --out=/tmp/fpbx-old.example.com-export \ | |
| * --fusionpbx-path=/var/www/fusionpbx | |
| * | |
| * Import on target server (domain must already exist in GUI): | |
| * php domain_migrate.php import \ | |
| * --domain=new.example.com \ | |
| * --in=/tmp/fpbx-old.example.com-export | |
| * | |
| * DB CONFIG: | |
| * Edit the DB_* constants below to match your FusionPBX PostgreSQL database. | |
| */ | |
| // ----------------- DB CONFIG ----------------- | |
| const DB_DSN = 'pgsql:host=127.0.0.1;port=5432;dbname=fusionpbx;'; | |
| const DB_USER = 'fusionpbx'; | |
| const DB_PASS = 'fusionpbx'; | |
| // ------------------------------------------------- | |
| // CLI bootstrap | |
| // ------------------------------------------------- | |
| if (php_sapi_name() !== 'cli') { | |
| fwrite(STDERR, "This script must be run from the command line.\n"); | |
| exit(1); | |
| } | |
| $args = parseArguments($argv); | |
| $command = $args['command'] ?? null; | |
| if (!$command || !in_array($command, ['export', 'import'], true)) { | |
| usage(); | |
| exit(1); | |
| } | |
| // Create DB connection | |
| try { | |
| $pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [ | |
| PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, | |
| ]); | |
| } catch (Exception $e) { | |
| fwrite(STDERR, "DB connection failed: " . $e->getMessage() . "\n"); | |
| exit(1); | |
| } | |
| // ------------------------------------------------- | |
| // Commands | |
| // ------------------------------------------------- | |
| if ($command === 'export') { | |
| $domainName = requireArg($args, 'domain'); | |
| $outputDir = requireArg($args, 'out'); | |
| $fusionpbxPath = requireArg($args, 'fusionpbx-path'); | |
| exportDomain($pdo, $domainName, $outputDir, $fusionpbxPath); | |
| exit(0); | |
| } | |
| if ($command === 'import') { | |
| $domainName = requireArg($args, 'domain'); | |
| $inputDir = requireArg($args, 'in'); | |
| importDomain($pdo, $domainName, $inputDir); | |
| exit(0); | |
| } | |
| // ------------------------------------------------- | |
| // Core functions | |
| // ------------------------------------------------- | |
| function exportDomain(PDO $pdo, string $domainName, string $outputDir, string $fusionpbxPath): void | |
| { | |
| echo "=== EXPORT DOMAIN: {$domainName} ===\n"; | |
| // Resolve domain_uuid | |
| $oldDomainUuid = getDomainUuid($pdo, $domainName); | |
| if (!$oldDomainUuid) { | |
| fwrite(STDERR, "Domain not found in v_domains: {$domainName}\n"); | |
| exit(1); | |
| } | |
| echo "Found domain_uuid: {$oldDomainUuid}\n"; | |
| // Ensure output dir | |
| if (!is_dir($outputDir)) { | |
| if (!mkdir($outputDir, 0775, true)) { | |
| fwrite(STDERR, "Failed to create output directory: {$outputDir}\n"); | |
| exit(1); | |
| } | |
| } | |
| // Collect default app_uuid list from FusionPBX XML templates | |
| $defaultAppUuids = collectDefaultAppUuids($fusionpbxPath); | |
| if (empty($defaultAppUuids)) { | |
| fwrite(STDERR, "ERROR: Could not collect any default app_uuid from XML under {$fusionpbxPath}\n"); | |
| fwrite(STDERR, "Check --fusionpbx-path. Aborting to avoid exporting default dialplans.\n"); | |
| exit(1); | |
| } | |
| $meta = [ | |
| 'domain_name' => $domainName, | |
| 'domain_uuid' => $oldDomainUuid, | |
| 'exported_at' => date('c'), | |
| 'default_app_uuids' => $defaultAppUuids, | |
| ]; | |
| file_put_contents(rtrim($outputDir, '/') . '/_meta.json', json_encode($meta, JSON_PRETTY_PRINT)); | |
| // Tables to export directly (filtered only by domain_uuid) | |
| // NOTE: v_destinations and v_gateways are included here. | |
| $tables = [ | |
| 'v_domain_settings', | |
| 'v_extensions', | |
| 'v_extension_users', | |
| 'v_ring_groups', | |
| 'v_ring_group_destinations', | |
| 'v_ivr_menus', | |
| 'v_ivr_menu_options', | |
| 'v_destinations', | |
| 'v_voicemails', | |
| 'v_voicemail_greetings', | |
| 'v_voicemail_messages', | |
| 'v_recordings', | |
| 'v_gateways', | |
| ]; | |
| foreach ($tables as $table) { | |
| echo "Exporting table {$table}...\n"; | |
| $stmt = $pdo->prepare("SELECT * FROM {$table} WHERE domain_uuid = :domain_uuid"); | |
| $stmt->execute([':domain_uuid' => $oldDomainUuid]); | |
| $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| file_put_contents("{$outputDir}/{$table}.json", json_encode($rows, JSON_PRETTY_PRINT)); | |
| } | |
| // Export dialplans (excluding default app_uuid) | |
| echo "Exporting v_dialplans (non-default) ...\n"; | |
| $placeholders = []; | |
| $params = [':domain_uuid' => $oldDomainUuid]; | |
| foreach ($defaultAppUuids as $idx => $uuid) { | |
| $ph = ":app_uuid_$idx"; | |
| $placeholders[] = $ph; | |
| $params[$ph] = $uuid; | |
| } | |
| $sqlDialplans = " | |
| SELECT * | |
| FROM v_dialplans | |
| WHERE domain_uuid = :domain_uuid | |
| AND ( | |
| app_uuid IS NULL | |
| OR app_uuid NOT IN (" . implode(', ', $placeholders) . ") | |
| ) | |
| "; | |
| $stmt = $pdo->prepare($sqlDialplans); | |
| $stmt->execute($params); | |
| $dialplans = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| file_put_contents("{$outputDir}/v_dialplans.json", json_encode($dialplans, JSON_PRETTY_PRINT)); | |
| echo "Exporting v_dialplan_details (matching exported v_dialplans) ...\n"; | |
| $dialplanUuids = array_column($dialplans, 'dialplan_uuid'); | |
| $dialplanDetails = []; | |
| if (!empty($dialplanUuids)) { | |
| // Build IN clause for dialplan_uuid | |
| $phs = []; | |
| $paramsDetails = []; | |
| foreach ($dialplanUuids as $i => $dpUuid) { | |
| $ph = ":dp_$i"; | |
| $phs[] = $ph; | |
| $paramsDetails[$ph] = $dpUuid; | |
| } | |
| $sqlDetails = " | |
| SELECT * | |
| FROM v_dialplan_details | |
| WHERE dialplan_uuid IN (" . implode(', ', $phs) . ") | |
| "; | |
| $stmt = $pdo->prepare($sqlDetails); | |
| $stmt->execute($paramsDetails); | |
| $dialplanDetails = $stmt->fetchAll(PDO::FETCH_ASSOC); | |
| } | |
| file_put_contents("{$outputDir}/v_dialplan_details.json", json_encode($dialplanDetails, JSON_PRETTY_PRINT)); | |
| echo "\n=== EXPORT COMPLETE ===\n"; | |
| echo "Output directory: {$outputDir}\n\n"; | |
| echo "Remember to copy filesystem data (voicemail, recordings, etc.) manually, e.g.:\n"; | |
| echo " recordings: /var/lib/freeswitch/recordings/{$domainName}/\n"; | |
| echo " voicemail : /var/lib/freeswitch/storage/voicemail/default/{$domainName}/\n"; | |
| } | |
| function importDomain(PDO $pdo, string $newDomainName, string $inputDir): void | |
| { | |
| echo "=== IMPORT INTO DOMAIN: {$newDomainName} ===\n"; | |
| if (!is_dir($inputDir)) { | |
| fwrite(STDERR, "Input directory does not exist: {$inputDir}\n"); | |
| exit(1); | |
| } | |
| $metaPath = rtrim($inputDir, '/') . '/_meta.json'; | |
| if (!file_exists($metaPath)) { | |
| fwrite(STDERR, "Missing _meta.json in input directory. Is this a valid export?\n"); | |
| exit(1); | |
| } | |
| $meta = json_decode(file_get_contents($metaPath), true); | |
| if (!$meta || empty($meta['domain_uuid']) || empty($meta['domain_name'])) { | |
| fwrite(STDERR, "Invalid _meta.json file.\n"); | |
| exit(1); | |
| } | |
| $oldDomainName = $meta['domain_name']; | |
| $oldDomainUuid = $meta['domain_uuid']; | |
| echo "Exported from domain: {$oldDomainName} ({$oldDomainUuid})\n"; | |
| // Resolve new domain_uuid | |
| $newDomainUuid = getDomainUuid($pdo, $newDomainName); | |
| if (!$newDomainUuid) { | |
| fwrite(STDERR, "Target domain not found in v_domains: {$newDomainName}\n"); | |
| fwrite(STDERR, "Create the domain first in FusionPBX GUI, then re-run.\n"); | |
| exit(1); | |
| } | |
| echo "Target domain_uuid: {$newDomainUuid}\n"; | |
| // Import order matters for foreign keys a bit; gateways can be early. | |
| $tables = [ | |
| 'v_domain_settings', | |
| 'v_extensions', | |
| 'v_extension_users', | |
| 'v_ring_groups', | |
| 'v_ring_group_destinations', | |
| 'v_ivr_menus', | |
| 'v_ivr_menu_options', | |
| 'v_destinations', | |
| 'v_voicemails', | |
| 'v_voicemail_greetings', | |
| 'v_voicemail_messages', | |
| 'v_recordings', | |
| 'v_gateways', | |
| 'v_dialplans', | |
| 'v_dialplan_details', | |
| ]; | |
| foreach ($tables as $table) { | |
| $path = "{$inputDir}/{$table}.json"; | |
| if (!file_exists($path)) { | |
| echo "Skipping {$table} (no export file found).\n"; | |
| continue; | |
| } | |
| echo "Importing {$table}...\n"; | |
| $rows = json_decode(file_get_contents($path), true); | |
| if (!is_array($rows) || empty($rows)) { | |
| echo " No rows to import for {$table}.\n"; | |
| continue; | |
| } | |
| foreach ($rows as $row) { | |
| if (!isset($row['domain_uuid'])) { | |
| // Some tables might not have domain_uuid – we skip those by design here. | |
| continue; | |
| } | |
| // Rewrite domain_uuid only | |
| $row['domain_uuid'] = $newDomainUuid; | |
| insertRow($pdo, $table, $row); | |
| } | |
| } | |
| echo "\n=== IMPORT COMPLETE ===\n"; | |
| echo "Now reload XML/dialplan on the new server, e.g.:\n"; | |
| echo " fs_cli -x \"reloadxml\"\n"; | |
| echo " fs_cli -x \"reload mod_dialplan_xml\"\n"; | |
| echo "\nTest calls, ring groups, IVR, voicemail, destinations, gateways, etc. for domain: {$newDomainName}\n"; | |
| } | |
| // ------------------------------------------------- | |
| // Helper: DB operations | |
| // ------------------------------------------------- | |
| function getDomainUuid(PDO $pdo, string $domainName): ?string | |
| { | |
| $stmt = $pdo->prepare("SELECT domain_uuid FROM v_domains WHERE domain_name = :domain_name"); | |
| $stmt->execute([':domain_name' => $domainName]); | |
| $row = $stmt->fetch(PDO::FETCH_ASSOC); | |
| return $row['domain_uuid'] ?? null; | |
| } | |
| function insertRow(PDO $pdo, string $table, array $row): void | |
| { | |
| // Build INSERT | |
| $cols = array_keys($row); | |
| $placeholders = array_map(fn($c) => ':' . $c, $cols); | |
| $sql = "INSERT INTO {$table} (" . implode(', ', $cols) . ") | |
| VALUES (" . implode(', ', $placeholders) . ")"; | |
| $stmt = $pdo->prepare($sql); | |
| // Bind params | |
| foreach ($row as $col => $val) { | |
| $stmt->bindValue(':' . $col, $val); | |
| } | |
| try { | |
| $stmt->execute(); | |
| } catch (Exception $e) { | |
| // Most likely duplicate UUID in target DB or constraint issue. | |
| fwrite(STDERR, " [WARN] Failed to insert into {$table}: " . $e->getMessage() . "\n"); | |
| } | |
| } | |
| // ------------------------------------------------- | |
| // Helper: Collect default app_uuid from XML | |
| // ------------------------------------------------- | |
| function collectDefaultAppUuids(string $fusionpbxPath): array | |
| { | |
| $fusionpbxPath = rtrim($fusionpbxPath, '/'); | |
| $paths = []; | |
| // Core dialplan templates | |
| $paths[] = $fusionpbxPath . '/resources/templates/xml/dialplan'; | |
| // App-specific dialplans | |
| $appDir = $fusionpbxPath . '/app'; | |
| if (is_dir($appDir)) { | |
| $dirIterator = new RecursiveDirectoryIterator($appDir, FilesystemIterator::SKIP_DOTS); | |
| $iterator = new RecursiveIteratorIterator($dirIterator); | |
| foreach ($iterator as $file) { | |
| /** @var SplFileInfo $file */ | |
| if (!$file->isFile()) continue; | |
| if (substr($file->getFilename(), -4) !== '.xml') continue; | |
| $path = $file->getPathname(); | |
| if (strpos($path, '/resources/switch/conf/dialplan/') !== false) { | |
| $paths[] = $path; | |
| } | |
| } | |
| } | |
| $uuids = []; | |
| foreach ($paths as $path) { | |
| if (is_dir($path)) { | |
| $dirIter = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS); | |
| $iter = new RecursiveIteratorIterator($dirIter); | |
| foreach ($iter as $file) { | |
| /** @var SplFileInfo $file */ | |
| if (!$file->isFile()) continue; | |
| if (substr($file->getFilename(), -4) !== '.xml') continue; | |
| $content = @file_get_contents($file->getPathname()); | |
| if ($content === false) continue; | |
| // Look for app_uuid="..." | |
| if (preg_match_all('/app_uuid="([0-9a-fA-F-]+)"/', $content, $matches)) { | |
| foreach ($matches[1] as $uuid) { | |
| $uuids[$uuid] = true; | |
| } | |
| } | |
| } | |
| } elseif (is_file($path)) { | |
| $content = @file_get_contents($path); | |
| if ($content === false) continue; | |
| if (preg_match_all('/app_uuid="([0-9a-fA-F-]+)"/', $content, $matches)) { | |
| foreach ($matches[1] as $uuid) { | |
| $uuids[$uuid] = true; | |
| } | |
| } | |
| } | |
| } | |
| $result = array_keys($uuids); | |
| sort($result); | |
| echo "Collected " . count($result) . " default app_uuid values from XML.\n"; | |
| return $result; | |
| } | |
| // ------------------------------------------------- | |
| // Helper: CLI parsing | |
| // ------------------------------------------------- | |
| function parseArguments(array $argv): array | |
| { | |
| $out = [ | |
| 'command' => null, | |
| ]; | |
| if (isset($argv[1])) { | |
| $out['command'] = $argv[1]; | |
| } | |
| for ($i = 2; $i < count($argv); $i++) { | |
| $arg = $argv[$i]; | |
| if (strpos($arg, '--') === 0) { | |
| $eqPos = strpos($arg, '='); | |
| if ($eqPos !== false) { | |
| $key = substr($arg, 2, $eqPos - 2); | |
| $val = substr($arg, $eqPos + 1); | |
| $out[$key] = $val; | |
| } else { | |
| $key = substr($arg, 2); | |
| $out[$key] = true; | |
| } | |
| } | |
| } | |
| return $out; | |
| } | |
| function requireArg(array $args, string $name): string | |
| { | |
| if (empty($args[$name])) { | |
| fwrite(STDERR, "Missing required argument --{$name}\n"); | |
| usage(); | |
| exit(1); | |
| } | |
| return (string)$args[$name]; | |
| } | |
| function usage(): void | |
| { | |
| $self = basename(__FILE__); | |
| echo "FusionPBX Single-Domain Migration\n"; | |
| echo "\n"; | |
| echo "Usage:\n"; | |
| echo " Export from source server:\n"; | |
| echo " php {$self} export \\\n"; | |
| echo " --domain=old.example.com \\\n"; | |
| echo " --out=/tmp/fpbx-old.example.com-export \\\n"; | |
| echo " --fusionpbx-path=/var/www/fusionpbx\n"; | |
| echo "\n"; | |
| echo " Import into target server (domain must exist already):\n"; | |
| echo " php {$self} import \\\n"; | |
| echo " --domain=new.example.com \\\n"; | |
| echo " --in=/tmp/fpbx-old.example.com-export\n"; | |
| echo "\n"; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment