diff --git a/config.dist.php b/config.dist.php index 2c9dd29..50b65ff 100644 --- a/config.dist.php +++ b/config.dist.php @@ -83,7 +83,7 @@ */ $mail_settings = new StdClass (); $mail_settings->mtype = "smtp"; -$mail_settings->msecure = "tls"; +$mail_settings->msecure = "tls"; // choices are: none, ssl, tls $mail_settings->mauth = "no"; $mail_settings->mserver = "127.0.0.1"; $mail_settings->mport = 25; @@ -143,4 +143,4 @@ * * @var array */ -$private_key_encryption_key = []; \ No newline at end of file +$private_key_encryption_key = []; diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3e29246 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,26 @@ +# php-ssl Documentation + +This directory contains the full documentation for **php-ssl**, a PHP 8.0+ SSL/TLS certificate monitoring web application. + +## Contents + +### Getting Started +- [Installation](getting-started/installation.md) — Clone, configure, database setup, web installer +- [Configuration](getting-started/configuration.md) — `config.php` reference and per-tenant overrides +- [Upgrading](getting-started/upgrading.md) — Applying database migrations after a git pull + +### Architecture +- [Overview](architecture/overview.md) — Request flow, URL structure, globals, autoloader +- [Multi-Tenancy](architecture/multi-tenancy.md) — Tenant isolation, roles, private zones, impersonation +- [Database Schema](architecture/database-schema.md) — All tables, relationships, and key columns +- [Class Reference](architecture/class-reference.md) — Every class: role, key methods, inheritance + +### Operations +- [Crontab Setup](operations/crontab-setup.md) — System crontab, in-app scheduling, manual runs +- [Web Server](operations/apache-nginx.md) — Apache and nginx configuration, BASE path, rewrites +- [Notifications](operations/notifications.md) — How recipient lists are built, delivery matrix, audit log +- [Troubleshooting](operations/troubleshooting.md) — Common problems and solutions + +### Coming Soon +- Features — Certificate scanning, CA management, DNS AXFR, agents, testssl.sh, notifications, private zones, WebAuthn, AD sync +- Development — Coding conventions, adding routes, migrations, frontend diff --git a/docs/architecture/class-reference.md b/docs/architecture/class-reference.md new file mode 100644 index 0000000..6c67330 --- /dev/null +++ b/docs/architecture/class-reference.md @@ -0,0 +1,251 @@ +# Class Reference + +All classes live in `functions/classes/`. They are instantiated in `functions/autoload.php` and available as globals in every route file. + +## Inheritance + +``` +Validate +└── Common + ├── SSL + ├── User + ├── URL + ├── Config + └── Zones +``` + +`Common` provides shared utilities (permalink generation, error handling, input validation wrappers). `Validate` provides low-level input sanitisation. All domain classes inherit both. + +--- + +## Database_PDO (`class.PDO.php`) + +All database access in the application goes through this class. Never use raw PDO directly. + +**Key methods:** + +| Method | Description | +|---|---| +| `getObjectQuery($sql, $params)` | Returns a single row as `stdClass`, or `null` | +| `getObjectsQuery($sql, $params)` | Returns an array of `stdClass` rows | +| `getValueQuery($sql, $params)` | Returns a single scalar value | +| `runQuery($sql, $params)` | Executes a non-SELECT query | +| `insertObject($table, $data)` | Inserts a row and returns the new ID | +| `updateObject($table, $data)` | Updates a row (must include `id` in `$data`) | +| `getObject($table, $id)` | Fetches a row by primary key | +| `lastInsertId()` | Returns the last auto-increment ID | + +--- + +## SSL (`class.SSL.php`) + +Core SSL scanner. Connects to hosts over TCP/TLS, extracts certificates, and updates the database. + +**Key methods:** + +| Method | Description | +|---|---| +| `fetch_website_certificate($host, $time, $tenant_id)` | Scan one host; returns cert array or `false` | +| `update_db_certificate($cert, $tenant_id, $zone_id, $time)` | Upsert cert into DB; returns cert ID | +| `assign_host_certificate($host, $ip, $port, $cert, $tls, $time, $user_id)` | Link cert to host, write log | +| `upsert_chain_cas($chain_pem, $tenant_id)` | Extract and store CA certs from a chain PEM | +| `process_certificate_chain($chain)` | Parse and validate a chain; returns structured array | +| `get_all_port_groups()` | Load all port groups (used to prime the port cache) | +| `resolve_ip($hostname)` | DNS-resolve a hostname to an IP | + +**Scanning flow:** +1. Validate hostname and port group +2. For local agent: iterate ports, attempt `stream_socket_client` SSL connection +3. For remote agent: send to `Agent` class via HTTP API +4. `update_host_last_check()` is always called (success or failure) +5. On success: `update_db_certificate()` → `upsert_chain_cas()` → `assign_host_certificate()` (if cert changed) + +--- + +## Certificates (`class.Certificates.php`) + +Certificate CRUD and status logic. + +**Key methods:** + +| Method | Description | +|---|---| +| `parse_cert($pem)` | Parse PEM with `openssl_x509_parse`, add `custom_validDays`, `custom_validTo`, `custom_purposes` | +| `get_status($parsed, $text, $validate_domain, $domain)` | Returns `['code' => int, 'text' => string]` | +| `get_status_int($parsed, $validate_domain, $domain)` | Status code: 0=unknown, 1=expired, 2=expiring, 3=valid, 10=domain mismatch, 11=self-signed | +| `get_status_color($code)` | Tabler colour class name for a status code | +| `get_certificate_hosts($cert_id)` | All hosts assigned to a certificate | +| `get_expired($days, $after_days)` | Certificates expiring soon or recently expired | +| `is_issuer_ignored($aki, $tenant_id, $type)` | Whether a certificate's issuer is on the ignored list | + +**Status code meanings:** + +| Code | Meaning | Colour | +|---|---|---| +| 0 | Unknown (no cert / unparseable) | Grey | +| 1 | Expired | Red | +| 2 | Expiring soon (within threshold) | Orange | +| 3 | Valid | Green | +| 10 | Domain mismatch | Red | +| 11 | Self-signed | Orange | + +--- + +## Zones (`class.Zones.php`) + +Zone and host management. + +**Key methods:** + +| Method | Description | +|---|---| +| `get_all($tenant_href)` | All zones for a tenant (respects private zone rules) | +| `get_zone($tenant_href, $zone_name)` | Single zone by tenant+name | +| `get_zone_hosts($zone_id)` | All non-ignored hosts in a zone | +| `get_tenant_agents($tenant_id)` | All agents available to a tenant | +| `is_host_inside_domain($hostname, $zone)` | Validates that a hostname belongs to the zone domain | + +--- + +## User (`class.User.php`) + +Session-based authentication, role checking, and permission validation. + +**Key methods:** + +| Method | Description | +|---|---| +| `validate_session($redirect, $json, $ajax)` | Require a valid session; redirect or return JSON error if not | +| `validate_user_permissions($level, $die)` | Require minimum permission level | +| `validate_tenant($die, $json)` | Verify the URL tenant matches the user's tenant (or admin) | +| `get_current_user()` | Returns the current `$user` object | +| `create_csrf_token()` | Generate a CSRF token for a form | +| `validate_csrf_token()` | Validate a submitted CSRF token; die on failure | +| `strip_input_tags($array)` | Strip HTML tags from all values in `$_GET` / `$_POST` | + +--- + +## Tenants (`class.Tenants.php`) + +Tenant CRUD. + +**Key methods:** `get_all()`, `get_tenant_by_href($href)`, `get_tenant_by_id($id)` + +--- + +## Log (`class.Log.php`) + +Audit logging. All changes to zones, hosts, certificates, users, and CAs are written here. + +**Key method:** + +```php +$Log->write($object, $object_id, $tenant_id, $user_id, $action, $public, $text, $old_json, $new_json); +``` + +The `$old_json` / `$new_json` parameters are only stored when `$log_object = true` in `config.php`. + +--- + +## Config (`class.Config.php`) + +Reads per-tenant overrides from the `config` DB table and merges them over the global `config.php` defaults. + +**Key method:** `get_config($tenant_id)` — returns an associative array of all config values for the tenant. + +--- + +## Cron (`class.Cron.php`) + +Reads the `cron` DB table and decides which scripts to execute on a given run. + +**Key method:** `execute_cronjobs($tenant_id)` — checks schedules, runs due scripts (and always runs `testssl_scan` regardless of schedule), updates `last_executed`. + +--- + +## Mail (`class.Mail.php`) + +Thin wrapper around PHPMailer. Used by the cron scripts to send certificate change and expiry notifications. + +--- + +## Modal (`class.Modal.php`) + +Renders Bootstrap modal HTML (header, body, footer, action button JavaScript). + +**Key method:** `modal_print($title, $content, $btn_text, $submit_url, $close, $header_class)` + +--- + +## Result (`class.Result.php`) + +Formats AJAX JSON responses and HTML alert banners. + +**Key methods:** + +| Method | Description | +|---|---| +| `show($type, $message, $die, $return, $inline, $br)` | Render a Bootstrap alert div | +| `result_json($status, $message, $data)` | Output a JSON response and exit | + +--- + +## AXFR (`class.AXFR.php`) + +DNS zone transfer client. Uses the Net_DNS2 submodule to perform AXFR requests and extract hostnames. + +--- + +## Agent (`class.Agent.php`) + +HTTP client for remote scanning agents. Sends hostname+ports to a remote agent's API and returns the result. + +--- + +## Thread / ScanThread (`class.Thread.php`) + +`pcntl_fork`-based threading for parallel certificate scanning. Used exclusively by the `update_certificates` cron script. + +--- + +## TestSSL (`class.testssl.php`) + +testssl.sh integration. **Not a global** — instantiate per-request: `new TestSSL($Database)`. + +**Key methods:** `run_pending()`, `parse_result($json)`, `send_completion_email($scan)`, `get_latest_by_hostnames($hostnames, $tenant_id, $admin)` + +--- + +## Migration (`class.Migration.php`) + +Tracks and applies incremental DB migrations from `db/migrations/`. + +**Key methods:** `get_pending()`, `apply_all()`, `get_current_version()`, `get_latest_version()` + +--- + +## WebAuthn (`class.WebAuthn.php`) + +WebAuthn/Passkey credential verification. Implements ES256 and RS256. + +--- + +## ADsync (`class.ADsync.php`) + +Active Directory LDAP user synchronisation. + +--- + +## Common (`class.Common.php`) + +Base class for `SSL`, `User`, `URL`, `Config`, and `Zones`. Provides: +- `validate_hostname()`, `validate_ip()`, `validate_mail()`, `validate_int()` +- `print_breadcrumbs()` +- `save_error()`, `result_die()` +- `scan_host()` — the forked-process worker function used by `update_certificates` + +--- + +## Validate (`class.Validate.php`) + +Base class for `Common`. Low-level input sanitisation and type checking. diff --git a/docs/architecture/database-schema.md b/docs/architecture/database-schema.md new file mode 100644 index 0000000..b2730b6 --- /dev/null +++ b/docs/architecture/database-schema.md @@ -0,0 +1,198 @@ +# Database Schema + +## Table hierarchy + +``` +tenants +└── zones (t_id) + └── hosts (z_id) + └── certificates (z_id, t_id) +users (t_id) +agents (t_id) +cas (t_id) +csrs (t_id) +testssl (tenant_id) +nmap_scans (tenant_id, zone_id) +ssl_port_groups (t_id) +cron (t_id) +config (t_id) +pkey ← referenced by certificates, cas, csrs +passkeys (user_id) +domains (t_id) +translations +migrations +logs +``` + +--- + +## Core tables + +### `tenants` + +The root of all tenant-scoped data. + +| Column | Type | Notes | +|---|---|---| +| `id` | int | Primary key | +| `name` | varchar(255) | Display name | +| `href` | varchar(255) | URL slug — used in all application URLs | +| `description` | text | Optional | +| `active` | tinyint | 1 = active | +| `admin` | tinyint | 1 = this is the admin tenant | +| `recipients` | text | Semicolon-separated email list for change notifications | +| `mail_style` | enum | `list` or `table` — email format for change notifications | +| `remove_orphaned` | tinyint | Whether the remove_orphaned cron script runs for this tenant | +| `log_retention` | int | Days to keep audit log entries | +| `lang_id` | int FK | Default language for the tenant (→ `translations`) | + +### `zones` + +A zone groups hosts for scanning. Can be a manual list or a DNS zone (AXFR). + +| Column | Type | Notes | +|---|---|---| +| `id` | int | Primary key | +| `t_id` | int FK | Tenant | +| `name` | varchar(255) | Zone name / domain | +| `type` | enum | `local` (manual) or `axfr` (DNS zone transfer) | +| `agent_id` | int FK | Scanning agent to use (→ `agents`) | +| `is_domain` | tinyint | If 1, zone name is appended to each hostname on add | +| `ignore` | tinyint | If 1, zone is excluded from all scans | +| `private_zone_uid` | int FK | NULL = public; user ID = private, owner-only (→ `users`) | +| `dns` | varchar | DNS server for AXFR | +| `tsig_name`, `tsig` | varchar | TSIG key for authenticated zone transfers | +| `record_types` | varchar | Comma-separated DNS record types to import via AXFR | +| `delete_records` | tinyint | If 1, AXFR removes hosts no longer in DNS | +| `check_ip` | tinyint | If 1, AXFR also imports A record IP addresses | +| `regex_include`, `regex_exclude` | text | Patterns to filter AXFR hostnames | + +### `hosts` + +One row per monitored hostname. + +| Column | Type | Notes | +|---|---|---| +| `id` | int | Primary key | +| `z_id` | int FK | Zone | +| `c_id` | int FK | Current certificate (→ `certificates`) | +| `c_id_old` | int FK | Previous certificate — used for change detection | +| `pg_id` | int FK | Port group (→ `ssl_port_groups`) | +| `hostname` | varchar(255) | The hostname or IP to scan | +| `ip` | varchar(15) | Last resolved IP address | +| `port` | int | Port where the current certificate was found | +| `tls_version` | varchar | TLS version from the last successful scan | +| `ignore` | tinyint | If 1, host is excluded from all scans | +| `mute` | tinyint | If 1, no change notifications are sent for this host | +| `h_recipients` | text | Semicolon-separated per-host notification email overrides | +| `last_check` | timestamp | Last scan attempt (success or failure) | +| `last_change` | timestamp | Last time the certificate changed | + +### `certificates` + +Stores discovered certificate PEM data. Each unique serial+zone combination is one row. + +| Column | Type | Notes | +|---|---|---| +| `id` | int | Primary key | +| `z_id` | int FK | Zone | +| `t_id` | int FK | Tenant | +| `serial` | varchar(255) | Certificate serial number | +| `certificate` | text | PEM-encoded leaf certificate | +| `chain` | text | PEM-encoded intermediate chain (leaf first) | +| `expires` | datetime | Certificate expiry date | +| `aki` | varchar(255) | Authority Key Identifier — links to signing CA's SKI | +| `is_manual` | tinyint | 1 = manually imported, excluded from orphan cleanup | +| `pkey_id` | int FK | Associated private key, if stored (→ `pkey`) | + +**Unique constraint:** `(z_id, serial)` — a given serial can appear in multiple zones (different tenants) but only once per zone. + +--- + +## Supporting tables + +### `cas` — Certificate Authorities + +CAs discovered from scanned certificate chains, or manually imported. + +| Column | Notes | +|---|---| +| `ski` | Subject Key Identifier — used to match against `certificates.aki` | +| `parent_ca_id` | Self-referencing FK for hierarchical chain display | +| `source` | `auto` (discovered from scan) or `manual` (imported) | +| `ignore_updates` | If 1, the cert is not updated when a new version is discovered | +| `ignore_expiry` | If 1, the expiry notification cron skips this CA | +| `pkey_id` | If set, this CA has a stored private key and can sign CSRs | + +### `csrs` — Certificate Signing Requests + +| Column | Notes | +|---|---| +| `status` | `pending`, `submitted`, or `signed` | +| `source` | `internal` (key generated here) or `external` (CSR imported) | +| `pkey_id` | Private key used to generate the CSR | +| `cert_id` | Resulting certificate after signing | +| `renewed_by` | Points to a newer CSR if this one was renewed | + +### `pkey` — Private keys + +Single-column table (`private_key_enc`). The encrypted private key is stored as a base64-encoded AES-256-GCM ciphertext. The encryption key comes from `$private_key_encryption_key[t_id]` in `config.php`. Referenced by `certificates`, `cas`, and `csrs`. + +### `ssl_port_groups` + +Named groups of ports to scan (e.g. `pg_ssl` = `443,8443`). Assigned per zone/host. + +### `agents` + +Remote scanning agents. The built-in local agent (`id=1`, `is_global=1`) scans from the server itself. Additional agents communicate via HTTP API. + +### `cron` + +Per-tenant cron schedules. Standard cron fields plus `script` (the PHP cron script name) and `force` (run on next tick regardless of schedule). + +### `config` + +Key/value pairs scoped to a `t_id`. Override any setting from `config.php` for a specific tenant. + +### `testssl` + +One row per testssl.sh scan request. + +| Column | Notes | +|---|---| +| `hash` | 64-character hex — used in public report URLs | +| `status` | `Requested`, `Scanning`, `Completed`, `Cancelled`, `Error` | +| `json_result` | Raw JSON output from testssl.sh | +| `notify_email` | If set, an email is sent on completion | + +### `logs` + +Audit trail for all object changes. + +| Column | Notes | +|---|---| +| `object` | Table name (e.g. `hosts`, `zones`, `certificates`) | +| `object_id` | Row ID in the source table | +| `action` | e.g. `add`, `refresh`, `delete` | +| `json_object_old` | Full object JSON before the change (requires `$log_object = true`) | +| `json_object_new` | Full object JSON after the change | + +### `migrations` + +Tracks applied migration filenames. Prevents re-applying migrations after git pulls. + +### `passkeys` + +WebAuthn credentials per user. Stores the public key, credential ID, and sign count. + +### `domains` + +Active Directory / LDAP configuration per tenant for user synchronisation. + +### `translations` + +Available UI languages. Each row maps a language name to a gettext locale code. + +### `nmap_scans` + +One row per nmap host-discovery scan request. diff --git a/docs/architecture/multi-tenancy.md b/docs/architecture/multi-tenancy.md new file mode 100644 index 0000000..9f3528f --- /dev/null +++ b/docs/architecture/multi-tenancy.md @@ -0,0 +1,93 @@ +# Multi-Tenancy + +php-ssl is a multi-tenant application. Every piece of user data is scoped to a tenant, and tenants are fully isolated from one another. + +--- + +## Tenant identification + +Tenants are identified in URLs by their `href` slug (e.g. `/admins/`), not their numeric `id`. The slug is set when the tenant is created and used to construct all links within the application. + +The `tenants` table is the top of the ownership hierarchy: + +``` +tenants → zones → hosts → certificates +``` + +All primary tables (`zones`, `hosts`, `certificates`, `users`, `agents`, `cas`, `csrs`, `testssl`) carry a `t_id` (tenant ID) column. All queries are scoped to `$user->t_id` unless the user is an admin. + +--- + +## User roles + +User permission levels are stored in `users.permission`: + +| Level | Name | Can do | +|---|---|---| +| 0 | No access | Read-only with restricted views | +| 1 | Read | View certificates and zones | +| 2 | Write | Add hosts, trigger manual scans | +| 3 | Admin | Edit zones, import, delete | + +The `users.admin` flag (separate from `permission`) grants system-wide admin access: the user can see all tenants, manage other tenants' data, and access the Tenants menu. + +### Admin capabilities + +- View and manage all tenants +- Impersonate any user (see below) +- Apply database migrations +- See the system health banner warnings + +--- + +## Private zones + +A zone can be marked as private at creation time. The `zones.private_zone_uid` column controls visibility: + +| Value | Meaning | +|---|---| +| `NULL` | Public zone — visible to all users in the tenant (and admins) | +| `user.id` | Private zone — visible only to the creating user | + +**Key rules:** +- Admins **cannot** see private zones belonging to other users +- The zones list shows a note when hidden private zones exist in the tenant +- Cron scripts scan private zones but send notifications only to the zone creator — tenant-wide recipients are never BCC'd on private-zone changes +- If `$log_object = false` in `config.php`, private zone filtering in the logs page for deleted-zone records will not work correctly + +**Where private-zone filtering is applied:** +- `Zones::get_all()` +- `Zones::search_zone_hosts()` +- `Certificates::get_expired()` +- `route/ajax/zone-hosts.php` +- `route/ajax/certificates.php` +- `route/ajax/logs.php` + +--- + +## Admin impersonation + +Admins can impersonate any user. When impersonating, `$_SESSION['impersonate_original']` is set to the admin's original username. + +**Impersonation blocks all private zone access** — even the impersonated user's own private zones are hidden while the session is impersonated. This prevents admins from using impersonation to read private data. + +Check the flag anywhere sensitive access control decisions are made: + +```php +if (isset($_SESSION['impersonate_original'])) { + // block private zone access +} +``` + +Stopping impersonation clears this key. + +--- + +## Tenant configuration overrides + +The `config` table stores per-tenant key/value overrides for settings defined in `config.php`. The `Config` class reads these and merges them over the global defaults at runtime, so `$expired_days`, mail settings, `scanMaxThreads`, and other values can differ per tenant. + +The resolution order for user-facing expiry thresholds is: +1. User's personal setting (`users.days`) +2. Tenant config override (`config` table) +3. Global default (`config.php`) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..968c34e --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,130 @@ +# Architecture Overview + +## Request flow + +``` +Browser request + │ + ▼ +index.php + ├── Loads config.php + ├── require functions/autoload.php + │ ├── Instantiates all classes (Database_PDO, URL, User, SSL, …) + │ ├── Parses URL → $_params + │ └── Initialises gettext locale + │ + ├── Special top-level slugs (no tenant routing): + │ login/ → route/login/index.php + │ install/ → route/install/index.php + │ report// → public testSSL report (no auth) + │ + └── Authenticated request + ├── Session validation ($User->validate_session()) + └── route/content.php + └── route/{route}/index.php +``` + +The HTML shell (head, nav, sidebar) is rendered in `index.php`. The `
` content area is populated by the included route file. Modal dialogs and AJAX data endpoints are loaded asynchronously by the browser. + +--- + +## URL structure + +URLs follow the pattern: + +``` +/{tenant}/{route}/{app}/{id1}/ +``` + +| Segment | Key | Description | +|---|---|---| +| `tenant` | `$_params['tenant']` | The tenant's `href` slug (not its numeric ID) | +| `route` | `$_params['route']` | Feature area (e.g. `zones`, `certificates`, `testssl`) | +| `app` | `$_params['app']` | Sub-resource (e.g. zone name, certificate serial) | +| `id1` | `$_params['id1']` | Further qualifier (e.g. hostname within a zone) | + +Parsing is done by `class.URL.php`. Valid routes are defined in `$url_items` in `functions/config.menu.php`. Requests to `route/` paths are handled directly by the browser (AJAX/modal loads) and are excluded from tenant routing. + +### Examples + +| URL | What it shows | +|---|---| +| `/admins/zones/` | Zone list for the `admins` tenant | +| `/admins/zones/example.com/` | Host list for the `example.com` zone | +| `/admins/zones/example.com/web01.example.com/` | Host detail page | +| `/admins/certificates/example.com/0x1234ABCD/` | Certificate detail page | +| `/admins/testssl/abc123.../` | testssl scan result | +| `/report/abc123.../` | Public testssl report (no login required) | + +--- + +## Autoloader and globals + +`functions/autoload.php` runs on every web request and makes the following variables available globally in all route files and modal handlers: + +| Variable | Type | Description | +|---|---|---| +| `$Database` | `Database_PDO` | Database abstraction — all DB access goes through this | +| `$User` | `User` | Auth and session management | +| `$user` | `stdClass` | Current user row (`$user->t_id`, `$user->admin`, `$user->id`) | +| `$_params` | `array` | Parsed URL: `tenant`, `route`, `app`, `id1` | +| `$SSL` | `SSL` | SSL scanner | +| `$Certificates` | `Certificates` | Certificate CRUD | +| `$Zones` | `Zones` | Zone and host management | +| `$Tenants` | `Tenants` | Tenant management | +| `$Log` | `Log` | Audit logging | +| `$Modal` | `Modal` | Modal HTML builder | +| `$Result` | `Result` | JSON/alert response formatter | +| `$Config` | `Config` | Per-tenant config overrides | +| `$Cron` | `Cron` | Cron schedule management | +| `$testssl_available` | `bool` | Whether `functions/testSSL/testssl.sh` exists | + +`TestSSL` is **not** a global — instantiate it per-request with `new TestSSL($Database)`. + +--- + +## Route structure + +``` +route/ +├── content.php ← dispatcher: includes route/{route}/index.php +├── {feature}/ +│ └── index.php ← feature page +├── ajax/ +│ └── *.php ← JSON endpoints for Bootstrap Table (server-side pagination) +│ ├── ca/ ← CA-specific AJAX (download, delete, toggle flags) +│ ├── csr/ ← CSR-specific AJAX +│ └── passkey/ ← WebAuthn registration/auth +├── modals/ +│ └── {feature}/ +│ ├── edit.php ← renders modal form HTML +│ └── edit-submit.php ← processes POST, returns JSON result +└── common/ + ├── checks.php ← system health alerts (shown on every page) + ├── header.php ← top navigation bar + ├── header-notifications.php + └── left-menu.php ← sidebar menu +``` + +### Modal pattern + +Modals are loaded asynchronously. A link with `data-bs-toggle="modal"` and an `href` triggers `$('.modal-content').load(href)` in `js/magic.js`. Two modal sizes are available: + +- `#modal1` — standard width (default) +- `#modal2` — extra-large (`modal-xl`); trigger with `data-bs-target="#modal2"` + +--- + +## Frontend stack + +| Library | Version | Purpose | +|---|---|---| +| Tabler | 1.4.0 | Bootstrap-based admin UI | +| Bootstrap | (bundled with Tabler) | Layout and components | +| jQuery | 3.6.0 | DOM manipulation and AJAX | +| Bootstrap Table | 1.26.0 | Server-side paginated tables | +| Tippy.js | — | Tooltips | + +All libraries are bundled locally in `js/` and `css/` — no CDN dependency. + +Custom JavaScript lives in `js/magic.js`. The dark/light theme toggle is stored in `$_SESSION['theme']`. diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md new file mode 100644 index 0000000..9a21035 --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,132 @@ +# Configuration + +php-ssl uses a two-layer configuration system: + +1. **`config.php`** — global defaults, loaded on every request +2. **`config` database table** — per-tenant overrides managed through the UI (Settings menu) + +When both are set, the database value takes precedence for the tenant in question. + +--- + +## config.php Reference + +Copy `config.dist.php` to `config.php` to get started. Never commit `config.php` to version control — it contains credentials. + +### Database + +```php +$db['host'] = "127.0.0.1"; +$db['user'] = "phpssladmin"; +$db['pass'] = "phpssladmin"; +$db['name'] = "php-ssl"; +$db['port'] = 3306; +$db['ssl'] = false; // set true to require TLS for the DB connection +``` + +### Application state + +```php +$installed = false; // set true after running the web installer +$debugging = false; // set true to display PHP errors on-screen +``` + +### Base path + +Only needed when the application is **not** at the web root: + +```php +define('BASE', '/php-ssl'); // the URL prefix, no trailing slash +``` + +Also update `RewriteBase` in `.htaccess` to match. Note: CSS/JS paths are hardcoded as absolute paths (`/css/`, `/js/`), so the web server must be configured to serve them regardless of where the app lives. + +### Certificate expiry thresholds + +```php +$expired_days = 20; // warn N days before expiry (cron notifications) +$expired_after_days = 7; // continue reporting N days after expiry +``` + +These are the system-wide defaults. Users can set a personal value in their profile, and tenants can override via the `config` DB table. The user setting takes precedence over the tenant setting. + +### Audit logging + +```php +$log_object = true; // store full object JSON in logs.json_object_old/new +``` + +Setting this to `false` reduces database growth but disables some features: the private-zone log filter (for deleted-zone records) relies on JSON stored in this column. + +### Backup retention + +```php +$backup_retention_period = 30; // days to keep database backups +``` + +### Session + +```php +$phpsessname = "phpssl"; // PHP session name; change for added security +``` + +### Mail (SMTP) + +```php +$mail_settings->mtype = "smtp"; +$mail_settings->msecure = "tls"; // "tls", "ssl", or "" for none +$mail_settings->mauth = "no"; // "yes" to use SMTP authentication +$mail_settings->mserver = "127.0.0.1"; +$mail_settings->mport = 25; +$mail_settings->muser = ""; +$mail_settings->mpass = ""; +``` + +### Mail sender identity + +```php +$mail_sender_settings->mail_from = "SSL Certificate check"; +$mail_sender_settings->mail_addr = "noreply@mydomain.com"; +$mail_sender_settings->email = "php-ssl@mydomain.com"; // shown in mail footer +$mail_sender_settings->www = "https://mywebsite.com"; // shown in mail footer +$mail_sender_settings->bcc = ""; // always-BCC address +$mail_sender_settings->url = "myurl"; +``` + +### WebAuthn / Passkeys + +These are only required when running behind a reverse proxy that terminates TLS, because PHP cannot auto-detect the correct public origin in that case: + +```php +$webauthn_origin = "https://php-ssl.example.com"; // full public origin +$webauthn_rpid = "php-ssl.example.com"; // hostname only, no scheme +``` + +Leave both empty to auto-detect from the HTTP request. + +### nmap + +```php +$nmap_path = "/usr/bin/nmap"; // path to the nmap binary +``` + +The web server user must have execute permission on this binary. + +### Private key encryption + +Private keys stored in the database are encrypted with AES-256-GCM. Configure one encryption secret per tenant: + +```php +$private_key_encryption_key[1] = 'change-me-to-a-long-random-secret'; +$private_key_encryption_key[2] = 'another-secret-for-tenant-2'; +``` + +Use a different, long random string for each tenant. Keep `config.php` outside version control. + +--- + +## Per-Tenant Database Overrides + +Settings in the `config` table (key/value pairs scoped to a `t_id`) override the corresponding `config.php` values for that tenant. Managed via **Settings** in the tenant UI. + +Overridable settings include: `expired_days`, `expired_after_days`, `scanMaxThreads`, mail settings, and others. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..3a31fc5 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,124 @@ +# Installation + +## Requirements + +| Component | Minimum | +|---|---| +| OS | Linux or Unix-like | +| Web server | Apache 2.4+ or nginx | +| PHP | 8.0 (8.3 recommended) | +| Database | MySQL 5.7+ or MariaDB 10.3+ | + +**Required PHP extensions:** `curl`, `gettext`, `openssl`, `pcntl`, `PDO`, `pdo_mysql`, `session` + +**Optional:** +- `nmap` binary — for network host discovery scans +- `testssl.sh` — for deep TLS analysis (configured as a git submodule) + +--- + +## 1. Clone the repository + +Clone with submodules. The `functions/assets/` and `functions/testSSL/` directories are git submodules (Net_DNS2, PHPMailer, testssl.sh). + +```bash +cd /var/www/html/ +git clone --recursive https://github.com/phpipam/php-ssl.git php-ssl +``` + +If you already cloned without `--recursive`, initialise the submodules afterwards: + +```bash +cd php-ssl +git submodule update --init --recursive +``` + +--- + +## 2. Configure the application + +Copy the example config file and edit it: + +```bash +cp config.dist.php config.php +``` + +At minimum, update the database credentials. See [Configuration](configuration.md) for the full reference. + +--- + +## 3. Create the database + +```sql +CREATE DATABASE `php-ssl`; +CREATE USER 'phpssladmin'@'localhost' IDENTIFIED BY 'phpssladmin'; +GRANT ALL ON `php-ssl`.* TO 'phpssladmin'@'localhost'; +``` + +--- + +## 4. Run the web installer + +Open the installer in your browser: + +``` +http:///php-ssl/install/ +``` + +The installer will: +1. Verify the database connection +2. Import `db/SCHEMA.sql` (creates all tables and seeds default data) +3. Create the initial admin user + +Once complete, set the installed flag in `config.php` to disable the installer: + +```php +$installed = true; +``` + +> **Important:** Leaving `$installed = false` after a successful install allows anyone to re-run the installer and overwrite your database. + +--- + +## 5. Seed the cron schedule (if not done by installer) + +The installer seeds default cron schedules for tenant 1. If you need to re-add them manually: + +```sql +INSERT INTO `cron` (`t_id`, `minute`, `hour`, `day`, `month`, `weekday`, `script`) +VALUES + (1, '*/30', '*', '*', '*', '*', 'update_certificates'), + (1, '15', '2', '*', '*', '*', 'remove_orphaned'), + (1, '0', '8', '*', '*', '*', 'expired_certificates'), + (1, '0', '3', '*', '*', '*', 'axfr_transfer'), + (1, '15', '1', '*', '*', '*', 'backup'); +``` + +--- + +## 6. Configure the system crontab + +Add a single crontab entry to drive all scheduled jobs: + +```cron +*/5 * * * * /usr/bin/php /var/www/html/php-ssl/cron.php +``` + +This runs every 5 minutes. The application checks its internal per-tenant schedules (stored in the `cron` DB table) to decide which scripts to actually execute. See [Crontab Setup](../operations/crontab-setup.md) for details. + +--- + +## 7. Configure your web server + +See [Web Server Configuration](../operations/apache-nginx.md). + +--- + +## Default login + +| Field | Value | +|---|---| +| Email | `admin` | +| Password | `admin` | + +You will be prompted to change the password on first login. diff --git a/docs/getting-started/upgrading.md b/docs/getting-started/upgrading.md new file mode 100644 index 0000000..33e926a --- /dev/null +++ b/docs/getting-started/upgrading.md @@ -0,0 +1,84 @@ +# Upgrading + +php-ssl uses numbered SQL migration files to evolve the database schema incrementally. After every `git pull` that includes new migration files, you must apply any pending migrations. + +--- + +## Migration files + +Migrations live in `db/migrations/` and are named `NNNN_description.sql`, for example: + +``` +0001_add_changepass_to_users.sql +0002_add_test_to_users.sql +... +``` + +Applied migrations are tracked in the `migrations` database table (by filename). A migration is never applied twice. + +--- + +## Checking for pending migrations + +### In the UI + +A warning banner is shown at the top of every page for admin users when unapplied migrations exist. It links directly to the migration management page. + +### From the command line + +```bash +php db/migrate.php status +``` + +Output: + +``` +DB version : 0023_add_testssl_table.sql +Latest : 0030_logs_mediumtext.sql +Pending : 7 + +Pending migrations: + - 0024_add_notify_email_to_testssl.sql + - 0025_cas_tenant_fk.sql + ... +``` + +--- + +## Applying migrations + +### From the command line (recommended) + +```bash +php db/migrate.php +``` + +This applies all pending migrations in order and reports success or failure for each. + +### From the UI + +Admins can apply individual migrations from the **Database Migrations** page in the application. + +--- + +## Keeping SCHEMA.sql in sync + +`db/SCHEMA.sql` is a full dump of the schema and is used by the web installer. After any schema change — whether a new migration file or a direct alteration — regenerate it: + +```bash +mysqldump --no-data --routines php-ssl > db/SCHEMA.sql +``` + +Commit both the migration file and the updated `SCHEMA.sql` together. + +--- + +## Upgrade procedure summary + +```bash +git pull +git submodule update --recursive +php db/migrate.php +``` + +If `cron.php` is running, migrations are safe to apply while it runs — each migration is a discrete SQL statement and the cron scripts are tenant-scoped. diff --git a/docs/operations/apache-nginx.md b/docs/operations/apache-nginx.md new file mode 100644 index 0000000..f3f9172 --- /dev/null +++ b/docs/operations/apache-nginx.md @@ -0,0 +1,132 @@ +# Web Server Configuration + +php-ssl is a standard PHP application with no build step. Deploy it to a directory served by Apache or nginx and configure URL rewriting so all requests pass through `index.php`. + +--- + +## Apache + +### Virtual host example (app at web root) + +```apache + + ServerName php-ssl.example.com + DocumentRoot /var/www/html/php-ssl + + SSLEngine on + SSLCertificateFile /etc/ssl/certs/php-ssl.crt + SSLCertificateKeyFile /etc/ssl/private/php-ssl.key + + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + +``` + +The repository includes an `.htaccess` file that enables `mod_rewrite` and routes all requests to `index.php`. Ensure `AllowOverride All` is set, or copy the rewrite rules directly into your virtual host config. + +### Running at a sub-path + +If the app is **not** at the web root (e.g. `https://example.com/php-ssl/`): + +1. Set the `BASE` constant in `config.php`: + + ```php + define('BASE', '/php-ssl'); + ``` + +2. Update `RewriteBase` in `.htaccess`: + + ```apache + RewriteBase /php-ssl + ``` + +3. Configure the web server to serve the CSS/JS assets from their absolute paths. The templates hardcode `/css/` and `/js/` (not relative to `BASE`), so the server must map those paths: + + ```apache + Alias /css /var/www/html/php-ssl/css + Alias /js /var/www/html/php-ssl/js + ``` + +--- + +## nginx + +### Server block example (app at web root) + +```nginx +server { + listen 443 ssl; + server_name php-ssl.example.com; + + ssl_certificate /etc/ssl/certs/php-ssl.crt; + ssl_certificate_key /etc/ssl/private/php-ssl.key; + + root /var/www/html/php-ssl; + index index.php; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.3-fpm.sock; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } + + # Deny direct access to sensitive files + location ~ /\.(ht|git) { + deny all; + } +} +``` + +### Running at a sub-path with nginx + +```nginx +location /php-ssl/ { + alias /var/www/html/php-ssl/; + try_files $uri $uri/ /php-ssl/index.php?$query_string; + + location ~ \.php$ { + fastcgi_pass unix:/run/php/php8.3-fpm.sock; + fastcgi_param SCRIPT_FILENAME /var/www/html/php-ssl$fastcgi_script_name; + include fastcgi_params; + } +} + +# Serve hardcoded absolute asset paths +location /css/ { alias /var/www/html/php-ssl/css/; } +location /js/ { alias /var/www/html/php-ssl/js/; } +``` + +Set `define('BASE', '/php-ssl');` in `config.php`. + +--- + +## Security recommendations + +- Serve over HTTPS only; redirect HTTP to HTTPS. +- Deny direct browser access to `config.php`, `functions/`, `db/`, and `cron.php`: + + **Apache:** + ```apache + + Require all denied + + + Require all denied + + ``` + + **nginx:** + ```nginx + location ~ ^/(config\.php|cron\.php)$ { deny all; } + location ~ ^/(functions|db)/ { deny all; } + ``` + +- The `install/` route is disabled by setting `$installed = true` in `config.php`. Consider also blocking it at the web server level after installation. diff --git a/docs/operations/crontab-setup.md b/docs/operations/crontab-setup.md new file mode 100644 index 0000000..f1ebf44 --- /dev/null +++ b/docs/operations/crontab-setup.md @@ -0,0 +1,80 @@ +# Crontab Setup + +php-ssl uses a two-level scheduling system: + +1. **System crontab** — triggers `cron.php` every 5 minutes +2. **In-app schedules** — per-tenant cron schedules stored in the `cron` DB table, checked by `cron.php` on each run + +--- + +## System crontab entry + +Add one entry to the system crontab (as the web server user or a dedicated service account): + +```cron +*/5 * * * * /usr/bin/php /var/www/html/php-ssl/cron.php +``` + +This is the only system-level entry needed. All scheduling granularity beyond "every 5 minutes" is handled by the in-app schedules. + +--- + +## In-app schedules + +Schedules are managed per-tenant under **Scanning → Cron** in the UI. Each row in the `cron` table has standard cron fields (`minute`, `hour`, `day`, `month`, `weekday`) plus a `script` name. + +Default schedules seeded at install time for tenant 1: + +| Script | Default schedule | Purpose | +|---|---|---| +| `update_certificates` | Every 30 minutes | Scan all hosts for certificate changes | +| `expired_certificates` | Daily at 08:00 | Send expiry notification emails | +| `remove_orphaned` | Daily at 02:15 | Remove certificates no longer assigned to any host | +| `axfr_transfer` | Daily at 03:00 | Perform DNS zone transfers (AXFR) | +| `backup` | Daily at 01:15 | Create a database backup | + +**Note:** `testssl_scan` is not in this table. It runs on every `cron.php` invocation regardless of schedule, picks up any scans with status `Requested`, and executes testssl.sh. + +### Force flag + +Each schedule row has a `force` column. Setting it to `1` causes the script to run on the next cron execution regardless of whether the schedule time has been reached, then resets to `0`. + +--- + +## Manual execution + +Run any cron script directly from the command line: + +```bash +php cron.php +``` + +Examples: + +```bash +php cron.php 1 update_certificates +php cron.php 1 expired_certificates +php cron.php 1 axfr_transfer +php cron.php 1 remove_orphaned +php cron.php 1 backup +``` + +All cron scripts are CLI-only and will exit with an error if called from a web request. + +--- + +## Parallelism + +`update_certificates` uses `pcntl_fork` to scan hosts in parallel. The maximum number of concurrent processes is controlled by the `scanMaxThreads` config setting (default: 64, configurable per tenant). The `pcntl` PHP extension must be installed and enabled. + +--- + +## FreeBSD note + +testssl.sh on FreeBSD requires `fdescfs` mounted at `/dev/fd`: + +``` +fdescfs /dev/fd fdescfs rw 0 0 +``` + +Add this to `/etc/fstab` and mount before running scans. diff --git a/docs/operations/notifications.md b/docs/operations/notifications.md new file mode 100644 index 0000000..9094628 --- /dev/null +++ b/docs/operations/notifications.md @@ -0,0 +1,118 @@ +# Email Notifications + +php-ssl sends email notifications from two cron scripts: + +- **`update_certificates.php`** — fired when a certificate changes (new cert detected on a host) +- **`expired_certificates.php`** — fired when certificates are expiring or have already expired + +Both scripts build the same recipient model and send mail through the shared `mailer` class backed by PHPMailer. + +--- + +## Recipient Model + +For each tenant, every notification run collects a set of email addresses. The final delivery depends on whether a changed certificate belongs to a public zone or a private zone. + +### 1. Tenant recipients (public zones only) + +The tenant record has a **Recipients** field (`tenants.recipients`). This is a free-form, semicolon-separated list of email addresses. These are not necessarily app users — they can be any address (a team alias, a ticketing system, etc.). + +> **Example:** `ops@example.com; monitoring@example.com` + +All valid addresses from this field are collected into `$email_to_tenant_recipents`. A single email is sent to the whole list as multiple `To:` recipients, so all tenant recipients receive the same combined message. + +### 2. Per-host / per-certificate recipients (public zones only) + +Each host row has an `h_recipients` column. This is also a semicolon-separated list of email addresses. Certificates being monitored by `expired_certificates.php` use the same column (hosts share a certificate by FK, so the host's `h_recipients` drives which individual addresses get notified). + +Each address found here that is **not already a tenant recipient** receives its own individual email (one `To:` per address). The message body contains only the entries relevant to that address. + +### 3. Private zone creator (private zones only) + +When a zone is marked private (`zones.private_zone_uid IS NOT NULL`), the zone creator's email address is used **instead of** tenant recipients and per-host recipients. The creator is looked up directly from the `users` table by their `id`. + +Private zone notifications are never sent to tenant recipients, even as BCC. This ensures other users cannot infer the existence of private zones from notification traffic. + +### 4. Global BCC (all sends) + +`config.php` has a `$mail_sender_settings->bcc` setting. When non-empty, this address is silently added as BCC to **every** outgoing email — both the tenant-wide send and every individual per-host send. This is useful for a central audit address. + +--- + +## Delivery Matrix + +| Certificate belongs to | Recipient(s) | BCC | +|---|---|---| +| Public zone | Tenant recipients (combined) | Global BCC (if set) | +| Public zone | Per-host addresses (individual sends) | Tenant recipients + Global BCC | +| Private zone | Zone creator only (individual send) | Global BCC only — **no** tenant recipients | + +--- + +## Where to Configure Each Setting + +| Setting | Where | +|---|---| +| Tenant recipients | Tenants → edit tenant → **Recipients** field | +| Per-host recipients | Zones → expand zone → host row → **Recipients** column | +| Global BCC | `config.php` → `$mail_sender_settings->bcc` | +| SMTP server / auth | `config.php` → `$mail_settings` block | +| Sender name / address | `config.php` → `$mail_sender_settings->mail_from` / `mail_addr` | + +--- + +## Email Format + +Each notification email is self-contained HTML. The format (table vs. list layout) is controlled by the **Mail style** setting on the tenant (`tenants.mail_style`): + +- **`table`** (default) — one row per host/certificate in an HTML table +- **`list`** — one block per host/certificate with labelled fields; used when `mail_style = "list"` + +Tenant recipients always receive the format determined by the tenant's `mail_style` setting. Per-host recipients and private zone creators always receive the list format. + +--- + +## Notification Triggers + +### Certificate change notifications (`update_certificates.php`) + +A notification is sent when a host's `last_change` timestamp matches the current cron execution time — meaning the SSL scan detected a new or different certificate on that host since the last scan. Muted hosts (`hosts.mute = 1`) are excluded. + +Certificates whose issuer is in the tenant's ignored issuers list are silently skipped (no email, no log entry). + +### Certificate expiry notifications (`expired_certificates.php`) + +A notification is sent for any certificate where: + +- The expiry date is within `$expired_days` days from now (configurable in `config.php`, default 20), **or** +- The certificate has already expired but less than `$expired_after_days` days ago (default 7) + +Certificates whose issuer is marked as ignored for expiry in the tenant's ignored issuers list are skipped. + +The expiry email groups certificates into two sections: **Expired** and **Certificates that will expire soon**. + +--- + +## Audit Log + +Every send attempt is recorded in the `logs` table. The log message distinguishes: + +- `"Certificate change notification email sent to tenant recipients"` — tenant-wide send succeeded +- `"Certificate change notification email FAILED for tenant recipients"` — send attempted but PHPMailer reported an error +- `"Certificate change notification email sent to user {name} ({email})"` — individual per-host or private-zone send succeeded +- `"Certificate change notification email FAILED for user {name} ({email})"` — individual send failed + +No log entry is written if there are no tenant recipients configured and no per-host/private-zone addresses to notify. + +--- + +## Troubleshooting + +**Connection appears in relay logs but no mail is delivered** +PHPMailer must be initialized with exception mode enabled (`new PHPMailer(true)`). Without it, SMTP failures (e.g. RCPT rejection) return `false` silently and the code assumes success. Check `stderr` output from the cron run for `Mailer Error:` lines. + +**Recipients field is empty / no sends attempted** +Check `tenants.recipients` in the database or via the tenant edit form. The field is parsed by splitting on `;` and `,`, then each token is validated as a syntactically valid email address. Invalid or blank entries are silently dropped. + +**Private zone owner not notified** +Verify `zones.private_zone_uid` matches a valid `users.id` and that the user record has a valid `email`. Also confirm the cron is not running under an impersonation session — impersonation blocks private zone access entirely. diff --git a/docs/operations/troubleshooting.md b/docs/operations/troubleshooting.md new file mode 100644 index 0000000..69a9eb2 --- /dev/null +++ b/docs/operations/troubleshooting.md @@ -0,0 +1,150 @@ +# Troubleshooting + +## Installation + +### "Config file missing" on first load + +`config.php` does not exist. Copy `config.dist.php` to `config.php` and fill in your database credentials before loading the app. + +### Installer redirects to itself repeatedly + +The installer checks the DB connection. Verify: +- The DB host, user, password, and database name in `config.php` are correct +- The MySQL/MariaDB server is running and reachable from the web server +- The DB user has `ALL` privileges on the database + +### Blank page or HTTP 500 after install + +Enable debugging temporarily to see the error: + +```php +$debugging = true; +``` + +Remember to set it back to `false` afterwards. + +--- + +## Git submodules + +The **System Health** banner (visible to all logged-in users) shows a warning if any of the following are missing: + +| File | Submodule | +|---|---| +| `functions/assets/Net_DNS2/Net/DNS2.php` | Net_DNS2 | +| `functions/assets/PHPMailer/src/PHPMailer.php` | PHPMailer | +| `functions/testSSL/testssl.sh` | testssl.sh | + +Fix with: + +```bash +git submodule update --init --recursive +``` + +--- + +## Scanning + +### "Threading is required for scanning certificates" + +The `pcntl` PHP extension is missing or disabled. Install it: + +```bash +# Debian/Ubuntu +apt install php8.3-pcntl + +# RHEL/CentOS +yum install php-process +``` + +Verify it is loaded: + +```bash +php -m | grep pcntl +``` + +### Hosts show "Unresolved" IP + +The web server user cannot resolve DNS for the hostname. Check: +- `/etc/resolv.conf` is correctly configured +- The hostname is reachable from the server +- If the host is internal, consider using a [remote scanning agent](../features/agents.md) + +### Certificate fetch fails with SSL error + +Common causes: +- The server is only reachable on a non-standard port — check the port group assignment for the zone +- A firewall blocks outbound connections on port 443 from the web server +- The remote server requires SNI and the PHP `openssl` extension version is old + +### Cron doesn't run + +1. Confirm the system crontab entry exists: `crontab -l` +2. Check the cron log: `grep CRON /var/log/syslog` +3. Run manually to see output: `php /var/www/html/php-ssl/cron.php` +4. Confirm the in-app schedule exists under **Scanning → Cron** for your tenant + +--- + +## Database migrations + +### Warning banner: "There are unapplied database migrations" + +Apply pending migrations: + +```bash +php db/migrate.php +``` + +Or apply them individually through the admin UI. + +### Migration fails with a foreign key error + +This usually means the schema is in a state inconsistent with the migration, often from a partial previous run. Check the error message for the table and column involved, then inspect the current schema: + +```bash +mysql -u phpssladmin -p php-ssl -e "DESCRIBE ;" +``` + +--- + +## testssl.sh + +### Scans stay in "Requested" state + +- Confirm `functions/testSSL/testssl.sh` exists (submodule initialised) +- Confirm `cron.php` is running — testssl scans are picked up on every cron run +- Check for errors in the scan record via the testssl detail page + +### testssl.sh fails on FreeBSD + +Mount `fdescfs`: + +``` +fdescfs /dev/fd fdescfs rw 0 0 +``` + +Add to `/etc/fstab` and run `mount /dev/fd`. + +--- + +## Email notifications + +### No emails sent after certificate change + +1. Verify SMTP settings in `config.php` (or the tenant's config overrides) +2. Check that the tenant has at least one recipient configured under **Tenants → Edit** +3. Run `update_certificates` manually and check for mail-related PHP errors: `php cron.php 1 update_certificates` +4. Confirm the host is not muted (mute icon in the zone host list) + +--- + +## PHP version + +A runtime warning is shown to all users if PHP < 8.0 is detected. The server requires PHP 8.0+; PHP 8.3 is the recommended version. Check your active version: + +```bash +php --version +``` + +If multiple PHP versions are installed, ensure the CLI version matches the web server version. diff --git a/functions/classes/class.Mail.php b/functions/classes/class.Mail.php index 0eb2058..438edea 100644 --- a/functions/classes/class.Mail.php +++ b/functions/classes/class.Mail.php @@ -172,8 +172,8 @@ private function initialize_mailer() require_once(dirname(__FILE__) . '/../assets/PHPMailer/src/PHPMailer.php'); require_once(dirname(__FILE__) . '/../assets/PHPMailer/src/SMTP.php'); require_once(dirname(__FILE__) . '/../assets/PHPMailer/src/Exception.php'); - // initialize object - $this->Php_mailer = new PHPMailer\PHPMailer\PHPMailer(); + // initialize object — pass true to enable exceptions on SMTP/send failure + $this->Php_mailer = new PHPMailer\PHPMailer\PHPMailer(true); $this->Php_mailer->CharSet = "UTF-8"; //set utf8 $this->Php_mailer->SMTPDebug = 0; //default no debugging @@ -267,7 +267,7 @@ public function clear_recipients() * @param array $bcc * @param string $content * @param bool $print_success - * @return [type] + * @return bool true on success, false on failure */ public function send($title = "", $to = array(), $cc = array(), $bcc = array(), $content = "", $print_success = true) { @@ -293,8 +293,10 @@ public function send($title = "", $to = array(), $cc = array(), $bcc = array(), $this->Php_mailer->addBCC($t); } } - // BCC mihapet always - // $this->Php_mailer->addBCC("miha.petkovsek@gmail.com"); + // Global BCC from config + if (!empty($this->settings->bcc)) { + $this->Php_mailer->addBCC($this->settings->bcc); + } // subject $this->Php_mailer->Subject = $title; @@ -302,16 +304,29 @@ public function send($title = "", $to = array(), $cc = array(), $bcc = array(), //send $this->Php_mailer->send(); } - catch (phpmailerException $e) { - print $this->Result->show("danger", "Mailer Error: " . $e->errorMessage()); + catch (PHPMailer\PHPMailer\Exception $e) { + $msg = "Mailer Error: " . $e->getMessage(); + if (php_sapi_name() === 'cli') { + fwrite(STDERR, $msg . PHP_EOL); + } else { + print $this->Result->show("danger", $msg); + } + return false; } catch (Exception $e) { - print $this->Result->show("danger", "Mailer Error: " . $e->errorMessage()); + $msg = "Mailer Error: " . $e->getMessage(); + if (php_sapi_name() === 'cli') { + fwrite(STDERR, $msg . PHP_EOL); + } else { + print $this->Result->show("danger", $msg); + } + return false; } // ok if ($print_success) print $this->Result->show("success", "Obvestilo poslano."); + return true; } /** diff --git a/functions/cron/expired_certificates.php b/functions/cron/expired_certificates.php index 34f22cc..fde2b2c 100644 --- a/functions/cron/expired_certificates.php +++ b/functions/cron/expired_certificates.php @@ -248,19 +248,26 @@ unset($rows); // send to tenant recipients together - $Mail->send ("Telemach php-ssl :: certificate expiration [".$tenant->name."]", $email_to_tenant_recipents, [], [], implode("\n", $content[$email_to_tenant_recipents[0]]), false); - - // Log $Log = new Log ($Database); - $Log->write ("users", NULL, $tenant->id, null, "notification", true, "Certificate expire notification email sent to all tenant admins", json_encode([$email]), json_encode(["title"=>"Telemach php-ssl :: certificate expiration [".$tenant->name."]", "data"=>implode("\n", $content[$email_to_tenant_recipents[0]])]), false); + if (!empty($email_to_tenant_recipents)) { + $sent = $Mail->send ("Telemach php-ssl :: certificate expiration [".$tenant->name."]", $email_to_tenant_recipents, [], [], implode("\n", $content[$email_to_tenant_recipents[0]]), false); + if ($sent) { + $Log->write ("users", NULL, $tenant->id, null, "notification", true, "Certificate expiry notification email sent to tenant recipients", json_encode($email_to_tenant_recipents), json_encode(["title"=>"Telemach php-ssl :: certificate expiration [".$tenant->name."]", "data"=>implode("\n", $content[$email_to_tenant_recipents[0]])]), false); + } else { + $Log->write ("users", NULL, $tenant->id, null, "notification", true, "Certificate expiry notification email FAILED for tenant recipients", json_encode($email_to_tenant_recipents), null, false); + } + } // send to per-cert recipients individually; private zone creators get no BCC to tenant recipients foreach ($content as $email => $rows) { if (!in_array($email, $email_to_tenant_recipents)) { $bcc = isset($private_zone_emails[$email]) ? [] : $email_to_tenant_recipents; - $Mail->send ("Telemach php-ssl :: certificate expiration", [$email], [], $bcc, implode("\n", $rows), false); - // Log - $Log->write ("users", $all_users[$email]->id ?? null, $tenant->id, null, "notification", true, "Certificate expire notification email sent to user ".(isset($all_users[$email]) ? $all_users[$email]->name : $email)." (".$email.")", json_encode([$email]), json_encode(["title"=>"Telemach php-ssl :: certificate expiration", "data"=>$rows]), false); + $sent = $Mail->send ("Telemach php-ssl :: certificate expiration", [$email], [], $bcc, implode("\n", $rows), false); + if ($sent) { + $Log->write ("users", $all_users[$email]->id ?? null, $tenant->id, null, "notification", true, "Certificate expiry notification email sent to user ".(isset($all_users[$email]) ? $all_users[$email]->name : $email)." (".$email.")", json_encode([$email]), json_encode(["title"=>"Telemach php-ssl :: certificate expiration", "data"=>$rows]), false); + } else { + $Log->write ("users", $all_users[$email]->id ?? null, $tenant->id, null, "notification", true, "Certificate expiry notification email FAILED for user ".(isset($all_users[$email]) ? $all_users[$email]->name : $email)." (".$email.")", json_encode([$email]), null, false); + } } } } diff --git a/functions/cron/update_certificates.php b/functions/cron/update_certificates.php index 174869a..8f6d377 100644 --- a/functions/cron/update_certificates.php +++ b/functions/cron/update_certificates.php @@ -267,21 +267,26 @@ $log_content_rows[] = "

