Skip to content
Avanet

Manage Sophos Switch via CLI, local REST API, and Fusion API

The local CLI and local REST API access one Sophos Switch directly. The Sophos Fusion Switch Management API is separate: it uses tenant-level service-principal credentials and distributes central policies to switches. This runbook identifies the applicable identity, base URL, and validation for both paths.

Scope and safe preliminary decisions

This runbook covers the following:

  • local CLI access over an approved device-management path, particularly SSH;
  • orientation and diagnosis in the CLI;
  • signing in to the device-local REST API;
  • the lifecycle of the session-specific bearer token;
  • a documented, non-modifying API test with GET /api/ports;
  • safe validation, troubleshooting, rollback, and session termination.

It deliberately does not provide a complete catalog of API endpoints or CLI commands. The central documentation provides guidance, but you must verify availability on the target device before using the API. Paths shown there, such as /ports, are relative to the server base /api, so the full request path is /api/ports. CLI commands, modes, and parameters can also vary by model and firmware. See Firmware and model variability for the required checks.

Decide the following before access:

  1. Is Sophos Fusion or local management the system of record?
  2. Which switch, model, firmware version, and management IP are affected?
  3. Is read-only access sufficient, or is an approved change required?
  4. What are the current state, success criteria, and rollback procedure?
  5. Is there an independent management path if the change interrupts normal access?

Keep identities and roles separate

The accounts under People are local accounts of the switch:

Local privilege typeRights on the switchTypical use
AdminView and change all switch functionsApproved local administration and write API requests
UserView settings, but do not change themDiagnostics and verification according to the principle of least privilege

These local roles are not the same as administrator roles in Sophos Fusion. A Fusion role does not automatically grant local CLI or API rights, and local credentials are not Fusion credentials. Send the credentials of a local switch account to /api/system/login.

Local accounts are managed in the local interface under People:

  1. Select Add to create an account, or choose Edit next to an account.
  2. Set Username, Password, and Privilege type.
  3. Select Privilege type as either Admin or User.
  4. Save with Apply.

A local password must meet all of the following requirements:

  • at least 10 and not more than 32 characters;
  • at least one letter and a number;
  • at least one of these special characters: @ ~ % * # + - =.

A local Admin account can change the passwords of other accounts, but not the password of the default admin account. Only the admin account itself or Sophos Fusion can change that password. For routine operation, use individual local accounts instead of a shared admin account.

Prepare access

Before a session:

  • Check management IP, model, firmware status, location and serial number against the change ticket.
  • Allow access only from an administrative management network and from an authorized admin host.
  • Do not make management services accessible from user VLANs or the Internet.
  • Check the time source on the switch and administrator host; incorrect time complicates log and API analysis.
  • For a change, have a current configuration backup and an independent recovery path available.
  • For Fusion-managed switches, preserve the central target state and explicitly document the local exception.
  • Never store credentials or bearer tokens in ticket text, chats, screenshots, shell history, or source code.

Replace the following placeholders:

PlaceholderMeaning
<Switch-IP address>Management IP or a trusted management hostname of the target device
<LOCAL-USERNAME>Local switch account, not a Fusion user
your-passwordLocal password; a placeholder only, never a real password
xxxxxxxx.yyyyyyyy.zzzzzzMasked example of a bearer token, not a real token

Use CLI safely

Connect to the switch

SSH is one of the local management services. It must be configured on the installed firmware and accessible from the management network. A general client command is:

ssh <LOCAL-USERNAME>@<Switch-IP-address>

This is a client-side example: replace <LOCAL-USERNAME> and <Switch-IP-address> completely, including the angle brackets. Before accepting the host key, compare its fingerprint through an independent, trusted channel. Never ignore a changed-host-key warning; first rule out device replacement, a factory reset, an IP conflict, or a possible man-in-the-middle attack.

If the model has a physical console port, it can provide an independent maintenance path. Use the connection and serial parameters for that model; do not copy them from another Sophos Switch model.

Orienting in the CLI

First, note the current prompt and therefore the current command mode. Do not assume that a command is available in every mode. The documented help features and keys are:

