Migrating away from Sophos UTM can be awkward. The WebAdmin interface gives you a configuration backup, but that backup is not especially friendly if your goal is to understand firewall rules, NAT, aliases, services, routing, WAF objects, or VPN leftovers.
The .abf backup is useful for disaster recovery, but it is not a migration document. It is a bundled configuration state file. You can pull strings out of it, but you will quickly end up spelunking through object references, internal IDs, and historical cruft.
A better approach is to use the Sophos UTM WebAdmin API to export the object graph into JSON, then process that into readable files.
That gives you something much more useful for migration planning:
- Network objects
- Host objects
- Network groups
- Service definitions
- Service groups
- Firewall rules
- NAT rules
- Masquerading rules
- Static routes
- Policy routes
- WAF entries
- Mail protection leftovers
- VPN objects
- OSPF/BGP routing objects
- References between rules and objects
Once exported, you can review what is actually in use, identify stale rules, and start mapping Sophos concepts into your next firewall platform, whether that is OPNsense, pfSense, VyOS, FortiGate, Juniper SRX, MikroTik, Linux nftables, or something completely custom.
Why not just use the backup file?
Sophos UTM backups are great if your goal is restoring Sophos UTM.
They are much less helpful if your goal is migration.
The backup contains the whole appliance configuration, but it is not shaped like a clean firewall policy export. You may be able to extract strings and object names, but it is not pleasant to answer questions like:
- Which firewall rules reference this host?
- Which NAT rules still matter?
- Which service groups include TCP and UDP members?
- Which rules reference deleted or stale objects?
- Which WAF entries are still tied to services we no longer run?
- Which aliases are genuinely reused versus one-off leftovers?
- Which objects are safe to ignore?
The WebAdmin API gives you structured JSON. That is the difference between archaeology with a toothbrush and having a map.
Enabling the Sophos UTM WebAdmin API
In WebAdmin, enable the REST API under the WebAdmin/API settings.
The exact menu name can vary slightly by UTM version, but generally you need to:
- Enable the WebAdmin REST API.
- Create or identify an administrative account that can access the API.
- Make sure your export host is allowed to connect to WebAdmin.
- Confirm HTTPS access to the UTM WebAdmin port.
Most UTM appliances use a self-signed certificate, so your client will probably need to disable TLS verification for this one-off export, or trust the appliance certificate.
API export strategy
The Sophos API is object-reference heavy. A firewall rule often does not contain the full source, destination, and service definition directly. Instead, it references objects by REF IDs.
So the export process needs two layers:
- Export major collections such as firewall rules, NAT rules, network objects, and service objects.
- Resolve references later when generating human-readable output.
For example, a firewall rule may reference:
REF_NetHosDmzGeppetto
REF_ServiceTcpHttps
REF_NetGrpInternalNetworks
Those references are not useful by themselves. You need the object export too, so you can turn them into:
DMZ-Geppetto
HTTPS
Internal Networks
Example PHP API client
Here is a small PHP client that can connect to Sophos UTM and export a set of common API endpoints to JSON files.
It is deliberately simple. It is not a full SDK. Its job is to get the configuration out of the appliance and into a directory where you can inspect, process, diff, and transform it.
<?php
declare(strict_types=1);
final class SophosUtmClient
{
private string $baseUrl;
private string $username;
private string $password;
private bool $verifyTls;
public function __construct(
string $baseUrl,
string $username,
string $password,
bool $verifyTls = false
) {
$this->baseUrl = rtrim($baseUrl, '/');
$this->username = $username;
$this->password = $password;
$this->verifyTls = $verifyTls;
}
public function get(string $path): array
{
$url = $this->baseUrl . '/' . ltrim($path, '/');
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => $this->username . ':' . $this->password,
CURLOPT_HTTPAUTH => CURLAUTH_BASIC,
CURLOPT_HTTPHEADER => [
'Accept: application/json',
],
CURLOPT_SSL_VERIFYPEER => $this->verifyTls,
CURLOPT_SSL_VERIFYHOST => $this->verifyTls ? 2 : 0,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 60,
]);
$body = curl_exec($ch);
if ($body === false) {
$error = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL error calling {$url}: {$error}");
}
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status < 200 || $status >= 300) {
throw new RuntimeException("HTTP {$status} from {$url}: {$body}");
}
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
throw new RuntimeException("Invalid JSON from {$url}: {$body}");
}
return $decoded;
}
}
Export script
This script loops over a set of useful Sophos UTM endpoints and writes the responses to JSON files.
<?php
declare(strict_types=1);
require __DIR__ . '/SophosUtmClient.php';
$baseUrl = getenv('SOPHOS_URL') ?: '';
$username = getenv('SOPHOS_USERNAME') ?: '';
$password = getenv('SOPHOS_PASSWORD') ?: '';
$outputDir = getenv('SOPHOS_EXPORT_DIR') ?: __DIR__ . '/sophos-export';
if ($baseUrl === '' || $username === '' || $password === '') {
fwrite(STDERR, "Required environment variables:\n");
fwrite(STDERR, " SOPHOS_URL=https://utm.example.com:4444/api\n");
fwrite(STDERR, " SOPHOS_USERNAME=admin\n");
fwrite(STDERR, " SOPHOS_PASSWORD=secret\n");
fwrite(STDERR, "Optional:\n");
fwrite(STDERR, " SOPHOS_EXPORT_DIR=./sophos-export\n");
exit(1);
}
$client = new SophosUtmClient(
baseUrl: $baseUrl,
username: $username,
password: $password,
verifyTls: false
);
$endpoints = [
// Network objects
'/objects/network/host',
'/objects/network/network',
'/objects/network/group',
'/objects/network/interface_address',
'/objects/network/dns_host',
'/objects/network/dns_group',
'/objects/network/availability_group',
// Services
'/objects/service/tcp',
'/objects/service/udp',
'/objects/service/tcpudp',
'/objects/service/icmp',
'/objects/service/group',
// Firewall
'/objects/packetfilter/packetfilter',
'/objects/packetfilter/group',
// NAT
'/objects/nat/masquerading',
'/objects/nat/dnat',
'/objects/nat/snat',
'/objects/nat/fullnat',
// Routing
'/objects/routing/static_gateway_route',
'/objects/routing/static_interface_route',
'/objects/routing/policy_route',
// Interfaces
'/objects/interface/ethernet',
'/objects/interface/vlan',
'/objects/interface/bridge',
'/objects/interface/pppoe',
// Web protection / WAF
'/objects/reverse_proxy/frontend',
'/objects/reverse_proxy/backend',
'/objects/reverse_proxy/profile',
// Mail protection
'/objects/mail/smtp/profile',
'/objects/mail/smtp/route',
'/objects/mail/pop3/profile',
// VPN-ish objects
'/objects/ipsec/connection',
'/objects/ipsec/remote_gateway',
'/objects/openvpn/site_to_site',
'/objects/openvpn/remote_access',
// Routing daemons, if used
'/objects/ospf/area',
'/objects/ospf/interface',
'/objects/bgp/system',
'/objects/bgp/neighbor',
];
if (!is_dir($outputDir) && !mkdir($outputDir, 0775, true)) {
throw new RuntimeException("Could not create output directory: {$outputDir}");
}
foreach ($endpoints as $endpoint) {
$safeName = trim($endpoint, '/');
$safeName = str_replace('/', '__', $safeName);
$file = $outputDir . '/' . $safeName . '.json';
echo "Exporting {$endpoint} -> {$file}\n";
try {
$data = $client->get($endpoint);
file_put_contents(
$file,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
} catch (Throwable $e) {
$errorFile = $outputDir . '/' . $safeName . '.error.txt';
file_put_contents($errorFile, $e->getMessage() . PHP_EOL);
echo " ERROR: {$e->getMessage()}\n";
}
}
Run it like this:
export SOPHOS_URL='https://utm.example.com:4444/api'
export SOPHOS_USERNAME='admin'
export SOPHOS_PASSWORD='your-password'
export SOPHOS_EXPORT_DIR='./sophos-export'
php export-sophos.php
After the export, you should have a directory full of files like:
objects__network__host.json
objects__network__network.json
objects__network__group.json
objects__service__tcp.json
objects__service__udp.json
objects__packetfilter__packetfilter.json
objects__nat__dnat.json
objects__nat__masquerading.json
Turning the export into a readable inventory
Raw JSON is better than a backup file, but it is still not migration-ready. The next step is to build lookup tables.
The basic idea is:
- Read every exported JSON file.
- Index every object by its Sophos REF.
- Use that index to resolve firewall rules, NAT rules, and groups.
- Emit readable CSV, Markdown, YAML, or JSON.
Here is a simplified object indexer:
<?php
declare(strict_types=1);
function loadJsonFiles(string $dir): array
{
$objects = [];
foreach (glob($dir . '/*.json') as $file) {
$data = json_decode((string) file_get_contents($file), true);
if (!is_array($data)) {
continue;
}
foreach (normaliseSophosCollection($data) as $item) {
if (!is_array($item)) {
continue;
}
$ref = $item['_ref'] ?? $item['ref'] ?? null;
if (is_string($ref) && $ref !== '') {
$objects[$ref] = $item + [
'_source_file' => basename($file),
];
}
}
}
return $objects;
}
function normaliseSophosCollection(array $data): array
{
if (array_is_list($data)) {
return $data;
}
foreach (['objects', 'items', 'result', 'data'] as $key) {
if (isset($data[$key]) && is_array($data[$key])) {
return array_is_list($data[$key]) ? $data[$key] : array_values($data[$key]);
}
}
return array_values($data);
}
function objectName(?string $ref, array $index): string
{
if ($ref === null || $ref === '') {
return '';
}
if (!isset($index[$ref])) {
return $ref;
}
return $index[$ref]['name'] ?? $index[$ref]['_ref'] ?? $ref;
}
Then you can use it to make a readable firewall rule export.
<?php
declare(strict_types=1);
require __DIR__ . '/sophos-index.php';
$exportDir = $argv[1] ?? './sophos-export';
$index = loadJsonFiles($exportDir);
$rulesFile = $exportDir . '/objects__packetfilter__packetfilter.json';
if (!is_file($rulesFile)) {
throw new RuntimeException("Missing firewall rules export: {$rulesFile}");
}
$rulesRaw = json_decode((string) file_get_contents($rulesFile), true);
$rules = normaliseSophosCollection($rulesRaw);
$out = fopen('php://output', 'w');
fputcsv($out, [
'enabled',
'position',
'name',
'source',
'service',
'destination',
'action',
'comment',
]);
foreach ($rules as $rule) {
if (!is_array($rule)) {
continue;
}
$sources = array_map(
fn($ref) => objectName($ref, $index),
(array)($rule['sources'] ?? $rule['source'] ?? [])
);
$services = array_map(
fn($ref) => objectName($ref, $index),
(array)($rule['services'] ?? $rule['service'] ?? [])
);
$destinations = array_map(
fn($ref) => objectName($ref, $index),
(array)($rule['destinations'] ?? $rule['destination'] ?? [])
);
fputcsv($out, [
empty($rule['disabled']) ? 'yes' : 'no',
$rule['position'] ?? '',
$rule['name'] ?? '',
implode(', ', $sources),
implode(', ', $services),
implode(', ', $destinations),
$rule['action'] ?? '',
$rule['comment'] ?? $rule['description'] ?? '',
]);
}
Run it:
php readable-firewall-rules.php ./sophos-export > firewall-rules.csv
Now you have something that humans can work with.
Important migration gotchas
Sophos groups do not always map cleanly
Sophos may allow a rule to refer to several objects directly. Your new platform may require a single alias, address group, or port group.
During one migration, I ran into this exact issue with OPNsense automation rules. A Sophos rule could effectively say:
source: Internal Networks, DNS Server 1, DNS Server 2
destination: Pi-hole
service: DNS
But the target firewall API wanted:
source: one alias
destination: one alias
service: one alias
So the migration process needed to create a new group alias such as:
DNS_ALLOWED_SOURCES:
type: networkgroup
content:
- INT_INTERNAL_ADDRESSES
- DNS_SERVER_1
- DNS_SERVER_2
Then the rule could reference DNS_ALLOWED_SOURCES.
Service groups may need flattening
Sophos service groups can contain multiple service definitions, and those definitions may be TCP, UDP, or mixed.
For example:
AMP_GROUP1:
protocol: tcp
ports:
- 2121:2281
AMP_GROUP10:
protocol: tcpudp
ports:
- 30810:30900
For a target firewall, it may be cleaner to create one combined port alias:
AMP_PORTS:
type: port
content:
- 2121:2281
- 30810:30900
Then set the firewall rule protocol to tcp/udp.
Object names may be too long
Sophos object names can be verbose. Some target platforms have stricter alias length limits.
For example, a name like this may need shortening:
SOPHOS_OBJ_SYDNEY_VLAN102_MANAGEMENT
into something like:
so_sydney_vlan102_mgmt
If you do this, keep a mapping file:
SOPHOS_OBJ_SYDNEY_VLAN102_MANAGEMENT: so_sydney_vlan102_mgmt
That mapping file becomes extremely useful when troubleshooting after cutover.
“Any” cannot always be combined with other aliases
If a Sophos rule has something like:
source: Any, SomeSpecificAlias
the target platform may reject it.
Logically, Any + Something is just Any, so simplify it.
Special Sophos objects need manual mapping
Sophos has concepts like:
This Firewall
External WAN Address
Interface Address
SYNC net
Those may not export as normal aliases that your new firewall understands.
You may need to map them manually:
FW_ROUTING_VIP:
type: host
content:
- 192.168.10.49
FW_DMZ_VIP:
type: host
content:
- 103.235.52.81
Do not assume the target firewall will understand the literal string This Firewall.
Recommended output formats
For migration, I like producing several layers of output.
1. Raw JSON
Keep the original API exports untouched.
sophos-export/raw/*.json
This is your evidence locker.
2. Resolved JSON
Create a version where references are expanded.
{
"name": "Allow DNS to Pi-hole",
"source": [
{
"ref": "REF_NetGrpInternal",
"name": "Internal Networks"
}
],
"service": [
{
"ref": "REF_SerDns",
"name": "DNS",
"protocols": ["tcp", "udp"],
"ports": ["53"]
}
],
"destination": [
{
"ref": "REF_NetHostPiHole",
"name": "DMZ Pi-hole"
}
]
}
3. CSV for review
This is useful for non-automation review.
enabled, position, name, source, service, destination, action, comment
4. YAML for migration
This becomes the start of your Git-managed target firewall configuration.
allow-dns-to-pihole:
enabled: true
source_net: DNS_ALLOWED_SOURCES
destination_net: DMZ_PI_HOLE
destination_port: DNS
protocol: tcp/udp
action: pass
Suggested migration workflow
A practical migration workflow looks like this:
- Export the UTM API into raw JSON.
- Build an object reference index.
- Generate readable firewall/NAT/routing reports.
- Identify dead services, old VPNs, old mail protection, and stale WAF rules.
- Create target-platform aliases.
- Create target-platform service aliases.
- Convert high-value firewall rules first.
- Leave questionable imported rules disabled or marked for review.
- Manually validate NAT, routing, and special firewall-local rules.
- Test cutover using logs, packet captures, and known traffic flows.
Do not aim for a perfect one-to-one clone.
A firewall migration is a chance to delete years of sediment. Treat the Sophos export as a historical document, not a sacred scroll.
Lessons learned
The biggest lesson is that exporting the config is only step one.
The hard part is translating firewall semantics.
Sophos might allow constructs that your target platform rejects. Your target platform may have different ideas about aliases, port groups, NAT, interface matching, or firewall-local addresses.
In my case, the useful path was:
Sophos UTM API
-> raw JSON export
-> resolved object graph
-> reviewable YAML
-> target firewall rules
-> manual cleanup and validation
The raw export gave me confidence. The readable YAML gave me control. The manual review kept me from blindly migrating old junk.
And that is the real goal: not just to move the firewall config, but to understand it well enough that the new firewall starts life cleaner than the old one ended.
