# Site Provisioning Pipeline — kaburu-provision.sh
One-shot provisioning for a new client site: DNS + hosting + mail + DKIM + SSL + WordPress. The “cPanel-like” consistency layer bolted onto CyberPanel. Built 2026-08-04, tested end-to-end on soho-networking.co.uk.
## Location
- Script: `/root/kaburu-provision.sh` on kaburusvr - Log: `/var/log/kaburu-provision.log` - Local master copy: `/home/kaburu/provision/kaburu-provision.sh` (kaburuaibox)
## Usage
```bash /root/kaburu-provision.sh <domain> –email <admin@email> [–wp] [–mailbox] [–clean] [–skip-mail] [–skip-ssl] [–dry-run] ```
| Flag | Meaning |
| —— | ——— |
| `–email` | Admin email (required). Local part becomes the mailbox if `–mailbox` |
| `–wp` | Install WordPress via canonical `/usr/local/bin/wp-install.sh` (full set) |
| `–mailbox` | Create mailbox `<localpart>@<domain>` (generated password) |
| `–clean` | Migration mode: strip stale old-host records (cPanel leftovers, old MX/SPF/DKIM) before laying golden template |
| `–dry-run` | Print the plan, execute nothing |
## Phases
| Phase | What | Mechanism |
| ——- | —— | ———– |
| P0 | Preflight | domain checks, discovers admin + package from CyberPanel DB |
| P1 | Cloudflare zone + golden DNS template | A `@`/`www`/`mail`(grey)/`webmail`, MX→mail.kaburu.cc, SPF (`include:spf.kaburu.cc -all`), DMARC (`p=quarantine; [email protected]`) — idempotent (create-if-missing) |
| P1b | Stale record cleanup (`–clean`) | deletes golden-name conflicts + old cPanel service records (cpanel/whm/ftp/webdisk/autodiscover/autoconfig/SRV) |
| P2 | CyberPanel website | `WebsiteManager.submitWebsiteCreation` via Django shell (not the v2 API — API requires adminPass we don't hold); `ssl:0` (panel's internal LE is broken: “Websites matching query does not exist”); resume-safe (dir+DB row exist → skip). CRITICAL: data dict must be the MINIMAL shape below — any of `path`/`HA`/`apacheBackend`/`mailDomain`/`alias`/`masterDomain` keys flips CyberPanel into child-domain mode → site gets user `nobody`/`nobody0` instead of a proper `kaburXXXX` user, plus a broken vhost (apache-backend proxy rewrite to dead 8082/8083, no direct LSAPI handler) |
| P2x | Quirks (always run) | public_html 755, dirs 755, php 644, `.user.ini` 640 + open_basedir, owner = vhost linux user. Missed = 404/403 (hit on soho: CyberPanel creates 0750) |
| P3 | Mail provisioning | e_domains insert, opendkim-genkey, KeyTable/SigningTable/TrustedHosts, reloads; optional mailbox (e_users insert) |
| P4 | DKIM publish | `default._domainkey.<domain>` TXT from the generated key (closes the CyberPanel publish gap) |
| P5 | SSL — LE wildcard | `acme.sh –issue -d <domain> -d '*.<domain>' –dns dns_cf` → install to `/etc/letsencrypt/live/<domain>/` (canonical path the vhost already reads — NO vhost.conf edits) + reloadcmd. Extensions: postfix SNI map entry (key-first PEM, `postmap -F`), dovecot `local_name mail.<domain>` block → mail clients get the publicly-trusted cert |
| P6 | WordPress | calls `/usr/local/bin/wp-install.sh <domain> <title> full` (DB `<linuxuser>_wp`, en_GB, admin kaburu/Kaburu2026, full plugin set) |
| P7 | Verify | dig SPF/DKIM/DMARC/MX, TLS on web + mail SNI, test send + DKIM signing check |
## Golden DNS template
``` A @ → 49.13.202.144 (proxied) A www → 49.13.202.144 (proxied) A mail → 49.13.202.144 (NOT proxied — mail must reach origin) A webmail → 49.13.202.144 (proxied) MX @ → mail.kaburu.cc (10) TXT @ → v=spf1 include:spf.kaburu.cc -all TXT _dmarc → v=DMARC1; p=quarantine; rua=mailto:[email protected] TXT default._domainkey → (from generated key, P4) ```
SPF anchor `spf.kaburu.cc` = `v=spf1 ip4:49.13.202.144 -all` (created 2026-08-04 — was referenced but missing, causing SPF permerror).
## Key gotchas encoded (from the soho test run)
1. CyberPanel v2 API needs adminPass (validated) — we don't hold it → use Django shell `submitWebsiteCreation` instead. Same code the UI runs. 2. Panel's internal LE (installSSLForDomain) fails on this install (“Websites matching query does not exist” — partial site created). Pass `ssl:0`; SSL is P5's job. 3. public_html is created 0750 → 404/403 until chmod 755 + files 644 (wiki quirk; enforced in P2x always). 4. e_users schema is minimal: `email, password, mail, DiskUsage, emailOwner_id` (emailOwner_id = the DOMAIN string). Password format `{CRYPT}` + bcrypt (`doveadm pw -s BLF-CRYPT`). Mail path prefixed `maildir:`. 5. SSL canonical path is `/etc/letsencrypt/live/<domain>/` — CyberPanel writes certFile/keyFile into vhost.conf at creation; just drop the cert there. Do NOT sed vhost cert paths (sed targets `sslCertFile` which doesn't match the `vhssl { keyFile }` block). 6. Wildcard cert (`domain` + `*.domain`) covers web + mail + everything; one LE issuance per domain instead of 38 individual certs. 7. `–clean` on a re-run deletes + recreates the golden records — converges correctly but causes a brief DNS blip; use it only for first-time migration. 8. P2 data dict — NEVER include child-mode keys (2026-08-07, kaburu.uk + l82.kaburu.co.uk double failure): `submitWebsiteCreation` does `try: HA = data['HA']; externalApp = 'nobody'` — if the dict contains `HA` at all, the site gets user `nobody` (or `nobody0`/`nobody01` on collision) instead of a proper `kaburXXXX` user, and the generated vhost is broken: apache-backend proxy rewrite to dead ports 8082/8083, no direct LSAPI scripthandler. The script's P2 previously shipped `'path','HA','apacheBackend','mailDomain','alias','masterDomain'` — all removed 2026-08-07. Correct minimal dict:
```python
data = {
'domainName': '<domain>', 'adminEmail': '<email>', 'phpSelection': 'PHP 8.3',
'package': '<pkg>', 'websiteOwner': 'admin',
'ssl': 0, 'dkimCheck': 1, 'openBasedir': 1, 'websitesLimit': 1, 'acl': 'user',
}
```
Verify after creation: `stat -c %U /home/<domain>` must be a `kaburXXXX`-style user (NEVER `nobody*`), and `grep -c apachebackend /usr/local/lsws/conf/vhosts/<domain>/vhost.conf` must be 0. Recovery from a nobody-site: delete DB rows (childdomains FK first!), `userdel`, `rm -rf` home + vhost dirs, remove httpd_config maps + virtualHost blocks, restart lshttpd, recreate with minimal dict. See `log/log.md` 2026-08-07 for the full worked example.
## Manual steps after provisioning (not yet automated)
- Registrar nameservers → Cloudflare (new domains; the CF zone must be active) - MainWP dashboard → Add Site (mainwp-child is installed by `full`) - Matomo site creation (stats.kaburu.co) - Wordfence WAF auto_prepend vhost edit (see cyberpanel Wordfence section) - Cloudflare SSL mode Full (Strict) for the zone
## CyberPanel GUI plugin (2026-08-04)
A GUI wrapper for the pipeline lives in the panel: Plugins → Provision Site (sidebar, under Plugins submenu).
- Page: `/provision/` — domain + admin-email inputs, option checkboxes (WP, mailbox, clean migration, dry run), each with hover tooltips, and a 🚀 Provision button - POST `/provision/submit` — validates (regex whitelist), launches the script in the background (never blocks the browser), returns immediately - GET `/provision/status` — page polls every 3s, streams the live log into a dark console; shows “✓ Provision complete” when done - Run logs: `/var/log/kaburu-provision-ui/<domain>.log` (per-run) + `/var/log/kaburu-provision.log` (all runs) - Auth: session-gated like every panel page (session `userID`); endpoints use `@csrf_exempt` matching the panel's own AJAX convention - Source: `/usr/local/CyberCP/provision/` (mirror on kaburuaibox at `/home/kaburu/provision/plugin/provision/`)
### Install recipe (if it ever needs rebuilding)
1. scp plugin tree → `/usr/local/CyberCP/provision/` 2. settings.py INSTALLED_APPS: add `'provision',` 3. urls.py: add `path('provision/', include('provision.urls')),` 4. `touch /home/cyberpanel/plugins/provision` 5. baseTemplate sidebar: link after “Installed” in plugins-submenu 6. `manage.py collectstatic` → copy provision.js to `/usr/local/lscp/cyberpanel/static/provision/` 7. `systemctl restart lscpd`
### CRITICAL — file perms (the panel-outage lesson, 2026-08-04)
The panel's WSGI worker runs as the cyberpanel user. Anything dropped into `/usr/local/CyberCP/` that the worker can't read crashes every panel request (Premature end of response header → 500 on everything, including `/`). scp as root leaves files `root:root 640` — invisible to the worker.
```bash chown -R root:cyberpanel /usr/local/CyberCP/<plugin>/ chmod -R 755 /usr/local/CyberCP/<plugin>; find … -type f -exec chmod 644 {} + systemctl restart lscpd ```
Test before touching: `sudo -u cyberpanel /usr/local/CyberCP/bin/python -c “import django,os; os.environ.setdefault('DJANGO_SETTINGS_MODULE','CyberCP.settings'); django.setup(); print('ok')“`
## Relationship to existing procedures
- Replaces the manual Steps 1–5 of wordpress-new-site for the DNS/site/mail/SSL parts; P6 delegates to `wp-install.sh` (which remains canonical for WP itself) - SSL follows ssl-issuance exactly (DNS-01, canonical path) - DKIM publish matches `publish-dkim.sh` logic per-domain