InputEffect
?List available commands
TABComplete the command
Arrow up / downShow previously executed commands
Left / right arrowMove within the current line
Backspace or Ctrl + HDelete a character
HistoryShow the command history
QClose the current output and return to the switch prompt

Check capitalization and exact availability with ? or TAB on the target device. Q closes paginated or active output; it does not end the SSH session.

Secure CLI sequence

  1. Start with a local User account when read-only access is sufficient.
  2. Verify the target device, prompt, and command mode.
  3. Use ? to display the commands available in that mode.
  4. Begin with status or display operations only.
  5. Before a change, capture the complete current state and exact rollback procedure.
  6. Make only one technical change at a time and verify it immediately.
  7. Return to the prompt when output is page-by-page with Q.
  8. End the session cleanly with the exit or logout command shown by ? on the target device, then confirm that the SSH connection has closed.

Treat the command history as sensitive information: it can contain management addresses, usernames, or entered parameters. Never enter passwords or tokens as CLI parameters unless the switch prompts for them interactively without displaying them.

REST API: documented login

The API is available over HTTPS at the switch’s management address. The switch creates a new bearer token for every session. When the current session ends, you must obtain a new token.

The documented login uses PATCH /api/system/login. The following example preserves the published syntax while using neutral placeholders:

curl -k https://<Switch-IP address>/api/system/login -X PATCH -H 'Content-Type:application/json' -d '{"user":"admin","password":"your-password"}'

A successful example response has this structure:

{
  "restful_res": {
    "token": "xxxxxxxx.yyyyyyyy.zzzzzz",
    "utctimestamp": "##########",
    "timeout": 900,
    "errCode": 0,
    "message": "OK"
  }
}

The following points apply:

  • token is a secret with the same protection requirements as a password.
  • utctimestamp and timeout are part of the specific session.
  • The documented example response shows timeout: 900; the static description does not specify the unit. For automation, check its meaning in the published API help, validate it for the firmware in use, and do not hard-code it.
  • errCode: 0 and message: "OK" identify the successful response shown. Check the HTTP status as well.
  • Request a new token after the session ends or a rejected, expired session; do not reuse an old token.

Hardened cURL pattern

The following pattern keeps the password and token out of process arguments, verifies the TLS certificate, and does not write a secrets file. It requires curl, jq, and a shell that supports here strings:

read -r -p 'Local switch username: ' SW_USER
read -r -s -p 'Local switch password: ' SW_PASSWORD
printf '\n'

LOGIN_RESPONSE="$({
  printf '%s\n%s\n' "$SW_USER" "$SW_PASSWORD" |
  jq -Rn '[inputs] | {user: .[0], password: .[1]}' |
  curl --disable --silent --show-error --fail-with-body \
    --cacert /path/to/switch-ca.pem \
    --request PATCH \
    --header 'Content-Type:application/json' \
    --data-binary @- \
    'https://<Switch-IP-address>/api/system/login'
})"
unset SW_PASSWORD

if [ "$(jq -r '.restful_res.errCode' <<<"$LOGIN_RESPONSE")" != "0" ]; then
  printf '%s\n' 'API login failed.' >&2
  unset LOGIN_RESPONSE SW_USER
  exit 1
fi

SW_TOKEN="$(jq -er '.restful_res.token' <<<"$LOGIN_RESPONSE")" || exit 1
unset LOGIN_RESPONSE

case "$SW_TOKEN" in
  ''|*[!A-Za-z0-9._~+/=-]*)
    printf '%s\n' 'API login did not return a valid bearer token.' >&2
    unset SW_TOKEN SW_USER
    exit 1
    ;;
esac

Replace /path/to/switch-ca.pem and <Switch-IP-address>. Use the hostname for which the certificate was issued. In an interactive shell, first ensure that debug tracing such as set -x is disabled. Do not expose variables through env, export, debug output, or core dumps.

REST API: request and verification

The documented sample call reads the ports and passes the token in the Authorization header:

curl -k https://<Switch IP Address>/api/ports -H 'authorization:Bearer <Token>'

<Token> is deliberately only a placeholder for the bearer token. This published example is therefore non-executable: do not replace <Token> with the real token or enter such a command in the shell history. The executable call below instead uses the SW_TOKEN variable that was set during login.

