Pair handshake protocol

Reference for the manager-initiated SSH pairing flow that joins a FreePBX PBX to a customsso-manager server. Written for auditors + future implementers.

Goal

Establish a long-lived, authenticated, IP-restricted SSH relationship between exactly one manager and one PBX such that:

What's exchanged

Direction Material Purpose
Operator → manager (one time) PBX SSH host + port + root password + optional label Bootstraps the whole flow. Manager wipes the password after handshake.
Manager → PBX (over SSH) ed25519 public key, freshly generated per PBX Installed into /root/.ssh/authorized_keys with from="<manager_ip>",no-pty,… restrictions
Manager → PBX (over SSH) HMAC secret (32 random bytes) Written to the agent's config; both sides use it to sign SSO landing tokens
Manager → PBX (over SSH) manager UUID + manager_url Recorded in the agent's config so it knows who owns it
PBX → manager (post-pair status probe) PBX UUID + hostname + disk / RAM / notification counts Manager stores it in pbxes row; refreshed every 60 s by the sidecar
(inferred from TCP layer) manager_ip = source IP of the SSH session TOFU — PBX records this as the allowed source IP for SSO landing redemption

All manager-side secrets (per-PBX SSH private key + HMAC secret) live in the pbxes table encrypted at rest with AES-256-GCM under the master key at /etc/customsso-manager/master.key.

Flow (current, v0.5+)

The pair is entirely manager-driven — no pair token, no PBX-side wizard, no copy/paste. The operator provides SSH credentials once, and the manager does everything over that SSH session.

┌──────────┐                                              ┌──────────┐
│ ADMIN    │                                              │ MANAGER  │
│ browser  │                                              │ sidecar  │
└────┬─────┘                                              └────┬─────┘
     │                                                          │
     │ 1. Add PBX form                                          │
     │    (ssh_host, ssh_port, ssh_root_pw, label)              │
     │ ────────────────────────────────────────────────────►    │
     │                                                          │
     │                                                          │ 2. INSERT jobs row
     │                                                          │    channel='pair', status='pending'
     │                                                          │
     │                                                          │ 3. Sidecar picks up the job,
     │                                                          │    opens SSH session to
     │                                                          │    root@ssh_host:ssh_port
     │                                                          │    using the provided password
     │                                                          │
     │                                                          │ 4. Over SSH, sidecar runs
     │                                                          │    pair_provisioner steps:
     │                                                          │
     │                                                          │    a. Fetch OS metadata
     │                                                          │    b. Generate per-PBX ed25519
     │                                                          │       SSH keypair (manager-side)
     │                                                          │    c. Install SSH pubkey into
     │                                                          │       /root/.ssh/authorized_keys
     │                                                          │       with from=<sidecar_ip>,
     │                                                          │       no-pty,no-X11,no-agent-fwd,
     │                                                          │       no-port-fwd
     │                                                          │    d. Download + install the
     │                                                          │       customsso FreePBX module
     │                                                          │       (fwconsole ma downloadinstall)
     │                                                          │    e. Write agent config:
     │                                                          │       manager_uuid, manager_url,
     │                                                          │       hmac_secret
     │                                                          │    f. Deploy sso_entry.php at
     │                                                          │       /var/www/html/customsso/
     │                                                          │    g. Add Apache Alias for
     │                                                          │       /customsso-sso
     │                                                          │    h. Record every path touched
     │                                                          │       in the pair manifest
     │                                                          │       /etc/customsso/pbx.manifest.json
     │                                                          │    i. Wipe the root SSH password
     │                                                          │       (passwd -d root) — the SSH
     │                                                          │       key is now the only way in
     │                                                          │
     │                                                          │ 5. Verify: reconnect over SSH
     │                                                          │    using key-only auth, run the
     │                                                          │    status probe once
     │                                                          │
     │                                                          │ 6. INSERT pbxes row with
     │                                                          │    encrypted ssh_privkey +
     │                                                          │    hmac_secret; job → 'complete'
     │                                                          │
     │ 7. Operator sees PBX in fleet view;                      │
     │    status badge flips to 'ok' on the                     │
     │    next 60 s status refresh                              │
     │                                                          │

Every step is streamed to a per-job transcript visible at /pair/progress/{job} while it's running, then persisted for 7 days at /pair/batch/{batch} and on the PBX's own Pair log tab under its detail page.

Trust chain

Trust assumptions

Manifest — the record of everything the manager touched

At pair time the sidecar builds /etc/customsso/pbx.manifest.json on the PBX listing every file created + every config edit made. Sample:

{
  "manager_uuid": "0f0…",
  "manager_url":  "https://sso.example.com",
  "paired_at":    "2026-07-12T18:03:44Z",
  "files": [
    "/var/www/html/customsso/sso_entry.php",
    "/etc/httpd/conf.d/customsso-alias.conf",
    "/etc/customsso/agent.conf",
    "/root/.ssh/authorized_keys.customsso.d/manager.pub"
  ],
  "authorized_keys_line": "from=\"10.0.0.10\",no-pty,no-X11,no-agent-forwarding,no-port-forwarding ssh-ed25519 AAAA…",
  "freepbx_modules": ["customsso"]
}

/usr/local/bin/customsso-unpair iterates this file and undoes exactly what pair time wrote — no orphan configs, no leftover keys.

Revoke flow

Either side can initiate. Both use SSH — that's the only channel between them.

Manager-initiated

PBX detail → Unpair:

  1. Manager SSHes into the PBX as root using the stored per-PBX SSH key
  2. Manager reads /etc/customsso/pbx.manifest.json
  3. Manager removes every file listed
  4. Manager strips its from= line from /root/.ssh/authorized_keys
  5. Manager uninstalls the customsso FreePBX module
  6. Manager DELETEs the pbxes row (cascade also drops orphan jobs)

PBX-initiated (admin at the PBX box)

Admin runs sudo /usr/local/bin/customsso-unpair on the PBX:

  1. Script reads the manifest and removes every file it lists
  2. Script strips the manager's line from /root/.ssh/authorized_keys
  3. Script uninstalls the customsso FreePBX module
  4. Manager's outbound SSH now fails (key auth refused)
  5. Manager UI shows the PBX as offline; admin can delete the pbxes row

After revoke, re-pair via the normal flow generates fresh SSH keypair + HMAC secret material.

Failure modes + diagnostics

Symptom Likely cause
pair job failed — auth_failed The root password provided didn't work. Wrong password, PubkeyAuthentication-only SSH config, or root login disabled.
pair job failed — module_install_failed fwconsole ma downloadinstall customsso failed. Usually because the PBX can't reach the customsso module repo URL configured on the manager (Settings → Advanced → PBX module repo).
pair job failed — apache_reload_failed The Apache Alias write took but the reload failed. Usually SELinux — check /var/log/audit/audit.log on the PBX for the denial.
Post-pair status shows offline immediately Sidecar can reach the PBX at pair time but the stored ssh_host differs from what the sidecar uses on refresh. Check the PBX detail → Access panel.
SSO landing returns "invalid manager IP" The manager_ip TOFU'd at pair time doesn't match the sidecar's current source IP. NAT change on the manager or a multi-homed box — Settings → Advanced → Public manager URL + re-push.