Deployment Guide
This document covers deployment of ForenDev's License module.
Read this first: the database name is *not* fixed
There is no hard-coded database name anywhere in the application. Both the
public site (config.php) and the License module (license/config.php) read
FOREN_DB_NAME / FOREN_DB_USER / FOREN_DB_PASS from the environment and
fall back to the literal placeholder foren. If your deployment uses a
different schema, set the variable — do not expect the code to follow a
convention.
Throughout this guide, write the values as:
| Placeholder | Meaning |
|---|---|
| --- | --- |
<DB> | schema name, e.g. whatever FOREN_DB_NAME is set to |
<DB_USER> | MySQL user, e.g. whatever FOREN_DB_USER is set to |
Historical note. Older revisions of this guide and of
docs/DATABASE.mdhard-codedforen_license, and older deployment notes mentionforendevandforenkey_unified. All three names are just examples of what a deployment may use; none of them is baked into the code. Verify your own values with:# on the server, as the pool's user printenv FOREN_DB_NAME FOREN_DB_USER FOREN_DB_HOST # or, from PHP executed by the same SAPI the site uses php -r 'foreach (["FOREN_DB_HOST","FOREN_DB_PORT","FOREN_DB_NAME","FOREN_DB_USER"] as $k) printf("%s=%s\n", $k, getenv($k) ?: "(unset)");'
Multiple schemas in one deployment. The
forenkey/subsystem and the main site have historically been pointed at *different* schemas (forenkey_unifiedvs. the public catalog). A single PHP-FPM pool exports exactly oneFOREN_DB_NAME, so whichever pool Nginx actually routes to decides which schema the whole site sees. This is the single most common cause of "the admin shows a different product list than the homepage". Verify the live wiring before debugging application code:grep -n fastcgi_pass /www/server/panel/vhost/nginx/your.site.conf grep -rn 'env\[FOREN_DB_NAME\]' /www/server/php/85/etc/php-fpm.d/
Environment Variables
The application reads all configuration from environment variables (PHP-FPM env, set in /www/server/php/85/etc/php-fpm.conf and/or a per-pool drop-in in php-fpm.d/).
Required
| Variable | Purpose | Example |
|---|---|---|
| --- | --- | --- |
FOREN_DB_HOST | MySQL host | 127.0.0.1 |
FOREN_DB_PORT | MySQL port | 3306 |
FOREN_DB_NAME | Database name | <DB> |
FOREN_DB_USER | Database user | <DB_USER> |
FOREN_DB_PASS | Database password | (32+ random chars) |
FOREN_LICENSE_PRIVATE_KEY | Path to Ed25519 private key | /path/to/ed25519_private.base64 |
FOREN_LICENSE_PUBLIC_KEY | Path to Ed25519 public key | /path/to/ed25519_public.base64 |
FOREN_LICENSE_V2_VIEW_KEY | 32 bytes base64, for v2 envelope | base64-encoded-32-bytes |
Optional
| Variable | Purpose | Default |
|---|---|---|
| --- | --- | --- |
FOREN_USE_UNIFIED_DB | Use MySQL instead of legacy SQLite | 1 if FOREN_DB_HOST set |
FOREN_SQLITE_PATH | Path to legacy SQLite | data/foren.sqlite |
FOREN_ADMIN_SESSION | Admin session cookie name | FOREN_ADMIN |
FORENKEY_SM2_PYTHON | Python binary for SM2 verify | python3 |
FORENKEY_SM2_HELPER | SM2 verify script path | (auto-detect) |
FORENKEY_BACKEND_KEY_ID | ForenKey key identifier | forenkey-rsa-prod |
FORENKEY_API_TOKEN | Shared bearer token, accepted alongside DB-backed API tokens | (unset = disabled) |
FORENKEY_ALLOW_OPEN_API | Allow anonymous machine API access. Local development only. | (unset = fail closed) |
FOREN_CERT_ENCRYPTION_KEY | 32 bytes base64, encrypts X.509 certificate private keys | (derived from the Ed25519 key) |
FORENKEY_API_TOKENis compared withhash_equals()against the presented bearer token before theapi_tokenstable is consulted. It is a deployment escape hatch for a ForenKey service that shares a token with this site. When unset, only database-backed tokens authenticate.
The machine API fails closed — do not expect anonymous access
Every ForenKey machine endpoint (
/api/keys/register,/api/keys/revoke,/api/assets/register,/api/pin/*, …) callsAuth::requireApiToken(). With no valid token the request is rejected with HTTP 401.This is deliberate. Earlier releases treated "no
FORENKEY_API_TOKENand noapi_tokensrow" as a *development compatibility mode* and let any anonymous caller through. That condition is indistinguishable from "production forgot to provision a token", so a single misconfiguration exposed key revocation, PIN writes and legacy RSA signing to the internet. A security control must not fail open.Provision one of the following — in order of preference:
- Database-backed token (recommended). Create it in the admin console
under *API Tokens*; it is stored as a SHA-256 hash and can be scoped, expired and revoked without a redeploy.
FORENKEY_API_TOKENinphp-fpm.conf/ the pool config — a singleshared secret for one trusted client. Use at least 32 random characters.
FORENKEY_ALLOW_OPEN_API=1— anonymous access. This is anexplicit, local-development-only opt-in. It is a fixed allowlist of
1/true/yes/on(case-insensitive, surrounding whitespace ignored); anything else, including0,offand unset, keeps the API closed. Never set this on a public deployment.
GET /forenkey/healthreportsauth_required, which is the authoritative signal: it istrueunless open access was explicitly enabled. It used to mirror onlyFORENKEY_API_TOKEN !== '', which showedfalse(i.e. "no auth needed") for a database-token-only deployment.Note that
GET /api/keys/pinstays disabled with 403pin_read_disabledwhenever no API token is configured at all, *even* whenFORENKEY_ALLOW_OPEN_API=1— plaintext PINs are never served anonymously.
FOREN_CERT_ENCRYPTION_KEYand the random fallback. When this is unset *and*license.private_key_pathis not readable,CertificateServicegenerates a random per-process key. Certificate private keys encrypted under it stop being decryptable as soon as the PHP process ends — a client that returns for its download token after an FPM reload gets nothing. Set the variable (or fix the key path) if you issue certificates in production. See SECURITY.md.
Generating the v2 view key
# 32 random bytes, base64 encoded
openssl rand 32 | base64
Generating Ed25519 keypair
# Use the provided script
php license/scripts/generate-keypair.php
# Or manually with openssl
openssl genpkey -algorithm ed25519 -out private.pem
cat private.pem | base64 > ed25519_private.base64
# Public key derivation requires specific Ed25519 math; use libsodium in PHP:
php -r "echo sodium_bin2base64(sodium_crypto_sign_publickey(sodium_base642bin(file_get_contents('ed25519_private.base64'), SODIUM_BASE64_VARIANT_ORIGINAL)), SODIUM_BASE64_VARIANT_ORIGINAL);"
Database Setup
1. Create database and user
CREATE DATABASE <DB> DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE USER '<DB_USER>'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD';
GRANT ALL PRIVILEGES ON <DB>.* TO '<DB_USER>'@'localhost';
FLUSH PRIVILEGES;
Note:
FOREN_DB_NAMEmust equal the<DB>you just created. The code does not validate this; a mismatch shows up as an empty admin product list.
2. Run migrations
Run all migrations in order (do not skip gaps; numeric gaps are intentional).
004_products.sql is deliberately absent from this list: it lives in
license/migrations/legacy/, is superseded by 005_unified_catalog.sql, and
license/scripts/init.php explicitly skips it.
for f in license/migrations/001_mysql.sql \
license/migrations/002_signature_compat.sql \
license/migrations/003_batch_v2_admin.sql \
license/migrations/005_unified_catalog.sql \
license/migrations/006_license_product_authorizations.sql \
license/migrations/007_certificates.sql \
license/migrations/008_license_plans.sql \
license/migrations/009_license_events_webhooks.sql \
license/migrations/010_license_authorization_tokens.sql \
license/migrations/011_security_and_performance.sql \
license/migrations/012_admin_security.sql
do
mysql -u '<DB_USER>' -p '<DB>' < "$f"
done
Or let the bundled runner do it (it skips the legacy 004 for you and
records what it applied):
php license/scripts/init.php
Migrations 011 and 012 use CREATE TABLE IF NOT EXISTS / guarded index
creation, so the table and index steps are safe to re-run.
Caveat: the
ALTER TABLE adminsblock at the top of 012 adds columns unconditionally. Re-running 012 against a database that already hastotp_secretwill fail withDuplicate column name. Comment out that block (or check for the columns first) if you need to re-apply 012.
3. Create initial admin
Use the admin UI (/admin/system.php) — the Admins tab has a "Create Administrator" form.
Or via raw SQL (replace CHANGE_ME with a strong password immediately):
<?php
$pdo = new PDO('mysql:host=127.0.0.1;dbname=<DB>', '<DB_USER>', 'PASSWORD');
$hash = password_hash('CHANGE_ME', PASSWORD_DEFAULT);
$now = gmdate('Y-m-d H:i:s'); // UTC, matches now_utc()
$pdo->prepare('INSERT INTO admins (username, password_hash, is_active, created_at, updated_at) VALUES (?, ?, 1, ?, ?)')
->execute(['admin', $hash, $now, $now]);
Security: Change the password immediately after first login. The admin UI enforces strong passwords (≥12 characters, uppercase + lowercase + digit + special character).
Web Server Configuration
Nginx (typical)
server {
listen 443 ssl http2;
server_name foren.dev;
root /www/wwwroot/foren.dev;
index index.php;
# TLS configuration
ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
# Block direct access to sensitive paths (order matters — most specific first)
location ^~ /tests/ { deny all; }
location ^~ /data/ {
deny all;
}
location ~ ^/(config\.php|license/config\.php|license/config\.example\.php|license/bootstrap\.php)$ {
deny all;
}
location ~ ^/license/(storage/|lib/|scripts/|migrations/) {
deny all;
}
# Block one-off diagnostic scripts left in the document root
location ~* ^/(forenkey/)?(probe|phpinfo|info|i|test)[0-9]*\.php$ { deny all; }
location ~* /(tests?|specs?)/.*\.php$ { deny all; }
# Default handler
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# PHP-FPM
location ~ \.php$ {
fastcgi_pass unix:/tmp/php-cgi-XX.sock;
fastcgi_index index.php;
include fastcgi.conf;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Static asset caching
location ~* \.(css|js|png|jpg|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
Required blocked paths
These paths MUST NOT be web-accessible. On Nginx (which does not read .htaccess), always use deny all directives:
/data/ -- legacy SQLite database
/config.php -- root config
/license/config.php -- license config (PHP file, not a directory)
/license/config.example.php -- config template
/license/bootstrap.php -- license bootstrap
/license/storage/ -- private keys and caches
/license/lib/ -- license service PHP classes
/license/migrations/ -- SQL schema files
/license/scripts/ -- CLI scripts
/tests/ -- regression scripts; MUST NOT be web-reachable
This is not optional. The regression scripts in
tests/are plain PHP files that run against the live configuration.test_coforen_e2e.php, for instance, inserts real rows intolicensesandactivation_codes. If Nginx can reach/tests/, a single HTTP GET from a crawler or a curious visitor mutates production data. The scripts are correct *code*; the defect is exposure. Two independent layers, both required:
- Nginx
deny allon/tests/(above) — the layer that actually matters.- Never deploy
tests/into the document root in the first place. Excludeit in the deploy rule: ```bash rsync -az --exclude-from=/root/.foren-rsync-excludes ./ /www/wwwroot/foren.dev/ ``` with
tests/listed in that excludes file.A
403on/tests/is the expected, correct result. If you see200, the block is missing.
PHP-FPM configuration
PHP-FPM must set the env variables listed above. Example /www/server/php/85/etc/php-fpm.conf:
env[FOREN_DB_HOST] = 127.0.0.1
env[FOREN_DB_PORT] = 3306
env[FOREN_DB_USER] = <DB_USER>
env[FOREN_DB_PASS] = STRONG_PASSWORD
env[FOREN_DB_NAME] = <DB>
env[FOREN_LICENSE_PRIVATE_KEY] = /www/wwwroot/foren.dev/license/storage/keys/ed25519_private.base64
env[FOREN_LICENSE_PUBLIC_KEY] = /www/wwwroot/foren.dev/license/storage/keys/ed25519_public.base64
env[FOREN_LICENSE_V2_VIEW_KEY] = base64-encoded-32-bytes
Note: Key paths are relative to the document root on this server. If using a different layout, adjust accordingly. The
license/storage/keys/directory MUST be readable by the PHP-FPM worker user but MUST NOT be web-accessible (see nginx deny block below).
Per-pool env vs. global env — read this before changing the database.
php-fpm.confenv[...]entries are inherited by every pool, but a pool that declares its ownenv[...]set replaces the values it names; it does not merge. A dedicated pool withenv[FOREN_DB_NAME] = forenkey_unifiedwill therefore shadow the global value entirely for requests routed to that pool.If your server has a per-site pool file, verify which socket Nginx actually uses before assuming anything about the live database:
# what Nginx routes to grep -n fastcgi_pass /www/server/panel/vhost/nginx/your.site.conf # which pools exist ls /www/server/php/85/etc/php-fpm.d/ # which env each declares grep -rn 'env\[FOREN_' /www/server/php/85/etc/php-fpm.conf /www/server/php/85/etc/php-fpm.d/An orphaned pool file (a pool that exists but is not the one in
fastcgi_pass) is the single most confusing state to debug: the config looks right, the pool looks configured, and the site still uses the default pool's database.
Cron Jobs
Webhook delivery (check_expiring_licenses.php)
Daily at 09:00 — checks for expiring licenses and delivers pending webhook events.
0 9 * * * cd /www/wwwroot/foren.dev && /usr/bin/php license/scripts/check_expiring_licenses.php >> /var/log/foren/expiry.log 2>&1
The script:
- Finds licenses expiring within 7 days (sends
license.expiringwebhook) - Finds licenses in grace period (sends
license.in_gracewebhook) - Delivers all pending
license_eventsto registered webhooks (retries failed ones)
Database backups
Daily at 02:00 — use the automated backup script.
The backup script will not guess your database name. If
FOREN_DB_NAME/FOREN_DB_USERare not in cron's environment it exits with code2and writes nothing, rather than dumping the wrong schema. A cron entry that runs as a different user than PHP-FPM therefore does *not* inherit the FPM pool env — you must pass the variables explicitly:
0 2 * * * cd /www/wwwroot/foren.dev && \
FOREN_DB_HOST=127.0.0.1 FOREN_DB_PORT=3306 \
FOREN_DB_NAME=<DB> FOREN_DB_USER=<DB_USER> \
FOREN_DB_PASS=STRONG_PASSWORD \
/www/server/php/85/bin/php license/scripts/backup.php \
--backup-dir=/backup/foren >> /var/log/foren/backup.log 2>&1
Prefer keeping the password out of the crontab: source an env file with mode
0600 owned by the cron user, e.g. /root/.foren-backup.env:
FOREN_DB_NAME=<DB>
FOREN_DB_USER=<DB_USER>
FOREN_DB_PASS=STRONG_PASSWORD
FOREN_DB_HOST=127.0.0.1
0 2 * * * cd /www/wwwroot/foren.dev && set -a && . /root/.foren-backup.env && set +a && \
/www/server/php/85/bin/php license/scripts/backup.php \
--backup-dir=/backup/foren >> /var/log/foren/backup.log 2>&1
The license/scripts/backup.php script backs up:
- MySQL database (
FOREN_DB_NAME) — gzip'd SQL dump, 7-day retention - SQLite database (
data/foren.sqlite) — if present, 7-day retention - Ed25519 signing keys — discovered from
FOREN_LICENSE_PRIVATE_KEY/
FORENKEY_BACKEND_PRIVATE_KEY, then license/storage/keys/, then
license/config.php's license.private_key_path
Accepted flags:
| Flag | Effect |
|---|---|
| --- | --- |
--backup-dir=PATH | Output directory (default /backup/foren) |
--dry-run | Report what *would* be backed up; write nothing |
--force | Overwrite an existing same-day backup |
--gpg-passphrase=FILE | Symmetrically encrypt the key archive (AES256); the file must contain only the passphrase |
--help | Usage |
Archive naming reflects the real encryption state — a key archive is written
as keys-YYYY-MM-DD.tar.gz.plain when no passphrase is supplied, and
keys-YYYY-MM-DD.tar.gz.gpg only when GPG actually ran. The manifest marks
plain archives [UNENCRYPTED].
A manifest is written to <backup-dir>/backup-manifest.txt on each run.
Note: Without
--gpg-passphrasethe key archive is a plain tarball protected only by filesystem permissions. For production, either pass--gpg-passphrase=/root/.foren-backup.gpg-pass(mode0600) or store the backup directory on an encrypted volume.
Ed25519 Private Key — Offline Backup SOP
The Ed25519 signing key is irreplaceable. Losing it means all previously-issued license signatures become unverifiable. Follow this SOP when first provisioning or rotating the key:
Step 1 — Generate key (first time only):
php license/scripts/generate-keypair.php
# Outputs:
# Private key: license/storage/keys/ed25519_private.base64
# Public key: license/storage/keys/ed25519_public.base64
Step 2 — Offline backup (do this immediately after generation):
# Method A: Encrypted USB + physical safe (recommended)
# 1. Insert a clean USB drive (no other data)
# 2. tar czf - license/storage/keys | gpg --symmetric --cipher-algo AES256 \
# --batch --passphrase "$(openssl rand -base64 32)" \
# -o foren-dev-keys-$(date +%Y%m%d).tar.gz.gpg
# 3. Verify the archive opens correctly with the passphrase
# 4. Store USB in a physically separate location from the server
# Method B: Encrypted container (cloud storage)
# Use a VeraCrypt container or similar, upload to a cloud storage
# that your organisation controls (not a third party).
Step 3 — Verify backup (after each key rotation):
# 1. Mount the backup media
# 2. Extract to a test directory: tar xzf foren-dev-keys-YYYYMMDD.tar.gz.gpg
# 3. Compare fingerprint: diff license/storage/keys/ed25519_public.base64 \
# /path/to/test-restore/ed25519_public.base64
# 4. If match: backup is valid. If mismatch: restore from a second backup copy.
Step 4 — Rotation (if key is compromised):
- Generate a new keypair
- Notify all active license holders to re-activate (v2 licenses become unverifiable)
- Back up the new key following Steps 2–3
- Destroy the old key from the server (do NOT destroy the old backup)
Backup Strategy
| What | How | Retention |
|---|---|---|
| --- | --- | --- |
| Database | mysqldump daily | 7 days |
| Ed25519 keys | Manual secure backup (offline) | Forever |
license/storage/keys/ | Secure backup (encrypted at rest) | Forever |
Critical: Ed25519 private keys cannot be regenerated — losing them means all previously-signed licenses become unverifiable. Back up to multiple secure locations.
Rollback Procedure
Note: The code directory is not a git repository. Use your deployment tool's rollback mechanism (e.g. rsync from a backup copy, or your CI/CD rollback feature).
# 1. Restore database (the dump is gzipped — pipe it through gunzip)
gunzip -c /backup/foren/<DB>-YYYY-MM-DD.sql.gz | mysql -u '<DB_USER>' -p '<DB>'
# 2. Restore code from backup
rsync -av --delete /backup/forendev-v1.2.3/ /www/wwwroot/foren.dev/
# 3. Restore license private keys (if they were in the backup)
rsync -av /backup/keys/ /www/wwwroot/foren.dev/license/storage/keys/
# 4. Restart PHP-FPM to clear opcache
systemctl restart php-fpm-85
# 5. Verify
curl https://foren.dev/license/api/coforen/health/
Monitoring
Health checks
curl -fsS https://foren.dev/license/api/coforen/health/
Should return {"ok": true, "db": "ok", "public_key_ready": true, ...} with status 200.
If db: "down" or public_key_ready: false, page the on-call.
Log locations
- Nginx access:
/www/wwwlogs/foren.dev.access.log - Nginx error:
/www/wwwlogs/foren.dev.error.log - PHP-FPM error:
/www/server/php/XX/var/log/php-fpm.log - License cron:
/var/log/foren/expiry.log(configure in crontab) - License webhooks: query
SELECT * FROM license_events WHERE delivered=0for backlog
Metrics to monitor
- Webhook delivery success rate:
SELECT AVG(retry_count) FROM license_events WHERE delivered=1 - Pending events:
SELECT COUNT(*) FROM license_events WHERE delivered=0 - Failed events:
SELECT COUNT(*) FROM license_events WHERE retry_count >= 5 - API rate limit hits:
SELECT COUNT(*) FROM license_rate_limits WHERE count > 50 - Active devices:
SELECT COUNT(*) FROM license_devices WHERE is_revoked=0 - Locked admin accounts:
SELECT username, locked_until FROM admins WHERE locked_until > UTC_TIMESTAMP() - 2FA adoption:
SELECT COUNT(*) AS enabled, (SELECT COUNT(*) FROM admins) AS total FROM admins WHERE totp_secret IS NOT NULL - Backup code exhaustion:
SELECT admin_id, COUNT(*) AS unused FROM admin_2fa_backup_codes WHERE used_at IS NULL GROUP BY admin_id HAVING unused < 3
Post-Deployment Verification
Run the regression suites before declaring a deploy good. None of them need a database or network access:
cd /www/wwwroot/foren.dev
php tests/test_url_safety.php # 41 assertions — download URL / XSS guards
php tests/test_script_json_safety.php # 58 assertions — inline-JSON escaping
php tests/test_totp_2fa.php # 44 assertions — TOTP, backup codes, key derivation
php tests/test_doc_links.php # 40 anchors — documentation links
php tests/test_css_classes.php # 99 classes — referenced classes exist
php tests/test_config_keys.php # 18 assertions — app_config keys resolve
Run these from a shell on the server, not over HTTP. See the
/tests/deny rule above — a browser request to these paths is now correctly refused. If you need to run them remotely, do it over SSH, notcurl.
test_css_classes.phpis the guard for the site's purged CSS: it parsesindex.php,verify.phpanddesign-lab.php, collects every class each page references, and asserts that it is actually defined inassets/icons.css,assets/theme.css,assets/verify-theme.cssandassets/design-lab.css. Because the stylesheets are pre-compiled and purged against an older page, adding a brand-new utility class to the HTML fails this test instead of silently rendering unstyled.
test_totp_2fa.php is the one that matters most after any change to
license/lib/TOTPService.php: it asserts the RFC 4226 HOTP vectors, so a
broken HMAC or truncation cannot ship unnoticed. Its key-derivation section
is equally important — encryptionKey() must never fall back to a derived
constant when no entropy is configured, because that makes every stored TOTP
secret decryptable from a database dump alone.
test_config_keys.php catches a whole class of silent failure. app_config()
returns the caller's default for a key that does not exist, so a typo loses a
security property without raising anything. It asserts every app_config()
literal resolves in config.php / license/config.php, and every FOREN_*
env var read by those files appears in this guide. Add a key here in the same
commit that introduces the app_config() call.
test_doc_links.php catches documentation drift — it resolves every
/docs/...#anchor link against the actual headings, so a renamed section
fails the check instead of silently becoming a dead link.
Manual 2FA smoke test
- Log in as an admin → System → Security → Setup 2FA
- Add the displayed secret to an authenticator app
- Enter the 6-digit code → confirm
- Copy the 8 backup codes (they are shown exactly once)
- Log out, log back in → confirm the 2FA prompt appears
- Log out, log back in → log in with a *backup code* instead of a TOTP code
- Confirm the used backup code no longer works, and that the remaining
count dropped by one
- Re-issue codes and confirm the old set is rejected
- Disable 2FA using a valid code, then confirm the secret and all codes
are gone
Manual lockout smoke test
- Attempt login with a wrong password 5 times
- The 6th attempt should report the account as locked, even with the
correct password
- Confirm
SELECT locked_until FROM admins WHERE username='...'is ~15
minutes in the future
- Wait for expiry (or clear the column) and confirm login works again