".$Mail->font_norm."Visit ".$mail_sender_settings->www.""; // send to tenant recipients together + $Log = new Log ($Database); if (!empty($email_to_tenant_recipents)) { - $Mail->send ("Telemach php-ssl :: changed certificates [".$tenant->name."]", $email_to_tenant_recipents, [], [], implode("\n", $content[$email_to_tenant_recipents[0]]), false); + $sent = $Mail->send ("Telemach php-ssl :: changed certificates [".$tenant->name."]", $email_to_tenant_recipents, [], [], implode("\n", $content[$email_to_tenant_recipents[0]]), false); + if ($sent) { + $Log->write ("users", NULL, $tenant->id, null, "notification", true, "Certificate change notification email sent to tenant recipients", json_encode($email_to_tenant_recipents), json_encode(["title"=>"Telemach php-ssl :: changed certificates [".$tenant->name."]", "data"=>$log_content_rows]), false); + } else { + $Log->write ("users", NULL, $tenant->id, null, "notification", true, "Certificate change notification email FAILED for tenant recipients", json_encode($email_to_tenant_recipents), null, false); + } } - // Log - $Log = new Log ($Database); - $Log->write ("users", NULL, $tenant->id, null, "notification", true, "Certificate change notification email sent to all tenant admins for certificate change", json_encode($email_to_tenant_recipents), json_encode(["title"=>"Telemach php-ssl :: changed certificates [".$tenant->name."]", "data"=>$log_content_rows]), false); - // send to per-host recipients individually; private zone creators get no BCC to tenant recipients foreach ($content as $email => $rows) { if (!in_array($email, $email_to_tenant_recipents)) { $bcc = isset($private_zone_emails[$email]) ? [] : $email_to_tenant_recipents; - $Mail->send ("Telemach php-ssl :: changed certificates", [$email], [], $bcc, implode("\n", $rows), false); - // Log - $Log->write ("users", $all_users[$email]->id ?? null, $tenant->id, null, "notification", true, "Certificate change notification email sent to user ".(isset($all_users[$email]) ? $all_users[$email]->name : $email)." (".$email.")", json_encode([$email]), json_encode(["title"=>"Telemach php-ssl :: changed certificates", "data"=>$rows]), false); + $sent = $Mail->send ("Telemach php-ssl :: changed certificates", [$email], [], $bcc, implode("\n", $rows), false); + if ($sent) { + $Log->write ("users", $all_users[$email]->id ?? null, $tenant->id, null, "notification", true, "Certificate change notification email sent to user ".(isset($all_users[$email]) ? $all_users[$email]->name : $email)." (".$email.")", json_encode([$email]), json_encode(["title"=>"Telemach php-ssl :: changed certificates", "data"=>$rows]), false); + } else { + $Log->write ("users", $all_users[$email]->id ?? null, $tenant->id, null, "notification", true, "Certificate change notification email FAILED for user ".(isset($all_users[$email]) ? $all_users[$email]->name : $email)." (".$email.")", json_encode([$email]), null, false); + } } } } 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 81d2b4b..9f82fe6 100644 --- a/functions/locale/de_DE.UTF-8/LC_MESSAGES/messages.po +++ b/functions/locale/de_DE.UTF-8/LC_MESSAGES/messages.po @@ -440,6 +440,9 @@ msgstr "Privat" msgid "Only visible to you — not even admins can see it" msgstr "Nur für Sie sichtbar – nicht einmal Admins können es sehen" +msgid "Appends zone name to each host" +msgstr "Hängt den Zonennamen an jeden Host an" + msgid "Only visible to you" msgstr "Nur für Sie sichtbar" @@ -1024,6 +1027,9 @@ msgstr "Verwaiste entfernen" msgid "Mail recipients" msgstr "E-Mail-Empfänger" +msgid "Tenant mail recipients" +msgstr "Mandanten-E-Mail-Empfänger" + msgid "Mail style" msgstr "E-Mail-Stil" diff --git a/functions/locale/sl_SI.UTF-8/LC_MESSAGES/messages.po b/functions/locale/sl_SI.UTF-8/LC_MESSAGES/messages.po index 4703e5e..1137f2b 100644 --- a/functions/locale/sl_SI.UTF-8/LC_MESSAGES/messages.po +++ b/functions/locale/sl_SI.UTF-8/LC_MESSAGES/messages.po @@ -900,6 +900,9 @@ msgstr "Vidno samo vam" msgid "Only visible to you — not even admins can see it" msgstr "Vidno samo vam — tega ne vidijo niti skrbniki" +msgid "Appends zone name to each host" +msgstr "Imenu gostitelja doda ime cone" + msgid "Remove all hosts from zone" msgstr "Odstrani vse gostitelje iz cone" @@ -1367,6 +1370,9 @@ msgstr "Prejemniki obvestil" msgid "Mail recipients" msgstr "Poštni prejemniki" +msgid "Tenant mail recipients" +msgstr "Poštni prejemniki najemnika" + msgid "Recipients updated" msgstr "Prejemniki posodobljeni" diff --git a/route/cas/ca-certificates/ca-certificate.php b/route/cas/ca-certificates/ca-certificate.php index fa9baa8..bbcb6c8 100644 --- a/route/cas/ca-certificates/ca-certificate.php +++ b/route/cas/ca-certificates/ca-certificate.php @@ -55,12 +55,29 @@ return; } +// Build the trust chain above this CA by walking parent_ca_id upward in the DB. +// The chain PEM is assembled parent-first (certificate-chain-steps.php reverses it). +$_ca_chain_pem = ''; +$_walk_id = (int)($ca->parent_ca_id ?? 0); +$_visited = [(int)$ca->id => true]; // guard against circular references +while ($_walk_id > 0) { + if (isset($_visited[$_walk_id])) break; // cycle detected + $_visited[$_walk_id] = true; + $_ancestor = $Database->getObjectQuery( + "SELECT id, certificate, parent_ca_id FROM cas WHERE id = ? AND t_id = ?", + [$_walk_id, (int)$ca->t_id] + ); + if (!$_ancestor || empty($_ancestor->certificate)) break; + $_ca_chain_pem .= trim($_ancestor->certificate) . "\n"; + $_walk_id = (int)($_ancestor->parent_ca_id ?? 0); +} + // Build compatibility object for sub-pages that expect $certificate $certificate = (object)[ 'id' => $ca->id, 'certificate' => $ca->certificate, 'pkey_id' => $ca->pkey_id ?? null, - 'chain' => null, + 'chain' => $_ca_chain_pem !== '' ? $_ca_chain_pem : null, 'is_manual' => '0', 'created' => $ca->created, 't_id' => $ca->t_id, @@ -256,5 +273,22 @@ + +
+
+
+ + +
+
+ chain)): ?> +
+ + + +
+
+
+ diff --git a/route/cas/ca-certificates/index.php b/route/cas/ca-certificates/index.php index 213b4b9..6d336a6 100644 --- a/route/cas/ca-certificates/index.php +++ b/route/cas/ca-certificates/index.php @@ -10,8 +10,8 @@ $all_tenants = $Tenants->get_all(); $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, +$select = "SELECT ca.id, ca.t_id, ca.name, ca.subject, ca.certificate, ca.expires, ca.created, ca.parent_ca_id, + ca.ignore_updates, ca.ignore_expiry, ca.serial, ca.ski, pca.name AS parent_ca_name, (pk.id IS NOT NULL AND pk.private_key_enc IS NOT NULL AND pk.private_key_enc != '') AS has_pkey, (SELECT COUNT(*) FROM certificates c WHERE c.aki = ca.ski AND c.t_id = ca.t_id) AS cert_count diff --git a/route/cas/index.php b/route/cas/index.php index c8355e4..1de6167 100644 --- a/route/cas/index.php +++ b/route/cas/index.php @@ -11,8 +11,8 @@ // get all tenants $all_tenants = $Tenants->get_all(); $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, +$select = "SELECT ca.id, ca.t_id, ca.name, ca.subject, ca.certificate, ca.expires, ca.created, ca.parent_ca_id, + ca.ignore_updates, ca.ignore_expiry, ca.serial, ca.ski, pca.name AS parent_ca_name, (pk.id IS NOT NULL AND pk.private_key_enc IS NOT NULL AND pk.private_key_enc != '') AS has_pkey, (SELECT COUNT(*) FROM certificates c WHERE c.aki = ca.ski AND c.t_id = ca.t_id) AS cert_count diff --git a/route/cas/table.php b/route/cas/table.php index 6764874..e362fe7 100644 --- a/route/cas/table.php +++ b/route/cas/table.php @@ -26,7 +26,32 @@ function ca_tree_sort(array $cas): array { // parent_ca_id is set but the parent CA is not known — incomplete chain $orphaned[] = $ca; } else { - $roots[] = $ca; + // parent_ca_id is null/0 — check whether this is truly self-signed (root) + // or an intermediate whose issuer was simply never served in any chain + $is_root = true; + if (!empty($ca->certificate)) { + $parsed = @openssl_x509_parse($ca->certificate); + if ($parsed) { + $aki_raw = trim($parsed['extensions']['authorityKeyIdentifier'] ?? ''); + $aki = ''; + foreach (explode("\n", $aki_raw) as $line) { + if (stripos($line, 'keyid:') !== false) { + $aki = trim(str_replace('keyid:', '', $line)); + break; + } + } + $ski = trim($ca->ski ?? $parsed['extensions']['subjectKeyIdentifier'] ?? ''); + // Non-empty AKI that differs from own SKI means a parent exists outside our DB + if ($aki !== '' && $ski !== '' && strcasecmp($aki, $ski) !== 0) { + $is_root = false; + } + } + } + if ($is_root) { + $roots[] = $ca; + } else { + $orphaned[] = $ca; + } } } $result = []; @@ -181,7 +206,40 @@ class="table table-hover align-top table-md" print ""; print " "; print " "; - $parent_html = $ca->parent_ca_name ? "" . htmlspecialchars($ca->parent_ca_name) . "" : ""; + if ($ca->parent_ca_name) { + // Parent is captured in the DB — link it + $parent_html = "" . htmlspecialchars($ca->parent_ca_name) . ""; + } elseif (!empty($ca->certificate)) { + // Parent not captured — derive issuer name from the cert PEM + $_pca_parsed = @openssl_x509_parse($ca->certificate); + if ($_pca_parsed) { + $_issuer_cn = $_pca_parsed['issuer']['CN'] ?? ''; + $_issuer_o = $_pca_parsed['issuer']['O'] ?? ''; + $_issuer_lbl = $_issuer_cn ?: $_issuer_o; + // Determine if this is a root (self-signed: AKI matches own SKI) + $_aki_raw = trim($_pca_parsed['extensions']['authorityKeyIdentifier'] ?? ''); + $_aki = ''; + foreach (explode("\n", $_aki_raw) as $_line) { + if (stripos($_line, 'keyid:') !== false) { + $_aki = trim(str_replace('keyid:', '', $_line)); + break; + } + } + $_ski = trim($ca->ski ?? $_pca_parsed['extensions']['subjectKeyIdentifier'] ?? ''); + $_is_root = $_aki === '' || ($_ski !== '' && strcasecmp($_aki, $_ski) === 0); + if ($_is_root) { + $parent_html = "" . _("Root") . ""; + } elseif ($_issuer_lbl !== '') { + $parent_html = "" . htmlspecialchars($_issuer_lbl) . ""; + } else { + $parent_html = ""; + } + } else { + $parent_html = ""; + } + } else { + $parent_html = ""; + } print " "; print " "; print " "; diff --git a/route/certificates/certificate/certificate-chain-steps.php b/route/certificates/certificate/certificate-chain-steps.php index a868a36..2d6c40a 100644 --- a/route/certificates/certificate/certificate-chain-steps.php +++ b/route/certificates/certificate/certificate-chain-steps.php @@ -1,6 +1,16 @@ getObjectsQuery( + "SELECT id, serial, ski FROM cas WHERE t_id = ? AND ski IS NOT NULL AND ski != ''", + [(int)$certificate->t_id] +); +foreach ($_cas_for_link as $_ca_row) { + $_ca_by_ski[strtolower(trim($_ca_row->ski))] = $_ca_row; +} + // chain $delimiter = "-----BEGIN CERTIFICATE-----\n"; $chains = array_reverse(array_values(array_filter(explode($delimiter, $certificate->chain)))); @@ -61,7 +71,15 @@ print "
{$indent}{$angle_icon_show}{$ca_icon} {$name_html}" . htmlspecialchars($ca->subject ?? '') . "{$parent_html}{$exp_html}{$key_html}
"; print ""; print " "; - $content[] = " "; // type $content[] = ""; @@ -130,7 +129,7 @@ $selected = $zone->is_domain == $type || ($_GET['action']=="add" && $type=="1") ? "selected" : ""; $content[] = ""; } - $content[] = ""; + $content[] = ""._('Appends zone name to each host').""; $content[] = " "; $content[] = ""; // description @@ -139,7 +138,6 @@ $content[] = " "; - $content[] = " "; // private zone - checkbox on add, info label on edit if ($_GET['action'] == "add") { @@ -151,14 +149,13 @@ $content[] = " "._("Only visible to you — not even admins can see it").""; $content[] = " "; $content[] = " "; - $content[] = " "; } elseif (!empty($zone->private_zone_uid)) { $content[] = ""; $content[] = " "; $content[] = " "; - $content[] = " "; } $content[] = ""; @@ -174,7 +171,6 @@ $content[] = " "; - $content[] = " "; // aname $content[] = ""; @@ -182,7 +178,6 @@ $content[] = " "; - $content[] = " "; // tsig $content[] = ""; @@ -190,7 +185,6 @@ $content[] = " "; - $content[] = " "; // tsig $content[] = ""; @@ -198,7 +192,6 @@ $content[] = " "; - $content[] = " "; // types $content[] = ""; @@ -206,7 +199,6 @@ $content[] = " "; - $content[] = " "; // delete $content[] = ""; @@ -238,7 +230,6 @@ $content[] = " "; - $content[] = " "; // regex - exclude $content[] = ""; @@ -246,7 +237,6 @@ $content[] = " "; - $content[] = " "; // test axfr transfer $content[] = ""; @@ -254,7 +244,6 @@ $content[] = " "; - $content[] = " "; $content[] = ""; diff --git a/route/modals/zones/host_cert_refresh.php b/route/modals/zones/host_cert_refresh.php index c97183e..923e546 100644 --- a/route/modals/zones/host_cert_refresh.php +++ b/route/modals/zones/host_cert_refresh.php @@ -73,7 +73,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); diff --git a/route/zones/zone/zone-details.php b/route/zones/zone/zone-details.php index eff1a62..750c8c4 100644 --- a/route/zones/zone/zone-details.php +++ b/route/zones/zone/zone-details.php @@ -65,7 +65,7 @@ $zone->recipients = str_replace(";", "
", $zone->recipients); print ""; -print " "; +print " "; print " "; print "";
"; - print "".$cert['certificate']['subject']['CN']." $ignored_issuer $ignored_cert
"; + $_chain_ski = strtolower(trim($cert['certificate']['extensions']['subjectKeyIdentifier'] ?? '')); + $_chain_ca = isset($_ca_by_ski[$_chain_ski]) ? $_ca_by_ski[$_chain_ski] : null; + $_chain_slug = $_chain_ca ? (!empty($_chain_ca->serial) ? $_chain_ca->serial : (int)$_chain_ca->id) : null; + $_cn_escaped = htmlspecialchars($cert['certificate']['subject']['CN']); + if ($_chain_slug) { + print "".$_cn_escaped." $ignored_issuer $ignored_cert
"; + } else { + print "".$_cn_escaped." $ignored_issuer $ignored_cert
"; + } print _("Issued by").": ".$cert['certificate']['issuer']['CN']."
"; print ""._("Expires on").": ".date("Y-m-d H:i:s", $cert['certificate']['validTo_time_t'])."
"; print ""._("SHA-256 Fingerprint").": ".chunk_split(openssl_x509_fingerprint($cert_x509, 'SHA256'), 2, ' ')."
"; diff --git a/route/modals/zones/edit.php b/route/modals/zones/edit.php index c991c2a..0c02fdc 100644 --- a/route/modals/zones/edit.php +++ b/route/modals/zones/edit.php @@ -83,7 +83,6 @@ $content[] = " "; $content[] = " "; $content[] = "
"; $content[] = "
"; $content[] = " "; $content[] = " "; $content[] = "
"; $content[] = "
"._("Private zone").""._("Private")." "._("Only visible to you").""; + $content[] = "
"; $content[] = " "; $content[] = " "; $content[] = "
"; $content[] = " "; $content[] = " "; $content[] = "
"; $content[] = " "; $content[] = " "; $content[] = "
"; $content[] = " "; $content[] = " "; $content[] = "
"; $content[] = " "; $content[] = " "; $content[] = "
"; $content[] = " "; $content[] = " "; $content[] = "
"; $content[] = " "; $content[] = " "; $content[] = "
"; $content[] = " Test transfer"; $content[] = " "; $content[] = "
"._("Mail recipients").""._("Tenant mail recipients")."".$zone->recipients."