foren.dev

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:

PlaceholderMeaning
------
<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.md hard-coded foren_license, and older deployment notes mention forendev and forenkey_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_unified vs. the public catalog). A single PHP-FPM pool exports exactly one FOREN_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

VariablePurposeExample
---------
FOREN_DB_HOSTMySQL host127.0.0.1
FOREN_DB_PORTMySQL port3306
FOREN_DB_NAMEDatabase name<DB>
FOREN_DB_USERDatabase user<DB_USER>
FOREN_DB_PASSDatabase password(32+ random chars)
FOREN_LICENSE_PRIVATE_KEYPath to Ed25519 private key/path/to/ed25519_private.base64
FOREN_LICENSE_PUBLIC_KEYPath to Ed25519 public key/path/to/ed25519_public.base64
FOREN_LICENSE_V2_VIEW_KEY32 bytes base64, for v2 envelopebase64-encoded-32-bytes

Optional

VariablePurposeDefault
---------
FOREN_USE_UNIFIED_DBUse MySQL instead of legacy SQLite1 if FOREN_DB_HOST set
FOREN_SQLITE_PATHPath to legacy SQLitedata/foren.sqlite
FOREN_ADMIN_SESSIONAdmin session cookie nameFOREN_ADMIN
FORENKEY_SM2_PYTHONPython binary for SM2 verifypython3
FORENKEY_SM2_HELPERSM2 verify script path(auto-detect)
FORENKEY_BACKEND_KEY_IDForenKey key identifierforenkey-rsa-prod
FORENKEY_API_TOKENShared bearer token, accepted alongside DB-backed API tokens(unset = disabled)
FORENKEY_ALLOW_OPEN_APIAllow anonymous machine API access. Local development only.(unset = fail closed)
FOREN_CERT_ENCRYPTION_KEY32 bytes base64, encrypts X.509 certificate private keys(derived from the Ed25519 key)

FORENKEY_API_TOKEN is compared with hash_equals() against the presented bearer token before the api_tokens table 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/*, …) calls Auth::requireApiToken(). With no valid token the request is rejected with HTTP 401.

This is deliberate. Earlier releases treated "no FORENKEY_API_TOKEN and no api_tokens row" 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:

  1. 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.

  1. FORENKEY_API_TOKEN in php-fpm.conf / the pool config — a single

shared secret for one trusted client. Use at least 32 random characters.

  1. FORENKEY_ALLOW_OPEN_API=1 — anonymous access. This is an

explicit, local-development-only opt-in. It is a fixed allowlist of 1 / true / yes / on (case-insensitive, surrounding whitespace ignored); anything else, including 0, off and unset, keeps the API closed. Never set this on a public deployment.

GET /forenkey/health reports auth_required, which is the authoritative signal: it is true unless open access was explicitly enabled. It used to mirror only FORENKEY_API_TOKEN !== '', which showed false (i.e. "no auth needed") for a database-token-only deployment.

Note that GET /api/keys/pin stays disabled with 403 pin_read_disabled whenever no API token is configured at all, *even* when FORENKEY_ALLOW_OPEN_API=1 — plaintext PINs are never served anonymously.

FOREN_CERT_ENCRYPTION_KEY and the random fallback. When this is unset *and* license.private_key_path is not readable, CertificateService generates 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_NAME must 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 admins block at the top of 012 adds columns unconditionally. Re-running 012 against a database that already has totp_secret will fail with Duplicate 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 into licenses and activation_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:

  1. Nginx deny all on /tests/ (above) — the layer that actually matters.
  2. Never deploy tests/ into the document root in the first place. Exclude

it 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 403 on /tests/ is the expected, correct result. If you see 200, 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.conf env[...] entries are inherited by every pool, but a pool that declares its own env[...] set replaces the values it names; it does not merge. A dedicated pool with env[FOREN_DB_NAME] = forenkey_unified will 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:

  1. Finds licenses expiring within 7 days (sends license.expiring webhook)
  2. Finds licenses in grace period (sends license.in_grace webhook)
  3. Delivers all pending license_events to 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_USER are not in cron's environment it exits with code 2 and 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:

  1. MySQL database (FOREN_DB_NAME) — gzip'd SQL dump, 7-day retention
  2. SQLite database (data/foren.sqlite) — if present, 7-day retention
  3. 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:

FlagEffect
------
--backup-dir=PATHOutput directory (default /backup/foren)
--dry-runReport what *would* be backed up; write nothing
--forceOverwrite an existing same-day backup
--gpg-passphrase=FILESymmetrically encrypt the key archive (AES256); the file must contain only the passphrase
--helpUsage

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-passphrase the key archive is a plain tarball protected only by filesystem permissions. For production, either pass --gpg-passphrase=/root/.foren-backup.gpg-pass (mode 0600) 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):

  1. Generate a new keypair
  2. Notify all active license holders to re-activate (v2 licenses become unverifiable)
  3. Back up the new key following Steps 2–3
  4. Destroy the old key from the server (do NOT destroy the old backup)

Backup Strategy

WhatHowRetention
---------
Databasemysqldump daily7 days
Ed25519 keysManual 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=0 for 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, not curl.

test_css_classes.php is the guard for the site's purged CSS: it parses index.php, verify.php and design-lab.php, collects every class each page references, and asserts that it is actually defined in assets/icons.css, assets/theme.css, assets/verify-theme.css and assets/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

  1. Log in as an admin → System → Security → Setup 2FA
  2. Add the displayed secret to an authenticator app
  3. Enter the 6-digit code → confirm
  4. Copy the 8 backup codes (they are shown exactly once)
  5. Log out, log back in → confirm the 2FA prompt appears
  6. Log out, log back in → log in with a *backup code* instead of a TOTP code
  7. Confirm the used backup code no longer works, and that the remaining

count dropped by one

  1. Re-issue codes and confirm the old set is rejected
  2. Disable 2FA using a valid code, then confirm the secret and all codes

are gone

Manual lockout smoke test

  1. Attempt login with a wrong password 5 times
  2. The 6th attempt should report the account as locked, even with the

correct password

  1. Confirm SELECT locked_until FROM admins WHERE username='...' is ~15

minutes in the future

  1. Wait for expiry (or clear the column) and confirm login works again