For a real session, use the protected token and certificate verification. --config - reads the curl configuration from standard input, so the Authorization header is not passed as a process argument and no temporary file is created:

printf 'header = "Authorization: Bearer %s"\n' "$SW_TOKEN" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    --cacert /path/to/switch-ca.pem \
    'https://<Switch-IP-address>/api/ports'

GET /api/ports is the appropriate first function test because the documented call requests status instead of demonstrating a configuration change. The test is only successful if:

  1. TLS verification and the connection succeed;
  2. no HTTP error is returned;
  3. the response is syntactically and technically plausible;
  4. the port count, port names, and expected states match the intended target device;
  5. no access data or tokens appear in the output or in logs.

curl --fail-with-body returns a failure status for HTTP errors while retaining the response body for local diagnosis. Before sharing that response, check it for tokens, addresses, serial numbers, and other internal data.

Control write API requests

Make write requests only as approved, limited, and reversible changes. Do not reuse a payload from another model, firmware version, or old script without validation.

For each write request:

  1. Check the method, path, parameters, data types, and response schema in the target device’s Swagger/OpenAPI schema, and confirm availability for its running firmware.
  2. Capture the affected state immediately beforehand with an appropriate read operation and store it securely.
  3. Send only the minimum required fields; do not guess unknown defaults.
  4. Limit the request to one switch and a small, reversible scope.
  5. Evaluate HTTP status and application-specific fields such as errCode and message.
  6. Confirm the resulting state with an independent GET and, where relevant, a functional test.
  7. Stop if the result differs from expectations; do not send further changes in a retry loop.

A successful HTTP connection alone does not prove a successful change. Similarly, a plausible JSON body does not prove that the intended data path continues to work. For example, port, VLAN or management changes must also be tested from the affected network segment.

Manage bearer tokens securely throughout their lifecycle

  1. Generate: Obtain a new token for each API session through PATCH /api/system/login.
  2. Verify: Check the HTTP result, errCode, message, token field, and session values without printing the token.
  3. Use: Send the token only in the Authorization: Bearer <token> header and only to the intended switch. <Token> is a non-executable placeholder; the executable examples generate the header from SW_TOKEN.
  4. Limit: Do not export, persist, or share tokens, or write them to files, Git, CI logs, or tickets. Use a separate controlled session for each parallel job.
  5. Renew: After the session ends or the token is rejected, do not continue with the same token; establish a new session. Avoid endless automatic login loops.
  6. Close: Invoke the documented authenticated logout operation, PATCH /api/system/logout. Here, too, the header reaches curl through standard input rather than through process arguments:
printf 'header = "Authorization: Bearer %s"\n' "$SW_TOKEN" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    --cacert /path/to/switch-ca.pem \
    --request PATCH \
    'https://<Switch-IP-address>/api/system/logout'
  1. Discard locally: Remove the local variables after logout. If logout is no longer possible because the connection was interrupted or the session is already invalid, still discard the secrets locally and verify session termination as specified for the firmware in use:
unset SW_TOKEN SW_USER LOGIN_RESPONSE SW_PASSWORD
  1. Check: Inspect shell history, temporary files, and job logs for accidentally exposed secrets. Treat an exposed token as compromised, end the session, and do not send any further requests with it.

Tenant-level Sophos Fusion Switch Management API

This section does not use https://<Switch-IP-address>/api/.... The Fusion API authenticates a service principal against the regional Sophos Fusion (formerly Sophos Central) host and acts on the specified tenant. Local People accounts, Admin/User roles, local session tokens, and the device Swagger schema do not apply.

Prerequisites, roles, and credentials

The switch must be registered in the correct tenant and managed by Sophos Fusion. The executable workflow below applies only to direct API credentials issued for that tenant (client_id and client_secret). Only a Super Admin of the direct tenant can create them under Global Settings > Access Control > API Credentials; the assigned service-principal role must permit the required reads and writes. Do not use Partner or Enterprise credentials with these shell examples. For those credentials, first follow the separate tenant-selection procedure in Manage Sophos Fusion API credentials securely. Return to this workflow only after credentials have been issued and validated specifically for the selected direct tenant.

