Skip to content

Read the cPanel hook payload where cPanel puts it - #90

Merged
0kaba0 merged 3 commits into
developfrom
fix/cpanel-hook-payload
Sep 6, 2026
Merged

0kaba0 merged 3 commits into
developfrom
fix/cpanel-hook-payload

Conversation

@0kaba0

@0kaba0 0kaba0 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Creating an account on the live cPanel box produced nothing: no queue entry, no log line, not even an error. The zone existed in PowerDNS, the hook was registered on Accounts::Create at stage post, and the script it pointed at worked when run by hand.

The script reads stdin and looks for domain at the top level. This is what cPanel actually sends:

{"context":{"category":"Whostmgr","event":"Accounts::Create","stage":"post"},
 "hook":{"exectype":"script","hook":"/usr/local/bin/seconddns-cpanel-domain_create.sh"},
 "data":{"user":"second555domain","domain":"second555domain.org","plan":"default", ...}}

The name is under data. Finding nothing, the hook does exit 0 without logging — so account creation on cPanel has never reached SecondDNS, and never said so. The delete hook has the same bug, and so does the addon-domain path.

This also explains the installer's own account sync that #88 removed: with the hooks silently doing nothing, something had to register the zones.

Fix

Both hooks read data when it is an object, falling back to the top level so any older or different shape keeps working.

Tests

tests/test_cpanel_hook_payload.sh extracts the parser out of each hook file — so the test cannot drift from the code it checks — and feeds it:

  • the real WHM payload captured from the live server
  • the flat top-level shape
  • a payload whose data has no domain (must yield nothing, not invent one)
  • malformed input
  • an addon domain arriving as newdomain under data

Mutations: reading the top level fails 2; dropping newdomain fails 1.

Note on how it hid

The silent exit 0 is what made this survive: a hook that cannot find its subject looks exactly like a hook that had nothing to do. Worth a follow-up — a hook that receives a payload it cannot read should say so — but that is a change in behaviour for every panel, so not in this PR.

Creating an account on the live box produced nothing: no queue entry, no log
line, not even an error. The zone existed in PowerDNS and the hook was
registered on Accounts::Create at stage post, pointing at a script that worked
when run by hand.

The script reads stdin and looks for "domain" at the top level. cPanel sends:

    {"context":{"category":"Whostmgr","event":"Accounts::Create","stage":"post"},
     "hook":{...},
     "data":{"user":"acct","domain":"second555domain.org", ...}}

The name is under "data". Finding nothing, the hook exits 0 without logging —
so account creation on cPanel has never reached SecondDNS, silently. The same
shape applies to the delete hook and to addon domains.

That also explains the installer's own account sync removed in #88: with the
hooks not firing, something had to register the zones.

Both hooks now read "data" when it is an object and fall back to the top level.

tests/test_cpanel_hook_payload.sh extracts the parser from each hook — so the
test cannot drift from the code — and feeds it the real WHM payload, the flat
shape, a payload with no domain, malformed input, and an addon domain arriving
as newdomain. Mutations: reading the top level fails 2, dropping newdomain
fails 1.
Two more, both found on the live box after the payload fix.

Accounts::Remove carries only the user:

    "data":{"killdns":1,"user":"second444mydom","keep_remote_databases":0}

There is no domain to read, so deleting an account removed nothing. The
installer already registers this at stage pre — the comment says "domain data
available before account is gone" — but the hook never used it. It now reads
userdata/<user>/main and queues every domain of the account: main, addon and
parked alike. Parsed by hand rather than through PyYAML, which is not a
dependency anywhere else.

And addon domains never fired at all: the installer registered
Api2::AddonDomain::addaddon, an event current cPanel does not have. Addon,
parked and alias domains all go through Whostmgr::Domain::park / ::unpark,
which carry target_domain (the account's main domain) and new_domain (the one
being added). Reading "domain" would have registered the wrong name. Both
events are now registered; the Api2 pair stays as a fallback for older cPanel.

Live, deleting an account with three domains:

    10:13:59  Zone deleted: addon4444domain.info   (addon)
    10:13:59  Zone deleted: second777domain.com    (main)
    10:13:59  Zone deleted: second1123domain.net   (alias)
    10:14:25  all delivered

Tests cover the park payload for both hooks, every domain of an account from
userdata, the DNS= fallback, an unknown user yielding nothing, and an explicit
domain still winning. Mutation: dropping new_domain fails 1 — the hook would
take target_domain, registering the account's main domain in place of the
addon.
@0kaba0

