initial prototype based on nat-as-server
Some checks failed
PHPUnit / test (push) Has been cancelled

This commit is contained in:
tom.hempel
2026-06-29 12:39:55 +02:00
commit f1caa9e681
148 changed files with 34905 additions and 0 deletions

21
cli/cleanup_sessions.php Normal file
View File

@ -0,0 +1,21 @@
<?php
/**
* CLI: Remove expired sessions from the database.
* Usage: php cli/cleanup_sessions.php
*/
if (php_sapi_name() !== 'cli') {
http_response_code(403);
echo "CLI only\n";
exit(1);
}
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
$stmt = $pdo->prepare("DELETE FROM session WHERE expiresAt < :now");
$stmt->execute([':now' => time()]);
$removed = $stmt->rowCount();
qdb_save($tmpDb, $lockFp);
echo "Removed $removed expired session(s).\n";

156
cli/diagnose_db.php Normal file
View File

@ -0,0 +1,156 @@
<?php
/**
* CLI: Print why the encrypted DB cannot be opened (deploy / env / permissions).
* Usage: php cli/diagnose_db.php [path/to/questionnaire_database]
*/
if (php_sapi_name() !== 'cli') {
http_response_code(403);
echo "CLI only\n";
exit(1);
}
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
/** @return array{ok: bool, error?: string} */
function qdb_probe_decrypt(string $dbPath, string $keyBytes): array {
if (!is_file($dbPath)) {
return ['ok' => false, 'error' => 'file missing'];
}
$storedEnc = @file_get_contents($dbPath);
if ($storedEnc === false || strlen($storedEnc) < 17) {
return ['ok' => false, 'error' => 'unreadable or too short'];
}
try {
aes256_cbc_decrypt_bytes($storedEnc, $keyBytes);
return ['ok' => true];
} catch (Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
}
$envPath = __DIR__ . '/../.env';
echo "Project: " . realpath(__DIR__ . '/..') . "\n";
echo ".env: " . ($envPath && is_readable($envPath) ? "readable" : "MISSING or not readable") . "\n";
$keyEnv = getenv('QDB_MASTER_KEY');
if ($keyEnv === false || $keyEnv === '') {
echo "QDB_MASTER_KEY: NOT SET (set in .env or Apache/PHP-FPM env)\n";
exit(1);
}
echo "QDB_MASTER_KEY: set (" . strlen($keyEnv) . " chars)\n";
$b64 = base64_decode($keyEnv, true);
echo " parses as base64: " . ($b64 !== false && strlen($b64) > 0 ? 'yes (' . strlen($b64) . ' bytes)' : 'no (uses first 32 ASCII chars)') . "\n";
$dbPath = $argv[1] ?? QDB_PATH;
if ($dbPath !== QDB_PATH && !is_file($dbPath)) {
echo "File not found: $dbPath\n";
exit(1);
}
if ($dbPath !== QDB_PATH) {
echo "Note: probing alternate file (app still uses " . QDB_PATH . " at runtime)\n";
}
$uploads = dirname($dbPath);
echo "uploads dir: $uploads\n";
echo " exists: " . (is_dir($uploads) ? 'yes' : 'no') . "\n";
echo " writable: " . (is_writable($uploads) ? 'yes' : 'NO — migrations cannot save') . "\n";
echo "DB file: $dbPath\n";
if (is_file($dbPath)) {
$mtime = filemtime($dbPath);
echo " exists: yes (" . filesize($dbPath) . " bytes)\n";
echo " modified: " . ($mtime ? date('Y-m-d H:i:s T', $mtime) : '?') . "\n";
} else {
echo " exists: no (will create on first write)\n";
}
$backupDir = $uploads . '/backups';
if (is_dir($backupDir)) {
$backups = glob($backupDir . '/questionnaire_database.*') ?: [];
rsort($backups);
echo "Backups in uploads/backups: " . count($backups) . "\n";
foreach (array_slice($backups, 0, 5) as $bp) {
$mt = filemtime($bp);
echo " - " . basename($bp) . ' (' . filesize($bp) . " bytes, " . ($mt ? date('Y-m-d H:i:s', $mt) : '?') . ")\n";
}
} else {
echo "Backups: none (uploads/backups missing)\n";
}
if (!is_file($dbPath)) {
exit(0);
}
$candidates = [
'current .env (get_master_key_bytes)' => get_master_key_bytes(),
'legacy fallback (pre-April 2026 default)' => str_pad('12345678901234567890123456789012', 32, "\0"),
'raw first 32 chars of QDB_MASTER_KEY' => str_pad(substr($keyEnv, 0, 32), 32, "\0"),
];
if ($b64 !== false && strlen($b64) > 0) {
$candidates['base64-decoded .env only'] = str_pad(substr($b64, 0, 32), 32, "\0");
}
echo "\nDecrypt probe (which key matches questionnaire_database?):\n";
$matchedLabel = null;
$matchedKey = null;
$appKey = get_master_key_bytes();
foreach ($candidates as $label => $keyBytes) {
$probe = qdb_probe_decrypt($dbPath, $keyBytes);
if ($probe['ok']) {
echo " MATCH: $label\n";
if ($matchedLabel === null) {
$matchedLabel = $label;
$matchedKey = $keyBytes;
}
} else {
echo " no: $label\n";
}
}
if ($matchedLabel === null) {
echo "\nNo known key opened the file. Likely causes:\n";
echo " - DB was encrypted with a different QDB_MASTER_KEY than in .env now\n";
echo " - File was corrupted (e.g. interrupted save); try an older uploads/backups copy\n";
echo " - Wrong DB path (another install under /var/www/...)\n";
echo "\nProbe other copies, e.g.:\n";
echo " php cli/diagnose_db.php /var/www/html/qdb/uploads/questionnaire_database\n";
echo "\nWiping is NOT required for schema updates. Only wipe if you accept data loss\n";
echo "and run: mv uploads/questionnaire_database uploads/questionnaire_database.bak\n";
echo "then: php seed_admin.php <user> <pass>\n";
exit(1);
}
$dbMatchesAppKey = $matchedKey !== null && hash_equals($matchedKey, $appKey);
if ($dbPath !== QDB_PATH && $dbMatchesAppKey) {
echo "\nThis file decrypts with your .env. Restore it into the app uploads dir:\n";
echo " cp " . escapeshellarg($dbPath) . ' ' . escapeshellarg(QDB_PATH) . "\n";
echo " chown www-data:www-data " . escapeshellarg(QDB_PATH) . "\n";
exit(0);
}
if (!$dbMatchesAppKey) {
echo "\nThe DB does NOT match get_master_key_bytes() but matches: $matchedLabel\n";
echo "Fix: align QDB_MASTER_KEY in .env and php-fpm pool env, then re-run diagnose.\n";
exit(1);
}
if ($dbPath !== QDB_PATH) {
echo "\nDecrypt OK for alternate path. Run without arguments after copying into uploads/.\n";
exit(0);
}
try {
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
$version = (int)$pdo->query('PRAGMA user_version')->fetchColumn();
$users = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
$hasSession = qdb_table_exists($pdo, 'session');
qdb_discard($tmpDb, $lockFp);
echo "\nOK: full open + migrations succeeded.\n";
echo " PRAGMA user_version: $version (expected " . QDB_VERSION . ")\n";
echo " users: $users\n";
echo " session table: " . ($hasSession ? 'yes' : 'MISSING') . "\n";
exit(0);
} catch (Throwable $e) {
echo "\nDecrypt OK but qdb_open failed: " . $e->getMessage() . "\n";
exit(1);
}

77
cli/diagnose_login.php Normal file
View File

@ -0,0 +1,77 @@
<?php
/**
* CLI: Reproduce login DB + token_add steps (same code path as auth/login).
* Usage: sudo -u www-data php cli/diagnose_login.php <username> <password>
*/
if (php_sapi_name() !== 'cli') {
exit(1);
}
require_once __DIR__ . '/../common.php';
require_once __DIR__ . '/../db_init.php';
$username = $argv[1] ?? '';
$password = $argv[2] ?? '';
if ($username === '' || $password === '') {
fwrite(STDERR, "Usage: php cli/diagnose_login.php <username> <password>\n");
fwrite(STDERR, "Run as www-data to match php-fpm: sudo -u www-data php cli/diagnose_login.php ...\n");
exit(1);
}
echo 'User: ' . (function_exists('posix_getpwuid') ? (posix_getpwuid(posix_geteuid())['name'] ?? '?') : '?') . "\n";
echo 'QDB_MASTER_KEY env length: ' . strlen(getenv('QDB_MASTER_KEY') ?: '') . "\n";
echo 'Derived key sha256: ' . hash('sha256', get_master_key_bytes()) . "\n\n";
try {
echo "1) qdb_open(read-only)... ";
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
echo "OK\n";
echo "2) lookup user... ";
$stmt = $pdo->prepare(
"SELECT userID, passwordHash, role, entityID, mustChangePassword FROM users WHERE username = :u"
);
$stmt->execute([':u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
qdb_discard($tmpDb, $lockFp);
echo "FAIL — user not found\n";
exit(1);
}
echo "OK (role={$user['role']}, mustChange={$user['mustChangePassword']})\n";
echo "3) password_verify... ";
if (!password_verify($password, $user['passwordHash'])) {
qdb_discard($tmpDb, $lockFp);
echo "FAIL — wrong password\n";
exit(1);
}
echo "OK\n";
qdb_discard($tmpDb, $lockFp);
echo "4) token_add (writable save, same as login)... ";
$token = bin2hex(random_bytes(32));
token_add($token, 120, [
'role' => $user['role'],
'entityID' => $user['entityID'],
'userID' => $user['userID'],
'temp' => (int)$user['mustChangePassword'] === 1,
]);
echo "OK\n";
echo "5) token_get_record... ";
$rec = token_get_record($token);
if (!$rec) {
echo "FAIL — session not readable after save\n";
exit(1);
}
echo "OK\n";
token_revoke($token);
echo "\nAll login steps succeeded. If the website still fails, check nginx routing (curl below).\n";
} catch (Throwable $e) {
echo "FAIL\n " . $e->getMessage() . "\n";
echo " " . $e->getFile() . ':' . $e->getLine() . "\n";
exit(1);
}