# 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 --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 `@` (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; rua=gwen@kaburu.cc`) — 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.` TXT from the generated key (closes the CyberPanel publish gap) | | P5 | SSL — LE wildcard | `acme.sh --issue -d -d '*.' --dns dns_cf` → install to **`/etc/letsencrypt/live//`** (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.` block → mail clients get the publicly-trusted cert | | P6 | WordPress | calls `/usr/local/bin/wp-install.sh 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:gwen@kaburu.cc 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