Keep the secret and JWT in a secret store, never in scripts, tickets, shell history, or CI output. You need curl, jq, Bash, an approved change window, and a recorded tenant ID, region, current list, complete target list, and rollback plan. API credentials do not replace a license or Support Subscription.

Authenticate the service principal and discover the regional host

The identity provider (IDP) issues the JWT through POST https://id.sophos.com/api/v2/oauth2/token. For the direct-tenant credentials required here, GET https://api.central.sophos.com/whoami/v1 returns id and apiHosts.dataRegion. The workflow stops if whoami does not return a tenant identity. Never send a partner or organization ID as X-Tenant-ID; do not guess the regional host or copy it from another tenant.

read -r -p 'Service principal client ID: ' SP_CLIENT_ID
read -r -s -p 'Service principal client secret: ' SP_CLIENT_SECRET
printf '\n'

TOKEN_RESPONSE="$({
  jq -rn --arg id "$SP_CLIENT_ID" --arg secret "$SP_CLIENT_SECRET" \
    '"grant_type=client_credentials&client_id=\($id|@uri)&client_secret=\($secret|@uri)&scope=token"' |
  curl --disable --silent --show-error --fail-with-body \
    --header 'Content-Type: application/x-www-form-urlencoded' \
    --data-binary @- \
    'https://id.sophos.com/api/v2/oauth2/token'
})"
unset SP_CLIENT_SECRET
FUSION_TOKEN="$(jq -er '
  select(.token_type == "bearer") |
  .access_token | select(type == "string" and length > 0)
' <<<"$TOKEN_RESPONSE")" || exit 1
unset TOKEN_RESPONSE
WHOAMI_RESPONSE="$(
  printf 'header = "Authorization: Bearer %s"\n' "$FUSION_TOKEN" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    'https://api.central.sophos.com/whoami/v1'
)"
UUID_RE='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$'
FUSION_TENANT_ID="$(jq -er --arg re "$UUID_RE" \
  'select(.idType == "tenant") | .id | select(type == "string" and test($re))' \
  <<<"$WHOAMI_RESPONSE")" || exit 1
FUSION_DATA_REGION="$(jq -er \
  '.apiHosts.dataRegion | select(type == "string" and test("^https://api-[a-z0-9-]+\\.central\\.sophos\\.com$"))' \
  <<<"$WHOAMI_RESPONSE")" || exit 1
unset WHOAMI_RESPONSE UUID_RE

Every Switch request requires both the Authorization: Bearer <token> and X-Tenant-ID: <tenant-id> headers, together with the discovered <data-region> host. The documented success statuses are 200 or 201; they prove only that the request was accepted.

Read and completely replace MAC filters safely

GET /switch/v1/settings/mac-filtering reads the tenant-wide list of blocked MAC addresses. PUT /switch/v1/settings/mac-filtering does not append: macAddresses must contain the complete desired list, which replaces the existing list. {"macAddresses":[]} clears every blocked entry.

The example adds a synthetic, locally administered 02: address. Do not publish a real client MAC address. The working files contain operational data and must be protected.

printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
  "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    "$FUSION_DATA_REGION/switch/v1/settings/mac-filtering" \
    > mac-filter-before.json

jq -e '.macAddresses | type == "array"' mac-filter-before.json >/dev/null || exit 1
jq '{macAddresses: (.macAddresses + ["02:00:00:00:00:51"] | unique)}' \
  mac-filter-before.json > mac-filter-desired.json
jq -S '.macAddresses' mac-filter-before.json mac-filter-desired.json

Write only after the complete diff has been approved. Between capturing the task baseline and saving the uniquely correlated task ID, no other macFilters change may run in the tenant:

printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
  "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    "$FUSION_DATA_REGION/switch/v1/tasks?type=macFilters&pageSize=50&pageTotal=true" \
    > mac-filter-tasks-before.json
jq -e '
  (.items | type == "array") and
  (.pages.current == 1) and
  (.pages.total >= 0) and (.pages.total <= 1) and
  ((.items | length) <= 50)
' mac-filter-tasks-before.json >/dev/null || exit 1

