Tag Archives: migration

Exporting a Sophos UTM Configuration into Something You Can Actually Migrate From

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:

  1. Enable the WebAdmin REST API.
  2. Create or identify an administrative account that can access the API.
  3. Make sure your export host is allowed to connect to WebAdmin.
  4. 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:

  1. Export major collections such as firewall rules, NAT rules, network objects, and service objects.
  2. 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:

  1. Read every exported JSON file.
  2. Index every object by its Sophos REF.
  3. Use that index to resolve firewall rules, NAT rules, and groups.
  4. 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:

  1. Export the UTM API into raw JSON.
  2. Build an object reference index.
  3. Generate readable firewall/NAT/routing reports.
  4. Identify dead services, old VPNs, old mail protection, and stale WAF rules.
  5. Create target-platform aliases.
  6. Create target-platform service aliases.
  7. Convert high-value firewall rules first.
  8. Leave questionable imported rules disabled or marked for review.
  9. Manually validate NAT, routing, and special firewall-local rules.
  10. 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.