Skip to content

Fix installer DB connection on aaPanel + sanitized errors#18

Merged
ChesnoTech merged 1 commit into
developfrom
fix/installer-aapanel-db
May 7, 2026
Merged

Fix installer DB connection on aaPanel + sanitized errors#18
ChesnoTech merged 1 commit into
developfrom
fix/installer-aapanel-db

Conversation

@ChesnoTech
Copy link
Copy Markdown
Owner

Summary

Web installer failed during DB step on aaPanel because PDO treated localhost as a Unix-socket request and the panel's socket lives at /tmp/mysql.sock (or /www/server/mysql/mysql.sock), not PHP's default path.

Branch Type

  • fix/

Fixes

  1. Default host localhost127.0.0.1 (force TCP)
  2. Auto-coerce localhost127.0.0.1 when no Unix socket given
  3. New optional Socket Path field (collapsible Advanced section)
  4. New helpers: installerBuildDsn() + installerFriendlyDbError()
  5. Sanitize PDO error messages — strip DSN fragments that could leak host/user
  6. aaPanel-aware error remediation hints
  7. PDO timeouts (10s test, 15s install) — installer can no longer hang
  8. Port cast to int; socket persisted in session

Components Affected

  • API Backend (PHP controllers)

Testing Checklist

  • PHP lint pass on both files
  • npm test 14/14 pass

The web installer failed on aaPanel-style stacks because PDO interpreted
'localhost' as a Unix-socket connection request, then looked for the
default socket path (which doesn't match aaPanel's /tmp/mysql.sock or
/www/server/mysql/mysql.sock) and failed with "No such file or directory".

Code review fixes:

1. Default DB host changed from `localhost` -> `127.0.0.1` to force TCP.
2. Auto-coerce `localhost` -> `127.0.0.1` whenever no explicit Unix socket
   path was supplied (both in handleTestDb() and getInstallerPdo()).
3. New optional "Socket Path" field in step 2 (collapsible Advanced
   section) for installs that genuinely require a Unix socket.
4. New helper installerBuildDsn() to build the DSN consistently for both
   TCP and unix_socket modes; replaces 3 duplicated string concatenations.
5. New helper installerFriendlyDbError() that:
   - Sanitizes any DSN fragment that could leak host/port/user
   - Maps common errors (Access denied, Unknown database, Connection
     refused, No such file, getaddrinfo, timeout) to clear, aaPanel-aware
     messages with concrete remediation steps.
6. Added 10s/15s PDO timeouts on every connection so the installer can
   never hang.
7. Port is cast to int (was passed as string).
8. Socket path is persisted in $_SESSION['install_db'] so subsequent
   migration / admin-create / finalize steps reuse it.