CHANGE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
  "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    --request PUT \
    --header 'Content-Type: application/json' \
    --data-binary @mac-filter-desired.json \
    "$FUSION_DATA_REGION/switch/v1/settings/mac-filtering" \
    > mac-filter-put-response.json

Read-back, task polling, and validation

A successful PUT does not prove that every switch applied the policy. Read back the exact setting, then query GET /switch/v1/tasks. Tasks are deleted after 30 days and are not a permanent audit archive. The documented filters are type, pageSize, and pageTotal. Because this workflow does not assume another documented page parameter, it processes no more than one complete page of 50 tasks and fails when pages.total > 1 instead of silently ignoring later pages.

printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
  "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    "$FUSION_DATA_REGION/switch/v1/settings/mac-filtering" \
    > mac-filter-after.json

jq -e '.macAddresses | type == "array"' mac-filter-after.json >/dev/null || exit 1
diff -u \
  <(jq -S '.macAddresses' mac-filter-desired.json) \
  <(jq -S '.macAddresses' mac-filter-after.json) || exit 1

CHANGE_TASK_ID=''
CHANGE_TASK_DONE=false
for CHANGE_POLL_ATTEMPT in $(seq 1 30); do
  printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
    "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
    curl --disable --silent --show-error --fail-with-body \
      --config - \
      "$FUSION_DATA_REGION/switch/v1/tasks?type=macFilters&pageSize=50&pageTotal=true" \
      > mac-filter-tasks.json

  jq -e '
    (.items | type == "array") and
    (.pages.current == 1) and
    (.pages.total >= 0) and (.pages.total <= 1) and
    ((.items | length) <= 50)
  ' mac-filter-tasks.json >/dev/null || exit 1

  if [ -z "$CHANGE_TASK_ID" ]; then
    jq -e -s --arg started "$CHANGE_STARTED_AT" '
      def epoch: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601;
      [.[0].items[].id] as $before |
      [.[1].items[] |
        select(
          .type == "macFilters" and
          (.id as $id | ($before | index($id) | not)) and
          ((.createdAt | epoch) >= ($started | epoch)) and
          ((.updatedAt | epoch) >= ($started | epoch))
        )
      ] | if length > 1 then error("multiple matching tasks") else . end
    ' mac-filter-tasks-before.json mac-filter-tasks.json \
      > mac-filter-task-matches.json || exit 1
    if jq -e 'length == 1' mac-filter-task-matches.json >/dev/null; then
      CHANGE_TASK_ID="$(jq -er '.[0].id | strings | select(length > 0)' \
        mac-filter-task-matches.json)" || exit 1
    fi
  fi

  if [ -n "$CHANGE_TASK_ID" ]; then
    jq -e --arg id "$CHANGE_TASK_ID" '
      [.items[] | select(.id == $id)] |
      select(length == 1) | .[0]
    ' mac-filter-tasks.json > mac-filter-task-current.json || exit 1

    if jq -e '.status.pending > 0' mac-filter-task-current.json >/dev/null; then
      :
    else
      jq -e '
        (.status.total | type == "number") and (.status.total > 0) and
        (.status.pending == 0) and (.status.failed == 0) and
        ((.status.noSupportSubscription // 0) == 0) and
        (.status.succeeded == .status.total) and
        (.switches | type == "array") and
        ((.switches | length) == .status.total) and
        all(.switches[];
          (.id | type == "string" and length > 0) and
          (.status == "succeeded") and
          (.error == null)
        )
      ' mac-filter-task-current.json >/dev/null || exit 1
      CHANGE_TASK_DONE=true
      break
    fi
  fi

  [ "$CHANGE_POLL_ATTEMPT" -lt 30 ] && sleep 10
done
[ "$CHANGE_TASK_DONE" = true ] || exit 1
jq '{id, type, createdAt, updatedAt, status, switches}' mac-filter-task-current.json

The workflow correlates exactly one new task by CHANGE_STARTED_AT, type: "macFilters", and the task IDs saved before the PUT. It then queries the collection at most 30 times at ten-second intervals and selects only the saved task ID. Success requires both a successful aggregate status and a terminal status of succeeded for every entry in switches[]; ambiguity, pagination, timeout, failures, and noSupportSubscription all cause the workflow to stop. The PUT is not repeated.

Handle Fusion API errors precisely

  • 401/403: Check JWT expiry, service principal, tenant context, and headers; local roles do not help.
  • Wrong tenant or regional host: Rediscover the tenant ID and host from whoami or the list of managed tenants.
  • HTTP 200/201 without effect: Check read-back and the matching task; continue bounded polling, but do not blindly repeat PUT.
  • noSupportSubscription: Correct the Support Subscription and device state in the correct tenant; there is no local workaround.
  • Code 10905 – Duplicate MAC filter policy: Inspect switches[].error, reread the state, and resolve a duplicate or stale request; never retry blindly.
  • Code 10906 – MAC filter list is exhausted: Stop and approve a smaller complete list; never send a subset as an append.
  • Code 10908 – MAC address already allowed in the static MAC table: Resolve the conflict and security impact; never remove static allow entries without review.

For failures, capture HTTP status, task ID, switch ID, status, error, message, and code together, and redact sensitive data.

Rollback and restore boundaries

Rollback requires another complete PUT of the saved list. Reread the current list first and rule out concurrent changes, then use the same read-back and task workflow until every switch reaches the required terminal status.

# Hold an exclusive change window before this fresh read.
printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
  "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    "$FUSION_DATA_REGION/switch/v1/settings/mac-filtering" \
    > mac-filter-pre-restore.json
jq -e '.macAddresses | type == "array"' mac-filter-pre-restore.json >/dev/null || exit 1
diff -u \
  <(jq -S '.macAddresses' mac-filter-desired.json) \
  <(jq -S '.macAddresses' mac-filter-pre-restore.json) || exit 1

printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
  "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    "$FUSION_DATA_REGION/switch/v1/tasks?type=macFilters&pageSize=50&pageTotal=true" \
    > mac-filter-restore-tasks-before.json
jq -e '
  (.items | type == "array") and
  (.pages.current == 1) and
  (.pages.total >= 0) and (.pages.total <= 1) and
  ((.items | length) <= 50)
' mac-filter-restore-tasks-before.json >/dev/null || exit 1

jq '{macAddresses}' mac-filter-before.json > mac-filter-restore.json
RESTORE_STARTED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
  "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    --request PUT \
    --header 'Content-Type: application/json' \
    --data-binary @mac-filter-restore.json \
    "$FUSION_DATA_REGION/switch/v1/settings/mac-filtering" \
    > mac-filter-restore-response.json

printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
  "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
  curl --disable --silent --show-error --fail-with-body \
    --config - \
    "$FUSION_DATA_REGION/switch/v1/settings/mac-filtering" \
    > mac-filter-restored.json
jq -e '.macAddresses | type == "array"' mac-filter-restored.json >/dev/null || exit 1
diff -u \
  <(jq -S '.macAddresses' mac-filter-restore.json) \
  <(jq -S '.macAddresses' mac-filter-restored.json) || exit 1

RESTORE_TASK_ID=''
RESTORE_TASK_DONE=false
for RESTORE_POLL_ATTEMPT in $(seq 1 30); do
  printf 'header = "Authorization: Bearer %s"\nheader = "X-Tenant-ID: %s"\n' \
    "$FUSION_TOKEN" "$FUSION_TENANT_ID" |
    curl --disable --silent --show-error --fail-with-body \
      --config - \
      "$FUSION_DATA_REGION/switch/v1/tasks?type=macFilters&pageSize=50&pageTotal=true" \
      > mac-filter-restore-tasks.json

  jq -e '
    (.items | type == "array") and
    (.pages.current == 1) and
    (.pages.total >= 0) and (.pages.total <= 1) and
    ((.items | length) <= 50)
  ' mac-filter-restore-tasks.json >/dev/null || exit 1

  if [ -z "$RESTORE_TASK_ID" ]; then
    jq -e -s --arg started "$RESTORE_STARTED_AT" '
      def epoch: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601;
      [.[0].items[].id] as $before |
      [.[1].items[] |
        select(
          .type == "macFilters" and
          (.id as $id | ($before | index($id) | not)) and
          ((.createdAt | epoch) >= ($started | epoch)) and
          ((.updatedAt | epoch) >= ($started | epoch))
        )
      ] | if length > 1 then error("multiple matching rollback tasks") else . end
    ' mac-filter-restore-tasks-before.json mac-filter-restore-tasks.json \
      > mac-filter-restore-task-matches.json || exit 1
    if jq -e 'length == 1' mac-filter-restore-task-matches.json >/dev/null; then
      RESTORE_TASK_ID="$(jq -er '.[0].id | strings | select(length > 0)' \
        mac-filter-restore-task-matches.json)" || exit 1
    fi
  fi

  if [ -n "$RESTORE_TASK_ID" ]; then
    jq -e --arg id "$RESTORE_TASK_ID" '
      [.items[] | select(.id == $id)] |
      select(length == 1) | .[0]
    ' mac-filter-restore-tasks.json > mac-filter-restore-task-current.json || exit 1

    if jq -e '.status.pending > 0' mac-filter-restore-task-current.json >/dev/null; then
      :
    else
      jq -e '
        (.status.total | type == "number") and (.status.total > 0) and
        (.status.pending == 0) and (.status.failed == 0) and
        ((.status.noSupportSubscription // 0) == 0) and
        (.status.succeeded == .status.total) and
        (.switches | type == "array") and
        ((.switches | length) == .status.total) and
        all(.switches[];
          (.id | type == "string" and length > 0) and
          (.status == "succeeded") and
          (.error == null)
        )
      ' mac-filter-restore-task-current.json >/dev/null || exit 1
      RESTORE_TASK_DONE=true
      break
    fi
  fi

  [ "$RESTORE_POLL_ATTEMPT" -lt 30 ] && sleep 10
done
[ "$RESTORE_TASK_DONE" = true ] || exit 1
jq '{id, type, createdAt, updatedAt, status, switches}' \
  mac-filter-restore-task-current.json

unset FUSION_TOKEN SP_CLIENT_ID FUSION_TENANT_ID FUSION_DATA_REGION \
  CHANGE_STARTED_AT CHANGE_TASK_ID RESTORE_STARTED_AT RESTORE_TASK_ID

mac-filter-before.json is only a snapshot of this setting, not a full switch backup. If the baseline is lost, never send an empty list as a reset: it clears every blocked entry. This API neither restores local CLI/REST configuration nor releases Active Threat Response isolation. Finally, unset the token variables, protect or securely delete the files, and record the task result.

Troubleshoot by symptom

TLS or certificate error

  • Check management name, IP, validity, certificate chain and system time.
  • Supply the correct CA with --cacert, or replace the device certificate through the approved management process.
  • Do not use -k as a permanent workaround. It encrypts the transport but does not authenticate the switch.
  • If the certificate or SSH host key has changed, first confirm that you are connecting to the intended switch.

Connection rejected, timeout or no route

  • Check the management IP, management VLAN, routing, ACL, and whether the service is enabled.
  • Test from an authorized host in the intended management network.
  • Do not bypass by opening the service for all networks or the Internet.
  • If a recent change interrupted access, use the independent management path and prepared rollback.

Login fails

  • Ensure that you are using a local switch account, not a Sophos Fusion account.
  • Check username, password requirements, account status and local role.
  • Do not make repeated automated guesses; they can trigger lockouts and obscure the cause.
  • For the standard account admin, note that its password is only changed by admin itself or Sophos Fusion.

HTTP 401 or 403

  • For 401, check whether the token or session has expired and sign in again if necessary.
  • For 403, check the local role and whether it permits the operation; do not grant broader rights by default.
  • Request a new token at most once in a controlled manner. If the error persists, securely capture the response and firmware version, consult the published API help, and compare the target device’s model and firmware.

HTTP 400, 404 or 405

  • Compare the path, method, headers, and JSON with the published API help, and validate them for the target device’s model and firmware.
  • Check capitalization and the /api prefix.
  • A 404 or 405 may indicate an unavailable endpoint or one defined differently in this firmware. Do not respond by trying similar write operations.

HTTP error with a response, or an errCode other than 0

  • Capture the HTTP status and response body together.
  • Record message, redact internal information before sharing, and do not blindly repeat an identical change.
  • If a change may have been partially applied, determine the actual state first with a GET, CLI output, and a functional test.

CLI command missing or rejected

  • Use ? to check whether the command exists in the current mode.
  • Use TAB to complete the syntax offered by the device.
  • Check the local role, command mode, model, and firmware.
  • Do not use a similar-sounding command from another firmware version.

Rollback, logout, and session termination

A GET /api/ports test does not change the configuration and therefore requires no configuration rollback. The API login does create a session, however, so end it and clear the local variables as described in the token lifecycle.

For a configuration change, define the rollback in advance:

  1. Export the current state and affected objects, or capture them with read operations.
  2. Prepare the exact inverse operation using the published API help validated for the installed firmware, or the target device’s CLI help.
  3. Define conditions that require an immediate abort, such as loss of management access, the uplink, VLAN connectivity, or PoE power.
  4. If an error occurs, do not attempt further optimization; restore the previous value through the path that still works.
  5. Then verify management access, port status, uplinks, and affected services again.
  6. If the normal path is lost, roll back through the previously verified independent management path, possibly the model-specific console port. A factory reset is not a normal rollback because it deletes the configuration.

For a switch managed in Sophos Fusion, a local rollback alone is not sufficient. Check the authoritative target state in Sophos Fusion, then reconcile an approved emergency change there or remove it completely from the local configuration. Do not alternate configuration changes between local and central management channels.

To finish:

  • exit a CLI output with Q and use the exit/logout command declared on the device at the prompt;
  • close the API session with the documented PATCH /api/system/logout;
  • discard local token and password variables with unset;
  • check that no temporary files or debug logs contain secrets;
  • document the result, firmware, management path used, verification, and any rollback in the ticket.

Safety hardening

  • Dedicated management VLAN with ACLs limited to a few admin hosts and required protocols.
  • Enable HTTPS and SSH only when needed; disable insecure or unused management services.
  • Use trusted certificates and verified SSH host keys.
  • Use individual local accounts: User for read-only access and Admin only for approved changes.
  • Replace default password immediately; avoid common passwords and rotate after personnel or service provider changes.
  • Keep API secrets out of source code, .env files, shell history, process arguments, and CI outputs.
  • Never run API clients with extensive header debugging as long as an Authorization header is set.
  • Keep sessions short, use a new token for each session, and then unset the variables.
  • Keep configuration backups protected and recoverable outside the switch.
  • Correlate local logs, central events, and change tickets by timestamp; verify the switch’s clock.
  • Regularly recertify local accounts, management ACLs, certificates, SSH keys and automation accesses.

Firmware and model variability

The static REST API page documents the login path /api/system/login, the read test /api/ports, and the Authorization header; the published API help also documents the logout path /api/system/logout. These central resources provide examples and guidance, but not a schema that applies universally to every device. The running target switch generates its own Swagger/OpenAPI schema for its model and firmware. The published CLI documents confirm the help features described above, but they do not guarantee that every additional command is identical across firmware versions.

Before production use, complete the following for each model and firmware version:

  • document the exact firmware version and hardware model;
  • Check CLI mode and syntax with ? and TAB;
  • check the target device’s Swagger/OpenAPI schema for the methods, paths, and schemas provided by the running firmware;
  • test login and GET /api/ports first in a controlled session;
  • validate all write automation against that exact target schema and in a non-production or clearly limited environment;
  • after firmware updates, retest login, certificate verification, the response schema, token handling, and every endpoint used;
  • if behavior differs, do not simply “make the script work” before understanding the new semantics and rollback requirements.

Final audit

  • Correct model, firmware, and management IP confirmed.
  • System of record—Sophos Fusion or local management—documented.
  • Local User or Admin account appropriate to the task used.
  • TLS certificate or SSH host key verified; no persistent insecure option used.
  • Token used only for its session and never disclosed.
  • HTTP status, errCode, message, and functional state checked.
  • For changes, current state, rollback, and independent access available.
  • Result verified through GET, CLI output, and any required functional test.
  • Session closed, variables deleted, and logs checked for secrets.
  • Local exception reconciled with Sophos Fusion and closed in the ticket.