diff --git a/CLAUDE.md b/CLAUDE.md index 1b7c631..a27d4fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**php-ssl** (v0.9.0) is a PHP 7.4+ SSL/TLS certificate monitoring web application. It scans predefined hostnames for certificate changes, supports DNS zone transfers (AXFR) to auto-discover hosts, remote scanning agents, and sends email notifications for changes and expirations. Multi-tenant architecture with full tenant isolation. +**php-ssl** (v0.9.0) is a PHP 8.0+ SSL/TLS certificate monitoring web application. It scans predefined hostnames for certificate changes, supports DNS zone transfers (AXFR) to auto-discover hosts, remote scanning agents, and sends email notifications for changes and expirations. Multi-tenant architecture with full tenant isolation. Key features: certificate monitoring, CA management, CSR generation/signing, testssl.sh integration, WebAuthn/Passkey authentication, Active Directory sync, multi-language (EN/SL/DE), database migrations UI, private key management, public scan report sharing. @@ -38,6 +38,8 @@ php cron.php # e.g.: php cron.php 1 update_certificates ``` +**PHP required:** 8.0 or later (server currently runs PHP 8.3) + **PHP extensions required:** `curl`, `gettext`, `openssl`, `pcntl`, `PDO`, `pdo_mysql`, `session` There is no build system — this is a traditional PHP app deployed directly to a web root. @@ -259,11 +261,11 @@ Key settings beyond DB credentials: ### PHP Version Compatibility -The server runs **PHP 7.4**. Do not use PHP 8.0+ syntax: -- No `match` expressions → use `switch/case` -- No `str_starts_with()` / `str_ends_with()` → use `strncmp()` or `substr()` -- No named arguments -- Typed properties (PHP 7.4+) and arrow functions `fn()` (PHP 7.4+) are fine +The server runs **PHP 8.3**. PHP 8.0+ syntax is fully supported and preferred: +- `match` expressions, `str_starts_with()`, `str_ends_with()`, `str_contains()` +- Named arguments, union types, nullsafe operator (`?->`) +- Enums (PHP 8.1+), readonly properties (PHP 8.1+), fibers (PHP 8.1+) +- A runtime warning banner is shown to all users if PHP < 8.0 is detected (via `route/common/checks.php`) ### Translations diff --git a/config.dist.php b/config.dist.php index 05965a3..2c9dd29 100644 --- a/config.dist.php +++ b/config.dist.php @@ -120,6 +120,16 @@ $webauthn_origin = ""; $webauthn_rpid = ""; +/** + * Path to the nmap binary used for network host discovery scans. + * + * Install nmap via your package manager: apt install nmap / yum install nmap + * The web server user must have execute permission on this binary. + * + * @var string + */ +$nmap_path = "/usr/bin/nmap"; + /** * Private key encryption keys — one entry per tenant (keyed by tenant ID). * diff --git a/db/SCHEMA.sql b/db/SCHEMA.sql index df75fe3..7c5b895 100644 --- a/db/SCHEMA.sql +++ b/db/SCHEMA.sql @@ -62,7 +62,7 @@ DROP TABLE IF EXISTS `cas`; CREATE TABLE `cas` ( `id` int(11) NOT NULL AUTO_INCREMENT, - `t_id` int(11) NOT NULL, + `t_id` int(11) unsigned NOT NULL, `name` varchar(255) NOT NULL, `certificate` text DEFAULT NULL, `pkey_id` int(11) unsigned DEFAULT NULL, @@ -80,8 +80,10 @@ CREATE TABLE `cas` ( KEY `pkey_id` (`pkey_id`), KEY `parent_ca_id` (`parent_ca_id`), KEY `cas_ski_tid` (`ski`,`t_id`), + KEY `cas_serial_tid` (`serial`,`t_id`), CONSTRAINT `cas_parent_fk` FOREIGN KEY (`parent_ca_id`) REFERENCES `cas` (`id`) ON DELETE SET NULL, - CONSTRAINT `cas_pkey_fk` FOREIGN KEY (`pkey_id`) REFERENCES `pkey` (`id`) ON DELETE SET NULL + CONSTRAINT `cas_pkey_fk` FOREIGN KEY (`pkey_id`) REFERENCES `pkey` (`id`) ON DELETE SET NULL, + CONSTRAINT `cas_tenant_fk` FOREIGN KEY (`t_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -327,8 +329,8 @@ CREATE TABLE `logs` ( `action` varchar(32) NOT NULL, `public` tinyint(1) NOT NULL DEFAULT 0, `text` text DEFAULT NULL, - `json_object_old` text DEFAULT NULL, - `json_object_new` text DEFAULT NULL, + `json_object_old` mediumtext DEFAULT NULL, + `json_object_new` mediumtext DEFAULT NULL, `is_revertable` tinyint(1) NOT NULL DEFAULT 0, `date` datetime DEFAULT current_timestamp() ON UPDATE current_timestamp(), PRIMARY KEY (`id`), @@ -498,6 +500,8 @@ CREATE TABLE `users` ( `changePass` tinyint(1) NOT NULL DEFAULT 0, `disabled` tinyint(1) NOT NULL DEFAULT 0, `force_passkey` tinyint(1) NOT NULL DEFAULT 0, + `totp_secret` varchar(255) DEFAULT NULL, + `totp_enabled` tinyint(1) NOT NULL DEFAULT 0, `lang_id` int(11) unsigned DEFAULT NULL, `test` varchar(50) DEFAULT NULL, `create_date` datetime NOT NULL DEFAULT current_timestamp(), @@ -600,6 +604,31 @@ CREATE TABLE `testssl` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +# Dump of table nmap_scans +# ------------------------------------------------------------ + +DROP TABLE IF EXISTS `nmap_scans`; + +CREATE TABLE `nmap_scans` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `tenant_id` int(11) NOT NULL, + `zone_id` int(11) NOT NULL, + `user_id` int(11) NOT NULL, + `prefix` varchar(50) NOT NULL, + `pg_id` int(11) DEFAULT NULL, + `ptr_lookup` tinyint(1) NOT NULL DEFAULT 0, + `notify_email` varchar(255) DEFAULT NULL, + `status` enum('Requested','Scanning','Completed','Error') NOT NULL DEFAULT 'Requested', + `hosts_found` int(11) NOT NULL DEFAULT 0, + `hosts_added` int(11) NOT NULL DEFAULT 0, + `requested` datetime DEFAULT NULL, + `completed` datetime DEFAULT NULL, + `error_msg` text DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_tenant_status` (`tenant_id`,`status`), + KEY `idx_zone_id` (`zone_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; diff --git a/db/migrations/0025_cas_tenant_fk.sql b/db/migrations/0025_cas_tenant_fk.sql new file mode 100644 index 0000000..1ea36a0 --- /dev/null +++ b/db/migrations/0025_cas_tenant_fk.sql @@ -0,0 +1,4 @@ +-- tenants.id is int(11) unsigned; cas.t_id must match for the FK to form +ALTER TABLE `cas` MODIFY COLUMN `t_id` int(11) unsigned NOT NULL; + +ALTER TABLE `cas` ADD CONSTRAINT `cas_tenant_fk` FOREIGN KEY (`t_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/db/migrations/0026_add_totp_2fa.sql b/db/migrations/0026_add_totp_2fa.sql new file mode 100644 index 0000000..e6f8414 --- /dev/null +++ b/db/migrations/0026_add_totp_2fa.sql @@ -0,0 +1,3 @@ +ALTER TABLE `users` + ADD COLUMN `totp_secret` VARCHAR(255) DEFAULT NULL AFTER `force_passkey`, + ADD COLUMN `totp_enabled` TINYINT(1) NOT NULL DEFAULT 0 AFTER `totp_secret`; diff --git a/db/migrations/0027_nmap_scans.sql b/db/migrations/0027_nmap_scans.sql new file mode 100644 index 0000000..e2426b2 --- /dev/null +++ b/db/migrations/0027_nmap_scans.sql @@ -0,0 +1,18 @@ +CREATE TABLE `nmap_scans` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `tenant_id` INT(11) NOT NULL, + `zone_id` INT(11) NOT NULL, + `user_id` INT(11) NOT NULL, + `prefix` VARCHAR(50) NOT NULL, + `ports` VARCHAR(500) NOT NULL, + `ptr_lookup` TINYINT(1) NOT NULL DEFAULT 0, + `status` ENUM('Requested','Scanning','Completed','Error') NOT NULL DEFAULT 'Requested', + `hosts_found` INT(11) NOT NULL DEFAULT 0, + `hosts_added` INT(11) NOT NULL DEFAULT 0, + `requested` DATETIME DEFAULT NULL, + `completed` DATETIME DEFAULT NULL, + `error_msg` TEXT DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_tenant_status` (`tenant_id`, `status`), + KEY `idx_zone_id` (`zone_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/db/migrations/0028_nmap_scans_notify_email.sql b/db/migrations/0028_nmap_scans_notify_email.sql new file mode 100644 index 0000000..19dc9d8 --- /dev/null +++ b/db/migrations/0028_nmap_scans_notify_email.sql @@ -0,0 +1,2 @@ +ALTER TABLE `nmap_scans` + ADD COLUMN `notify_email` VARCHAR(255) DEFAULT NULL AFTER `ptr_lookup`; diff --git a/db/migrations/0029_nmap_scans_pg_id.sql b/db/migrations/0029_nmap_scans_pg_id.sql new file mode 100644 index 0000000..11684eb --- /dev/null +++ b/db/migrations/0029_nmap_scans_pg_id.sql @@ -0,0 +1,3 @@ +ALTER TABLE `nmap_scans` + DROP COLUMN `ports`, + ADD COLUMN `pg_id` INT DEFAULT NULL AFTER `prefix`; diff --git a/db/migrations/0030_logs_mediumtext.sql b/db/migrations/0030_logs_mediumtext.sql new file mode 100644 index 0000000..61d5365 --- /dev/null +++ b/db/migrations/0030_logs_mediumtext.sql @@ -0,0 +1,3 @@ +ALTER TABLE `logs` + MODIFY COLUMN `json_object_old` MEDIUMTEXT DEFAULT NULL, + MODIFY COLUMN `json_object_new` MEDIUMTEXT DEFAULT NULL; diff --git a/functions/assets/GoogleAuthenticator/GoogleAuthenticator.php b/functions/assets/GoogleAuthenticator/GoogleAuthenticator.php new file mode 100644 index 0000000..bf7d116 --- /dev/null +++ b/functions/assets/GoogleAuthenticator/GoogleAuthenticator.php @@ -0,0 +1,252 @@ +_getBase32LookupTable(); + + // Valid secret lengths are 80 to 640 bits + if ($secretLength < 16 || $secretLength > 128) { + throw new Exception('Bad secret length'); + } + $secret = ''; + $rnd = false; + if (function_exists('random_bytes')) { + $rnd = random_bytes($secretLength); + } elseif (function_exists('mcrypt_create_iv')) { + $rnd = mcrypt_create_iv($secretLength, MCRYPT_DEV_URANDOM); + } elseif (function_exists('openssl_random_pseudo_bytes')) { + $rnd = openssl_random_pseudo_bytes($secretLength, $cryptoStrong); + if (!$cryptoStrong) { + $rnd = false; + } + } + if ($rnd !== false) { + for ($i = 0; $i < $secretLength; ++$i) { + $secret .= $validChars[ord($rnd[$i]) & 31]; + } + } else { + throw new Exception('No source of secure random'); + } + + return $secret; + } + + /** + * Calculate the code, with given secret and point in time. + * + * @param string $secret + * @param int|null $timeSlice + * + * @return string + */ + public function getCode($secret, $timeSlice = null) + { + if ($timeSlice === null) { + $timeSlice = floor(time() / 30); + } + + $secretkey = $this->_base32Decode($secret); + + // Pack time into binary string + $time = chr(0).chr(0).chr(0).chr(0).pack('N*', $timeSlice); + // Hash it with users secret key + $hm = hash_hmac('SHA1', $time, $secretkey, true); + // Use last nipple of result as index/offset + $offset = ord(substr($hm, -1)) & 0x0F; + // grab 4 bytes of the result + $hashpart = substr($hm, $offset, 4); + + // Unpak binary value + $value = unpack('N', $hashpart); + $value = $value[1]; + // Only 32 bits + $value = $value & 0x7FFFFFFF; + + $modulo = pow(10, $this->_codeLength); + + return str_pad($value % $modulo, $this->_codeLength, '0', STR_PAD_LEFT); + } + + /** + * Get QR-Code URL for image, from google charts. + * + * @param string $name + * @param string $secret + * @param string $title + * @param array $params + * + * @return string + */ + public function getQRCodeGoogleUrl($name, $secret, $title = null, $params = array()) + { + $width = !empty($params['width']) && (int) $params['width'] > 0 ? (int) $params['width'] : 200; + $height = !empty($params['height']) && (int) $params['height'] > 0 ? (int) $params['height'] : 200; + $level = !empty($params['level']) && array_search($params['level'], array('L', 'M', 'Q', 'H')) !== false ? $params['level'] : 'M'; + + $urlencoded = urlencode('otpauth://totp/'.$name.'?secret='.$secret.''); + if (isset($title)) { + $urlencoded .= urlencode('&issuer='.urlencode($title)); + } + + return "https://api.qrserver.com/v1/create-qr-code/?data=$urlencoded&size=${width}x${height}&ecc=$level"; + } + + /** + * Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now. + * + * @param string $secret + * @param string $code + * @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after) + * @param int|null $currentTimeSlice time slice if we want use other that time() + * + * @return bool + */ + public function verifyCode($secret, $code, $discrepancy = 1, $currentTimeSlice = null) + { + if ($currentTimeSlice === null) { + $currentTimeSlice = floor(time() / 30); + } + + if (strlen($code) != 6) { + return false; + } + + for ($i = -$discrepancy; $i <= $discrepancy; ++$i) { + $calculatedCode = $this->getCode($secret, $currentTimeSlice + $i); + if ($this->timingSafeEquals($calculatedCode, $code)) { + return true; + } + } + + return false; + } + + /** + * Set the code length, should be >=6. + * + * @param int $length + * + * @return PHPGangsta_GoogleAuthenticator + */ + public function setCodeLength($length) + { + $this->_codeLength = $length; + + return $this; + } + + /** + * Helper class to decode base32. + * + * @param $secret + * + * @return bool|string + */ + protected function _base32Decode($secret) + { + if (empty($secret)) { + return ''; + } + + $base32chars = $this->_getBase32LookupTable(); + $base32charsFlipped = array_flip($base32chars); + + $paddingCharCount = substr_count($secret, $base32chars[32]); + $allowedValues = array(6, 4, 3, 1, 0); + if (!in_array($paddingCharCount, $allowedValues)) { + return false; + } + for ($i = 0; $i < 4; ++$i) { + if ($paddingCharCount == $allowedValues[$i] && + substr($secret, -($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])) { + return false; + } + } + $secret = str_replace('=', '', $secret); + $secret = str_split($secret); + $binaryString = ''; + for ($i = 0; $i < count($secret); $i = $i + 8) { + $x = ''; + if (!in_array($secret[$i], $base32chars)) { + return false; + } + for ($j = 0; $j < 8; ++$j) { + $x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT); + } + $eightBits = str_split($x, 8); + for ($z = 0; $z < count($eightBits); ++$z) { + $binaryString .= (($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48) ? $y : ''; + } + } + + return $binaryString; + } + + /** + * Get array with all 32 characters for decoding from/encoding to base32. + * + * @return array + */ + protected function _getBase32LookupTable() + { + return array( + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', // 7 + 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', // 15 + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', // 23 + 'Y', 'Z', '2', '3', '4', '5', '6', '7', // 31 + '=', // padding char + ); + } + + /** + * A timing safe equals comparison + * more info here: http://blog.ircmaxell.com/2014/11/its-all-about-time.html. + * + * @param string $safeString The internal (safe) value to be checked + * @param string $userString The user submitted (unsafe) value + * + * @return bool True if the two strings are identical + */ + private function timingSafeEquals($safeString, $userString) + { + if (function_exists('hash_equals')) { + return hash_equals($safeString, $userString); + } + $safeLen = strlen($safeString); + $userLen = strlen($userString); + + if ($userLen != $safeLen) { + return false; + } + + $result = 0; + + for ($i = 0; $i < $userLen; ++$i) { + $result |= (ord($safeString[$i]) ^ ord($userString[$i])); + } + + // They are only identical strings if $result is exactly 0... + return $result === 0; + } +} diff --git a/functions/autoload.php b/functions/autoload.php index e416d10..7e5ee97 100644 --- a/functions/autoload.php +++ b/functions/autoload.php @@ -137,6 +137,7 @@ function load_translations ($Database) include ("classes/class.Migration.php"); include ("classes/class.WebAuthn.php"); include ("classes/class.testssl.php"); +include ("classes/class.Nmap.php"); # testssl submodule availability flag $testssl_available = file_exists(dirname(__FILE__)."/testSSL/testssl.sh"); diff --git a/functions/class.testssl.php b/functions/class.testssl.php deleted file mode 100644 index 1dbd29f..0000000 --- a/functions/class.testssl.php +++ /dev/null @@ -1,498 +0,0 @@ -Database = $Database; - $this->testssl_path = dirname(__FILE__) . '/../testSSL/testssl.sh'; - } - - // ------------------------------------------------------------------------- - // Query helpers - // ------------------------------------------------------------------------- - - public function get_all(int $tenant_id, bool $is_admin): array - { - if ($is_admin) { - $rows = $this->Database->getObjectsQuery( - "SELECT ts.*, u.name AS user_name, t.name AS tenant_name - FROM testssl ts - LEFT JOIN users u ON ts.user_id = u.id - LEFT JOIN tenants t ON ts.tenant_id = t.id - ORDER BY ts.requested DESC", - [] - ); - } else { - $rows = $this->Database->getObjectsQuery( - "SELECT ts.*, u.name AS user_name, t.name AS tenant_name - FROM testssl ts - LEFT JOIN users u ON ts.user_id = u.id - LEFT JOIN tenants t ON ts.tenant_id = t.id - WHERE ts.tenant_id = ? - ORDER BY ts.requested DESC", - [$tenant_id] - ); - } - return $rows ?: []; - } - - public function get_by_id(int $id, int $tenant_id, bool $is_admin): ?object - { - if ($is_admin) { - return $this->Database->getObjectQuery( - "SELECT ts.*, u.name AS user_name, t.name AS tenant_name - FROM testssl ts - LEFT JOIN users u ON ts.user_id = u.id - LEFT JOIN tenants t ON ts.tenant_id = t.id - WHERE ts.id = ?", - [$id] - ) ?: null; - } - return $this->Database->getObjectQuery( - "SELECT ts.*, u.name AS user_name, t.name AS tenant_name - FROM testssl ts - LEFT JOIN users u ON ts.user_id = u.id - LEFT JOIN tenants t ON ts.tenant_id = t.id - WHERE ts.id = ? AND ts.tenant_id = ?", - [$id, $tenant_id] - ) ?: null; - } - - public function get_by_hash(string $hash): ?object - { - return $this->Database->getObjectQuery( - "SELECT ts.*, u.name AS user_name, t.name AS tenant_name - FROM testssl ts - LEFT JOIN users u ON ts.user_id = u.id - LEFT JOIN tenants t ON ts.tenant_id = t.id - WHERE ts.hash = ?", - [$hash] - ) ?: null; - } - - /** Authenticated lookup by hash with tenant scoping. */ - public function get_by_hash_auth(string $hash, int $tenant_id, bool $is_admin): ?object - { - if ($is_admin) { - return $this->Database->getObjectQuery( - "SELECT ts.*, u.name AS user_name, t.name AS tenant_name - FROM testssl ts - LEFT JOIN users u ON ts.user_id = u.id - LEFT JOIN tenants t ON ts.tenant_id = t.id - WHERE ts.hash = ?", - [$hash] - ) ?: null; - } - return $this->Database->getObjectQuery( - "SELECT ts.*, u.name AS user_name, t.name AS tenant_name - FROM testssl ts - LEFT JOIN users u ON ts.user_id = u.id - LEFT JOIN tenants t ON ts.tenant_id = t.id - WHERE ts.hash = ? AND ts.tenant_id = ?", - [$hash, $tenant_id] - ) ?: null; - } - - /** Latest scan per hostname for the given tenant (for zone-hosts badges). */ - public function get_latest_by_hostnames(array $hostnames, int $tenant_id, bool $is_admin): array - { - if (empty($hostnames)) { return []; } - $n = count($hostnames); - $placeholders = implode(',', array_fill(0, $n, '?')); - $tenant_clause = $is_admin ? '' : ' AND ts.tenant_id = ?'; - $params = array_merge( - $hostnames, - $hostnames, - $is_admin ? [] : [$tenant_id] - ); - $rows = $this->Database->getObjectsQuery( - "SELECT ts.hostname, ts.port, ts.rating, ts.status, ts.completed, ts.hash, ts.id - FROM testssl ts - INNER JOIN ( - SELECT hostname, MAX(id) AS max_id - FROM testssl - WHERE hostname IN ($placeholders) - GROUP BY hostname - ) latest ON ts.id = latest.max_id - WHERE ts.hostname IN ($placeholders){$tenant_clause}", - $params - ) ?: []; - $map = []; - foreach ($rows as $r) { $map[$r->hostname] = $r; } - return $map; - } - - // ------------------------------------------------------------------------- - // Mutations - // ------------------------------------------------------------------------- - - public function create(string $hostname, int $port, int $tenant_id, int $user_id, ?string $notify_email = null): int - { - $hash = bin2hex(random_bytes(32)); - $this->Database->runQuery( - "INSERT INTO testssl (tenant_id, user_id, hostname, port, hash, notify_email, status, requested) - VALUES (?, ?, ?, ?, ?, ?, 'Requested', NOW())", - [$tenant_id, $user_id, $hostname, $port, $hash, $notify_email] - ); - return (int)$this->Database->lastInsertId(); - } - - public function cancel(int $id, int $tenant_id, bool $is_admin): void - { - if ($is_admin) { - $this->Database->runQuery( - "UPDATE testssl SET status = 'Cancelled' WHERE id = ? AND status = 'Requested'", - [$id] - ); - } else { - $this->Database->runQuery( - "UPDATE testssl SET status = 'Cancelled' WHERE id = ? AND tenant_id = ? AND status = 'Requested'", - [$id, $tenant_id] - ); - } - } - - public function delete(int $id, int $tenant_id, bool $is_admin): void - { - if ($is_admin) { - $this->Database->runQuery("DELETE FROM testssl WHERE id = ?", [$id]); - } else { - $this->Database->runQuery( - "DELETE FROM testssl WHERE id = ? AND tenant_id = ?", - [$id, $tenant_id] - ); - } - } - - // ------------------------------------------------------------------------- - // Cron execution - // ------------------------------------------------------------------------- - - /** Fetch all Requested scans for a tenant, mark Scanning, then run each. */ - public function run_pending(int $tenant_id): void - { - $scans = $this->Database->getObjectsQuery( - "SELECT * FROM testssl WHERE tenant_id = ? AND status = 'Requested' ORDER BY requested ASC", - [$tenant_id] - ); - if (!$scans) { return; } - - foreach ($scans as $scan) { - $this->Database->runQuery( - "UPDATE testssl SET status = 'Scanning', started = NOW() WHERE id = ?", - [$scan->id] - ); - $this->run_scan($scan); - } - } - - private function run_scan(object $scan): void - { - if (!file_exists($this->testssl_path)) { - $this->Database->runQuery( - "UPDATE testssl SET status = 'Error', completed = NOW(), error_message = ? WHERE id = ?", - ['testssl.sh submodule not found', $scan->id] - ); - return; - } - - $json_file = sys_get_temp_dir() . '/testssl_' . $scan->id . '_' . time() . '.json'; - - // Build argument array — proc_open with array skips the shell entirely - $args = [ - 'bash', - $this->testssl_path, - '--jsonfile', $json_file, - '--quiet', - '--color', '0', - '--warnings', 'off', - $scan->hostname . ':' . (int)$scan->port, - ]; - - $descriptors = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - - $proc = proc_open($args, $descriptors, $pipes); - if (!is_resource($proc)) { - $this->Database->runQuery( - "UPDATE testssl SET status = 'Error', completed = NOW(), error_message = 'proc_open failed' WHERE id = ?", - [$scan->id] - ); - return; - } - - fclose($pipes[0]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $exit_code = proc_close($proc); - - if ($exit_code !== 0 && !file_exists($json_file)) { - $error = substr($stderr, -2000); - $this->Database->runQuery( - "UPDATE testssl SET status = 'Error', completed = NOW(), error_message = ? WHERE id = ?", - [$error ?: 'testssl.sh exited with code ' . $exit_code, $scan->id] - ); - return; - } - - $json_raw = file_exists($json_file) ? file_get_contents($json_file) : null; - @unlink($json_file); - - // detect fatal scan problems reported inside the JSON - $decoded = $json_raw ? json_decode($json_raw, true) : null; - if (is_array($decoded)) { - foreach ($decoded as $item) { - if (isset($item['id']) && $item['id'] === 'scanProblem' - && isset($item['severity']) && strtoupper($item['severity']) === 'FATAL') { - $this->Database->runQuery( - "UPDATE testssl SET status = 'Error', completed = NOW(), json_result = ?, error_message = ? WHERE id = ?", - [$json_raw, substr($item['finding'] ?? 'Fatal scan error', 0, 2000), $scan->id] - ); - return; - } - } - } - - $rating = $this->extract_rating($decoded); - - $this->Database->runQuery( - "UPDATE testssl SET status = 'Completed', completed = NOW(), json_result = ?, rating = ? WHERE id = ?", - [$json_raw, $rating, $scan->id] - ); - - if (!empty($scan->notify_email)) { - $scan->rating = $rating; - $scan->status = 'Completed'; - $scan->completed = date('Y-m-d H:i:s'); - $this->send_completion_email($scan); - } - } - - private function send_completion_email(object $scan): void - { - global $mail_sender_settings; - - if (empty($scan->notify_email)) { return; } - - $Mail = new mailer(); - - // Fetch tenant href for building the authenticated link - $tenant = $this->Database->getObjectQuery( - "SELECT href FROM tenants WHERE id = ?", - [$scan->tenant_id] - ); - $tenant_href = $tenant ? $tenant->href : ''; - - $base_url = rtrim($mail_sender_settings->www ?? '', '/'); - $auth_link = $base_url . '/' . $tenant_href . '/testssl/' . (int)$scan->id . '/'; - $pub_link = $base_url . '/report/' . $scan->hash . '/'; - - $rating_label = $scan->rating ?: '—'; - $completed = $scan->completed ? date('Y-m-d H:i:s', strtotime($scan->completed)) : '—'; - $started = isset($scan->started) && $scan->started ? date('Y-m-d H:i:s', strtotime($scan->started)) : '—'; - $requested = isset($scan->requested) && $scan->requested ? date('Y-m-d H:i:s', strtotime($scan->requested)) : '—'; - - $td = "border-bottom:1px solid #eee;padding:5px 8px;vertical-align:top;"; - - $rows = []; - $rows[] = $Mail->font_title . _("testSSL scan completed") . "

"; - $rows[] = ""; - $rows[] = ""; - $rows[] = ""; - $rows[] = ""; - $rows[] = ""; - $rows[] = ""; - $rows[] = ""; - $rows[] = "
" . $Mail->font_norm . _("Hostname") . "" . $Mail->font_bold . htmlspecialchars($scan->hostname) . "
" . $Mail->font_norm . _("Port") . "" . $Mail->font_norm . (int)$scan->port . "
" . $Mail->font_norm . _("Rating") . "" . $Mail->font_bold . htmlspecialchars($rating_label) . "
" . $Mail->font_norm . _("Requested") . "" . $Mail->font_norm . $requested . "
" . $Mail->font_norm . _("Started") . "" . $Mail->font_norm . $started . "
" . $Mail->font_norm . _("Completed") . "" . $Mail->font_norm . $completed . "
"; - $rows[] = "
"; - - if ($tenant_href) { - $rows[] = $Mail->font_norm . "" . _("View report") . "
"; - } - $rows[] = $Mail->font_norm . "" . _("Public link") . "
"; - $rows[] = "
" . $Mail->font_norm . "Visit " . ($mail_sender_settings->www ?? '') . ""; - - $Mail->send( - "Telemach php-ssl :: testSSL scan completed", - [$scan->notify_email], - [], - [], - implode("\n", $rows), - false - ); - } - - // ------------------------------------------------------------------------- - // JSON parsing helpers - // ------------------------------------------------------------------------- - - /** Extract the overall rating (e.g. "A+") from testssl JSON array. */ - public function extract_rating(?array $data): ?string - { - if (!$data) { return null; } - foreach ($data as $item) { - if (isset($item['id']) && $item['id'] === 'overall_grade') { - return $item['finding'] ?? null; - } - } - return null; - } - - /** - * Parse flat testssl JSON array into grouped sections for display. - * Returns assoc array: section_key => ['title' => string, 'items' => [...]] - */ - public function parse_result(?string $json_raw): array - { - if (!$json_raw) { return []; } - $data = json_decode($json_raw, true); - if (!is_array($data)) { return []; } - - $sections = [ - 'general' => ['title' => _('General'), 'items' => []], - 'protocols' => ['title' => _('Protocols via sockets'), 'items' => []], - 'ciphers' => ['title' => _('Cipher categories'), 'items' => []], - 'pfs' => ['title' => _('Robust forward secrecy (FS)'), 'items' => []], - 'server' => ['title' => _('Server defaults'), 'items' => []], - 'header' => ['title' => _('HTTP security headers'), 'items' => []], - 'vulns' => ['title' => _('Vulnerabilities'), 'items' => []], - 'rating' => ['title' => _('Rating'), 'items' => []], - 'other' => ['title' => _('Other'), 'items' => []], - ]; - - // id prefix → section - $prefix_map = [ - 'service' => 'general', 'cert' => 'general', - 'issuer' => 'general', 'cn' => 'general', - 'san' => 'general', 'key' => 'general', - 'fingerprint' => 'general', 'trust' => 'general', - 'chain' => 'general', 'expiration' => 'general', - 'protocol_' => 'protocols','SSLv2' => 'protocols', - 'SSLv3' => 'protocols','TLS1' => 'protocols', - 'cipher_' => 'ciphers', 'cipherlist_' => 'ciphers', - 'fs_' => 'pfs', 'pfs' => 'pfs', - 'FS' => 'pfs', - 'server_defaults' => 'server', 'session' => 'server', - 'renegotiation' => 'server', 'compression' => 'server', - 'HSTS' => 'header', 'HPKP' => 'header', - 'banner' => 'header', 'cookie' => 'header', - 'security_header' => 'header', - 'heartbleed' => 'vulns', 'CCS' => 'vulns', - 'ticketbleed' => 'vulns', 'ROBOT' => 'vulns', - 'BEAST' => 'vulns', 'LUCKY13' => 'vulns', - 'RC4' => 'vulns', 'POODLE' => 'vulns', - 'SWEET32' => 'vulns', 'FREAK' => 'vulns', - 'DROWN' => 'vulns', 'LOGJAM' => 'vulns', - 'CRIME' => 'vulns', 'BREACH' => 'vulns', - 'GOLDENDOODLE' => 'vulns', 'ZOMBIE' => 'vulns', - 'vuln' => 'vulns', - 'overall_grade' => 'rating', 'grade' => 'rating', - ]; - - foreach ($data as $item) { - $id = $item['id'] ?? ''; - $finding = $item['finding'] ?? ''; - $sev = $item['severity'] ?? ''; - - $section = 'other'; - foreach ($prefix_map as $prefix => $sec) { - if ($id === $prefix || strncmp($id, $prefix, strlen($prefix)) === 0) { - $section = $sec; - break; - } - } - - $sections[$section]['items'][] = [ - 'id' => $id, - 'label' => $this->id_to_label($id), - 'finding' => $finding, - 'severity' => $sev, - ]; - } - - return array_filter($sections, fn($s) => !empty($s['items'])); - } - - private function id_to_label(string $id): string - { - return ucwords(strtolower(str_replace(['_', '-'], ' ', $id))); - } - - /** Severity → Bootstrap colour class. */ - public function severity_class(string $sev): string - { - switch (strtolower($sev)) { - case 'ok': case 'info': return 'success'; - case 'low': return 'info'; - case 'medium': case 'warn': return 'warning'; - case 'high': case 'critical': return 'danger'; - default: return 'secondary'; - } - } - - /** Rating → Bootstrap colour class. */ - public function rating_class(?string $rating): string - { - if (!$rating) { return 'secondary'; } - if ($rating[0] === 'A') { return 'success'; } - if ($rating[0] === 'B') { return 'info'; } - if ($rating[0] === 'C') { return 'warning'; } - return 'danger'; - } - - // ------------------------------------------------------------------------- - // Export - // ------------------------------------------------------------------------- - - public function export_json(object $scan): void - { - $filename = 'testssl_' . $scan->hostname . '_' . $scan->port - . '_' . date('Ymd', strtotime($scan->completed ?? $scan->requested)) - . '.json'; - header('Content-Type: application/json'); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - print $scan->json_result ?? '[]'; - exit; - } - - public function export_csv(object $scan): void - { - $filename = 'testssl_' . $scan->hostname . '_' . $scan->port - . '_' . date('Ymd', strtotime($scan->completed ?? $scan->requested)) - . '.csv'; - header('Content-Type: text/csv'); - header('Content-Disposition: attachment; filename="' . $filename . '"'); - - $data = json_decode($scan->json_result ?? '[]', true); - $out = fopen('php://output', 'w'); - fputcsv($out, ['id', 'finding', 'severity', 'cve', 'cwe']); - if (is_array($data)) { - foreach ($data as $item) { - fputcsv($out, [ - $item['id'] ?? '', - $item['finding'] ?? '', - $item['severity'] ?? '', - $item['cve'] ?? '', - $item['cwe'] ?? '', - ]); - } - } - fclose($out); - exit; - } -} diff --git a/functions/classes/class.AXFR.php b/functions/classes/class.AXFR.php index e51b038..99447f6 100644 --- a/functions/classes/class.AXFR.php +++ b/functions/classes/class.AXFR.php @@ -118,9 +118,19 @@ public function __construct(Database_PDO $Database) $this->Database = $Database; // Results $this->Result = new Result(); - // include Net_DNS2 - ini_set("include_path", dirname(__FILE__) . "/../assets/Net_DNS2"); - require_once(dirname(__FILE__) . "/../assets/Net_DNS2/Net/DNS2.php"); + // register PSR-4 autoloader for Net_DNS2 v2.x + $net_dns2_src = dirname(__FILE__) . "/../assets/Net_DNS2/src"; + if (!is_readable($net_dns2_src . "/NetDNS2/Client.php")) { + throw new Exception(_("Net_DNS2 submodule is missing. Run: git submodule update --init --recursive")); + } + spl_autoload_register(function (string $class) use ($net_dns2_src): void { + if (str_starts_with($class, 'NetDNS2\\')) { + $file = $net_dns2_src . '/' . str_replace('\\', '/', $class) . '.php'; + if (is_readable($file)) { + require_once $file; + } + } + }); } /** @@ -140,7 +150,7 @@ public function execute() // check response if (isset($result->answer)) { foreach ($result->answer as $rr) { - if (in_array($rr->type, $this->valid_record_types)) { + if (in_array($rr->type->label(), $this->valid_record_types)) { // save to result $this->result["values"][] = $rr; } @@ -163,9 +173,9 @@ public function execute() private function set_link() { if ($this->link === false) { - $this->link = new Net_DNS2_Resolver([ + $this->link = new \NetDNS2\Resolver([ 'nameservers' => $this->nameservers, - 'use_tcp' => $this->use_tcp + 'use_tcp' => $this->use_tcp, ]); } @@ -262,7 +272,9 @@ private function filter_results_include_regex() { if (strlen($this->regex_include) > 0) { foreach ($this->result["values"] as $k => $rr) { - if (!preg_match($this->regex_include, $rr->name) && !preg_match($this->regex_include, $rr->address)) { + $name = (string)$rr->name; + $address = isset($rr->address) ? (string)$rr->address : ''; + if (!preg_match($this->regex_include, $name) && !preg_match($this->regex_include, $address)) { unset($this->result["values"][$k]); } } @@ -278,7 +290,9 @@ private function filter_results_exclude_regex() { if (strlen($this->regex_exclude) > 0) { foreach ($this->result["values"] as $k => $rr) { - if (preg_match($this->regex_exclude, $rr->name) || preg_match($this->regex_exclude, $rr->address)) { + $name = (string)$rr->name; + $address = isset($rr->address) ? (string)$rr->address : ''; + if (preg_match($this->regex_exclude, $name) || preg_match($this->regex_exclude, $address)) { unset($this->result["values"][$k]); } } @@ -345,10 +359,13 @@ private function get_diff_axfr_records($check_ip = 0) // AXFR received records if (sizeof($this->result['values']) > 0) { foreach ($this->result['values'] as $rr) { - $this->records['axfr_records'][] = $rr->name; - $this->records['axfr_records'][] = $rr->cname; - if ($check_ip == "1") - $this->records['axfr_records'][] = $rr->address; + $this->records['axfr_records'][] = (string)$rr->name; + if (isset($rr->cname)) { + $this->records['axfr_records'][] = (string)$rr->cname; + } + if ($check_ip == "1" && isset($rr->address)) { + $this->records['axfr_records'][] = (string)$rr->address; + } } } // make unique diff --git a/functions/classes/class.Agent.php b/functions/classes/class.Agent.php index bdd3c6e..6c92e6d 100644 --- a/functions/classes/class.Agent.php +++ b/functions/classes/class.Agent.php @@ -121,6 +121,7 @@ public function scan() else { // save result and result code $result_info = curl_getinfo($API_conn); + if (!is_array($this->result)) { $this->result = []; } $this->result['result_code'] = $result_info['http_code']; // error ? diff --git a/functions/classes/class.Certificates.php b/functions/classes/class.Certificates.php index 1589a24..ba130e5 100644 --- a/functions/classes/class.Certificates.php +++ b/functions/classes/class.Certificates.php @@ -111,11 +111,14 @@ public function get_expired($days = 30, $expired_days = 7) $pz_clause = $impersonating ? "and z.private_zone_uid is null" : "and (z.private_zone_uid is null or z.private_zone_uid = ".(int)$this->user->id.")"; // fetch try { + // keep rows with a current host, plus host-less rows only for manually imported certs + // (a host-less non-manual cert is just a superseded/replaced scan result, not a real orphan) + $orphan_clause = "and (h.id is not null or c.is_manual = 1)"; if ($this->user->admin == "1") { - $certs = $this->Database->getObjectsQuery("select *,c.id as id,z.name as zone_name,z.private_zone_uid as private_zone_uid from certificates as c JOIN zones as z ON c.z_id = z.id JOIN tenants as t ON z.t_id = t.id JOIN hosts as h ON h.c_id = c.id and c.expires < ? and c.expires > ? $pz_clause order by expires asc", [$from_date, $expired_from_date]); + $certs = $this->Database->getObjectsQuery("select *,c.id as id,z.name as zone_name,z.private_zone_uid as private_zone_uid from certificates as c JOIN zones as z ON c.z_id = z.id JOIN tenants as t ON z.t_id = t.id LEFT JOIN hosts as h ON h.c_id = c.id where c.expires < ? and c.expires > ? $pz_clause $orphan_clause order by expires asc", [$from_date, $expired_from_date]); } else { - $certs = $this->Database->getObjectsQuery("select *,c.id as id,z.name as zone_name,z.private_zone_uid as private_zone_uid from certificates as c JOIN zones as z ON c.z_id = z.id JOIN tenants as t ON z.t_id = t.id JOIN hosts as h ON h.c_id = c.id and t.id = ? and c.expires < ? and c.expires > ? $pz_clause order by expires asc", [$this->user->t_id, $from_date, $expired_from_date]); + $certs = $this->Database->getObjectsQuery("select *,c.id as id,z.name as zone_name,z.private_zone_uid as private_zone_uid from certificates as c JOIN zones as z ON c.z_id = z.id JOIN tenants as t ON z.t_id = t.id LEFT JOIN hosts as h ON h.c_id = c.id where t.id = ? and c.expires < ? and c.expires > ? $pz_clause $orphan_clause order by expires asc", [$this->user->t_id, $from_date, $expired_from_date]); } } catch (Exception $e) { @@ -130,7 +133,10 @@ public function get_expired($days = 30, $expired_days = 7) $certs_new[$t->id] = $t; $certs_new[$t->id]->hosts = []; } - $certs_new[$t->id]->hosts[] = (object)['hostname' => $t->hostname, 'port' => $t->port]; + // LEFT JOIN: certs with no assigned host (e.g. manually imported) yield a null hostname + if ($t->hostname !== null) { + $certs_new[$t->id]->hosts[] = (object)['hostname' => $t->hostname, 'port' => $t->port]; + } } $certs = $certs_new; } @@ -638,7 +644,7 @@ public function get_all_ignored_issuers($t_id = NULL) // fetch from cas table — only rows with at least one notification flag set try { - if ($this->user->admin == "1") { + if (!isset($this->user) || $this->user === null || $this->user->admin == "1") { $ignored = $this->Database->getObjectsQuery( "SELECT * FROM cas WHERE ski IS NOT NULL AND (ignore_updates = 1 OR ignore_expiry = 1)" ); diff --git a/functions/classes/class.Common.php b/functions/classes/class.Common.php index 7e7c3e0..e0c35da 100644 --- a/functions/classes/class.Common.php +++ b/functions/classes/class.Common.php @@ -35,7 +35,7 @@ public function print_system_warnings(): void // Check $installed flag if (!isset($installed) || $installed !== true) { - $warnings[] = ['text' => 'Application is not marked as installed. Open config.php and set $installed = true;']; + $warnings[] = ['text' => 'Application is not marked as installed. Open config.php and set $installed = true;']; } // Check for default password (admin/admin — sha512 hash) @@ -51,8 +51,8 @@ public function print_system_warnings(): void $current = $Migration->get_current_version(); $latest = $Migration->get_latest_version(); $label = "DB schema out of date (version {$current} → {$latest}), {$count} change(s) pending -"; - $btn = "{$label} "; - $js = ""; + $btn = "{$label}"; + $js = ""; $warnings[] = ['text' => $btn . $js]; } } @@ -64,9 +64,8 @@ public function print_system_warnings(): void $icon = ""; foreach ($warnings as $warning) { - print "' modal_html += ' ' modal_html += '' - modal_html += ''; + modal_html += ''; // set default content $(index + ' .modal-content').html(modal_html); @@ -101,6 +101,11 @@ $(document).ready(function () { //post to check form $.post('/route/login/login_check.php', logindata, function (data) { $('div#loginCheck').html(data).fadeIn('fast'); + // 2FA required — reload login page; index.php will show the challenge + if ($('#2fa_required').length > 0) { + window.location.reload(); + return; + } //reload after 1 seconds if succeeded! if (data.search("alert alert-success") != -1) { var url = $('#login_redirect').length > 0 ? $('#login_redirect').text() : "/"; diff --git a/js/qrcode.min.js b/js/qrcode.min.js new file mode 100644 index 0000000..993e88f --- /dev/null +++ b/js/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); \ No newline at end of file diff --git a/route/ajax/0014_add_cas_table.sql b/route/ajax/0014_add_cas_table.sql deleted file mode 100644 index b44b0d4..0000000 --- a/route/ajax/0014_add_cas_table.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE `cas` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `t_id` int(11) NOT NULL, - `name` varchar(255) NOT NULL, - `certificate` text NOT NULL, - `pkey_id` int(11) DEFAULT NULL, - `subject` varchar(500) DEFAULT NULL, - `expires` datetime DEFAULT NULL, - `created` datetime DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - KEY `t_id` (`t_id`), - KEY `pkey_id` (`pkey_id`), - CONSTRAINT `cas_pkey_fk` FOREIGN KEY (`pkey_id`) REFERENCES `pkey` (`id`) ON DELETE SET NULL -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/route/ajax/ca/create.php b/route/ajax/ca/create.php index 675b764..2a5841a 100644 --- a/route/ajax/ca/create.php +++ b/route/ajax/ca/create.php @@ -32,7 +32,7 @@ $pathlen = isset($body['pathlen']) && $body['pathlen'] !== null ? max(0, (int)$body['pathlen']) : null; // Determine tenant -if ($user->admin === "1" && !empty($body['t_id'])) { +if ($user->admin == "1" && !empty($body['t_id'])) { $t_id = (int)$body['t_id']; if (!$Database->getObject("tenants", $t_id)) { print json_encode(['status' => 'error', 'message' => _("Invalid tenant.")]); @@ -104,7 +104,7 @@ } $parent_ca = $parent_ca[0]; // Tenant access check - if ($user->admin !== "1" && (int)$parent_ca->t_id !== $t_id) { + if ($user->admin != "1" && (int)$parent_ca->t_id !== $t_id) { print json_encode(['status' => 'error', 'message' => _("Access denied to parent CA.")]); exit; } diff --git a/route/ajax/ca/delete.php b/route/ajax/ca/delete.php index 2c0d3f6..a88e451 100644 --- a/route/ajax/ca/delete.php +++ b/route/ajax/ca/delete.php @@ -21,7 +21,7 @@ exit; } -if ($user->admin === "1") { +if ($user->admin == "1") { $ca = $Database->getObjectQuery("SELECT * FROM cas WHERE id = ?", [$ca_id]); } else { $ca = $Database->getObjectQuery("SELECT * FROM cas WHERE id = ? AND t_id = ?", [$ca_id, $user->t_id]); diff --git a/route/ajax/ca/download.php b/route/ajax/ca/download.php index f3538ec..745f271 100644 --- a/route/ajax/ca/download.php +++ b/route/ajax/ca/download.php @@ -18,7 +18,7 @@ exit; } -if ($user->admin === "1") { +if ($user->admin == "1") { $ca = $Database->getObjectQuery("SELECT * FROM cas WHERE id = ?", [$ca_id]); } else { $ca = $Database->getObjectQuery("SELECT * FROM cas WHERE id = ? AND t_id = ?", [$ca_id, $user->t_id]); diff --git a/route/ajax/ca/import.php b/route/ajax/ca/import.php index 66216c4..3b97d02 100644 --- a/route/ajax/ca/import.php +++ b/route/ajax/ca/import.php @@ -24,7 +24,7 @@ $passphrase = $body['passphrase'] ?? null; // Determine tenant -if ($user->admin === "1" && !empty($body['t_id'])) { +if ($user->admin == "1" && !empty($body['t_id'])) { $t_id = (int)$body['t_id']; if (!$Database->getObject("tenants", $t_id)) { print json_encode(['status' => 'error', 'message' => _("Invalid tenant.")]); diff --git a/route/ajax/ca/update-flags.php b/route/ajax/ca/update-flags.php index bcdd2b2..6b30acd 100644 --- a/route/ajax/ca/update-flags.php +++ b/route/ajax/ca/update-flags.php @@ -17,7 +17,7 @@ exit; } -if ((int)$user->permission < 3 && $user->admin !== "1") { +if ((int)$user->permission < 3 && $user->admin != "1") { print json_encode(['status' => 'error', 'message' => _("Permission denied.")]); exit; } @@ -32,7 +32,7 @@ } // Fetch CA with tenant scope check -if ($user->admin === "1") { +if ($user->admin == "1") { $ca = $Database->getObjectQuery("SELECT id FROM cas WHERE id = ?", [$ca_id]); } else { $ca = $Database->getObjectQuery("SELECT id FROM cas WHERE id = ? AND t_id = ?", [$ca_id, (int)$user->t_id]); diff --git a/route/ajax/cert-download.php b/route/ajax/cert-download.php index 6ef87e0..ac51ff4 100644 --- a/route/ajax/cert-download.php +++ b/route/ajax/cert-download.php @@ -17,7 +17,7 @@ exit; } -if ($user->admin === "1") { +if ($user->admin == "1") { $cert = $Database->getObjectQuery("SELECT * FROM certificates WHERE id = ?", [$cert_id]); } else { $cert = $Database->getObjectQuery("SELECT * FROM certificates WHERE id = ? AND t_id = ?", [$cert_id, $user->t_id]); diff --git a/route/ajax/certificates.php b/route/ajax/certificates.php index 4180ac9..a930032 100644 --- a/route/ajax/certificates.php +++ b/route/ajax/certificates.php @@ -151,6 +151,7 @@ elseif($status_int==1) { $textclass='red'; $danger_class = "red"; } elseif($status_int==2) { $textclass='orange'; $danger_class = "orange"; } elseif($status_int==3) { $textclass='green'; } + elseif($status_int==11) { $textclass='orange'; $danger_class = "orange"; } else { $textclass=''; } diff --git a/route/ajax/chain-download.php b/route/ajax/chain-download.php index 758e6db..c441bf6 100644 --- a/route/ajax/chain-download.php +++ b/route/ajax/chain-download.php @@ -19,7 +19,7 @@ exit('Invalid request.'); } -if ($user->admin === "1") { +if ($user->admin == "1") { $cert = $Database->getObjectQuery("SELECT * FROM certificates WHERE id = ?", [$cert_id]); } else { $cert = $Database->getObjectQuery("SELECT * FROM certificates WHERE id = ? AND t_id = ?", [$cert_id, $user->t_id]); diff --git a/route/ajax/create.php b/route/ajax/create.php index 584ed4a..d1f5e52 100644 --- a/route/ajax/create.php +++ b/route/ajax/create.php @@ -13,7 +13,7 @@ $content .= ""; // Tenant selector — admin only -if ($user->admin === "1") { +if ($user->admin == "1") { $all_tenants = $Tenants->get_all(); $content .= ""; } else { foreach ($groups as $tenant_id => $cas) { - if ($user->admin === "1") { + if ($user->admin == "1") { $tenant_name = isset($all_tenants[$tenant_id]) ? htmlspecialchars($all_tenants[$tenant_id]->name) : $tenant_id; print ""; print " "; diff --git a/route/cas/ca-certificates/ca-certificate.php b/route/cas/ca-certificates/ca-certificate.php index 68e3ea8..fa9baa8 100644 --- a/route/cas/ca-certificates/ca-certificate.php +++ b/route/cas/ca-certificates/ca-certificate.php @@ -15,7 +15,7 @@ LEFT JOIN cas pca ON ca.parent_ca_id = pca.id"; // Look up by serial first, fall back to numeric ID for legacy URLs -if ($user->admin === "1") { +if ($user->admin == "1") { $ca_rows = $Database->getObjectsQuery("$base_select WHERE ca.serial = ?", [$app]); if (empty($ca_rows) && ctype_digit($app)) { $ca_rows = $Database->getObjectsQuery("$base_select WHERE ca.id = ?", [(int)$app]); @@ -101,11 +101,12 @@ ? "
" . _("Certificate validity is more than 398 days") . "" : ""; -if ($status['code'] == 0) { $textclass = 'muted'; } -elseif ($status['code'] == 1) { $textclass = 'danger'; } -elseif ($status['code'] == 2) { $textclass = 'warning'; } -elseif ($status['code'] == 3) { $textclass = 'success'; } -else { $textclass = ''; } +if ($status['code'] == 0) { $textclass = 'muted'; } +elseif ($status['code'] == 1) { $textclass = 'danger'; } +elseif ($status['code'] == 2) { $textclass = 'warning'; } +elseif ($status['code'] == 3) { $textclass = 'success'; } +elseif ($status['code'] == 11) { $textclass = 'warning'; } +else { $textclass = ''; } $td_min_width = "160px"; diff --git a/route/cas/ca-certificates/index.php b/route/cas/ca-certificates/index.php index b8096e6..213b4b9 100644 --- a/route/cas/ca-certificates/index.php +++ b/route/cas/ca-certificates/index.php @@ -9,7 +9,7 @@ $all_tenants = $Tenants->get_all(); -$where = $user->admin !== "1" ? " WHERE ca.t_id = " . (int)$user->t_id : ""; +$where = $user->admin != "1" ? " WHERE ca.t_id = " . (int)$user->t_id : ""; $select = "SELECT ca.id, ca.t_id, ca.name, ca.subject, ca.expires, ca.created, ca.parent_ca_id, ca.ignore_updates, ca.ignore_expiry, ca.serial, pca.name AS parent_ca_name, @@ -23,7 +23,7 @@ $all_cas = $Database->getObjectsQuery($select, []); $groups = []; -if ($user->admin === "1") { +if ($user->admin == "1") { foreach ($all_tenants as $t) { $groups[$t->id] = []; } } foreach ($all_cas as $ca) { $groups[$ca->t_id][] = $ca; } diff --git a/route/cas/index.php b/route/cas/index.php index b76fea0..c8355e4 100644 --- a/route/cas/index.php +++ b/route/cas/index.php @@ -10,7 +10,7 @@ // get all tenants $all_tenants = $Tenants->get_all(); -$where = $user->admin === "1" ? " WHERE" : " WHERE ca.t_id = " . (int)$user->t_id . " AND"; +$where = $user->admin == "1" ? " WHERE" : " WHERE ca.t_id = " . (int)$user->t_id . " AND"; $select = "SELECT ca.id, ca.t_id, ca.name, ca.subject, ca.expires, ca.created, ca.parent_ca_id, ca.ignore_updates, ca.ignore_expiry, ca.serial, pca.name AS parent_ca_name, @@ -24,7 +24,7 @@ $all_cas = $Database->getObjectsQuery($select, []); $groups = []; -if ($user->admin === "1") { +if ($user->admin == "1") { foreach ($all_tenants as $t) { $groups[$t->id] = []; } } foreach ($all_cas as $ca) { $groups[$ca->t_id][] = $ca; } diff --git a/route/cas/table.php b/route/cas/table.php index a1a365b..6764874 100644 --- a/route/cas/table.php +++ b/route/cas/table.php @@ -9,17 +9,22 @@ /** * Sort a flat array of CA objects into parent-before-child DFS order. * Returns array of [$ca, $depth] pairs; siblings ordered as received (caller sorts by name). - * CAs whose parent_ca_id is not present in the set are treated as roots. + * CAs whose parent_ca_id points to a CA not present in the set are grouped under a synthetic + * "Unknown" placeholder node to make incomplete chains visible. */ function ca_tree_sort(array $cas): array { $by_id = []; $children = []; $roots = []; + $orphaned = []; foreach ($cas as $ca) { $by_id[(int)$ca->id] = $ca; } foreach ($cas as $ca) { $pid = (int)($ca->parent_ca_id ?? 0); if ($pid && isset($by_id[$pid])) { $children[$pid][] = $ca; + } elseif ($pid) { + // parent_ca_id is set but the parent CA is not known — incomplete chain + $orphaned[] = $ca; } else { $roots[] = $ca; } @@ -32,6 +37,27 @@ function ca_tree_sort(array $cas): array { } }; foreach ($roots as $root) { $visit($root, 0); } + // Orphaned intermediates: their issuer was never discovered. Show them nested under a + // synthetic "Unknown" placeholder so the incomplete chain is clearly visible. + if (!empty($orphaned)) { + $placeholder = new stdClass(); + $placeholder->id = 0; + $placeholder->name = _("Unknown"); + $placeholder->is_unknown_placeholder = true; + $placeholder->parent_ca_id = null; + $placeholder->parent_ca_name = null; + $placeholder->subject = null; + $placeholder->expires = null; + $placeholder->has_pkey = false; + $placeholder->cert_count = 0; + $placeholder->ignore_updates = 0; + $placeholder->ignore_expiry = 0; + $placeholder->serial = null; + $result[] = [$placeholder, 0]; + foreach ($orphaned as $ca) { + $visit($ca, 1); + } + } return $result; } @@ -41,7 +67,7 @@ function ca_tree_sort(array $cas): array { $ca_icon = ''; $angle_icon = ''; -$can_manage = $user->admin === "1" || (int)$user->permission >= 3; +$can_manage = $user->admin == "1" || (int)$user->permission >= 3; ?>
@@ -74,7 +100,7 @@ class="table table-hover align-top table-md" print "
"; } else { foreach ($groups as $tenant_id => $cas) { - if ($user->admin === "1") { + if ($user->admin == "1") { $tenant_name = isset($all_tenants[$tenant_id]) ? htmlspecialchars($all_tenants[$tenant_id]->name) : $tenant_id; print ""; print " "; @@ -87,6 +113,14 @@ class="table table-hover align-top table-md" } foreach (ca_tree_sort($cas) as [$ca, $depth]) { + // Synthetic placeholder for CAs with an unknown issuer + if (!empty($ca->is_unknown_placeholder)) { + $unknown_icon = ''; + print ""; + print " "; + print ""; + continue; + } $ca_id = (int)$ca->id; $name_esc = htmlspecialchars($ca->name, ENT_QUOTES); diff --git a/route/certificates/cas.php b/route/certificates/cas.php index 36bd606..d09443b 100644 --- a/route/certificates/cas.php +++ b/route/certificates/cas.php @@ -12,12 +12,12 @@ FROM cas ca LEFT JOIN pkey pk ON ca.pkey_id = pk.id LEFT JOIN cas pca ON ca.parent_ca_id = pca.id" - . ($user->admin !== "1" ? " WHERE ca.t_id = " . (int)$user->t_id : "") + . ($user->admin != "1" ? " WHERE ca.t_id = " . (int)$user->t_id : "") . " ORDER BY ca.name ASC"; $all_cas = $Database->getObjectsQuery($select, []); $groups = []; -if ($user->admin === "1") { +if ($user->admin == "1") { foreach ($all_tenants as $t) { $groups[$t->id] = []; } } foreach ($all_cas as $ca) { $groups[$ca->t_id][] = $ca; } diff --git a/route/certificates/certificate.php b/route/certificates/certificate.php index f778589..2627fc9 100644 --- a/route/certificates/certificate.php +++ b/route/certificates/certificate.php @@ -71,6 +71,7 @@ elseif($status['code']==1) { $textclass='danger'; } elseif($status['code']==2) { $textclass='warning'; } elseif($status['code']==3) { $textclass='success'; } + elseif($status['code']==11) { $textclass='warning'; } else { $textclass=''; } // no altnames diff --git a/route/common/checks.php b/route/common/checks.php index 3c38ded..bb246e8 100644 --- a/route/common/checks.php +++ b/route/common/checks.php @@ -1,56 +1,60 @@ ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/src/NetDNS2/Resolver.php', 'url' => 'https://github.com/mikepultz/netdns2'], - 'PHPMailer' => ['path' => __DIR__ . '/../../functions/assets/PHPMailer/src/PHPMailer.php','url' => 'https://github.com/PHPMailer/PHPMailer'], - 'testssl.sh' => ['path' => __DIR__ . '/../../functions/testSSL/testssl.sh', 'url' => 'https://github.com/testssl/testssl.sh'], +// PHP version check — shown to all logged-in users +if (PHP_MAJOR_VERSION < 8) { + print ""; +} + +// Submodule checks — try to load each PHP module and verify its class is defined +$php_submodules = [ + 'Net_DNS2' => [ + 'file' => __DIR__ . '/../../functions/assets/Net_DNS2/src/NetDNS2/Client.php', + 'class' => 'NetDNS2\\Client', + 'url' => 'https://github.com/mikepultz/netdns2', + ], + 'PHPMailer' => [ + 'file' => __DIR__ . '/../../functions/assets/PHPMailer/src/PHPMailer.php', + 'class' => 'PHPMailer\\PHPMailer\\PHPMailer', + 'url' => 'https://github.com/PHPMailer/PHPMailer', + ], + 'GoogleAuthenticator' => [ + 'file' => __DIR__ . '/../../functions/assets/GoogleAuthenticator/GoogleAuthenticator.php', + 'class' => 'PHPGangsta_GoogleAuthenticator', + 'url' => 'https://github.com/PHPGangsta/GoogleAuthenticator', + ], ]; $missing_submodules = []; -foreach ($submodules as $name => $info) { - if (!file_exists($info['path'])) { +foreach ($php_submodules as $name => $info) { + if (class_exists($info['class'])) { + continue; // already loaded elsewhere in this request + } + if (!is_readable($info['file'])) { + $missing_submodules[$name] = $info['url']; + continue; + } + require_once $info['file']; + if (!class_exists($info['class'])) { $missing_submodules[$name] = $info['url']; } } +// testssl.sh is a shell script — verify it exists and is executable +if (!is_executable(__DIR__ . '/../../functions/testSSL/testssl.sh')) { + $missing_submodules['testssl.sh'] = 'https://github.com/testssl/testssl.sh'; +} if (!empty($missing_submodules)) { print ""; + print " git submodule update --init --recursive"; } -if ($user->admin === "1") { - $migration_dir = __DIR__ . '/../../db/migrations/'; - $fs_migrations = []; - if (is_dir($migration_dir)) { - foreach (glob($migration_dir . '*.sql') as $f) { - $fs_migrations[] = basename($f); - } - sort($fs_migrations); - } - if (!empty($fs_migrations)) { - try { - $applied = $Database->getObjectsQuery("SELECT filename FROM migrations ORDER BY filename ASC", []); - $applied_names = array_map(fn($r) => $r->filename, $applied ?: []); - $pending = array_values(array_diff($fs_migrations, $applied_names)); - } catch (Exception $e) { - $pending = []; - } - if (!empty($pending)) { - $count = count($pending); - $list = implode(', ', $pending); - print ""; - } - } -} +$Common->print_system_warnings(); \ No newline at end of file diff --git a/route/common/header.php b/route/common/header.php index 4a082b3..6703dde 100644 --- a/route/common/header.php +++ b/route/common/header.php @@ -46,7 +46,7 @@ - + @@ -99,7 +99,7 @@ - + diff --git a/route/common/left-menu.php b/route/common/left-menu.php index 8bc7495..8692d65 100644 --- a/route/common/left-menu.php +++ b/route/common/left-menu.php @@ -90,7 +90,7 @@ $expanded = "false"; $show = ""; foreach ($items['submenu'] as $link=>$sm) { - if($_params['app'] == $link) { + if(($_params['app'] ?? null) == $link) { $expanded = "true"; $show = "show"; break; @@ -111,7 +111,7 @@ print '"; } else { foreach ($groups as $tenant_id => $csrs) { - if ($user->admin === "1") { + if ($user->admin == "1") { $tenant_name = isset($all_tenants[$tenant_id]) ? htmlspecialchars($all_tenants[$tenant_id]->name) : $tenant_id; print ""; print " "; @@ -178,7 +178,7 @@ class="table table-hover align-top table-md" . " data-bs-toggle='modal' data-bs-target='#modal2'>" . "{$renew_icon} " . _("Renew") . ""; if (!empty($c->has_pkey)) { - if ($user->admin === "1" || (int)$user->permission >= 3) { + if ($user->admin == "1" || (int)$user->permission >= 3) { $actions .= "{$dl_icon} .key"; } else { $actions .= "{$dl_icon} .key"; diff --git a/route/csrs/csr-generate.php b/route/csrs/csr-generate.php index af75c9e..db932fc 100644 --- a/route/csrs/csr-generate.php +++ b/route/csrs/csr-generate.php @@ -129,7 +129,7 @@ // Store private key — encrypted if configured, otherwise return to client once global $private_key_encryption_key; -if ($user->admin === "1" && !empty($body['t_id'])) { +if ($user->admin == "1" && !empty($body['t_id'])) { $t_id = (int)$body['t_id']; if (!$Database->getObject("tenants", $t_id)) { print json_encode(['status' => 'error', 'message' => _("Invalid tenant.")]); @@ -181,7 +181,7 @@ // Mark source CSR as renewed if ($source_csr_id > 0) { - if ($user->admin === "1") { + if ($user->admin == "1") { $src = $Database->getObjectQuery("SELECT id FROM csrs WHERE id = ?", [$source_csr_id]); } else { $src = $Database->getObjectQuery("SELECT id FROM csrs WHERE id = ? AND t_id = ?", [$source_csr_id, $t_id]); diff --git a/route/csrs/csr-import.php b/route/csrs/csr-import.php index 5c26a03..9a34eb3 100644 --- a/route/csrs/csr-import.php +++ b/route/csrs/csr-import.php @@ -112,7 +112,7 @@ exit; } - if ($user->admin === "1") { + if ($user->admin == "1") { $zone = $Database->getObjectQuery("SELECT * FROM zones WHERE id = ?", [$zone_id]); } else { $zone = $Database->getObjectQuery("SELECT * FROM zones WHERE id = ? AND t_id = ?", [$zone_id, (int)$user->t_id]); @@ -200,7 +200,7 @@ } elseif ($pkey_res !== false) { // No cert provided but key available — scan certificates for a key match // Admins may have saved the CSR under their own t_id while the cert lives in another tenant - if ($user->admin === "1") { + if ($user->admin == "1") { $tenant_certs = $Database->getObjectsQuery( "SELECT id, certificate, t_id FROM certificates WHERE certificate != '' AND certificate IS NOT NULL", [] diff --git a/route/csrs/templates/index.php b/route/csrs/templates/index.php index 5eaabec..6b7b17a 100644 --- a/route/csrs/templates/index.php +++ b/route/csrs/templates/index.php @@ -1,14 +1,14 @@ validate_session(); -$can_manage = $user->admin === "1" || (int)$user->permission >= 3; +$can_manage = $user->admin == "1" || (int)$user->permission >= 3; -$all_tpls = $Database->getObjectsQuery("SELECT * FROM csr_templates" . ($user->admin !== "1" ? " WHERE t_id = " . (int)$user->t_id : "") . " ORDER BY name ASC", []); +$all_tpls = $Database->getObjectsQuery("SELECT * FROM csr_templates" . ($user->admin != "1" ? " WHERE t_id = " . (int)$user->t_id : "") . " ORDER BY name ASC", []); $all_tenants = $Tenants->get_all(); // Group by tenant $groups = []; -if ($user->admin === "1") { +if ($user->admin == "1") { foreach ($all_tenants as $t) { $groups[$t->id] = []; } } foreach ($all_tpls as $tpl) { $groups[$tpl->t_id][] = $tpl; } @@ -68,7 +68,7 @@ class="table table-hover align-top table-md" print ""; } else { foreach ($groups as $tenant_id => $tpls) { - if ($user->admin === "1") { + if ($user->admin == "1") { $tenant_name = isset($all_tenants[$tenant_id]) ? htmlspecialchars($all_tenants[$tenant_id]->name) : $tenant_id; print ""; print " "; diff --git a/route/dashboard/card-certificates-expire.php b/route/dashboard/card-certificates-expire.php index 385e0ca..29d2e71 100644 --- a/route/dashboard/card-certificates-expire.php +++ b/route/dashboard/card-certificates-expire.php @@ -97,6 +97,8 @@ print " "; print ""; @@ -105,6 +107,10 @@ print " "; print ""; } diff --git a/route/dashboard/card-latest-certificates.php b/route/dashboard/card-latest-certificates.php index c9e50e8..0908cd8 100644 --- a/route/dashboard/card-latest-certificates.php +++ b/route/dashboard/card-latest-certificates.php @@ -1,6 +1,6 @@ admin === "1") { +if ($user->admin == "1") { $latest_certs = $Database->getObjectsQuery( "SELECT c.id, c.serial, c.certificate, c.expires, c.created, c.t_id, z.name AS zone_name, z.id AS z_id, @@ -45,7 +45,7 @@ - admin === "1"): ?> + admin == "1"): ?> @@ -81,7 +81,7 @@ - admin === "1"): ?> + admin == "1"): ?> diff --git a/route/dashboard/card-latest-hosts.php b/route/dashboard/card-latest-hosts.php index 714685d..eda9f2f 100644 --- a/route/dashboard/card-latest-hosts.php +++ b/route/dashboard/card-latest-hosts.php @@ -1,6 +1,6 @@ admin === "1") { +if ($user->admin == "1") { $latest_hosts = $Database->getObjectsQuery( "SELECT h.id, h.hostname, h.port, h.ip, h.last_check, h.last_change, h.ignore, z.name AS zone_name, z.id AS z_id, @@ -45,7 +45,7 @@ - admin === "1"): ?> + admin == "1"): ?> @@ -73,7 +73,7 @@ - admin === "1"): ?> + admin == "1"): ?> diff --git a/route/dashboard/card-top-cas.php b/route/dashboard/card-top-cas.php index de06013..adfaa50 100644 --- a/route/dashboard/card-top-cas.php +++ b/route/dashboard/card-top-cas.php @@ -1,6 +1,6 @@ admin === "1") { +if ($user->admin == "1") { $top_cas = $Database->getObjectsQuery( "SELECT ca.id, ca.name, ca.ski, ca.t_id, t.name AS tenant_name, t.href AS tenant_href, COUNT(c.id) AS cert_count @@ -47,7 +47,7 @@ - admin === "1"): ?> + admin == "1"): ?> @@ -57,7 +57,7 @@ $ca): $cert_count = (int)$ca->cert_count; $pct = $max_count > 0 ? round($cert_count / $max_count * 100) : 0; - $ca_href = $user->admin === "1" ? ($ca->tenant_href ?? $user->href) : $user->href; + $ca_href = $user->admin == "1" ? ($ca->tenant_href ?? $user->href) : $user->href; ?> @@ -73,7 +73,7 @@ - admin === "1"): ?> + admin == "1"): ?> diff --git a/route/fetch/index.php b/route/fetch/index.php index 33cd13b..f633acf 100644 --- a/route/fetch/index.php +++ b/route/fetch/index.php @@ -59,7 +59,7 @@ // select $selected = @$_POST['agent_id']==$agent->id ? "selected" : ""; // print - print ""; + print ""; } } ?> diff --git a/route/login/2fa_challenge.php b/route/login/2fa_challenge.php new file mode 100644 index 0000000..3a4fe8a --- /dev/null +++ b/route/login/2fa_challenge.php @@ -0,0 +1,141 @@ + + +
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + php-ssl-scan :: +

+
+ +
+
+
+

+ +

+ +
+ + +
+ + + + + +
+ + + +
+
+ +
info
+
+ +
+
+
+ + diff --git a/route/modals/agents/edit-submit.php b/route/modals/agents/edit-submit.php index 8f02866..e730036 100644 --- a/route/modals/agents/edit-submit.php +++ b/route/modals/agents/edit-submit.php @@ -23,7 +23,7 @@ if($tenant===null) $Result->show("danger", _("Invalid tenant").".", true, false, false, false); // not allowed - if($user->admin !== "1" && $user->t_id!=$_POST['t_id']) + if($user->admin != "1" && $user->t_id!=$_POST['t_id']) $Result->show("danger", _("Admin privileges required").".", true, false, false, false); } else { @@ -35,7 +35,7 @@ // get tenant $tenant = $Tenants->get_tenant_by_href ($agent->t_id); // not allowed - if($user->admin !== "1" && $user->t_id!=$agent->t_id) + if($user->admin != "1" && $user->t_id!=$agent->t_id) $Result->show("danger", _("Admin privileges required").".", true, false, false, false); } diff --git a/route/modals/agents/edit.php b/route/modals/agents/edit.php index 43e73cc..39d2e9a 100644 --- a/route/modals/agents/edit.php +++ b/route/modals/agents/edit.php @@ -36,7 +36,7 @@ $btn_text = ""; } # rtenant validation -elseif($user->admin !== "1" && $user->t_id!=$_GET['tenant']) { +elseif($user->admin != "1" && $user->t_id!=$_GET['tenant']) { # content $content = []; $content[] = $Result->show("danger", _("Admin user required"), false, false, true); @@ -84,7 +84,7 @@ $content[] = " "; $content[] = " "; $content[] = " "; - if($user->admin !== "1" || $_GET['action']!=="add") + if($user->admin != "1" || $_GET['action']!=="add") $content[] = " "; $content[] = " "; $content[] = "
" . _("Tenant") . " *"; $content .= "
" . _("No certificate authorities found.") . "
" . $url_items['tenants']['icon'] . " " . _("Tenant") . " " . $tenant_name . "
" . _("No certificate authorities found.") . "
" . $url_items['tenants']['icon'] . " " . _("Tenant") . " " . $tenant_name . "
{$unknown_icon} " . _("Unknown — issuer not discovered (incomplete chain)") . "
" . _("No CSRs found.") . "
" . $url_items['tenants']['icon'] . " " . _("Tenant") . " " . $tenant_name . "
" . _("No templates found.") . "
" . $url_items['tenants']['icon'] . " " . _("Tenant") . " " . $tenant_name . "".$cert_parsed['custom_validDays']."
".date("d. M H:i", strtotime($cert_parsed['custom_validTo']))."
"; print " ".$url_items["certificates"]['icon']." ".$cert_parsed['serialNumberHex'].""; + if(isset($t->is_manual) && $t->is_manual == "1") + print " "._("Manual").""; if($user->admin=="1") print "
".$tenants[$t->t_id]->name.""; print "
"; $hosts = $t->hosts; $host_count = count($hosts); + if ($host_count == 0) { + print ""._("Not assigned to a host").""; + } + else { $first = $hosts[0]; print "".$first->hostname.""; if ($host_count > 1) { @@ -118,6 +124,7 @@ } print ""; } + } print "
tenant_name ?? ''); ?>
tenant_name ?? ''); ?>
#
tenant_name ?? ''); ?> "; diff --git a/route/modals/agents/refresh.php b/route/modals/agents/refresh.php index d8f0ffb..150b866 100644 --- a/route/modals/agents/refresh.php +++ b/route/modals/agents/refresh.php @@ -26,7 +26,7 @@ $title = _("Refresh")." "._("agent"); # tenant validation -if($user->admin !== "1" && $user->t_id!=$_GET['tenant']) { +if($user->admin != "1" && $user->t_id!=$_GET['tenant']) { # content $content = []; $content[] = $Result->show("danger", _("Admin user required"), false, false, true); diff --git a/route/modals/cas/ca-create.php b/route/modals/cas/ca-create.php index b65a0cb..6e13d9b 100644 --- a/route/modals/cas/ca-create.php +++ b/route/modals/cas/ca-create.php @@ -29,7 +29,7 @@ $pathlen = isset($body['pathlen']) && $body['pathlen'] !== null ? max(0, (int)$body['pathlen']) : null; // Determine tenant -if ($user->admin === "1" && !empty($body['t_id'])) { +if ($user->admin == "1" && !empty($body['t_id'])) { $t_id = (int)$body['t_id']; if (!$Database->getObject("tenants", $t_id)) { print json_encode(['status' => 'error', 'message' => _("Invalid tenant.")]); @@ -101,7 +101,7 @@ } $parent_ca = $parent_ca[0]; // Tenant access check - if ($user->admin !== "1" && (int)$parent_ca->t_id !== $t_id) { + if ($user->admin != "1" && (int)$parent_ca->t_id !== $t_id) { print json_encode(['status' => 'error', 'message' => _("Access denied to parent CA.")]); exit; } diff --git a/route/modals/cas/create.php b/route/modals/cas/create.php index 11e5d84..f95085c 100644 --- a/route/modals/cas/create.php +++ b/route/modals/cas/create.php @@ -12,7 +12,7 @@ global $private_key_encryption_key; // Load CAs with private keys for parent selector (scoped to user's tenant; admin sees all) -if ($user->admin === "1") { +if ($user->admin == "1") { $parent_cas = $Database->getObjectsQuery( "SELECT ca.id, ca.name, ca.subject, ca.t_id FROM cas ca INNER JOIN pkey pk ON ca.pkey_id = pk.id @@ -27,13 +27,13 @@ ORDER BY ca.name ASC", [$user->t_id] ); } -$all_tenants_map = $user->admin === "1" ? $Tenants->get_all() : []; +$all_tenants_map = $user->admin == "1" ? $Tenants->get_all() : []; $content = "