9. Frontend now sends `db_socket` in `dbCredentials`.
@ChesnoTech ChesnoTech merged commit b27a540 into develop May 7, 2026
4 checks passed
@ChesnoTech ChesnoTech deleted the fix/installer-aapanel-db branch May 7, 2026 03:24
ChesnoTech added a commit that referenced this pull request May 7, 2026
* Complete Turkish translation + register QC recheck actions (#13)

- Turkish (tr.json): expanded from 399 to 1926 lines (~76% translated)
  using word-mapping generator. Remaining 463 keys are technical terms.
- Register handle_qc_recheck_count and handle_qc_recheck_historical
  in admin_v2.php action registry (were defined but unregistered).
- Add qc_recheck_count and qc_recheck_historical to api-contracts.test.ts.

Audit result: 0 action registry mismatches, 14/14 tests pass.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Add production deployment script for Ubuntu 22 + Docker (#14)

One-command installer for Proxmox/Ubuntu VMs:
- Auto-installs Docker if missing
- Clones KeyGate from GitHub
- Generates .env with secure random passwords
- Creates self-signed SSL cert
- Builds and starts Docker stack
- Creates admin user with super_admin role
- Verifies health endpoint
- Prints access URLs and credentials

Usage: curl -fsSL https://raw.githubusercontent.com/.../install.sh | sudo bash

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Fix deploy script + Docker healthcheck bugs found during testing (#15)

deploy/install.sh:
- Fix: acl_roles column is role_name not role_key
- Fix: admin_users.email is required (NOT NULL)
- Fix: set must_change_password=0 for initial admin
- Use ON DUPLICATE KEY UPDATE for idempotent role creation

docker-compose.yml:
- Fix: healthcheck was hitting /activate/ (404) instead of
  /api/health.php — DocumentRoot IS /var/www/html/activate
  so the correct internal URL is /api/health.php

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Integrate graphify knowledge graph into project workflow (#16)

Wire up the graphify skill (~/.claude/skills/graphify) so it's enforced
project-wide, with auto-rebuild on commits and code edits.

Changes:
- .claude/settings.json: PreToolUse hook for grep/find tools now points to
  FINAL_PRODUCTION_SYSTEM/graphify-out/ (the actual graph location).
- Added PostToolUse hook that tracks Edit/Write/MultiEdit on code files
  and a Stop hook that triggers `graphify update FINAL_PRODUCTION_SYSTEM`
  in background when changes touched FINAL_PRODUCTION_SYSTEM/.
- CLAUDE.md: graphify rules now reference FINAL_PRODUCTION_SYSTEM/graphify-out/
  with correct commands.
- .gitignore: ignore graphify-out/ artifacts (12MB+ graph.json + 25MB cache),
  rebuilt locally by post-commit hook.
- Git hooks (post-commit + post-checkout) installed via `graphify hook install`.

Initial graph state: 13,138 nodes, 19,511 edges, 1,260 communities,
AST-only extraction.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Enforce caveman + superpowers + graphify skills project-wide (#17)

Wires up the user's full personal skill stack so every Claude Code session
in this project gets reminded which skills to use and when.

Changes:
- CLAUDE.md: New "Personal Skills (Enforced)" section documenting all
  three skills, their triggers, and the workflow rules.
  - caveman: full mode by default, drop articles/filler.
  - superpowers: table mapping task → skill (brainstorming, writing-plans,
    executing-plans, TDD, systematic-debugging, verification-before-
    completion, dispatching-parallel-agents, etc.).
  - graphify: existing knowledge graph rules consolidated under this
    section.
- .claude/settings.json: Added SessionStart hook that injects a reminder
  about all 3 skills as additionalContext at session start.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Fix installer DB connection on aaPanel + better error messages (#18)

The web installer failed on aaPanel-style stacks because PDO interpreted
'localhost' as a Unix-socket connection request, then looked for the
default socket path (which doesn't match aaPanel's /tmp/mysql.sock or
/www/server/mysql/mysql.sock) and failed with "No such file or directory".

Code review fixes:

1. Default DB host changed from `localhost` -> `127.0.0.1` to force TCP.
2. Auto-coerce `localhost` -> `127.0.0.1` whenever no explicit Unix socket
   path was supplied (both in handleTestDb() and getInstallerPdo()).
3. New optional "Socket Path" field in step 2 (collapsible Advanced
   section) for installs that genuinely require a Unix socket.
4. New helper installerBuildDsn() to build the DSN consistently for both
   TCP and unix_socket modes; replaces 3 duplicated string concatenations.
5. New helper installerFriendlyDbError() that:
   - Sanitizes any DSN fragment that could leak host/port/user
   - Maps common errors (Access denied, Unknown database, Connection
     refused, No such file, getaddrinfo, timeout) to clear, aaPanel-aware
     messages with concrete remediation steps.
6. Added 10s/15s PDO timeouts on every connection so the installer can
   never hang.
7. Port is cast to int (was passed as string).
8. Socket path is persisted in $_SESSION['install_db'] so subsequent
   migration / admin-create / finalize steps reuse it.
9. Frontend now sends `db_socket` in `dbCredentials`.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* P0: Joomla-grade installer hardening for arbitrary Linux panels (#19)

Phase 0 of the multi-panel compatibility plan. No table-prefix work yet
(that's P1). All changes local to install/ajax.php + install/index.php.

Hardening:

1. Async per-migration runner. handleInstallDb() split into:
   - install_db_init  → returns ordered file list with applied flags
   - install_db_step  → applies ONE migration file (browser drives loop)
   - install_db_all   → legacy single-shot fast path
   Browser pumps the step loop, bypassing max_execution_time caps that
   panels like aaPanel/Plesk/cPanel enforce (often 30-60s).

2. Per-statement SQL splitter (installerSplitSql) respects backticks,
   single/double-quoted strings, line comments (-- and #), and block
   comments. Strips DELIMITER + outer BEGIN/COMMIT wrappers. Lets the
   runner survive PDO buffer caps and report progress accurately.

3. set_time_limit(0) + ignore_user_abort(true) at top — best-effort.

4. Preflight expansions:
   - open_basedir detection — flags if app root outside allowed paths
   - disable_functions audit — fails if mkdir/chmod/file_put_contents/
     unlink/rmdir/fopen blocked
   - Live mkdir+write+read+unlink probe under uploads/
   - parent_writable flag returned
   - php_version_full echoed for support tickets

5. Charset auto-fallback: SELECT VERSION() on first connect. MariaDB
   <5.5.3 or MySQL <5.7 → utf8mb3 (legacy 'utf8'). Persisted in session
   and surfaced to UI via dbCharset selector.

6. CREATE DATABASE skip-toggle: new step-2 checkbox + 1044/1142 error
   handler returns suggest_skip_create:true so JS can show "Tick & retry"
   button. Plesk/CyberPanel/ISPConfig users no longer hit a dead end.

7. Reverse-proxy IP hardening (getClientIp): only honor X-Forwarded-For
   / X-Real-IP / Client-IP when REMOTE_ADDR is in private/loopback
   range. Closes the spoofable trusted-network 2FA-bypass surface.

8. Auto-unlock recovery: install.lock + admin_users empty/missing →
   silent unlock. Inlined in install/index.php (avoids dragging ajax.php's
   JSON header into HTML) and mirrored as installerCheckIncompleteState()
   in ajax.php for runtime symmetry. Logged to install/install.log.

9. Unix-socket auto-detect: handleDetectSocket probes /tmp/mysql.sock,
   /var/run/mysqld/mysqld.sock, /var/lib/mysql/mysql.sock,
   /www/server/mysql/mysql.sock, /var/run/mariadb/mariadb.sock,
   /usr/local/mysql/mysql.sock + 2 more. UI Detect button auto-fills.

10. handleHealth post-install probe: SELECT 1 + SHOW TABLES for the
    five canonical KeyGate tables + admin_users count. Useful for step
    6 link and external monitoring during shared-host installs.

11. installerBuildDsn() now accepts $charset; getInstallerPdo() pulls
    it from session. installerFriendlyDbError() adds 1044/1142 hints
    and 1045/no-password specific messaging.

12. installerLog() helper: append-only audit trail at install/install.log.

UI changes (install/index.php):
- Step 2: skip-create-DB checkbox, charset selector, prefix input
  (P1-ready, hidden behind Advanced section), Detect socket button.
- Step 3: replaced single-shot runMigrations with init+step loop;
  per-row spinner; per-row pass/skip/error with file name + message;
  stops on first hard error so user reads it.
- Failed test_db with suggest_skip_create renders inline retry button.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* P1: Joomla-style table-prefix support across SQL + PHP runtime (#20)

Introduces optional database-table prefix so multiple KeyGate instances
can coexist in one database (panels like cPanel/Plesk often share a
single DB across customer apps). Default prefix is empty → bit-for-bit
identical schema to the previous release.

Changes:

1. tools/prefix-codemod.php
   New one-shot script. Self-discovers the canonical KeyGate table list
   from CREATE TABLE / ALTER TABLE statements in database/*.sql, then
   rewrites:
   - SQL files (32): every `tablename` and unbacktiked SQL-keyword target
     becomes `#__tablename`. The `#__` sentinel is the Joomla convention.
   - PHP files (~50): every backticked or bare-name SQL table reference
     inside string literals becomes `' . t('tablename') . '` (single-quote
     concat) or `" . t('tablename') . "` (double-quote concat).
   Token-based PHP parser (token_get_all) so comments / identifiers /
   non-string code is never touched. Idempotent — second run is a no-op.
   Run: docker compose exec web php /tmp/codemod.php --root /var/www/html/activate --apply

2. FINAL_PRODUCTION_SYSTEM/functions/db-helpers.php
   New file. Defines `t(string $name): string` returning DB_PREFIX . name.
   Also defines a fallback `define('DB_PREFIX', '')` if config.php hasn't
   set it — covers all legacy installs.

3. FINAL_PRODUCTION_SYSTEM/constants.php
   Loads functions/db-helpers.php at the top so t() is available before
   any controller runs.

4. FINAL_PRODUCTION_SYSTEM/database/*.sql
   Codemod output: 32 SQL files with `#__` markers. Schema is identical
   when prefix='' (the default). 276 backticked refs converted.

5. FINAL_PRODUCTION_SYSTEM/{controllers,api,functions,*}.php
   Codemod output: ~362 site rewrites across ~54 files. Every SQL string
   literal that referenced a canonical table now resolves through t().

6. FINAL_PRODUCTION_SYSTEM/install/ajax.php
   - installerRunSqlFile() substitutes `#__` → $_SESSION['install_db']['prefix']
     before running each migration. Defense-in-depth: aborts if any `#__`
     remains post-substitution.
   - installerT() helper for installer-time queries (mirrors t() but reads
     prefix from session, since DB_PREFIX isn't defined yet).
   - handleInstallDbInit/Step/All all use `installerT('schema_versions')`
     when checking applied migrations.
   - handleCreateAdmin uses installerT() for admin_users/acl_roles.
   - handleFinalize uses installerT() for system_config/technicians/
     trusted_networks/admin_ip_whitelist; passes prefix + charset to
     generateConfig().
   - handleHealth probes prefix-aware physical table names.
   - installerCheckIncompleteState() reads DB_PREFIX from existing
     config.php so auto-unlock works on prefixed installs.
   - generateConfig() emits define('DB_PREFIX', '...') AND propagates
     the auto-detected charset (utf8mb4 or utf8mb3 fallback).

7. FINAL_PRODUCTION_SYSTEM/install/index.php
   Inline auto-unlock logic now reads DB_PREFIX from config.php, so the
   admin_users probe targets the correct physical table name.

8. FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh
   New KEYGATE_DB_PREFIX env var (default empty). Pre-runs `sed` over
   every .sql file into a /tmp staging copy so the original (read-only)
   mount stays untouched. schema_versions tracking table picks up the
   prefix consistently. Validates prefix against ^[a-z][a-z0-9_]{0,9}$.
   Checksum is computed against the original file (stable across prefix
   choices).

Backward compatibility:
- Existing installs without DB_PREFIX in config.php → db-helpers.php
  defaults to empty string → t('admin_users') === 'admin_users'.
- 32 .sql files use `#__` placeholders. Without substitution they're
  invalid SQL — but they're never executed without going through either
  installerRunSqlFile() or 00-init.sh's sed pass.
- Verified: live admin login + list_keys both succeed against the
  pre-existing dev database after the codemod.
- 14/14 frontend tests pass.

Risk register from the plan:
- ✅ Codemod misses dynamic table refs → CI lint will catch (P2 task).
- ✅ FK / TRIGGER / VIEW unqualified table refs → SQL pass handles them.
- ✅ Empty-prefix path emits literal `#__` → installerRunSqlFile asserts
   strpos(#__) === false post-substitution and aborts.
- ✅ Prefix collides with reserved name → step-2 UI deny-list +
   00-init.sh regex validation.
- ✅ Async runner slows happy-path → fast path retained (install_db_all).
- ✅ Legacy config.php lacks DB_PREFIX → db-helpers.php fallback.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* P2: Installer resilience — resume, retry/skip, structured log, health probe (#21)

Phase 2 of the multi-panel installer plan. Adds operational robustness
on top of P0 hardening + P1 prefix support.

Changes:

1. Resumable installer
   - install/.progress.json breadcrumb file. Each step writes its number
     and timestamp on completion via the new progress_set action.
   - On page boot, JS calls progress_get; if last_step >= 1, prompts
     user "Resume from step N+1?" with Cancel = start over (which clears
     the file). Bypassed for fresh installs.
   - handleFinalize unlinks the breadcrumb on success (install complete).

2. Per-migration retry / skip
   - Step 3 UI: when a migration errors, inline Retry + Skip buttons
     appear next to the failed row.
   - Retry: re-runs install_db_step for that file. On success, the
     migration loop resumes from the next file.
   - Skip: prompts a hard-yes confirmation, then calls the new
     migration_skip action which inserts a row into schema_versions
     with checksum prefix `SKIPPED:` (so future audits can tell apart
     successful applies from forced skips). Loop resumes.
   - Both paths respect the canonical migration whitelist.

3. Structured install.log
   - installerLog() is now called from auto-unlock recovery, finalize
     completion, progress_set, migration_skip, and progress_clear.
   - Audit format: `[YYYY-MM-DD HH:MM:SS] event_name: details`.

4. Health-probe button on step 6
   - "Run health check" button next to "Open Admin Panel".
   - Calls existing handleHealth action; renders pass/fail per check
     (DB connect, presence of {prefix}admin_users, oem_keys, technicians,
     system_config, schema_versions, plus admin account count).

5. install.lock content extended
   - Now persists db_prefix and db_charset alongside installer_ver,
     admin_username, php_version, server_software. Makes post-install
     forensics easier.

6. .gitignore
   - install/install.log and install/.progress.json — runtime per-host
     artifacts, never to be committed.

Verified live:
- POST progress_set/progress_get round-trip works
- Lint clean on ajax.php + index.php
- 14/14 frontend tests pass

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* docs + CI: document multi-panel installer + DB_PREFIX, add codemod CI (#22)

- CLAUDE.md gets a "Multi-Panel Web Installer" section summarizing the
  P0+P1+P2 features that just landed, plus a DB_PREFIX block explaining
  the `#__` sentinel, t() runtime helper, backward-compat empty default,
  and how to add a new table cleanly.
- Development Commands section now includes the prefix-codemod commands
  (dry-run / apply / verify).

CI additions (.github/workflows/ci.yml):

1. New "Prefix Codemod Idempotency" job:
   - Runs tools/prefix-codemod.php in dry-run against the committed tree.
   - Asserts SQL=0 + PHP=0 changes (idempotent).
   - Runs --verify mode to confirm no unprefixed table refs left in SQL.
   - Catches any future PR that introduces hardcoded table names.

2. New "Installer (restricted PHP env)" job:
   - Boots PHP 8.3 with max_execution_time=15, allow_url_fopen=Off,
     memory_limit=128M to mimic aaPanel/Plesk-style restrictions.
   - Lints install/ajax.php + install/index.php under that env.
   - Loads ajax.php and asserts the seven new helpers are defined
     (installerBuildDsn, installerRunSqlFile, installerSplitSql,
     installerProbeSockets, installerCheckIncompleteState, installerT,
     plus the original).
   - Drives installerSplitSql across every database/*.sql file and
     prints statement counts. Catches any regression where the splitter
     mishandles a real migration.

Both new jobs run on push and PR. They join the existing PHP Lint,
Frontend Build & Test, and Docker Stack jobs.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Release: bump VERSION.php to 2.2.0

P0 + P1 + P2 multi-panel installer + docs + CI guardrails. See PRs
#19, #20, #21, #22 for complete change set.

- 2.1.0 → 2.2.0
- APP_VERSION_CODE 210 → 220
- APP_VERSION_DATE 2026-03-22 → 2026-05-07

---------

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
ChesnoTech added a commit that referenced this pull request May 9, 2026
* Complete Turkish translation + register QC recheck actions (#13)

- Turkish (tr.json): expanded from 399 to 1926 lines (~76% translated)
  using word-mapping generator. Remaining 463 keys are technical terms.
- Register handle_qc_recheck_count and handle_qc_recheck_historical
  in admin_v2.php action registry (were defined but unregistered).
- Add qc_recheck_count and qc_recheck_historical to api-contracts.test.ts.

Audit result: 0 action registry mismatches, 14/14 tests pass.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Add production deployment script for Ubuntu 22 + Docker (#14)

One-command installer for Proxmox/Ubuntu VMs:
- Auto-installs Docker if missing
- Clones KeyGate from GitHub
- Generates .env with secure random passwords
- Creates self-signed SSL cert
- Builds and starts Docker stack
- Creates admin user with super_admin role
- Verifies health endpoint
- Prints access URLs and credentials

Usage: curl -fsSL https://raw.githubusercontent.com/.../install.sh | sudo bash

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Fix deploy script + Docker healthcheck bugs found during testing (#15)

deploy/install.sh:
- Fix: acl_roles column is role_name not role_key
- Fix: admin_users.email is required (NOT NULL)
- Fix: set must_change_password=0 for initial admin
- Use ON DUPLICATE KEY UPDATE for idempotent role creation

docker-compose.yml:
- Fix: healthcheck was hitting /activate/ (404) instead of
  /api/health.php — DocumentRoot IS /var/www/html/activate
  so the correct internal URL is /api/health.php

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Integrate graphify knowledge graph into project workflow (#16)

Wire up the graphify skill (~/.claude/skills/graphify) so it's enforced
project-wide, with auto-rebuild on commits and code edits.

Changes:
- .claude/settings.json: PreToolUse hook for grep/find tools now points to
  FINAL_PRODUCTION_SYSTEM/graphify-out/ (the actual graph location).
- Added PostToolUse hook that tracks Edit/Write/MultiEdit on code files
  and a Stop hook that triggers `graphify update FINAL_PRODUCTION_SYSTEM`
  in background when changes touched FINAL_PRODUCTION_SYSTEM/.
- CLAUDE.md: graphify rules now reference FINAL_PRODUCTION_SYSTEM/graphify-out/
  with correct commands.
- .gitignore: ignore graphify-out/ artifacts (12MB+ graph.json + 25MB cache),
  rebuilt locally by post-commit hook.
- Git hooks (post-commit + post-checkout) installed via `graphify hook install`.

Initial graph state: 13,138 nodes, 19,511 edges, 1,260 communities,
AST-only extraction.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Enforce caveman + superpowers + graphify skills project-wide (#17)

Wires up the user's full personal skill stack so every Claude Code session
in this project gets reminded which skills to use and when.

Changes:
- CLAUDE.md: New "Personal Skills (Enforced)" section documenting all
  three skills, their triggers, and the workflow rules.
  - caveman: full mode by default, drop articles/filler.
  - superpowers: table mapping task → skill (brainstorming, writing-plans,
    executing-plans, TDD, systematic-debugging, verification-before-
    completion, dispatching-parallel-agents, etc.).
  - graphify: existing knowledge graph rules consolidated under this
    section.
- .claude/settings.json: Added SessionStart hook that injects a reminder
  about all 3 skills as additionalContext at session start.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* Fix installer DB connection on aaPanel + better error messages (#18)

The web installer failed on aaPanel-style stacks because PDO interpreted
'localhost' as a Unix-socket connection request, then looked for the
default socket path (which doesn't match aaPanel's /tmp/mysql.sock or
/www/server/mysql/mysql.sock) and failed with "No such file or directory".

Code review fixes:

1. Default DB host changed from `localhost` -> `127.0.0.1` to force TCP.
2. Auto-coerce `localhost` -> `127.0.0.1` whenever no explicit Unix socket
   path was supplied (both in handleTestDb() and getInstallerPdo()).
3. New optional "Socket Path" field in step 2 (collapsible Advanced
   section) for installs that genuinely require a Unix socket.
4. New helper installerBuildDsn() to build the DSN consistently for both
   TCP and unix_socket modes; replaces 3 duplicated string concatenations.
5. New helper installerFriendlyDbError() that:
   - Sanitizes any DSN fragment that could leak host/port/user
   - Maps common errors (Access denied, Unknown database, Connection
     refused, No such file, getaddrinfo, timeout) to clear, aaPanel-aware
     messages with concrete remediation steps.
6. Added 10s/15s PDO timeouts on every connection so the installer can
   never hang.
7. Port is cast to int (was passed as string).
8. Socket path is persisted in $_SESSION['install_db'] so subsequent
   migration / admin-create / finalize steps reuse it.
9. Frontend now sends `db_socket` in `dbCredentials`.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* P0: Joomla-grade installer hardening for arbitrary Linux panels (#19)

Phase 0 of the multi-panel compatibility plan. No table-prefix work yet
(that's P1). All changes local to install/ajax.php + install/index.php.

Hardening:

1. Async per-migration runner. handleInstallDb() split into:
   - install_db_init  → returns ordered file list with applied flags
   - install_db_step  → applies ONE migration file (browser drives loop)
   - install_db_all   → legacy single-shot fast path
   Browser pumps the step loop, bypassing max_execution_time caps that
   panels like aaPanel/Plesk/cPanel enforce (often 30-60s).

2. Per-statement SQL splitter (installerSplitSql) respects backticks,
   single/double-quoted strings, line comments (-- and #), and block
   comments. Strips DELIMITER + outer BEGIN/COMMIT wrappers. Lets the
   runner survive PDO buffer caps and report progress accurately.

3. set_time_limit(0) + ignore_user_abort(true) at top — best-effort.

4. Preflight expansions:
   - open_basedir detection — flags if app root outside allowed paths
   - disable_functions audit — fails if mkdir/chmod/file_put_contents/
     unlink/rmdir/fopen blocked
   - Live mkdir+write+read+unlink probe under uploads/
   - parent_writable flag returned
   - php_version_full echoed for support tickets

5. Charset auto-fallback: SELECT VERSION() on first connect. MariaDB
   <5.5.3 or MySQL <5.7 → utf8mb3 (legacy 'utf8'). Persisted in session
   and surfaced to UI via dbCharset selector.

6. CREATE DATABASE skip-toggle: new step-2 checkbox + 1044/1142 error
   handler returns suggest_skip_create:true so JS can show "Tick & retry"
   button. Plesk/CyberPanel/ISPConfig users no longer hit a dead end.

7. Reverse-proxy IP hardening (getClientIp): only honor X-Forwarded-For
   / X-Real-IP / Client-IP when REMOTE_ADDR is in private/loopback
   range. Closes the spoofable trusted-network 2FA-bypass surface.

8. Auto-unlock recovery: install.lock + admin_users empty/missing →
   silent unlock. Inlined in install/index.php (avoids dragging ajax.php's
   JSON header into HTML) and mirrored as installerCheckIncompleteState()
   in ajax.php for runtime symmetry. Logged to install/install.log.

9. Unix-socket auto-detect: handleDetectSocket probes /tmp/mysql.sock,
   /var/run/mysqld/mysqld.sock, /var/lib/mysql/mysql.sock,
   /www/server/mysql/mysql.sock, /var/run/mariadb/mariadb.sock,
   /usr/local/mysql/mysql.sock + 2 more. UI Detect button auto-fills.

10. handleHealth post-install probe: SELECT 1 + SHOW TABLES for the
    five canonical KeyGate tables + admin_users count. Useful for step
    6 link and external monitoring during shared-host installs.

11. installerBuildDsn() now accepts $charset; getInstallerPdo() pulls
    it from session. installerFriendlyDbError() adds 1044/1142 hints
    and 1045/no-password specific messaging.

12. installerLog() helper: append-only audit trail at install/install.log.

UI changes (install/index.php):
- Step 2: skip-create-DB checkbox, charset selector, prefix input
  (P1-ready, hidden behind Advanced section), Detect socket button.
- Step 3: replaced single-shot runMigrations with init+step loop;
  per-row spinner; per-row pass/skip/error with file name + message;
  stops on first hard error so user reads it.
- Failed test_db with suggest_skip_create renders inline retry button.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* P1: Joomla-style table-prefix support across SQL + PHP runtime (#20)

Introduces optional database-table prefix so multiple KeyGate instances
can coexist in one database (panels like cPanel/Plesk often share a
single DB across customer apps). Default prefix is empty → bit-for-bit
identical schema to the previous release.

Changes:

1. tools/prefix-codemod.php
   New one-shot script. Self-discovers the canonical KeyGate table list
   from CREATE TABLE / ALTER TABLE statements in database/*.sql, then
   rewrites:
   - SQL files (32): every `tablename` and unbacktiked SQL-keyword target
     becomes `#__tablename`. The `#__` sentinel is the Joomla convention.
   - PHP files (~50): every backticked or bare-name SQL table reference
     inside string literals becomes `' . t('tablename') . '` (single-quote
     concat) or `" . t('tablename') . "` (double-quote concat).
   Token-based PHP parser (token_get_all) so comments / identifiers /
   non-string code is never touched. Idempotent — second run is a no-op.
   Run: docker compose exec web php /tmp/codemod.php --root /var/www/html/activate --apply

2. FINAL_PRODUCTION_SYSTEM/functions/db-helpers.php
   New file. Defines `t(string $name): string` returning DB_PREFIX . name.
   Also defines a fallback `define('DB_PREFIX', '')` if config.php hasn't
   set it — covers all legacy installs.

3. FINAL_PRODUCTION_SYSTEM/constants.php
   Loads functions/db-helpers.php at the top so t() is available before
   any controller runs.

4. FINAL_PRODUCTION_SYSTEM/database/*.sql
   Codemod output: 32 SQL files with `#__` markers. Schema is identical
   when prefix='' (the default). 276 backticked refs converted.

5. FINAL_PRODUCTION_SYSTEM/{controllers,api,functions,*}.php
   Codemod output: ~362 site rewrites across ~54 files. Every SQL string
   literal that referenced a canonical table now resolves through t().

6. FINAL_PRODUCTION_SYSTEM/install/ajax.php
   - installerRunSqlFile() substitutes `#__` → $_SESSION['install_db']['prefix']
     before running each migration. Defense-in-depth: aborts if any `#__`
     remains post-substitution.
   - installerT() helper for installer-time queries (mirrors t() but reads
     prefix from session, since DB_PREFIX isn't defined yet).
   - handleInstallDbInit/Step/All all use `installerT('schema_versions')`
     when checking applied migrations.
   - handleCreateAdmin uses installerT() for admin_users/acl_roles.
   - handleFinalize uses installerT() for system_config/technicians/
     trusted_networks/admin_ip_whitelist; passes prefix + charset to
     generateConfig().
   - handleHealth probes prefix-aware physical table names.
   - installerCheckIncompleteState() reads DB_PREFIX from existing
     config.php so auto-unlock works on prefixed installs.
   - generateConfig() emits define('DB_PREFIX', '...') AND propagates
     the auto-detected charset (utf8mb4 or utf8mb3 fallback).

7. FINAL_PRODUCTION_SYSTEM/install/index.php
   Inline auto-unlock logic now reads DB_PREFIX from config.php, so the
   admin_users probe targets the correct physical table name.

8. FINAL_PRODUCTION_SYSTEM/database/docker-init/00-init.sh
   New KEYGATE_DB_PREFIX env var (default empty). Pre-runs `sed` over
   every .sql file into a /tmp staging copy so the original (read-only)
   mount stays untouched. schema_versions tracking table picks up the
   prefix consistently. Validates prefix against ^[a-z][a-z0-9_]{0,9}$.
   Checksum is computed against the original file (stable across prefix
   choices).

Backward compatibility:
- Existing installs without DB_PREFIX in config.php → db-helpers.php
  defaults to empty string → t('admin_users') === 'admin_users'.
- 32 .sql files use `#__` placeholders. Without substitution they're
  invalid SQL — but they're never executed without going through either
  installerRunSqlFile() or 00-init.sh's sed pass.
- Verified: live admin login + list_keys both succeed against the
  pre-existing dev database after the codemod.
- 14/14 frontend tests pass.

Risk register from the plan:
- ✅ Codemod misses dynamic table refs → CI lint will catch (P2 task).
- ✅ FK / TRIGGER / VIEW unqualified table refs → SQL pass handles them.
- ✅ Empty-prefix path emits literal `#__` → installerRunSqlFile asserts
   strpos(#__) === false post-substitution and aborts.
- ✅ Prefix collides with reserved name → step-2 UI deny-list +
   00-init.sh regex validation.
- ✅ Async runner slows happy-path → fast path retained (install_db_all).
- ✅ Legacy config.php lacks DB_PREFIX → db-helpers.php fallback.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* P2: Installer resilience — resume, retry/skip, structured log, health probe (#21)

Phase 2 of the multi-panel installer plan. Adds operational robustness
on top of P0 hardening + P1 prefix support.

Changes:

1. Resumable installer
   - install/.progress.json breadcrumb file. Each step writes its number
     and timestamp on completion via the new progress_set action.
   - On page boot, JS calls progress_get; if last_step >= 1, prompts
     user "Resume from step N+1?" with Cancel = start over (which clears
     the file). Bypassed for fresh installs.
   - handleFinalize unlinks the breadcrumb on success (install complete).

2. Per-migration retry / skip
   - Step 3 UI: when a migration errors, inline Retry + Skip buttons
     appear next to the failed row.
   - Retry: re-runs install_db_step for that file. On success, the
     migration loop resumes from the next file.
   - Skip: prompts a hard-yes confirmation, then calls the new
     migration_skip action which inserts a row into schema_versions
     with checksum prefix `SKIPPED:` (so future audits can tell apart
     successful applies from forced skips). Loop resumes.
   - Both paths respect the canonical migration whitelist.

3. Structured install.log
   - installerLog() is now called from auto-unlock recovery, finalize
     completion, progress_set, migration_skip, and progress_clear.
   - Audit format: `[YYYY-MM-DD HH:MM:SS] event_name: details`.

4. Health-probe button on step 6
   - "Run health check" button next to "Open Admin Panel".
   - Calls existing handleHealth action; renders pass/fail per check
     (DB connect, presence of {prefix}admin_users, oem_keys, technicians,
     system_config, schema_versions, plus admin account count).

5. install.lock content extended
   - Now persists db_prefix and db_charset alongside installer_ver,
     admin_username, php_version, server_software. Makes post-install
     forensics easier.

6. .gitignore
   - install/install.log and install/.progress.json — runtime per-host
     artifacts, never to be committed.

Verified live:
- POST progress_set/progress_get round-trip works
- Lint clean on ajax.php + index.php
- 14/14 frontend tests pass

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* docs + CI: document multi-panel installer + DB_PREFIX, add codemod CI (#22)

- CLAUDE.md gets a "Multi-Panel Web Installer" section summarizing the
  P0+P1+P2 features that just landed, plus a DB_PREFIX block explaining
  the `#__` sentinel, t() runtime helper, backward-compat empty default,
  and how to add a new table cleanly.
- Development Commands section now includes the prefix-codemod commands
  (dry-run / apply / verify).

CI additions (.github/workflows/ci.yml):

1. New "Prefix Codemod Idempotency" job:
   - Runs tools/prefix-codemod.php in dry-run against the committed tree.
   - Asserts SQL=0 + PHP=0 changes (idempotent).
   - Runs --verify mode to confirm no unprefixed table refs left in SQL.
   - Catches any future PR that introduces hardcoded table names.

2. New "Installer (restricted PHP env)" job:
   - Boots PHP 8.3 with max_execution_time=15, allow_url_fopen=Off,
     memory_limit=128M to mimic aaPanel/Plesk-style restrictions.
   - Lints install/ajax.php + install/index.php under that env.
   - Loads ajax.php and asserts the seven new helpers are defined
     (installerBuildDsn, installerRunSqlFile, installerSplitSql,
     installerProbeSockets, installerCheckIncompleteState, installerT,
     plus the original).
   - Drives installerSplitSql across every database/*.sql file and
     prints statement counts. Catches any regression where the splitter
     mishandles a real migration.

Both new jobs run on push and PR. They join the existing PHP Lint,
Frontend Build & Test, and Docker Stack jobs.

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>

* P0: anti-piracy hardening (RS256 JWT + DB row HMAC) (#24)

* P0: anti-piracy hardening — RS256 JWT + DB row HMAC + remove wildcard

Eliminates trivial license-bypass paths from the threat model:

1. JWT signing HS256 -> RS256 (asymmetric)
   - Hardcoded secret 'keygate-community-verification-key-2026' no longer
     enables forging enterprise tokens. Private key only on Cloudflare
     Worker (LICENSE_PRIVATE_KEY secret); public key embedded in
     license-helpers.php for verify-only.
   - PHP decodeLicenseJwt() uses openssl_verify(OPENSSL_ALGO_SHA256).
   - Worker uses crypto.subtle RSASSA-PKCS1-v1_5 SHA-256.
   - Removed createLicenseJwt() — local code never signs in prod.
   - 90-day legacy HS256 verify window via LEGACY_HS256_SECRET +
     /api/migrate route for existing customers.

2. DB row integrity HMAC
   - New column license_info.integrity_hmac (CHAR(64)).
   - Per-instance license_row_secret in system_config, rotated on every
     successful registerLicense().
   - getEffectiveLicense(), canAddTechnician(), canAddKeys(), and
     isFeatureAvailable() all re-check HMAC; mismatch -> forced
     community fallback + validation_status='invalid'.
   - Defeats direct INSERT bypass: attacker needs both row fields AND
     the per-instance secret, and the secret rotates.

3. Wildcard instance_id='*' removed
   - Worker GitHub Sponsors / LemonSqueezy / T-Bank flows no longer
     issue wildcard tokens. Pending purchases stored as
     pending_claim:true; customer binds via /api/claim with their
     installation's instance_id.
   - registerLicense() rejects wildcard payloads.

4. Dev license generation hardened
   - Local signing removed. /api/dev-issue Worker route gated by
     DEV_TOKEN secret. LicenseController calls Worker, requires admin
     to paste DEV_TOKEN.

5. Frontend
   - "Claim license" card (GitHub Sponsors / pending purchases).
   - "Migrate legacy license" card (HS256 -> RS256).
   - DEV_TOKEN input on Dev Tools card.
   - 12 new i18n keys in en.json + ru.json.

Files:
- license-server/worker.js, wrangler.toml — RS256 signer, /api/claim,
  /api/migrate, /api/dev-issue, no-wildcard issuance
- functions/license-helpers.php — RS256 verify, row HMAC, secret
  rotation, wildcard rejection
- controllers/admin/LicenseController.php — Worker dev-issue, claim,
  migrate handlers
- admin_v2.php — register license_claim, license_migrate actions
- database/license_p0_hmac_migration.sql — integrity_hmac column
- database/docker-init/00-init.sh, install/ajax.php — migration phase 27
- frontend/api/license.ts, hooks/use-license.ts, pages/license/index.tsx,
  test/api-contracts.test.ts, i18n/en.json, i18n/ru.json
- .gitignore — exclude license-server/.keys/

Backward-compat: v2.2.0 community installs auto-fallback to community
on first boot post-upgrade (HS256 rejected, banner prompts
re-register). Single existing paid customer migrates via /api/migrate.

Verification (live PHP smoke test):
- RS256 token verifies via openssl_verify -> OK
- Legacy HS256 token verifies during migration window -> OK
- Tampered JWT signature -> rejected
- HMAC compute returns 64 hex chars

Phase 1 of 3. P1 (hardware-fingerprint + rebind quota) and P2
(phone-home grace + revocation) follow in separate PRs.

Plan: ~/.claude/plans/polymorphic-coalescing-bumblebee.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: trigger workflows for PR #24

---------

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* P1: hardware-bound licensing — server hwfp + 3-per-365 rebind quota (#25)

Binds each license_info row to the host's server-side hardware
fingerprint so a license cannot be moved to a different VM/host
without invoking the Worker /api/rebind route, which is rate-limited.
Closes the easy "clone the VM, keep using the license" bypass.

1. Server hardware fingerprint (cross-OS)
   - functions/hardware-fingerprint.php — composite SHA256 of
     machine_id | system_uuid | primary_mac | root_volume_uuid | host_os.
   - Linux: /etc/machine-id, /sys/class/dmi/id/product_uuid, NIC MAC
     from /sys/class/net (skip lo/docker/veth/virbr/kube), blkid for
     root volume UUID.
   - Windows: HKLM\SOFTWARE\Microsoft\Cryptography MachineGuid,
     Win32_ComputerSystemProduct.UUID via PowerShell, Get-NetAdapter
     for primary MAC, `vol C:` for volume serial.
   - Cached in system_config('server_hwfp') as JSON; recompute only
     on admin "Re-detect hardware" or after successful rebind.
   - 3-of-5 component-match soft threshold tolerates legitimate
     single-component changes (NIC swap, disk replaced) without
     forcing a rebind.

2. Schema (license_p1_hwbind_migration.sql, phase 28)
   - hardware_fingerprint CHAR(64) NULL
   - hwfp_bound_at TIMESTAMP NULL
   - hwfp_rebind_count TINYINT UNSIGNED NOT NULL DEFAULT 0
   - hwfp_last_rebind_at TIMESTAMP NULL
   - INDEX idx_hwfp on hardware_fingerprint
   - validation_status enum extended with 'rebinding_required' and
     'clock_drift' (the latter reserved for P2).
   - system_config slots: server_hwfp, license_prev_tier.

3. PHP enforcement (functions/license-helpers.php)
   - HMAC formula now includes hardware_fingerprint as a 7th field.
     Existing P0 rows fail HMAC after upgrade -> community fallback ->
     re-register binds the new fp on insert. Acceptable backward-compat.
   - registerLicense() validates JWT 'hwfp' claim against server fp
     (3-of-5 soft threshold). Auto-binds current fp if claim absent.
   - getEffectiveLicense() detects fp drift and sets
     'rebinding_required'. 7-day grace serves the previous tier
     (license_prev_tier system_config) before degrading to community.
   - applyRebindResponse() updates row from /api/rebind result.

4. Worker (license-server/worker.js)
   - createJwt now stamps `hwfp` claim into:
       /api/register, /api/claim, /api/migrate, /api/dev-issue
     all of which require a `hardware_fingerprint` body param.
   - New /api/rebind: verifies license_key payload, looks up KV by
     email, enforces 3-per-rolling-365-day quota via
     `rebind:{email}:{ts}` records (each with 365d TTL — count yields
     window count without external state). Mints fresh RS256 JWT
     bound to new_hardware_fingerprint, increments rebind_count,
     returns rebind_quota_remaining.

5. Backend handlers (controllers/admin/LicenseController.php)
   - handle_license_status — exposes `hardware` block (current fp,
     bound fp, rebind_count, quota), and license.rebind_required +
     license.rebind_grace_ends.
   - handle_license_redetect_hw — force-recompute server fp
     (admin-triggered).
   - handle_license_rebind — call Worker /api/rebind with current
     license_key + freshly-detected fp, applyRebindResponse on success.
   - admin_v2.php registers license_redetect_hw + license_rebind
     actions (both POST + CSRF).

6. Frontend (license page)
   - api/license.ts: redetectHardware(), rebindLicense(reason?)
   - hooks/use-license.ts: useRedetectHardware(), useRebindLicense()
   - pages/license/index.tsx: new "Hardware binding" card showing
     current vs bound fingerprint, rebind count vs quota,
     "Re-detect hardware" + "Rebind to current hardware" buttons.
     Card highlights amber when license.rebind_required is set,
     displays grace-window deadline.
   - test/api-contracts.test.ts: license_redetect_hw, license_rebind
   - i18n/en.json + ru.json: 15 new keys (sub.hw_*, license.hw_*,
     license.rebound, license.rebind_failed).

Backward-compat: pre-P1 rows have NULL hardware_fingerprint, fp gate
in getEffectiveLicense() skips them until customer re-registers
(auto-bind on insert). Single existing paid customer rebinds via
the new UI button before P2 ships.

Verification (live PHP smoke):
- computeServerHwfp() returns 64-char hex composite + 5 components
- compareHwfp(self, self) accepts (3/5 match in Docker, expected;
  system_uuid + root_volume_uuid blank inside container)
- compareHwfp(self, all-zero) rejects
- Migration applies clean: integrity_hmac + hardware_fingerprint +
  hwfp_bound_at + hwfp_rebind_count + hwfp_last_rebind_at columns
  present, validation_status enum extended.

Phase 2 of 3. P2 (phone-home grace + revocation list + clock-drift)
follows in separate PR.

Plan: ~/.claude/plans/polymorphic-coalescing-bumblebee.md

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* P2: phone-home grace + revocation list + clock-drift defense (#26)

* P2: phone-home grace + revocation list + clock-drift defense

Closes the "register once, never check in again" bypass and adds
forensic anchors (jti) for license revocation. Phase 3 of 3 in the
anti-piracy hardening initiative.

1. Phone-home cadence (PHP)
   - functions/license-phone-home.php — phoneHomeValidate(),
     applyValidateResponse(), recordPhoneHomeFailure(),
     checkPhoneHomeGrace(), firePhoneHomeAsync().
   - On every getEffectiveLicense() call, lazy-include the helper and
     fire async phone-home (POSIX: spawn `php cli/license-validate.php`
     in background; Windows: synchronous fallback with 6s timeout).
   - Throttled: at most one validate per `license_phonehome_interval`
     (default 86400s = 24h).
   - cli/license-validate.php — CLI shim for cron entry; idempotent
     (re-honors throttle), exits 0 on no-op.

2. Grace bands (enforced in getEffectiveLicense())
     0–14d  → cached tier, no banner.
     14–30d → cached tier + banner ("validation failed for N days").
     >30d   → community tier (validation_status='expired'), banner.
     revoked   (Worker says so) → community immediately.
     must_rebind (Worker says so) → 'rebinding_required' (P1 path).
   User-confirmed 14d/30d thresholds.

3. Revocation by jti (Worker)
   - createJwt() now stamps a `jti` (UUID) into every minted token.
   - Worker maintains `revoked:{jti}` KV records (10y TTL).
   - GitHub Sponsors `cancelled`, LemonSqueezy `subscription_cancelled`/
     `subscription_expired`, T-Bank `REVERSED`/`REFUNDED` webhook
     branches all extract jti from the stored JWT and call revokeJti()
     via best-effort try/catch.
   - /api/validate checks `revoked:{jti}` before issuing valid:true.

4. /api/validate extensions (Worker)
   - Returns: { valid, tier, revoked, expires_at, hardware_fingerprint,
     rebind_quota_remaining, rebind_quota_limit, server_time,
     must_rebind, jti, reason? }
   - hwfp drift: caller-supplied hardware_fingerprint vs KV-stored fp
     → must_rebind:true (PHP-side then sets validation_status to
     'rebinding_required', P1 grace path serves prev tier 7 days).

5. Clock-drift defense (PHP)
   - server_time_drift_seconds and clock_drift_strikes columns track
     local-vs-server clock delta. Drift >5min for 3 consecutive checks
     → validation_status='clock_drift'. Defeats pirates rolling clocks
     back to dodge expires_at.

6. Cache HMAC (defeats UPDATE-the-cache forgery)
   - system_config('license_validation_cache') stores last validate
     response + an HMAC anchored to license_row_secret (rotated on
     every register/rebind). Direct UPDATE of system_config is not
     enough — attacker also needs the rotated secret.

7. Schema (license_p2_phonehome_migration.sql, phase 29)
   - validation_failure_count INT UNSIGNED NOT NULL DEFAULT 0
   - last_validation_error TEXT NULL
   - server_time_drift_seconds INT NOT NULL DEFAULT 0
   - clock_drift_strikes TINYINT UNSIGNED NOT NULL DEFAULT 0
   - current_jti CHAR(36) NULL
   - system_config slots: license_validation_cache, license_phonehome_interval

8. Backend handlers (controllers/admin/LicenseController.php)
   - handle_license_status — exposes `phonehome` block (last_validated_at,
     failure_count, last_error, drift, jti, grace band, banners).
   - handle_license_force_validate — admin-triggered force phone-home
     bypassing the 24h throttle.
   - admin_v2.php registers license_force_validate (POST + CSRF).

9. Frontend (license page)
   - api/license.ts: forceValidate(); LicensePhoneHome interface added
     to LicenseStatusResponse.
   - hooks/use-license.ts: useForceValidate(). Toast variants for OK /
     revoked / must_rebind / failed.
   - pages/license/index.tsx: new "License validation (phone-home)"
     card showing last validated, failure count, drift, jti,
     last_error, and "Validate now" button. Card colors itself
     amber on banner band, red on expired band.
   - test/api-contracts.test.ts: license_force_validate.
   - i18n/en.json + ru.json: 15 new keys.

Backward-compat: pre-P2 rows have NULL for the new columns; phone-home
is opt-in (fires only when license-phone-home.php is present and the
license has last_validated_at/license_key). First boot post-upgrade
inserts last_validated_at = NOW() on the next register/validate cycle.

Verification (live PHP smoke):
- checkPhoneHomeGrace bands at 0d/13d/20d/35d return ok / ok /
  banner / expired with correct banner text.
- Migration applies clean: validation_failure_count, last_validation_error,
  server_time_drift_seconds, clock_drift_strikes, current_jti columns
  present.
- Worker JS parses cleanly (node syntax check).

Cloudflare deploy after merge:
  cd license-server && wrangler deploy
  # picks up jti claim, revokeJti, /api/validate extensions

Plan: ~/.claude/plans/polymorphic-coalescing-bumblebee.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: trigger workflows for PR #26

---------

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Release v2.3.0 — Anti-piracy hardening (P0 + P1 + P2)

Cumulative release covering 3 anti-piracy phases shipped on develop:

P0 (#24): RS256 JWT signing replaces hardcoded HS256 secret. Per-row
HMAC anchored to a per-instance rotated secret defeats direct
INSERT bypass. Wildcard instance_id='*' rejected. Dev-license
signing moved off the customer host onto the Worker.

P1 (#25): Cross-OS server hardware fingerprint binds each license to
the host. 3-of-5 component-match threshold tolerates legitimate
single-component changes. Worker /api/rebind enforces 3-per-365-day
quota. 7-day grace for rebinding_required state.

P2 (#26): Phone-home with 14-day soft / 30-day hard grace. Every JWT
carries jti; revoked:{jti} KV records consulted on /api/validate.
clock_drift detection (5-min threshold × 3 strikes) defeats local
clock rollback. HMAC-anchored cache rejects UPDATE-the-cache forgery.

Bypass coverage:
| Bypass                                  | Status               |
| Hardcoded HS256 secret in source        | closed (P0)          |
| Direct INSERT INTO license_info         | closed (P0)          |
| Wildcard instance_id='*' JWT            | closed (P0)          |
| VM clone keeps the license              | closed (P1)          |
| Move to fresh hardware                  | rate-limited (P1)    |
| Register once, never validate           | closed (P2)          |
| Roll system clock back                  | closed (P2)          |
| Tampered installer / patched PHP        | deferred (P3)        |

3 new migrations (phases 27/28/29). 5 new admin actions
(license_claim, license_migrate, license_redetect_hw, license_rebind,
license_force_validate). 2 new helper modules
(hardware-fingerprint.php, license-phone-home.php). 1 new CLI shim
(cli/license-validate.php).

Founder action required after merge:
  cd license-server
  openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out license_priv.pem
  openssl pkey -in license_priv.pem -pubout -out license_pub.pem
  # Paste license_pub.pem into FINAL_PRODUCTION_SYSTEM/functions/license-helpers.php
  wrangler secret put LICENSE_PRIVATE_KEY
  wrangler secret put LEGACY_HS256_SECRET
  wrangler secret put DEV_TOKEN
  wrangler deploy

Customer cron (Linux):
  0 3 * * * cd /var/www/keygate && /usr/bin/php FINAL_PRODUCTION_SYSTEM/cli/license-validate.php >> /var/log/keygate-phonehome.log 2>&1

Sunset:
  90 days post-deploy → wrangler secret delete LEGACY_HS256_SECRET

Plan: ~/.claude/plans/polymorphic-coalescing-bumblebee.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: trigger workflows for release PR #27

---------

Co-authored-by: ChesnoTech <263363000+ChesnoTech@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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