0kaba0 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Two more defects, both found on the live box after the payload fix landed. Same PR, same root cause: nobody had ever watched these hooks run.

Deleting an account removed nothing. Accounts::Remove carries only the user:

"data":{"killdns":1,"user":"second444mydom","keep_remote_databases":0}

No domain to read. The installer already registers this at stage pre — its comment even says "domain data available before account is gone" — but the hook never used that. It now reads userdata/<user>/main and queues every domain of the account: main, addon and parked. Parsed by hand rather than through PyYAML, which nothing else here depends on.

Addon domains never fired at all. The installer registered Api2::AddonDomain::addaddon — an event current cPanel does not have. Addon, parked and alias domains all go through Whostmgr::Domain::park / ::unpark, and those carry two names:

"data":{"target_domain":"second777domain.com","new_domain":"addon4444domain.info","user":"acct"}

target_domain is the account's main domain, new_domain is the one being added. Reading domain would have registered the wrong name entirely. Both events are now registered; the Api2 pair stays as a fallback for older cPanel.

Live

Creating an alias, then an addon, then deleting the account that held them:

10:03:45  Zone created: second2domain.net        (alias, via park)
10:08:00  Zone created: addon4444domain.info     (addon, via park)
10:09:10  Zone deleted: second2domain.net        (via unpark)
10:13:59  Zone deleted: addon4444domain.info
10:13:59  Zone deleted: second777domain.com
10:13:59  Zone deleted: second1123domain.net
10:14:25  all delivered

Three domains of one account, one hook, one event.

Tests

13 checks now: the park payload for both hooks, every domain of an account read from userdata, the DNS= fallback when userdata is missing, an unknown user yielding nothing rather than a guess, and an explicit domain still winning over the lookup. Mutation: dropping new_domain fails 1 — without it the hook takes target_domain, registering the account's main domain in place of the addon.

One observation, not fixed here

The owner check inside the delete hook reports API unreachable when cPanel runs it, while the same call from a shell answers instantly. Delivery re-checks and the outcome was correct, so nothing was lost — but it means that guard currently rests on the second line only. Worth its own look.

@0kaba0

0kaba0 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

One item, then ready. The three fixes are right and the live proof (alias, addon, account with three domains — three zones removed by one hook) is the kind that counts; park/unpark registration with the Api2 events kept as fallback, new_domain over target_domain, and the pre-stage userdata read all check out. The item: in domain_delete.sh the account path fails silently twice — 'except Exception: try … except Exception: pass' and the whole python under 2>/dev/null — so an account whose userdata cannot be read is removed from the panel while every one of its zones stays on the secondary, with no line anywhere. That is the silent-wrong-answer class again. When the list comes back empty for a user (not for a single-domain event), log it with the user name before exiting 0, and add the row: userdata absent → one log line, nothing queued. Then ready. CI green, no attribution trailers, comments within two lines.

The lookup failed twice in silence: except Exception: pass inside the parser
and 2>/dev/null around it. An account whose userdata cannot be read would
disappear from the panel with every one of its zones left on the secondary and
nothing in the log to say so.

With a user in the payload and no domains found, the hook now logs the account
name and the path to check, and queues nothing. Mutation: dropping that line
fails the test.
@0kaba0

0kaba0 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Done — and you named the shape exactly: two layers of silence, except Exception: pass inside the parser and 2>/dev/null around the whole call.

With a user in the payload and no domains found, the hook now writes one line and queues nothing:

[!] No domains found for account 'nosuchuser' — its zones stay on the secondary; check /var/cpanel/userdata/nosuchuser/main

Verified on the live box with a user that does not exist: the line appears, seconddns-queue status stays at pending=0 failed=0.

The test needed SECONDDNS_CONFIG and SECONDDNS_LOG to be overridable to check this at all — the hook exits early without a config, so my first version of the row passed for the wrong reason (empty log, no config, no line). It now supplies a config and asserts the line by name. 14 checks; mutation: dropping the log line fails 1.

@0kaba0

0kaba0 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Verified: an account whose domain list comes back empty now logs it by name with the path to check, the row asserts the log line for a missing userdata, CI green, no attribution trailers, comments within two lines. Ready to merge into develop; the rest of the cPanel circle runs on the merged state.

@0kaba0
0kaba0 merged commit e2b8abe into develop Sep 6, 2026
1 check passed
@0kaba0
0kaba0 deleted the fix/cpanel-hook-payload branch September 6, 2026 10:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant