From acd5022fa9aa70b0f8a740d8b353cf22bf0dd5a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 08:05:19 +0000 Subject: [PATCH 01/34] Add missing log show modal (fixes 404 on Show button in Logs) The Show button in Administration > Logs linked to route/modals/logs/show.php which did not exist, producing a 404. Creates the file: displays log metadata (user, tenant, object, action, content, date), a field-level diff for edit actions, and old/new JSON objects for users with admin permission. Fixes #5 https://claude.ai/code/session_01FE2bYP3eVFS7jxmRdGFnEw --- route/modals/logs/show.php | 140 +++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 route/modals/logs/show.php diff --git a/route/modals/logs/show.php b/route/modals/logs/show.php new file mode 100644 index 0000000..cdefc73 --- /dev/null +++ b/route/modals/logs/show.php @@ -0,0 +1,140 @@ +validate_session (); + +# strip tags +$_GET = $User->strip_input_tags ($_GET); + +# validate id +if(!$Common->validate_int($_GET['id'])) { + $content = []; + $content[] = $Result->show("danger", _("Invalid log ID"), false, false, true); + $header_class = "danger"; + $Modal->modal_print (_("Log details"), implode("\n", $content), "", "", false, $header_class); + die(); +} + +# fetch log +$log = $Log->get_log_by_id ((int)$_GET['id'], $user); + +if($log === null || $log === false) { + $content = []; + $content[] = $Result->show("danger", _("Log entry not found"), false, false, true); + $header_class = "danger"; + $Modal->modal_print (_("Log details"), implode("\n", $content), "", "", false, $header_class); + die(); +} + +# fetch users +$users = $User->get_all (); + +# decode JSON objects +$logdata_old = json_decode($log->json_object_old, true); +$logdata_new = json_decode($log->json_object_new, true); +if($logdata_old === null) { $logdata_old = []; } +if($logdata_new === null) { $logdata_new = []; } + +# build content +$content = []; + +$content[] = ""; + +# user +$u_name = isset($users[$log->object_u_id]) ? htmlspecialchars($users[$log->object_u_id]->name, ENT_QUOTES, 'UTF-8') : _("System"); +$content[] = ""; + +# tenant (admin only) +if($user->admin === "1") { + $tenant = $Database->getObject("tenants", $log->object_t_id); + $t_name = $tenant ? htmlspecialchars($tenant->name, ENT_QUOTES, 'UTF-8') : "-"; + $content[] = ""; +} + +# object +$content[] = ""; + +# action +$log_nice = clone $log; +$log_nice = $Log->format_log_entry($log_nice, $user); +$content[] = ""; + +# content +$content[] = ""; + +# date +$content[] = ""; + +$content[] = "
"._("User")."".$u_name."
"._("Tenant")."".$t_name."
"._("Object")."".htmlspecialchars(ucwords($log->object), ENT_QUOTES, 'UTF-8')."
"._("Action")."".$log_nice->action."
"._("Content")."".htmlspecialchars($log->text, ENT_QUOTES, 'UTF-8')."
"._("Date")."".$log_nice->date."
"; + +# show old/new objects if user has permission and objects exist +if($User->get_user_permissions(3) && (strlen($log->json_object_old) > 0 || strlen($log->json_object_new) > 0) && $log->action !== "notification") { + + # diff for edit actions + if(strlen($log->json_object_old) > 0 && strlen($log->json_object_new) > 0 && $log->action !== "add" && $log->action !== "delete" && $log->action !== "refresh") { + $d1 = $logdata_old; + $d2 = $logdata_new; + $diff = _log_modal_diff($d2, $d1); + if(!empty($diff)) { + $content[] = "
"; + $content[] = ""._("Changed fields").""; + $content[] = "
".$Log->pretty_json(json_encode($diff))."
"; + } + } + + # old object + if(strlen($log->json_object_old) > 0) { + $content[] = "
"; + $content[] = ""._("Old object").""; + $content[] = "
".$Log->pretty_json(json_encode($logdata_old))."
"; + } + + # new object + if(strlen($log->json_object_new) > 0) { + $content[] = "
"; + $content[] = ""._("New object").""; + $content[] = "
".$Log->pretty_json(json_encode($logdata_new))."
"; + } +} + +# email notification +if($log->action === "notification") { + $maildata = json_decode($log->json_object_new); + if($maildata) { + $content[] = "
"; + $content[] = ""; + $content[] = ""; + $content[] = ""; + $content[] = "
"._("Title")."".htmlspecialchars($maildata->title, ENT_QUOTES, 'UTF-8')."
"._("Sent to")."".htmlspecialchars(implode(", ", json_decode($log->json_object_old)), ENT_QUOTES, 'UTF-8')."
"; + } +} + +# print modal +$Modal->modal_print (_("Log details")." [".(int)$log->id."]", implode("\n", $content), "", "", false, "info"); + + +function _log_modal_diff($a1, $a2) { + $r = []; + foreach ($a1 as $k => $v) { + if(array_key_exists($k, $a2)) { + if(is_array($v)) { + $sub = _log_modal_diff($v, $a2[$k]); + if(!empty($sub)) { $r[$k] = $sub; } + } else { + if($v != $a2[$k]) { + $old = is_null($a2[$k]) ? 'null' : $a2[$k]; + $r[$k] = $old." => ".$v; + } + } + } else { + $r[$k] = $v; + } + } + return $r; +} From c147a8dc2541e1fe56b58ced1937ab29baf75772 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 08:11:26 +0000 Subject: [PATCH 02/34] Fix cron force flag not clearing due to DB connection lost after forking update_certificates.php forks child processes via pcntl_fork(). Each child inherits the Cron class's $this->Database PDO connection. When a child exits, PHP's destructor sends COM_QUIT over the shared MySQL socket, causing MySQL to close the connection server-side. Subsequent calls to clear_force() and update_last_executed() on $this->Database then fail silently with "server has gone away", leaving force flags set and last-executed timestamps unupdated for all jobs after update_certificates in the loop. Fix: reconnect $this->Database immediately after each cron script include, before calling clear_force(). This gives subsequent DB operations a fresh, live connection regardless of what the included script did to the inherited one. Fixes #9 https://claude.ai/code/session_01FE2bYP3eVFS7jxmRdGFnEw --- functions/classes/class.Cron.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/functions/classes/class.Cron.php b/functions/classes/class.Cron.php index 78312c9..6642cb6 100644 --- a/functions/classes/class.Cron.php +++ b/functions/classes/class.Cron.php @@ -216,6 +216,12 @@ public function execute_cronjobs($execution_time, $cli_arguments = []) $this->update_last_executed($j->id); // execute script include(dirname(__FILE__) . "/../cron/{$j->script}.php"); + // Forked scripts (update_certificates) use pcntl_fork(). When child processes + // exit, their PHP destructors send COM_QUIT over the inherited MySQL socket, + // causing the parent's connection to be closed server-side ("server has gone + // away"). Reconnect before any further DB operations so clear_force() and the + // next iteration's update_last_executed() always have a live connection. + $this->Database = new Database_PDO(); // clear force only after the script has finished — if it crashes, force stays set if (!empty($j->force)) { $this->clear_force($j->id); From 54382d6a2a9c24906ba9fade1c713dde6a93d9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Thu, 21 May 2026 10:50:33 +0200 Subject: [PATCH 03/34] Bugfixes --- route/common/header.php | 2 +- route/fetch/index.php | 2 +- route/modals/csr-templates/edit-submit.php | 1 + route/search/index.php | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/route/common/header.php b/route/common/header.php index 4a082b3..88c5c9e 100644 --- a/route/common/header.php +++ b/route/common/header.php @@ -46,7 +46,7 @@ - + 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/modals/csr-templates/edit-submit.php b/route/modals/csr-templates/edit-submit.php index dc8e3b4..4db4bd8 100644 --- a/route/modals/csr-templates/edit-submit.php +++ b/route/modals/csr-templates/edit-submit.php @@ -6,6 +6,7 @@ require('../../../functions/autoload.php'); $User->validate_session(false, true, true); +$User->validate_csrf_token(); $_POST_safe = $User->strip_input_tags($_POST); $tpl_id = (int)($_POST_safe['id'] ?? 0); diff --git a/route/search/index.php b/route/search/index.php index 64c1485..85cf8ba 100644 --- a/route/search/index.php +++ b/route/search/index.php @@ -39,7 +39,7 @@
- +
From 2fd09e8feba154be4d64a30729afd057261e4724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Thu, 21 May 2026 10:58:49 +0200 Subject: [PATCH 04/34] Pin Net_DNS2 submodule to v1.5.5 v2.0 renamed classes from Net_DNS2_* to NetDNS2\* (PSR-4) and removed Net/DNS2.php. Code in class.AXFR.php uses the v1.x API, so pin to the last compatible release. --- functions/assets/Net_DNS2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/assets/Net_DNS2 b/functions/assets/Net_DNS2 index 37dcffa..ea39ef5 160000 --- a/functions/assets/Net_DNS2 +++ b/functions/assets/Net_DNS2 @@ -1 +1 @@ -Subproject commit 37dcffabf099a33871a9870834a6976f92d4b2ec +Subproject commit ea39ef5a97d5c2b9893a8c35af7b5fd5b0e40bc9 From ded894253145b80b62046110449753e03a2c1683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Thu, 21 May 2026 11:38:28 +0200 Subject: [PATCH 05/34] Pin Net_DNS2 submodule back to v2.0.8 --- functions/assets/Net_DNS2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/assets/Net_DNS2 b/functions/assets/Net_DNS2 index ea39ef5..37dcffa 160000 --- a/functions/assets/Net_DNS2 +++ b/functions/assets/Net_DNS2 @@ -1 +1 @@ -Subproject commit ea39ef5a97d5c2b9893a8c35af7b5fd5b0e40bc9 +Subproject commit 37dcffabf099a33871a9870834a6976f92d4b2ec From 68617309308a0866cce0d2620c8a20cdadfeccf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Thu, 21 May 2026 11:46:04 +0200 Subject: [PATCH 06/34] Pin Net_DNS2 to v1.5.5, track version-1.5.x branch --- .gitmodules | 1 + functions/assets/Net_DNS2 | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index f0c4256..241ce8d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,6 +3,7 @@ [submodule "functions/assets/Net_DNS2"] path = functions/assets/Net_DNS2 url = https://github.com/mikepultz/netdns2.git + branch = version-1.5.x [submodule "functions/assets/PHPMailer"] path = functions/assets/PHPMailer diff --git a/functions/assets/Net_DNS2 b/functions/assets/Net_DNS2 index 37dcffa..ea39ef5 160000 --- a/functions/assets/Net_DNS2 +++ b/functions/assets/Net_DNS2 @@ -1 +1 @@ -Subproject commit 37dcffabf099a33871a9870834a6976f92d4b2ec +Subproject commit ea39ef5a97d5c2b9893a8c35af7b5fd5b0e40bc9 From fd4c482b4bb7eb9681862fc5bf4b4fdc37ab17b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Thu, 21 May 2026 11:47:01 +0200 Subject: [PATCH 07/34] Fix AXFR Net_DNS2 v2.x incompatibility: guard require, graceful error in modals and cron - class.AXFR.php: check for Net/DNS2.php before require; throw Exception if missing (v2.x not compatible with PHP 7.4) - axfr_transfer.php: remove redundant top-level require; collapse Net_DNS2_Exception catch into Exception - axfr-sync.php, axfr-test.php: wrap AXFR instantiation in try/catch; show error in modal instead of fatal - checks.php: update Net_DNS2 check path to src/NetDNS2/Resolver.php (v2.x structure) - class.Cron.php: reconnect DB after forked cron scripts to avoid MySQL gone-away --- functions/classes/class.AXFR.php | 8 +++- functions/classes/class.Cron.php | 8 +++- functions/cron/axfr_transfer.php | 9 ---- route/common/checks.php | 2 +- route/modals/zones/axfr-sync.php | 81 +++++++++++++++++--------------- route/modals/zones/axfr-test.php | 35 ++++++++------ 6 files changed, 77 insertions(+), 66 deletions(-) diff --git a/functions/classes/class.AXFR.php b/functions/classes/class.AXFR.php index e51b038..e06a82d 100644 --- a/functions/classes/class.AXFR.php +++ b/functions/classes/class.AXFR.php @@ -118,9 +118,13 @@ public function __construct(Database_PDO $Database) $this->Database = $Database; // Results $this->Result = new Result(); - // include Net_DNS2 + // include Net_DNS2 v1.x — v2.x is not compatible with PHP 7.4 + $net_dns2 = dirname(__FILE__) . "/../assets/Net_DNS2/Net/DNS2.php"; + if (!file_exists($net_dns2)) { + throw new Exception(_("Net_DNS2 v1.x is required for AXFR (v2.x is not compatible with PHP 7.4). Run: git -C functions/assets/Net_DNS2 checkout v1.5.5")); + } ini_set("include_path", dirname(__FILE__) . "/../assets/Net_DNS2"); - require_once(dirname(__FILE__) . "/../assets/Net_DNS2/Net/DNS2.php"); + require_once($net_dns2); } /** diff --git a/functions/classes/class.Cron.php b/functions/classes/class.Cron.php index 78312c9..68a0c91 100644 --- a/functions/classes/class.Cron.php +++ b/functions/classes/class.Cron.php @@ -216,6 +216,12 @@ public function execute_cronjobs($execution_time, $cli_arguments = []) $this->update_last_executed($j->id); // execute script include(dirname(__FILE__) . "/../cron/{$j->script}.php"); + // Forked scripts (update_certificates) use pcntl_fork(). When child processes + // exit, their PHP destructors send COM_QUIT over the inherited MySQL socket, + // causing the parent's connection to be closed server-side ("server has gone + // away"). Reconnect before any further DB operations so clear_force() and the + // next iteration's update_last_executed() always have a live connection. + $this->Database = new Database_PDO(); // clear force only after the script has finished — if it crashes, force stays set if (!empty($j->force)) { $this->clear_force($j->id); @@ -353,4 +359,4 @@ public function rand($min = 0, $max = 60, $step = 5) // return return $randomNumber; } -} \ No newline at end of file +} diff --git a/functions/cron/axfr_transfer.php b/functions/cron/axfr_transfer.php index e30f362..e687181 100644 --- a/functions/cron/axfr_transfer.php +++ b/functions/cron/axfr_transfer.php @@ -18,10 +18,6 @@ $Database = new Database_PDO (); $SSL = new SSL ($Database); -# include Net_DNS2 -ini_set("include_path", dirname(__FILE__)."/../assets/Net_DNS2"); -require_once(dirname(__FILE__)."/../assets/Net_DNS2/Net/DNS2.php"); - # script can only be run from cli if(php_sapi_name()!="cli") { $Common->errors[] = "This script can only be run from cli!"; @@ -130,12 +126,7 @@ } } -} catch (Net_DNS2_Exception $e) { - // print error - $Common->errors[] = $e->getMessage(); - $Common->result_die (); } catch (Exception $e) { - // print error $Common->errors[] = $e->getMessage(); $Common->result_die (); } diff --git a/route/common/checks.php b/route/common/checks.php index 3c38ded..5172c96 100644 --- a/route/common/checks.php +++ b/route/common/checks.php @@ -2,7 +2,7 @@ // Submodule presence checks — shown to all logged-in users $submodules = [ - 'Net_DNS2' => ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/src/NetDNS2/Resolver.php', 'url' => 'https://github.com/mikepultz/netdns2'], + 'Net_DNS2' => ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/Net/DNS2.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'], ]; diff --git a/route/modals/zones/axfr-sync.php b/route/modals/zones/axfr-sync.php index e67818b..4414bdc 100644 --- a/route/modals/zones/axfr-sync.php +++ b/route/modals/zones/axfr-sync.php @@ -14,39 +14,44 @@ # fetch zone details $zone = $Zones->get_zone ($_params['tenant'], $_GET['zone_name']); -// init class -$AXFR = new AXFR ($Database); -// set dns, tcp and tsig parameters -$AXFR->set_nameservers (explode(",",$zone->dns)); // set anmeservers to query -$AXFR->set_tsig ($zone->tsig_name, $zone->tsig); // set tsig parameters -$AXFR->set_zone_name ($zone->aname); // set zone name to query -$AXFR->set_valid_types (explode(",", $zone->record_types)); // set valid dns record types -$AXFR->set_regexes ($zone->regex_include, $zone->regex_exclude); // set regexes - -// execute -$AXFR->execute(); - -// get result -$results = $AXFR->get_records (); - -// error ? -if($results['success']==false) { - $content[] = "
".$results['error']."
"; -} -else { - // calculate differences [create, remove, new etc] - $AXFR->calculate_diffs ($zone->id, $zone->check_ip); - - // add records - $AXFR->create_new_records (); - - // remove records not in DNS AXFR - if ($zone->delete_records=="1") { - $AXFR->delete_records (); +try { + // init class + $AXFR = new AXFR ($Database); + // set dns, tcp and tsig parameters + $AXFR->set_nameservers (explode(",",$zone->dns)); + $AXFR->set_tsig ($zone->tsig_name, $zone->tsig); + $AXFR->set_zone_name ($zone->aname); + $AXFR->set_valid_types (explode(",", $zone->record_types)); + $AXFR->set_regexes ($zone->regex_include, $zone->regex_exclude); + + // execute + $AXFR->execute(); + + // get result + $results = $AXFR->get_records (); + + // error ? + if($results['success']==false) { + $content[] = "
".$results['error']."
"; } else { - $AXFR->records['removed_records'] = []; + // calculate differences [create, remove, new etc] + $AXFR->calculate_diffs ($zone->id, $zone->check_ip); + + // add records + $AXFR->create_new_records (); + + // remove records not in DNS AXFR + if ($zone->delete_records=="1") { + $AXFR->delete_records (); + } + else { + $AXFR->records['removed_records'] = []; + } } +} catch (Exception $e) { + $content[] = "
".$e->getMessage()."
"; + $AXFR = null; } # title @@ -54,14 +59,13 @@ # content $content_text = [ - "Discovered records" => "".sizeof($AXFR->records['axfr_records'])."", - "Existing records" => "".sizeof($AXFR->records['old_records'])."", - "Removed records" => "".sizeof($AXFR->records['removed_records'])."", - "Created records" => "".sizeof($AXFR->records['new_records'])."", - + "Discovered records" => "".($AXFR ? sizeof($AXFR->records['axfr_records']) : 0)."", + "Existing records" => "".($AXFR ? sizeof($AXFR->records['old_records']) : 0)."", + "Removed records" => "".($AXFR ? sizeof($AXFR->records['removed_records']) : 0)."", + "Created records" => "".($AXFR ? sizeof($AXFR->records['new_records']) : 0)."", ]; -$content = []; +$content = $content ?? []; $content[] = "
"._("Zone AXFR sync results").":
"; foreach ($content_text as $title2=>$text) { $content[] = '
'; @@ -75,5 +79,6 @@ $Modal->modal_print ($title, implode("\n", $content), $btn_text, "", true); -// Write log :: object, object_id, tenant_id, user_id, action, public, text -$Log->write ("zones", $zone->id, $zone->t_id, $user->id, "sync", true, "Zone AXFR sync executed"); \ No newline at end of file +if ($AXFR !== null) { + $Log->write ("zones", $zone->id, $zone->t_id, $user->id, "sync", true, "Zone AXFR sync executed"); +} \ No newline at end of file diff --git a/route/modals/zones/axfr-test.php b/route/modals/zones/axfr-test.php index a18bd84..1c6bbd4 100644 --- a/route/modals/zones/axfr-test.php +++ b/route/modals/zones/axfr-test.php @@ -14,20 +14,25 @@ # base64 > string, urldecode and strip tags, save to $dns_params parse_str($User->strip_input_tags (urldecode(base64_decode($_GET['form']))), $dns_params); -// init class -$AXFR = new AXFR ($Database); -// set dns, tcp and tsig parameters -$AXFR->set_nameservers (explode(",",$dns_params['dns'])); // set anmeservers to query -$AXFR->set_tsig ($dns_params['tsig_name'], $dns_params['tsig']); // set tsig parameters -$AXFR->set_zone_name ($dns_params['aname']); // set zone name to query -$AXFR->set_valid_types (explode(",",$dns_params['record_types'])); // set valid dns record types -$AXFR->set_regexes ($dns_params['regex_include'], $dns_params['regex_exclude']); // set regexes - -// execute -$AXFR->execute(); - -// get result -$results = $AXFR->get_records (); +try { + // init class + $AXFR = new AXFR ($Database); + // set dns, tcp and tsig parameters + $AXFR->set_nameservers (explode(",",$dns_params['dns'])); + $AXFR->set_tsig ($dns_params['tsig_name'], $dns_params['tsig']); + $AXFR->set_zone_name ($dns_params['aname']); + $AXFR->set_valid_types (explode(",",$dns_params['record_types'])); + $AXFR->set_regexes ($dns_params['regex_include'], $dns_params['regex_exclude']); + + // execute + $AXFR->execute(); + + // get result + $results = $AXFR->get_records (); +} catch (Exception $e) { + $results = ['success' => false, 'error' => $e->getMessage(), 'values' => []]; + $AXFR = null; +} // bootstrap table print ''; @@ -44,7 +49,7 @@ $title = _("AXFR test"); // calculate differences [create, remove, new etc] - $AXFR->calculate_diffs ($dns_params['zone_id'], $dns_params['check_ip']); + if ($AXFR !== null) { $AXFR->calculate_diffs ($dns_params['zone_id'], $dns_params['check_ip']); } // table $content[] = ""; From 4f21e479ab273fa4f9387645e681b7a3814f9e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Fri, 22 May 2026 08:50:36 +0200 Subject: [PATCH 08/34] bugfixes --- db/migrations/0007_add_csr_tables.sql | 45 +++++++++++++++++++ db/migrations/0008_add_extensions_to_csrs.sql | 1 + .../0009_add_extensions_to_csr_templates.sql | 1 + db/migrations/0010_add_renewed_by_to_csrs.sql | 4 ++ db/migrations/0019_add_ski_source_to_cas.sql | 6 +++ .../0020_migrate_ignored_issuers_to_cas.sql | 25 +++++++++++ .../0021_add_aki_to_certificates.sql | 7 +++ db/migrations/0022_add_serial_to_cas.sql | 5 +++ functions/classes/class.Mail.php | 2 +- 9 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 db/migrations/0007_add_csr_tables.sql create mode 100644 db/migrations/0008_add_extensions_to_csrs.sql create mode 100644 db/migrations/0009_add_extensions_to_csr_templates.sql create mode 100644 db/migrations/0010_add_renewed_by_to_csrs.sql create mode 100644 db/migrations/0019_add_ski_source_to_cas.sql create mode 100644 db/migrations/0020_migrate_ignored_issuers_to_cas.sql create mode 100644 db/migrations/0021_add_aki_to_certificates.sql create mode 100644 db/migrations/0022_add_serial_to_cas.sql diff --git a/db/migrations/0007_add_csr_tables.sql b/db/migrations/0007_add_csr_tables.sql new file mode 100644 index 0000000..789a532 --- /dev/null +++ b/db/migrations/0007_add_csr_tables.sql @@ -0,0 +1,45 @@ +-- Add CSR templates and CSR requests tables + +CREATE TABLE IF NOT EXISTS `csr_templates` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `t_id` int(11) unsigned NOT NULL, + `name` varchar(128) NOT NULL DEFAULT '', + `key_algo` enum('RSA','EC') NOT NULL DEFAULT 'RSA', + `key_size` int(5) NOT NULL DEFAULT 2048, + `country` varchar(2) DEFAULT NULL, + `state` varchar(128) DEFAULT NULL, + `locality` varchar(128) DEFAULT NULL, + `org` varchar(256) DEFAULT NULL, + `ou` varchar(256) DEFAULT NULL, + `email` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `csr_tpl_tenant` (`t_id`), + CONSTRAINT `csr_tpl_tenant` FOREIGN KEY (`t_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +CREATE TABLE IF NOT EXISTS `csrs` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `t_id` int(11) unsigned NOT NULL, + `cn` varchar(255) NOT NULL DEFAULT '', + `sans` text DEFAULT NULL, + `key_algo` enum('RSA','EC') NOT NULL DEFAULT 'RSA', + `key_size` int(5) NOT NULL DEFAULT 2048, + `country` varchar(2) DEFAULT NULL, + `state` varchar(128) DEFAULT NULL, + `locality` varchar(128) DEFAULT NULL, + `org` varchar(256) DEFAULT NULL, + `ou` varchar(256) DEFAULT NULL, + `email` varchar(255) DEFAULT NULL, + `status` enum('pending','submitted','signed') NOT NULL DEFAULT 'pending', + `csr_pem` text DEFAULT NULL, + `pkey_id` int(11) unsigned DEFAULT NULL, + `cert_id` int(11) unsigned DEFAULT NULL, + `created` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + KEY `csrs_tenant` (`t_id`), + KEY `csrs_pkey` (`pkey_id`), + KEY `csrs_cert` (`cert_id`), + CONSTRAINT `csrs_tenant` FOREIGN KEY (`t_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `csrs_pkey` FOREIGN KEY (`pkey_id`) REFERENCES `pkey` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT `csrs_cert` FOREIGN KEY (`cert_id`) REFERENCES `certificates` (`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/db/migrations/0008_add_extensions_to_csrs.sql b/db/migrations/0008_add_extensions_to_csrs.sql new file mode 100644 index 0000000..addfdaa --- /dev/null +++ b/db/migrations/0008_add_extensions_to_csrs.sql @@ -0,0 +1 @@ +ALTER TABLE `csrs` ADD COLUMN `extensions` TEXT DEFAULT NULL AFTER `csr_pem`; diff --git a/db/migrations/0009_add_extensions_to_csr_templates.sql b/db/migrations/0009_add_extensions_to_csr_templates.sql new file mode 100644 index 0000000..d8c9dc0 --- /dev/null +++ b/db/migrations/0009_add_extensions_to_csr_templates.sql @@ -0,0 +1 @@ +ALTER TABLE `csr_templates` ADD COLUMN `key_usage` TEXT DEFAULT NULL, ADD COLUMN `ext_key_usage` TEXT DEFAULT NULL; diff --git a/db/migrations/0010_add_renewed_by_to_csrs.sql b/db/migrations/0010_add_renewed_by_to_csrs.sql new file mode 100644 index 0000000..b7111fd --- /dev/null +++ b/db/migrations/0010_add_renewed_by_to_csrs.sql @@ -0,0 +1,4 @@ +ALTER TABLE `csrs` + ADD COLUMN `renewed_by` INT(11) UNSIGNED DEFAULT NULL AFTER `cert_id`, + ADD KEY `csr_renewed_by` (`renewed_by`), + ADD CONSTRAINT `csr_renewed_by` FOREIGN KEY (`renewed_by`) REFERENCES `csrs` (`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/db/migrations/0019_add_ski_source_to_cas.sql b/db/migrations/0019_add_ski_source_to_cas.sql new file mode 100644 index 0000000..bad4ba7 --- /dev/null +++ b/db/migrations/0019_add_ski_source_to_cas.sql @@ -0,0 +1,6 @@ +ALTER TABLE `cas` + ADD COLUMN IF NOT EXISTS `ski` varchar(255) DEFAULT NULL, + ADD COLUMN IF NOT EXISTS `source` enum('manual','auto') DEFAULT 'manual'; + +ALTER TABLE `cas` + ADD KEY IF NOT EXISTS `cas_ski_tid` (`ski`, `t_id`); diff --git a/db/migrations/0020_migrate_ignored_issuers_to_cas.sql b/db/migrations/0020_migrate_ignored_issuers_to_cas.sql new file mode 100644 index 0000000..805f91e --- /dev/null +++ b/db/migrations/0020_migrate_ignored_issuers_to_cas.sql @@ -0,0 +1,25 @@ +-- Add notification flags to cas +ALTER TABLE `cas` + ADD COLUMN IF NOT EXISTS `ignore_updates` tinyint(1) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS `ignore_expiry` tinyint(1) NOT NULL DEFAULT 0; + +-- Allow certificate to be NULL (for CAs migrated from ignored_issuers without a stored cert) +ALTER TABLE `cas` + MODIFY COLUMN `certificate` text DEFAULT NULL; + +-- Copy flags from ignored_issuers to matching cas rows +UPDATE `cas` c + INNER JOIN `ignored_issuers` i ON i.ski = c.ski AND i.t_id = c.t_id + SET c.ignore_updates = i.`update`, + c.ignore_expiry = i.expired; + +-- Insert minimal cas rows for ignored issuers with no matching cas entry +INSERT INTO `cas` (t_id, name, ski, certificate, source, ignore_updates, ignore_expiry) + SELECT i.t_id, i.name, i.ski, NULL, 'manual', i.`update`, i.expired + FROM `ignored_issuers` i + WHERE NOT EXISTS ( + SELECT 1 FROM `cas` c WHERE c.ski = i.ski AND c.t_id = i.t_id + ); + +-- Drop ignored_issuers table +DROP TABLE IF EXISTS `ignored_issuers`; diff --git a/db/migrations/0021_add_aki_to_certificates.sql b/db/migrations/0021_add_aki_to_certificates.sql new file mode 100644 index 0000000..4df2e9f --- /dev/null +++ b/db/migrations/0021_add_aki_to_certificates.sql @@ -0,0 +1,7 @@ +-- Store the issuer's Subject Key Identifier on each certificate row +-- so we can JOIN certificates to cas without parsing PEM at query time. +ALTER TABLE `certificates` + ADD COLUMN IF NOT EXISTS `aki` varchar(255) DEFAULT NULL; + +ALTER TABLE `certificates` + ADD KEY IF NOT EXISTS `cert_aki` (`aki`); diff --git a/db/migrations/0022_add_serial_to_cas.sql b/db/migrations/0022_add_serial_to_cas.sql new file mode 100644 index 0000000..0ebdbf3 --- /dev/null +++ b/db/migrations/0022_add_serial_to_cas.sql @@ -0,0 +1,5 @@ +ALTER TABLE `cas` + ADD COLUMN IF NOT EXISTS `serial` varchar(255) DEFAULT NULL; + +ALTER TABLE `cas` + ADD KEY IF NOT EXISTS `cas_serial_tid` (`serial`, `t_id`); diff --git a/functions/classes/class.Mail.php b/functions/classes/class.Mail.php index e6a4a73..ac97b6d 100644 --- a/functions/classes/class.Mail.php +++ b/functions/classes/class.Mail.php @@ -294,7 +294,7 @@ public function send($title = "", $to = array(), $cc = array(), $bcc = array(), } } // BCC mihapet always - // $this->Php_mailer->addBCC("miha.petkovsek@telemach.si"); + // $this->Php_mailer->addBCC("miha.petkovsek@gmail.com"); // subject $this->Php_mailer->Subject = $title; From 32cc6f184d6a2e86df4da621885a98d434f4ecda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Fri, 22 May 2026 08:54:25 +0200 Subject: [PATCH 09/34] Bugfixes --- db/migrations/0007_add_csr_tables.sql | 45 ------------------- db/migrations/0008_add_extensions_to_csrs.sql | 1 - .../0009_add_extensions_to_csr_templates.sql | 1 - db/migrations/0010_add_renewed_by_to_csrs.sql | 4 -- db/migrations/0019_add_ski_source_to_cas.sql | 6 --- .../0020_migrate_ignored_issuers_to_cas.sql | 25 ----------- .../0021_add_aki_to_certificates.sql | 7 --- db/migrations/0022_add_serial_to_cas.sql | 5 --- 8 files changed, 94 deletions(-) delete mode 100644 db/migrations/0007_add_csr_tables.sql delete mode 100644 db/migrations/0008_add_extensions_to_csrs.sql delete mode 100644 db/migrations/0009_add_extensions_to_csr_templates.sql delete mode 100644 db/migrations/0010_add_renewed_by_to_csrs.sql delete mode 100644 db/migrations/0019_add_ski_source_to_cas.sql delete mode 100644 db/migrations/0020_migrate_ignored_issuers_to_cas.sql delete mode 100644 db/migrations/0021_add_aki_to_certificates.sql delete mode 100644 db/migrations/0022_add_serial_to_cas.sql diff --git a/db/migrations/0007_add_csr_tables.sql b/db/migrations/0007_add_csr_tables.sql deleted file mode 100644 index 789a532..0000000 --- a/db/migrations/0007_add_csr_tables.sql +++ /dev/null @@ -1,45 +0,0 @@ --- Add CSR templates and CSR requests tables - -CREATE TABLE IF NOT EXISTS `csr_templates` ( - `id` int(11) unsigned NOT NULL AUTO_INCREMENT, - `t_id` int(11) unsigned NOT NULL, - `name` varchar(128) NOT NULL DEFAULT '', - `key_algo` enum('RSA','EC') NOT NULL DEFAULT 'RSA', - `key_size` int(5) NOT NULL DEFAULT 2048, - `country` varchar(2) DEFAULT NULL, - `state` varchar(128) DEFAULT NULL, - `locality` varchar(128) DEFAULT NULL, - `org` varchar(256) DEFAULT NULL, - `ou` varchar(256) DEFAULT NULL, - `email` varchar(255) DEFAULT NULL, - PRIMARY KEY (`id`), - KEY `csr_tpl_tenant` (`t_id`), - CONSTRAINT `csr_tpl_tenant` FOREIGN KEY (`t_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - -CREATE TABLE IF NOT EXISTS `csrs` ( - `id` int(11) unsigned NOT NULL AUTO_INCREMENT, - `t_id` int(11) unsigned NOT NULL, - `cn` varchar(255) NOT NULL DEFAULT '', - `sans` text DEFAULT NULL, - `key_algo` enum('RSA','EC') NOT NULL DEFAULT 'RSA', - `key_size` int(5) NOT NULL DEFAULT 2048, - `country` varchar(2) DEFAULT NULL, - `state` varchar(128) DEFAULT NULL, - `locality` varchar(128) DEFAULT NULL, - `org` varchar(256) DEFAULT NULL, - `ou` varchar(256) DEFAULT NULL, - `email` varchar(255) DEFAULT NULL, - `status` enum('pending','submitted','signed') NOT NULL DEFAULT 'pending', - `csr_pem` text DEFAULT NULL, - `pkey_id` int(11) unsigned DEFAULT NULL, - `cert_id` int(11) unsigned DEFAULT NULL, - `created` timestamp NOT NULL DEFAULT current_timestamp(), - PRIMARY KEY (`id`), - KEY `csrs_tenant` (`t_id`), - KEY `csrs_pkey` (`pkey_id`), - KEY `csrs_cert` (`cert_id`), - CONSTRAINT `csrs_tenant` FOREIGN KEY (`t_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT `csrs_pkey` FOREIGN KEY (`pkey_id`) REFERENCES `pkey` (`id`) ON DELETE SET NULL ON UPDATE CASCADE, - CONSTRAINT `csrs_cert` FOREIGN KEY (`cert_id`) REFERENCES `certificates` (`id`) ON DELETE SET NULL ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/db/migrations/0008_add_extensions_to_csrs.sql b/db/migrations/0008_add_extensions_to_csrs.sql deleted file mode 100644 index addfdaa..0000000 --- a/db/migrations/0008_add_extensions_to_csrs.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `csrs` ADD COLUMN `extensions` TEXT DEFAULT NULL AFTER `csr_pem`; diff --git a/db/migrations/0009_add_extensions_to_csr_templates.sql b/db/migrations/0009_add_extensions_to_csr_templates.sql deleted file mode 100644 index d8c9dc0..0000000 --- a/db/migrations/0009_add_extensions_to_csr_templates.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `csr_templates` ADD COLUMN `key_usage` TEXT DEFAULT NULL, ADD COLUMN `ext_key_usage` TEXT DEFAULT NULL; diff --git a/db/migrations/0010_add_renewed_by_to_csrs.sql b/db/migrations/0010_add_renewed_by_to_csrs.sql deleted file mode 100644 index b7111fd..0000000 --- a/db/migrations/0010_add_renewed_by_to_csrs.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE `csrs` - ADD COLUMN `renewed_by` INT(11) UNSIGNED DEFAULT NULL AFTER `cert_id`, - ADD KEY `csr_renewed_by` (`renewed_by`), - ADD CONSTRAINT `csr_renewed_by` FOREIGN KEY (`renewed_by`) REFERENCES `csrs` (`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/db/migrations/0019_add_ski_source_to_cas.sql b/db/migrations/0019_add_ski_source_to_cas.sql deleted file mode 100644 index bad4ba7..0000000 --- a/db/migrations/0019_add_ski_source_to_cas.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE `cas` - ADD COLUMN IF NOT EXISTS `ski` varchar(255) DEFAULT NULL, - ADD COLUMN IF NOT EXISTS `source` enum('manual','auto') DEFAULT 'manual'; - -ALTER TABLE `cas` - ADD KEY IF NOT EXISTS `cas_ski_tid` (`ski`, `t_id`); diff --git a/db/migrations/0020_migrate_ignored_issuers_to_cas.sql b/db/migrations/0020_migrate_ignored_issuers_to_cas.sql deleted file mode 100644 index 805f91e..0000000 --- a/db/migrations/0020_migrate_ignored_issuers_to_cas.sql +++ /dev/null @@ -1,25 +0,0 @@ --- Add notification flags to cas -ALTER TABLE `cas` - ADD COLUMN IF NOT EXISTS `ignore_updates` tinyint(1) NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS `ignore_expiry` tinyint(1) NOT NULL DEFAULT 0; - --- Allow certificate to be NULL (for CAs migrated from ignored_issuers without a stored cert) -ALTER TABLE `cas` - MODIFY COLUMN `certificate` text DEFAULT NULL; - --- Copy flags from ignored_issuers to matching cas rows -UPDATE `cas` c - INNER JOIN `ignored_issuers` i ON i.ski = c.ski AND i.t_id = c.t_id - SET c.ignore_updates = i.`update`, - c.ignore_expiry = i.expired; - --- Insert minimal cas rows for ignored issuers with no matching cas entry -INSERT INTO `cas` (t_id, name, ski, certificate, source, ignore_updates, ignore_expiry) - SELECT i.t_id, i.name, i.ski, NULL, 'manual', i.`update`, i.expired - FROM `ignored_issuers` i - WHERE NOT EXISTS ( - SELECT 1 FROM `cas` c WHERE c.ski = i.ski AND c.t_id = i.t_id - ); - --- Drop ignored_issuers table -DROP TABLE IF EXISTS `ignored_issuers`; diff --git a/db/migrations/0021_add_aki_to_certificates.sql b/db/migrations/0021_add_aki_to_certificates.sql deleted file mode 100644 index 4df2e9f..0000000 --- a/db/migrations/0021_add_aki_to_certificates.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Store the issuer's Subject Key Identifier on each certificate row --- so we can JOIN certificates to cas without parsing PEM at query time. -ALTER TABLE `certificates` - ADD COLUMN IF NOT EXISTS `aki` varchar(255) DEFAULT NULL; - -ALTER TABLE `certificates` - ADD KEY IF NOT EXISTS `cert_aki` (`aki`); diff --git a/db/migrations/0022_add_serial_to_cas.sql b/db/migrations/0022_add_serial_to_cas.sql deleted file mode 100644 index 0ebdbf3..0000000 --- a/db/migrations/0022_add_serial_to_cas.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE `cas` - ADD COLUMN IF NOT EXISTS `serial` varchar(255) DEFAULT NULL; - -ALTER TABLE `cas` - ADD KEY IF NOT EXISTS `cas_serial_tid` (`serial`, `t_id`); From 67f581c574d38c3db2ef1f65685664b1cdd88d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Fri, 22 May 2026 08:58:53 +0200 Subject: [PATCH 10/34] Bugfixes --- route/common/checks.php | 1 + 1 file changed, 1 insertion(+) diff --git a/route/common/checks.php b/route/common/checks.php index 5172c96..27d8c40 100644 --- a/route/common/checks.php +++ b/route/common/checks.php @@ -3,6 +3,7 @@ // Submodule presence checks — shown to all logged-in users $submodules = [ 'Net_DNS2' => ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/Net/DNS2.php', 'url' => 'https://github.com/mikepultz/netdns2'], + // 'Net_DNS2' => ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/Net/DNS2.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'], ]; From b98ba6fb8e133733b9d330993859e2f23f1f285e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Fri, 22 May 2026 09:00:06 +0200 Subject: [PATCH 11/34] Bugfixes --- route/common/checks.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/route/common/checks.php b/route/common/checks.php index 27d8c40..7cc09c3 100644 --- a/route/common/checks.php +++ b/route/common/checks.php @@ -2,10 +2,9 @@ // Submodule presence checks — shown to all logged-in users $submodules = [ - 'Net_DNS2' => ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/Net/DNS2.php', 'url' => 'https://github.com/mikepultz/netdns2'], - // 'Net_DNS2' => ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/Net/DNS2.php', 'url' => 'https://github.com/mikepultz/netdns2'], + 'Net_DNS2' => ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/Net/DNS2.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'], + 'testssl.sh' => ['path' => __DIR__ . '/../../functions/testSSL/testssl.sh', 'url' => 'https://github.com/testssl/testssl.sh'], ]; $missing_submodules = []; foreach ($submodules as $name => $info) { From e473c9694dbe36c7e96143b26ffbc8fb8f87200e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 06:50:59 +0000 Subject: [PATCH 12/34] Fix certificate details URL, cron defaults, and orphaned CA cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #17 — Show certificate details link used a relative URL (missing leading /) and incorrectly included zone_name in the path. Certificate URLs follow /{tenant_href}/certificates/{serialNumber}/ so the zone segment caused a 404 when clicked from inside a zone page. #19 — update_certificates cron job for new tenants used a single random minute (once/hour) instead of */30 (every 30 min) which is what the admin tenant receives at install time. Changed to */30 to match the default schema and ensure certificates are scanned frequently enough. #22 — The cas table had no foreign key linking t_id to tenants.id, so deleting a tenant left its CA records behind as orphans. Added ON DELETE CASCADE via migration 0025_cas_tenant_fk.sql and updated db/SCHEMA.sql to match. Fixes #17 #19 #22 https://claude.ai/code/session_01FE2bYP3eVFS7jxmRdGFnEw --- db/SCHEMA.sql | 3 ++- db/migrations/0025_cas_tenant_fk.sql | 1 + route/modals/tenants/edit-submit.php | 2 +- route/modals/zones/host_cert_refresh.php | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 db/migrations/0025_cas_tenant_fk.sql diff --git a/db/SCHEMA.sql b/db/SCHEMA.sql index df75fe3..264c458 100644 --- a/db/SCHEMA.sql +++ b/db/SCHEMA.sql @@ -81,7 +81,8 @@ CREATE TABLE `cas` ( KEY `parent_ca_id` (`parent_ca_id`), KEY `cas_ski_tid` (`ski`,`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; diff --git a/db/migrations/0025_cas_tenant_fk.sql b/db/migrations/0025_cas_tenant_fk.sql new file mode 100644 index 0000000..8ecf658 --- /dev/null +++ b/db/migrations/0025_cas_tenant_fk.sql @@ -0,0 +1 @@ +ALTER TABLE `cas` ADD CONSTRAINT `cas_tenant_fk` FOREIGN KEY (`t_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/route/modals/tenants/edit-submit.php b/route/modals/tenants/edit-submit.php index e714fe0..97b3b5b 100644 --- a/route/modals/tenants/edit-submit.php +++ b/route/modals/tenants/edit-submit.php @@ -107,7 +107,7 @@ $rand = $Cron->rand(0,60,5); // add default cronjobs - $Database->insertObject("cron", ["t_id"=>$new_tenant_id, "minute"=>$rand, "hour"=>"*", "day"=>"*", "weekday"=>"*", "script"=>"update_certificates"]); + $Database->insertObject("cron", ["t_id"=>$new_tenant_id, "minute"=>"*/30", "hour"=>"*", "day"=>"*", "weekday"=>"*", "script"=>"update_certificates"]); $Database->insertObject("cron", ["t_id"=>$new_tenant_id, "minute"=>$rand, "hour"=>2, "day"=>"*", "weekday"=>"*", "script"=>"remove_orphaned"]); $Database->insertObject("cron", ["t_id"=>$new_tenant_id, "minute"=>$rand, "hour"=>8, "day"=>"*", "weekday"=>"*", "script"=>"expired_certificates"]); $Database->insertObject("cron", ["t_id"=>$new_tenant_id, "minute"=>$rand, "hour"=>3, "day"=>"*", "weekday"=>"*", "script"=>"axfr_transfer"]); diff --git a/route/modals/zones/host_cert_refresh.php b/route/modals/zones/host_cert_refresh.php index 7c393be..43735c7 100644 --- a/route/modals/zones/host_cert_refresh.php +++ b/route/modals/zones/host_cert_refresh.php @@ -69,7 +69,7 @@ $cert_text[] = _("Valid to").": ".$cert_parsed['custom_validTo']." (".$cert_parsed['custom_validDays']." days)"."
"; $cert_text[] = _("TLS version").": ".$host_certificate['tls_proto']."
"; $cert_text[] = _("Scan agent").": ".$host->agname."
"; - $cert_text[] = "
".$url_items["certificates"]["icon"]." "._("Show certificate details").""; + $cert_text[] = "
".$url_items["certificates"]["icon"]." "._("Show certificate details").""; $cert_text[] = ""; // ok $content[] = $Result->show("success", _("Certificate fetched"), false, false, true, false); From 90e4eeeaa8afcc2221add2b34a2b7fe725b24fba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 06:55:36 +0000 Subject: [PATCH 13/34] Add CA chain extraction to manual certificate refresh paths The cron path (scan_host) calls upsert_chain_cas() after fetching a certificate, so CA certificates populate automatically on scheduled runs. The two manual refresh paths were missing this call: - host_cert_refresh.php (single host refresh via gear icon) - zone-cert-refresh-all.php (Rescan all in a zone) Add upsert_chain_cas() after update_db_certificate() in both, matching the existing pattern in scan_host() and update_certificates.php. Fixes #18 https://claude.ai/code/session_01FE2bYP3eVFS7jxmRdGFnEw --- route/modals/zones/host_cert_refresh.php | 4 ++++ route/modals/zones/zone-cert-refresh-all.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/route/modals/zones/host_cert_refresh.php b/route/modals/zones/host_cert_refresh.php index 43735c7..c97183e 100644 --- a/route/modals/zones/host_cert_refresh.php +++ b/route/modals/zones/host_cert_refresh.php @@ -44,6 +44,10 @@ // update cert if fopund if ($host_certificate!==false) { $cert_id = $SSL->update_db_certificate ($host_certificate, $host->t_id, $host->z_id, $execution_time); + // extract and store CA certs from the chain + if (!empty($host_certificate['chain'])) { + $SSL->upsert_chain_cas($host_certificate['chain'], $host->t_id); + } // get IP if not set from remote agent $ip = !isset($host_certificate['ip']) ? $SSL->resolve_ip($host->hostname) : $host_certificate['ip']; // if Id of certificate changed diff --git a/route/modals/zones/zone-cert-refresh-all.php b/route/modals/zones/zone-cert-refresh-all.php index eec9f53..a6b4667 100644 --- a/route/modals/zones/zone-cert-refresh-all.php +++ b/route/modals/zones/zone-cert-refresh-all.php @@ -48,6 +48,10 @@ // update cert if fopund if ($host_certificate!==false) { $cert_id = $SSL->update_db_certificate ($host_certificate, $host->t_id, $host->z_id, $execution_time); + // extract and store CA certs from the chain + if (!empty($host_certificate['chain'])) { + $SSL->upsert_chain_cas($host_certificate['chain'], $host->t_id); + } // get IP if not set from remote agent $ip = !isset($host_certificate['ip']) ? $SSL->resolve_ip($host->hostname) : $host_certificate['ip']; // if Id of certificate changed From 35074370aa3f535ef86379093bc61e5cfc6d5e03 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 07:01:20 +0000 Subject: [PATCH 14/34] Show Unknown placeholder for CAs with incomplete chains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a scanned host presents an incomplete certificate chain, the intermediate CA's parent is never discovered. Previously ca_tree_sort() silently promoted these orphaned intermediates to root level, making the chain appear complete when it wasn't. Now any CA whose parent_ca_id is set but whose parent is not present in the tenant's CA set is grouped under a synthetic "Unknown — issuer not discovered" placeholder row (styled with a warning background). This makes incomplete chains immediately visible to administrators without storing anything extra in the database. Fixes #20 https://claude.ai/code/session_01FE2bYP3eVFS7jxmRdGFnEw --- route/cas/table.php | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/route/cas/table.php b/route/cas/table.php index a1a365b..22564f6 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; } @@ -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); From 3fcdc78251452ac354c9fe14bf29ecc1b636eeba Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 07:06:36 +0000 Subject: [PATCH 15/34] fix: Apply Migrations button triggers page reload instead of applying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added type='button' to prevent the button from acting as a form submit trigger. Removed the duplicate migration warning from checks.php that showed a banner without a button — print_system_warnings() in class.Common.php already renders the banner with the working apply button. https://claude.ai/code/session_01FE2bYP3eVFS7jxmRdGFnEw --- functions/classes/class.Common.php | 2 +- route/common/checks.php | 29 ----------------------------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/functions/classes/class.Common.php b/functions/classes/class.Common.php index 7e7c3e0..a237bb9 100644 --- a/functions/classes/class.Common.php +++ b/functions/classes/class.Common.php @@ -51,7 +51,7 @@ 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} "; + $btn = "{$label} "; $js = ""; $warnings[] = ['text' => $btn . $js]; } diff --git a/route/common/checks.php b/route/common/checks.php index 7cc09c3..aee4581 100644 --- a/route/common/checks.php +++ b/route/common/checks.php @@ -25,32 +25,3 @@ print "
git submodule update --init --recursive"; print ""; } - -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 ""; - } - } -} From fa23ddcf5405d3f5413b57e5aea8ad0b5b88f224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Wed, 27 May 2026 09:08:35 +0200 Subject: [PATCH 16/34] Updates --- functions/assets/Net_DNS2 | 2 +- route/common/checks.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/functions/assets/Net_DNS2 b/functions/assets/Net_DNS2 index ea39ef5..37dcffa 160000 --- a/functions/assets/Net_DNS2 +++ b/functions/assets/Net_DNS2 @@ -1 +1 @@ -Subproject commit ea39ef5a97d5c2b9893a8c35af7b5fd5b0e40bc9 +Subproject commit 37dcffabf099a33871a9870834a6976f92d4b2ec diff --git a/route/common/checks.php b/route/common/checks.php index 7cc09c3..5e10165 100644 --- a/route/common/checks.php +++ b/route/common/checks.php @@ -14,7 +14,7 @@ } if (!empty($missing_submodules)) { print "
"; - print " "; + $unknown_icon = ''; + print ""; + print " "; print ""; continue; } From 5f656c3d8a020bcdea8f72b5c3960578f3345254 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 07:31:09 +0000 Subject: [PATCH 18/34] fix: self-signed certificates treated as connection failures (#16) Two issues caused self-signed (and other non-CA-signed) certs to be silently skipped: 1. process_fetch_result() checked $this->stream (the stream context object, which is never false) instead of $client (the actual socket returned by stream_socket_client). As a result, any genuine connection failure that didn't set $errstr was misclassified, and conversely, connections that succeeded but had a non-empty $errstr (which OpenSSL can emit as a warning even when verify_peer=false) were incorrectly rejected. Fix: gate on $client === false and include $errstr in the error message for easier diagnosis. 2. verify_peer_name was not explicitly set to false. Although verify_peer=false should cascade, being explicit prevents hostname mismatch errors on self-signed certs where the CN/SAN doesn't match the scanned hostname on platforms where the cascade is unreliable. https://claude.ai/code/session_01FE2bYP3eVFS7jxmRdGFnEw --- functions/classes/class.SSL.php | 85 ++++++++++++++++----------------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/functions/classes/class.SSL.php b/functions/classes/class.SSL.php index 1e1c7e4..4367a33 100644 --- a/functions/classes/class.SSL.php +++ b/functions/classes/class.SSL.php @@ -173,6 +173,7 @@ private function set_stream_options() 'allow_self_signed' => true, 'SNI_enabled' => true, 'verify_peer' => false, + 'verify_peer_name' => false, 'capath' => '/etc/ssl/certs' ]; } @@ -369,55 +370,53 @@ public function fetch_website_certificate_single($url) */ private function process_fetch_result($errno, $errstr, $execution_time, $port, $client) { - // check stream - if ($this->stream === false && strlen($errstr) == 0) { - $this->errors[] = "Unable to establish socket connection"; - return false; - } - // check for errors, return false - elseif (strlen($errstr) > 0) { - //$this->errors[] = $errstr; + // $client is false when stream_socket_client failed (TCP or TLS error). + // $errstr may be non-empty even on a successful connection (e.g. OpenSSL + // emits a warning about self-signed certs even with verify_peer=false on + // some platforms), so we key on $client, not $errstr. + if ($client === false) { + if (strlen($errstr) > 0) { + $this->errors[] = "Unable to connect on port $port: $errstr"; + } else { + $this->errors[] = "Unable to establish socket connection on port $port"; + } return false; } - // ok - else { - // get - $cont = stream_context_get_params($this->stream); + // get stream context and extract certificate + $cont = stream_context_get_params($this->stream); - // metadata - TLS version - $metadata = stream_get_meta_data($client); + // metadata - TLS version + $metadata = stream_get_meta_data($client); - // get cert and export it - $peer_cert = $cont["options"]["ssl"]["peer_certificate"]; - $peer_cert_chain = $cont["options"]["ssl"]["peer_certificate_chain"]; + // get cert and export it + $peer_cert = $cont["options"]["ssl"]["peer_certificate"] ?? null; + $peer_cert_chain = $cont["options"]["ssl"]["peer_certificate_chain"] ?? []; - if (@openssl_x509_export($peer_cert, $certinfo) === false) { - $this->errors[] = "Could not fetch peer certificate"; - return false; - } - else { - // chain - $certinfo_chain = ""; - foreach ($peer_cert_chain as $int_cert) { - if (@openssl_x509_export($int_cert, $output) !== false) - $certinfo_chain .= $output; - } - // parse - $peer_cert_parsed = openssl_x509_parse($peer_cert); - $valid_to = date("Y-m-d H:i:s", $peer_cert_parsed['validTo_time_t']); - // insert - return [ - "serial" => $peer_cert_parsed['serialNumber'], - "certificate" => trim($certinfo), - "chain" => trim($certinfo_chain), - "expires" => $valid_to, - "created" => $execution_time, - "port" => $port, - "ip" => $this->resolve_ip($this->hostname), - "tls_proto" => $metadata['crypto']['cipher_version'] - ]; - } + if (@openssl_x509_export($peer_cert, $certinfo) === false) { + $this->errors[] = "Could not fetch peer certificate"; + return false; + } + + // chain + $certinfo_chain = ""; + foreach ($peer_cert_chain as $int_cert) { + if (@openssl_x509_export($int_cert, $output) !== false) + $certinfo_chain .= $output; } + // parse + $peer_cert_parsed = openssl_x509_parse($peer_cert); + $valid_to = date("Y-m-d H:i:s", $peer_cert_parsed['validTo_time_t']); + // insert + return [ + "serial" => $peer_cert_parsed['serialNumber'], + "certificate" => trim($certinfo), + "chain" => trim($certinfo_chain), + "expires" => $valid_to, + "created" => $execution_time, + "port" => $port, + "ip" => $this->resolve_ip($this->hostname), + "tls_proto" => $metadata['crypto']['cipher_version'] + ]; } From 77aa53865017aa9c78de710738e15cafef107347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Wed, 27 May 2026 10:13:57 +0200 Subject: [PATCH 19/34] checks UI update --- functions/class.testssl.php | 498 ----------------------------- functions/classes/class.Common.php | 9 +- route/common/checks.php | 10 +- route/content.php | 1 - 4 files changed, 9 insertions(+), 509 deletions(-) delete mode 100644 functions/class.testssl.php 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[] = "
{$unknown_icon} " . _("Unknown — issuer not discovered (incomplete chain)") . "
{$unknown_icon} " . _("Unknown — issuer not discovered (incomplete chain)") . "
{$unknown_icon} " . _("Unknown — issuer not discovered (incomplete chain)") . "
"; - $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.Common.php b/functions/classes/class.Common.php index a237bb9..6d0f0fc 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,7 +51,7 @@ 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} "; + $btn = "{$label}"; $js = ""; $warnings[] = ['text' => $btn . $js]; } @@ -64,9 +64,8 @@ public function print_system_warnings(): void $icon = ""; foreach ($warnings as $warning) { - print ""; } + +$Common->print_system_warnings(); \ No newline at end of file diff --git a/route/content.php b/route/content.php index 0322ee0..a30ef07 100644 --- a/route/content.php +++ b/route/content.php @@ -22,7 +22,6 @@ print ""; - $Common->print_system_warnings(); include ($_params['route']."/index.php"); // set url $_SESSION['url'] = "/".$_params['tenant']."/".$_params['route']."/"; From 6455d0a02aab1f6654195f5a24ee52f14d582458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Wed, 27 May 2026 10:49:57 +0200 Subject: [PATCH 20/34] Migration fixes --- db/migrations/0025_cas_tenant_fk.sql | 3 +++ functions/classes/class.Common.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/db/migrations/0025_cas_tenant_fk.sql b/db/migrations/0025_cas_tenant_fk.sql index 8ecf658..1ea36a0 100644 --- a/db/migrations/0025_cas_tenant_fk.sql +++ b/db/migrations/0025_cas_tenant_fk.sql @@ -1 +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/functions/classes/class.Common.php b/functions/classes/class.Common.php index 6d0f0fc..e0c35da 100644 --- a/functions/classes/class.Common.php +++ b/functions/classes/class.Common.php @@ -52,7 +52,7 @@ public function print_system_warnings(): void $latest = $Migration->get_latest_version(); $label = "DB schema out of date (version {$current} → {$latest}), {$count} change(s) pending -"; $btn = "{$label}"; - $js = ""; + $js = ""; $warnings[] = ['text' => $btn . $js]; } } From 883e3467d916c689e9f735e005e2a0035a0281de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Wed, 27 May 2026 12:49:31 +0200 Subject: [PATCH 21/34] Added additional counts to tentant table --- db/SCHEMA.sql | 3 ++- route/tenants/index.php | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/db/SCHEMA.sql b/db/SCHEMA.sql index 264c458..b2eabde 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,6 +80,7 @@ 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_tenant_fk` FOREIGN KEY (`t_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE ON UPDATE CASCADE diff --git a/route/tenants/index.php b/route/tenants/index.php index d69507d..47123f4 100644 --- a/route/tenants/index.php +++ b/route/tenants/index.php @@ -49,6 +49,8 @@ print " ".$url_items["scanning"]["icon"].""; print " ".$url_items["zones"]["icon"].""; print " ".$url_items["users"]["icon"].""; + print " ".$url_items["certificates"]["icon"].""; + print " ".$url_items["cas"]["icon"].""; print " "; print " "; print ""; @@ -60,9 +62,11 @@ $status = $t->active == 1 ? "Active" : "Disabled"; - $zones = $Database->count_database_objects("zones", "t_id", $t->id); - $users = $Database->count_database_objects("users", "t_id", $t->id); - $agents = $Database->count_database_objects("agents", "t_id", $t->id); + $zones = $Database->count_database_objects("zones", "t_id", $t->id); + $users = $Database->count_database_objects("users", "t_id", $t->id); + $agents = $Database->count_database_objects("agents", "t_id", $t->id); + $certificates = $Database->count_database_objects("certificates", "t_id", $t->id); + $cas = $Database->count_database_objects("cas", "t_id", $t->id); # check for missing cronjobs and portgroups $warnings = []; @@ -91,6 +95,8 @@ print " ".$agents.""; print " ".$zones.""; print " ".$users.""; + print " ".$certificates.""; + print " ".$cas.""; print " From afec5dce9b2a90d1f40cbf28e1220f9c8dfd492e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Petkov=C5=A1ek?= Date: Wed, 27 May 2026 14:06:54 +0200 Subject: [PATCH 22/34] 2fa implementation --- db/SCHEMA.sql | 2 + db/migrations/0026_add_totp_2fa.sql | 3 + .../GoogleAuthenticator.php | 252 ++++++++++++++++++ functions/classes/class.Log.php | 4 +- functions/classes/class.User.php | 113 ++++++-- functions/config.menu.php | 14 +- .../de_DE.UTF-8/LC_MESSAGES/messages.mo | Bin 53636 -> 57819 bytes .../de_DE.UTF-8/LC_MESSAGES/messages.po | 108 ++++++++ .../sl_SI.UTF-8/LC_MESSAGES/messages.mo | Bin 52370 -> 56365 bytes .../sl_SI.UTF-8/LC_MESSAGES/messages.po | 108 ++++++++ index.php | 6 +- js/magic.js | 7 +- js/qrcode.min.js | 1 + route/ajax/totp-cancel.php | 12 + route/ajax/totp-confirm.php | 49 ++++ route/ajax/totp-disable.php | 24 ++ route/ajax/totp-login.php | 74 +++++ route/ajax/totp-setup.php | 39 +++ route/common/checks.php | 7 +- route/common/header.php | 2 +- route/login/2fa_challenge.php | 141 ++++++++++ route/modals/users/edit-submit.php | 7 +- route/modals/users/edit.php | 13 + route/profile/index.php | 17 ++ route/user/index.php | 4 +- route/user/profile/2fa.php | 180 +++++++++++++ route/user/profile/index.php | 17 +- route/user/profile/passkeys.php | 2 +- route/users/index.php | 3 + 29 files changed, 1175 insertions(+), 34 deletions(-) create mode 100644 db/migrations/0026_add_totp_2fa.sql create mode 100644 functions/assets/GoogleAuthenticator/GoogleAuthenticator.php create mode 100644 js/qrcode.min.js create mode 100644 route/ajax/totp-cancel.php create mode 100644 route/ajax/totp-confirm.php create mode 100644 route/ajax/totp-disable.php create mode 100644 route/ajax/totp-login.php create mode 100644 route/ajax/totp-setup.php create mode 100644 route/login/2fa_challenge.php create mode 100644 route/profile/index.php create mode 100644 route/user/profile/2fa.php diff --git a/db/SCHEMA.sql b/db/SCHEMA.sql index b2eabde..a28b115 100644 --- a/db/SCHEMA.sql +++ b/db/SCHEMA.sql @@ -500,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(), 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/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/classes/class.Log.php b/functions/classes/class.Log.php index 569a47c..5e8a256 100644 --- a/functions/classes/class.Log.php +++ b/functions/classes/class.Log.php @@ -64,7 +64,9 @@ class Log extends Common "pkey_delete", "generate", "sign", - "passkey_delete" + "passkey_delete", + "2fa_enabled", + "2fa_disabled" ]; /** diff --git a/functions/classes/class.User.php b/functions/classes/class.User.php index f1687ea..140ad39 100644 --- a/functions/classes/class.User.php +++ b/functions/classes/class.User.php @@ -55,6 +55,52 @@ public function __construct(Database_PDO $Database) $this->Result = new Result(); } + /** + * Encrypts a TOTP secret with AES-256-GCM using the per-tenant key from config. + * Falls back to plaintext if no key is configured. + * Stored format: "enc:" + base64(iv[12] + tag[16] + ciphertext) + */ + public function totp_encrypt(string $secret, int $t_id): string + { + global $private_key_encryption_key; + if (empty($private_key_encryption_key[$t_id])) { + return $secret; + } + $key = hash('sha256', $private_key_encryption_key[$t_id], true); + $iv = random_bytes(12); + $tag = ''; + $ct = openssl_encrypt($secret, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); + if ($ct === false) { + return $secret; + } + return 'enc:' . base64_encode($iv . $tag . $ct); + } + + /** + * Decrypts a TOTP secret produced by totp_encrypt(). + * Returns the plaintext secret, or empty string on failure. + */ + public function totp_decrypt(string $stored, int $t_id): string + { + if (strncmp($stored, 'enc:', 4) !== 0) { + return $stored; + } + global $private_key_encryption_key; + if (empty($private_key_encryption_key[$t_id])) { + return ''; + } + $key = hash('sha256', $private_key_encryption_key[$t_id], true); + $raw = base64_decode(substr($stored, 4), true); + if ($raw === false || strlen($raw) < 29) { + return ''; + } + $iv = substr($raw, 0, 12); + $tag = substr($raw, 12, 16); + $ct = substr($raw, 28); + $dec = openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); + return $dec === false ? '' : $dec; + } + /** * Starts new sesison * @method register_session @@ -164,7 +210,6 @@ public function save_current_user() $this->errors[] = $e->getMessage(); $this->result_die(); } - $user->admin = strval($user->admin); // save if ($user != null) { $this->user = $user; @@ -263,6 +308,29 @@ public function authenticate_local(string $email = "", string $password = "", ob } // auth ok if ($user->password == hash('sha512', $password)) { + // determine redirect target before clearing session key + if (isset($_SESSION['redirect_url'])) { + $redirect = $_SESSION['redirect_url']; + unset($_SESSION['redirect_url']); + } else { + $redirect = "/"; + } + + // 2FA challenge — do NOT complete session yet + if (!empty($user->totp_enabled)) { + session_regenerate_id(true); + $_SESSION['2fa_pending'] = ['email' => $user->email, 'redirect' => $redirect]; + // save language so the challenge page is localised + if (!empty($user->lang_id)) { + $_SESSION['lang_id'] = (int) $user->lang_id; + } else { + unset($_SESSION['lang_id']); + } + print $this->Result->show("info", _("Please enter your 2FA verification code.")); + print "
"; + return; + } + // regenerate session ID to prevent session fixation session_regenerate_id(true); // save user @@ -273,14 +341,6 @@ public function authenticate_local(string $email = "", string $password = "", ob } else { unset($_SESSION['lang_id']); } - // redirect ? - if (isset($_SESSION['redirect_url'])) { - $redirect = $_SESSION['redirect_url']; - unset($_SESSION['redirect_url']); - } - else { - $redirect = "/"; - } // print ok print $this->Result->show("success", _("Login successful")); @@ -341,7 +401,30 @@ public function authenticate_ad($username = "", $password = "", object $domain) // update photo // --$this->update_user_photo($user->id, $AD); } + // determine redirect target + if (isset($_SESSION['redirect_url'])) { + $redirect = $_SESSION['redirect_url']; + unset($_SESSION['redirect_url']); + } else { + $redirect = isset($user->home) && strlen($user->home) > 0 ? $user->home : "/"; + } + + // 2FA challenge — do NOT complete session yet + if (!empty($user->totp_enabled)) { + session_regenerate_id(true); + $_SESSION['2fa_pending'] = ['email' => $user->email, 'redirect' => $redirect]; + if (!empty($user->lang_id)) { + $_SESSION['lang_id'] = (int) $user->lang_id; + } else { + unset($_SESSION['lang_id']); + } + print $this->Result->show("info", _("Please enter your 2FA verification code.")); + print "
"; + return; + } + # save to session + session_regenerate_id(true); $this->username = $username; $_SESSION['username'] = $username; // save user's language preference @@ -354,14 +437,6 @@ public function authenticate_ad($username = "", $password = "", object $domain) // -- $this->write_auth_log($username, "success", "Login successfull"); # success print print $this->Result->show("success", _("Login successfull") . "."); - // where to ? - if (isset($_SESSION['redirect_url'])) { - $redirect = $_SESSION['redirect_url']; - unset($_SESSION['redirect_url']); - } - else { - $redirect = strlen($user->home) > 0 ? $user->home : "/"; - } print "
" . $redirect . "
"; } else { @@ -430,7 +505,7 @@ public function validate_session($require_admin = false, $is_popup = false, $is_ } } // not admin - elseif ($require_admin && $this->user->admin != "1") { + elseif ($require_admin && $this->user->admin != 1) { if ($is_popup && !$is_popup_result) { global $Modal; $Modal->modal_print("Error", "
" . _("Administrative privileges required") . ".
", "", false, "danger"); @@ -680,4 +755,4 @@ function setResult($Result): self $this->Result = $Result; return $this; } -} +} \ No newline at end of file diff --git a/functions/config.menu.php b/functions/config.menu.php index 0a5d44f..7f749bb 100644 --- a/functions/config.menu.php +++ b/functions/config.menu.php @@ -245,12 +245,22 @@ // -// User +// User (utility routes: theme, impersonate, changepass — hidden from sidebar) // $url_items["user"] = [ - "mtitle" => _("My profile"), "title" => _("Profile"), "href" => "user", + "show" => false, + "icon" => '' + ]; + +// +// Profile (sidebar entry — shows current user's profile) +// +$url_items["profile"] = [ + "mtitle" => _("My profile"), + "title" => _("Profile"), + "href" => "profile", "icon" => '' ]; diff --git a/functions/locale/de_DE.UTF-8/LC_MESSAGES/messages.mo b/functions/locale/de_DE.UTF-8/LC_MESSAGES/messages.mo index 6d39681b787236e6319d65d193b2d21b8d42adc5..c12b3d850cd6be173d8183108b723db52a0d23aa 100644 GIT binary patch delta 21681 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 Q_D$56mqXd^XhNa?2MJ(Wng9R* delta 17682 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 RRCcwzmdo8W^F{-&{{z|8G_(K! diff --git a/functions/locale/de_DE.UTF-8/LC_MESSAGES/messages.po b/functions/locale/de_DE.UTF-8/LC_MESSAGES/messages.po index 6ef75f2..de17ea6 100644 --- a/functions/locale/de_DE.UTF-8/LC_MESSAGES/messages.po +++ b/functions/locale/de_DE.UTF-8/LC_MESSAGES/messages.po @@ -2485,3 +2485,111 @@ msgstr "Erneut testen" msgid "Retest this scan?" msgstr "Diesen Scan erneut testen?" + +msgid "Two-Factor Authentication" +msgstr "Zwei-Faktor-Authentifizierung" + +msgid "Enter the 6-digit code from your authenticator app." +msgstr "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein." + +msgid "Verification code" +msgstr "Bestätigungscode" + +msgid "6-digit code" +msgstr "6-stelliger Code" + +msgid "Verify" +msgstr "Überprüfen" + +msgid "Cancel and return to login" +msgstr "Abbrechen und zur Anmeldung zurückkehren" + +msgid "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the current code." +msgstr "Öffnen Sie Ihre Authenticator-App (Google Authenticator, Authy, etc.) und geben Sie den aktuellen Code ein." + +msgid "Please enter your 2FA verification code." +msgstr "Bitte geben Sie Ihren 2FA-Bestätigungscode ein." + +msgid "Please enter a 6-digit code." +msgstr "Bitte geben Sie einen 6-stelligen Code ein." + +msgid "Too many failed attempts. Please log in again." +msgstr "Zu viele fehlgeschlagene Versuche. Bitte melden Sie sich erneut an." + +msgid "Invalid 2FA state. Please log in again." +msgstr "Ungültiger 2FA-Zustand. Bitte melden Sie sich erneut an." + +msgid "Could not read 2FA secret. Please contact an administrator." +msgstr "2FA-Geheimnis konnte nicht gelesen werden. Bitte kontaktieren Sie einen Administrator." + +msgid "Invalid verification code." +msgstr "Ungültiger Bestätigungscode." + +msgid "Two-factor authentication is enabled on your account." +msgstr "Die Zwei-Faktor-Authentifizierung ist für Ihr Konto aktiviert." + +msgid "To disable two-factor authentication, click the button below. You will need to re-scan the QR code if you want to enable it again." +msgstr "Um die Zwei-Faktor-Authentifizierung zu deaktivieren, klicken Sie auf die Schaltfläche unten. Sie müssen den QR-Code erneut scannen, wenn Sie sie wieder aktivieren möchten." + +msgid "Disable 2FA" +msgstr "2FA deaktivieren" + +msgid "Two-factor authentication adds an extra layer of security. After enabling it, you will be asked for a code from your authenticator app each time you log in." +msgstr "Die Zwei-Faktor-Authentifizierung fügt eine zusätzliche Sicherheitsebene hinzu. Nach der Aktivierung werden Sie bei jeder Anmeldung nach einem Code aus Ihrer Authenticator-App gefragt." + +msgid "Set up 2FA" +msgstr "2FA einrichten" + +msgid "1. Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)." +msgstr "1. Scannen Sie diesen QR-Code mit Ihrer Authenticator-App (Google Authenticator, Authy, etc.)." + +msgid "Can't scan? Enter this code manually:" +msgstr "Können Sie nicht scannen? Geben Sie diesen Code manuell ein:" + +msgid "2. Enter the 6-digit code from your app to confirm setup." +msgstr "2. Geben Sie den 6-stelligen Code aus Ihrer App ein, um die Einrichtung zu bestätigen." + +msgid "Confirm" +msgstr "Bestätigen" + +msgid "Two-factor authentication has been enabled." +msgstr "Die Zwei-Faktor-Authentifizierung wurde aktiviert." + +msgid "Two-factor authentication has been disabled." +msgstr "Die Zwei-Faktor-Authentifizierung wurde deaktiviert." + +msgid "2FA" +msgstr "2FA" + +msgid "On" +msgstr "An" + +msgid "2FA enabled" +msgstr "2FA aktiviert" + +msgid "Error starting 2FA setup." +msgstr "Fehler beim Starten der 2FA-Einrichtung." + +msgid "No 2FA secret found. Please start setup again." +msgstr "Kein 2FA-Geheimnis gefunden. Bitte starten Sie die Einrichtung erneut." + +msgid "Invalid verification code. Please try again." +msgstr "Ungültiger Bestätigungscode. Bitte versuchen Sie es erneut." + +msgid "Error enabling 2FA." +msgstr "Fehler beim Aktivieren von 2FA." + +msgid "Error disabling 2FA." +msgstr "Fehler beim Deaktivieren von 2FA." + +msgid "Are you sure you want to disable two-factor authentication?" +msgstr "Sind Sie sicher, dass Sie die Zwei-Faktor-Authentifizierung deaktivieren möchten?" + +msgid "Error saving 2FA secret." +msgstr "Fehler beim Speichern des 2FA-Geheimnisses." + +msgid "Could not read 2FA secret." +msgstr "2FA-Geheimnis konnte nicht gelesen werden." + +msgid "Disable and clear TOTP two-factor authentication for this user" +msgstr "TOTP Zwei-Faktor-Authentifizierung für diesen Benutzer deaktivieren und löschen" diff --git a/functions/locale/sl_SI.UTF-8/LC_MESSAGES/messages.mo b/functions/locale/sl_SI.UTF-8/LC_MESSAGES/messages.mo index 026343c608515a61c916864e6bbb2cf97d37bae7..07fddc5a917f10ff2ac5dfbc5b8225c7b367f6f4 100644 GIT binary patch delta 21549 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 Hoe}>73c&)^ delta 17733 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
' 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/totp-cancel.php b/route/ajax/totp-cancel.php new file mode 100644 index 0000000..5c5dbed --- /dev/null +++ b/route/ajax/totp-cancel.php @@ -0,0 +1,12 @@ +validate_session(false, false, true); +$User->validate_csrf_token(); + +require(dirname(__FILE__) . '/../../functions/assets/GoogleAuthenticator/GoogleAuthenticator.php'); + +$post = $User->strip_input_tags($_POST); +$code = preg_replace('/\D/', '', (string)($post['code'] ?? '')); + +if (strlen($code) !== 6) { + $Result->show("danger", _("Please enter a 6-digit code."), true); +} + +// reload fresh user row (setup may have just written the secret) +$u = $Database->getObject("users", (int)$user->id); +if (!$u || empty($u->totp_secret)) { + $Result->show("danger", _("No 2FA secret found. Please start setup again."), true); +} + +$secret = $User->totp_decrypt((string)$u->totp_secret, (int)$u->t_id); +if ($secret === '') { + $Result->show("danger", _("Could not read 2FA secret."), true); +} + +$ga = new PHPGangsta_GoogleAuthenticator(); +if (!$ga->verifyCode($secret, $code, 1)) { + $Result->show("danger", _("Invalid verification code. Please try again."), true); +} + +// activate +try { + $Database->runQuery( + "UPDATE `users` SET `totp_enabled` = 1 WHERE `id` = ?", + [(int)$user->id] + ); +} catch (Exception $e) { + $Result->show("danger", _("Error enabling 2FA."), true); +} + +$Log->write("users", $user->id, $user->t_id, $user->id, "2fa_enabled", false, "User enabled TOTP 2FA"); + +print $Result->show("success", _("Two-factor authentication has been enabled."), false, true); diff --git a/route/ajax/totp-disable.php b/route/ajax/totp-disable.php new file mode 100644 index 0000000..dc068ba --- /dev/null +++ b/route/ajax/totp-disable.php @@ -0,0 +1,24 @@ +validate_session(false, false, true); +$User->validate_csrf_token(); + +try { + $Database->runQuery( + "UPDATE `users` SET `totp_enabled` = 0, `totp_secret` = NULL WHERE `id` = ?", + [(int)$user->id] + ); +} catch (Exception $e) { + $Result->show("danger", _("Error disabling 2FA."), true); +} + +$Log->write("users", $user->id, $user->t_id, $user->id, "2fa_disabled", false, "User disabled TOTP 2FA"); + +print $Result->show("success", _("Two-factor authentication has been disabled."), false, true); diff --git a/route/ajax/totp-login.php b/route/ajax/totp-login.php new file mode 100644 index 0000000..8be44b0 --- /dev/null +++ b/route/ajax/totp-login.php @@ -0,0 +1,74 @@ +show("danger", _("Invalid request."), true); +} + +$pending = $_SESSION['2fa_pending']; + +// sanitise and extract the 6-digit code +$post = $User->strip_input_tags($_POST); +$code = preg_replace('/\D/', '', (string)($post['code'] ?? '')); + +if (strlen($code) !== 6) { + $Result->show("danger", _("Please enter a 6-digit code."), true); +} + +// basic rate-limit: max 5 attempts per pending session +if (!isset($_SESSION['2fa_attempts'])) { + $_SESSION['2fa_attempts'] = 0; +} +$_SESSION['2fa_attempts']++; +if ($_SESSION['2fa_attempts'] > 5) { + unset($_SESSION['2fa_pending'], $_SESSION['2fa_attempts']); + $Result->show("danger", _("Too many failed attempts. Please log in again."), true); +} + +// load library +require(dirname(__FILE__) . '/../../functions/assets/GoogleAuthenticator/GoogleAuthenticator.php'); + +// fetch user +$user_obj = $Database->getObjectQuery("SELECT * FROM `users` WHERE `email` = ?", [$pending['email']]); +if (!$user_obj || empty($user_obj->totp_enabled)) { + unset($_SESSION['2fa_pending'], $_SESSION['2fa_attempts']); + $Result->show("danger", _("Invalid 2FA state. Please log in again."), true); +} + +// decrypt secret +$secret = $User->totp_decrypt((string)$user_obj->totp_secret, (int)$user_obj->t_id); +if ($secret === '') { + $Result->show("danger", _("Could not read 2FA secret. Please contact an administrator."), true); +} + +// verify — allow ±1 time window (30 s) to accommodate clock drift +$ga = new PHPGangsta_GoogleAuthenticator(); +if (!$ga->verifyCode($secret, $code, 1)) { + $Result->show("danger", _("Invalid verification code."), true); +} + +// success — complete the session +session_regenerate_id(true); +$_SESSION['username'] = $pending['email']; +unset($_SESSION['2fa_pending'], $_SESSION['2fa_attempts']); + +// restore language +if (!empty($user_obj->lang_id)) { + $_SESSION['lang_id'] = (int)$user_obj->lang_id; +} else { + unset($_SESSION['lang_id']); +} + +$redirect = $pending['redirect'] ?? '/'; + +$Log->write("user", $user_obj->id, $user_obj->t_id, $user_obj->id, "login", false, "User has logged in (2FA verified)"); + +print $Result->show("success", _("Login successful")); +print "
" . htmlspecialchars($redirect, ENT_QUOTES, 'UTF-8') . "
"; diff --git a/route/ajax/totp-setup.php b/route/ajax/totp-setup.php new file mode 100644 index 0000000..a297f32 --- /dev/null +++ b/route/ajax/totp-setup.php @@ -0,0 +1,39 @@ +validate_session(false, false, true); +$User->validate_csrf_token(); + +require(dirname(__FILE__) . '/../../functions/assets/GoogleAuthenticator/GoogleAuthenticator.php'); + +$ga = new PHPGangsta_GoogleAuthenticator(); +$secret = $ga->createSecret(32); + +// store encrypted, but NOT yet enabled +$stored = $User->totp_encrypt($secret, (int)$user->t_id); +try { + $Database->runQuery( + "UPDATE `users` SET `totp_secret` = ?, `totp_enabled` = 0 WHERE `id` = ?", + [$stored, (int)$user->id] + ); +} catch (Exception $e) { + $Result->show("danger", _("Error saving 2FA secret."), true); +} + +// build otpauth URI +$issuer = 'php-ssl'; +$account = rawurlencode($issuer) . ':' . rawurlencode($user->email); +$uri = 'otpauth://totp/' . $account + . '?secret=' . rawurlencode($secret) + . '&issuer=' . rawurlencode($issuer) + . '&algorithm=SHA1&digits=6&period=30'; + +header('Content-Type: application/json'); +print json_encode(['status' => 'ok', 'uri' => $uri]); diff --git a/route/common/checks.php b/route/common/checks.php index c7b2b6e..488cb9f 100644 --- a/route/common/checks.php +++ b/route/common/checks.php @@ -2,9 +2,10 @@ // Submodule presence checks — shown to all logged-in users $submodules = [ - 'Net_DNS2' => ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/Net/DNS2.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'], + 'Net_DNS2' => ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/Net/DNS2.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'], + 'GoogleAuthenticator' => ['path' => __DIR__ . '/../../functions/assets/GoogleAuthenticator/GoogleAuthenticator.php', 'url' => 'https://github.com/PHPGangsta/GoogleAuthenticator'], ]; $missing_submodules = []; foreach ($submodules as $name => $info) { diff --git a/route/common/header.php b/route/common/header.php index 88c5c9e..99cae84 100644 --- a/route/common/header.php +++ b/route/common/header.php @@ -99,7 +99,7 @@
- + 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/users/edit-submit.php b/route/modals/users/edit-submit.php index dd588c2..a9e22e0 100644 --- a/route/modals/users/edit-submit.php +++ b/route/modals/users/edit-submit.php @@ -111,6 +111,11 @@ } # language preference (NULL = use tenant default) $update['lang_id'] = (!empty($_POST['lang_id']) && is_numeric($_POST['lang_id'])) ? (int)$_POST['lang_id'] : null; + # disable 2FA — only when the user currently has it enabled + if (!empty($_POST['disable_totp']) && !empty($edit_user->totp_enabled)) { + $update['totp_enabled'] = 0; + $update['totp_secret'] = null; + } } } @@ -128,7 +133,7 @@ # edit: check for actual changes if($_POST['action']==="edit") { $is_change = false; - foreach(['name', 'email', 'permission', 'days', 'days_expired', 'changePass', 'disabled', 'force_passkey', 'lang_id'] as $k) { + foreach(['name', 'email', 'permission', 'days', 'days_expired', 'changePass', 'disabled', 'force_passkey', 'lang_id', 'totp_enabled', 'totp_secret'] as $k) { if(isset($update[$k]) && $edit_user->$k != $update[$k]) { $is_change = true; break; diff --git a/route/modals/users/edit.php b/route/modals/users/edit.php index ae47913..1b7c0ec 100644 --- a/route/modals/users/edit.php +++ b/route/modals/users/edit.php @@ -183,6 +183,19 @@ $content[] = " "; $content[] = ""; + // disable 2FA — only on edit, only when enabled + if (!empty($edit_user->totp_enabled)) { + $content[] = ""; + $content[] = " "._("2FA").""; + $content[] = " "; + $content[] = " "; + $content[] = " "; + $content[] = ""; + } + // language — only on edit; query translations from DB $all_langs = []; try { diff --git a/route/profile/index.php b/route/profile/index.php new file mode 100644 index 0000000..688f265 --- /dev/null +++ b/route/profile/index.php @@ -0,0 +1,17 @@ + 'pf-passkeys', 'notifications' => 'pf-notifications', 'activity' => 'pf-logs', '2fa' => 'pf-2fa']; + $_profile_default_tab = $_profile_tab_map[$_params['app']]; + include(dirname(__FILE__)."/../user/profile/index.php"); +} +else { + $Common->save_error("Invalid profile item"); + require(dirname(__FILE__)."/../error/404.php"); + die(); +} diff --git a/route/user/index.php b/route/user/index.php index 0cc4123..2cedc3d 100644 --- a/route/user/index.php +++ b/route/user/index.php @@ -32,8 +32,8 @@ include("profile/index.php"); } -elseif (in_array(@$_params['app'], ['passkeys', 'notifications', 'activity'])) { - $_profile_tab_map = ['passkeys' => 'pf-passkeys', 'notifications' => 'pf-notifications', 'activity' => 'pf-logs']; +elseif (in_array(@$_params['app'], ['passkeys', 'notifications', 'activity', '2fa'])) { + $_profile_tab_map = ['passkeys' => 'pf-passkeys', 'notifications' => 'pf-notifications', 'activity' => 'pf-logs', '2fa' => 'pf-2fa']; $_profile_default_tab = $_profile_tab_map[$_params['app']]; include("profile/index.php"); } diff --git a/route/user/profile/2fa.php b/route/user/profile/2fa.php new file mode 100644 index 0000000..df72972 --- /dev/null +++ b/route/user/profile/2fa.php @@ -0,0 +1,180 @@ + + +
+

