From b3b61e211fe4d0b8f0f0c228296c17dc860c74a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:10:31 +0000 Subject: [PATCH 01/24] Initial plan From f61acd9da0003e7ca8cdf931e303f78f65c6e6e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:33:04 +0000 Subject: [PATCH 02/24] feat: certificate manager web app with PHP backend and React frontend Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/cf7fd86a-281b-4d57-a124-6d63c1225976 Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- README.md | 152 ++++--- backend/.htaccess | 5 + backend/api/cert.php | 441 +++++++++++++++++++ backend/api/services.php | 85 ++++ backend/api/settings.php | 53 +++ backend/config.php | 17 + backend/data/services.json | 1 + backend/data/settings.json | 1 + backend/lib/CertManager.php | 197 +++++++++ backend/lib/Services.php | 106 +++++ backend/lib/ZeroSSL.php | 143 ++++++ index.html | 5 +- src/App.css | 1 + src/api/certificates.api.ts | 61 +++ src/api/services.api.ts | 48 ++ src/components/DomainsInput/DomainsInput.css | 64 +++ src/components/DomainsInput/DomainsInput.tsx | 67 +++ src/components/Layout/Layout.css | 106 +++++ src/components/Layout/Layout.tsx | 81 ++++ src/components/Modal/Modal.css | 71 +++ src/components/Modal/Modal.tsx | 45 ++ src/components/StatusBadge/StatusBadge.tsx | 47 ++ src/config/config.ts | 2 +- src/index.css | 255 +++++++++++ src/navigation/MainStack.tsx | 19 +- src/routes/mainRoutes.ts | 40 +- src/screens/errors/Error403Screen.tsx | 4 +- src/screens/errors/Error404Screen.tsx | 4 +- src/screens/home/HomePageScreen.css | 110 +++++ src/screens/home/HomePageScreen.tsx | 164 ++++++- src/screens/services/ServiceDetailScreen.css | 238 ++++++++++ src/screens/services/ServiceDetailScreen.tsx | 367 +++++++++++++++ src/screens/services/ServiceFormScreen.css | 87 ++++ src/screens/services/ServiceFormScreen.tsx | 274 ++++++++++++ src/screens/services/ServicesListScreen.css | 101 +++++ src/screens/services/ServicesListScreen.tsx | 184 ++++++++ src/screens/settings/SettingsScreen.css | 109 +++++ src/screens/settings/SettingsScreen.tsx | 189 ++++++++ src/types/service.types.ts | 56 +++ vite.config.ts | 9 + 40 files changed, 3930 insertions(+), 79 deletions(-) create mode 100644 backend/.htaccess create mode 100644 backend/api/cert.php create mode 100644 backend/api/services.php create mode 100644 backend/api/settings.php create mode 100644 backend/config.php create mode 100644 backend/data/services.json create mode 100644 backend/data/settings.json create mode 100644 backend/lib/CertManager.php create mode 100644 backend/lib/Services.php create mode 100644 backend/lib/ZeroSSL.php create mode 100644 src/api/certificates.api.ts create mode 100644 src/api/services.api.ts create mode 100644 src/components/DomainsInput/DomainsInput.css create mode 100644 src/components/DomainsInput/DomainsInput.tsx create mode 100644 src/components/Layout/Layout.css create mode 100644 src/components/Layout/Layout.tsx create mode 100644 src/components/Modal/Modal.css create mode 100644 src/components/Modal/Modal.tsx create mode 100644 src/components/StatusBadge/StatusBadge.tsx create mode 100644 src/screens/services/ServiceDetailScreen.css create mode 100644 src/screens/services/ServiceDetailScreen.tsx create mode 100644 src/screens/services/ServiceFormScreen.css create mode 100644 src/screens/services/ServiceFormScreen.tsx create mode 100644 src/screens/services/ServicesListScreen.css create mode 100644 src/screens/services/ServicesListScreen.tsx create mode 100644 src/screens/settings/SettingsScreen.css create mode 100644 src/screens/settings/SettingsScreen.tsx create mode 100644 src/types/service.types.ts diff --git a/README.md b/README.md index da98444..9c46df4 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,102 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default tseslint.config({ - extends: [ - // Remove ...tseslint.configs.recommended and replace with this - ...tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - ...tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - ...tseslint.configs.stylisticTypeChecked, - ], - languageOptions: { - // other options... - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - }, -}) -``` +# CertManager + +A web application for managing SSL/TLS certificates via the [ZeroSSL](https://zerossl.com) API. + +![CertManager Dashboard](https://github.com/user-attachments/assets/81ea4bcd-fa49-436c-9b65-381bcb4cce5a) + +## Features + +- **Service Management** – Create and manage services, each associated with one or more domains and a certificate path. +- **Automatic Certificate Issuance** – Generates a CSR, requests a 90-day certificate from ZeroSSL, handles domain verification (HTTP file or email), downloads the certificate, and installs it automatically. +- **One-click Renewal** – Before expiry, click *Renew* to replace the old certificate with a fresh one. +- **Automatic Restart** – After installation, the configured restart command is executed on the server (e.g. `systemctl restart nginx`). +- **Dashboard** – Overview of all services with certificate status, expiry countdown, and quick-action links. +- **Settings** – Store your ZeroSSL API key securely in the backend. + +## Architecture + +| Layer | Technology | +|-------|-----------| +| Frontend | React 19 + TypeScript + Vite | +| Backend API | PHP (local JSON storage) | +| Certificate Authority | ZeroSSL REST API v2 | -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default tseslint.config({ - plugins: { - // Add the react-x and react-dom plugins - 'react-x': reactX, - 'react-dom': reactDom, - }, - rules: { - // other rules... - // Enable its recommended typescript rules - ...reactX.configs['recommended-typescript'].rules, - ...reactDom.configs.recommended.rules, - }, -}) +### Directory Structure + +``` +├── backend/ # PHP backend +│ ├── api/ +│ │ ├── services.php # Services CRUD API +│ │ ├── cert.php # Certificate operations (request/verify/install/renew) +│ │ └── settings.php # ZeroSSL API key management +│ ├── lib/ +│ │ ├── Services.php # JSON-backed service store +│ │ ├── ZeroSSL.php # ZeroSSL REST API client +│ │ └── CertManager.php # CSR generation, cert installation, command execution +│ └── data/ +│ ├── services.json # Services data store +│ └── settings.json # App settings (ZeroSSL API key) +└── src/ # React frontend + ├── screens/ + │ ├── home/ # Dashboard + │ ├── services/ # Services list, form, and detail (cert workflow) + │ └── settings/ # ZeroSSL API key configuration + ├── components/ # Layout, Modal, StatusBadge, DomainsInput + ├── api/ # TypeScript API wrappers + └── types/ # TypeScript types ``` + +## Getting Started + +### Prerequisites + +- **PHP 8.0+** with `openssl` and `curl` extensions enabled +- **Node.js 18+** +- A free [ZeroSSL account](https://app.zerossl.com/signup) and API access key + +### Development Setup + +1. **Install frontend dependencies:** + ```bash + npm install + ``` + +2. **Start the PHP backend** (in a separate terminal): + ```bash + php -S localhost:8000 -t backend/ + ``` + +3. **Start the frontend dev server:** + ```bash + npm run dev + ``` + The Vite dev server proxies all `/api/*` requests to the PHP backend at `http://localhost:8000`. + +4. Open [http://localhost:5173](http://localhost:5173) and go to **Settings** to add your ZeroSSL API key. + +### Production Deployment + +1. Build the frontend: + ```bash + npm run build + ``` + +2. Configure your web server (Apache/Nginx) to: + - Serve the `dist/` directory for all non-API requests + - Proxy `/api/*` to the PHP backend (or place the `backend/` directory under your document root and configure `mod_rewrite`/`try_files`) + +3. Ensure the PHP process has write access to `backend/data/` and the certificate directories. + +## Certificate Workflow + +1. **Create a Service** – Set the service name, domains, certificate directory path, verification method, and restart command. +2. **Request Certificate** – CertManager generates a private key and CSR, then calls ZeroSSL to initiate the certificate request. +3. **Verify Domain** – For HTTP verification, the validation file is automatically created in your webroot. For email, ZeroSSL sends a verification link. +4. **Install** – Once issued, downloads the certificate, creates `fullchain.pem` (cert + CA bundle) and `privkey.key`, places them at the configured path, and executes the restart command. +5. **Renew** – When notified by ZeroSSL that a certificate is expiring, click *Renew* to issue a fresh certificate and reinstall automatically. + +## Security Notes + +- The ZeroSSL API key is stored in `backend/data/settings.json` — ensure this file is not web-accessible. +- The PHP backend uses `exec()` to run restart commands; only deploy on trusted infrastructure. +- Private keys are stored in `backend/data/services.json` in the interim; restrict access to this file (`chmod 600`). diff --git a/backend/.htaccess b/backend/.htaccess new file mode 100644 index 0000000..0654970 --- /dev/null +++ b/backend/.htaccess @@ -0,0 +1,5 @@ +Options -Indexes +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ index.php [QSA,L] diff --git a/backend/api/cert.php b/backend/api/cert.php new file mode 100644 index 0000000..1417a71 --- /dev/null +++ b/backend/api/cert.php @@ -0,0 +1,441 @@ + false, 'error' => 'ZeroSSL API key is not configured. Go to Settings to add it.']); + exit; + } + return new ZeroSSL($apiKey); +} + +switch ($method) { + case 'GET': + handleGet(); + break; + case 'POST': + handlePost($input); + break; + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); +} + +// --------------------------------------------------------------------------- + +function handleGet(): void +{ + global $serviceStore; + $action = $_GET['action'] ?? 'status'; + $serviceId = $_GET['service_id'] ?? null; + + if ($action === 'status') { + if (!$serviceId) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'service_id is required']); + return; + } + $service = $serviceStore->getById($serviceId); + if (!$service) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + return; + } + if (empty($service['cert_id'])) { + echo json_encode(['success' => true, 'cert_status' => 'none', 'service' => $service]); + return; + } + + $zerossl = getZeroSSL(); + $cert = $zerossl->getCertificate($service['cert_id']); + if (!empty($cert['id'])) { + $serviceStore->update($serviceId, [ + 'cert_status' => mapZeroSSLStatus($cert['status'] ?? ''), + 'cert_expiry' => $cert['expires'] ?? null, + ]); + } + echo json_encode(['success' => true, 'cert' => $cert, 'service' => $serviceStore->getById($serviceId)]); + return; + } + + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Unknown action']); +} + +// --------------------------------------------------------------------------- + +function handlePost(array $input): void +{ + global $serviceStore, $certManager; + + $action = $input['action'] ?? ''; + $serviceId = $input['service_id'] ?? null; + + if (!$serviceId) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'service_id is required']); + return; + } + + $service = $serviceStore->getById($serviceId); + if (!$service) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + return; + } + + switch ($action) { + case 'request': + actionRequest($service, $certManager); + break; + case 'verify': + actionVerify($service, $certManager); + break; + case 'install': + actionInstall($service, $certManager); + break; + case 'renew': + actionRenew($service, $certManager); + break; + default: + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Unknown action']); + } +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +function actionRequest(array $service, CertManager $certManager): void +{ + global $serviceStore; + + $domains = $service['domains'] ?? []; + if (empty($domains)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'No domains configured for this service']); + return; + } + + try { + // 1. Generate private key + CSR + $privateKey = $certManager->generatePrivateKey(); + $csr = $certManager->generateCSR($privateKey, $domains); + + // 2. Create certificate request on ZeroSSL + $zerossl = getZeroSSL(); + $cert = $zerossl->createCertificate($domains, $csr, 90); + + if (!empty($cert['error'])) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $cert['error']['type'] ?? 'ZeroSSL error', 'details' => $cert]); + return; + } + + $certId = $cert['id'] ?? null; + $validationDetails = $cert['validation'] ?? []; + + if (!$certId) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'No certificate ID returned by ZeroSSL', 'raw' => $cert]); + return; + } + + // 3. Store the private key and cert info + $serviceStore->update($service['id'], [ + 'cert_id' => $certId, + 'cert_status' => 'pending_validation', + 'private_key' => $privateKey, + 'validation' => $validationDetails, + 'cert_expiry' => null, + ]); + + // 4. Handle verification method + $verificationMethod = $service['verification_method'] ?? 'http'; + + if ($verificationMethod === 'http') { + $result = handleHttpValidation($service, $certId, $validationDetails, $certManager, $zerossl); + echo json_encode($result); + } else { + // EMAIL verification – initiate it, user checks their inbox + $email = $service['verification_email'] ?? ''; + $challenge = $zerossl->initiateVerification($certId, 'EMAIL', $email); + echo json_encode([ + 'success' => true, + 'message' => 'Verification email sent. Please check your inbox and click the link to verify ownership.', + 'cert_id' => $certId, + 'cert_status' => 'pending_validation', + 'challenge' => $challenge, + ]); + } + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} + +function actionVerify(array $service, CertManager $certManager): void +{ + global $serviceStore; + + $certId = $service['cert_id'] ?? null; + if (!$certId) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'No certificate request found for this service. Request a certificate first.']); + return; + } + + try { + $zerossl = getZeroSSL(); + $verificationMethod = $service['verification_method'] ?? 'http'; + $zsMethod = ($verificationMethod === 'http') ? 'HTTP_CSR_HASH' : 'EMAIL'; + + // Re-trigger validation if needed + $challenge = $zerossl->verifyDomain($certId, $zsMethod); + + // Poll status + $cert = $zerossl->getCertificate($certId); + $status = mapZeroSSLStatus($cert['status'] ?? ''); + + $serviceStore->update($service['id'], [ + 'cert_status' => $status, + 'cert_expiry' => $cert['expires'] ?? null, + ]); + + echo json_encode([ + 'success' => true, + 'cert_status' => $status, + 'cert' => $cert, + 'challenge' => $challenge, + ]); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} + +function actionInstall(array $service, CertManager $certManager): void +{ + global $serviceStore; + + $certId = $service['cert_id'] ?? null; + $privateKey = $service['private_key'] ?? null; + + if (!$certId) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'No certificate request found. Request a certificate first.']); + return; + } + if (!$privateKey) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Private key not found. Please request a new certificate.']); + return; + } + + try { + $zerossl = getZeroSSL(); + + // 1. Check that cert is issued + $cert = $zerossl->getCertificate($certId); + $status = $cert['status'] ?? ''; + if ($status !== 'issued') { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => "Certificate is not issued yet (status: {$status}). Verify domain ownership first.", + ]); + return; + } + + // 2. Download certificate + $download = $zerossl->downloadCertificate($certId); + if (empty($download['certificate.crt'])) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Failed to download certificate', 'raw' => $download]); + return; + } + + $certPem = $download['certificate.crt']; + $caBundlePem = $download['ca_bundle.crt'] ?? ''; + $certPath = $service['cert_path'] ?? ''; + + if (empty($certPath)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Certificate path is not configured']); + return; + } + + // 3. Write files to disk + $certManager->installCertificate($certPem, $caBundlePem, $privateKey, $certPath); + + // 4. Execute restart command + $restartCommand = $service['restart_command'] ?? ''; + $cmdResult = $certManager->executeCommand($restartCommand); + + // 5. Clean up HTTP validation file if present + $webrootPath = $service['webroot_path'] ?? ''; + $validation = $service['validation'] ?? []; + $filename = extractValidationFilename($validation, $service['domains'][0] ?? ''); + if ($webrootPath && $filename) { + $certManager->removeValidationFile($webrootPath, $filename); + } + + // 6. Update service record + $serviceStore->update($service['id'], [ + 'cert_status' => 'issued', + 'cert_expiry' => $cert['expires'] ?? null, + ]); + + echo json_encode([ + 'success' => true, + 'message' => 'Certificate installed successfully', + 'cert_expiry' => $cert['expires'] ?? null, + 'restart_output' => $cmdResult['output'], + 'restart_exit_code' => $cmdResult['exit_code'], + ]); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} + +function actionRenew(array $service, CertManager $certManager): void +{ + global $serviceStore; + + // Cancel old cert if still pending + $oldCertId = $service['cert_id'] ?? null; + if ($oldCertId) { + try { + $zerossl = getZeroSSL(); + $oldCert = $zerossl->getCertificate($oldCertId); + if (in_array($oldCert['status'] ?? '', ['draft', 'pending_validation'], true)) { + $zerossl->cancelCertificate($oldCertId); + } + } catch (Throwable) { + // Best effort – continue even if cancel fails + } + } + + // Clear old cert data and request a fresh certificate + $serviceStore->update($service['id'], [ + 'cert_id' => null, + 'cert_status' => 'none', + 'private_key' => null, + 'validation' => null, + ]); + + // Reload updated service and request a new one + $updatedService = $serviceStore->getById($service['id']); + actionRequest($updatedService, $certManager); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function handleHttpValidation( + array $service, + string $certId, + array $validationDetails, + CertManager $certManager, + ZeroSSL $zerossl +): array { + global $serviceStore; + + // Initiate HTTP_CSR_HASH challenge + $challenge = $zerossl->initiateVerification($certId, 'HTTP_CSR_HASH'); + + // Extract file info from validation details + $domain = $service['domains'][0] ?? ''; + $domainValidation = $validationDetails['other_methods'][$domain] ?? $validationDetails['other_methods'][array_key_first($validationDetails['other_methods'] ?? [])] ?? null; + + $filename = null; + $content = null; + + if ($domainValidation) { + $fileUrl = $domainValidation['file_validation_url_http'] ?? $domainValidation['file_validation_url_https'] ?? ''; + $parts = explode('/', $fileUrl); + $filename = end($parts); + $content = implode("\n", $domainValidation['file_validation_content'] ?? []); + } + + // Fallback: parse from challenge response + if (!$filename && !empty($challenge['details'])) { + foreach ($challenge['details'] as $domainChallenge) { + if (!empty($domainChallenge['file_validation_url_http'])) { + $parts = explode('/', $domainChallenge['file_validation_url_http']); + $filename = end($parts); + $content = implode("\n", $domainChallenge['file_validation_content'] ?? []); + break; + } + } + } + + $webrootPath = $service['webroot_path'] ?? ''; + if ($webrootPath && $filename && $content) { + $certManager->createValidationFile($webrootPath, $filename, $content); + $serviceStore->update($service['id'], [ + 'validation_filename' => $filename, + ]); + + return [ + 'success' => true, + 'message' => 'Validation file created. Waiting for ZeroSSL to verify it (this may take a few minutes).', + 'cert_id' => $certId, + 'cert_status' => 'pending_validation', + 'validation_url' => "http://{$domain}/.well-known/pki-validation/{$filename}", + ]; + } + + return [ + 'success' => true, + 'message' => 'Certificate request created. Please verify domain ownership manually.', + 'cert_id' => $certId, + 'cert_status' => 'pending_validation', + 'challenge' => $challenge, + 'validation' => $validationDetails, + ]; +} + +function mapZeroSSLStatus(string $status): string +{ + return match ($status) { + 'draft', 'pending_validation' => 'pending_validation', + 'issued' => 'issued', + 'cancelled', 'revoked' => 'cancelled', + 'expiring_soon' => 'expiring_soon', + default => $status ?: 'none', + }; +} + +function extractValidationFilename(array $validation, string $domain): ?string +{ + $domainValidation = $validation['other_methods'][$domain] ?? null; + if (!$domainValidation) { + return null; + } + $fileUrl = $domainValidation['file_validation_url_http'] ?? ''; + if (!$fileUrl) { + return null; + } + $parts = explode('/', $fileUrl); + return end($parts) ?: null; +} diff --git a/backend/api/services.php b/backend/api/services.php new file mode 100644 index 0000000..0fbc37e --- /dev/null +++ b/backend/api/services.php @@ -0,0 +1,85 @@ +getById($id); + if (!$service) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + exit; + } + echo json_encode(['success' => true, 'service' => $service]); + } else { + echo json_encode(['success' => true, 'services' => $services->getAll()]); + } + break; + + case 'POST': + $action = $input['action'] ?? 'create'; + + switch ($action) { + case 'create': + $required = ['name', 'domains', 'cert_path']; + foreach ($required as $field) { + if (empty($input[$field])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => "Field '{$field}' is required"]); + exit; + } + } + $service = $services->create($input); + echo json_encode(['success' => true, 'service' => $service]); + break; + + case 'update': + $id = $input['id'] ?? null; + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Service ID is required']); + exit; + } + $updated = $services->update($id, $input); + if (!$updated) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + exit; + } + echo json_encode(['success' => true, 'service' => $updated]); + break; + + case 'delete': + $id = $input['id'] ?? null; + if (!$id) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Service ID is required']); + exit; + } + $deleted = $services->delete($id); + if (!$deleted) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Service not found']); + exit; + } + echo json_encode(['success' => true]); + break; + + default: + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Unknown action']); + } + break; + + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); +} diff --git a/backend/api/settings.php b/backend/api/settings.php new file mode 100644 index 0000000..ebe8608 --- /dev/null +++ b/backend/api/settings.php @@ -0,0 +1,53 @@ + '']; + } + $raw = file_get_contents(SETTINGS_FILE); + $data = json_decode($raw, true); + return is_array($data) ? $data : ['zerossl_api_key' => '']; +} + +function saveSettings(array $settings): void +{ + file_put_contents(SETTINGS_FILE, json_encode($settings, JSON_PRETTY_PRINT)); +} + +switch ($method) { + case 'GET': + $settings = loadSettings(); + // Mask the API key for display + $masked = $settings; + if (!empty($masked['zerossl_api_key'])) { + $key = $masked['zerossl_api_key']; + $len = strlen($key); + if ($len <= 8) { + $masked['zerossl_api_key_masked'] = str_repeat('*', $len); + } else { + $masked['zerossl_api_key_masked'] = substr($key, 0, 4) . str_repeat('*', $len - 8) . substr($key, -4); + } + $masked['has_api_key'] = true; + } else { + $masked['has_api_key'] = false; + } + echo json_encode(['success' => true, 'settings' => $masked]); + break; + + case 'POST': + $current = loadSettings(); + $updated = array_merge($current, array_intersect_key($input, ['zerossl_api_key' => true])); + saveSettings($updated); + echo json_encode(['success' => true, 'message' => 'Settings saved']); + break; + + default: + http_response_code(405); + echo json_encode(['success' => false, 'error' => 'Method not allowed']); +} diff --git a/backend/config.php b/backend/config.php new file mode 100644 index 0000000..db71039 --- /dev/null +++ b/backend/config.php @@ -0,0 +1,17 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + if ($privKey === false) { + throw new RuntimeException('Failed to generate private key: ' . openssl_error_string()); + } + openssl_pkey_export($privKey, $pem); + return $pem; + } + + /** + * Generate a Certificate Signing Request (CSR) for the given domains. + * + * @param string $privateKeyPem PEM-encoded private key. + * @param array $domains List of domain names (first is used as CN). + * @return string PEM-encoded CSR. + */ + public function generateCSR(string $privateKeyPem, array $domains): string + { + if (empty($domains)) { + throw new InvalidArgumentException('At least one domain is required'); + } + + $primaryDomain = $domains[0]; + + $dn = [ + 'commonName' => $primaryDomain, + 'organizationName' => 'CertManager', + 'countryName' => 'US', + ]; + + $privKey = openssl_pkey_get_private($privateKeyPem); + if ($privKey === false) { + throw new RuntimeException('Invalid private key'); + } + + // Build SAN config for multiple domains + $sanList = implode(',', array_map(fn($d) => "DNS:{$d}", $domains)); + $configPath = $this->buildOpenSSLConfig($sanList); + + $csrOptions = []; + if ($configPath) { + $csrOptions['config'] = $configPath; + } + + $csr = openssl_csr_new($dn, $privKey, $csrOptions); + if ($csr === false) { + $this->cleanupTmpConfig($configPath); + throw new RuntimeException('Failed to generate CSR: ' . openssl_error_string()); + } + + openssl_csr_export($csr, $csrPem); + $this->cleanupTmpConfig($configPath); + return $csrPem; + } + + /** + * Write the certificate files to the configured path directory. + * + * Creates: + * {cert_path}/fullchain.pem – certificate + CA bundle + * {cert_path}/privkey.key – private key + * {cert_path}/cert.pem – leaf certificate only + * {cert_path}/chain.pem – CA bundle only + * + * @param string $certPem Leaf certificate PEM. + * @param string $caBundlePem CA bundle PEM. + * @param string $privateKeyPem Private key PEM. + * @param string $certPath Target directory path. + */ + public function installCertificate( + string $certPem, + string $caBundlePem, + string $privateKeyPem, + string $certPath + ): void { + if (!is_dir($certPath)) { + if (!mkdir($certPath, 0755, true)) { + throw new RuntimeException("Cannot create directory: {$certPath}"); + } + } + + $certPath = rtrim($certPath, '/'); + + $fullChain = $certPem . "\n" . $caBundlePem; + + $this->writeFile("{$certPath}/fullchain.pem", $fullChain, 0644); + $this->writeFile("{$certPath}/privkey.key", $privateKeyPem, 0600); + $this->writeFile("{$certPath}/cert.pem", $certPem, 0644); + $this->writeFile("{$certPath}/chain.pem", $caBundlePem, 0644); + } + + /** + * Create the HTTP file-based validation file at the web root. + * + * ZeroSSL expects: http://{domain}/.well-known/pki-validation/{filename} + * + * @param string $webrootPath The document root of the web server. + * @param string $validationFilename The filename provided by ZeroSSL. + * @param string $validationContent The content provided by ZeroSSL. + */ + public function createValidationFile( + string $webrootPath, + string $validationFilename, + string $validationContent + ): void { + $validationDir = rtrim($webrootPath, '/') . '/.well-known/pki-validation'; + if (!is_dir($validationDir)) { + if (!mkdir($validationDir, 0755, true)) { + throw new RuntimeException("Cannot create validation directory: {$validationDir}"); + } + } + $this->writeFile("{$validationDir}/{$validationFilename}", $validationContent, 0644); + } + + /** + * Remove the HTTP validation file after certificate issuance. + */ + public function removeValidationFile(string $webrootPath, string $validationFilename): void + { + $filePath = rtrim($webrootPath, '/') . '/.well-known/pki-validation/' . $validationFilename; + if (file_exists($filePath)) { + unlink($filePath); + } + } + + /** + * Execute a shell command safely and return its output and exit code. + * + * @return array{output: string, exit_code: int} + */ + public function executeCommand(string $command): array + { + if (empty(trim($command))) { + return ['output' => '', 'exit_code' => 0]; + } + + $output = []; + $exitCode = 0; + exec(escapeshellcmd($command) . ' 2>&1', $output, $exitCode); + + return [ + 'output' => implode("\n", $output), + 'exit_code' => $exitCode, + ]; + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + private function writeFile(string $path, string $content, int $mode): void + { + if (file_put_contents($path, $content) === false) { + throw new RuntimeException("Cannot write file: {$path}"); + } + chmod($path, $mode); + } + + private function buildOpenSSLConfig(string $sanList): ?string + { + $tmpFile = tempnam(sys_get_temp_dir(), 'openssl_'); + if ($tmpFile === false) { + return null; + } + $config = <<filePath = $filePath; + if (!file_exists($filePath)) { + file_put_contents($filePath, json_encode(['services' => []])); + } + } + + private function load(): array + { + $raw = file_get_contents($this->filePath); + if ($raw === false) { + return ['services' => []]; + } + $data = json_decode($raw, true); + return is_array($data) ? $data : ['services' => []]; + } + + private function save(array $data): void + { + file_put_contents($this->filePath, json_encode($data, JSON_PRETTY_PRINT)); + } + + public function getAll(): array + { + $data = $this->load(); + return $data['services'] ?? []; + } + + public function getById(string $id): ?array + { + $services = $this->getAll(); + foreach ($services as $service) { + if ($service['id'] === $id) { + return $service; + } + } + return null; + } + + public function create(array $serviceData): array + { + $data = $this->load(); + $service = array_merge([ + 'id' => $this->generateId(), + 'name' => '', + 'description' => '', + 'domains' => [], + 'cert_path' => '', + 'webroot_path' => '', + 'restart_command' => '', + 'verification_method' => 'http', + 'verification_email' => '', + 'cert_id' => null, + 'cert_status' => 'none', + 'cert_expiry' => null, + 'last_updated' => date('c'), + 'created_at' => date('c'), + ], $serviceData); + + $data['services'][] = $service; + $this->save($data); + return $service; + } + + public function update(string $id, array $updateData): ?array + { + $data = $this->load(); + foreach ($data['services'] as &$service) { + if ($service['id'] === $id) { + $updateData['last_updated'] = date('c'); + // Prevent overwriting the ID + unset($updateData['id']); + $service = array_merge($service, $updateData); + $this->save($data); + return $service; + } + } + return null; + } + + public function delete(string $id): bool + { + $data = $this->load(); + $original = count($data['services']); + $data['services'] = array_values( + array_filter($data['services'], fn($s) => $s['id'] !== $id) + ); + if (count($data['services']) < $original) { + $this->save($data); + return true; + } + return false; + } + + private function generateId(): string + { + return bin2hex(random_bytes(8)); + } +} diff --git a/backend/lib/ZeroSSL.php b/backend/lib/ZeroSSL.php new file mode 100644 index 0000000..dcd842e --- /dev/null +++ b/backend/lib/ZeroSSL.php @@ -0,0 +1,143 @@ +apiKey = $apiKey; + $this->apiBase = rtrim($apiBase, '/'); + } + + /** + * Create a new certificate request. + * + * @param array $domains List of domains (first is the primary CN). + * @param string $csr PEM-encoded Certificate Signing Request. + * @param int $days Validity in days (30 or 90 for free tier). + */ + public function createCertificate(array $domains, string $csr, int $days = 90): array + { + $payload = [ + 'certificate_domains' => implode(',', $domains), + 'certificate_validity_days' => $days, + 'certificate_csr' => $csr, + ]; + return $this->request('POST', '/certificates', $payload); + } + + /** + * Initiate domain validation. + * + * @param string $certId ZeroSSL certificate ID. + * @param string $method EMAIL | HTTP_CSR_HASH | CNAME_CSR_HASH + * @param string $validationEmail Required for EMAIL method. + */ + public function initiateVerification(string $certId, string $method, string $validationEmail = ''): array + { + $payload = ['validation_method' => $method]; + if ($method === 'EMAIL' && $validationEmail !== '') { + $payload['validation_email'] = $validationEmail; + } + return $this->request('POST', "/certificates/{$certId}/challenges", $payload); + } + + /** + * Re-trigger or check challenge verification. + * + * @param string $certId ZeroSSL certificate ID. + * @param string $method Validation method used. + */ + public function verifyDomain(string $certId, string $method): array + { + $payload = ['validation_method' => $method]; + return $this->request('POST', "/certificates/{$certId}/challenges", $payload); + } + + /** + * Download the issued certificate as a zip archive (base64 encoded contents). + * Returns array with keys: certificate.crt, ca_bundle.crt + */ + public function downloadCertificate(string $certId): array + { + return $this->request('GET', "/certificates/{$certId}/download/return"); + } + + /** + * Get certificate details and current status. + */ + public function getCertificate(string $certId): array + { + return $this->request('GET', "/certificates/{$certId}"); + } + + /** + * Cancel a pending certificate. + */ + public function cancelCertificate(string $certId): array + { + return $this->request('DELETE', "/certificates/{$certId}"); + } + + /** + * List all certificates in the account. + */ + public function listCertificates(): array + { + return $this->request('GET', '/certificates'); + } + + // ------------------------------------------------------------------ + // Private helpers + // ------------------------------------------------------------------ + + private function request(string $method, string $path, array $payload = []): array + { + $url = $this->apiBase . $path . '?access_key=' . urlencode($this->apiKey); + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 60); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); + + switch (strtoupper($method)) { + case 'POST': + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($payload)); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/x-www-form-urlencoded', + ]); + break; + case 'DELETE': + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE'); + break; + case 'GET': + default: + break; + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + return ['success' => false, 'error' => "cURL error: {$curlError}"]; + } + + $decoded = json_decode($response, true); + if (!is_array($decoded)) { + return [ + 'success' => false, + 'error' => 'Invalid JSON response from ZeroSSL', + 'raw' => $response, + 'http_code' => $httpCode, + ]; + } + + return $decoded; + } +} diff --git a/index.html b/index.html index e4b78ea..8ec25ac 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,10 @@ - Vite + React + TS + CertManager – SSL Certificate Management + + +
diff --git a/src/App.css b/src/App.css index e69de29..bce2d0f 100644 --- a/src/App.css +++ b/src/App.css @@ -0,0 +1 @@ +/* App-level overrides */ \ No newline at end of file diff --git a/src/api/certificates.api.ts b/src/api/certificates.api.ts new file mode 100644 index 0000000..bbee3be --- /dev/null +++ b/src/api/certificates.api.ts @@ -0,0 +1,61 @@ +import { API_BASE } from '../config/config'; +import type { CertActionResult, Settings } from '../types/service.types'; + +const headers = { 'Content-Type': 'application/json' }; + +export async function requestCertificate(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'request', service_id: serviceId }), + }); + return res.json(); +} + +export async function verifyCertificate(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'verify', service_id: serviceId }), + }); + return res.json(); +} + +export async function installCertificate(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'install', service_id: serviceId }), + }); + return res.json(); +} + +export async function renewCertificate(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'renew', service_id: serviceId }), + }); + return res.json(); +} + +export async function getCertStatus(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php?action=status&service_id=${encodeURIComponent(serviceId)}`); + return res.json(); +} + +export async function fetchSettings(): Promise { + const res = await fetch(`${API_BASE}/settings.php`); + const data = await res.json(); + return data.settings ?? { has_api_key: false }; +} + +export async function saveSettings(settings: { zerossl_api_key: string }): Promise { + const res = await fetch(`${API_BASE}/settings.php`, { + method: 'POST', + headers, + body: JSON.stringify(settings), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Failed to save settings'); +} diff --git a/src/api/services.api.ts b/src/api/services.api.ts new file mode 100644 index 0000000..fb6bc86 --- /dev/null +++ b/src/api/services.api.ts @@ -0,0 +1,48 @@ +import { API_BASE } from '../config/config'; +import type { Service, ServiceFormData } from '../types/service.types'; + +const headers = { 'Content-Type': 'application/json' }; + +export async function fetchServices(): Promise { + const res = await fetch(`${API_BASE}/services.php`); + const data = await res.json(); + return data.services ?? []; +} + +export async function fetchService(id: string): Promise { + const res = await fetch(`${API_BASE}/services.php?id=${encodeURIComponent(id)}`); + const data = await res.json(); + return data.service ?? null; +} + +export async function createService(formData: ServiceFormData): Promise { + const res = await fetch(`${API_BASE}/services.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'create', ...formData }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Failed to create service'); + return data.service; +} + +export async function updateService(id: string, formData: Partial): Promise { + const res = await fetch(`${API_BASE}/services.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'update', id, ...formData }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Failed to update service'); + return data.service; +} + +export async function deleteService(id: string): Promise { + const res = await fetch(`${API_BASE}/services.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'delete', id }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Failed to delete service'); +} diff --git a/src/components/DomainsInput/DomainsInput.css b/src/components/DomainsInput/DomainsInput.css new file mode 100644 index 0000000..2807120 --- /dev/null +++ b/src/components/DomainsInput/DomainsInput.css @@ -0,0 +1,64 @@ +.domains-input { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-primary); + padding: 6px 10px; + transition: border-color 0.15s; +} + +.domains-input:focus-within { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.domains-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.domain-tag { + display: inline-flex; + align-items: center; + gap: 4px; + background: rgba(59, 130, 246, 0.15); + color: var(--accent-blue); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 4px; + padding: 2px 8px; + font-size: 13px; + font-weight: 500; +} + +.domain-tag-remove { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0 0 0 4px; + font-size: 16px; + line-height: 1; + opacity: 0.7; + border-radius: 0; +} + +.domain-tag-remove:hover { + opacity: 1; +} + +.domains-hint { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +.domains-tag-input { + flex: 1; + min-width: 140px; + border: none; + background: transparent; + outline: none; + color: var(--text-primary); + padding: 0; +} diff --git a/src/components/DomainsInput/DomainsInput.tsx b/src/components/DomainsInput/DomainsInput.tsx new file mode 100644 index 0000000..d5a3805 --- /dev/null +++ b/src/components/DomainsInput/DomainsInput.tsx @@ -0,0 +1,67 @@ +import { FC, useState, KeyboardEvent } from 'react'; +import './DomainsInput.css'; + +interface Props { + value: string[]; + onChange: (domains: string[]) => void; + placeholder?: string; +} + +export const DomainsInput: FC = ({ value, onChange, placeholder = 'example.com' }) => { + const [inputVal, setInputVal] = useState(''); + + const addDomain = (raw: string) => { + const domain = raw.trim().toLowerCase(); + if (!domain) return; + if (value.includes(domain)) { + setInputVal(''); + return; + } + onChange([...value, domain]); + setInputVal(''); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ',' || e.key === ' ') { + e.preventDefault(); + addDomain(inputVal); + } + if (e.key === 'Backspace' && inputVal === '' && value.length > 0) { + onChange(value.slice(0, -1)); + } + }; + + const remove = (domain: string) => { + onChange(value.filter((d) => d !== domain)); + }; + + return ( +
+
+ {value.map((domain) => ( + + {domain} + + + ))} + setInputVal(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => addDomain(inputVal)} + placeholder={value.length === 0 ? placeholder : 'Add another…'} + /> +
+

Press Enter or comma to add a domain

+
+ ); +}; diff --git a/src/components/Layout/Layout.css b/src/components/Layout/Layout.css new file mode 100644 index 0000000..da849a4 --- /dev/null +++ b/src/components/Layout/Layout.css @@ -0,0 +1,106 @@ +.layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + height: 100vh; + z-index: 100; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 20px 16px; + font-size: 16px; + font-weight: 700; + color: var(--text-primary); + border-bottom: 1px solid var(--border); +} + +.sidebar-nav { + flex: 1; + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-link { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: var(--radius); + color: var(--text-secondary); + font-weight: 500; + transition: all 0.15s ease; + text-decoration: none; +} + +.sidebar-link:hover { + background: var(--bg-hover); + color: var(--text-primary); + text-decoration: none; +} + +.sidebar-link.active { + background: rgba(59, 130, 246, 0.15); + color: var(--accent-blue); +} + +.sidebar-footer { + padding: 16px 20px; + border-top: 1px solid var(--border); +} + +.version-tag { + font-size: 11px; + color: var(--text-muted); +} + +.main-content { + flex: 1; + margin-left: var(--sidebar-width); + min-height: 100vh; + overflow-x: hidden; +} + +.page-wrapper { + padding: 32px; + max-width: 1200px; +} + +@media (max-width: 768px) { + .sidebar { + width: 60px; + } + .sidebar-logo span, + .sidebar-link span, + .sidebar-footer { + display: none; + } + .sidebar-logo { + justify-content: center; + padding: 20px 8px; + } + .sidebar-link { + justify-content: center; + padding: 10px; + } + .main-content { + margin-left: 60px; + } + .page-wrapper { + padding: 20px 16px; + } +} diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx new file mode 100644 index 0000000..05518dc --- /dev/null +++ b/src/components/Layout/Layout.tsx @@ -0,0 +1,81 @@ +import { FC, ReactNode } from 'react'; +import { NavLink } from 'react-router-dom'; +import './Layout.css'; + +interface Props { + children: ReactNode; +} + +const navItems = [ + { + to: '/', + label: 'Dashboard', + icon: ( + + + + + + + ), + }, + { + to: '/services', + label: 'Services', + icon: ( + + + + + ), + }, + { + to: '/settings', + label: 'Settings', + icon: ( + + + + + ), + }, +]; + +export const Layout: FC = ({ children }) => { + return ( +
+ +
+
+ {children} +
+
+
+ ); +}; diff --git a/src/components/Modal/Modal.css b/src/components/Modal/Modal.css new file mode 100644 index 0000000..37b98a3 --- /dev/null +++ b/src/components/Modal/Modal.css @@ -0,0 +1,71 @@ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal-box { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + width: 100%; + max-height: 90vh; + overflow-y: auto; + animation: slideUp 0.2s ease; +} + +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.modal-sm { max-width: 400px; } +.modal-md { max-width: 600px; } +.modal-lg { max-width: 800px; } + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + font-size: 16px; + font-weight: 600; +} + +.modal-close { + color: var(--text-muted); + flex-shrink: 0; +} +.modal-close:hover { + color: var(--text-primary); +} + +.modal-body { + padding: 20px 24px 24px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 20px; + border-top: 1px solid var(--border); + margin-top: 20px; +} diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..3b66623 --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,45 @@ +import { FC, ReactNode, useEffect } from 'react'; +import './Modal.css'; + +interface Props { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + size?: 'sm' | 'md' | 'lg'; +} + +export const Modal: FC = ({ isOpen, onClose, title, children, size = 'md' }) => { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + if (isOpen) document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="modal-title" + > +
+ + +
+
{children}
+
+
+ ); +}; diff --git a/src/components/StatusBadge/StatusBadge.tsx b/src/components/StatusBadge/StatusBadge.tsx new file mode 100644 index 0000000..3158dcd --- /dev/null +++ b/src/components/StatusBadge/StatusBadge.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; +import type { CertStatus } from '../../types/service.types'; + +interface Props { + status: CertStatus; +} + +const statusConfig: Record = { + none: { label: 'No Certificate', color: '#64748b', bg: 'rgba(100,116,139,0.15)' }, + pending_validation: { label: 'Pending Validation', color: '#f59e0b', bg: 'rgba(245,158,11,0.15)' }, + issued: { label: 'Active', color: '#22c55e', bg: 'rgba(34,197,94,0.15)' }, + expiring_soon: { label: 'Expiring Soon', color: '#f97316', bg: 'rgba(249,115,22,0.15)' }, + expired: { label: 'Expired', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' }, + cancelled: { label: 'Cancelled', color: '#94a3b8', bg: 'rgba(148,163,184,0.15)' }, + error: { label: 'Error', color: '#ef4444', bg: 'rgba(239,68,68,0.15)' }, +}; + +export const StatusBadge: FC = ({ status }) => { + const config = statusConfig[status] ?? statusConfig.none; + return ( + + + {config.label} + + ); +}; diff --git a/src/config/config.ts b/src/config/config.ts index 767fe8f..fef09ac 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1 +1 @@ -export const API_BASE = "" \ No newline at end of file +export const API_BASE = "/api" \ No newline at end of file diff --git a/src/index.css b/src/index.css index e69de29..7c3b0b9 100644 --- a/src/index.css +++ b/src/index.css @@ -0,0 +1,255 @@ +:root { + --bg-primary: #0f1117; + --bg-secondary: #1a1d27; + --bg-card: #1e2130; + --bg-hover: #252839; + --border: #2d3148; + --border-light: #3a3f5c; + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --accent-blue: #3b82f6; + --accent-blue-dark: #2563eb; + --accent-green: #22c55e; + --accent-yellow: #f59e0b; + --accent-red: #ef4444; + --accent-purple: #a855f7; + --sidebar-width: 240px; + --radius: 8px; + --radius-lg: 12px; + --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + line-height: 1.6; + min-height: 100vh; +} + +a { + color: var(--accent-blue); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + cursor: pointer; + font-family: inherit; + font-size: 14px; + border: none; + border-radius: var(--radius); + padding: 8px 16px; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: 500; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +input, textarea, select { + font-family: inherit; + font-size: 14px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-primary); + padding: 8px 12px; + width: 100%; + transition: border-color 0.15s ease; + outline: none; +} + +input:focus, textarea:focus, select:focus { + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +input::placeholder, textarea::placeholder { + color: var(--text-muted); +} + +select option { + background: var(--bg-secondary); +} + +label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +h1 { font-size: 24px; font-weight: 700; } +h2 { font-size: 20px; font-weight: 600; } +h3 { font-size: 16px; font-weight: 600; } + +.btn-primary { + background: var(--accent-blue); + color: #fff; +} +.btn-primary:hover:not(:disabled) { + background: var(--accent-blue-dark); +} + +.btn-secondary { + background: var(--bg-card); + color: var(--text-primary); + border: 1px solid var(--border); +} +.btn-secondary:hover:not(:disabled) { + background: var(--bg-hover); +} + +.btn-danger { + background: var(--accent-red); + color: #fff; +} +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-success { + background: var(--accent-green); + color: #fff; +} +.btn-success:hover:not(:disabled) { + background: #16a34a; +} + +.btn-warning { + background: var(--accent-yellow); + color: #000; +} +.btn-warning:hover:not(:disabled) { + background: #d97706; +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + padding: 6px 10px; +} +.btn-ghost:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +@media (max-width: 640px) { + .form-row { + grid-template-columns: 1fr; + } +} + +.spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); +} +.empty-state svg { + opacity: 0.3; + margin-bottom: 16px; +} +.empty-state h3 { + color: var(--text-secondary); + margin-bottom: 8px; +} + +.tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + background: var(--bg-hover); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.error-box { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: var(--radius); + padding: 12px 16px; + color: #fca5a5; + font-size: 13px; +} + +.success-box { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: var(--radius); + padding: 12px 16px; + color: #86efac; + font-size: 13px; +} + +.info-box { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: var(--radius); + padding: 12px 16px; + color: #93c5fd; + font-size: 13px; +} + +.warning-box { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: var(--radius); + padding: 12px 16px; + color: #fcd34d; + font-size: 13px; +} diff --git a/src/navigation/MainStack.tsx b/src/navigation/MainStack.tsx index c834d15..6f04b09 100644 --- a/src/navigation/MainStack.tsx +++ b/src/navigation/MainStack.tsx @@ -2,16 +2,19 @@ import { createElement, FC } from 'react' import { Route, Routes } from 'react-router-dom' import { MainRoutes } from '../routes/mainRoutes' import { HomePageScreen } from '../screens/home/HomePageScreen' +import { Layout } from '../components/Layout/Layout' export const MainStack: FC = () => { return ( - - } /> - { - MainRoutes.map(route => ( - - )) - } - + + + } /> + { + MainRoutes.map(route => ( + + )) + } + + ) } \ No newline at end of file diff --git a/src/routes/mainRoutes.ts b/src/routes/mainRoutes.ts index 572a540..1d8fdbf 100644 --- a/src/routes/mainRoutes.ts +++ b/src/routes/mainRoutes.ts @@ -1,5 +1,9 @@ import { ElementType } from "react" import { HomePageScreen } from "../screens/home/HomePageScreen" +import { ServicesListScreen } from "../screens/services/ServicesListScreen" +import { ServiceDetailScreen } from "../screens/services/ServiceDetailScreen" +import { ServiceFormScreen } from "../screens/services/ServiceFormScreen" +import { SettingsScreen } from "../screens/settings/SettingsScreen" type Routes = { path: string, @@ -11,10 +15,42 @@ type Routes = { export const MainRoutes: Routes = [ { - path: "/home", - title: "Home", + path: "/", + title: "Dashboard", element: HomePageScreen, onNav: true, link: "/" }, + { + path: "/services", + title: "Services", + element: ServicesListScreen, + onNav: true, + link: "/services" + }, + { + path: "/services/new", + title: "New Service", + element: ServiceFormScreen, + link: "/services/new" + }, + { + path: "/services/:id", + title: "Service Detail", + element: ServiceDetailScreen, + link: "/services" + }, + { + path: "/services/:id/edit", + title: "Edit Service", + element: ServiceFormScreen, + link: "/services" + }, + { + path: "/settings", + title: "Settings", + element: SettingsScreen, + onNav: true, + link: "/settings" + }, ] \ No newline at end of file diff --git a/src/screens/errors/Error403Screen.tsx b/src/screens/errors/Error403Screen.tsx index a00cf32..ec723a8 100644 --- a/src/screens/errors/Error403Screen.tsx +++ b/src/screens/errors/Error403Screen.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from "react"; +import { FC } from "react"; export const Error403Screen: FC = () => { @@ -6,7 +6,7 @@ export const Error403Screen: FC = () => { return ( <> - ciao 403 + 403 Forbidden ) } \ No newline at end of file diff --git a/src/screens/errors/Error404Screen.tsx b/src/screens/errors/Error404Screen.tsx index c74ff5c..7e8bc2f 100644 --- a/src/screens/errors/Error404Screen.tsx +++ b/src/screens/errors/Error404Screen.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from "react"; +import { FC } from "react"; export const Error404Screen: FC = () => { @@ -6,7 +6,7 @@ export const Error404Screen: FC = () => { return ( <> - ciao 2 + 404 Not Found ) } \ No newline at end of file diff --git a/src/screens/home/HomePageScreen.css b/src/screens/home/HomePageScreen.css index e69de29..71a2ee1 100644 --- a/src/screens/home/HomePageScreen.css +++ b/src/screens/home/HomePageScreen.css @@ -0,0 +1,110 @@ +.dashboard {} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 28px; + gap: 16px; + flex-wrap: wrap; +} + +.page-subtitle { + color: var(--text-muted); + font-size: 14px; + margin-top: 4px; +} + +.btn-new-service { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 18px; + border-radius: var(--radius); + background: var(--accent-blue); + color: #fff; + font-weight: 500; + font-size: 14px; + text-decoration: none; + transition: background 0.15s; + flex-shrink: 0; +} +.btn-new-service:hover { + background: var(--accent-blue-dark); + text-decoration: none; +} + +.stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +@media (max-width: 900px) { + .stat-grid { grid-template-columns: repeat(2, 1fr); } +} + +.stat-card { + text-align: center; + padding: 24px 16px; +} + +.stat-value { + font-size: 36px; + font-weight: 700; + line-height: 1; + margin-bottom: 8px; +} + +.stat-label { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; +} + +.services-table { + width: 100%; + border-collapse: collapse; +} + +.services-table th { + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} + +.services-table td { + padding: 12px 12px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.services-table tr:last-child td { + border-bottom: none; +} + +.services-table tbody tr:hover td { + background: var(--bg-hover); +} + +.service-name { + font-weight: 600; + font-size: 14px; +} + +.service-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.domains-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} \ No newline at end of file diff --git a/src/screens/home/HomePageScreen.tsx b/src/screens/home/HomePageScreen.tsx index c8c636a..26261d2 100644 --- a/src/screens/home/HomePageScreen.tsx +++ b/src/screens/home/HomePageScreen.tsx @@ -1,16 +1,158 @@ -import { useNotification } from "../../contexts/NotificationContext"; -import "./HomePageScreen.css" -import { FC, useEffect, useState } from "react"; +import { FC, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { fetchServices } from '../../api/services.api'; +import { StatusBadge } from '../../components/StatusBadge/StatusBadge'; +import type { Service } from '../../types/service.types'; +import './HomePageScreen.css'; +interface Stats { + total: number; + active: number; + pending: number; + expiring: number; + expired: number; + noCert: number; +} + +function computeStats(services: Service[]): Stats { + return { + total: services.length, + active: services.filter((s) => s.cert_status === 'issued').length, + pending: services.filter((s) => s.cert_status === 'pending_validation').length, + expiring: services.filter((s) => s.cert_status === 'expiring_soon').length, + expired: services.filter((s) => s.cert_status === 'expired').length, + noCert: services.filter((s) => s.cert_status === 'none').length, + }; +} + +function daysUntilExpiry(expiry: string | null): number | null { + if (!expiry) return null; + const ms = new Date(expiry).getTime() - Date.now(); + return Math.ceil(ms / (1000 * 60 * 60 * 24)); +} export const HomePageScreen: FC = () => { - const { notify } = useNotification(); + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchServices() + .then(setServices) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + const stats = computeStats(services); + + const statCards = [ + { label: 'Total Services', value: stats.total, color: 'var(--accent-blue)' }, + { label: 'Active Certs', value: stats.active, color: 'var(--accent-green)' }, + { label: 'Pending', value: stats.pending, color: 'var(--accent-yellow)' }, + { label: 'Expired / No Cert', value: stats.expired + stats.noCert, color: 'var(--accent-red)' }, + ]; + + return ( +
+
+
+

Dashboard

+

Overview of all managed certificates

+
+ + + + + New Service + +
+ +
+ {statCards.map((card) => ( +
+
{card.value}
+
{card.label}
+
+ ))} +
- notify("test", "error"); +
+
+

Recent Services

+ View all → +
- return ( - <> - ciao - - ) -} \ No newline at end of file + {loading ? ( +
+
+
+ ) : services.length === 0 ? ( +
+ + + +

No services yet

+

Create your first service to start managing certificates.

+ + Create Service + +
+ ) : ( + + + + + + + + + + + + {services.slice(0, 5).map((service) => { + const days = daysUntilExpiry(service.cert_expiry); + return ( + + + + + + + + ); + })} + +
ServiceDomainsStatusExpires
+
{service.name}
+ {service.description && ( +
{service.description}
+ )} +
+
+ {service.domains.slice(0, 2).map((d) => ( + {d} + ))} + {service.domains.length > 2 && ( + +{service.domains.length - 2} + )} +
+
+ {days !== null ? ( + + {days > 0 ? `${days}d` : 'Expired'} + + ) : ( + + )} + + + Manage → + +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/screens/services/ServiceDetailScreen.css b/src/screens/services/ServiceDetailScreen.css new file mode 100644 index 0000000..65f7941 --- /dev/null +++ b/src/screens/services/ServiceDetailScreen.css @@ -0,0 +1,238 @@ +.detail-screen {} + +.breadcrumb { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-muted); + margin-bottom: 6px; +} +.breadcrumb a { + color: var(--text-muted); +} +.breadcrumb a:hover { + color: var(--accent-blue); +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 28px; + gap: 16px; +} + +.page-subtitle { + color: var(--text-muted); + font-size: 14px; + margin-top: 4px; +} + +.header-actions { + display: flex; + gap: 10px; +} + +.detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .detail-grid { + grid-template-columns: 1fr; + } + .detail-right { + order: -1; + } +} + +.cert-status-card {} + +.cert-status-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.btn-sm { + font-size: 12px; + padding: 5px 10px; +} + +.cert-status-body { + display: flex; + flex-direction: column; + gap: 12px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.cert-expiry, +.cert-id { + display: flex; + flex-direction: column; + gap: 3px; +} + +.cert-expiry-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.cert-expiry-date { + font-size: 15px; + font-weight: 600; +} + +.cert-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.cert-action-btn { + width: 100%; + justify-content: center; + padding: 10px; + font-size: 14px; + font-weight: 600; +} + +.restart-output { + margin-top: 8px; + background: rgba(0,0,0,0.3); + border-radius: 4px; + padding: 8px; + font-size: 11px; + font-family: monospace; + white-space: pre-wrap; + word-break: break-all; + max-height: 120px; + overflow-y: auto; +} + +.activity-log { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 300px; + overflow-y: auto; +} + +.log-entry { + display: flex; + gap: 10px; + font-size: 12px; + padding: 6px 10px; + border-radius: 4px; + background: var(--bg-primary); +} + +.log-time { + color: var(--text-muted); + flex-shrink: 0; + font-family: monospace; +} + +.log-info .log-message { color: var(--text-secondary); } +.log-success .log-message { color: var(--accent-green); } +.log-error .log-message { color: var(--accent-red); } +.log-warning .log-message { color: var(--accent-yellow); } + +.info-row { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} + +.info-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.info-code { + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-primary); + padding: 3px 8px; + border-radius: 4px; + font-family: 'Courier New', monospace; + word-break: break-all; +} + +.domains-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.workflow-steps { + display: flex; + flex-direction: column; + gap: 0; +} + +.workflow-step { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border); +} + +.workflow-step:last-child { + border-bottom: none; +} + +.step-num { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--bg-hover); + border: 2px solid var(--border-light); + color: var(--text-muted); + font-size: 13px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.2s; +} + +.workflow-step.done .step-num { + background: rgba(34, 197, 94, 0.15); + border-color: var(--accent-green); + color: var(--accent-green); +} + +.step-content { + display: flex; + flex-direction: column; + gap: 2px; +} + +.step-content strong { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.step-content span { + font-size: 12px; + color: var(--text-muted); +} diff --git a/src/screens/services/ServiceDetailScreen.tsx b/src/screens/services/ServiceDetailScreen.tsx new file mode 100644 index 0000000..a5b370b --- /dev/null +++ b/src/screens/services/ServiceDetailScreen.tsx @@ -0,0 +1,367 @@ +import { FC, useEffect, useState, useCallback } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { fetchService } from '../../api/services.api'; +import { + requestCertificate, + verifyCertificate, + installCertificate, + renewCertificate, + getCertStatus, +} from '../../api/certificates.api'; +import { StatusBadge } from '../../components/StatusBadge/StatusBadge'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { Service, CertActionResult } from '../../types/service.types'; +import './ServiceDetailScreen.css'; + +interface LogEntry { + time: string; + message: string; + type: 'info' | 'success' | 'error' | 'warning'; +} + +function formatDate(dateStr: string | null): string { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }); +} + +function daysUntilExpiry(expiry: string | null): number | null { + if (!expiry) return null; + const ms = new Date(expiry).getTime() - Date.now(); + return Math.ceil(ms / (1000 * 60 * 60 * 24)); +} + +export const ServiceDetailScreen: FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { notify } = useNotification(); + + const [service, setService] = useState(null); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [logs, setLogs] = useState([]); + const [lastResult, setLastResult] = useState(null); + + const addLog = (message: string, type: LogEntry['type'] = 'info') => { + setLogs((prev) => [ + ...prev, + { time: new Date().toLocaleTimeString(), message, type }, + ]); + }; + + const load = useCallback(() => { + if (!id) return; + fetchService(id) + .then((s) => { + if (!s) { navigate('/services'); return; } + setService(s); + }) + .catch(() => notify('Failed to load service', 'error')) + .finally(() => setLoading(false)); + }, [id, navigate, notify]); + + useEffect(() => { load(); }, [load]); + + const handleAction = async ( + label: string, + fn: (serviceId: string) => Promise, + ) => { + if (!id) return; + setActionLoading(true); + addLog(`Starting: ${label}…`); + try { + const result = await fn(id); + setLastResult(result); + if (result.success) { + addLog(result.message ?? `${label} completed successfully.`, 'success'); + notify(`${label} successful`, 'success'); + } else { + addLog(result.error ?? `${label} failed.`, 'error'); + notify(result.error ?? `${label} failed`, 'error'); + } + // Refresh service data + fetchService(id).then((s) => s && setService(s)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : 'Unknown error'; + addLog(msg, 'error'); + notify(msg, 'error'); + } finally { + setActionLoading(false); + } + }; + + const handleRefreshStatus = async () => { + if (!id) return; + setActionLoading(true); + try { + await getCertStatus(id); + const s = await fetchService(id); + if (s) setService(s); + notify('Status refreshed', 'success'); + } catch { + notify('Failed to refresh status', 'error'); + } finally { + setActionLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (!service) return null; + + const days = daysUntilExpiry(service.cert_expiry); + const canRequest = service.cert_status === 'none' || service.cert_status === 'error' || service.cert_status === 'cancelled'; + const canVerify = service.cert_status === 'pending_validation'; + const canInstall = service.cert_status === 'pending_validation' || service.cert_status === 'issued'; + const canRenew = service.cert_status === 'issued' || service.cert_status === 'expiring_soon' || service.cert_status === 'expired'; + + return ( +
+ {/* Header */} +
+
+
+ Services + / + {service.name} +
+

{service.name}

+ {service.description &&

{service.description}

} +
+
+ + + + + + Edit + +
+
+ +
+ {/* Left column */} +
+ {/* Certificate Status Card */} +
+
+

Certificate Status

+ +
+ +
+ + + {service.cert_expiry && ( +
+ Expires + + {formatDate(service.cert_expiry)} + {days !== null && ( + + ({days > 0 ? `${days} days` : 'expired'}) + + )} + +
+ )} + + {service.cert_id && ( +
+ Cert ID + {service.cert_id} +
+ )} +
+ + {/* Action Buttons */} +
+ {canRequest && ( + + )} + + {canVerify && ( + + )} + + {canInstall && ( + + )} + + {canRenew && ( + + )} +
+ + {/* Last result */} + {lastResult && ( +
+ {lastResult.success ? lastResult.message : lastResult.error} + {lastResult.validation_url && ( +
+ Validation URL: {lastResult.validation_url} +
+ )} + {lastResult.restart_output && ( +
{lastResult.restart_output}
+ )} +
+ )} +
+ + {/* Activity Log */} + {logs.length > 0 && ( +
+

Activity Log

+
+ {logs.map((entry, i) => ( +
+ {entry.time} + {entry.message} +
+ ))} +
+
+ )} +
+ + {/* Right column – Service info */} +
+
+

Service Configuration

+ +
+ Domains +
+ {service.domains.map((d) => ( + {d} + ))} +
+
+ +
+ Certificate Path + {service.cert_path || '—'} +
+ +
+ Verification Method + + {service.verification_method === 'http' ? '🌐 HTTP File' : '📧 Email'} + +
+ + {service.verification_method === 'http' && service.webroot_path && ( +
+ Webroot Path + {service.webroot_path} +
+ )} + + {service.verification_method === 'email' && service.verification_email && ( +
+ Verification Email + {service.verification_email} +
+ )} + + {service.restart_command && ( +
+ Restart Command + {service.restart_command} +
+ )} + +
+ Created + {formatDate(service.created_at)} +
+
+ + {/* Workflow Guide */} +
+

Certificate Workflow

+
+ {[ + { num: 1, label: 'Request', desc: 'Generate CSR and request cert from ZeroSSL', done: service.cert_status !== 'none' }, + { num: 2, label: 'Verify', desc: 'Prove domain ownership via HTTP file or email', done: service.cert_status === 'issued' || service.cert_status === 'expiring_soon' }, + { num: 3, label: 'Install', desc: 'Download, bundle, and install to cert path', done: service.cert_status === 'issued' || service.cert_status === 'expiring_soon' }, + { num: 4, label: 'Restart', desc: 'Execute restart command automatically', done: service.cert_status === 'issued' || service.cert_status === 'expiring_soon' }, + ].map((step) => ( +
+
{step.done ? '✓' : step.num}
+
+ {step.label} + {step.desc} +
+
+ ))} +
+
+
+
+
+ ); +}; diff --git a/src/screens/services/ServiceFormScreen.css b/src/screens/services/ServiceFormScreen.css new file mode 100644 index 0000000..317a97f --- /dev/null +++ b/src/screens/services/ServiceFormScreen.css @@ -0,0 +1,87 @@ +.form-screen {} + +.form-card { + margin-bottom: 20px; +} + +.form-section-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.field-error { + color: var(--accent-red); + font-size: 12px; + margin-top: 4px; +} + +.field-hint { + color: var(--text-muted); + font-size: 12px; + margin-top: 4px; + display: block; +} + +.radio-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.radio-option { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: all 0.15s; + color: var(--text-primary); + font-weight: normal; + margin-bottom: 0; +} + +.radio-option:hover { + border-color: var(--border-light); + background: var(--bg-hover); +} + +.radio-option.selected { + border-color: var(--accent-blue); + background: rgba(59, 130, 246, 0.08); +} + +.radio-option input[type="radio"] { + width: auto; + margin-top: 2px; + accent-color: var(--accent-blue); + flex-shrink: 0; +} + +.radio-content { + display: flex; + flex-direction: column; + gap: 3px; +} + +.radio-content strong { + font-size: 14px; + font-weight: 600; +} + +.radio-content span { + font-size: 12px; + color: var(--text-muted); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 8px; +} diff --git a/src/screens/services/ServiceFormScreen.tsx b/src/screens/services/ServiceFormScreen.tsx new file mode 100644 index 0000000..81aedde --- /dev/null +++ b/src/screens/services/ServiceFormScreen.tsx @@ -0,0 +1,274 @@ +import { FC, useState, useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { createService, fetchService, updateService } from '../../api/services.api'; +import { DomainsInput } from '../../components/DomainsInput/DomainsInput'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { ServiceFormData, VerificationMethod } from '../../types/service.types'; +import './ServiceFormScreen.css'; + +const EMPTY_FORM: ServiceFormData = { + name: '', + description: '', + domains: [], + cert_path: '', + webroot_path: '', + restart_command: '', + verification_method: 'http', + verification_email: '', +}; + +export const ServiceFormScreen: FC = () => { + const { id } = useParams<{ id?: string }>(); + const isEdit = Boolean(id); + const navigate = useNavigate(); + const { notify } = useNotification(); + + const [form, setForm] = useState(EMPTY_FORM); + const [loading, setLoading] = useState(isEdit); + const [saving, setSaving] = useState(false); + const [errors, setErrors] = useState>({}); + + useEffect(() => { + if (!isEdit || !id) return; + fetchService(id) + .then((service) => { + if (!service) { + notify('Service not found', 'error'); + navigate('/services'); + return; + } + setForm({ + name: service.name, + description: service.description, + domains: service.domains, + cert_path: service.cert_path, + webroot_path: service.webroot_path, + restart_command: service.restart_command, + verification_method: service.verification_method, + verification_email: service.verification_email, + }); + }) + .catch(() => notify('Failed to load service', 'error')) + .finally(() => setLoading(false)); + // navigate and notify are stable refs — excluded intentionally + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, isEdit]); + + const validate = (): boolean => { + const e: Record = {}; + if (!form.name.trim()) e.name = 'Service name is required'; + if (form.domains.length === 0) e.domains = 'At least one domain is required'; + if (!form.cert_path.trim()) e.cert_path = 'Certificate path is required'; + if (form.verification_method === 'http' && !form.webroot_path.trim()) { + e.webroot_path = 'Webroot path is required for HTTP verification'; + } + if (form.verification_method === 'email' && !form.verification_email.trim()) { + e.verification_email = 'Verification email is required'; + } + setErrors(e); + return Object.keys(e).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + setSaving(true); + try { + if (isEdit && id) { + await updateService(id, form); + notify('Service updated successfully', 'success'); + navigate(`/services/${id}`); + } else { + const service = await createService(form); + notify('Service created successfully', 'success'); + navigate(`/services/${service.id}`); + } + } catch (err: unknown) { + notify(err instanceof Error ? err.message : 'Failed to save service', 'error'); + } finally { + setSaving(false); + } + }; + + const set = (key: K, value: ServiceFormData[K]) => { + setForm((prev) => ({ ...prev, [key]: value })); + if (errors[key]) setErrors((prev) => { const n = { ...prev }; delete n[key]; return n; }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

{isEdit ? 'Edit Service' : 'New Service'}

+

+ {isEdit ? 'Update service configuration' : 'Configure a new SSL-managed service'} +

+
+
+ +
+ {/* Basic Info */} +
+

Basic Information

+
+
+ + set('name', e.target.value)} + placeholder="e.g. My Web Application" + /> + {errors.name && {errors.name}} +
+
+ + set('description', e.target.value)} + placeholder="Optional description" + /> +
+
+ +
+ + set('domains', d)} + placeholder="example.com" + /> + {errors.domains && {errors.domains}} +
+
+ + {/* Certificate Settings */} +
+

Certificate Settings

+
+ + set('cert_path', e.target.value)} + placeholder="/etc/nginx/certs/mysite" + /> + + Directory where fullchain.pem and privkey.key will be saved + + {errors.cert_path && {errors.cert_path}} +
+ +
+ + set('restart_command', e.target.value)} + placeholder="systemctl restart nginx" + /> + + Command to execute after certificate installation (runs as the PHP process user) + +
+
+ + {/* Verification */} +
+

Domain Verification

+ +
+ +
+ {(['http', 'email'] as VerificationMethod[]).map((method) => ( + + ))} +
+
+ + {form.verification_method === 'http' && ( +
+ + set('webroot_path', e.target.value)} + placeholder="/var/www/html" + /> + + Document root of the web server (the validation file will be created at {'{webroot}'}/.well-known/pki-validation/) + + {errors.webroot_path && {errors.webroot_path}} +
+ )} + + {form.verification_method === 'email' && ( +
+ + set('verification_email', e.target.value)} + placeholder="admin@example.com" + /> + + Must be admin@, administrator@, webmaster@, hostmaster@ or postmaster@ at your domain + + {errors.verification_email && {errors.verification_email}} +
+ )} +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/screens/services/ServicesListScreen.css b/src/screens/services/ServicesListScreen.css new file mode 100644 index 0000000..a6cbd18 --- /dev/null +++ b/src/screens/services/ServicesListScreen.css @@ -0,0 +1,101 @@ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 24px; + gap: 16px; + flex-wrap: wrap; +} + +.page-subtitle { + color: var(--text-muted); + font-size: 14px; + margin-top: 4px; +} + +.btn-new-service { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 18px; + border-radius: var(--radius); + background: var(--accent-blue); + color: #fff; + font-weight: 500; + font-size: 14px; + text-decoration: none; + transition: background 0.15s; + flex-shrink: 0; + border: none; + cursor: pointer; +} +.btn-new-service:hover { + background: var(--accent-blue-dark); + text-decoration: none; +} + +.services-table { + width: 100%; + border-collapse: collapse; +} + +.services-table th { + text-align: left; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} + +.services-table td { + padding: 14px 12px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.services-table tr:last-child td { + border-bottom: none; +} + +.services-table tbody tr:hover td { + background: rgba(255,255,255,0.02); +} + +.service-name { + font-weight: 600; + font-size: 14px; +} + +.service-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +.domains-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.cert-path { + font-size: 11px; + color: var(--text-muted); + background: var(--bg-primary); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Courier New', monospace; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + display: inline-block; + white-space: nowrap; +} + +.action-buttons { + display: flex; + gap: 4px; +} diff --git a/src/screens/services/ServicesListScreen.tsx b/src/screens/services/ServicesListScreen.tsx new file mode 100644 index 0000000..1c86f3a --- /dev/null +++ b/src/screens/services/ServicesListScreen.tsx @@ -0,0 +1,184 @@ +import { FC, useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { fetchServices, deleteService } from '../../api/services.api'; +import { StatusBadge } from '../../components/StatusBadge/StatusBadge'; +import { Modal } from '../../components/Modal/Modal'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { Service } from '../../types/service.types'; +import './ServicesListScreen.css'; + +function daysUntilExpiry(expiry: string | null): number | null { + if (!expiry) return null; + const ms = new Date(expiry).getTime() - Date.now(); + return Math.ceil(ms / (1000 * 60 * 60 * 24)); +} + +export const ServicesListScreen: FC = () => { + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(true); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const { notify } = useNotification(); + const navigate = useNavigate(); + + const load = () => { + setLoading(true); + fetchServices() + .then(setServices) + .catch(() => notify('Failed to load services', 'error')) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); // load is stable within this mount — eslint-disable-line react-hooks/exhaustive-deps + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await deleteService(deleteTarget.id); + notify(`Service "${deleteTarget.name}" deleted`, 'success'); + setDeleteTarget(null); + load(); + } catch (e: unknown) { + notify(e instanceof Error ? e.message : 'Delete failed', 'error'); + } finally { + setDeleting(false); + } + }; + + return ( +
+
+
+

Services

+

Manage all your certificate services

+
+ + + + + New Service + +
+ +
+ {loading ? ( +
+
+

Loading services…

+
+ ) : services.length === 0 ? ( +
+ + + +

No services configured

+

Add a service to start managing SSL certificates.

+ + Create First Service + +
+ ) : ( + + + + + + + + + + + + + {services.map((service) => { + const days = daysUntilExpiry(service.cert_expiry); + return ( + + + + + + + + + ); + })} + +
ServiceDomainsCert PathStatusExpiresActions
+
{service.name}
+ {service.description && ( +
{service.description}
+ )} +
+
+ {service.domains.slice(0, 2).map((d) => ( + {d} + ))} + {service.domains.length > 2 && ( + +{service.domains.length - 2} + )} +
+
+ {service.cert_path || '—'} + + {days !== null ? ( + + {days > 0 ? `${days} days` : 'Expired'} + + ) : ( + + )} + +
+ + + +
+
+ )} +
+ + setDeleteTarget(null)} + title="Delete Service" + size="sm" + > +

+ Are you sure you want to delete{' '} + "{deleteTarget?.name}"? + This action cannot be undone. +

+
+ + +
+
+
+ ); +}; diff --git a/src/screens/settings/SettingsScreen.css b/src/screens/settings/SettingsScreen.css new file mode 100644 index 0000000..433d0c1 --- /dev/null +++ b/src/screens/settings/SettingsScreen.css @@ -0,0 +1,109 @@ +.settings-screen {} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 28px; + gap: 16px; +} + +.page-subtitle { + color: var(--text-muted); + font-size: 14px; + margin-top: 4px; +} + +.settings-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + align-items: start; +} + +@media (max-width: 900px) { + .settings-grid { grid-template-columns: 1fr; } +} + +.settings-card {} + +.settings-section-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; + font-weight: 600; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.settings-desc { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; + margin-bottom: 20px; +} + +.api-key-input { + display: flex; + gap: 6px; + align-items: center; +} + +.api-key-input input { + flex: 1; +} + +.toggle-visibility { + padding: 7px 10px; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.how-it-works { + display: flex; + flex-direction: column; + gap: 0; +} + +.how-step { + display: flex; + gap: 14px; + align-items: flex-start; + padding: 14px 0; + border-bottom: 1px solid var(--border); +} + +.how-step:last-child { + border-bottom: none; +} + +.how-num { + width: 26px; + height: 26px; + border-radius: 50%; + background: rgba(59, 130, 246, 0.15); + color: var(--accent-blue); + font-size: 12px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 1px; +} + +.how-step strong { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 3px; +} + +.how-step p { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} diff --git a/src/screens/settings/SettingsScreen.tsx b/src/screens/settings/SettingsScreen.tsx new file mode 100644 index 0000000..de5b377 --- /dev/null +++ b/src/screens/settings/SettingsScreen.tsx @@ -0,0 +1,189 @@ +import { FC, useEffect, useState } from 'react'; +import { fetchSettings, saveSettings } from '../../api/certificates.api'; +import { useNotification } from '../../contexts/NotificationContext'; +import type { Settings } from '../../types/service.types'; +import './SettingsScreen.css'; + +export const SettingsScreen: FC = () => { + const [settings, setSettings] = useState(null); + const [apiKey, setApiKey] = useState(''); + const [showKey, setShowKey] = useState(false); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + const { notify } = useNotification(); + + useEffect(() => { + fetchSettings() + .then((s) => { + setSettings(s); + }) + .catch(() => notify('Failed to load settings', 'error')) + .finally(() => setLoading(false)); + // notify is a stable ref — excluded intentionally + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!apiKey.trim()) { + notify('Please enter an API key', 'error'); + return; + } + setSaving(true); + try { + await saveSettings({ zerossl_api_key: apiKey.trim() }); + notify('Settings saved successfully', 'success'); + setApiKey(''); + // Refresh + const updated = await fetchSettings(); + setSettings(updated); + } catch (e: unknown) { + notify(e instanceof Error ? e.message : 'Failed to save', 'error'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+

Settings

+

Configure CertManager preferences

+
+
+ +
+
+

+ + + + ZeroSSL Configuration +

+

+ CertManager uses the{' '} + ZeroSSL{' '} + API to issue free SSL certificates. You need an API access key from your ZeroSSL dashboard. +

+ + {settings?.has_api_key && ( +
+ ✓ API Key configured +
+ {settings.zerossl_api_key_masked} +
+
+ )} + + {!settings?.has_api_key && ( +
+ ⚠ No API key configured +
+ You won't be able to request certificates until you add your ZeroSSL API key. +
+
+ )} + +
+
+ +
+ setApiKey(e.target.value)} + placeholder={settings?.has_api_key ? 'Enter new key to replace…' : 'Paste your ZeroSSL API key'} + autoComplete="off" + /> + +
+
+ +
+
+ +
+

+ + + + + + How it Works +

+
+
+
1
+
+ Create a Service +

Define the domains, certificate path, and restart command for each of your web services.

+
+
+
+
2
+
+ Request a Certificate +

CertManager generates a CSR and requests a certificate from ZeroSSL automatically.

+
+
+
+
3
+
+ Verify Domain +

For HTTP verification, the validation file is created in your webroot automatically. For email, ZeroSSL sends a verification link.

+
+
+
+
4
+
+ Install & Restart +

Once issued, the certificate is downloaded, bundled (fullchain.pem + privkey.key), installed at the configured path, and your service is restarted.

+
+
+
+
5
+
+ Renew Before Expiry +

When you receive an expiry notice from ZeroSSL, hit Renew to automatically replace the certificate.

+
+
+
+
+
+
+ ); +}; diff --git a/src/types/service.types.ts b/src/types/service.types.ts new file mode 100644 index 0000000..bc87292 --- /dev/null +++ b/src/types/service.types.ts @@ -0,0 +1,56 @@ +export type CertStatus = + | 'none' + | 'pending_validation' + | 'issued' + | 'expiring_soon' + | 'expired' + | 'cancelled' + | 'error'; + +export type VerificationMethod = 'http' | 'email'; + +export interface Service { + id: string; + name: string; + description: string; + domains: string[]; + cert_path: string; + webroot_path: string; + restart_command: string; + verification_method: VerificationMethod; + verification_email: string; + cert_id: string | null; + cert_status: CertStatus; + cert_expiry: string | null; + last_updated: string; + created_at: string; +} + +export interface ServiceFormData { + name: string; + description: string; + domains: string[]; + cert_path: string; + webroot_path: string; + restart_command: string; + verification_method: VerificationMethod; + verification_email: string; +} + +export interface CertActionResult { + success: boolean; + message?: string; + error?: string; + cert_id?: string; + cert_status?: CertStatus; + cert_expiry?: string; + validation_url?: string; + restart_output?: string; + restart_exit_code?: number; +} + +export interface Settings { + zerossl_api_key?: string; + zerossl_api_key_masked?: string; + has_api_key: boolean; +} diff --git a/vite.config.ts b/vite.config.ts index 8b0f57b..f7c610b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,13 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, }) From d861f2d1460d5c63f1798768c33626bed4b75d6b Mon Sep 17 00:00:00 2001 From: "ilBiondo.js" Date: Mon, 30 Mar 2026 20:10:17 +0200 Subject: [PATCH 03/24] 0.0.3 - some changes --- backend/api/info.php | 8 ++++++++ backend/data/services.json | 30 +++++++++++++++++++++++++++++- backend/data/settings.json | 4 +++- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 backend/api/info.php diff --git a/backend/api/info.php b/backend/api/info.php new file mode 100644 index 0000000..7cebde2 --- /dev/null +++ b/backend/api/info.php @@ -0,0 +1,8 @@ + Date: Mon, 30 Mar 2026 18:16:26 +0000 Subject: [PATCH 04/24] feat: add path existence and writability validation when creating/editing a service Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/86859840-ef85-4e81-a85c-5b704dc51df9 Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- backend/api/services.php | 150 +++++++++++++++++++++ src/api/services.api.ts | 20 ++- src/screens/services/ServiceFormScreen.css | 73 ++++++++++ src/screens/services/ServiceFormScreen.tsx | 121 ++++++++++++++++- src/types/service.types.ts | 14 ++ 5 files changed, 372 insertions(+), 6 deletions(-) diff --git a/backend/api/services.php b/backend/api/services.php index 0fbc37e..f601a9c 100644 --- a/backend/api/services.php +++ b/backend/api/services.php @@ -28,6 +28,23 @@ $action = $input['action'] ?? 'create'; switch ($action) { + case 'validate_paths': + $certPath = $input['cert_path'] ?? ''; + $webrootPath = $input['webroot_path'] ?? ''; + $verMethod = $input['verification_method'] ?? 'http'; + + $result = ['success' => true, 'cert_path' => null, 'webroot_path' => null]; + + if ($certPath !== '') { + $result['cert_path'] = validatePath($certPath, 'cert'); + } + if ($webrootPath !== '' && $verMethod === 'http') { + $result['webroot_path'] = validatePath($webrootPath, 'webroot'); + } + + echo json_encode($result); + break; + case 'create': $required = ['name', 'domains', 'cert_path']; foreach ($required as $field) { @@ -37,6 +54,15 @@ exit; } } + + // Validate paths before persisting + $pathErrors = collectPathErrors($input); + if ($pathErrors) { + http_response_code(422); + echo json_encode(['success' => false, 'error' => implode(' | ', $pathErrors), 'path_errors' => $pathErrors]); + exit; + } + $service = $services->create($input); echo json_encode(['success' => true, 'service' => $service]); break; @@ -48,6 +74,15 @@ echo json_encode(['success' => false, 'error' => 'Service ID is required']); exit; } + + // Validate paths before persisting + $pathErrors = collectPathErrors($input); + if ($pathErrors) { + http_response_code(422); + echo json_encode(['success' => false, 'error' => implode(' | ', $pathErrors), 'path_errors' => $pathErrors]); + exit; + } + $updated = $services->update($id, $input); if (!$updated) { http_response_code(404); @@ -83,3 +118,118 @@ http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed']); } + +// --------------------------------------------------------------------------- +// Path validation helpers +// --------------------------------------------------------------------------- + +/** + * Validate a single filesystem path. + * + * For 'cert' paths the directory may not exist yet; in that case the parent + * directory must exist and be writable so that CertManager can create it. + * + * For 'webroot' paths the directory must already exist and be writable. + * + * @return array{valid: bool, exists: bool, writable: bool, error?: string, note?: string} + */ +function validatePath(string $path, string $type = 'cert'): array +{ + $path = rtrim($path, '/'); + + if ($type === 'webroot') { + if (!is_dir($path)) { + return [ + 'valid' => false, + 'exists' => false, + 'writable' => false, + 'error' => "Webroot directory does not exist: {$path}", + ]; + } + if (!is_writable($path)) { + return [ + 'valid' => false, + 'exists' => true, + 'writable' => false, + 'error' => "Webroot directory is not writable: {$path}", + ]; + } + return ['valid' => true, 'exists' => true, 'writable' => true]; + } + + // cert_path: directory may not exist yet + if (is_dir($path)) { + if (!is_writable($path)) { + return [ + 'valid' => false, + 'exists' => true, + 'writable' => false, + 'error' => "Certificate directory is not writable: {$path}", + ]; + } + return ['valid' => true, 'exists' => true, 'writable' => true]; + } + + if (file_exists($path)) { + return [ + 'valid' => false, + 'exists' => true, + 'writable' => false, + 'error' => "Path exists but is not a directory: {$path}", + ]; + } + + // Directory doesn't exist – check that the parent is writable + $parent = dirname($path); + if (!is_dir($parent)) { + return [ + 'valid' => false, + 'exists' => false, + 'writable' => false, + 'error' => "Parent directory does not exist: {$parent}", + ]; + } + if (!is_writable($parent)) { + return [ + 'valid' => false, + 'exists' => false, + 'writable' => false, + 'error' => "Parent directory is not writable (cannot create {$path}): {$parent}", + ]; + } + return [ + 'valid' => true, + 'exists' => false, + 'writable' => true, + 'note' => "Directory will be created automatically: {$path}", + ]; +} + +/** + * Run all relevant path checks for a service payload and return a list of + * human-readable error messages (empty array = all OK). + */ +function collectPathErrors(array $input): array +{ + $errors = []; + $certPath = $input['cert_path'] ?? ''; + $webroot = $input['webroot_path'] ?? ''; + $verMethod = $input['verification_method'] ?? 'http'; + + if ($certPath !== '') { + $check = validatePath($certPath, 'cert'); + if (!$check['valid']) { + $errors[] = $check['error']; + } + } + + if ($verMethod === 'http' && $webroot !== '') { + $check = validatePath($webroot, 'webroot'); + if (!$check['valid']) { + $errors[] = $check['error']; + } + } + + return $errors; +} + diff --git a/src/api/services.api.ts b/src/api/services.api.ts index fb6bc86..540c8f5 100644 --- a/src/api/services.api.ts +++ b/src/api/services.api.ts @@ -1,5 +1,5 @@ import { API_BASE } from '../config/config'; -import type { Service, ServiceFormData } from '../types/service.types'; +import type { Service, ServiceFormData, PathsValidationResult } from '../types/service.types'; const headers = { 'Content-Type': 'application/json' }; @@ -46,3 +46,21 @@ export async function deleteService(id: string): Promise { const data = await res.json(); if (!data.success) throw new Error(data.error ?? 'Failed to delete service'); } + +export async function checkPaths( + certPath: string, + webrootPath: string, + verificationMethod: string, +): Promise { + const res = await fetch(`${API_BASE}/services.php`, { + method: 'POST', + headers, + body: JSON.stringify({ + action: 'validate_paths', + cert_path: certPath, + webroot_path: webrootPath, + verification_method: verificationMethod, + }), + }); + return res.json(); +} diff --git a/src/screens/services/ServiceFormScreen.css b/src/screens/services/ServiceFormScreen.css index 317a97f..c66d312 100644 --- a/src/screens/services/ServiceFormScreen.css +++ b/src/screens/services/ServiceFormScreen.css @@ -26,6 +26,79 @@ display: block; } +/* Path label row – label + status badge side-by-side */ +.path-label-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.path-label-row label { + margin-bottom: 0; +} + +/* Path status badges */ +.path-status { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; +} + +.path-checking { + color: var(--text-muted); + background: rgba(100, 116, 139, 0.12); + border: 1px solid rgba(100, 116, 139, 0.2); +} +.path-checking .spinner { + width: 10px; + height: 10px; + border-width: 1.5px; + border-color: rgba(100,116,139,0.4); + border-top-color: var(--text-muted); +} + +.path-ok { + color: var(--accent-green); + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.25); +} + +.path-warn { + color: var(--accent-yellow); + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.25); +} + +.path-error { + color: var(--accent-red); + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.25); + max-width: 360px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Input border state overrides */ +input.input-ok { + border-color: var(--accent-green); +} +input.input-ok:focus { + box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); +} + +input.input-error { + border-color: var(--accent-red); +} +input.input-error:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15); +} + .radio-group { display: flex; flex-direction: column; diff --git a/src/screens/services/ServiceFormScreen.tsx b/src/screens/services/ServiceFormScreen.tsx index 81aedde..b4f5eb1 100644 --- a/src/screens/services/ServiceFormScreen.tsx +++ b/src/screens/services/ServiceFormScreen.tsx @@ -1,9 +1,9 @@ -import { FC, useState, useEffect } from 'react'; +import { FC, useState, useEffect, useCallback } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { createService, fetchService, updateService } from '../../api/services.api'; +import { createService, fetchService, updateService, checkPaths } from '../../api/services.api'; import { DomainsInput } from '../../components/DomainsInput/DomainsInput'; import { useNotification } from '../../contexts/NotificationContext'; -import type { ServiceFormData, VerificationMethod } from '../../types/service.types'; +import type { ServiceFormData, VerificationMethod, PathCheckResult } from '../../types/service.types'; import './ServiceFormScreen.css'; const EMPTY_FORM: ServiceFormData = { @@ -17,6 +17,51 @@ const EMPTY_FORM: ServiceFormData = { verification_email: '', }; +// Statuses for a path field: idle | checking | ok | warn | error +type PathStatus = { + state: 'idle' | 'checking' | 'ok' | 'warn' | 'error'; + message?: string; +}; + +const IDLE: PathStatus = { state: 'idle' }; + +function statusFromResult(result: PathCheckResult | null | undefined): PathStatus { + if (!result) return IDLE; + if (!result.valid) return { state: 'error', message: result.error }; + if (!result.exists) return { state: 'warn', message: result.note ?? 'Directory will be created automatically' }; + return { state: 'ok', message: result.exists ? 'Directory exists and is writable' : undefined }; +} + +const PathStatusIcon: FC<{ status: PathStatus }> = ({ status }) => { + if (status.state === 'idle') return null; + if (status.state === 'checking') { + return ; + } + if (status.state === 'ok') { + return ( + + + Accessible + + ); + } + if (status.state === 'warn') { + return ( + + + Will be created + + ); + } + // error + return ( + + + {status.message ?? 'Invalid path'} + + ); +}; + export const ServiceFormScreen: FC = () => { const { id } = useParams<{ id?: string }>(); const isEdit = Boolean(id); @@ -28,6 +73,9 @@ export const ServiceFormScreen: FC = () => { const [saving, setSaving] = useState(false); const [errors, setErrors] = useState>({}); + const [certPathStatus, setCertPathStatus] = useState(IDLE); + const [webrootPathStatus, setWebrootPathStatus] = useState(IDLE); + useEffect(() => { if (!isEdit || !id) return; fetchService(id) @@ -54,6 +102,48 @@ export const ServiceFormScreen: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, isEdit]); + /** + * Call the backend validate_paths action and update the status badges. + * Only checks non-empty fields; silently resets the other badge to idle. + */ + const runPathCheck = useCallback( + async (certPath: string, webrootPath: string, verMethod: string) => { + const checkCert = certPath.trim() !== ''; + const checkWebroot = webrootPath.trim() !== '' && verMethod === 'http'; + + if (!checkCert && !checkWebroot) return; + + if (checkCert) setCertPathStatus({ state: 'checking' }); + if (checkWebroot) setWebrootPathStatus({ state: 'checking' }); + + try { + const result = await checkPaths(certPath, webrootPath, verMethod); + if (checkCert) setCertPathStatus(statusFromResult(result.cert_path)); + if (checkWebroot) setWebrootPathStatus(statusFromResult(result.webroot_path)); + } catch { + if (checkCert) setCertPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); + if (checkWebroot) setWebrootPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); + } + }, + [], + ); + + const handleCertPathBlur = () => { + if (form.cert_path.trim()) { + runPathCheck(form.cert_path, form.webroot_path, form.verification_method); + } else { + setCertPathStatus(IDLE); + } + }; + + const handleWebrootPathBlur = () => { + if (form.webroot_path.trim()) { + runPathCheck(form.cert_path, form.webroot_path, form.verification_method); + } else { + setWebrootPathStatus(IDLE); + } + }; + const validate = (): boolean => { const e: Record = {}; if (!form.name.trim()) e.name = 'Service name is required'; @@ -65,6 +155,13 @@ export const ServiceFormScreen: FC = () => { if (form.verification_method === 'email' && !form.verification_email.trim()) { e.verification_email = 'Verification email is required'; } + // Block submit if we know a path is invalid + if (certPathStatus.state === 'error') { + e.cert_path = certPathStatus.message ?? 'Certificate path is invalid'; + } + if (webrootPathStatus.state === 'error') { + e.webroot_path = webrootPathStatus.message ?? 'Webroot path is invalid'; + } setErrors(e); return Object.keys(e).length === 0; }; @@ -93,6 +190,10 @@ export const ServiceFormScreen: FC = () => { const set = (key: K, value: ServiceFormData[K]) => { setForm((prev) => ({ ...prev, [key]: value })); if (errors[key]) setErrors((prev) => { const n = { ...prev }; delete n[key]; return n; }); + // Reset path status when field changes + if (key === 'cert_path') setCertPathStatus(IDLE); + if (key === 'webroot_path') setWebrootPathStatus(IDLE); + if (key === 'verification_method') setWebrootPathStatus(IDLE); }; if (loading) { @@ -157,13 +258,18 @@ export const ServiceFormScreen: FC = () => {

Certificate Settings

- +
+ + +
set('cert_path', e.target.value)} + onBlur={handleCertPathBlur} placeholder="/etc/nginx/certs/mysite" + className={certPathStatus.state === 'error' ? 'input-error' : certPathStatus.state === 'ok' ? 'input-ok' : ''} /> Directory where fullchain.pem and privkey.key will be saved @@ -222,13 +328,18 @@ export const ServiceFormScreen: FC = () => { {form.verification_method === 'http' && (
- +
+ + +
set('webroot_path', e.target.value)} + onBlur={handleWebrootPathBlur} placeholder="/var/www/html" + className={webrootPathStatus.state === 'error' ? 'input-error' : webrootPathStatus.state === 'ok' ? 'input-ok' : ''} /> Document root of the web server (the validation file will be created at {'{webroot}'}/.well-known/pki-validation/) diff --git a/src/types/service.types.ts b/src/types/service.types.ts index bc87292..7880097 100644 --- a/src/types/service.types.ts +++ b/src/types/service.types.ts @@ -54,3 +54,17 @@ export interface Settings { zerossl_api_key_masked?: string; has_api_key: boolean; } + +export interface PathCheckResult { + valid: boolean; + exists: boolean; + writable: boolean; + error?: string; + note?: string; +} + +export interface PathsValidationResult { + success: boolean; + cert_path: PathCheckResult | null; + webroot_path: PathCheckResult | null; +} From 037b56cb5bcb4e2df2507c2d995f302612e20db6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:09:16 +0000 Subject: [PATCH 05/24] feat: default to email verification and auto-derive webmaster@ from entered domains Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/e955b43e-cfca-4c5a-a7da-bfc56435c533 Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- src/screens/services/ServiceFormScreen.tsx | 85 +++++++++++++++------- 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/src/screens/services/ServiceFormScreen.tsx b/src/screens/services/ServiceFormScreen.tsx index b4f5eb1..c4e69f9 100644 --- a/src/screens/services/ServiceFormScreen.tsx +++ b/src/screens/services/ServiceFormScreen.tsx @@ -1,4 +1,4 @@ -import { FC, useState, useEffect, useCallback } from 'react'; +import { FC, useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { createService, fetchService, updateService, checkPaths } from '../../api/services.api'; import { DomainsInput } from '../../components/DomainsInput/DomainsInput'; @@ -13,10 +13,23 @@ const EMPTY_FORM: ServiceFormData = { cert_path: '', webroot_path: '', restart_command: '', - verification_method: 'http', + verification_method: 'email', verification_email: '', }; +/** + * Extract the registrable root domain (last two dot-separated parts). + * Examples: + * example.com → example.com + * www.example.com → example.com + * sub.app.example.com → example.com + */ +function rootDomain(domain: string): string { + const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').split('/')[0]; + const parts = clean.split('.'); + return parts.length <= 2 ? clean : parts.slice(-2).join('.'); +} + // Statuses for a path field: idle | checking | ok | warn | error type PathStatus = { state: 'idle' | 'checking' | 'ok' | 'warn' | 'error'; @@ -76,6 +89,10 @@ export const ServiceFormScreen: FC = () => { const [certPathStatus, setCertPathStatus] = useState(IDLE); const [webrootPathStatus, setWebrootPathStatus] = useState(IDLE); + // Track the last email value that was set automatically so we know if the + // user has overridden it with something custom. + const autoDerivedEmailRef = useRef(''); + useEffect(() => { if (!isEdit || !id) return; fetchService(id) @@ -102,6 +119,23 @@ export const ServiceFormScreen: FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, isEdit]); + // Auto-derive webmaster@ whenever the first domain changes. + // Only overwrites the email if it is empty or still matches the previously + // auto-derived value (i.e. the user has not manually customised it). + useEffect(() => { + if (isEdit) return; // Don't auto-override when editing an existing service + if (form.domains.length === 0) return; + const derived = `webmaster@${rootDomain(form.domains[0])}`; + setForm((prev) => { + if (prev.verification_email === '' || prev.verification_email === autoDerivedEmailRef.current) { + autoDerivedEmailRef.current = derived; + return { ...prev, verification_email: derived }; + } + return prev; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.domains, isEdit]); + /** * Call the backend validate_paths action and update the status badges. * Only checks non-empty fields; silently resets the other badge to idle. @@ -299,7 +333,7 @@ export const ServiceFormScreen: FC = () => {
- {(['http', 'email'] as VerificationMethod[]).map((method) => ( + {(['email', 'http'] as VerificationMethod[]).map((method) => (
+ {form.verification_method === 'email' && ( +
+ + set('verification_email', e.target.value)} + placeholder="webmaster@example.com" + /> + + Auto-filled as webmaster@<root-domain>. For subdomains (e.g. sub.example.com) the email is always webmaster@example.com. + Must be one of: admin@, administrator@, webmaster@, hostmaster@ or postmaster@. + + {errors.verification_email && {errors.verification_email}} +
+ )} + {form.verification_method === 'http' && (
@@ -347,23 +399,6 @@ export const ServiceFormScreen: FC = () => { {errors.webroot_path && {errors.webroot_path}}
)} - - {form.verification_method === 'email' && ( -
- - set('verification_email', e.target.value)} - placeholder="admin@example.com" - /> - - Must be admin@, administrator@, webmaster@, hostmaster@ or postmaster@ at your domain - - {errors.verification_email && {errors.verification_email}} -
- )}
From a62f6674508d79e975aeb10866095b92e923a264 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:28:12 +0000 Subject: [PATCH 06/24] feat: IMAP auto-verification of ZeroSSL email challenges Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/489adfef-3323-4f63-997b-5786d9c3e51e Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- backend/api/cert.php | 69 +++++ backend/api/settings.php | 92 +++++- backend/lib/ImapMailbox.php | 302 +++++++++++++++++++ src/api/certificates.api.ts | 36 ++- src/screens/services/ServiceDetailScreen.tsx | 18 ++ src/screens/settings/SettingsScreen.tsx | 229 +++++++++++++- src/types/service.types.ts | 6 + 7 files changed, 732 insertions(+), 20 deletions(-) create mode 100644 backend/lib/ImapMailbox.php diff --git a/backend/api/cert.php b/backend/api/cert.php index 1417a71..2ff0f08 100644 --- a/backend/api/cert.php +++ b/backend/api/cert.php @@ -4,6 +4,7 @@ require_once __DIR__ . '/../lib/Services.php'; require_once __DIR__ . '/../lib/ZeroSSL.php'; require_once __DIR__ . '/../lib/CertManager.php'; +require_once __DIR__ . '/../lib/ImapMailbox.php'; $serviceStore = new Services(SERVICES_FILE); $certManager = new CertManager(); @@ -113,6 +114,9 @@ function handlePost(array $input): void case 'renew': actionRenew($service, $certManager); break; + case 'poll_email': + actionPollEmail($service); + break; default: http_response_code(400); echo json_encode(['success' => false, 'error' => 'Unknown action']); @@ -347,6 +351,71 @@ function actionRenew(array $service, CertManager $certManager): void actionRequest($updatedService, $certManager); } +// --------------------------------------------------------------------------- + +function actionPollEmail(array $service): void +{ + global $serviceStore; + + $settingsRaw = @file_get_contents(SETTINGS_FILE); + $settings = json_decode($settingsRaw ?: '{}', true) ?? []; + + $host = $settings['imap_host'] ?? ''; + $port = (int) ($settings['imap_port'] ?? 993); + $encryption = $settings['imap_encryption'] ?? 'ssl'; + $username = $settings['imap_username'] ?? ''; + $password = $settings['imap_password'] ?? ''; + + if (empty($host) || empty($username) || empty($password)) { + http_response_code(400); + echo json_encode([ + 'success' => false, + 'error' => 'IMAP mailbox is not configured. Go to Settings to set up the verification inbox.', + ]); + return; + } + + try { + $imap = new ImapMailbox($host, $port, $encryption, $username, $password); + $results = $imap->processVerificationEmails(); + + // Re-check cert status on ZeroSSL after clicking any links + $certId = $service['cert_id'] ?? null; + $certStatus = $service['cert_status']; + $certExpiry = $service['cert_expiry'] ?? null; + + if ($certId) { + $zerossl = getZeroSSL(); + $cert = $zerossl->getCertificate($certId); + $certStatus = mapZeroSSLStatus($cert['status'] ?? ''); + $certExpiry = $cert['expires'] ?? null; + $serviceStore->update($service['id'], [ + 'cert_status' => $certStatus, + 'cert_expiry' => $certExpiry, + ]); + } + + $found = count($results); + $clicked = count(array_filter($results, fn($r) => $r['success'])); + + $message = $found > 0 + ? "Processed {$clicked}/{$found} verification link(s). Certificate status: {$certStatus}." + : 'No new ZeroSSL verification emails found in the inbox. The email may not have arrived yet — try again in a moment.'; + + echo json_encode([ + 'success' => true, + 'message' => $message, + 'cert_status' => $certStatus, + 'links_found' => $found, + 'links_clicked' => $clicked, + 'details' => $results, + ]); + } catch (Throwable $e) { + http_response_code(500); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/backend/api/settings.php b/backend/api/settings.php index ebe8608..a1576f8 100644 --- a/backend/api/settings.php +++ b/backend/api/settings.php @@ -1,21 +1,32 @@ '']; + return []; } - $raw = file_get_contents(SETTINGS_FILE); + $raw = file_get_contents(SETTINGS_FILE); $data = json_decode($raw, true); - return is_array($data) ? $data : ['zerossl_api_key' => '']; + return is_array($data) ? $data : []; } -function saveSettings(array $settings): void +function saveSettingsData(array $settings): void { file_put_contents(SETTINGS_FILE, json_encode($settings, JSON_PRETTY_PRINT)); } @@ -23,27 +34,49 @@ function saveSettings(array $settings): void switch ($method) { case 'GET': $settings = loadSettings(); - // Mask the API key for display - $masked = $settings; + $masked = $settings; + + // ZeroSSL API key masking if (!empty($masked['zerossl_api_key'])) { $key = $masked['zerossl_api_key']; $len = strlen($key); - if ($len <= 8) { - $masked['zerossl_api_key_masked'] = str_repeat('*', $len); - } else { - $masked['zerossl_api_key_masked'] = substr($key, 0, 4) . str_repeat('*', $len - 8) . substr($key, -4); - } + $masked['zerossl_api_key_masked'] = $len <= 8 + ? str_repeat('*', $len) + : substr($key, 0, 4) . str_repeat('*', $len - 8) . substr($key, -4); $masked['has_api_key'] = true; } else { $masked['has_api_key'] = false; } + unset($masked['zerossl_api_key']); + + // IMAP password masking + $masked['has_imap_config'] = !empty($settings['imap_host']) && !empty($settings['imap_username']); + if (!empty($masked['imap_password'])) { + $masked['imap_password_masked'] = '••••••••'; + } + unset($masked['imap_password']); + echo json_encode(['success' => true, 'settings' => $masked]); break; case 'POST': - $current = loadSettings(); - $updated = array_merge($current, array_intersect_key($input, ['zerossl_api_key' => true])); - saveSettings($updated); + $action = $input['action'] ?? 'save'; + + if ($action === 'test_imap') { + handleTestImap($input); + break; + } + + // Save settings — only allowed keys, don't overwrite password with empty string + global $SETTINGS_ALLOWED; + $current = loadSettings(); + $patch = array_intersect_key($input, array_flip($SETTINGS_ALLOWED)); + + if (array_key_exists('imap_password', $patch) && $patch['imap_password'] === '') { + unset($patch['imap_password']); + } + + saveSettingsData(array_merge($current, $patch)); echo json_encode(['success' => true, 'message' => 'Settings saved']); break; @@ -51,3 +84,34 @@ function saveSettings(array $settings): void http_response_code(405); echo json_encode(['success' => false, 'error' => 'Method not allowed']); } + +// --------------------------------------------------------------------------- + +function handleTestImap(array $input): void +{ + $settings = loadSettings(); + $host = $input['imap_host'] ?? $settings['imap_host'] ?? ''; + $port = (int) ($input['imap_port'] ?? $settings['imap_port'] ?? 993); + $encryption = $input['imap_encryption'] ?? $settings['imap_encryption'] ?? 'ssl'; + $username = $input['imap_username'] ?? $settings['imap_username'] ?? ''; + $password = $input['imap_password'] ?? $settings['imap_password'] ?? ''; + + if (empty($host) || empty($username) || empty($password)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'IMAP host, username, and password are required']); + return; + } + + try { + $imap = new ImapMailbox($host, $port, $encryption, $username, $password); + $info = $imap->testConnection(); + echo json_encode([ + 'success' => true, + 'message' => "Connected successfully. Mailbox has {$info['nmsgs']} message(s), {$info['recent']} recent.", + 'info' => $info, + ]); + } catch (Throwable $e) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => $e->getMessage()]); + } +} diff --git a/backend/lib/ImapMailbox.php b/backend/lib/ImapMailbox.php new file mode 100644 index 0000000..4f2e7c5 --- /dev/null +++ b/backend/lib/ImapMailbox.php @@ -0,0 +1,302 @@ +host = $host; + $this->port = $port; + $this->encryption = strtolower($encryption); + $this->username = $username; + $this->password = $password; + $this->folder = $folder; + } + + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /** + * Open the mailbox, find every unseen ZeroSSL verification email, click + * the verification link inside each one, and mark the email as seen. + * + * @return array + * @throws RuntimeException when the IMAP extension is missing or the + * connection fails. + */ + public function processVerificationEmails(): array + { + $this->requireImapExtension(); + + $connection = $this->openConnection(); + + try { + $results = $this->findAndProcessLinks($connection); + } finally { + @imap_close($connection, CL_EXPUNGE); + } + + return $results; + } + + /** + * Test the IMAP connection and return basic mailbox statistics. + * + * @return array{mailbox: string, nmsgs: int, recent: int} + * @throws RuntimeException on failure. + */ + public function testConnection(): array + { + $this->requireImapExtension(); + + $connection = $this->openConnection(); + $check = imap_check($connection); + @imap_close($connection); + + return [ + 'mailbox' => $check->Mailbox ?? $this->buildMailboxString(), + 'nmsgs' => $check->Nmsgs ?? 0, + 'recent' => $check->Recent ?? 0, + ]; + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /** @throws RuntimeException */ + private function requireImapExtension(): void + { + if (!function_exists('imap_open')) { + throw new RuntimeException( + 'PHP IMAP extension is not available. ' . + 'Install php-imap (e.g. apt install php-imap) and restart PHP.' + ); + } + } + + /** @return resource */ + private function openConnection() + { + // Disable PHP warnings; capture error via imap_last_error() + $connection = @imap_open( + $this->buildMailboxString(), + $this->username, + $this->password, + 0, + 1 + ); + + if ($connection === false) { + throw new RuntimeException( + 'IMAP connection failed: ' . (imap_last_error() ?: 'unknown error') + ); + } + + return $connection; + } + + private function buildMailboxString(): string + { + $flags = '/imap'; + + switch ($this->encryption) { + case 'ssl': + $flags .= '/ssl'; + break; + case 'tls': + $flags .= '/tls'; + break; + default: + $flags .= '/notls'; + } + + return '{' . $this->host . ':' . $this->port . $flags . '}' . $this->folder; + } + + /** + * @param resource $connection + * @return array + */ + private function findAndProcessLinks($connection): array + { + // Search for unseen messages whose sender contains "zerossl.com" + $uids = @imap_search($connection, 'FROM "zerossl.com" UNSEEN', SE_UID); + + if (empty($uids)) { + return []; + } + + $processed = []; + + foreach ($uids as $uid) { + $body = $this->fetchBody($connection, $uid); + $links = $this->extractVerificationLinks($body); + + foreach ($links as $url) { + $clickResult = $this->clickVerificationLink($url); + $processed[] = [ + 'url' => $url, + 'success' => $clickResult['success'], + 'message' => $clickResult['message'], + ]; + } + + // Mark the message as seen so we won't process it again + @imap_setflag_full($connection, (string) $uid, '\\Seen', ST_UID); + } + + return $processed; + } + + /** + * Fetch and decode the full text body of a message (plain text + HTML parts). + * + * @param resource $connection + */ + private function fetchBody($connection, int $uid): string + { + $structure = imap_fetchstructure($connection, $uid, FT_UID); + $body = ''; + + if (!empty($structure->parts)) { + foreach ($structure->parts as $index => $part) { + $sectionNum = $index + 1; + $subtype = strtolower($part->subtype ?? ''); + + if ($subtype === 'plain' || $subtype === 'html') { + $raw = imap_fetchbody($connection, $uid, (string) $sectionNum, FT_UID); + $body .= ' ' . $this->decodePart($raw, $part->encoding ?? ENC7BIT); + } + } + } else { + $raw = @imap_body($connection, $uid, FT_UID); + $body = $this->decodePart($raw ?: '', $structure->encoding ?? ENC7BIT); + } + + return $body; + } + + private function decodePart(string $raw, int $encoding): string + { + switch ($encoding) { + case ENCBASE64: + return base64_decode($raw); + case ENCQUOTEDPRINTABLE: + return quoted_printable_decode($raw); + default: + return $raw; + } + } + + /** + * Extract ZeroSSL verification URLs from an email body (handles both HTML + * href attributes and plain-text URLs). + * + * @return list + */ + private function extractVerificationLinks(string $body): array + { + $links = []; + + // 1. Look inside HTML href attributes first + if (preg_match_all( + '/href=["\']([^"\']*app\.zerossl\.com[^"\']*)["\']/', + $body, + $matches + )) { + foreach ($matches[1] as $url) { + $links[] = html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + } + + // 2. Also scan plain text (after stripping HTML tags) + $plain = html_entity_decode(strip_tags($body), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + if (preg_match_all('#https://app\.zerossl\.com/[^\s\'"<>]+#i', $plain, $matches)) { + foreach ($matches[0] as $url) { + $url = rtrim($url, '.,;:)>"\']'); + if (!in_array($url, $links, true)) { + $links[] = $url; + } + } + } + + // Deduplicate, validate, and restrict to the expected ZeroSSL domain + $links = array_unique($links); + + return array_values(array_filter($links, static function (string $url): bool { + if (!filter_var($url, FILTER_VALIDATE_URL)) { + return false; + } + $host = parse_url($url, PHP_URL_HOST); + return $host !== false && $host !== null + && str_ends_with($host, 'zerossl.com'); + })); + } + + /** + * Make an HTTPS GET request to a ZeroSSL verification URL (i.e. "click" the + * link on behalf of the user). + * + * @return array{success: bool, message: string, http_code?: int} + */ + private function clickVerificationLink(string $url): array + { + if (!filter_var($url, FILTER_VALIDATE_URL)) { + return ['success' => false, 'message' => "Invalid URL: {$url}"]; + } + + $host = parse_url($url, PHP_URL_HOST); + if (!$host || !str_ends_with($host, 'zerossl.com')) { + return ['success' => false, 'message' => "URL does not belong to zerossl.com: {$url}"]; + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 20, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_USERAGENT => 'CertManager/1.0', + ]); + + $response = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($curlError) { + return ['success' => false, 'message' => "cURL error: {$curlError}", 'http_code' => 0]; + } + + $success = ($httpCode >= 200 && $httpCode < 400); + + return [ + 'success' => $success, + 'message' => ($success ? 'Clicked' : 'Failed to click') . " verification link (HTTP {$httpCode}): {$url}", + 'http_code' => $httpCode, + ]; + } +} diff --git a/src/api/certificates.api.ts b/src/api/certificates.api.ts index bbee3be..cdbe205 100644 --- a/src/api/certificates.api.ts +++ b/src/api/certificates.api.ts @@ -39,6 +39,19 @@ export async function renewCertificate(serviceId: string): Promise { + const res = await fetch(`${API_BASE}/cert.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'poll_email', service_id: serviceId }), + }); + return res.json(); +} + export async function getCertStatus(serviceId: string): Promise { const res = await fetch(`${API_BASE}/cert.php?action=status&service_id=${encodeURIComponent(serviceId)}`); return res.json(); @@ -47,10 +60,10 @@ export async function getCertStatus(serviceId: string): Promise { const res = await fetch(`${API_BASE}/settings.php`); const data = await res.json(); - return data.settings ?? { has_api_key: false }; + return data.settings ?? { has_api_key: false, has_imap_config: false }; } -export async function saveSettings(settings: { zerossl_api_key: string }): Promise { +export async function saveSettings(settings: Record): Promise { const res = await fetch(`${API_BASE}/settings.php`, { method: 'POST', headers, @@ -59,3 +72,22 @@ export async function saveSettings(settings: { zerossl_api_key: string }): Promi const data = await res.json(); if (!data.success) throw new Error(data.error ?? 'Failed to save settings'); } + +export async function testImapConnection( + imapSettings: Partial<{ + imap_host: string; + imap_port: number; + imap_encryption: string; + imap_username: string; + imap_password: string; + }> +): Promise<{ message: string }> { + const res = await fetch(`${API_BASE}/settings.php`, { + method: 'POST', + headers, + body: JSON.stringify({ action: 'test_imap', ...imapSettings }), + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error ?? 'Connection test failed'); + return data; +} diff --git a/src/screens/services/ServiceDetailScreen.tsx b/src/screens/services/ServiceDetailScreen.tsx index a5b370b..06c5702 100644 --- a/src/screens/services/ServiceDetailScreen.tsx +++ b/src/screens/services/ServiceDetailScreen.tsx @@ -7,6 +7,7 @@ import { installCertificate, renewCertificate, getCertStatus, + pollEmailVerification, } from '../../api/certificates.api'; import { StatusBadge } from '../../components/StatusBadge/StatusBadge'; import { useNotification } from '../../contexts/NotificationContext'; @@ -221,6 +222,23 @@ export const ServiceDetailScreen: FC = () => { )} + {canVerify && service.verification_method === 'email' && ( + + )} + {canInstall && ( +
+
+ +
+ + +
+ +
+

@@ -163,8 +384,8 @@ export const SettingsScreen: FC = () => {
3
- Verify Domain -

For HTTP verification, the validation file is created in your webroot automatically. For email, ZeroSSL sends a verification link.

+ Auto-Verify via Email +

ZeroSSL sends a verification email to webmaster@ at your domain. CertManager polls the configured IMAP inbox and clicks the verification link automatically.

diff --git a/src/types/service.types.ts b/src/types/service.types.ts index 7880097..72beef0 100644 --- a/src/types/service.types.ts +++ b/src/types/service.types.ts @@ -53,6 +53,12 @@ export interface Settings { zerossl_api_key?: string; zerossl_api_key_masked?: string; has_api_key: boolean; + has_imap_config: boolean; + imap_host?: string; + imap_port?: number; + imap_encryption?: string; + imap_username?: string; + imap_password_masked?: string; } export interface PathCheckResult { From 22874ae8c778aacb0bbc6012fc62b22a1b95f380 Mon Sep 17 00:00:00 2001 From: "ilBiondo.js" Date: Tue, 31 Mar 2026 00:10:01 +0200 Subject: [PATCH 07/24] 0.0.4 - some chagnes --- backend/api/cert.php | 1 + backend/data/services.json | 28 +------------------- backend/lib/ImapMailbox.php | 4 +-- package-lock.json | 4 +-- package.json | 2 +- src/screens/services/ServiceDetailScreen.tsx | 2 +- 6 files changed, 8 insertions(+), 33 deletions(-) diff --git a/backend/api/cert.php b/backend/api/cert.php index 2ff0f08..d907f69 100644 --- a/backend/api/cert.php +++ b/backend/api/cert.php @@ -409,6 +409,7 @@ function actionPollEmail(array $service): void 'links_found' => $found, 'links_clicked' => $clicked, 'details' => $results, + 'resluts' => $results, ]); } catch (Throwable $e) { http_response_code(500); diff --git a/backend/data/services.json b/backend/data/services.json index ea36b9c..ed62f5e 100644 --- a/backend/data/services.json +++ b/backend/data/services.json @@ -1,29 +1,3 @@ { - "services": [ - { - "id": "5098060c2df1e27b", - "name": "Test", - "description": "", - "domains": [ - "jawsdevelopers.ch", - "db.jawsdevelopers.ch", - "test.jawsdevelopers.ch", - "assets.jawsdevelopers.ch", - "vgm.jawsdevelopers.ch", - "pass.jawsdevelopers.ch", - "work.jawsdevelopers.ch" - ], - "cert_path": "\/mount\/jawsdevelopersch\/ssl.jawsdevelopers.ch", - "webroot_path": "\/mount\/jawsdevelopersch\/website\/", - "restart_command": "systemctl restart apache2", - "verification_method": "http", - "verification_email": "", - "cert_id": null, - "cert_status": "none", - "cert_expiry": null, - "last_updated": "2026-03-30T18:01:22+00:00", - "created_at": "2026-03-30T18:01:22+00:00", - "action": "create" - } - ] + "services": [] } \ No newline at end of file diff --git a/backend/lib/ImapMailbox.php b/backend/lib/ImapMailbox.php index 4f2e7c5..c79c6cf 100644 --- a/backend/lib/ImapMailbox.php +++ b/backend/lib/ImapMailbox.php @@ -139,8 +139,8 @@ private function buildMailboxString(): string */ private function findAndProcessLinks($connection): array { - // Search for unseen messages whose sender contains "zerossl.com" - $uids = @imap_search($connection, 'FROM "zerossl.com" UNSEEN', SE_UID); + // Search for unseen messages whose sender contains "trust-provider.com" + $uids = @imap_search($connection, 'FROM "trust-provider.com" UNSEEN', SE_UID); if (empty($uids)) { return []; diff --git a/package-lock.json b/package-lock.json index 2ff7248..de1f54c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "teampltevite", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "teampltevite", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/package.json b/package.json index 76d4bc7..e924e95 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "teampltevite", "private": true, - "version": "0.0.3", + "version": "0.0.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src/screens/services/ServiceDetailScreen.tsx b/src/screens/services/ServiceDetailScreen.tsx index 06c5702..d359088 100644 --- a/src/screens/services/ServiceDetailScreen.tsx +++ b/src/screens/services/ServiceDetailScreen.tsx @@ -243,7 +243,7 @@ export const ServiceDetailScreen: FC = () => { + <> + + + )} {canInstall && ( @@ -303,6 +356,82 @@ export const ServiceDetailScreen: FC = () => {

)} + + {/* Inbox preview panel */} + {inboxEmails !== null && ( +
+
+

📧 Verification Inbox

+ +
+ + {inboxEmails.length === 0 ? ( +

+ No ZeroSSL verification emails found. The email may not have arrived yet — try again in a moment. +

+ ) : ( +
+ {inboxEmails.map((email, idx) => ( +
+
+ #{idx + 1} + {email.subject || '(no subject)'} +
+ + {email.order_number && ( +
+ Order # + {email.order_number} + +
+ )} + + {email.dcv_code && ( +
+ DCV Code + {email.dcv_code} + +
+ )} + +
+ Verification Link{email.links.length > 1 ? 's' : ''} + {email.links.map((url, li) => ( +
+ + 🔗 Click to verify + + +
+ ))} +
+
+ ))} +
+ )} +
+ )}
{/* Right column – Service info */} diff --git a/src/types/service.types.ts b/src/types/service.types.ts index 72beef0..13dd070 100644 --- a/src/types/service.types.ts +++ b/src/types/service.types.ts @@ -61,6 +61,21 @@ export interface Settings { imap_password_masked?: string; } +export interface InboxEmail { + uid: number; + subject: string; + links: string[]; + dcv_code: string; + order_number: string; +} + +export interface ReadInboxResult { + success: boolean; + error?: string; + emails?: InboxEmail[]; + count?: number; +} + export interface PathCheckResult { valid: boolean; exists: boolean; From 1ef566e1421f23624cf5da53dde5c8024edc3aca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:30:12 +0000 Subject: [PATCH 14/24] feat: replace copy buttons with single Open Verification button that opens link and copies DCV code Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/303602af-f9d2-49f7-97a8-75a95737932b Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- src/screens/services/ServiceDetailScreen.css | 63 +++++++----------- src/screens/services/ServiceDetailScreen.tsx | 70 ++++++++------------ 2 files changed, 52 insertions(+), 81 deletions(-) diff --git a/src/screens/services/ServiceDetailScreen.css b/src/screens/services/ServiceDetailScreen.css index bdb71b9..3e07c4f 100644 --- a/src/screens/services/ServiceDetailScreen.css +++ b/src/screens/services/ServiceDetailScreen.css @@ -308,55 +308,40 @@ letter-spacing: 0.04em; } -.btn-copy { - background: var(--bg-hover); - border: 1px solid var(--border); - border-radius: 5px; - color: var(--text-secondary); - font-size: 11px; - font-weight: 600; - padding: 3px 9px; - cursor: pointer; - transition: background 0.15s, color 0.15s; - white-space: nowrap; -} - -.btn-copy:hover { - background: var(--accent-blue); - color: #fff; - border-color: var(--accent-blue); -} - .inbox-links { display: flex; flex-direction: column; gap: 6px; + margin-top: 4px; } -.inbox-link-row { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.inbox-link { +.btn-open-verification { display: inline-flex; align-items: center; - gap: 6px; + gap: 8px; + width: 100%; + justify-content: center; font-size: 13px; - font-weight: 600; - color: var(--accent-blue); - text-decoration: none; - background: rgba(59, 130, 246, 0.08); - border: 1px solid rgba(59, 130, 246, 0.3); - border-radius: 6px; - padding: 5px 12px; - transition: background 0.15s; + font-weight: 700; + color: #fff; + background: var(--accent-blue); + border: none; + border-radius: 7px; + padding: 9px 16px; + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} + +.btn-open-verification:hover { + background: #2563eb; +} + +.btn-open-verification:active { + transform: scale(0.98); } -.inbox-link:hover { - background: rgba(59, 130, 246, 0.18); - text-decoration: none; +.btn-open-verification.copied { + background: var(--accent-green); + cursor: default; } diff --git a/src/screens/services/ServiceDetailScreen.tsx b/src/screens/services/ServiceDetailScreen.tsx index 3f47f3e..d3017d9 100644 --- a/src/screens/services/ServiceDetailScreen.tsx +++ b/src/screens/services/ServiceDetailScreen.tsx @@ -133,14 +133,18 @@ export const ServiceDetailScreen: FC = () => { } }; - const copyToClipboard = async (text: string, key: string) => { + const openVerification = async (url: string, dcvCode: string, key: string) => { + // Copy the DCV code first, then open the link — user just pastes on the page try { - await navigator.clipboard.writeText(text); - setCopiedKey(key); - setTimeout(() => setCopiedKey(null), 2000); + if (dcvCode) { + await navigator.clipboard.writeText(dcvCode); + setCopiedKey(key); + setTimeout(() => setCopiedKey(null), 3000); + } } catch { - // fallback – select/copy manually + // clipboard not available — proceed anyway } + window.open(url, '_blank', 'noopener,noreferrer'); }; if (loading) { @@ -378,52 +382,34 @@ export const ServiceDetailScreen: FC = () => { {email.subject || '(no subject)'}
- {email.order_number && ( -
- Order # - {email.order_number} - -
- )} - {email.dcv_code && (
DCV Code {email.dcv_code} -
)}
- Verification Link{email.links.length > 1 ? 's' : ''} {email.links.map((url, li) => ( -
- - 🔗 Click to verify - - -
+ ))}
From dde2cbf11db43b608c1e4ef1faadab11c0ccacd3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:36:39 +0000 Subject: [PATCH 15/24] fix: extract DCV code from email HTML body div, not just URL query param Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/e23af10e-8085-4f8c-a2f9-c5f17512c671 Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- backend/lib/ImapMailbox.php | 61 +++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/backend/lib/ImapMailbox.php b/backend/lib/ImapMailbox.php index 38f978e..f33c39e 100644 --- a/backend/lib/ImapMailbox.php +++ b/backend/lib/ImapMailbox.php @@ -196,9 +196,10 @@ private function readEmailData($connection): array continue; } - // Extract DCV code and order number from the first link's query string. - // ZeroSSL/trust-provider.com uses camelCase (dcvCode, orderNumber) but - // the casing may vary across API versions, so we check both forms. + // Extract order number and DCV code. + // Priority: (1) visible code displayed in a
//

in the email + // body — that is the code the user actually needs to paste on the + // verification page; (2) fall back to the dcvCode URL query parameter. $dcvCode = ''; $orderNumber = ''; $parsedUrl = parse_url($links[0]); @@ -208,6 +209,11 @@ private function readEmailData($connection): array $orderNumber = $params['orderNumber'] ?? $params['ordernumber'] ?? ''; } + $bodyCode = $this->extractDcvCodeFromBody($body); + if ($bodyCode !== '') { + $dcvCode = $bodyCode; + } + $emails[] = [ 'uid' => $uid, 'subject' => $subject, @@ -302,6 +308,55 @@ private function decodePart(string $raw, int $encoding): string } } + /** + * Extract the DCV (Domain Control Validation) code that Sectigo/ZeroSSL + * displays visually in the email body HTML. + * + * The code is shown in a prominent block element (div, td, p) as a long + * uppercase alphanumeric string — typically 28–64 characters. It is the + * same value as the `dcvCode` URL query parameter but extracting it from + * the rendered body is more robust and matches exactly what the user would + * copy-paste from the email. + * + * Strategy (tried in order): + * 1. Any

, ,

, or whose entire text content is a + * 25–80 char uppercase-alphanumeric string (with optional hyphens). + * 2. Text that immediately follows a label containing "code", "dcv", + * or "validation" (case-insensitive). + */ + private function extractDcvCodeFromBody(string $body): string + { + // ---- Strategy 1: standalone code in a block/inline element ---------- + // Match an element whose only content is the code string. + // Handles both HTML-entity-encoded (& etc.) and plain bodies. + $codePattern = '/[A-Z0-9][A-Z0-9\-]{23,78}[A-Z0-9]/'; + + if (preg_match_all( + '/<(?:div|td|p|span|b|strong|h[1-6])[^>]*>\s*(' . trim($codePattern, '/') . ')\s*<\/(?:div|td|p|span|b|strong|h[1-6])>/i', + $body, + $matches + )) { + foreach ($matches[1] as $candidate) { + // Must be mostly uppercase-alphanumeric (allow hyphens). + // Reject anything that looks like a URL or sentence. + if (preg_match('/^[A-Z0-9][A-Z0-9\-]{23,78}[A-Z0-9]$/', $candidate)) { + return $candidate; + } + } + } + + // ---- Strategy 2: code that follows a "validation code" label -------- + if (preg_match( + '/(?:validation\s+code|dcv\s*code|your\s+code)[^A-Z0-9]*([A-Z0-9][A-Z0-9\-]{23,78}[A-Z0-9])/i', + strip_tags($body), + $m + )) { + return strtoupper($m[1]); + } + + return ''; + } + /** * Extract ZeroSSL domain-verification URLs from an email body. * From 21426d7f4ec8d842470b74f158d492d0ee6f3c04 Mon Sep 17 00:00:00 2001 From: "ilBiondo.js" Date: Tue, 31 Mar 2026 19:43:51 +0200 Subject: [PATCH 16/24] 0.0.7 - Changes regex --- backend/api/cert.php | 68 -------------------- backend/lib/ImapMailbox.php | 4 +- package-lock.json | 4 +- package.json | 2 +- src/api/certificates.api.ts | 13 ---- src/screens/services/ServiceDetailScreen.tsx | 15 ----- 6 files changed, 5 insertions(+), 101 deletions(-) diff --git a/backend/api/cert.php b/backend/api/cert.php index 60af0f2..bed8ad0 100644 --- a/backend/api/cert.php +++ b/backend/api/cert.php @@ -114,9 +114,6 @@ function handlePost(array $input): void case 'renew': actionRenew($service, $certManager); break; - case 'poll_email': - actionPollEmail($service); - break; case 'read_inbox': actionReadInbox($service); break; @@ -380,71 +377,6 @@ function actionRenew(array $service, CertManager $certManager): void // --------------------------------------------------------------------------- -function actionPollEmail(array $service): void -{ - global $serviceStore; - - $settingsRaw = @file_get_contents(SETTINGS_FILE); - $settings = json_decode($settingsRaw ?: '{}', true) ?? []; - - $host = $settings['imap_host'] ?? ''; - $port = (int) ($settings['imap_port'] ?? 993); - $encryption = $settings['imap_encryption'] ?? 'ssl'; - $username = $settings['imap_username'] ?? ''; - $password = $settings['imap_password'] ?? ''; - - if (empty($host) || empty($username) || empty($password)) { - http_response_code(400); - echo json_encode([ - 'success' => false, - 'error' => 'IMAP mailbox is not configured. Go to Settings to set up the verification inbox.', - ]); - return; - } - - try { - $imap = new ImapMailbox($host, $port, $encryption, $username, $password); - $results = $imap->processVerificationEmails(); - - // Re-check cert status on ZeroSSL after clicking any links - $certId = $service['cert_id'] ?? null; - $certStatus = $service['cert_status']; - $certExpiry = $service['cert_expiry'] ?? null; - - if ($certId) { - $zerossl = getZeroSSL(); - $cert = $zerossl->getCertificate($certId); - $certStatus = mapZeroSSLStatus($cert['status'] ?? ''); - $certExpiry = $cert['expires'] ?? null; - $serviceStore->update($service['id'], [ - 'cert_status' => $certStatus, - 'cert_expiry' => $certExpiry, - ]); - } - - $found = count($results); - $clicked = count(array_filter($results, fn($r) => $r['success'])); - - $message = $found > 0 - ? "Processed {$clicked}/{$found} verification link(s). Certificate status: {$certStatus}." - : 'No new ZeroSSL verification emails found in the inbox. The email may not have arrived yet — try again in a moment.'; - - echo json_encode([ - 'success' => true, - 'message' => $message, - 'cert_status' => $certStatus, - 'links_found' => $found, - 'links_clicked' => $clicked, - 'details' => $results, - ]); - } catch (Throwable $e) { - http_response_code(500); - echo json_encode(['success' => false, 'error' => $e->getMessage()]); - } -} - -// --------------------------------------------------------------------------- - function actionReadInbox(array $service): void { $settingsRaw = @file_get_contents(SETTINGS_FILE); diff --git a/backend/lib/ImapMailbox.php b/backend/lib/ImapMailbox.php index f33c39e..97f7d3f 100644 --- a/backend/lib/ImapMailbox.php +++ b/backend/lib/ImapMailbox.php @@ -329,7 +329,7 @@ private function extractDcvCodeFromBody(string $body): string // ---- Strategy 1: standalone code in a block/inline element ---------- // Match an element whose only content is the code string. // Handles both HTML-entity-encoded (& etc.) and plain bodies. - $codePattern = '/[A-Z0-9][A-Z0-9\-]{23,78}[A-Z0-9]/'; + $codePattern = '/[A-Za-z0-9][A-Za-z0-9\-\*]{23,78}[A-Za-z0-9]/'; if (preg_match_all( '/<(?:div|td|p|span|b|strong|h[1-6])[^>]*>\s*(' . trim($codePattern, '/') . ')\s*<\/(?:div|td|p|span|b|strong|h[1-6])>/i', @@ -339,7 +339,7 @@ private function extractDcvCodeFromBody(string $body): string foreach ($matches[1] as $candidate) { // Must be mostly uppercase-alphanumeric (allow hyphens). // Reject anything that looks like a URL or sentence. - if (preg_match('/^[A-Z0-9][A-Z0-9\-]{23,78}[A-Z0-9]$/', $candidate)) { + if (preg_match('/^[A-Za-z0-9][A-Za-z0-9\-\*]{23,78}[A-Za-z0-9]$/', $candidate)) { return $candidate; } } diff --git a/package-lock.json b/package-lock.json index 399800c..d71d4a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "teampltevite", - "version": "0.0.6", + "version": "0.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "teampltevite", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/package.json b/package.json index 5459c5b..97b74ca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "teampltevite", "private": true, - "version": "0.0.6", + "version": "0.0.7", "type": "module", "scripts": { "dev": "vite", diff --git a/src/api/certificates.api.ts b/src/api/certificates.api.ts index 67e4f66..cfae64f 100644 --- a/src/api/certificates.api.ts +++ b/src/api/certificates.api.ts @@ -39,19 +39,6 @@ export async function renewCertificate(serviceId: string): Promise { - const res = await fetch(`${API_BASE}/cert.php`, { - method: 'POST', - headers, - body: JSON.stringify({ action: 'poll_email', service_id: serviceId }), - }); - return res.json(); -} - /** * Read the configured IMAP inbox for ZeroSSL verification emails and return * the links and DCV codes found in them WITHOUT clicking anything. diff --git a/src/screens/services/ServiceDetailScreen.tsx b/src/screens/services/ServiceDetailScreen.tsx index d3017d9..f01913c 100644 --- a/src/screens/services/ServiceDetailScreen.tsx +++ b/src/screens/services/ServiceDetailScreen.tsx @@ -7,7 +7,6 @@ import { installCertificate, renewCertificate, getCertStatus, - pollEmailVerification, readInbox, } from '../../api/certificates.api'; import { StatusBadge } from '../../components/StatusBadge/StatusBadge'; @@ -264,20 +263,6 @@ export const ServiceDetailScreen: FC = () => { {canVerify && service.verification_method === 'email' && ( <> - + )} @@ -359,7 +394,11 @@ export const ServiceDetailScreen: FC = () => { No ZeroSSL verification emails found. The email may not have arrived yet — try again in a moment.

) : ( -
+ <> +

+ ℹ️ These emails have been removed from the inbox. Use 📨 Resend Email if you need a new copy. +

+
{inboxEmails.map((email, idx) => (
@@ -399,7 +438,8 @@ export const ServiceDetailScreen: FC = () => {
))} -
+
+ )}
)} From 7fb1e7aed3316cb62ca4ccd0f8003a6e809bf09c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:04:32 +0000 Subject: [PATCH 18/24] feat: single combined cert file, file path for cert_path, SSH/sudo restart credentials Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/0a9d1acc-887b-4fbe-99d4-623913ff031c Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- backend/api/cert.php | 7 +- backend/api/services.php | 80 +++++++++--------- backend/lib/CertManager.php | 94 ++++++++++++++++------ src/screens/services/ServiceFormScreen.tsx | 59 ++++++++++++-- src/types/service.types.ts | 6 ++ 5 files changed, 178 insertions(+), 68 deletions(-) diff --git a/backend/api/cert.php b/backend/api/cert.php index 305e61f..2aad542 100644 --- a/backend/api/cert.php +++ b/backend/api/cert.php @@ -318,7 +318,12 @@ function actionInstall(array $service, CertManager $certManager): void // 4. Execute restart command $restartCommand = $service['restart_command'] ?? ''; - $cmdResult = $certManager->executeCommand($restartCommand); + $cmdResult = $certManager->executeCommand( + $restartCommand, + $service['restart_ssh_host'] ?? '', + $service['restart_ssh_user'] ?? '', + $service['restart_ssh_password'] ?? '' + ); // 5. Clean up HTTP validation file if present $webrootPath = $service['webroot_path'] ?? ''; diff --git a/backend/api/services.php b/backend/api/services.php index f601a9c..05d80e7 100644 --- a/backend/api/services.php +++ b/backend/api/services.php @@ -126,8 +126,10 @@ /** * Validate a single filesystem path. * - * For 'cert' paths the directory may not exist yet; in that case the parent - * directory must exist and be writable so that CertManager can create it. + * For 'cert' paths the value is a *full file path* (not a directory). + * - If the file already exists it must be writable. + * - If the file does not exist the parent directory must exist and be writable. + * - The basename must be non-empty (i.e. the path must include a filename). * * For 'webroot' paths the directory must already exist and be writable. * @@ -137,72 +139,76 @@ function validatePath(string $path, string $type = 'cert'): array { $path = rtrim($path, '/'); - if ($type === 'webroot') { - if (!is_dir($path)) { + if ($type === 'cert') { + // Ensure the path includes a filename (basename must be non-empty and + // not the same as the directory portion). + $basename = basename($path); + if ($basename === '' || $basename === '.') { return [ 'valid' => false, 'exists' => false, 'writable' => false, - 'error' => "Webroot directory does not exist: {$path}", + 'error' => 'Certificate path must include a filename (e.g. /etc/nginx/ssl/mysite.pem)', ]; } - if (!is_writable($path)) { + + // File already exists — must be writable + if (file_exists($path)) { + if (!is_writable($path)) { + return [ + 'valid' => false, + 'exists' => true, + 'writable' => false, + 'error' => "Certificate file is not writable: {$path}", + ]; + } + return ['valid' => true, 'exists' => true, 'writable' => true]; + } + + // File doesn't exist — check parent directory + $parent = dirname($path); + if (!is_dir($parent)) { return [ 'valid' => false, - 'exists' => true, + 'exists' => false, 'writable' => false, - 'error' => "Webroot directory is not writable: {$path}", + 'error' => "Parent directory does not exist: {$parent}", ]; } - return ['valid' => true, 'exists' => true, 'writable' => true]; - } - - // cert_path: directory may not exist yet - if (is_dir($path)) { - if (!is_writable($path)) { + if (!is_writable($parent)) { return [ 'valid' => false, - 'exists' => true, + 'exists' => false, 'writable' => false, - 'error' => "Certificate directory is not writable: {$path}", + 'error' => "Parent directory is not writable (cannot create file): {$parent}", ]; } - return ['valid' => true, 'exists' => true, 'writable' => true]; - } - - if (file_exists($path)) { return [ - 'valid' => false, - 'exists' => true, - 'writable' => false, - 'error' => "Path exists but is not a directory: {$path}", + 'valid' => true, + 'exists' => false, + 'writable' => true, + 'note' => "File will be created: {$path}", ]; } - // Directory doesn't exist – check that the parent is writable - $parent = dirname($path); - if (!is_dir($parent)) { + // ── webroot ─────────────────────────────────────────────────────────── + if (!is_dir($path)) { return [ 'valid' => false, 'exists' => false, 'writable' => false, - 'error' => "Parent directory does not exist: {$parent}", + 'error' => "Webroot directory does not exist: {$path}", ]; } - if (!is_writable($parent)) { + if (!is_writable($path)) { return [ 'valid' => false, - 'exists' => false, + 'exists' => true, 'writable' => false, - 'error' => "Parent directory is not writable (cannot create {$path}): {$parent}", + 'error' => "Webroot directory is not writable: {$path}", ]; } - return [ - 'valid' => true, - 'exists' => false, - 'writable' => true, - 'note' => "Directory will be created automatically: {$path}", - ]; + return ['valid' => true, 'exists' => true, 'writable' => true]; } /** diff --git a/backend/lib/CertManager.php b/backend/lib/CertManager.php index 590233d..5fef7a6 100644 --- a/backend/lib/CertManager.php +++ b/backend/lib/CertManager.php @@ -65,39 +65,42 @@ public function generateCSR(string $privateKeyPem, array $domains): string } /** - * Write the certificate files to the configured path directory. + * Write a single combined PEM certificate file to the given file path. * - * Creates: - * {cert_path}/fullchain.pem – certificate + CA bundle - * {cert_path}/privkey.key – private key - * {cert_path}/cert.pem – leaf certificate only - * {cert_path}/chain.pem – CA bundle only + * The file contains, in order: + * 1. The leaf certificate + * 2. The CA bundle / intermediate chain + * 3. The private key + * + * This all-in-one format is accepted by nginx, Apache, HAProxy and most + * other servers. The file is created with mode 0600 because it contains + * the private key. The parent directory is created automatically if it + * does not already exist. * * @param string $certPem Leaf certificate PEM. * @param string $caBundlePem CA bundle PEM. * @param string $privateKeyPem Private key PEM. - * @param string $certPath Target directory path. + * @param string $certFilePath Full path to the target file + * (e.g. /etc/nginx/ssl/mysite.pem). */ public function installCertificate( string $certPem, string $caBundlePem, string $privateKeyPem, - string $certPath + string $certFilePath ): void { - if (!is_dir($certPath)) { - if (!mkdir($certPath, 0755, true)) { - throw new RuntimeException("Cannot create directory: {$certPath}"); + $dir = dirname($certFilePath); + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + throw new RuntimeException("Cannot create directory: {$dir}"); } } - $certPath = rtrim($certPath, '/'); - - $fullChain = $certPem . "\n" . $caBundlePem; + $combined = rtrim($certPem) . "\n\n" + . rtrim($caBundlePem) . "\n\n" + . rtrim($privateKeyPem) . "\n"; - $this->writeFile("{$certPath}/fullchain.pem", $fullChain, 0644); - $this->writeFile("{$certPath}/privkey.key", $privateKeyPem, 0600); - $this->writeFile("{$certPath}/cert.pem", $certPem, 0644); - $this->writeFile("{$certPath}/chain.pem", $caBundlePem, 0644); + $this->writeFile($certFilePath, $combined, 0600); } /** @@ -135,19 +138,62 @@ public function removeValidationFile(string $webrootPath, string $validationFile } /** - * Execute a shell command safely and return its output and exit code. + * Execute a shell command and return its output and exit code. + * + * Execution modes (first match wins): + * 1. SSH — $sshHost is set: run the command on a remote host via SSH. + * If $sshPassword is also set, authentication uses sshpass(1); + * otherwise key-based authentication is assumed. + * 2. Sudo — $sshHost is empty but $sshPassword is set: run the command + * on the local machine via "sudo -S" (reads password from stdin). + * 3. Local — no credentials: run as the current PHP process user (legacy + * behaviour, useful when the web server already has permission or + * when a sudoers NOPASSWD rule is in place). * + * @param string $command Shell command to execute. + * @param string $sshHost Remote host (or empty for local execution). + * @param string $sshUser SSH / sudo username (defaults to "root" for SSH). + * @param string $sshPassword SSH or sudo password (leave empty for key-based SSH). * @return array{output: string, exit_code: int} */ - public function executeCommand(string $command): array - { + public function executeCommand( + string $command, + string $sshHost = '', + string $sshUser = '', + string $sshPassword = '' + ): array { if (empty(trim($command))) { return ['output' => '', 'exit_code' => 0]; } - $output = []; - $exitCode = 0; - exec(escapeshellcmd($command) . ' 2>&1', $output, $exitCode); + $output = []; + $exitCode = 0; + + if (!empty($sshHost)) { + // ── Remote execution via SSH ─────────────────────────────────── + $user = !empty($sshUser) ? $sshUser : 'root'; + $sshBase = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + . escapeshellarg("{$user}@{$sshHost}") . ' ' + . escapeshellarg($command) . ' 2>&1'; + + if (!empty($sshPassword)) { + $fullCmd = 'sshpass -p ' . escapeshellarg($sshPassword) . ' ' . $sshBase; + } else { + $fullCmd = $sshBase; + } + + exec($fullCmd, $output, $exitCode); + } elseif (!empty($sshPassword)) { + // ── Local execution with sudo ────────────────────────────────── + $sudoUser = !empty($sshUser) ? ' -u ' . escapeshellarg($sshUser) : ''; + $fullCmd = 'echo ' . escapeshellarg($sshPassword) + . ' | sudo -S' . $sudoUser . ' ' + . escapeshellcmd($command) . ' 2>&1'; + exec($fullCmd, $output, $exitCode); + } else { + // ── Local execution as current user ──────────────────────────── + exec(escapeshellcmd($command) . ' 2>&1', $output, $exitCode); + } return [ 'output' => implode("\n", $output), diff --git a/src/screens/services/ServiceFormScreen.tsx b/src/screens/services/ServiceFormScreen.tsx index c4e69f9..e2888bd 100644 --- a/src/screens/services/ServiceFormScreen.tsx +++ b/src/screens/services/ServiceFormScreen.tsx @@ -13,6 +13,9 @@ const EMPTY_FORM: ServiceFormData = { cert_path: '', webroot_path: '', restart_command: '', + restart_ssh_host: '', + restart_ssh_user: '', + restart_ssh_password: '', verification_method: 'email', verification_email: '', }; @@ -41,8 +44,8 @@ const IDLE: PathStatus = { state: 'idle' }; function statusFromResult(result: PathCheckResult | null | undefined): PathStatus { if (!result) return IDLE; if (!result.valid) return { state: 'error', message: result.error }; - if (!result.exists) return { state: 'warn', message: result.note ?? 'Directory will be created automatically' }; - return { state: 'ok', message: result.exists ? 'Directory exists and is writable' : undefined }; + if (!result.exists) return { state: 'warn', message: result.note ?? 'Will be created automatically' }; + return { state: 'ok', message: 'Accessible' }; } const PathStatusIcon: FC<{ status: PathStatus }> = ({ status }) => { @@ -109,6 +112,9 @@ export const ServiceFormScreen: FC = () => { cert_path: service.cert_path, webroot_path: service.webroot_path, restart_command: service.restart_command, + restart_ssh_host: service.restart_ssh_host ?? '', + restart_ssh_user: service.restart_ssh_user ?? '', + restart_ssh_password: service.restart_ssh_password ?? '', verification_method: service.verification_method, verification_email: service.verification_email, }); @@ -293,7 +299,7 @@ export const ServiceFormScreen: FC = () => {

Certificate Settings

- +
{ value={form.cert_path} onChange={(e) => set('cert_path', e.target.value)} onBlur={handleCertPathBlur} - placeholder="/etc/nginx/certs/mysite" + placeholder="/etc/nginx/ssl/mysite.pem" className={certPathStatus.state === 'error' ? 'input-error' : certPathStatus.state === 'ok' ? 'input-ok' : ''} /> - Directory where fullchain.pem and privkey.key will be saved + Full path to the certificate file (cert + chain + key combined into one PEM). The file will be created or overwritten. {errors.cert_path && {errors.cert_path}}
@@ -321,9 +327,50 @@ export const ServiceFormScreen: FC = () => { placeholder="systemctl restart nginx" /> - Command to execute after certificate installation (runs as the PHP process user) + Command to execute after certificate installation
+ +
+ + set('restart_ssh_host', e.target.value)} + placeholder="192.168.1.10 or server.example.com" + /> + + If set, the restart command is executed on this remote host via SSH. Leave blank to run locally. + +
+ +
+
+ + set('restart_ssh_user', e.target.value)} + placeholder="root" + /> +
+
+ + set('restart_ssh_password', e.target.value)} + placeholder="••••••••" + autoComplete="new-password" + /> +
+
+ + With SSH Host set: connects via SSH (uses sshpass if password is given). Without SSH Host: runs the command locally via sudo -S using the password above. +
{/* Verification */} diff --git a/src/types/service.types.ts b/src/types/service.types.ts index 13dd070..58daf0c 100644 --- a/src/types/service.types.ts +++ b/src/types/service.types.ts @@ -17,6 +17,9 @@ export interface Service { cert_path: string; webroot_path: string; restart_command: string; + restart_ssh_host: string; + restart_ssh_user: string; + restart_ssh_password: string; verification_method: VerificationMethod; verification_email: string; cert_id: string | null; @@ -33,6 +36,9 @@ export interface ServiceFormData { cert_path: string; webroot_path: string; restart_command: string; + restart_ssh_host: string; + restart_ssh_user: string; + restart_ssh_password: string; verification_method: VerificationMethod; verification_email: string; } From 06cde98b68fc2dd5aec9aa1204cab079459984b3 Mon Sep 17 00:00:00 2001 From: "ilBiondo.js" Date: Tue, 31 Mar 2026 22:10:40 +0200 Subject: [PATCH 19/24] 0.0.8 - Some changes --- backend/lib/CertManager.php | 23 ++++++++++------------- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/backend/lib/CertManager.php b/backend/lib/CertManager.php index 5fef7a6..4d05b5a 100644 --- a/backend/lib/CertManager.php +++ b/backend/lib/CertManager.php @@ -97,8 +97,8 @@ public function installCertificate( } $combined = rtrim($certPem) . "\n\n" - . rtrim($caBundlePem) . "\n\n" - . rtrim($privateKeyPem) . "\n"; + . rtrim($caBundlePem) . "\n\n" + . rtrim($privateKeyPem) . "\n"; $this->writeFile($certFilePath, $combined, 0600); } @@ -172,23 +172,20 @@ public function executeCommand( if (!empty($sshHost)) { // ── Remote execution via SSH ─────────────────────────────────── $user = !empty($sshUser) ? $sshUser : 'root'; - $sshBase = 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' - . escapeshellarg("{$user}@{$sshHost}") . ' ' - . escapeshellarg($command) . ' 2>&1'; - - if (!empty($sshPassword)) { - $fullCmd = 'sshpass -p ' . escapeshellarg($sshPassword) . ' ' . $sshBase; - } else { - $fullCmd = $sshBase; - } + $sshBase = 'HOME=/tmp /usr/bin/ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + . escapeshellarg("{$user}@{$sshHost}") . ' ' + . escapeshellarg($command) . ' 2>&1'; + + $fullCmd = '/bin/sshpass -p ' + . escapeshellarg($sshPassword) . ' ' . $sshBase; exec($fullCmd, $output, $exitCode); } elseif (!empty($sshPassword)) { // ── Local execution with sudo ────────────────────────────────── $sudoUser = !empty($sshUser) ? ' -u ' . escapeshellarg($sshUser) : ''; $fullCmd = 'echo ' . escapeshellarg($sshPassword) - . ' | sudo -S' . $sudoUser . ' ' - . escapeshellcmd($command) . ' 2>&1'; + . ' | sudo -S' . $sudoUser . ' ' + . escapeshellcmd($command) . ' 2>&1'; exec($fullCmd, $output, $exitCode); } else { // ── Local execution as current user ──────────────────────────── diff --git a/package-lock.json b/package-lock.json index d71d4a2..cb61788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "teampltevite", - "version": "0.0.7", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "teampltevite", - "version": "0.0.7", + "version": "0.0.8", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/package.json b/package.json index 97b74ca..bc7a04f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "teampltevite", "private": true, - "version": "0.0.7", + "version": "0.0.8", "type": "module", "scripts": { "dev": "vite", From 6fa4bb13115ad245cb357c7a3caa38f97ac759c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:15:03 +0000 Subject: [PATCH 20/24] fix: replace hardcoded /bin/sshpass with dynamic binary discovery and php-ssh2 extension fallback Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/3b2fc37c-b141-4f6e-a45b-6fbcaef1d9d8 Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- backend/lib/CertManager.php | 212 ++++++++++++++++++++++++++++++------ 1 file changed, 176 insertions(+), 36 deletions(-) diff --git a/backend/lib/CertManager.php b/backend/lib/CertManager.php index 4d05b5a..3810055 100644 --- a/backend/lib/CertManager.php +++ b/backend/lib/CertManager.php @@ -141,19 +141,19 @@ public function removeValidationFile(string $webrootPath, string $validationFile * Execute a shell command and return its output and exit code. * * Execution modes (first match wins): - * 1. SSH — $sshHost is set: run the command on a remote host via SSH. - * If $sshPassword is also set, authentication uses sshpass(1); - * otherwise key-based authentication is assumed. - * 2. Sudo — $sshHost is empty but $sshPassword is set: run the command - * on the local machine via "sudo -S" (reads password from stdin). - * 3. Local — no credentials: run as the current PHP process user (legacy - * behaviour, useful when the web server already has permission or - * when a sudoers NOPASSWD rule is in place). + * 1. SSH — $sshHost is set: connects to the remote host and runs the + * command. Authentication order: + * a) PHP ssh2 extension (password or key-based) — no external binary needed. + * b) sshpass binary (searched in common paths) + openssh client. + * c) Plain openssh client (key / agent — only when no password given). + * 2. Sudo — $sshHost is empty but $sshPassword is set: runs the command + * locally via "sudo -S", feeding the password through a pipe. + * 3. Local — no credentials: runs as the current PHP process user. * * @param string $command Shell command to execute. * @param string $sshHost Remote host (or empty for local execution). * @param string $sshUser SSH / sudo username (defaults to "root" for SSH). - * @param string $sshPassword SSH or sudo password (leave empty for key-based SSH). + * @param string $sshPassword Password for SSH or sudo (leave empty for key-based SSH). * @return array{output: string, exit_code: int} */ public function executeCommand( @@ -166,36 +166,176 @@ public function executeCommand( return ['output' => '', 'exit_code' => 0]; } - $output = []; + if (!empty($sshHost)) { + return $this->executeViaSsh($command, $sshHost, $sshUser, $sshPassword); + } + + if (!empty($sshPassword)) { + return $this->executeViaSudo($command, $sshUser, $sshPassword); + } + + // ── Local execution as current user ─────────────────────────────── + $out = []; $exitCode = 0; + exec(escapeshellcmd($command) . ' 2>&1', $out, $exitCode); + return ['output' => implode("\n", $out), 'exit_code' => $exitCode]; + } - if (!empty($sshHost)) { - // ── Remote execution via SSH ─────────────────────────────────── - $user = !empty($sshUser) ? $sshUser : 'root'; - $sshBase = 'HOME=/tmp /usr/bin/ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' - . escapeshellarg("{$user}@{$sshHost}") . ' ' - . escapeshellarg($command) . ' 2>&1'; - - $fullCmd = '/bin/sshpass -p ' - . escapeshellarg($sshPassword) . ' ' . $sshBase; - - exec($fullCmd, $output, $exitCode); - } elseif (!empty($sshPassword)) { - // ── Local execution with sudo ────────────────────────────────── - $sudoUser = !empty($sshUser) ? ' -u ' . escapeshellarg($sshUser) : ''; - $fullCmd = 'echo ' . escapeshellarg($sshPassword) - . ' | sudo -S' . $sudoUser . ' ' - . escapeshellcmd($command) . ' 2>&1'; - exec($fullCmd, $output, $exitCode); - } else { - // ── Local execution as current user ──────────────────────────── - exec(escapeshellcmd($command) . ' 2>&1', $output, $exitCode); - } - - return [ - 'output' => implode("\n", $output), - 'exit_code' => $exitCode, + // ------------------------------------------------------------------ + // Private SSH / sudo helpers + // ------------------------------------------------------------------ + + /** + * Run a command on a remote host via SSH. + * + * Tries (in order): + * 1. PHP ssh2 extension — no external binary required. + * 2. sshpass + ssh — searched in common PATH locations. + * 3. Plain ssh — usable when no password is required (key/agent auth). + * + * @return array{output: string, exit_code: int} + */ + private function executeViaSsh( + string $command, + string $host, + string $user, + string $password + ): array { + $user = !empty($user) ? $user : 'root'; + + // ── Strategy 1: PHP ssh2 extension ──────────────────────────────── + if (extension_loaded('ssh2')) { + return $this->executeViaSsh2Extension($command, $host, $user, $password); + } + + // ── Strategy 2: sshpass binary ──────────────────────────────────── + if (!empty($password)) { + $sshpass = $this->findBinary('sshpass'); + if ($sshpass !== null) { + $ssh = $this->findBinary('ssh') ?? 'ssh'; + $sshCmd = $ssh . ' -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + . escapeshellarg("{$user}@{$host}") . ' ' + . escapeshellarg($command); + $fullCmd = $sshpass . ' -p ' . escapeshellarg($password) . ' ' . $sshCmd . ' 2>&1'; + $out = []; + $exitCode = 0; + exec($fullCmd, $out, $exitCode); + return ['output' => implode("\n", $out), 'exit_code' => $exitCode]; + } + + // sshpass not found and password was supplied — abort with a clear message + return [ + 'output' => 'SSH with password requires either the PHP ssh2 extension' + . ' (php-ssh2) or the sshpass utility to be installed on the server.', + 'exit_code' => 127, + ]; + } + + // ── Strategy 3: plain ssh (key / agent-based) ───────────────────── + $ssh = $this->findBinary('ssh') ?? 'ssh'; + $fullCmd = $ssh . ' -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + . escapeshellarg("{$user}@{$host}") . ' ' + . escapeshellarg($command) . ' 2>&1'; + $out = []; + $exitCode = 0; + exec($fullCmd, $out, $exitCode); + return ['output' => implode("\n", $out), 'exit_code' => $exitCode]; + } + + /** + * Use PHP's ssh2 extension to run a command on a remote host. + * + * @return array{output: string, exit_code: int} + */ + private function executeViaSsh2Extension( + string $command, + string $host, + string $user, + string $password + ): array { + $conn = @ssh2_connect($host, 22); + if ($conn === false) { + return ['output' => "SSH: Could not connect to {$host}", 'exit_code' => 1]; + } + + if (!empty($password)) { + if (!@ssh2_auth_password($conn, $user, $password)) { + return ['output' => "SSH: Password authentication failed for {$user}@{$host}", 'exit_code' => 1]; + } + } elseif (!@ssh2_auth_agent($conn, $user)) { + return ['output' => "SSH: Agent/key authentication failed for {$user}@{$host}", 'exit_code' => 1]; + } + + $stream = @ssh2_exec($conn, $command . ' 2>&1'); + if ($stream === false) { + return ['output' => 'SSH: Failed to execute remote command', 'exit_code' => 1]; + } + + stream_set_blocking($stream, true); + $output = (string) stream_get_contents($stream); + fclose($stream); + + return ['output' => rtrim($output), 'exit_code' => 0]; + } + + /** + * Run a command locally via sudo, feeding the password through a pipe. + * + * Uses proc_open so the password goes directly to sudo's stdin without + * being visible in the process list (avoids "echo pass | sudo"). + * + * @return array{output: string, exit_code: int} + */ + private function executeViaSudo(string $command, string $user, string $password): array + { + $sudoUser = !empty($user) ? ' -u ' . escapeshellarg($user) : ''; + $fullCmd = 'sudo -S' . $sudoUser . ' ' . escapeshellcmd($command) . ' 2>&1'; + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $proc = @proc_open($fullCmd, $descriptors, $pipes); + if (!is_resource($proc)) { + return ['output' => 'Failed to start sudo process', 'exit_code' => 1]; + } + + fwrite($pipes[0], $password . "\n"); + fclose($pipes[0]); + + $stdout = (string) stream_get_contents($pipes[1]); + fclose($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($proc); + + $combined = rtrim($stdout . ($stderr ? "\n" . $stderr : '')); + return ['output' => $combined, 'exit_code' => $exitCode]; + } + + /** + * Find the full path of a system binary by checking common directories + * and then falling back to `which`. + */ + private function findBinary(string $name): ?string + { + $candidates = [ + "/usr/bin/{$name}", + "/bin/{$name}", + "/usr/local/bin/{$name}", + "/usr/sbin/{$name}", ]; + foreach ($candidates as $path) { + if (is_executable($path)) { + return $path; + } + } + // `which` fallback + $found = trim((string) shell_exec('which ' . escapeshellarg($name) . ' 2>/dev/null')); + return ($found !== '' && is_executable($found)) ? $found : null; } // ------------------------------------------------------------------ From 265010e74b686cd239b4686285fe5816d72775a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:20:34 +0000 Subject: [PATCH 21/24] fix: add UserKnownHostsFile=/dev/null and LogLevel=ERROR to SSH options to prevent known_hosts permission errors Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/352b0f15-136b-4a6c-916a-d16d1f6a4c0c Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- backend/lib/CertManager.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/lib/CertManager.php b/backend/lib/CertManager.php index 3810055..8b15fa8 100644 --- a/backend/lib/CertManager.php +++ b/backend/lib/CertManager.php @@ -213,7 +213,11 @@ private function executeViaSsh( $sshpass = $this->findBinary('sshpass'); if ($sshpass !== null) { $ssh = $this->findBinary('ssh') ?? 'ssh'; - $sshCmd = $ssh . ' -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + $sshCmd = $ssh + . ' -o StrictHostKeyChecking=no' + . ' -o UserKnownHostsFile=/dev/null' + . ' -o LogLevel=ERROR' + . ' -o ConnectTimeout=10 ' . escapeshellarg("{$user}@{$host}") . ' ' . escapeshellarg($command); $fullCmd = $sshpass . ' -p ' . escapeshellarg($password) . ' ' . $sshCmd . ' 2>&1'; @@ -233,7 +237,11 @@ private function executeViaSsh( // ── Strategy 3: plain ssh (key / agent-based) ───────────────────── $ssh = $this->findBinary('ssh') ?? 'ssh'; - $fullCmd = $ssh . ' -o StrictHostKeyChecking=no -o ConnectTimeout=10 ' + $fullCmd = $ssh + . ' -o StrictHostKeyChecking=no' + . ' -o UserKnownHostsFile=/dev/null' + . ' -o LogLevel=ERROR' + . ' -o ConnectTimeout=10 ' . escapeshellarg("{$user}@{$host}") . ' ' . escapeshellarg($command) . ' 2>&1'; $out = []; From 0fe2720d7f93a5cd622d694406a8787831c3ab5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:55:45 +0000 Subject: [PATCH 22/24] feat: add split-files checkbox for separate cert, CA bundle and private key file paths Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/f03de3ee-f19d-45b3-88d7-d307e6afc9c7 Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- backend/api/cert.php | 13 +- backend/api/services.php | 44 +++++- backend/lib/CertManager.php | 32 ++++ backend/lib/Services.php | 3 + src/api/services.api.ts | 6 + src/screens/services/ServiceFormScreen.css | 20 +++ src/screens/services/ServiceFormScreen.tsx | 166 ++++++++++++++++++--- src/types/service.types.ts | 8 + 8 files changed, 264 insertions(+), 28 deletions(-) diff --git a/backend/api/cert.php b/backend/api/cert.php index 2aad542..245e6dd 100644 --- a/backend/api/cert.php +++ b/backend/api/cert.php @@ -314,7 +314,18 @@ function actionInstall(array $service, CertManager $certManager): void } // 3. Write files to disk - $certManager->installCertificate($certPem, $caBundlePem, $privateKey, $certPath); + if (!empty($service['split_files'])) { + $caPath = $service['ca_path'] ?? ''; + $keyPath = $service['key_path'] ?? ''; + if (empty($caPath) || empty($keyPath)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'ca_path and key_path must be configured when split files mode is enabled']); + return; + } + $certManager->installCertificateSplit($certPem, $caBundlePem, $privateKey, $certPath, $caPath, $keyPath); + } else { + $certManager->installCertificate($certPem, $caBundlePem, $privateKey, $certPath); + } // 4. Execute restart command $restartCommand = $service['restart_command'] ?? ''; diff --git a/backend/api/services.php b/backend/api/services.php index 05d80e7..38794cf 100644 --- a/backend/api/services.php +++ b/backend/api/services.php @@ -29,11 +29,20 @@ switch ($action) { case 'validate_paths': - $certPath = $input['cert_path'] ?? ''; + $certPath = $input['cert_path'] ?? ''; $webrootPath = $input['webroot_path'] ?? ''; - $verMethod = $input['verification_method'] ?? 'http'; + $verMethod = $input['verification_method'] ?? 'http'; + $splitFiles = !empty($input['split_files']); + $caPath = $input['ca_path'] ?? ''; + $keyPath = $input['key_path'] ?? ''; - $result = ['success' => true, 'cert_path' => null, 'webroot_path' => null]; + $result = [ + 'success' => true, + 'cert_path' => null, + 'ca_path' => null, + 'key_path' => null, + 'webroot_path' => null, + ]; if ($certPath !== '') { $result['cert_path'] = validatePath($certPath, 'cert'); @@ -41,6 +50,14 @@ if ($webrootPath !== '' && $verMethod === 'http') { $result['webroot_path'] = validatePath($webrootPath, 'webroot'); } + if ($splitFiles) { + if ($caPath !== '') { + $result['ca_path'] = validatePath($caPath, 'cert'); + } + if ($keyPath !== '') { + $result['key_path'] = validatePath($keyPath, 'cert'); + } + } echo json_encode($result); break; @@ -217,10 +234,11 @@ function validatePath(string $path, string $type = 'cert'): array */ function collectPathErrors(array $input): array { - $errors = []; - $certPath = $input['cert_path'] ?? ''; - $webroot = $input['webroot_path'] ?? ''; - $verMethod = $input['verification_method'] ?? 'http'; + $errors = []; + $certPath = $input['cert_path'] ?? ''; + $webroot = $input['webroot_path'] ?? ''; + $verMethod = $input['verification_method'] ?? 'http'; + $splitFiles = !empty($input['split_files']); if ($certPath !== '') { $check = validatePath($certPath, 'cert'); @@ -229,6 +247,18 @@ function collectPathErrors(array $input): array } } + if ($splitFiles) { + foreach (['ca_path', 'key_path'] as $field) { + $path = $input[$field] ?? ''; + if ($path !== '') { + $check = validatePath($path, 'cert'); + if (!$check['valid']) { + $errors[] = $check['error']; + } + } + } + } + if ($verMethod === 'http' && $webroot !== '') { $check = validatePath($webroot, 'webroot'); if (!$check['valid']) { diff --git a/backend/lib/CertManager.php b/backend/lib/CertManager.php index 8b15fa8..e1434ab 100644 --- a/backend/lib/CertManager.php +++ b/backend/lib/CertManager.php @@ -103,6 +103,38 @@ public function installCertificate( $this->writeFile($certFilePath, $combined, 0600); } + /** + * Write the certificate components to three separate files. + * + * @param string $certPem Leaf certificate PEM. + * @param string $caBundlePem CA bundle PEM. + * @param string $privateKeyPem Private key PEM. + * @param string $certFilePath Full path for the leaf certificate. + * @param string $caFilePath Full path for the CA bundle. + * @param string $keyFilePath Full path for the private key (written 0600). + */ + public function installCertificateSplit( + string $certPem, + string $caBundlePem, + string $privateKeyPem, + string $certFilePath, + string $caFilePath, + string $keyFilePath + ): void { + foreach ([$certFilePath, $caFilePath, $keyFilePath] as $path) { + $dir = dirname($path); + if (!is_dir($dir)) { + if (!mkdir($dir, 0755, true)) { + throw new RuntimeException("Cannot create directory: {$dir}"); + } + } + } + + $this->writeFile($certFilePath, rtrim($certPem) . "\n", 0644); + $this->writeFile($caFilePath, rtrim($caBundlePem) . "\n", 0644); + $this->writeFile($keyFilePath, rtrim($privateKeyPem) . "\n", 0600); + } + /** * Create the HTTP file-based validation file at the web root. * diff --git a/backend/lib/Services.php b/backend/lib/Services.php index 7e28351..e98dcd7 100644 --- a/backend/lib/Services.php +++ b/backend/lib/Services.php @@ -52,7 +52,10 @@ public function create(array $serviceData): array 'name' => '', 'description' => '', 'domains' => [], + 'split_files' => false, 'cert_path' => '', + 'ca_path' => '', + 'key_path' => '', 'webroot_path' => '', 'restart_command' => '', 'verification_method' => 'http', diff --git a/src/api/services.api.ts b/src/api/services.api.ts index 540c8f5..1c492bd 100644 --- a/src/api/services.api.ts +++ b/src/api/services.api.ts @@ -51,6 +51,9 @@ export async function checkPaths( certPath: string, webrootPath: string, verificationMethod: string, + splitFiles?: boolean, + caPath?: string, + keyPath?: string, ): Promise { const res = await fetch(`${API_BASE}/services.php`, { method: 'POST', @@ -60,6 +63,9 @@ export async function checkPaths( cert_path: certPath, webroot_path: webrootPath, verification_method: verificationMethod, + split_files: splitFiles ?? false, + ca_path: caPath ?? '', + key_path: keyPath ?? '', }), }); return res.json(); diff --git a/src/screens/services/ServiceFormScreen.css b/src/screens/services/ServiceFormScreen.css index c66d312..2e02b4c 100644 --- a/src/screens/services/ServiceFormScreen.css +++ b/src/screens/services/ServiceFormScreen.css @@ -158,3 +158,23 @@ input.input-error:focus { gap: 12px; margin-top: 8px; } + +/* Split files toggle checkbox */ +.split-files-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: 500; + font-size: 14px; + color: var(--text-primary); + margin-bottom: 0; +} + +.split-files-toggle input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent-blue); + cursor: pointer; + flex-shrink: 0; +} diff --git a/src/screens/services/ServiceFormScreen.tsx b/src/screens/services/ServiceFormScreen.tsx index e2888bd..8113425 100644 --- a/src/screens/services/ServiceFormScreen.tsx +++ b/src/screens/services/ServiceFormScreen.tsx @@ -10,7 +10,10 @@ const EMPTY_FORM: ServiceFormData = { name: '', description: '', domains: [], + split_files: false, cert_path: '', + ca_path: '', + key_path: '', webroot_path: '', restart_command: '', restart_ssh_host: '', @@ -90,6 +93,8 @@ export const ServiceFormScreen: FC = () => { const [errors, setErrors] = useState>({}); const [certPathStatus, setCertPathStatus] = useState(IDLE); + const [caPathStatus, setCaPathStatus] = useState(IDLE); + const [keyPathStatus, setKeyPathStatus] = useState(IDLE); const [webrootPathStatus, setWebrootPathStatus] = useState(IDLE); // Track the last email value that was set automatically so we know if the @@ -109,7 +114,10 @@ export const ServiceFormScreen: FC = () => { name: service.name, description: service.description, domains: service.domains, + split_files: service.split_files ?? false, cert_path: service.cert_path, + ca_path: service.ca_path ?? '', + key_path: service.key_path ?? '', webroot_path: service.webroot_path, restart_command: service.restart_command, restart_ssh_host: service.restart_ssh_host ?? '', @@ -147,21 +155,29 @@ export const ServiceFormScreen: FC = () => { * Only checks non-empty fields; silently resets the other badge to idle. */ const runPathCheck = useCallback( - async (certPath: string, webrootPath: string, verMethod: string) => { + async (certPath: string, caPath: string, keyPath: string, webrootPath: string, verMethod: string, splitFiles: boolean) => { const checkCert = certPath.trim() !== ''; + const checkCa = splitFiles && caPath.trim() !== ''; + const checkKey = splitFiles && keyPath.trim() !== ''; const checkWebroot = webrootPath.trim() !== '' && verMethod === 'http'; - if (!checkCert && !checkWebroot) return; + if (!checkCert && !checkCa && !checkKey && !checkWebroot) return; if (checkCert) setCertPathStatus({ state: 'checking' }); + if (checkCa) setCaPathStatus({ state: 'checking' }); + if (checkKey) setKeyPathStatus({ state: 'checking' }); if (checkWebroot) setWebrootPathStatus({ state: 'checking' }); try { - const result = await checkPaths(certPath, webrootPath, verMethod); + const result = await checkPaths(certPath, webrootPath, verMethod, splitFiles, caPath, keyPath); if (checkCert) setCertPathStatus(statusFromResult(result.cert_path)); + if (checkCa) setCaPathStatus(statusFromResult(result.ca_path)); + if (checkKey) setKeyPathStatus(statusFromResult(result.key_path)); if (checkWebroot) setWebrootPathStatus(statusFromResult(result.webroot_path)); } catch { if (checkCert) setCertPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); + if (checkCa) setCaPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); + if (checkKey) setKeyPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); if (checkWebroot) setWebrootPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); } }, @@ -170,15 +186,31 @@ export const ServiceFormScreen: FC = () => { const handleCertPathBlur = () => { if (form.cert_path.trim()) { - runPathCheck(form.cert_path, form.webroot_path, form.verification_method); + runPathCheck(form.cert_path, form.ca_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); } else { setCertPathStatus(IDLE); } }; + const handleCaPathBlur = () => { + if (form.ca_path.trim()) { + runPathCheck(form.cert_path, form.ca_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); + } else { + setCaPathStatus(IDLE); + } + }; + + const handleKeyPathBlur = () => { + if (form.key_path.trim()) { + runPathCheck(form.cert_path, form.ca_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); + } else { + setKeyPathStatus(IDLE); + } + }; + const handleWebrootPathBlur = () => { if (form.webroot_path.trim()) { - runPathCheck(form.cert_path, form.webroot_path, form.verification_method); + runPathCheck(form.cert_path, form.ca_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); } else { setWebrootPathStatus(IDLE); } @@ -189,6 +221,10 @@ export const ServiceFormScreen: FC = () => { if (!form.name.trim()) e.name = 'Service name is required'; if (form.domains.length === 0) e.domains = 'At least one domain is required'; if (!form.cert_path.trim()) e.cert_path = 'Certificate path is required'; + if (form.split_files) { + if (!form.ca_path.trim()) e.ca_path = 'CA bundle path is required'; + if (!form.key_path.trim()) e.key_path = 'Private key path is required'; + } if (form.verification_method === 'http' && !form.webroot_path.trim()) { e.webroot_path = 'Webroot path is required for HTTP verification'; } @@ -199,6 +235,12 @@ export const ServiceFormScreen: FC = () => { if (certPathStatus.state === 'error') { e.cert_path = certPathStatus.message ?? 'Certificate path is invalid'; } + if (form.split_files && caPathStatus.state === 'error') { + e.ca_path = caPathStatus.message ?? 'CA bundle path is invalid'; + } + if (form.split_files && keyPathStatus.state === 'error') { + e.key_path = keyPathStatus.message ?? 'Private key path is invalid'; + } if (webrootPathStatus.state === 'error') { e.webroot_path = webrootPathStatus.message ?? 'Webroot path is invalid'; } @@ -232,8 +274,15 @@ export const ServiceFormScreen: FC = () => { if (errors[key]) setErrors((prev) => { const n = { ...prev }; delete n[key]; return n; }); // Reset path status when field changes if (key === 'cert_path') setCertPathStatus(IDLE); + if (key === 'ca_path') setCaPathStatus(IDLE); + if (key === 'key_path') setKeyPathStatus(IDLE); if (key === 'webroot_path') setWebrootPathStatus(IDLE); if (key === 'verification_method') setWebrootPathStatus(IDLE); + // When toggling split_files off, reset the extra statuses + if (key === 'split_files' && !value) { + setCaPathStatus(IDLE); + setKeyPathStatus(IDLE); + } }; if (loading) { @@ -297,26 +346,103 @@ export const ServiceFormScreen: FC = () => { {/* Certificate Settings */}

Certificate Settings

+ + {/* Split files toggle */}
-
- - -
- set('cert_path', e.target.value)} - onBlur={handleCertPathBlur} - placeholder="/etc/nginx/ssl/mysite.pem" - className={certPathStatus.state === 'error' ? 'input-error' : certPathStatus.state === 'ok' ? 'input-ok' : ''} - /> + - Full path to the certificate file (cert + chain + key combined into one PEM). The file will be created or overwritten. + When unchecked, all three are written as a single combined PEM file. + When checked, each is written to its own path. - {errors.cert_path && {errors.cert_path}}
+ {!form.split_files ? ( + /* ── Combined PEM mode ─────────────────────────────── */ +
+
+ + +
+ set('cert_path', e.target.value)} + onBlur={handleCertPathBlur} + placeholder="/etc/nginx/ssl/mysite.pem" + className={certPathStatus.state === 'error' ? 'input-error' : certPathStatus.state === 'ok' ? 'input-ok' : ''} + /> + + Full path to the certificate file (cert + chain + key combined into one PEM). The file will be created or overwritten. + + {errors.cert_path && {errors.cert_path}} +
+ ) : ( + /* ── Split files mode ──────────────────────────────── */ + <> +
+
+ + +
+ set('cert_path', e.target.value)} + onBlur={handleCertPathBlur} + placeholder="/etc/nginx/ssl/cert.pem" + className={certPathStatus.state === 'error' ? 'input-error' : certPathStatus.state === 'ok' ? 'input-ok' : ''} + /> + Path to the leaf certificate (certificate.crt). + {errors.cert_path && {errors.cert_path}} +
+ +
+
+ + +
+ set('ca_path', e.target.value)} + onBlur={handleCaPathBlur} + placeholder="/etc/nginx/ssl/ca.pem" + className={caPathStatus.state === 'error' ? 'input-error' : caPathStatus.state === 'ok' ? 'input-ok' : ''} + /> + Path to the CA / intermediate chain (ca_bundle.crt). + {errors.ca_path && {errors.ca_path}} +
+ +
+
+ + +
+ set('key_path', e.target.value)} + onBlur={handleKeyPathBlur} + placeholder="/etc/nginx/ssl/privkey.pem" + className={keyPathStatus.state === 'error' ? 'input-error' : keyPathStatus.state === 'ok' ? 'input-ok' : ''} + /> + Path to the private key file (stored with 0600 permissions). + {errors.key_path && {errors.key_path}} +
+ + )} +
Date: Mon, 27 Apr 2026 17:05:27 +0000 Subject: [PATCH 23/24] feat: split files mode writes fullchain (cert+CA) + key separately Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/71c6cf64-7fae-43b5-8cb5-b8c8f5c9b2ae Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- backend/api/cert.php | 7 ++- backend/api/services.php | 6 +-- backend/lib/CertManager.php | 17 +++--- src/screens/services/ServiceFormScreen.tsx | 63 +++++----------------- 4 files changed, 27 insertions(+), 66 deletions(-) diff --git a/backend/api/cert.php b/backend/api/cert.php index 245e6dd..f60d62c 100644 --- a/backend/api/cert.php +++ b/backend/api/cert.php @@ -315,14 +315,13 @@ function actionInstall(array $service, CertManager $certManager): void // 3. Write files to disk if (!empty($service['split_files'])) { - $caPath = $service['ca_path'] ?? ''; $keyPath = $service['key_path'] ?? ''; - if (empty($caPath) || empty($keyPath)) { + if (empty($keyPath)) { http_response_code(400); - echo json_encode(['success' => false, 'error' => 'ca_path and key_path must be configured when split files mode is enabled']); + echo json_encode(['success' => false, 'error' => 'key_path must be configured when split files mode is enabled']); return; } - $certManager->installCertificateSplit($certPem, $caBundlePem, $privateKey, $certPath, $caPath, $keyPath); + $certManager->installCertificateSplit($certPem, $caBundlePem, $privateKey, $certPath, $keyPath); } else { $certManager->installCertificate($certPem, $caBundlePem, $privateKey, $certPath); } diff --git a/backend/api/services.php b/backend/api/services.php index 38794cf..c2a8207 100644 --- a/backend/api/services.php +++ b/backend/api/services.php @@ -33,7 +33,6 @@ $webrootPath = $input['webroot_path'] ?? ''; $verMethod = $input['verification_method'] ?? 'http'; $splitFiles = !empty($input['split_files']); - $caPath = $input['ca_path'] ?? ''; $keyPath = $input['key_path'] ?? ''; $result = [ @@ -51,9 +50,6 @@ $result['webroot_path'] = validatePath($webrootPath, 'webroot'); } if ($splitFiles) { - if ($caPath !== '') { - $result['ca_path'] = validatePath($caPath, 'cert'); - } if ($keyPath !== '') { $result['key_path'] = validatePath($keyPath, 'cert'); } @@ -248,7 +244,7 @@ function collectPathErrors(array $input): array } if ($splitFiles) { - foreach (['ca_path', 'key_path'] as $field) { + foreach (['key_path'] as $field) { $path = $input[$field] ?? ''; if ($path !== '') { $check = validatePath($path, 'cert'); diff --git a/backend/lib/CertManager.php b/backend/lib/CertManager.php index e1434ab..8b6bca6 100644 --- a/backend/lib/CertManager.php +++ b/backend/lib/CertManager.php @@ -104,13 +104,17 @@ public function installCertificate( } /** - * Write the certificate components to three separate files. + * Write the certificate to two separate files: a fullchain file + * (leaf certificate + CA bundle concatenated) and a private key file. + * + * This is the "split files" mode where the server reads the chain from one + * file and the key from another (e.g. nginx `ssl_certificate` / + * `ssl_certificate_key`). * * @param string $certPem Leaf certificate PEM. * @param string $caBundlePem CA bundle PEM. * @param string $privateKeyPem Private key PEM. - * @param string $certFilePath Full path for the leaf certificate. - * @param string $caFilePath Full path for the CA bundle. + * @param string $certFilePath Full path for the fullchain file (cert + CA). * @param string $keyFilePath Full path for the private key (written 0600). */ public function installCertificateSplit( @@ -118,10 +122,9 @@ public function installCertificateSplit( string $caBundlePem, string $privateKeyPem, string $certFilePath, - string $caFilePath, string $keyFilePath ): void { - foreach ([$certFilePath, $caFilePath, $keyFilePath] as $path) { + foreach ([$certFilePath, $keyFilePath] as $path) { $dir = dirname($path); if (!is_dir($dir)) { if (!mkdir($dir, 0755, true)) { @@ -130,8 +133,8 @@ public function installCertificateSplit( } } - $this->writeFile($certFilePath, rtrim($certPem) . "\n", 0644); - $this->writeFile($caFilePath, rtrim($caBundlePem) . "\n", 0644); + $fullchain = rtrim($certPem) . "\n\n" . rtrim($caBundlePem) . "\n"; + $this->writeFile($certFilePath, $fullchain, 0644); $this->writeFile($keyFilePath, rtrim($privateKeyPem) . "\n", 0600); } diff --git a/src/screens/services/ServiceFormScreen.tsx b/src/screens/services/ServiceFormScreen.tsx index 8113425..230a1fe 100644 --- a/src/screens/services/ServiceFormScreen.tsx +++ b/src/screens/services/ServiceFormScreen.tsx @@ -93,7 +93,6 @@ export const ServiceFormScreen: FC = () => { const [errors, setErrors] = useState>({}); const [certPathStatus, setCertPathStatus] = useState(IDLE); - const [caPathStatus, setCaPathStatus] = useState(IDLE); const [keyPathStatus, setKeyPathStatus] = useState(IDLE); const [webrootPathStatus, setWebrootPathStatus] = useState(IDLE); @@ -155,28 +154,24 @@ export const ServiceFormScreen: FC = () => { * Only checks non-empty fields; silently resets the other badge to idle. */ const runPathCheck = useCallback( - async (certPath: string, caPath: string, keyPath: string, webrootPath: string, verMethod: string, splitFiles: boolean) => { + async (certPath: string, keyPath: string, webrootPath: string, verMethod: string, splitFiles: boolean) => { const checkCert = certPath.trim() !== ''; - const checkCa = splitFiles && caPath.trim() !== ''; const checkKey = splitFiles && keyPath.trim() !== ''; const checkWebroot = webrootPath.trim() !== '' && verMethod === 'http'; - if (!checkCert && !checkCa && !checkKey && !checkWebroot) return; + if (!checkCert && !checkKey && !checkWebroot) return; if (checkCert) setCertPathStatus({ state: 'checking' }); - if (checkCa) setCaPathStatus({ state: 'checking' }); if (checkKey) setKeyPathStatus({ state: 'checking' }); if (checkWebroot) setWebrootPathStatus({ state: 'checking' }); try { - const result = await checkPaths(certPath, webrootPath, verMethod, splitFiles, caPath, keyPath); + const result = await checkPaths(certPath, webrootPath, verMethod, splitFiles, '', keyPath); if (checkCert) setCertPathStatus(statusFromResult(result.cert_path)); - if (checkCa) setCaPathStatus(statusFromResult(result.ca_path)); if (checkKey) setKeyPathStatus(statusFromResult(result.key_path)); if (checkWebroot) setWebrootPathStatus(statusFromResult(result.webroot_path)); } catch { if (checkCert) setCertPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); - if (checkCa) setCaPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); if (checkKey) setKeyPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); if (checkWebroot) setWebrootPathStatus({ state: 'error', message: 'Could not verify path (server unreachable)' }); } @@ -186,23 +181,15 @@ export const ServiceFormScreen: FC = () => { const handleCertPathBlur = () => { if (form.cert_path.trim()) { - runPathCheck(form.cert_path, form.ca_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); + runPathCheck(form.cert_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); } else { setCertPathStatus(IDLE); } }; - const handleCaPathBlur = () => { - if (form.ca_path.trim()) { - runPathCheck(form.cert_path, form.ca_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); - } else { - setCaPathStatus(IDLE); - } - }; - const handleKeyPathBlur = () => { if (form.key_path.trim()) { - runPathCheck(form.cert_path, form.ca_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); + runPathCheck(form.cert_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); } else { setKeyPathStatus(IDLE); } @@ -210,7 +197,7 @@ export const ServiceFormScreen: FC = () => { const handleWebrootPathBlur = () => { if (form.webroot_path.trim()) { - runPathCheck(form.cert_path, form.ca_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); + runPathCheck(form.cert_path, form.key_path, form.webroot_path, form.verification_method, form.split_files); } else { setWebrootPathStatus(IDLE); } @@ -222,7 +209,6 @@ export const ServiceFormScreen: FC = () => { if (form.domains.length === 0) e.domains = 'At least one domain is required'; if (!form.cert_path.trim()) e.cert_path = 'Certificate path is required'; if (form.split_files) { - if (!form.ca_path.trim()) e.ca_path = 'CA bundle path is required'; if (!form.key_path.trim()) e.key_path = 'Private key path is required'; } if (form.verification_method === 'http' && !form.webroot_path.trim()) { @@ -235,9 +221,6 @@ export const ServiceFormScreen: FC = () => { if (certPathStatus.state === 'error') { e.cert_path = certPathStatus.message ?? 'Certificate path is invalid'; } - if (form.split_files && caPathStatus.state === 'error') { - e.ca_path = caPathStatus.message ?? 'CA bundle path is invalid'; - } if (form.split_files && keyPathStatus.state === 'error') { e.key_path = keyPathStatus.message ?? 'Private key path is invalid'; } @@ -274,13 +257,11 @@ export const ServiceFormScreen: FC = () => { if (errors[key]) setErrors((prev) => { const n = { ...prev }; delete n[key]; return n; }); // Reset path status when field changes if (key === 'cert_path') setCertPathStatus(IDLE); - if (key === 'ca_path') setCaPathStatus(IDLE); if (key === 'key_path') setKeyPathStatus(IDLE); if (key === 'webroot_path') setWebrootPathStatus(IDLE); if (key === 'verification_method') setWebrootPathStatus(IDLE); - // When toggling split_files off, reset the extra statuses + // When toggling split_files off, reset the key status if (key === 'split_files' && !value) { - setCaPathStatus(IDLE); setKeyPathStatus(IDLE); } }; @@ -355,11 +336,11 @@ export const ServiceFormScreen: FC = () => { checked={form.split_files} onChange={(e) => set('split_files', e.target.checked)} /> - Use separate files for certificate, CA bundle and private key + Use separate files for fullchain and private key - When unchecked, all three are written as a single combined PEM file. - When checked, each is written to its own path. + When unchecked, cert + CA + key are written as a single combined PEM file. + When checked, cert + CA are written together as a fullchain file and the private key is written to a separate path.
@@ -389,7 +370,7 @@ export const ServiceFormScreen: FC = () => { <>
- +
{ value={form.cert_path} onChange={(e) => set('cert_path', e.target.value)} onBlur={handleCertPathBlur} - placeholder="/etc/nginx/ssl/cert.pem" + placeholder="/etc/nginx/ssl/fullchain.pem" className={certPathStatus.state === 'error' ? 'input-error' : certPathStatus.state === 'ok' ? 'input-ok' : ''} /> - Path to the leaf certificate (certificate.crt). + Path to the fullchain file (certificate + CA bundle concatenated). {errors.cert_path && {errors.cert_path}}
-
-
- - -
- set('ca_path', e.target.value)} - onBlur={handleCaPathBlur} - placeholder="/etc/nginx/ssl/ca.pem" - className={caPathStatus.state === 'error' ? 'input-error' : caPathStatus.state === 'ok' ? 'input-ok' : ''} - /> - Path to the CA / intermediate chain (ca_bundle.crt). - {errors.ca_path && {errors.ca_path}} -
-
From 7a31514a34e9bd2341aed067b1531ccdb4e53cf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:06:54 +0000 Subject: [PATCH 24/24] =?UTF-8?q?refactor:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20simplify=20key=5Fpath=20check;=20remove=20caPath=20?= =?UTF-8?q?from=20checkPaths=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/JAWS-Developers/CertManager/sessions/71c6cf64-7fae-43b5-8cb5-b8c8f5c9b2ae Co-authored-by: ilBiondojs <86325934+ilBiondojs@users.noreply.github.com> --- backend/api/services.php | 12 +++++------- src/api/services.api.ts | 2 -- src/screens/services/ServiceFormScreen.tsx | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/backend/api/services.php b/backend/api/services.php index c2a8207..13216f1 100644 --- a/backend/api/services.php +++ b/backend/api/services.php @@ -244,13 +244,11 @@ function collectPathErrors(array $input): array } if ($splitFiles) { - foreach (['key_path'] as $field) { - $path = $input[$field] ?? ''; - if ($path !== '') { - $check = validatePath($path, 'cert'); - if (!$check['valid']) { - $errors[] = $check['error']; - } + $keyPath = $input['key_path'] ?? ''; + if ($keyPath !== '') { + $check = validatePath($keyPath, 'cert'); + if (!$check['valid']) { + $errors[] = $check['error']; } } } diff --git a/src/api/services.api.ts b/src/api/services.api.ts index 1c492bd..7f6a595 100644 --- a/src/api/services.api.ts +++ b/src/api/services.api.ts @@ -52,7 +52,6 @@ export async function checkPaths( webrootPath: string, verificationMethod: string, splitFiles?: boolean, - caPath?: string, keyPath?: string, ): Promise { const res = await fetch(`${API_BASE}/services.php`, { @@ -64,7 +63,6 @@ export async function checkPaths( webroot_path: webrootPath, verification_method: verificationMethod, split_files: splitFiles ?? false, - ca_path: caPath ?? '', key_path: keyPath ?? '', }), }); diff --git a/src/screens/services/ServiceFormScreen.tsx b/src/screens/services/ServiceFormScreen.tsx index 230a1fe..b21fa1a 100644 --- a/src/screens/services/ServiceFormScreen.tsx +++ b/src/screens/services/ServiceFormScreen.tsx @@ -166,7 +166,7 @@ export const ServiceFormScreen: FC = () => { if (checkWebroot) setWebrootPathStatus({ state: 'checking' }); try { - const result = await checkPaths(certPath, webrootPath, verMethod, splitFiles, '', keyPath); + const result = await checkPaths(certPath, webrootPath, verMethod, splitFiles, keyPath); if (checkCert) setCertPathStatus(statusFromResult(result.cert_path)); if (checkKey) setKeyPathStatus(statusFromResult(result.key_path)); if (checkWebroot) setWebrootPathStatus(statusFromResult(result.webroot_path));