+ + + + + + +

+
+ +
+ +force_passkey)): ?> +
+
+ + +
+
+ + +totp_enabled)): ?> + + +
+ + +
+ +

+ + + + + + + + +

+ +
+ +
+ + + + + + +
+ + diff --git a/route/user/profile/index.php b/route/user/profile/index.php index e6b3c3b..7d95fce 100644 --- a/route/user/profile/index.php +++ b/route/user/profile/index.php @@ -11,7 +11,7 @@ # determine active tab from route $_active_tab = $_profile_default_tab ?? 'pf-account'; -$_base_url = '/' . htmlspecialchars($user->href) . '/user/'; +$_base_url = '/' . htmlspecialchars($user->href) . '/profile/'; function _tab_active(string $tab, string $active): string { return $tab === $active ? ' active' : ''; @@ -37,12 +37,21 @@ function _tab_active(string $tab, string $active): string {

+
+ +
+
diff --git a/route/user/profile/passkeys.php b/route/user/profile/passkeys.php index 907ebfa..85280bc 100644 --- a/route/user/profile/passkeys.php +++ b/route/user/profile/passkeys.php @@ -42,7 +42,7 @@
-
+
diff --git a/route/users/index.php b/route/users/index.php index 0c700a4..8ced621 100644 --- a/route/users/index.php +++ b/route/users/index.php @@ -129,6 +129,9 @@ class="table table-hover align-middle table-md" } else { $login_html = ""; } + if (!empty($u->totp_enabled) && !$force_pk) { + $login_html .= " 2FA"; + } // Actions $actions = ""; From bd63a6b8cf654af674f54f2a18cc29d04566bf1e Mon Sep 17 00:00:00 2001 From: riversdev0 Date: Thu, 28 May 2026 15:34:28 -0500 Subject: [PATCH 23/34] Fixing that same string-to-int comparison again --- functions/classes/class.User.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/functions/classes/class.User.php b/functions/classes/class.User.php index 140ad39..b1a3176 100644 --- a/functions/classes/class.User.php +++ b/functions/classes/class.User.php @@ -210,6 +210,7 @@ public function save_current_user() $this->errors[] = $e->getMessage(); $this->result_die(); } + $user->admin = strval($user->admin); // save if ($user != null) { $this->user = $user; @@ -505,7 +506,7 @@ public function validate_session($require_admin = false, $is_popup = false, $is_ } } // not admin - elseif ($require_admin && $this->user->admin != 1) { + elseif ($require_admin && $this->user->admin != "1") { if ($is_popup && !$is_popup_result) { global $Modal; $Modal->modal_print("Error", "
" . _("Administrative privileges required") . ".
", "", false, "danger"); @@ -755,4 +756,4 @@ function setResult($Result): self $this->Result = $Result; return $this; } -} \ No newline at end of file +} From 52dd9b465fe33a759de3a2a63b2349b359ae0722 Mon Sep 17 00:00:00 2001 From: Miha Petkovsek Date: Mon, 1 Jun 2026 12:47:03 +0200 Subject: [PATCH 24/34] Bugfixes --- .gitmodules | 1 - CLAUDE.md | 14 +++--- functions/classes/class.AXFR.php | 43 ++++++++++++------ functions/classes/class.PDO.php | 8 ++-- functions/classes/class.SSL.php | 2 +- functions/classes/class.URL.php | 17 ++++--- functions/classes/class.User.php | 4 +- functions/config.menu.php | 2 + route/ajax/0014_add_cas_table.sql | 14 ------ route/ajax/ca/create.php | 4 +- route/ajax/ca/delete.php | 2 +- route/ajax/ca/download.php | 2 +- route/ajax/ca/import.php | 2 +- route/ajax/ca/update-flags.php | 4 +- route/ajax/cert-download.php | 2 +- route/ajax/chain-download.php | 2 +- route/ajax/create.php | 6 +-- route/ajax/csr/cert-upload.php | 4 +- route/ajax/csr/delete.php | 2 +- route/ajax/csr/download.php | 2 +- route/ajax/csr/generate.php | 4 +- route/ajax/csr/import.php | 6 +-- route/ajax/csr/reject.php | 2 +- route/ajax/csr/sign.php | 4 +- route/ajax/csr/template-delete.php | 2 +- route/ajax/csr/templates.php | 6 +-- route/ajax/csrs.php | 6 +-- route/ajax/details.php | 2 +- route/ajax/passkey/delete.php | 2 +- route/ajax/pkey-delete.php | 2 +- route/ajax/pkey-download.php | 2 +- route/ajax/pkey-export.php | 2 +- route/ajax/pkey-upload.php | 2 +- route/ajax/testssl-action.php | 2 +- route/ajax/testssl-export.php | 2 +- route/ajax/zone-hosts.php | 2 +- route/ca-certificates/table.php | 4 +- route/cas/ca-certificates/ca-certificate.php | 2 +- route/cas/ca-certificates/index.php | 4 +- route/cas/index.php | 4 +- route/cas/table.php | 4 +- route/certificates/cas.php | 4 +- route/common/checks.php | 48 ++++++++++++++++---- route/csrs/_csr_list.php | 8 ++-- route/csrs/csr-generate.php | 4 +- route/csrs/csr-import.php | 4 +- route/csrs/templates/index.php | 8 ++-- route/dashboard/card-latest-certificates.php | 6 +-- route/dashboard/card-latest-hosts.php | 6 +-- route/dashboard/card-top-cas.php | 8 ++-- route/modals/agents/edit-submit.php | 4 +- route/modals/agents/edit.php | 4 +- route/modals/agents/refresh.php | 2 +- route/modals/cas/ca-create.php | 4 +- route/modals/cas/create.php | 12 ++--- route/modals/cas/import.php | 6 +-- route/modals/cas/index.php | 6 +-- route/modals/cas/view.php | 8 ++-- route/modals/certificates/import.php | 2 +- route/modals/certificates/pkey-export.php | 2 +- route/modals/certificates/pkey-upload.php | 2 +- route/modals/cron/edit-submit.php | 2 +- route/modals/cron/edit.php | 2 +- route/modals/csr-templates/edit-submit.php | 4 +- route/modals/csr-templates/edit.php | 4 +- route/modals/csrs/create.php | 10 ++-- route/modals/csrs/csrs.php | 6 +-- route/modals/csrs/details.php | 4 +- route/modals/csrs/import.php | 6 +-- route/modals/csrs/sign.php | 4 +- route/modals/csrs/upload-cert.php | 4 +- route/modals/domains/edit-submit.php | 6 +-- route/modals/domains/edit.php | 4 +- route/modals/ignored/edit-submit.php | 4 +- route/modals/ignored/edit.php | 6 +-- route/modals/logs/show.php | 2 +- route/modals/logs/truncate-submit.php | 2 +- route/modals/logs/truncate.php | 2 +- route/modals/portgroups/edit-submit.php | 4 +- route/modals/portgroups/edit.php | 4 +- route/modals/tenants/edit.php | 4 +- route/modals/testssl/request-host.php | 2 +- route/modals/testssl/request-submit.php | 2 +- route/modals/testssl/request.php | 2 +- route/modals/users/edit-submit.php | 2 +- route/modals/users/edit.php | 2 +- route/modals/zones/edit.php | 4 +- route/testssl/index.php | 2 +- route/testssl/report.php | 2 +- route/testssl/testssl-action.php | 2 +- route/testssl/zone-hosts.php | 2 +- route/user/index.php | 2 +- route/users/index.php | 2 +- route/users/user-details.php | 2 +- route/users/user/user-details.php | 2 +- route/validate/index.php | 2 +- 96 files changed, 253 insertions(+), 214 deletions(-) delete mode 100644 route/ajax/0014_add_cas_table.sql diff --git a/.gitmodules b/.gitmodules index 241ce8d..f0c4256 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,7 +3,6 @@ [submodule "functions/assets/Net_DNS2"] path = functions/assets/Net_DNS2 url = https://github.com/mikepultz/netdns2.git - branch = version-1.5.x [submodule "functions/assets/PHPMailer"] path = functions/assets/PHPMailer 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/functions/classes/class.AXFR.php b/functions/classes/class.AXFR.php index e06a82d..99447f6 100644 --- a/functions/classes/class.AXFR.php +++ b/functions/classes/class.AXFR.php @@ -118,13 +118,19 @@ public function __construct(Database_PDO $Database) $this->Database = $Database; // Results $this->Result = new Result(); - // include Net_DNS2 v1.x — v2.x is not compatible with PHP 7.4 - $net_dns2 = dirname(__FILE__) . "/../assets/Net_DNS2/Net/DNS2.php"; - if (!file_exists($net_dns2)) { - throw new Exception(_("Net_DNS2 v1.x is required for AXFR (v2.x is not compatible with PHP 7.4). Run: git -C functions/assets/Net_DNS2 checkout v1.5.5")); + // 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")); } - ini_set("include_path", dirname(__FILE__) . "/../assets/Net_DNS2"); - require_once($net_dns2); + 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; + } + } + }); } /** @@ -144,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; } @@ -167,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, ]); } @@ -266,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]); } } @@ -282,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]); } } @@ -349,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.PDO.php b/functions/classes/class.PDO.php index 0d7e961..ddc94f9 100644 --- a/functions/classes/class.PDO.php +++ b/functions/classes/class.PDO.php @@ -986,7 +986,7 @@ public function fetch_all_objects($table = null, $sortField = "id", $sortAsc = t * @param mixed $value * @return object|false */ - public function fetch_object($table = null, $method = null, $value) + public function fetch_object($table, $method, $value) { # null table if (is_null($table) || strlen($table) == 0) @@ -1078,7 +1078,7 @@ public function fetch_unique_items($table, $field) * @param mixed $values * @return bool */ - public function create_object($table = null, $values) + public function create_object($table, $values) { # null table if (is_null($table) || strlen($table) == 0) @@ -1114,7 +1114,7 @@ public function create_object($table = null, $values) * @param mixed $values * @return bool */ - public function update_object($table = null, $values, $key = "id") + public function update_object($table, $values, $key = "id") { # null table if (is_null($table) || strlen($table) == 0) @@ -1154,7 +1154,7 @@ public function update_object($table = null, $values, $key = "id") * @param mixed $id * @return bool */ - public function remove_object($table = null, $id) + public function remove_object($table, $id) { # null table if (is_null($table) || strlen($table) == 0) diff --git a/functions/classes/class.SSL.php b/functions/classes/class.SSL.php index 4367a33..880e14a 100644 --- a/functions/classes/class.SSL.php +++ b/functions/classes/class.SSL.php @@ -436,7 +436,7 @@ private function process_fetch_result($errno, $errstr, $execution_time, $port, $ * @param string $execution_time * @return int|void */ - public function update_db_certificate($certificate = [], $tenant_id = 0, $zone_id = 0, $execution_time) + public function update_db_certificate($certificate, $tenant_id, $zone_id, $execution_time) { try { diff --git a/functions/classes/class.URL.php b/functions/classes/class.URL.php index f9c1301..c061cd3 100644 --- a/functions/classes/class.URL.php +++ b/functions/classes/class.URL.php @@ -20,12 +20,12 @@ class URL extends Common */ public function __construct() { - // parse url - $uri = parse_url($_SERVER['REQUEST_URI']); + // parse url (REQUEST_URI is absent in CLI/cron context) + $uri = parse_url($_SERVER['REQUEST_URI'] ?? '/'); // process query first to prevent overriding - $this->process_query(@$uri['query']); + $this->process_query($uri['query'] ?? ''); // process path - $this->process_path(@$uri['path']); + $this->process_path($uri['path'] ?? '/'); // validate $this->validate_requested_uri(); // search @@ -43,6 +43,7 @@ public function __construct() */ private function process_query($uri = "") { + if ($uri === '') return; // split by & $uri_arr = explode("&", $uri); // loop @@ -50,7 +51,7 @@ private function process_query($uri = "") // split $tmp = explode("=", $line); // save - $this->uri_params[$tmp[0]] = strip_tags($tmp[1]); + $this->uri_params[$tmp[0]] = strip_tags($tmp[1] ?? ''); } } @@ -130,12 +131,16 @@ private function process_search_string () { */ public function validate_path($user) { + // no user (CLI/cron context) + if (is_null($user)) { + return false; + } // admin if ($user->admin == "1") { return true; } // non-admin - tenant check - if ($user->href == $this->uri_params['tenant']) { + if (($this->uri_params['tenant'] ?? '') !== '' && $user->href == $this->uri_params['tenant']) { return true; } // fail diff --git a/functions/classes/class.User.php b/functions/classes/class.User.php index 140ad39..eaf7dbe 100644 --- a/functions/classes/class.User.php +++ b/functions/classes/class.User.php @@ -298,7 +298,7 @@ public function authenticate($email, $password, $domain_id) * @param object $domain * @return void */ - public function authenticate_local(string $email = "", string $password = "", object $domain) + public function authenticate_local(string $email, string $password, object $domain) { // fetch user details $user = $this->fetch_user_details($email); @@ -364,7 +364,7 @@ public function authenticate_local(string $email = "", string $password = "", ob * @param mixed $password * @return void */ - public function authenticate_ad($username = "", $password = "", object $domain) + public function authenticate_ad($username, $password, object $domain) { // connect to ad and init search $AD = new ADsync($this->Database, $domain); diff --git a/functions/config.menu.php b/functions/config.menu.php index 7f749bb..b5434c0 100644 --- a/functions/config.menu.php +++ b/functions/config.menu.php @@ -1,5 +1,7 @@ [], 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/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..8168d2d 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]); 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..a83a51d 100644 --- a/route/cas/table.php +++ b/route/cas/table.php @@ -41,7 +41,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 +74,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 " "; 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/common/checks.php b/route/common/checks.php index 488cb9f..bb246e8 100644 --- a/route/common/checks.php +++ b/route/common/checks.php @@ -1,17 +1,49 @@ ['path' => __DIR__ . '/../../functions/assets/Net_DNS2/Net/DNS2.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'], - 'GoogleAuthenticator' => ['path' => __DIR__ . '/../../functions/assets/GoogleAuthenticator/GoogleAuthenticator.php', 'url' => 'https://github.com/PHPGangsta/GoogleAuthenticator'], +// 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 ""; } 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-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/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 . "
" . _("No CSRs found.") . "
" . $url_items['tenants']['icon'] . " " . _("Tenant") . " " . $tenant_name . "
" . _("No templates found.") . "
" . $url_items['tenants']['icon'] . " " . _("Tenant") . " " . $tenant_name . "
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 = ""; $content .= ""; // Tenant selector — admin only -if ($user->admin === "1") { +if ($user->admin == "1") { $all_tenants = $Tenants->get_all(); $content .= "
" . _("Tenant") . " *"; $content .= "
" . _("Parent CA") . ""; $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/modals/cas/view.php b/route/modals/cas/view.php index 702c16b..cd2cf3f 100644 --- a/route/modals/cas/view.php +++ b/route/modals/cas/view.php @@ -15,7 +15,7 @@ } // Fetch CA (tenant-scoped) -if ($user->admin === "1") { +if ($user->admin == "1") { $ca = $Database->getObjectsQuery( "SELECT ca.*, pk.private_key_enc IS NOT NULL AND pk.private_key_enc != '' AS has_pkey, pca.name AS parent_ca_name @@ -92,13 +92,13 @@ function ca_fmt_dn($dn) { $content .= ""; $content .= $row(_("Display name"), "" . htmlspecialchars($ca->name) . ""); -if ($user->admin === "1") { +if ($user->admin == "1") { $all_tenants = $Tenants->get_all(); $tname = isset($all_tenants[(int)$ca->t_id]) ? htmlspecialchars($all_tenants[(int)$ca->t_id]->name) : $ca->t_id; $content .= $row(_("Tenant"), $tname); } $content .= $row(_("Parent CA"), $ca->parent_ca_name ? htmlspecialchars($ca->parent_ca_name) : "" . _("None (self-signed root)") . ""); -if ($user->admin === "1" || (int)$user->permission >= 3) { +if ($user->admin == "1" || (int)$user->permission >= 3) { $pkey_val = $ca->has_pkey ? "" . _("Stored — can sign") . "" . "" @@ -142,7 +142,7 @@ function ca_fmt_dn($dn) { $content .= "
" . _("Tenant") . " *"; $content .= "
" . _("No certificate authorities found.") . "
" . $url_items['tenants']['icon'] . " " . _("Tenant") . " " . $tenant_name . "
" . _("General") . "
"; // Notification flags (admins and permission >= 3 can edit) -$can_edit_flags = ($user->admin === "1" || (int)$user->permission >= 3); +$can_edit_flags = ($user->admin == "1" || (int)$user->permission >= 3); $chk_updates = $ca->ignore_updates ? " checked" : ""; $chk_expiry = $ca->ignore_expiry ? " checked" : ""; diff --git a/route/modals/certificates/import.php b/route/modals/certificates/import.php index 0196a87..aea6170 100644 --- a/route/modals/certificates/import.php +++ b/route/modals/certificates/import.php @@ -137,7 +137,7 @@ // Check if encryption is configured for at least one tenant the user can access global $private_key_encryption_key; - $pkey_enc_available = $user->admin === "1" + $pkey_enc_available = $user->admin == "1" ? !empty($private_key_encryption_key) : !empty($private_key_encryption_key[(int)$user->t_id]); diff --git a/route/modals/certificates/pkey-export.php b/route/modals/certificates/pkey-export.php index 1397251..9e3b3ed 100644 --- a/route/modals/certificates/pkey-export.php +++ b/route/modals/certificates/pkey-export.php @@ -16,7 +16,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/modals/certificates/pkey-upload.php b/route/modals/certificates/pkey-upload.php index 2644281..cca40c6 100644 --- a/route/modals/certificates/pkey-upload.php +++ b/route/modals/certificates/pkey-upload.php @@ -17,7 +17,7 @@ } // Fetch cert — enforce tenant access -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/modals/cron/edit-submit.php b/route/modals/cron/edit-submit.php index e78fcfa..046a546 100644 --- a/route/modals/cron/edit-submit.php +++ b/route/modals/cron/edit-submit.php @@ -31,7 +31,7 @@ $tenant = $Tenants->get_tenant_by_href ($cronjob->t_id); # not allowed -if($user->admin !== "1" && $user->t_id!=$cronjob->t_id) +if($user->admin != "1" && $user->t_id!=$cronjob->t_id) $Result->show("danger", _("Admin privileges required").".", true, false, false, false); diff --git a/route/modals/cron/edit.php b/route/modals/cron/edit.php index c71c622..6bb7fa5 100644 --- a/route/modals/cron/edit.php +++ b/route/modals/cron/edit.php @@ -25,7 +25,7 @@ $title = _("Edit cronjob"); # tenant validation -if($user->admin !== "1" && $user->t_id!=$cronjob->t_id) { +if($user->admin != "1" && $user->t_id!=$cronjob->t_id) { # content $content = []; $content[] = $Result->show("danger", _("Admin user required"), false, false, true); diff --git a/route/modals/csr-templates/edit-submit.php b/route/modals/csr-templates/edit-submit.php index 4db4bd8..ec86d79 100644 --- a/route/modals/csr-templates/edit-submit.php +++ b/route/modals/csr-templates/edit-submit.php @@ -32,7 +32,7 @@ $Result->show("danger", _("Template name is required."), true, false, false, false); } -if ($user->admin === "1" && !empty($_POST_safe['t_id'])) { +if ($user->admin == "1" && !empty($_POST_safe['t_id'])) { $t_id = (int)$_POST_safe['t_id']; if (!$Database->getObject("tenants", $t_id)) { $Result->show("danger", _("Invalid tenant."), true, false, false, false); @@ -58,7 +58,7 @@ try { if ($tpl_id > 0) { // Update — verify ownership - if ($user->admin === "1") { + if ($user->admin == "1") { $existing = $Database->getObjectQuery("SELECT id FROM csr_templates WHERE id = ?", [$tpl_id]); } else { $existing = $Database->getObjectQuery("SELECT id FROM csr_templates WHERE id = ? AND t_id = ?", [$tpl_id, $t_id]); diff --git a/route/modals/csr-templates/edit.php b/route/modals/csr-templates/edit.php index bba2608..e3dd7e5 100644 --- a/route/modals/csr-templates/edit.php +++ b/route/modals/csr-templates/edit.php @@ -13,7 +13,7 @@ $tpl = null; if ($tpl_id > 0) { - if ($user->admin === "1") { + if ($user->admin == "1") { $tpl = $Database->getObjectQuery("SELECT * FROM csr_templates WHERE id = ?", [$tpl_id]); } else { $tpl = $Database->getObjectQuery("SELECT * FROM csr_templates WHERE id = ? AND t_id = ?", [$tpl_id, $user->t_id]); @@ -35,7 +35,7 @@ $content .= ""; // Tenant selector — admin only -if ($user->admin === "1") { +if ($user->admin == "1") { $all_tenants = $Tenants->get_all(); $tpl_t_id = $tpl ? (int)$tpl->t_id : 0; $content .= ""; $rows .= ""; -if ($user->admin === "1" || (int)$user->permission >= 3) { +if ($user->admin == "1" || (int)$user->permission >= 3) { $dl_icon = ''; if (!empty($csr->pkey_id)) { $pkey_val = "" . _("Stored") . "" diff --git a/route/modals/csrs/import.php b/route/modals/csrs/import.php index 4d49176..846c307 100644 --- a/route/modals/csrs/import.php +++ b/route/modals/csrs/import.php @@ -11,7 +11,7 @@ $all_zones = $Zones->get_all(); $has_zones = !empty($all_zones); $zone_options = ''; -if ($user->admin === "1") { +if ($user->admin == "1") { $by_tenant = []; foreach ($all_zones as $z) { $by_tenant[$z->tenant_name][] = $z; @@ -32,7 +32,7 @@ $content = ""; // Tenant selector — admin only -if ($user->admin === "1") { +if ($user->admin == "1") { $all_tenants = $Tenants->get_all(); $content .= "
"; $content .= ""; @@ -152,7 +152,7 @@ function togglePassphrase(val) { } var payload = { csr_pem: csrPem }; - admin === "1"): ?> + admin == "1"): ?> payload.t_id = parseInt(document.getElementById('import-tenant-id').value); if (keyPem) { diff --git a/route/modals/csrs/sign.php b/route/modals/csrs/sign.php index 8833119..4b1b5c5 100644 --- a/route/modals/csrs/sign.php +++ b/route/modals/csrs/sign.php @@ -14,7 +14,7 @@ exit; } -if ($user->admin === "1") { +if ($user->admin == "1") { $csr = $Database->getObjectQuery("SELECT * FROM csrs WHERE id = ?", [$csr_id]); } else { $csr = $Database->getObjectQuery("SELECT * FROM csrs WHERE id = ? AND t_id = ?", [$csr_id, $user->t_id]); @@ -118,7 +118,7 @@ $content .= "" . _("No zones available.") . ""; } else { $content .= ""; -if ($user->admin === "1") { +if ($user->admin == "1") { $by_tenant = []; foreach ($all_zones as $z) { $by_tenant[$z->t_id][] = $z; } foreach ($all_tenants as $t) { diff --git a/route/modals/domains/edit-submit.php b/route/modals/domains/edit-submit.php index 826c946..79ecffa 100644 --- a/route/modals/domains/edit-submit.php +++ b/route/modals/domains/edit-submit.php @@ -25,7 +25,7 @@ $Result->show("danger", _("Invalid tenant").".", true, false, false, false); # tenant access - non-admins can only manage their own tenant -if($user->admin !== "1" && $user->t_id !== $tenant->id) +if($user->admin != "1" && $user->t_id !== $tenant->id) $Result->show("danger", _("Access denied").".", true, false, false, false); # fetch domain to edit/delete @@ -93,7 +93,7 @@ $update['autocreateGroup'] = $_POST['autocreateGroup']; # admin credentials - only update if user is admin - if($user->admin === "1") { + if($user->admin == "1") { $update['adminUsername'] = strlen($_POST['adminUsername']) > 0 ? $_POST['adminUsername'] : null; # only update password if provided if(strlen($_POST['adminPassword']) > 0) { @@ -121,7 +121,7 @@ break; } } - if($user->admin === "1") { + if($user->admin == "1") { if(isset($update['adminUsername']) && $domain->adminUsername != $update['adminUsername']) $is_change = true; if(isset($update['adminPassword'])) $is_change = true; } diff --git a/route/modals/domains/edit.php b/route/modals/domains/edit.php index 7366839..935783b 100644 --- a/route/modals/domains/edit.php +++ b/route/modals/domains/edit.php @@ -34,7 +34,7 @@ $btn_text = ""; } # tenant validation -elseif($user->admin !== "1" && $user->t_id!=$tenant->id) { +elseif($user->admin != "1" && $user->t_id!=$tenant->id) { $content = []; $content[] = $Result->show("danger", _("Admin user required"), false, false, true); $header_class = "danger"; @@ -143,7 +143,7 @@ $content[] = ""; // Admin credentials - only for admins - if($user->admin === "1") { + if($user->admin == "1") { $content[] = "
"; $content[] = ""; $content[] = " "; diff --git a/route/modals/ignored/edit-submit.php b/route/modals/ignored/edit-submit.php index 664b7b7..b648e5c 100644 --- a/route/modals/ignored/edit-submit.php +++ b/route/modals/ignored/edit-submit.php @@ -25,7 +25,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 @@ if($issuer===null) $Result->show("danger", _("Invalid issuer").".", true, false, false, false); // not allowed - if($user->admin !== "1" && $user->t_id!=$issuer->t_id) + if($user->admin != "1" && $user->t_id!=$issuer->t_id) $Result->show("danger", _("Admin privileges required").".", true, false, false, false); } diff --git a/route/modals/ignored/edit.php b/route/modals/ignored/edit.php index 84893ed..2311649 100644 --- a/route/modals/ignored/edit.php +++ b/route/modals/ignored/edit.php @@ -37,7 +37,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); @@ -80,7 +80,7 @@ $content[] = ""; $content[] = "
" . _("Tenant") . " *"; diff --git a/route/modals/csrs/create.php b/route/modals/csrs/create.php index a26cfba..28d717e 100644 --- a/route/modals/csrs/create.php +++ b/route/modals/csrs/create.php @@ -17,7 +17,7 @@ $renew_t_id = 0; if ($renew_csr_id > 0) { - if ($user->admin === "1") { + if ($user->admin == "1") { $renew_csr = $Database->getObjectQuery("SELECT * FROM csrs WHERE id = ?", [$renew_csr_id]); } else { $renew_csr = $Database->getObjectQuery("SELECT * FROM csrs WHERE id = ? AND t_id = ?", [$renew_csr_id, $user->t_id]); @@ -53,7 +53,7 @@ } if ($renew_cert_id > 0) { - if ($user->admin === "1") { + if ($user->admin == "1") { $renew_cert = $Database->getObjectQuery("SELECT * FROM certificates WHERE id = ?", [$renew_cert_id]); } else { $renew_cert = $Database->getObjectQuery("SELECT * FROM certificates WHERE id = ? AND t_id = ?", [$renew_cert_id, $user->t_id]); @@ -142,7 +142,7 @@ } // Load templates for this tenant -if ($user->admin === "1") { +if ($user->admin == "1") { $templates = $Database->getObjectsQuery("SELECT * FROM csr_templates ORDER BY name"); } else { $templates = $Database->getObjectsQuery("SELECT * FROM csr_templates WHERE t_id = ? ORDER BY name", [$user->t_id]); @@ -191,7 +191,7 @@ }; // Tenant selector — admin only -if ($user->admin === "1") { +if ($user->admin == "1") { $all_tenants_for_form = $Tenants->get_all(); $content .= "
" . _("Tenant") . " *"; $content .= "
" . _("Key & status") . "
" . _("Key") . "" . htmlspecialchars($key_label) . "

"._("Admin credentials (admin only)")."
"._("Admin username")."
"; // tenant - admin - if($user->admin === "1" && $_GET['action']=="add") { + if($user->admin == "1" && $_GET['action']=="add") { $content[] = ""; $content[] = " "; $content[] = " "; @@ -94,7 +94,7 @@ $content[] = " "; $content[] = " "; $content[] = " "; - if($user->admin !== "1" || $_GET['action']!=="add") + if($user->admin != "1" || $_GET['action']!=="add") $content[] = " "; $content[] = " "; $content[] = " "; # tenant (admin only) -if($user->admin === "1") { +if($user->admin == "1") { $tenant = $Database->getObject("tenants", $log->object_t_id); $t_name = $tenant ? htmlspecialchars($tenant->name, ENT_QUOTES, 'UTF-8') : "-"; $content[] = ""; diff --git a/route/modals/logs/truncate-submit.php b/route/modals/logs/truncate-submit.php index 91a2e47..8075d35 100644 --- a/route/modals/logs/truncate-submit.php +++ b/route/modals/logs/truncate-submit.php @@ -36,7 +36,7 @@ } # validate ids for non-admin - if ($user->admin !== "1") { + if ($user->admin != "1") { foreach ($ids as $id) { if ($user->t_id !== $id) { throw new Exception('Invalid tenant'); diff --git a/route/modals/logs/truncate.php b/route/modals/logs/truncate.php index 19bea2b..851bef1 100644 --- a/route/modals/logs/truncate.php +++ b/route/modals/logs/truncate.php @@ -41,7 +41,7 @@ $all_tenants = $Tenants->get_all () ; // select tenants - if($user->admin === "1") { + if($user->admin == "1") { $content[] = _('Select tenants for which you want to truncate logs:')."

"; $content[] = ""; $content[] = ""; diff --git a/route/modals/portgroups/edit-submit.php b/route/modals/portgroups/edit-submit.php index 951db7b..e509ce7 100644 --- a/route/modals/portgroups/edit-submit.php +++ b/route/modals/portgroups/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 ($port_group->t_id); # not allowed - if($user->admin !== "1" && $user->t_id!=$port_group->t_id) + if($user->admin != "1" && $user->t_id!=$port_group->t_id) $Result->show("danger", _("Admin privileges required").".", true, false, false, false); } diff --git a/route/modals/portgroups/edit.php b/route/modals/portgroups/edit.php index a27a94c..19624a5 100644 --- a/route/modals/portgroups/edit.php +++ b/route/modals/portgroups/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[] = " "; print ""; @@ -105,6 +107,10 @@ print " "; print ""; }
"._("Tenant")."".$tenant->name.""; diff --git a/route/modals/logs/show.php b/route/modals/logs/show.php index cdefc73..66e4339 100644 --- a/route/modals/logs/show.php +++ b/route/modals/logs/show.php @@ -51,7 +51,7 @@ $content[] = "
"._("User")."".$u_name."
"._("Tenant")."".$t_name."
"; diff --git a/route/modals/tenants/edit.php b/route/modals/tenants/edit.php index 5f6a6c1..de634fc 100644 --- a/route/modals/tenants/edit.php +++ b/route/modals/tenants/edit.php @@ -31,7 +31,7 @@ $btn_text = ""; } # admin -elseif($user->admin !== "1") { +elseif($user->admin != "1") { # content $content = []; $content[] = $Result->show("danger", _("Admin user required"), false, false, true); @@ -66,7 +66,7 @@ $content[] = " "; $content[] = " "; $content[] = " "; - if($user->admin !== "1" || $_GET['action']!=="add") + if($user->admin != "1" || $_GET['action']!=="add") $content[] = " "; if($_GET['action']=="delete") $content[] = " "; diff --git a/route/modals/testssl/request-host.php b/route/modals/testssl/request-host.php index bf0aa47..aa217d3 100644 --- a/route/modals/testssl/request-host.php +++ b/route/modals/testssl/request-host.php @@ -7,7 +7,7 @@ $tid = isset($_GET['t_id']) && is_numeric($_GET['t_id']) ? (int)$_GET['t_id'] : (int)$user->t_id; // Non-admins scoped to own tenant -if ($user->admin !== "1") { $tid = (int)$user->t_id; } +if ($user->admin != "1") { $tid = (int)$user->t_id; } $content = []; $content[] = ""; diff --git a/route/modals/testssl/request-submit.php b/route/modals/testssl/request-submit.php index 66612e1..36f47e1 100644 --- a/route/modals/testssl/request-submit.php +++ b/route/modals/testssl/request-submit.php @@ -3,7 +3,7 @@ $User->validate_session(false, true, false); $User->validate_csrf_token(); -$is_admin = $user->admin === "1"; +$is_admin = $user->admin == "1"; $hostname = trim($_POST['hostname'] ?? ''); $port = isset($_POST['port']) && is_numeric($_POST['port']) ? (int)$_POST['port'] : 443; $tenant_id = isset($_POST['tenant_id']) && is_numeric($_POST['tenant_id']) ? (int)$_POST['tenant_id'] : (int)$user->t_id; diff --git a/route/modals/testssl/request.php b/route/modals/testssl/request.php index e18c470..b90ac75 100644 --- a/route/modals/testssl/request.php +++ b/route/modals/testssl/request.php @@ -2,7 +2,7 @@ require('../../../functions/autoload.php'); $User->validate_session(false, true, false); -$is_admin = $user->admin === "1"; +$is_admin = $user->admin == "1"; $all_tenants = $is_admin ? $Tenants->get_all() : []; $content = []; diff --git a/route/modals/users/edit-submit.php b/route/modals/users/edit-submit.php index a9e22e0..91ea18e 100644 --- a/route/modals/users/edit-submit.php +++ b/route/modals/users/edit-submit.php @@ -25,7 +25,7 @@ $Result->show("danger", _("Invalid tenant").".", true, false, false, false); # tenant access - non-admins can only manage their own tenant -if($user->admin !== "1" && $user->t_id !== $tenant->id) +if($user->admin != "1" && $user->t_id !== $tenant->id) $Result->show("danger", _("Access denied").".", true, false, false, false); # fetch user to edit/delete diff --git a/route/modals/users/edit.php b/route/modals/users/edit.php index 1b7c0ec..acbfea1 100644 --- a/route/modals/users/edit.php +++ b/route/modals/users/edit.php @@ -36,7 +36,7 @@ $btn_text = ""; } # tenant access - non-admins can only manage their own tenant -elseif($user->admin !== "1" && (is_null($tenant) || $user->t_id !== $tenant->id)) { +elseif($user->admin != "1" && (is_null($tenant) || $user->t_id !== $tenant->id)) { # content $content = []; $content[] = $Result->show("danger", _("Access denied"), false, false, true); diff --git a/route/modals/zones/edit.php b/route/modals/zones/edit.php index f2c0371..c991c2a 100644 --- a/route/modals/zones/edit.php +++ b/route/modals/zones/edit.php @@ -57,7 +57,7 @@ $content[] = ""; $content[] = ""; // tenant - admin - if($user->admin === "1" && $_GET['action']=="add") { + if($user->admin == "1" && $_GET['action']=="add") { $content[] = ""; $content[] = " "; $content[] = " "; print " "; - print " "; + print " "; print ""; + + if ($User->get_user_permissions(3)) { + $Nmap = new Nmap($Database); + $nmap_pending = $Nmap->get_zone_pending_scans((int) $zone->id); + + print ""; + print " "; + print " "; + print ""; + } } print ""; @@ -275,3 +288,72 @@ print ""; print ""; } + + +// +// Nmap scan history (RWA+ only) +// +if ($User->get_user_permissions(3)) { + // $Nmap and $nmap_scans were already populated above in the button block + if (!isset($Nmap)) { + $Nmap = new Nmap($Database); + } + $nmap_scans = $Nmap->get_zone_scans((int) $zone->id); + + print "
"; + print "
"; + print "
"; + print ''; + print _("Network scans"); + print "
"; + + if (empty($nmap_scans)) { + print "
"._("No scans yet.")."
"; + } else { + print "
"._("Tenant").""; @@ -77,7 +77,7 @@ $content[] = " "; $content[] = " "; $content[] = " "; - if($user->admin !== "1" || $_GET['action']!=="add") + if($user->admin != "1" || $_GET['action']!=="add") $content[] = " "; if($_GET['action']=="delete") $content[] = " "; diff --git a/route/testssl/index.php b/route/testssl/index.php index ab457b5..f73cd20 100644 --- a/route/testssl/index.php +++ b/route/testssl/index.php @@ -8,7 +8,7 @@ } $TestSSL = new TestSSL($Database); -$is_admin = $user->admin === "1"; +$is_admin = $user->admin == "1"; $all_tenants = $Tenants->get_all(); $all_scans = $TestSSL->get_all((int)$user->t_id, $is_admin); diff --git a/route/testssl/report.php b/route/testssl/report.php index d7658cf..cea093e 100644 --- a/route/testssl/report.php +++ b/route/testssl/report.php @@ -3,7 +3,7 @@ $scan_hash = $_params['app']; $TestSSL = new TestSSL($Database); -$scan = $TestSSL->get_by_hash_auth($scan_hash, (int)$user->t_id, $user->admin === "1"); +$scan = $TestSSL->get_by_hash_auth($scan_hash, (int)$user->t_id, $user->admin == "1"); if (!$scan) { $Result->show('danger', _("Scan not found or access denied."), false); diff --git a/route/testssl/testssl-action.php b/route/testssl/testssl-action.php index e7ad298..251af7f 100644 --- a/route/testssl/testssl-action.php +++ b/route/testssl/testssl-action.php @@ -12,7 +12,7 @@ } $TestSSL = new TestSSL($Database); -$is_admin = $user->admin === "1"; +$is_admin = $user->admin == "1"; try { if ($action === 'cancel') { diff --git a/route/testssl/zone-hosts.php b/route/testssl/zone-hosts.php index c55d68d..3d03e8c 100644 --- a/route/testssl/zone-hosts.php +++ b/route/testssl/zone-hosts.php @@ -123,7 +123,7 @@ // fetch latest testssl result per hostname for this batch $TestSSL_obj = new TestSSL($Database); $_batch_hostnames = array_map(fn($h) => $h->hostname, $hosts ?: []); - $testssl_map = $TestSSL_obj->get_latest_by_hostnames($_batch_hostnames, (int)$user->t_id, $user->admin === "1"); + $testssl_map = $TestSSL_obj->get_latest_by_hostnames($_batch_hostnames, (int)$user->t_id, $user->admin == "1"); if(sizeof($hosts)>0) { foreach ($hosts as $h) { diff --git a/route/user/index.php b/route/user/index.php index 2cedc3d..5e553a3 100644 --- a/route/user/index.php +++ b/route/user/index.php @@ -62,7 +62,7 @@ die(); } // must be admin - if ($user->admin !== "1") { + if ($user->admin != "1") { $Common->save_error("Administrative privileges required"); require (dirname(__FILE__)."/../error/500.php"); die(); diff --git a/route/users/index.php b/route/users/index.php index 8ced621..58a81dc 100644 --- a/route/users/index.php +++ b/route/users/index.php @@ -136,7 +136,7 @@ class="table table-hover align-middle table-md" // Actions $actions = ""; - if ($user->admin === "1" && !isset($_SESSION['impersonate_original']) && $u->email !== $user->email) { + if ($user->admin == "1" && !isset($_SESSION['impersonate_original']) && $u->email !== $user->email) { $actions .= "{$imp_icon} "; } $actions .= "{$edit_icon} " . _("Edit") . ""; diff --git a/route/users/user-details.php b/route/users/user-details.php index e9c9d1d..1fbc90b 100644 --- a/route/users/user-details.php +++ b/route/users/user-details.php @@ -82,7 +82,7 @@ - admin === "1" && !isset($_SESSION['impersonate_original']) && $view_user->email !== $user->email): ?> + admin == "1" && !isset($_SESSION['impersonate_original']) && $view_user->email !== $user->email): ?> diff --git a/route/users/user/user-details.php b/route/users/user/user-details.php index 2eb3733..48b4879 100644 --- a/route/users/user/user-details.php +++ b/route/users/user/user-details.php @@ -77,7 +77,7 @@ -admin === "1" && !isset($_SESSION['impersonate_original']) && $view_user->email !== $user->email): ?> +admin == "1" && !isset($_SESSION['impersonate_original']) && $view_user->email !== $user->email): ?> diff --git a/route/validate/index.php b/route/validate/index.php index 1a60754..4ae4075 100644 --- a/route/validate/index.php +++ b/route/validate/index.php @@ -3,7 +3,7 @@ $User->validate_session (true); # admin only -if ($user->admin !== "1") { +if ($user->admin != "1") { $Result->show("danger", _("Admin access required."), true, false, false, false); } else { From 3327b916170c3f788dcd374207a930f76a048d7c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 1 Jun 2026 11:15:09 +0000 Subject: [PATCH 25/34] fix: zone icon turns green when hosts have no certificate yet (#21) Two bugs combined to cause a zone with unscanned hosts to show green: 1. count_zone_certs() used COUNT(DISTINCT c_id) without excluding NULL, so hosts with c_id = NULL (not yet scanned) were counted as having a certificate, returning 1 instead of 0. 2. The icon color logic in all.php only went grey when $hosts == 0, not when hosts exist but none have a certificate assigned yet. Fix: add WHERE c_id IS NOT NULL to the count query, and extend the grey condition to ($hosts == 0 || $certs == 0). https://claude.ai/code/session_01FE2bYP3eVFS7jxmRdGFnEw --- functions/classes/class.Zones.php | 2 +- route/zones/all.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/classes/class.Zones.php b/functions/classes/class.Zones.php index 90ba15e..6a2789c 100644 --- a/functions/classes/class.Zones.php +++ b/functions/classes/class.Zones.php @@ -199,7 +199,7 @@ public function count_zone_certs($zone_id = 0) { // fetch try { - $cnt = $this->Database->getObjectQuery("select count(distinct(c_id)) as cnt from hosts where z_id = ?", [$zone_id]); + $cnt = $this->Database->getObjectQuery("select count(distinct(c_id)) as cnt from hosts where z_id = ? and c_id is not null", [$zone_id]); } catch (Exception $e) { $this->errors[] = $e->getMessage(); diff --git a/route/zones/all.php b/route/zones/all.php index e9ad609..542540e 100644 --- a/route/zones/all.php +++ b/route/zones/all.php @@ -121,7 +121,7 @@ $expire_soon = $expire_soon-$expired_certs_cnt; // ikona levo $icon_color = $expired_certs_cnt == 0 ? "text-success" : "text-danger"; - $icon_color = $hosts==0 ? "text-muted" : $icon_color; + $icon_color = ($hosts == 0 || $certs == 0) ? "text-muted" : $icon_color; $icon_color = $expired_certs_cnt == 0 && $expire_soon!=0 ? "text-warning" : $icon_color; // klase za badge $warning_class = $expire_soon==0 ? "" : "text-warning"; From 04903c7c8a56078cd8c6cd6298908c9b7691b800 Mon Sep 17 00:00:00 2001 From: Miha Petkovsek Date: Mon, 1 Jun 2026 13:32:00 +0200 Subject: [PATCH 26/34] Bugfixes --- functions/classes/class.testssl.php | 9 +++++++-- index.php | 2 +- route/common/header.php | 2 +- route/common/left-menu.php | 4 ++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/functions/classes/class.testssl.php b/functions/classes/class.testssl.php index c04a489..739c903 100644 --- a/functions/classes/class.testssl.php +++ b/functions/classes/class.testssl.php @@ -227,19 +227,25 @@ private function run_scan(object $scan): void $json_file = sys_get_temp_dir() . '/testssl_' . $scan->id . '_' . time() . '.json'; // Build argument array — proc_open with array skips the shell entirely + // Outer `timeout` guards against hangs in checks that ignore --openssl-timeout + // (e.g. sub_early_data uses -ign_eof with the bundled OpenSSL 1.0.2 binary + // which predates the -timeout flag, so the per-connect timeouts do nothing there). $args = [ + 'timeout', '300', 'bash', $this->testssl_path, '--jsonfile', $json_file, '--quiet', '--color', '0', '--warnings', 'off', + '--openssl-timeout', '10', + '--socket-timeout', '10', $scan->hostname . ':' . (int)$scan->port, ]; $descriptors = [ 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], + 1 => ['file', '/dev/null', 'w'], 2 => ['pipe', 'w'], ]; @@ -254,7 +260,6 @@ private function run_scan(object $scan): void fclose($pipes[0]); $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); fclose($pipes[2]); $exit_code = proc_close($proc); diff --git a/index.php b/index.php index 1a88673..dd31e0c 100644 --- a/index.php +++ b/index.php @@ -48,7 +48,7 @@ - <?php print $title; ?> + <?php print $title ?? ''; ?> diff --git a/route/common/header.php b/route/common/header.php index 99cae84..6703dde 100644 --- a/route/common/header.php +++ b/route/common/header.php @@ -46,7 +46,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 '
"._("Add host")."".''." "._("Add host")."".''." "._("Add new host")."
"._("Scan hosts").""; + print "".''." "._("Scan hosts").""; + + print "
"; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + + foreach ($nmap_scans as $s) { + switch ($s->status) { + case "Completed": $badge = "badge bg-info-lt text-green"; break; + case "Scanning": $badge = "badge bg-info-lt text-blue"; break; + case "Error": $badge = "badge bg-info-lt text-red"; break; + default: $badge = "badge bg-info-lt text-muted"; break; + } + $ptr_icon = $s->ptr_lookup ? ""._("Yes")."" : ""; + $completed = $s->completed ? $s->completed : ""; + $error_tip = $s->status === "Error" && $s->error_msg + ? " title='" . htmlspecialchars($s->error_msg) . "' data-bs-toggle='tooltip'" + : ""; + + print ""; + $pg_display = !empty($s->pg_name) ? htmlspecialchars($s->pg_name) . " (" . htmlspecialchars($s->pg_ports ?? '') . ")" : ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + print ""; + } + + print "
"._("Prefix").""._("Port group").""._("PTR").""._("Status").""._("Found").""._("Added").""._("Requested").""._("Completed").""._("User")."
" . htmlspecialchars($s->prefix) . "{$pg_display}{$ptr_icon}" . htmlspecialchars($s->status) . "" . (int) $s->hosts_found . "" . (int) $s->hosts_added . "" . htmlspecialchars($s->requested) . "" . $completed . "" . htmlspecialchars($s->username ?? '') . "
"; + } + + print ""; // card + print ""; // col +} diff --git a/version.php b/version.php index 2495323..000aafe 100644 --- a/version.php +++ b/version.php @@ -11,7 +11,7 @@ */ $version_major = 0; $version_minor = 9; -$version_patch = 0; +$version_patch = 3; /** * Full version string, e.g. "1.0" From 158c228c2586cf6c3c4ebd833d54640380873310 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 08:42:51 +0000 Subject: [PATCH 29/34] Extend self-signed status to certificate list and email notifications Add status 11 (Self-signed) handling to the certificates AJAX endpoint (textclass/danger_class) and to the cron email notification status text, matching how Domain mismatch (10) is handled in both places. https://claude.ai/code/session_01AwEEnNu1zMmukiSrQCvFWy --- functions/cron/update_certificates.php | 1 + route/ajax/certificates.php | 1 + 2 files changed, 2 insertions(+) diff --git a/functions/cron/update_certificates.php b/functions/cron/update_certificates.php index 271c905..3def4a2 100644 --- a/functions/cron/update_certificates.php +++ b/functions/cron/update_certificates.php @@ -175,6 +175,7 @@ if ($status_int == "1") { $status = "Expired"; $color = "#E74C3C"; } elseif ($status_int == "2") { $status = "Expires soon"; $color = "#FF5733"; } elseif ($status_int == "10") { $status = "Domain mismatch"; $color = "#FF5733"; } + elseif ($status_int == "11") { $status = "Self-signed"; $color = "#FF5733"; } else { $status = "Valid"; $color = "#1ABC9C"; } // check if cert is ignored, if so skip to next item ! 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=''; } From 1ec90a14a61de4231de8f7808d89ccce410a02db Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 08:50:54 +0000 Subject: [PATCH 30/34] Fix self-signed status on certificate and CA detail pages Three detail pages were calling get_status() with validate_domain=true but domain=false, which incorrectly triggered a domain mismatch before the self-signed check could run. Switch to validate_domain=false on detail pages where no hostname context exists, and add textclass handling for status 11 in all three files. https://claude.ai/code/session_01AwEEnNu1zMmukiSrQCvFWy --- route/cas/ca-certificates/ca-certificate.php | 13 +++++++------ route/certificates/certificate.php | 3 ++- route/zones/zone/host/index.php | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/route/cas/ca-certificates/ca-certificate.php b/route/cas/ca-certificates/ca-certificate.php index 8168d2d..addf416 100644 --- a/route/cas/ca-certificates/ca-certificate.php +++ b/route/cas/ca-certificates/ca-certificate.php @@ -96,16 +96,17 @@ $cert = openssl_x509_read($ca->certificate); // Status and display classes -$status = $Certificates->get_status($certificate_details, true, false); +$status = $Certificates->get_status($certificate_details, false, false); $valid_period = $certificate_details['custom_validAllDays'] > 398 ? "
" . _("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/certificates/certificate.php b/route/certificates/certificate.php index f778589..63e8861 100644 --- a/route/certificates/certificate.php +++ b/route/certificates/certificate.php @@ -61,7 +61,7 @@ $cert = openssl_x509_read($certificate->certificate); // status - $status = $Certificates->get_status ($certificate_details, true, false); + $status = $Certificates->get_status ($certificate_details, false, false); // valid_period $valid_period = $certificate_details['custom_validAllDays']>398 ? "
".''." "._("Certificate validity is more than 398 days")."" : ""; @@ -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/zones/zone/host/index.php b/route/zones/zone/host/index.php index 7ed96e2..b5862d0 100644 --- a/route/zones/zone/host/index.php +++ b/route/zones/zone/host/index.php @@ -68,7 +68,7 @@ $cert_old = $Zones->get_host_old_certificate($host->c_id_old); if ($cert_old) { $cert_old_parsed = $Certificates->parse_cert($cert_old->certificate); - $cert_old_status = $Certificates->get_status($cert_old_parsed, true, false, ""); + $cert_old_status = $Certificates->get_status($cert_old_parsed, false, false, ""); $cert_old_textclass = $Certificates->get_status_color($cert_old_status['code']); From 28bfe68d33ec1826076005df64bbafcde363e8d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 08:53:47 +0000 Subject: [PATCH 31/34] Fix self-signed detection and restore text visibility on detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs: (1) is_self_signed() compared subject === issuer but route files add a custom CN_all key to subject, making the comparison always fail — fix by intersecting against standard X.509 keys before comparing. (2) Previous commit accidentally flipped get_status() \$text param from true to false on detail pages, hiding badge text — reverted. https://claude.ai/code/session_01AwEEnNu1zMmukiSrQCvFWy --- functions/classes/class.Certificates.php | 6 +++++- route/cas/ca-certificates/ca-certificate.php | 2 +- route/certificates/certificate.php | 2 +- route/zones/zone/host/index.php | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/functions/classes/class.Certificates.php b/functions/classes/class.Certificates.php index 21060cc..3a3ed1b 100644 --- a/functions/classes/class.Certificates.php +++ b/functions/classes/class.Certificates.php @@ -479,7 +479,11 @@ private function is_self_signed($cert_parsed = false) if (empty($cert_parsed['subject']) || empty($cert_parsed['issuer'])) { return false; } - return $cert_parsed['subject'] === $cert_parsed['issuer']; + // Strip custom keys added by route files (e.g. CN_all) before comparing + $standard_keys = ['C', 'ST', 'L', 'O', 'OU', 'CN', 'emailAddress', 'serialNumber']; + $subject = array_intersect_key($cert_parsed['subject'], array_flip($standard_keys)); + $issuer = array_intersect_key($cert_parsed['issuer'], array_flip($standard_keys)); + return $subject === $issuer; } /** diff --git a/route/cas/ca-certificates/ca-certificate.php b/route/cas/ca-certificates/ca-certificate.php index addf416..fa9baa8 100644 --- a/route/cas/ca-certificates/ca-certificate.php +++ b/route/cas/ca-certificates/ca-certificate.php @@ -96,7 +96,7 @@ $cert = openssl_x509_read($ca->certificate); // Status and display classes -$status = $Certificates->get_status($certificate_details, false, false); +$status = $Certificates->get_status($certificate_details, true, false); $valid_period = $certificate_details['custom_validAllDays'] > 398 ? "
" . _("Certificate validity is more than 398 days") . "" : ""; diff --git a/route/certificates/certificate.php b/route/certificates/certificate.php index 63e8861..2627fc9 100644 --- a/route/certificates/certificate.php +++ b/route/certificates/certificate.php @@ -61,7 +61,7 @@ $cert = openssl_x509_read($certificate->certificate); // status - $status = $Certificates->get_status ($certificate_details, false, false); + $status = $Certificates->get_status ($certificate_details, true, false); // valid_period $valid_period = $certificate_details['custom_validAllDays']>398 ? "
".''." "._("Certificate validity is more than 398 days")."" : ""; diff --git a/route/zones/zone/host/index.php b/route/zones/zone/host/index.php index b5862d0..7ed96e2 100644 --- a/route/zones/zone/host/index.php +++ b/route/zones/zone/host/index.php @@ -68,7 +68,7 @@ $cert_old = $Zones->get_host_old_certificate($host->c_id_old); if ($cert_old) { $cert_old_parsed = $Certificates->parse_cert($cert_old->certificate); - $cert_old_status = $Certificates->get_status($cert_old_parsed, false, false, ""); + $cert_old_status = $Certificates->get_status($cert_old_parsed, true, false, ""); $cert_old_textclass = $Certificates->get_status_color($cert_old_status['code']); From 95abf2dda8162f779d891a5710bb3cdd94ed2896 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 08:57:34 +0000 Subject: [PATCH 32/34] Fix AKI chain validation when authorityKeyIdentifier has extra metadata Some certs (e.g. Fortinet) include DirName and serial after the key ID in authorityKeyIdentifier. Truncate at the first space so only the hex key ID is compared against subjectKeyIdentifier. https://claude.ai/code/session_01AwEEnNu1zMmukiSrQCvFWy --- functions/classes/class.SSL.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/functions/classes/class.SSL.php b/functions/classes/class.SSL.php index 880e14a..47f5529 100644 --- a/functions/classes/class.SSL.php +++ b/functions/classes/class.SSL.php @@ -623,7 +623,9 @@ public function process_certificate_chain($chain) // check if it has childs in chain if (isset($result[$m + 1])) { // make sure current issued child certificate - if ($result[$m]['certificate']['extensions']['subjectKeyIdentifier'] != trim(str_replace("keyid:", "", $result[$m + 1]['certificate']['extensions']['authorityKeyIdentifier']))) { + $aki_raw = trim(str_replace("keyid:", "", $result[$m + 1]['certificate']['extensions']['authorityKeyIdentifier'])); + $aki = explode(" ", $aki_raw)[0]; + if ($result[$m]['certificate']['extensions']['subjectKeyIdentifier'] != $aki) { $result[$m + 1]['errors']['authorityKeyIdentifier'] = _("Certificate not signed by parent"); } // can current sign certificates ? :) From 4d2a47d10eb94bd690d9d3059713e090bcb22c26 Mon Sep 17 00:00:00 2001 From: Miha Petkovsek Date: Tue, 16 Jun 2026 11:06:21 +0200 Subject: [PATCH 33/34] Updates --- functions/classes/class.SSL.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/functions/classes/class.SSL.php b/functions/classes/class.SSL.php index 47f5529..9c39dd0 100644 --- a/functions/classes/class.SSL.php +++ b/functions/classes/class.SSL.php @@ -624,10 +624,13 @@ public function process_certificate_chain($chain) if (isset($result[$m + 1])) { // make sure current issued child certificate $aki_raw = trim(str_replace("keyid:", "", $result[$m + 1]['certificate']['extensions']['authorityKeyIdentifier'])); - $aki = explode(" ", $aki_raw)[0]; + $aki = explode("\n", $aki_raw)[0]; + if ($result[$m]['certificate']['extensions']['subjectKeyIdentifier'] != $aki) { $result[$m + 1]['errors']['authorityKeyIdentifier'] = _("Certificate not signed by parent"); } + + // can current sign certificates ? :) if (strpos($result[$m]['certificate']['extensions']['basicConstraints'], "CA:TRUE") === false) { $result[$m]['errors']['basicConstraints'] = _("Certificate not allowed to issue certificates"); From cb84c5d4c1c5b266049c81ac9e65f08e347652fd Mon Sep 17 00:00:00 2001 From: Miha Petkovsek Date: Fri, 10 Jul 2026 14:56:51 +0200 Subject: [PATCH 34/34] Fixed manually imported cetificates not wshown before expiration --- functions/classes/class.Certificates.php | 42 +++++--------------- route/dashboard/card-certificates-expire.php | 7 ++++ 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/functions/classes/class.Certificates.php b/functions/classes/class.Certificates.php index 3a3ed1b..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; } @@ -309,7 +315,7 @@ public function get_status($cert_parsed = false, $text = false, $validate_domain * @param array|bool $cert_parsed Parsed certificate array or false * @param bool $validate_domain Whether to validate domain against certificate * @param string $domain Domain to validate - * @return int Status code: 0=unknown, 1=expired, 2=expires soon, 3=valid, 10=domain mismatch, 11=self-signed + * @return int Status code: 0=unknown, 1=expired, 2=expires soon, 3=valid, 10=domain mismatch */ public function get_status_int($cert_parsed = false, $validate_domain = false, $domain = "") { @@ -328,10 +334,6 @@ public function get_status_int($cert_parsed = false, $validate_domain = false, $ return 10; } } - // check if certificate is self-signed (subject matches issuer) - if ($this->is_self_signed($cert_parsed)) { - return 11; - } // result if ($days < 0) { @@ -361,7 +363,6 @@ public function get_status_color($status_int = 0) case 2: return 'orange'; case 3: return 'green'; case 10: return 'red'; - case 11: return 'orange'; default: return 'secondary'; } } @@ -383,9 +384,6 @@ public function get_status_text($status_int = 0, $text = false) if ($status_int == 10) { return " " . _("Domain mismatch") . " "; } - if ($status_int == 11) { - return " " . _("Self-signed") . " "; - } if ($status_int == 0) { return " " . _("Unknown") . " "; } @@ -468,24 +466,6 @@ private function validate_cert_domain_validity($domain = "", $cert_parsed = []) return false; } - /** - * Check if certificate is self-signed (subject and issuer are identical) - * @method is_self_signed - * @param array|bool $cert_parsed - * @return bool - */ - private function is_self_signed($cert_parsed = false) - { - if (empty($cert_parsed['subject']) || empty($cert_parsed['issuer'])) { - return false; - } - // Strip custom keys added by route files (e.g. CN_all) before comparing - $standard_keys = ['C', 'ST', 'L', 'O', 'OU', 'CN', 'emailAddress', 'serialNumber']; - $subject = array_intersect_key($cert_parsed['subject'], array_flip($standard_keys)); - $issuer = array_intersect_key($cert_parsed['issuer'], array_flip($standard_keys)); - return $subject === $issuer; - } - /** * List of allowed certificate formats * @method allowed_cert_formats @@ -664,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/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 "
".$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 "