From 8c1e02af8959962cd7b79460de7098d087f0756a Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 24 Sep 2026 21:19:27 -0500 Subject: [PATCH] feat: move acme certificate automation into a plugin (C7) --- docker/Dockerfile | 2 +- docker/entrypoint.sh | 14 +- docker/nginx-https.conf | 21 +- docker/nginx.conf | 8 +- package-lock.json | 282 +++++++-- package.json | 2 + packages/plugin-sdk/ARCHITECTURE.md | 31 +- packages/plugin-sdk/FINISH-LIST.md | 16 + packages/plugin-sdk/src/backend.ts | 61 ++ packages/plugin-sdk/src/capabilities.ts | 2 +- packages/plugin-sdk/src/testing.ts | 123 +++- plugins/acme-ssl/CHANGELOG.md | 15 + plugins/acme-ssl/README.md | 36 ++ plugins/acme-ssl/locales/en.json | 58 ++ .../acme-ssl/locales/translated/af_ZA.json | 29 + .../acme-ssl/locales/translated/ar_SA.json | 29 + .../acme-ssl/locales/translated/bg_BG.json | 29 + .../acme-ssl/locales/translated/bn_BD.json | 29 + .../acme-ssl/locales/translated/ca_ES.json | 29 + .../acme-ssl/locales/translated/cs_CZ.json | 29 + .../acme-ssl/locales/translated/da_DK.json | 29 + .../acme-ssl/locales/translated/de_DE.json | 29 + .../acme-ssl/locales/translated/el_GR.json | 29 + .../acme-ssl/locales/translated/es_ES.json | 29 + .../acme-ssl/locales/translated/fi_FI.json | 29 + .../acme-ssl/locales/translated/fr_FR.json | 29 + .../acme-ssl/locales/translated/he_IL.json | 29 + .../acme-ssl/locales/translated/hi_IN.json | 29 + .../acme-ssl/locales/translated/hu_HU.json | 29 + .../acme-ssl/locales/translated/id_ID.json | 29 + .../acme-ssl/locales/translated/it_IT.json | 29 + .../acme-ssl/locales/translated/ja_JP.json | 29 + .../acme-ssl/locales/translated/ko_KR.json | 29 + .../acme-ssl/locales/translated/nl_NL.json | 29 + .../acme-ssl/locales/translated/no_NO.json | 29 + .../acme-ssl/locales/translated/pl_PL.json | 29 + .../acme-ssl/locales/translated/pt_BR.json | 29 + .../acme-ssl/locales/translated/pt_PT.json | 29 + .../acme-ssl/locales/translated/ro_RO.json | 29 + .../acme-ssl/locales/translated/ru_RU.json | 29 + .../acme-ssl/locales/translated/sr_SP.json | 29 + .../acme-ssl/locales/translated/sv_SE.json | 29 + .../acme-ssl/locales/translated/th_TH.json | 29 + .../acme-ssl/locales/translated/tr_TR.json | 29 + .../acme-ssl/locales/translated/uk_UA.json | 29 + .../acme-ssl/locales/translated/vi_VN.json | 29 + .../acme-ssl/locales/translated/zh_CN.json | 29 + .../acme-ssl/locales/translated/zh_TW.json | 29 + plugins/acme-ssl/manifest.json | 120 ++++ plugins/acme-ssl/package.json | 18 + plugins/acme-ssl/src/backend/acme.ts | 217 +++++++ plugins/acme-ssl/src/backend/cloudflare.ts | 78 +++ plugins/acme-ssl/src/backend/index.ts | 50 ++ plugins/acme-ssl/src/backend/renewal.ts | 28 + plugins/acme-ssl/src/backend/routes.ts | 73 +++ plugins/acme-ssl/src/backend/runner.ts | 162 +++++ plugins/acme-ssl/src/backend/settings.ts | 77 +++ .../src/frontend/AcmeStatusSetting.tsx | 121 ++++ plugins/acme-ssl/src/frontend/index.tsx | 10 + .../acme-ssl/tests/backend/acme-ssl.test.ts | 252 ++++++++ .../acme-ssl/tests/backend/transport.test.ts | 102 +++ .../acme-ssl/tests/frontend/activate.test.tsx | 85 +++ plugins/acme-ssl/tsconfig.json | 4 + plugins/acme-ssl/vitest.config.ts | 3 + .../tests/frontend/activate.test.tsx | 8 +- src/backend/database/database.ts | 101 +-- .../database/routes/acme-ssl-routes.ts | 586 ------------------ src/backend/database/routes/tls-routes.ts | 128 ++++ src/backend/database/routes/users.ts | 4 +- src/backend/plugins/ctx-system.ts | 129 ++++ src/backend/plugins/ctx.ts | 2 + src/backend/starter.ts | 6 +- .../tests/database/routes/tls-routes.test.ts | 155 +++++ src/backend/tests/plugins/ctx-system.test.ts | 142 +++++ src/backend/tests/tls/certificate.test.ts | 59 ++ src/backend/tests/tls/test-certs.ts | 82 +++ src/backend/tests/tls/tls-service.test.ts | 199 ++++++ .../utils/acme-ssl-settings-migration.test.ts | 144 +++++ src/backend/tls/acme-challenges.ts | 51 ++ src/backend/tls/certificate.ts | 123 ++++ .../nginx-reload.ts} | 8 +- .../auto-ssl-setup.ts => tls/self-signed.ts} | 107 ++-- src/backend/tls/tls-service.ts | 227 +++++++ .../acme-ssl-settings-migration.ts | 127 ++++ src/ui/api/acme-ssl-api.ts | 59 -- src/ui/api/tls-api.ts | 38 ++ src/ui/locales/en.json | 37 +- src/ui/locales/translated/af_ZA.json | 23 - src/ui/locales/translated/ar_SA.json | 23 - src/ui/locales/translated/bg_BG.json | 23 - src/ui/locales/translated/bn_BD.json | 23 - src/ui/locales/translated/ca_ES.json | 23 - src/ui/locales/translated/cs_CZ.json | 23 - src/ui/locales/translated/da_DK.json | 23 - src/ui/locales/translated/de_DE.json | 23 - src/ui/locales/translated/el_GR.json | 23 - src/ui/locales/translated/es_ES.json | 23 - src/ui/locales/translated/fi_FI.json | 23 - src/ui/locales/translated/fr_FR.json | 23 - src/ui/locales/translated/he_IL.json | 23 - src/ui/locales/translated/hi_IN.json | 23 - src/ui/locales/translated/hu_HU.json | 23 - src/ui/locales/translated/id_ID.json | 23 - src/ui/locales/translated/it_IT.json | 23 - src/ui/locales/translated/ja_JP.json | 23 - src/ui/locales/translated/ko_KR.json | 23 - src/ui/locales/translated/nl_NL.json | 23 - src/ui/locales/translated/no_NO.json | 23 - src/ui/locales/translated/pl_PL.json | 23 - src/ui/locales/translated/pt_BR.json | 23 - src/ui/locales/translated/pt_PT.json | 23 - src/ui/locales/translated/ro_RO.json | 23 - src/ui/locales/translated/ru_RU.json | 23 - src/ui/locales/translated/sr_SP.json | 23 - src/ui/locales/translated/sv_SE.json | 23 - src/ui/locales/translated/th_TH.json | 23 - src/ui/locales/translated/tr_TR.json | 23 - src/ui/locales/translated/uk_UA.json | 23 - src/ui/locales/translated/vi_VN.json | 23 - src/ui/locales/translated/zh_CN.json | 23 - src/ui/locales/translated/zh_TW.json | 23 - src/ui/sidebar/AdminSettingsPanel.tsx | 89 +-- src/ui/sidebar/AdminSettingsSections.tsx | 270 +++----- 123 files changed, 4808 insertions(+), 1912 deletions(-) create mode 100644 plugins/acme-ssl/CHANGELOG.md create mode 100644 plugins/acme-ssl/README.md create mode 100644 plugins/acme-ssl/locales/en.json create mode 100644 plugins/acme-ssl/locales/translated/af_ZA.json create mode 100644 plugins/acme-ssl/locales/translated/ar_SA.json create mode 100644 plugins/acme-ssl/locales/translated/bg_BG.json create mode 100644 plugins/acme-ssl/locales/translated/bn_BD.json create mode 100644 plugins/acme-ssl/locales/translated/ca_ES.json create mode 100644 plugins/acme-ssl/locales/translated/cs_CZ.json create mode 100644 plugins/acme-ssl/locales/translated/da_DK.json create mode 100644 plugins/acme-ssl/locales/translated/de_DE.json create mode 100644 plugins/acme-ssl/locales/translated/el_GR.json create mode 100644 plugins/acme-ssl/locales/translated/es_ES.json create mode 100644 plugins/acme-ssl/locales/translated/fi_FI.json create mode 100644 plugins/acme-ssl/locales/translated/fr_FR.json create mode 100644 plugins/acme-ssl/locales/translated/he_IL.json create mode 100644 plugins/acme-ssl/locales/translated/hi_IN.json create mode 100644 plugins/acme-ssl/locales/translated/hu_HU.json create mode 100644 plugins/acme-ssl/locales/translated/id_ID.json create mode 100644 plugins/acme-ssl/locales/translated/it_IT.json create mode 100644 plugins/acme-ssl/locales/translated/ja_JP.json create mode 100644 plugins/acme-ssl/locales/translated/ko_KR.json create mode 100644 plugins/acme-ssl/locales/translated/nl_NL.json create mode 100644 plugins/acme-ssl/locales/translated/no_NO.json create mode 100644 plugins/acme-ssl/locales/translated/pl_PL.json create mode 100644 plugins/acme-ssl/locales/translated/pt_BR.json create mode 100644 plugins/acme-ssl/locales/translated/pt_PT.json create mode 100644 plugins/acme-ssl/locales/translated/ro_RO.json create mode 100644 plugins/acme-ssl/locales/translated/ru_RU.json create mode 100644 plugins/acme-ssl/locales/translated/sr_SP.json create mode 100644 plugins/acme-ssl/locales/translated/sv_SE.json create mode 100644 plugins/acme-ssl/locales/translated/th_TH.json create mode 100644 plugins/acme-ssl/locales/translated/tr_TR.json create mode 100644 plugins/acme-ssl/locales/translated/uk_UA.json create mode 100644 plugins/acme-ssl/locales/translated/vi_VN.json create mode 100644 plugins/acme-ssl/locales/translated/zh_CN.json create mode 100644 plugins/acme-ssl/locales/translated/zh_TW.json create mode 100644 plugins/acme-ssl/manifest.json create mode 100644 plugins/acme-ssl/package.json create mode 100644 plugins/acme-ssl/src/backend/acme.ts create mode 100644 plugins/acme-ssl/src/backend/cloudflare.ts create mode 100644 plugins/acme-ssl/src/backend/index.ts create mode 100644 plugins/acme-ssl/src/backend/renewal.ts create mode 100644 plugins/acme-ssl/src/backend/routes.ts create mode 100644 plugins/acme-ssl/src/backend/runner.ts create mode 100644 plugins/acme-ssl/src/backend/settings.ts create mode 100644 plugins/acme-ssl/src/frontend/AcmeStatusSetting.tsx create mode 100644 plugins/acme-ssl/src/frontend/index.tsx create mode 100644 plugins/acme-ssl/tests/backend/acme-ssl.test.ts create mode 100644 plugins/acme-ssl/tests/backend/transport.test.ts create mode 100644 plugins/acme-ssl/tests/frontend/activate.test.tsx create mode 100644 plugins/acme-ssl/tsconfig.json create mode 100644 plugins/acme-ssl/vitest.config.ts delete mode 100644 src/backend/database/routes/acme-ssl-routes.ts create mode 100644 src/backend/database/routes/tls-routes.ts create mode 100644 src/backend/plugins/ctx-system.ts create mode 100644 src/backend/tests/database/routes/tls-routes.test.ts create mode 100644 src/backend/tests/plugins/ctx-system.test.ts create mode 100644 src/backend/tests/tls/certificate.test.ts create mode 100644 src/backend/tests/tls/test-certs.ts create mode 100644 src/backend/tests/tls/tls-service.test.ts create mode 100644 src/backend/tests/utils/acme-ssl-settings-migration.test.ts create mode 100644 src/backend/tls/acme-challenges.ts create mode 100644 src/backend/tls/certificate.ts rename src/backend/{utils/nginx-ssl-reload.ts => tls/nginx-reload.ts} (95%) rename src/backend/{utils/auto-ssl-setup.ts => tls/self-signed.ts} (75%) create mode 100644 src/backend/tls/tls-service.ts create mode 100644 src/backend/utils/crypto-migration/acme-ssl-settings-migration.ts delete mode 100644 src/ui/api/acme-ssl-api.ts create mode 100644 src/ui/api/tls-api.ts diff --git a/docker/Dockerfile b/docker/Dockerfile index bb1fc9eb7..f3412b1f4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -93,7 +93,7 @@ ENV DATA_DIR=/app/data \ NODE_ENV=production \ POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd -RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \ +RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \ update-ca-certificates && \ rm -rf /var/lib/apt/lists/* && \ mkdir -p /app/data /app/uploads /app/nginx /tmp/nginx && \ diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b27299820..7a7742d2b 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -73,7 +73,7 @@ if [ "$ENABLE_SSL" = "true" ] && [ "$PORT" = "$SSL_PORT" ]; then sed -i '/# BEGIN HTTP_REDIRECT_SERVER/,/# END HTTP_REDIRECT_SERVER/d' /tmp/nginx/nginx.conf fi -mkdir -p /app/data /app/uploads /app/data/acme-webroot/.well-known/acme-challenge +mkdir -p /app/data /app/uploads chmod 755 /app/data /app/uploads 2>/dev/null || true if [ -w /app/data ]; then @@ -96,8 +96,16 @@ if [ "$ENABLE_SSL" = "true" ]; then if openssl x509 -in /app/data/ssl/termix.crt -checkend 2592000 -noout >/dev/null 2>&1; then echo "SSL certificates are valid and will be reused for domain: $DOMAIN" else - echo "SSL certificate is expired or expiring soon, regenerating..." - rm -f /app/data/ssl/termix.crt /app/data/ssl/termix.key + SUBJECT=$(openssl x509 -in /app/data/ssl/termix.crt -noout -subject 2>/dev/null | sed 's/^subject=//') + ISSUER=$(openssl x509 -in /app/data/ssl/termix.crt -noout -issuer 2>/dev/null | sed 's/^issuer=//') + # Only replace our own self-signed certificate. A CA-issued one is + # kept until it expires, even when nothing renews it. + if [ -n "$SUBJECT" ] && [ "$SUBJECT" = "$ISSUER" ]; then + echo "Self-signed SSL certificate is expired or expiring soon, regenerating..." + rm -f /app/data/ssl/termix.crt /app/data/ssl/termix.key + else + echo "WARNING: SSL certificate expires within 30 days and was not issued by Termix. Renew it." + fi fi else echo "SSL certificates not found, will generate new ones..." diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 01c8201ae..dd9503b17 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -74,7 +74,18 @@ http { listen ${PORT}; server_name _; - return 301 https://$host:${SSL_PORT}$request_uri; + # ACME http-01 checks arrive on plain HTTP and must not be redirected. + location ^~ /.well-known/acme-challenge/ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + return 301 https://$host:${SSL_PORT}$request_uri; + } } # END HTTP_REDIRECT_SERVER @@ -92,9 +103,11 @@ http { add_header X-Content-Type-Options nosniff always; location ^~ /.well-known/acme-challenge/ { - root /app/data/acme-webroot; - default_type "text/plain"; - try_files $uri =404; + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location = /sw.js { diff --git a/docker/nginx.conf b/docker/nginx.conf index 905fb338b..01e730d58 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -79,9 +79,11 @@ http { add_header X-Content-Type-Options nosniff always; location ^~ /.well-known/acme-challenge/ { - root /app/data/acme-webroot; - default_type "text/plain"; - try_files $uri =404; + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location = /sw.js { diff --git a/package-lock.json b/package-lock.json index 11807737b..86f8b5574 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,6 +61,7 @@ "@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/source-code-pro": "^5.3.0", "@monaco-editor/react": "^4.7.0", + "@peculiar/x509": "^2.1.0", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-checkbox": "^1.3.11", @@ -139,6 +140,7 @@ "react-photo-view": "^1.2.7", "react-syntax-highlighter": "^16.1.1", "react-xtermjs": "^1.0.10", + "reflect-metadata": "^0.2.2", "remark-gfm": "^4.0.1", "sonner": "^2.0.8", "tailwind-merge": "^3.5.0", @@ -3862,130 +3864,194 @@ "tslib": "^2.8.1" } }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", - "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "node_modules/@peculiar/asn1-asym-key": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-asym-key/-/asn1-asym-key-2.9.5.tgz", + "integrity": "sha512-U3Q2bUWYNVpfk+oOhPl/+OSHxXaBTQBwMf93jJ7oPi/sbmcY5CnARdjGMwLoXT/ggWSryo5t7A7o3/Gfz0U2WA==", + "dev": true, "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "@peculiar/asn1-x509-attr": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.9.5.tgz", + "integrity": "sha512-4Ivz0fsyFofe0bslNsYcGN9+iETdHX4v+BFwIPr+K/BAt61sHXECP63ff9FntU9zcfVQELJFdUG9so44OjUlBQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "@peculiar/asn1-x509-attr": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", - "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.9.5.tgz", + "integrity": "sha512-9dO6PcoJeI+1xJ4rG7eINMa6f9Ix/QdRs5UGZ0D7v/R3TzmsbVr5cqAgfYw4xgS9U54ouHYqEz0bIZBbM/UY1g==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", - "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.9.5.tgz", + "integrity": "sha512-OD+HLlcV7wCL64w0rheX/C0J31DbJce0nf8ulv3UNftQ617JEZE3sQ4cMN6PotXWlJ1M5GQx90G3bywtdKOstw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", - "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.9.5.tgz", + "integrity": "sha512-btcH7lbcD4410qnjHvv7F0kEuMNKbX8CFAZSs9CDPWHKzT1byLPHYeR40AnUw9z4CzsijMY0daBFYUvVx75sdA==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.8.0", - "@peculiar/asn1-pkcs8": "^2.8.0", - "@peculiar/asn1-rsa": "^2.8.0", - "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-cms": "^2.9.5", + "@peculiar/asn1-pkcs8": "^2.9.5", + "@peculiar/asn1-rsa": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", - "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.9.5.tgz", + "integrity": "sha512-c7UghAGQdBfAHL9JdwcJkAc/lDJKNXfCdYXPXlf8BZdD/fVJjJnXKHxX4gyLhkFTvnGhME7caieSgc2ROtqYTQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", - "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.9.5.tgz", + "integrity": "sha512-4R/zMgLSHbQIZRBwP1KzX6iuNHmW3xFWwu9TS9A00N60fmQqp9DkkqnQFyd0mAYMQTrQefuzQ/yP4U8sOLGUgQ==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.8.0", - "@peculiar/asn1-pfx": "^2.8.0", - "@peculiar/asn1-pkcs8": "^2.8.0", - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "@peculiar/asn1-x509-attr": "^2.8.0", + "@peculiar/asn1-cms": "^2.9.5", + "@peculiar/asn1-pfx": "^2.9.5", + "@peculiar/asn1-pkcs8": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "@peculiar/asn1-x509-attr": "^2.9.5", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", - "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.9.5.tgz", + "integrity": "sha512-kHwBLVMkm9ljlM521ovS7y4z4mr8xuHTdNyyoPlDvckBwFKSgs9HqKBryLBeqHqL2YpIgl+Vspl7kzNGG2phzA==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.5.tgz", + "integrity": "sha512-Ez3wLKVjaxdsLcgeWN4OE31QkM7oBOgKuuBJxldRYAkfYw2C+8zJPcSd/SThnbhszsEOlAmoaS5kIIkN29fGKQ==", "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", - "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.9.5.tgz", + "integrity": "sha512-NP3ecvN6zTDlwCvogIWL7MJN9AZNuskkIbnlhrlkB97Mn2EqirBUQ8GKzsOmdAL3pRvjGu1Uk/a4DNfiZrWxhw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-schema": "^2.9.5", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", - "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.9.5.tgz", + "integrity": "sha512-PlbL1a74RZiry7puPCZ5hdk/puvivxYJPbjadr2i7DBjHCJFEKCfTRPKL3DILeZFtzJjn95J5EBtSc4/TB9/6Q==", "license": "MIT", "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@peculiar/asn1-x509-post-quantum": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-post-quantum/-/asn1-x509-post-quantum-2.9.5.tgz", + "integrity": "sha512-CTznDkboSgwRpXSFhta+Pe5cQ12JtojdMrmOZTWYkBSvbW/1R5pflPIHkpSlaILIfGcAaxIVpXq880xCfJYzow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@peculiar/asn1-asym-key": "^2.9.5", + "@peculiar/asn1-schema": "^2.9.5", + "@peculiar/asn1-x509": "^2.9.5", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/json-schema": { @@ -4028,20 +4094,21 @@ } }, "node_modules/@peculiar/x509": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", - "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.1.0.tgz", + "integrity": "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==", + "dev": true, "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-csr": "^2.6.0", - "@peculiar/asn1-ecc": "^2.6.0", - "@peculiar/asn1-pkcs9": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-cms": "^2.9.4", + "@peculiar/asn1-csr": "^2.9.4", + "@peculiar/asn1-ecc": "^2.9.4", + "@peculiar/asn1-pkcs9": "^2.9.4", + "@peculiar/asn1-rsa": "^2.9.4", + "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-x509-post-quantum": "^2.9.4", "pvtsutils": "^1.3.6", - "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" }, @@ -6311,6 +6378,28 @@ "node": ">=20.0.0" } }, + "node_modules/@simplewebauthn/server/node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@stablelib/base64": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", @@ -6964,6 +7053,10 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@termix-plugin/acme-ssl": { + "resolved": "plugins/acme-ssl", + "link": true + }, "node_modules/@termix-plugin/ai": { "resolved": "plugins/ai", "link": true @@ -8242,6 +8335,44 @@ "node": ">= 0.6" } }, + "node_modules/acme-client": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz", + "integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.11.0", + "asn1js": "^3.0.5", + "axios": "^1.7.2", + "debug": "^4.3.5", + "node-forge": "^1.3.1" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/acme-client/node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -14480,6 +14611,15 @@ "semver": "^7.3.5" } }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, "node_modules/node-gyp": { "version": "12.3.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.3.0.tgz", @@ -18578,6 +18718,16 @@ } } }, + "plugins/acme-ssl": { + "name": "@termix-plugin/acme-ssl", + "version": "1.0.0", + "dependencies": { + "acme-client": "^5.4.0" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, "plugins/ai": { "name": "@termix-plugin/ai", "version": "1.0.0", diff --git a/package.json b/package.json index 92decfdc8..d41e3be9f 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "@fontsource/jetbrains-mono": "^5.3.0", "@fontsource/source-code-pro": "^5.3.0", "@monaco-editor/react": "^4.7.0", + "@peculiar/x509": "^2.1.0", "@radix-ui/react-accordion": "^1.2.20", "@radix-ui/react-alert-dialog": "^1.1.23", "@radix-ui/react-checkbox": "^1.3.11", @@ -181,6 +182,7 @@ "react-photo-view": "^1.2.7", "react-syntax-highlighter": "^16.1.1", "react-xtermjs": "^1.0.10", + "reflect-metadata": "^0.2.2", "remark-gfm": "^4.0.1", "sonner": "^2.0.8", "tailwind-merge": "^3.5.0", diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index d6ec33aea..9fb418c7a 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -1715,11 +1715,11 @@ deciding, not the mechanism. | Capability | Risk | Meaning | | -------------------- | -------- | -------------------------------------------------------------- | | `credentials:read` | critical | See plaintext secrets for any reachable host | +| `system:tls` | critical | Replace the server certificate and answer ACME challenges | | `ssh:connect` | high | Run commands on hosts it is connected to | | `process:spawn` | high | Run programs on the Termix server | | `users:write` | high | Create and change user accounts | | `auth:provide` | high | Add a login method, second factor or SSH auth type | -| `system:tls` | high | Request and replace the server certificate | | `device:serial` | high | Open a physical serial or USB device on the Termix server | | `hosts:write` | medium | Create and change hosts | | `credentials:use` | medium | Connect using a host's stored credentials, without seeing them | @@ -1801,6 +1801,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. | `ctx.fetch` | `network:outbound` | **B17**, signal **B18**, tls **C4** | | `ctx.process.run` / `.ensureBinary` | `process:spawn` (+ `network:outbound` to download) | **C3** | | `ctx.auth.registerKeyboardInteractiveHandler` | `auth:provide` | **C3** | +| `ctx.system.*` | `system:tls` | **C7** | **B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a generic check for a capability no other ctx member wraps. Unlike every other @@ -1972,6 +1973,33 @@ keyType })` saves a key pair as a new encrypted credential and returns its needs one (termix-identity publishes the key first and removes that row if the save fails). +**C7** added `ctx.system`, the server certificate, all behind `system:tls` +(critical: whoever holds it decides what certificate every browser is shown) +and all audited. Core keeps serving HTTPS: at boot it makes a self-signed +certificate only when none exists or its own self-signed one is expiring, and +it never replaces a certificate a CA issued, so a certificate keeps being +served, unrenewed, with its plugin disabled. The code is in `src/backend/tls/`. + +- `tlsStatus()` is `{ enabled, certificate, renewal }`: the served leaf (names, + issuer, dates, `selfSigned`, fingerprint) and the plugin that renews it. +- `writeTlsCertificate(cert, key)` validates the pair with node's X509 parser + (it parses, the leaf has not expired, the key matches) and swaps both files + in by rename. It does not reload. +- `reloadTls()` serves what is on disk now: `setSecureContext` on the direct + HTTPS server (starting it if HTTPS was off), or an nginx reload in Docker. +- `publishHttpChallenge(token, content)` answers + `GET /.well-known/acme-challenge/`, which core serves publicly + because no `/plugin-api/` path can be on it. nginx proxies that location + to the backend on both the HTTPS server and the port 80 redirect server. +- `registerTlsRenewer()` tells core the plugin renews the certificate. Admin + settings warn with the expiry date when a CA-issued certificate has no + renewer. Both registrations are dropped on deactivate. + +Uploading a certificate by hand stays core (`POST /users/tls-certificate`), +since it is not ACME. The acme-ssl plugin is the first caller: it runs +acme-client with every request sent through `ctx.fetch` (an axios adapter), +keeps its account key sealed in `ctx.kv`, and checks every 12 hours. + **C3** added `ctx.process`, for a plugin that ships a program (opkssh): - `run(file, args, { env, cwd, timeoutMs })` starts it without a shell and @@ -2619,6 +2647,7 @@ The bundled plugins predate the SDK, apart from workspaces (A9), snippets (B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations (B17), ai (B18), homepage (B19), totp and webauthn (C1), sso and ldap (C2), opkssh and warpgate (C3), step-ca (C4), vault (C5), termix-identity (C6), +acme-ssl (C7), which import nothing from core. The others still reach core by relative path (`../../../../src/backend/...`), which an esbuild plugin, `packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md index 697b1a1d5..2371d5f16 100644 --- a/packages/plugin-sdk/FINISH-LIST.md +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -671,3 +671,19 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite `/termix-id/u/` resolver URL. It keeps working through the 308, but the docs should show `/plugin-api/termix-identity/u/`. Owner: D0. +- **C7 (acme-ssl):** the 2.8 leftovers stay on disk and in the database for + one release: the `acme_ssl_settings` settings row (the Cloudflare token is + already stripped from it), `DATA_DIR/certbot` and `DATA_DIR/acme-webroot`. + Delete them in the release after 2.9.0. Owner: after D4. +- **C7 (acme-ssl):** the docs page linked from Admin Settings > HTTPS + certificate (`docs.termix.site/features/networking/ssl`) still describes + certbot and the webroot challenge. Point it at the plugin. Owner: D0. +- **C7, pre-existing:** `AutoSSLSetup.setupEnvironmentVariables` in + `src/backend/tls/self-signed.ts` rewrites `SSL_CERT_PATH`, `SSL_KEY_PATH` + and `SSL_DOMAIN` to the defaults in `DATA_DIR/.env` on every boot, so custom + certificate paths set in the environment are lost after the first start. + Owner: D0. +- **C7, pre-existing:** `src/backend/scripts/setup-ssl.sh` and + `enable-ssl.sh` are not called by anything and write a `JWT_SECRET` and + `DATABASE_KEY` to `.env`, which SystemCrypto now owns. Delete or rewrite + them. Owner: D0. diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index c32c86611..700a1248a 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -1631,6 +1631,65 @@ export interface PluginSchedule { after: (delayMs: number, fn: () => void | Promise) => () => void; } +/** The certificate core serves, read from its configured paths. */ +export interface PluginTlsCertificateInfo { + subject: string; + issuer: string; + /** DNS names and IPs from the subject alternative names. */ + names: string[]; + notBefore: string; + notAfter: string; + /** True for core's own first-boot certificate. */ + selfSigned: boolean; + /** SHA-256 fingerprint, colon separated. */ + fingerprint: string; +} + +export interface PluginTlsStatus { + /** Whether Termix is set up to serve HTTPS at all. */ + enabled: boolean; + /** Null when there is no readable certificate. */ + certificate: PluginTlsCertificateInfo | null; + /** The plugin that renews the certificate, when one is registered. */ + renewal: { pluginId: string; pluginName: string } | null; +} + +export interface PluginTlsReloadResult { + /** Whether the new certificate is being served now. */ + applied: boolean; + message: string; +} + +/** + * The server certificate. Needs system:tls, and every call is audited. Core + * owns serving it; a plugin can only replace it, reload it and answer ACME + * http-01 challenges. + */ +export interface PluginSystem { + tlsStatus: () => Promise; + /** + * Replaces the served certificate and key. Throws when either does not + * parse, the certificate has expired, or the key does not match it. Does + * not reload: call reloadTls() after. + */ + writeTlsCertificate: ( + certificatePem: string, + privateKeyPem: string, + ) => Promise; + /** Serves the certificate on disk without a restart. */ + reloadTls: () => Promise; + /** + * Answers GET /.well-known/acme-challenge/ with `content` until the + * returned function is called or the plugin is disabled. + */ + publishHttpChallenge: (token: string, content: string) => Promise<() => void>; + /** + * Tells core this plugin renews the certificate, so admin settings stop + * warning that nothing does. Dropped on deactivate. + */ + registerTlsRenewer: () => Promise<() => void>; +} + export interface PluginContext { readonly pluginId: string; readonly manifest: PluginManifest; @@ -1670,6 +1729,8 @@ export interface PluginContext { readonly fetch: PluginFetch; /** Programs on the Termix server. Needs process:spawn. */ readonly process: PluginProcess; + /** The server certificate. Needs system:tls. */ + readonly system: PluginSystem; /** * Runs `fn` with `userId` as the acting user, for background work that has diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index 7b278636a..4c26cf08b 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -39,12 +39,12 @@ function entry(id: string, risk: CapabilityRisk): CapabilityInfo { */ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("credentials:read", "critical"), + entry("system:tls", "critical"), entry("ssh:connect", "high"), entry("process:spawn", "high"), entry("users:write", "high"), entry("auth:provide", "high"), - entry("system:tls", "high"), entry("device:serial", "high"), entry("hosts:write", "medium"), diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index bab50ed16..bed1f1c62 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -71,6 +71,8 @@ import type { PluginNotificationChannel, PluginFetchInit, PluginBinarySpec, + PluginTlsCertificateInfo, + PluginTlsStatus, PluginKeyboardInteractiveHandler, PluginProcessHandle, PluginProcessOptions, @@ -140,6 +142,19 @@ export interface FakeContextOptions { * ensureBinary it answers "/tmp//bin/". */ process?: FakeProcessOptions; + /** + * What ctx.system.tlsStatus answers before any write. renewal is filled in + * from registerTlsRenewer calls. + */ + tlsStatus?: Omit; + /** + * Checks a ctx.system.writeTlsCertificate call. Throw to reject the pair. + * Without it every pair is accepted and described with placeholder info. + */ + validateTls?: ( + certificatePem: string, + privateKeyPem: string, + ) => PluginTlsCertificateInfo; /** * Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted * proxy login on, password login off), with this message and a 409. @@ -297,6 +312,15 @@ export interface FakePluginContext { }>; /** Every ctx.process.ensureBinary call, in order. */ binaries: PluginBinarySpec[]; + /** What ctx.system did: accepted writes, reloads, live challenges and renewers. */ + tls: { + writes: Array<{ certificatePem: string; privateKeyPem: string }>; + reloads: number; + /** ctx.system calls by name, in order. */ + calls: string[]; + challenges: Map; + renewers: number; + }; /** Every ctx.audit.record entry, in order. */ audits: Array<{ action: string; success: boolean; [key: string]: unknown }>; /** Every ctx.hosts.recordActivity call, in order. */ @@ -403,6 +427,17 @@ export function createFakeContext( const fetches: FakePluginContext["fetches"] = []; const processRuns: FakePluginContext["processRuns"] = []; const binaries: FakePluginContext["binaries"] = []; + const tls: FakePluginContext["tls"] = { + writes: [], + reloads: 0, + calls: [], + challenges: new Map(), + renewers: 0, + }; + let tlsState: Omit = options.tlsStatus ?? { + enabled: true, + certificate: null, + }; const secretStore = new Map(); const audits: FakePluginContext["audits"] = []; const activities: FakePluginContext["activities"] = []; @@ -1044,6 +1079,62 @@ export function createFakeContext( }, }, + system: { + tlsStatus: async () => { + tls.calls.push("tlsStatus"); + return { + ...tlsState, + renewal: tls.renewers > 0 ? { pluginId, pluginName: pluginId } : null, + }; + }, + writeTlsCertificate: async (certificatePem, privateKeyPem) => { + tls.calls.push("writeTlsCertificate"); + const info = options.validateTls + ? options.validateTls(certificatePem, privateKeyPem) + : { + subject: "CN=fake", + issuer: "CN=fake-ca", + names: [], + notBefore: new Date().toISOString(), + notAfter: new Date(Date.now() + 90 * 86_400_000).toISOString(), + selfSigned: false, + fingerprint: "00", + }; + tls.writes.push({ certificatePem, privateKeyPem }); + tlsState = { ...tlsState, certificate: info }; + return info; + }, + reloadTls: async () => { + tls.calls.push("reloadTls"); + tls.reloads++; + return { applied: true, message: "reloaded" }; + }, + publishHttpChallenge: async (token, content) => { + tls.calls.push("publishHttpChallenge"); + tls.challenges.set(token, content); + let live = true; + const remove = () => { + if (!live) return; + live = false; + tls.challenges.delete(token); + }; + disposals.push(remove); + return remove; + }, + registerTlsRenewer: async () => { + tls.calls.push("registerTlsRenewer"); + tls.renewers++; + let live = true; + const remove = () => { + if (!live) return; + live = false; + tls.renewers--; + }; + disposals.push(remove); + return remove; + }, + }, + asUser: async (userId, fn) => { const previous = actor; actor = userId; @@ -1080,6 +1171,7 @@ export function createFakeContext( fetches, processRuns, binaries, + tls, secretStore, audits, activities, @@ -1148,6 +1240,10 @@ export interface MockContextOptions { /** See FakeContextOptions. */ process?: FakeProcessOptions; /** See FakeContextOptions. */ + tlsStatus?: FakeContextOptions["tlsStatus"]; + /** See FakeContextOptions. */ + validateTls?: FakeContextOptions["validateTls"]; + /** See FakeContextOptions. */ refuseEnrollment?: string; /** See FakeContextOptions. */ baseUrl?: string; @@ -1171,7 +1267,7 @@ export interface MockPluginContext extends FakePluginContext { * topic outside the plugin's own namespace, settings:read-core on * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, * notify:send on ctx.notify, network:outbound on ctx.fetch, process:spawn on - * ctx.process, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately + * ctx.process, system:tls on ctx.system, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately * ungated. As the SDK grows a member, add its gate here in the same shape. */ export function createMockCtx( @@ -1199,6 +1295,8 @@ export function createMockCtx( notificationChannels: options.notificationChannels, fetch: options.fetch, process: options.process, + tlsStatus: options.tlsStatus, + validateTls: options.validateTls, refuseEnrollment: options.refuseEnrollment, baseUrl: options.baseUrl, linkedUsers: options.linkedUsers, @@ -1537,6 +1635,29 @@ export function createMockCtx( }, }, + system: { + tlsStatus: async () => { + require("system:tls"); + return ctx.system.tlsStatus(); + }, + writeTlsCertificate: async (certificatePem, privateKeyPem) => { + require("system:tls"); + return ctx.system.writeTlsCertificate(certificatePem, privateKeyPem); + }, + reloadTls: async () => { + require("system:tls"); + return ctx.system.reloadTls(); + }, + publishHttpChallenge: async (token, content) => { + require("system:tls"); + return ctx.system.publishHttpChallenge(token, content); + }, + registerTlsRenewer: async () => { + require("system:tls"); + return ctx.system.registerTlsRenewer(); + }, + }, + secrets: { ...ctx.secrets, get: async (key) => { diff --git a/plugins/acme-ssl/CHANGELOG.md b/plugins/acme-ssl/CHANGELOG.md new file mode 100644 index 000000000..2f0b1a214 --- /dev/null +++ b/plugins/acme-ssl/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 1.0.0 + +Bundled with Termix 2.9.0. + +- ACME certificates moved out of core. Termix no longer runs certbot; the + plugin talks to the CA directly. +- The domain, email, challenge type and Cloudflare token moved from Admin + Settings into this plugin's settings. The token is now stored encrypted. +- Renewal is automatic: a check every 12 hours renews within 30 days of + expiry. +- The 2.8 certbot account is not reused. A new ACME account is registered on + the first request. +- Uploading your own certificate stays in Admin Settings > HTTPS certificate. diff --git a/plugins/acme-ssl/README.md b/plugins/acme-ssl/README.md new file mode 100644 index 000000000..7945260bd --- /dev/null +++ b/plugins/acme-ssl/README.md @@ -0,0 +1,36 @@ +# ACME Certificates + +Gets the certificate Termix serves over HTTPS from Let's Encrypt, or any other +ACME certificate authority, and renews it before it expires. + +Termix itself serves HTTPS. This plugin only gets certificates: it hands each +one to core, which checks that the key matches and swaps it in without a +restart. Turn the plugin off and the current certificate keeps being served, +but nothing renews it. Admin Settings > HTTPS certificate then warns with the +expiry date. + +## Settings + +Admin settings, under Settings > Plugins > ACME Certificates: + +- **Renew automatically**: check twice a day and get a new certificate when + there is none, it is the self-signed one, it does not cover the domain, or + it expires within 30 days. +- **Domain** and **Email**. +- **Certificate authority**: Let's Encrypt, Let's Encrypt staging (for + testing without rate limits) or a custom ACME directory URL. +- **Challenge type**: + - **HTTP**: the CA fetches `http:///.well-known/acme-challenge/...`, + so port 80 must reach Termix. Core answers it. + - **DNS (Cloudflare)**: needs an API token with Zone:DNS:Edit. Works when + port 80 is closed. + +The status box shows the served certificate and the last attempt, and has a +button to request a certificate now. + +## How it works + +It runs [acme-client](https://github.com/publishlab/node-acme-client) with +every request sent through `ctx.fetch`. The ACME account key is sealed with +`ctx.secrets.seal` and kept in `ctx.kv`. Certificates are written and loaded +through `ctx.system` (`system:tls`). diff --git a/plugins/acme-ssl/locales/en.json b/plugins/acme-ssl/locales/en.json new file mode 100644 index 000000000..5c0aaa55b --- /dev/null +++ b/plugins/acme-ssl/locales/en.json @@ -0,0 +1,58 @@ +{ + "permissions": { + "manage": { + "title": "Manage ACME certificates", + "description": "See the certificate status and request a new certificate now." + } + }, + "settings": { + "autoRenew": { + "label": "Renew automatically", + "description": "Check twice a day and get a new certificate when there is none, it does not cover the domain, or it expires within 30 days." + }, + "domain": { + "label": "Domain", + "description": "The public domain name for the certificate.", + "placeholder": "termix.example.com" + }, + "email": { + "label": "Email", + "description": "Contact email for the ACME account and expiry notices.", + "placeholder": "admin@example.com" + }, + "provider": { + "label": "Certificate authority", + "description": "Where to get the certificate. Use staging to test without hitting Let's Encrypt rate limits.", + "letsencrypt": "Let's Encrypt", + "letsencryptStaging": "Let's Encrypt (staging)", + "custom": "Custom ACME directory" + }, + "directoryUrl": { + "label": "ACME directory URL", + "description": "Only for a custom certificate authority. Must be an https URL." + }, + "challengeType": { + "label": "Challenge type", + "description": "How to prove you own the domain.", + "http": "HTTP - needs port 80 reachable from the internet", + "cloudflare": "DNS (Cloudflare) - needs a Cloudflare API token" + }, + "cloudflareToken": { + "label": "Cloudflare API token", + "description": "Scoped token with Zone:DNS:Edit permission for your domain." + } + }, + "status": { + "title": "Certificate status", + "none": "No certificate", + "names": "Covers {{names}}", + "issuer": "Issued by {{issuer}}", + "expires": "Expires {{date}}", + "lastIssued": "Last issued {{date}}", + "lastError": "Last attempt failed: {{error}}", + "missing": "Set the domain, email and the fields your challenge type needs, then save, before requesting a certificate.", + "request": "Request certificate now", + "requesting": "Requesting certificate...", + "issued": "Certificate issued and installed" + } +} diff --git a/plugins/acme-ssl/locales/translated/af_ZA.json b/plugins/acme-ssl/locales/translated/af_ZA.json new file mode 100644 index 000000000..88c629b45 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/af_ZA.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domein", + "placeholder": "termix.voorbeeld.com", + "description": "Die publieke domeinnaam vir die sertifikaat." + }, + "email": { + "label": "E-pos", + "placeholder": "admin@voorbeeld.com" + }, + "challengeType": { + "label": "Uitdagingsoort", + "cloudflare": "DNS (Cloudflare) - vereis 'n Cloudflare API-token" + }, + "cloudflareToken": { + "label": "Cloudflare API-token", + "description": "Omvangryke token met Zone:DNS:Wysig toestemming vir jou domein." + } + }, + "status": { + "title": "Sertifikaatstatus", + "none": "Geen sertifikaat nie", + "expires": "Vervaldatum {{date}}", + "lastIssued": "Laas uitgereik {{date}}", + "request": "Uitreik / Hernu Sertifikaat", + "requesting": "Versoek sertifikaat..." + } +} diff --git a/plugins/acme-ssl/locales/translated/ar_SA.json b/plugins/acme-ssl/locales/translated/ar_SA.json new file mode 100644 index 000000000..a80917cd8 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/ar_SA.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "النطاق", + "placeholder": "termix.example.com", + "description": "اسم النطاق العام للشهادة." + }, + "email": { + "label": "البريد الإلكتروني", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "نوع التحدي", + "cloudflare": "DNS (Cloudflare) - يتطلب رمز Cloudflare API" + }, + "cloudflareToken": { + "label": "رمز Cloudflare API", + "description": "رمز مقيد بصلاحية Zone:DNS:Edit للنطاق الخاص بك." + } + }, + "status": { + "title": "حالة الشهادة", + "none": "لا توجد شهادة", + "expires": "تنتهي في {{date}}", + "lastIssued": "آخر إصدار {{date}}", + "request": "إصدار / تجديد الشهادة", + "requesting": "جارٍ طلب الشهادة..." + } +} diff --git a/plugins/acme-ssl/locales/translated/bg_BG.json b/plugins/acme-ssl/locales/translated/bg_BG.json new file mode 100644 index 000000000..81b062aad --- /dev/null +++ b/plugins/acme-ssl/locales/translated/bg_BG.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Домейн", + "placeholder": "termix.example.com", + "description": "Публичното име на домейна за сертификата." + }, + "email": { + "label": "Имейл", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Тип предизвикателство", + "cloudflare": "DNS (Cloudflare) - изисква Cloudflare API токен" + }, + "cloudflareToken": { + "label": "API токен на Cloudflare", + "description": "Ограничен токен с разрешение Zone:DNS:Edit за вашия домейн." + } + }, + "status": { + "title": "Статус на сертификата", + "none": "Няма сертификат", + "expires": "Изтича {{date}}", + "lastIssued": "Последно издадено {{date}}", + "request": "Издаване / Подновяване на сертификат", + "requesting": "Заявка за сертификат..." + } +} diff --git a/plugins/acme-ssl/locales/translated/bn_BD.json b/plugins/acme-ssl/locales/translated/bn_BD.json new file mode 100644 index 000000000..db2c9a7d3 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/bn_BD.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "ডোমেইন", + "placeholder": "termix.example.com", + "description": "সার্টিফিকেটটির পাবলিক ডোমেইন নাম।" + }, + "email": { + "label": "ইমেল", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "চ্যালেঞ্জের ধরণ", + "cloudflare": "ডিএনএস (ক্লাউডফ্লেয়ার) - এর জন্য একটি ক্লাউডফ্লেয়ার এপিআই টোকেন প্রয়োজন।" + }, + "cloudflareToken": { + "label": "ক্লাউডফ্লেয়ার এপিআই টোকেন", + "description": "আপনার ডোমেনের জন্য Zone:DNS:Edit অনুমতিসহ স্কোপড টোকেন।" + } + }, + "status": { + "title": "সার্টিফিকেটের অবস্থা", + "none": "কোন সার্টিফিকেট নেই", + "expires": "মেয়াদ শেষ হবে {{date}}", + "lastIssued": "সর্বশেষ প্রকাশিত {{date}}", + "request": "সার্টিফিকেট ইস্যু / নবায়ন করুন", + "requesting": "সার্টিফিকেটের জন্য অনুরোধ..." + } +} diff --git a/plugins/acme-ssl/locales/translated/ca_ES.json b/plugins/acme-ssl/locales/translated/ca_ES.json new file mode 100644 index 000000000..60db3c04e --- /dev/null +++ b/plugins/acme-ssl/locales/translated/ca_ES.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domini", + "placeholder": "termix.example.com", + "description": "El nom de domini públic del certificat." + }, + "email": { + "label": "Correu electrònic", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Tipus de repte", + "cloudflare": "DNS (Cloudflare): requereix un token d'API de Cloudflare" + }, + "cloudflareToken": { + "label": "Token de l'API de Cloudflare", + "description": "Token amb àmbit i permís Zone:DNS:Edit per al vostre domini." + } + }, + "status": { + "title": "Estat del certificat", + "none": "Sense certificat", + "expires": "Caduca {{date}}", + "lastIssued": "Última emissió {{date}}", + "request": "Emetre / Renovar el certificat", + "requesting": "Sol·licitant certificat..." + } +} diff --git a/plugins/acme-ssl/locales/translated/cs_CZ.json b/plugins/acme-ssl/locales/translated/cs_CZ.json new file mode 100644 index 000000000..19cd8e36a --- /dev/null +++ b/plugins/acme-ssl/locales/translated/cs_CZ.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Doména", + "placeholder": "termix.example.com", + "description": "Název veřejné domény pro certifikát." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Typ výzvy", + "cloudflare": "DNS (Cloudflare) – vyžaduje token Cloudflare API" + }, + "cloudflareToken": { + "label": "Token API Cloudflare", + "description": "Token s vymezeným rozsahem a oprávněním Zóna:DNS:Úpravy pro vaši doménu." + } + }, + "status": { + "title": "Stav certifikátu", + "none": "Žádný certifikát", + "expires": "Platnost vyprší {{date}}", + "lastIssued": "Naposledy vydáno {{date}}", + "request": "Vydání / Obnova certifikátu", + "requesting": "Žádost o certifikát..." + } +} diff --git a/plugins/acme-ssl/locales/translated/da_DK.json b/plugins/acme-ssl/locales/translated/da_DK.json new file mode 100644 index 000000000..8914d4894 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/da_DK.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domæne", + "placeholder": "termix.example.com", + "description": "Det offentlige domænenavn for certifikatet." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@eksempel.com" + }, + "challengeType": { + "label": "Udfordringstype", + "cloudflare": "DNS (Cloudflare) - kræver et Cloudflare API-token" + }, + "cloudflareToken": { + "label": "Cloudflare API-token", + "description": "Scoped-token med Zone:DNS:Rediger-tilladelse for dit domæne." + } + }, + "status": { + "title": "Certifikatstatus", + "none": "Intet certifikat", + "expires": "Udløber {{date}}", + "lastIssued": "Sidst udstedt {{date}}", + "request": "Udstede/forny certifikat", + "requesting": "Anmoder om certifikat..." + } +} diff --git a/plugins/acme-ssl/locales/translated/de_DE.json b/plugins/acme-ssl/locales/translated/de_DE.json new file mode 100644 index 000000000..9ecf51a82 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/de_DE.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domain", + "placeholder": "termix.example.com", + "description": "Der öffentliche Domainname für das Zertifikat." + }, + "email": { + "label": "E-Mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Challenge-Typ", + "cloudflare": "DNS (Cloudflare) – erfordert einen Cloudflare-API-Token" + }, + "cloudflareToken": { + "label": "Cloudflare-API-Token", + "description": "Bereichsbezogener Token mit Zone:DNS:Edit-Berechtigung für Ihre Domain." + } + }, + "status": { + "title": "Zertifikatsstatus", + "none": "Kein Zertifikat", + "expires": "Läuft ab {{date}}", + "lastIssued": "Zuletzt ausgestellt {{date}}", + "request": "Zertifikat ausstellen / erneuern", + "requesting": "Zertifikat wird angefordert..." + } +} diff --git a/plugins/acme-ssl/locales/translated/el_GR.json b/plugins/acme-ssl/locales/translated/el_GR.json new file mode 100644 index 000000000..f05f2c3a2 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/el_GR.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Πεδίο ορισμού", + "placeholder": "termix.example.com", + "description": "Το όνομα δημόσιου τομέα για το πιστοποιητικό." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Τύπος πρόκλησης", + "cloudflare": "DNS (Cloudflare) - απαιτεί διακριτικό API Cloudflare" + }, + "cloudflareToken": { + "label": "Διακριτικό API Cloudflare", + "description": "Διακριτικό πεδίου εφαρμογής με δικαίωμα Zone:DNS:Edit για τον τομέα σας." + } + }, + "status": { + "title": "Κατάσταση πιστοποιητικού", + "none": "Δεν υπάρχει πιστοποιητικό", + "expires": "Λήγει {{date}}", + "lastIssued": "Τελευταία έκδοση {{date}}", + "request": "Έκδοση / Ανανέωση Πιστοποιητικού", + "requesting": "Αίτημα πιστοποιητικού..." + } +} diff --git a/plugins/acme-ssl/locales/translated/es_ES.json b/plugins/acme-ssl/locales/translated/es_ES.json new file mode 100644 index 000000000..026db9a97 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/es_ES.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Dominio", + "placeholder": "termix.example.com", + "description": "El nombre de dominio público para el certificado." + }, + "email": { + "label": "Correo electrónico", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Tipo de desafío", + "cloudflare": "DNS (Cloudflare) - requiere un token de API de Cloudflare" + }, + "cloudflareToken": { + "label": "Token de API de Cloudflare", + "description": "Token con permiso Zone:DNS:Edit limitado para tu dominio." + } + }, + "status": { + "title": "Estado del certificado", + "none": "Sin certificado", + "expires": "Caduca {{date}}", + "lastIssued": "Última emisión {{date}}", + "request": "Emitir / Renovar certificado", + "requesting": "Solicitando certificado..." + } +} diff --git a/plugins/acme-ssl/locales/translated/fi_FI.json b/plugins/acme-ssl/locales/translated/fi_FI.json new file mode 100644 index 000000000..4efc7187d --- /dev/null +++ b/plugins/acme-ssl/locales/translated/fi_FI.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Verkkotunnus", + "placeholder": "termix.esimerkki.com", + "description": "Varmenteen julkinen verkkotunnus." + }, + "email": { + "label": "Sähköposti", + "placeholder": "admin@esimerkki.com" + }, + "challengeType": { + "label": "Haasteen tyyppi", + "cloudflare": "DNS (Cloudflare) – vaatii Cloudflare API -tunnuksen" + }, + "cloudflareToken": { + "label": "Cloudflare API-tunnus", + "description": "Laajuusaluetunnus, jolla on Zone:DNS:Edit-käyttöoikeus verkkotunnuksellesi." + } + }, + "status": { + "title": "Sertifikaatin tila", + "none": "Ei todistusta", + "expires": "Vanhenee {{date}}", + "lastIssued": "Viimeksi myönnetty {{date}}", + "request": "Myönnä / Uudista sertifikaatti", + "requesting": "Pyydetään varmennetta..." + } +} diff --git a/plugins/acme-ssl/locales/translated/fr_FR.json b/plugins/acme-ssl/locales/translated/fr_FR.json new file mode 100644 index 000000000..12fc3d7aa --- /dev/null +++ b/plugins/acme-ssl/locales/translated/fr_FR.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domaine", + "placeholder": "termix.example.com", + "description": "Le nom de domaine public pour le certificat." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Type de challenge", + "cloudflare": "DNS (Cloudflare) - nécessite un jeton API Cloudflare" + }, + "cloudflareToken": { + "label": "Jeton API Cloudflare", + "description": "Jeton limité avec l'autorisation Zone:DNS:Edit pour votre domaine." + } + }, + "status": { + "title": "Statut du certificat", + "none": "Aucun certificat", + "expires": "Expire le {{date}}", + "lastIssued": "Dernière émission le {{date}}", + "request": "Émettre / Renouveler le certificat", + "requesting": "Demande de certificat en cours..." + } +} diff --git a/plugins/acme-ssl/locales/translated/he_IL.json b/plugins/acme-ssl/locales/translated/he_IL.json new file mode 100644 index 000000000..cdac7502d --- /dev/null +++ b/plugins/acme-ssl/locales/translated/he_IL.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "תְחוּם", + "placeholder": "termix.example.com", + "description": "שם התחום הציבורי עבור התעודה." + }, + "email": { + "label": "אֶלֶקטרוֹנִי", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "סוג האתגר", + "cloudflare": "DNS (Cloudflare) - דורש אסימון API של Cloudflare" + }, + "cloudflareToken": { + "label": "אסימון API של Cloudflare", + "description": "אסימון בטווח עם הרשאת עריכה של אזור:DNS: עבור הדומיין שלך." + } + }, + "status": { + "title": "סטטוס התעודה", + "none": "אין תעודה", + "expires": "פג תוקף {{date}}", + "lastIssued": "הוצאה אחרונה {{date}}", + "request": "הנפקה / חידוש תעודה", + "requesting": "מבקש תעודה..." + } +} diff --git a/plugins/acme-ssl/locales/translated/hi_IN.json b/plugins/acme-ssl/locales/translated/hi_IN.json new file mode 100644 index 000000000..ed1ec0219 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/hi_IN.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "डोमेन", + "placeholder": "termix.example.com", + "description": "प्रमाणपत्र के लिए सार्वजनिक डोमेन नाम।" + }, + "email": { + "label": "ईमेल", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "चुनौती प्रकार", + "cloudflare": "DNS (Cloudflare) - Cloudflare API टोकन आवश्यक है" + }, + "cloudflareToken": { + "label": "Cloudflare API टोकन", + "description": "आपके डोमेन के लिए Zone:DNS:Edit अनुमति वाला स्कोप्ड टोकन।" + } + }, + "status": { + "title": "प्रमाणपत्र स्थिति", + "none": "कोई प्रमाणपत्र नहीं", + "expires": "{{date}} को समाप्त होगा", + "lastIssued": "अंतिम जारी: {{date}}", + "request": "प्रमाणपत्र जारी करें / नवीनीकरण करें", + "requesting": "प्रमाणपत्र अनुरोध किया जा रहा है..." + } +} diff --git a/plugins/acme-ssl/locales/translated/hu_HU.json b/plugins/acme-ssl/locales/translated/hu_HU.json new file mode 100644 index 000000000..6d02ec417 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/hu_HU.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domain", + "placeholder": "termix.example.com", + "description": "A tanúsítvány nyilvános domainneve." + }, + "email": { + "label": "Email", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Kihívás típusa", + "cloudflare": "DNS (Cloudflare) – Cloudflare API tokent igényel" + }, + "cloudflareToken": { + "label": "Cloudflare API token", + "description": "Hatókörbe tartozó token Zóna:DNS:Szerkesztés engedéllyel a domainhez." + } + }, + "status": { + "title": "Tanúsítvány állapota", + "none": "Nincs tanúsítvány", + "expires": "Lejár {{date}}", + "lastIssued": "Utolsó kiadás {{date}}", + "request": "Tanúsítvány kiállítása / megújítása", + "requesting": "Tanúsítvány kérése..." + } +} diff --git a/plugins/acme-ssl/locales/translated/id_ID.json b/plugins/acme-ssl/locales/translated/id_ID.json new file mode 100644 index 000000000..809bc146f --- /dev/null +++ b/plugins/acme-ssl/locales/translated/id_ID.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domain", + "placeholder": "termix.example.com", + "description": "Nama domain publik untuk sertifikat tersebut." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Jenis Tantangan", + "cloudflare": "DNS (Cloudflare) - memerlukan token API Cloudflare" + }, + "cloudflareToken": { + "label": "Token API Cloudflare", + "description": "Token terlingkup dengan izin Zone:DNS:Edit untuk domain Anda." + } + }, + "status": { + "title": "Status Sertifikat", + "none": "Tidak ada sertifikat", + "expires": "Berakhir {{date}}", + "lastIssued": "Terakhir diterbitkan {{date}}", + "request": "Menerbitkan/Memperpanjang Sertifikat", + "requesting": "Meminta sertifikat..." + } +} diff --git a/plugins/acme-ssl/locales/translated/it_IT.json b/plugins/acme-ssl/locales/translated/it_IT.json new file mode 100644 index 000000000..a1bddb487 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/it_IT.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Dominio", + "placeholder": "termix.example.com", + "description": "Il nome di dominio pubblico per il certificato." + }, + "email": { + "label": "Email", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Tipo di challenge", + "cloudflare": "DNS (Cloudflare) - richiede un token API Cloudflare" + }, + "cloudflareToken": { + "label": "Token API Cloudflare", + "description": "Token con autorizzazione Zone:DNS:Edit per il tuo dominio." + } + }, + "status": { + "title": "Stato del certificato", + "none": "Nessun certificato", + "expires": "Scade il {{date}}", + "lastIssued": "Ultimo rilascio il {{date}}", + "request": "Emetti / Rinnova certificato", + "requesting": "Richiesta certificato in corso..." + } +} diff --git a/plugins/acme-ssl/locales/translated/ja_JP.json b/plugins/acme-ssl/locales/translated/ja_JP.json new file mode 100644 index 000000000..e1f65ed9c --- /dev/null +++ b/plugins/acme-ssl/locales/translated/ja_JP.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "ドメイン", + "placeholder": "termix.example.com", + "description": "証明書の公開ドメイン名。" + }, + "email": { + "label": "メールアドレス", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "チャレンジタイプ", + "cloudflare": "DNS(Cloudflare)- Cloudflare APIトークンが必要です" + }, + "cloudflareToken": { + "label": "Cloudflare APIトークン", + "description": "ドメインに対するZone:DNS:Edit権限を持つスコープ付きトークン。" + } + }, + "status": { + "title": "証明書ステータス", + "none": "証明書なし", + "expires": "有効期限 {{date}}", + "lastIssued": "最終発行日 {{date}}", + "request": "証明書を発行/更新", + "requesting": "証明書をリクエスト中..." + } +} diff --git a/plugins/acme-ssl/locales/translated/ko_KR.json b/plugins/acme-ssl/locales/translated/ko_KR.json new file mode 100644 index 000000000..98789500e --- /dev/null +++ b/plugins/acme-ssl/locales/translated/ko_KR.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "도메인", + "placeholder": "termix.example.com", + "description": "인증서에 사용할 공개 도메인 이름입니다." + }, + "email": { + "label": "이메일", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "인증 방식", + "cloudflare": "DNS(Cloudflare) - Cloudflare API 토큰 필요" + }, + "cloudflareToken": { + "label": "Cloudflare API 토큰", + "description": "도메인에 대한 Zone:DNS:Edit 권한이 있는 범위 지정 토큰입니다." + } + }, + "status": { + "title": "인증서 상태", + "none": "인증서 없음", + "expires": "만료일: {{date}}", + "lastIssued": "마지막 발급일: {{date}}", + "request": "인증서 발급 / 갱신", + "requesting": "인증서 요청 중..." + } +} diff --git a/plugins/acme-ssl/locales/translated/nl_NL.json b/plugins/acme-ssl/locales/translated/nl_NL.json new file mode 100644 index 000000000..03587b9c6 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/nl_NL.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domein", + "placeholder": "termix.example.com", + "description": "De publieke domeinnaam voor het certificaat." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Uitdagingstype", + "cloudflare": "DNS (Cloudflare) - vereist een Cloudflare API-token" + }, + "cloudflareToken": { + "label": "Cloudflare API-token", + "description": "Een scoped token met Zone:DNS:Edit-machtiging voor uw domein." + } + }, + "status": { + "title": "Certificaatstatus", + "none": "Geen certificaat", + "expires": "Verloopt {{date}}", + "lastIssued": "Laatst uitgegeven {{date}}", + "request": "Certificaat aanvragen/verlengen", + "requesting": "Certificaat aanvragen..." + } +} diff --git a/plugins/acme-ssl/locales/translated/no_NO.json b/plugins/acme-ssl/locales/translated/no_NO.json new file mode 100644 index 000000000..9a98f175e --- /dev/null +++ b/plugins/acme-ssl/locales/translated/no_NO.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domene", + "placeholder": "termix.example.com", + "description": "Det offentlige domenenavnet for sertifikatet." + }, + "email": { + "label": "E-post", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Utfordringstype", + "cloudflare": "DNS (Cloudflare) – krever et Cloudflare API-token" + }, + "cloudflareToken": { + "label": "Cloudflare API-token", + "description": "Scope-token med Zone:DNS:Rediger-tillatelse for domenet ditt." + } + }, + "status": { + "title": "Sertifikatstatus", + "none": "Ingen sertifikat", + "expires": "Utløper {{date}}", + "lastIssued": "Sist utstedt {{date}}", + "request": "Utstede/fornye sertifikat", + "requesting": "Be om sertifikat..." + } +} diff --git a/plugins/acme-ssl/locales/translated/pl_PL.json b/plugins/acme-ssl/locales/translated/pl_PL.json new file mode 100644 index 000000000..8da4f3301 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/pl_PL.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domena", + "placeholder": "termix.example.com", + "description": "Publiczna nazwa domeny certyfikatu." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Typ wyzwania", + "cloudflare": "DNS (Cloudflare) - wymaga tokenu API Cloudflare" + }, + "cloudflareToken": { + "label": "Token API Cloudflare", + "description": "Token zakresowy z uprawnieniem Zone:DNS:Edit dla Twojej domeny." + } + }, + "status": { + "title": "Status certyfikatu", + "none": "Brak certyfikatu", + "expires": "Wygasa {{date}}", + "lastIssued": "Ostatnio wystawiono {{date}}", + "request": "Wystaw / odnów certyfikat", + "requesting": "Żądanie certyfikatu..." + } +} diff --git a/plugins/acme-ssl/locales/translated/pt_BR.json b/plugins/acme-ssl/locales/translated/pt_BR.json new file mode 100644 index 000000000..0006b239b --- /dev/null +++ b/plugins/acme-ssl/locales/translated/pt_BR.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domínio", + "placeholder": "termix.example.com", + "description": "O nome de domínio público do certificado." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Tipo de desafio", + "cloudflare": "DNS (Cloudflare) - requer um token de API do Cloudflare" + }, + "cloudflareToken": { + "label": "Token da API do Cloudflare", + "description": "Token com escopo definido e permissão Zone:DNS:Edit para o seu domínio." + } + }, + "status": { + "title": "Status do certificado", + "none": "Sem certificado", + "expires": "Expira em {{date}}", + "lastIssued": "Última edição {{date}}", + "request": "Emitir/Renovar Certificado", + "requesting": "Solicitar certificado..." + } +} diff --git a/plugins/acme-ssl/locales/translated/pt_PT.json b/plugins/acme-ssl/locales/translated/pt_PT.json new file mode 100644 index 000000000..e1381cad6 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/pt_PT.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domínio", + "placeholder": "termix.example.com", + "description": "O nome de domínio público para o certificado." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Tipo de desafio", + "cloudflare": "DNS (Cloudflare) - requer um token de API da Cloudflare" + }, + "cloudflareToken": { + "label": "Token de API da Cloudflare", + "description": "Token com âmbito e permissão Zone:DNS:Edit para o seu domínio." + } + }, + "status": { + "title": "Estado do certificado", + "none": "Sem certificado", + "expires": "Expira a {{date}}", + "lastIssued": "Última emissão a {{date}}", + "request": "Emitir / Renovar certificado", + "requesting": "A solicitar certificado..." + } +} diff --git a/plugins/acme-ssl/locales/translated/ro_RO.json b/plugins/acme-ssl/locales/translated/ro_RO.json new file mode 100644 index 000000000..fb081884c --- /dev/null +++ b/plugins/acme-ssl/locales/translated/ro_RO.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domeniu", + "placeholder": "termix.example.com", + "description": "Numele de domeniu public pentru certificat." + }, + "email": { + "label": "E-mail", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Tipul provocării", + "cloudflare": "DNS (Cloudflare) - necesită un token API Cloudflare" + }, + "cloudflareToken": { + "label": "Tokenul API Cloudflare", + "description": "Token cu scop specific și permisiune Zone:DNS:Edit pentru domeniul dvs." + } + }, + "status": { + "title": "Starea certificatului", + "none": "Fără certificat", + "expires": "Expiră la {{date}}", + "lastIssued": "Ultima emisiune {{date}}", + "request": "Emitere / Reînnoire certificat", + "requesting": "Se solicită certificatul..." + } +} diff --git a/plugins/acme-ssl/locales/translated/ru_RU.json b/plugins/acme-ssl/locales/translated/ru_RU.json new file mode 100644 index 000000000..9f6b02d22 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/ru_RU.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Домен", + "placeholder": "termix.example.com", + "description": "Общедоступное доменное имя для сертификата." + }, + "email": { + "label": "Email", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Тип проверки", + "cloudflare": "DNS (Cloudflare) — требуется API-токен Cloudflare" + }, + "cloudflareToken": { + "label": "API-токен Cloudflare", + "description": "Токен с ограниченной областью действия и разрешением Zone:DNS:Edit для вашего домена." + } + }, + "status": { + "title": "Статус сертификата", + "none": "Сертификат отсутствует", + "expires": "Истекает {{date}}", + "lastIssued": "Последний выпуск {{date}}", + "request": "Выпустить / Обновить сертификат", + "requesting": "Запрос сертификата..." + } +} diff --git a/plugins/acme-ssl/locales/translated/sr_SP.json b/plugins/acme-ssl/locales/translated/sr_SP.json new file mode 100644 index 000000000..126cf510a --- /dev/null +++ b/plugins/acme-ssl/locales/translated/sr_SP.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Домен", + "placeholder": "termix.example.com", + "description": "Јавно име домена за сертификат." + }, + "email": { + "label": "Е-пошта", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Тип изазова", + "cloudflare": "DNS (Cloudflare) - захтева Cloudflare API токен" + }, + "cloudflareToken": { + "label": "Клаудфлејр АПИ токен", + "description": "Ограничени токен са дозволом Zone:DNS:Edit за ваш домен." + } + }, + "status": { + "title": "Статус сертификата", + "none": "Без сертификата", + "expires": "Истиче {{date}}", + "lastIssued": "Последње издање {{date}}", + "request": "Издавање / обнављање сертификата", + "requesting": "Захтева се сертификат..." + } +} diff --git a/plugins/acme-ssl/locales/translated/sv_SE.json b/plugins/acme-ssl/locales/translated/sv_SE.json new file mode 100644 index 000000000..4a7e50ac4 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/sv_SE.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Domän", + "placeholder": "termix.example.com", + "description": "Det offentliga domännamnet för certifikatet." + }, + "email": { + "label": "E-post", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Utmaningstyp", + "cloudflare": "DNS (Cloudflare) – kräver en Cloudflare API-token" + }, + "cloudflareToken": { + "label": "Cloudflare API-token", + "description": "Scoped-token med Zone:DNS:Edit-behörighet för din domän." + } + }, + "status": { + "title": "Certifikatstatus", + "none": "Inget certifikat", + "expires": "Utgångsdatum {{date}}", + "lastIssued": "Senast utfärdad {{date}}", + "request": "Utfärda/förnya certifikat", + "requesting": "Begär certifikat..." + } +} diff --git a/plugins/acme-ssl/locales/translated/th_TH.json b/plugins/acme-ssl/locales/translated/th_TH.json new file mode 100644 index 000000000..11cc7ca05 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/th_TH.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "โดเมน", + "placeholder": "เทอร์มิกซ์.ตัวอย่าง.com", + "description": "ชื่อโดเมนสาธารณะสำหรับใบรับรอง" + }, + "email": { + "label": "อีเมล", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "ประเภทความท้าทาย", + "cloudflare": "DNS (Cloudflare) - ต้องใช้โทเค็น API ของ Cloudflare" + }, + "cloudflareToken": { + "label": "โทเค็น API ของ Cloudflare", + "description": "โทเค็นแบบจำกัดขอบเขตพร้อมสิทธิ์ Zone:DNS:Edit สำหรับโดเมนของคุณ" + } + }, + "status": { + "title": "สถานะใบรับรอง", + "none": "ไม่มีใบรับรอง", + "expires": "หมดอายุ {{date}}", + "lastIssued": "ออกครั้งล่าสุด {{date}}", + "request": "ออก/ต่ออายุใบรับรอง", + "requesting": "ขอใบรับรอง..." + } +} diff --git a/plugins/acme-ssl/locales/translated/tr_TR.json b/plugins/acme-ssl/locales/translated/tr_TR.json new file mode 100644 index 000000000..7f1cc2154 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/tr_TR.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Alan Adı", + "placeholder": "termix.example.com", + "description": "Sertifika için genel alan adı." + }, + "email": { + "label": "E-posta", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Doğrulama Türü", + "cloudflare": "DNS (Cloudflare) - bir Cloudflare API belirteci gerektirir" + }, + "cloudflareToken": { + "label": "Cloudflare API Belirteci", + "description": "Alan adınız için Zone:DNS:Edit iznine sahip kapsamlı belirteç." + } + }, + "status": { + "title": "Sertifika Durumu", + "none": "Sertifika yok", + "expires": "Son kullanma: {{date}}", + "lastIssued": "Son veriliş: {{date}}", + "request": "Sertifika Ver / Yenile", + "requesting": "Sertifika isteniyor..." + } +} diff --git a/plugins/acme-ssl/locales/translated/uk_UA.json b/plugins/acme-ssl/locales/translated/uk_UA.json new file mode 100644 index 000000000..0b3d891b6 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/uk_UA.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Домен", + "placeholder": "termix.example.com", + "description": "Ім'я публічного домену для сертифіката." + }, + "email": { + "label": "Електронна пошта", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Тип виклику", + "cloudflare": "DNS (Cloudflare) – потрібен токен API Cloudflare" + }, + "cloudflareToken": { + "label": "Токен API Cloudflare", + "description": "Токен з обмеженою областю дії та дозволом Zone:DNS:Edit для вашого домену." + } + }, + "status": { + "title": "Статус сертифіката", + "none": "Без сертифіката", + "expires": "Термін дії закінчується {{date}}", + "lastIssued": "Останній виданий {{date}}", + "request": "Видача / поновлення сертифіката", + "requesting": "Запит сертифіката..." + } +} diff --git a/plugins/acme-ssl/locales/translated/vi_VN.json b/plugins/acme-ssl/locales/translated/vi_VN.json new file mode 100644 index 000000000..6958f65e9 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/vi_VN.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "Tên miền", + "placeholder": "termix.example.com", + "description": "Tên miền công khai để cấp chứng chỉ." + }, + "email": { + "label": "Email", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "Loại thử thách", + "cloudflare": "DNS (Cloudflare) - yêu cầu Token API Cloudflare" + }, + "cloudflareToken": { + "label": "Token API Cloudflare", + "description": "Token có phạm vi với quyền Zone:DNS:Edit cho tên miền của bạn." + } + }, + "status": { + "title": "Trạng thái chứng chỉ", + "none": "Không có chứng chỉ", + "expires": "Hết hạn {{date}}", + "lastIssued": "Cấp lần cuối {{date}}", + "request": "Cấp / Gia hạn chứng chỉ", + "requesting": "Đang yêu cầu chứng chỉ..." + } +} diff --git a/plugins/acme-ssl/locales/translated/zh_CN.json b/plugins/acme-ssl/locales/translated/zh_CN.json new file mode 100644 index 000000000..fc5e64cd5 --- /dev/null +++ b/plugins/acme-ssl/locales/translated/zh_CN.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "域名", + "placeholder": "termix.example.com", + "description": "证书的公共域名。" + }, + "email": { + "label": "邮箱", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "验证类型", + "cloudflare": "DNS (Cloudflare) - 需要 Cloudflare API 令牌" + }, + "cloudflareToken": { + "label": "Cloudflare API 令牌", + "description": "具有您域名的 Zone:DNS:Edit 权限的限定作用域令牌。" + } + }, + "status": { + "title": "证书状态", + "none": "无证书", + "expires": "过期于 {{date}}", + "lastIssued": "上次签发于 {{date}}", + "request": "签发 / 续期证书", + "requesting": "正在请求证书..." + } +} diff --git a/plugins/acme-ssl/locales/translated/zh_TW.json b/plugins/acme-ssl/locales/translated/zh_TW.json new file mode 100644 index 000000000..49367bc7b --- /dev/null +++ b/plugins/acme-ssl/locales/translated/zh_TW.json @@ -0,0 +1,29 @@ +{ + "settings": { + "domain": { + "label": "網域", + "placeholder": "termix.example.com", + "description": "憑證使用的公開網域名稱。" + }, + "email": { + "label": "電子郵件", + "placeholder": "admin@example.com" + }, + "challengeType": { + "label": "驗證類型", + "cloudflare": "DNS (Cloudflare) - 需要 Cloudflare API 權杖" + }, + "cloudflareToken": { + "label": "Cloudflare API 權杖", + "description": "具有您網域 Zone:DNS:Edit 權限的限定範圍權杖。" + } + }, + "status": { + "title": "憑證狀態", + "none": "無憑證", + "expires": "到期日 {{date}}", + "lastIssued": "上次簽發日期 {{date}}", + "request": "簽發 / 續期憑證", + "requesting": "請求憑證中..." + } +} diff --git a/plugins/acme-ssl/manifest.json b/plugins/acme-ssl/manifest.json new file mode 100644 index 000000000..90e050c08 --- /dev/null +++ b/plugins/acme-ssl/manifest.json @@ -0,0 +1,120 @@ +{ + "id": "acme-ssl", + "name": "ACME Certificates", + "version": "1.0.0", + "description": "Gets and renews the Termix HTTPS certificate from Let's Encrypt or another ACME CA, with the http-01 or Cloudflare dns-01 challenge. Termix keeps serving the current certificate when this plugin is off, but it is no longer renewed.", + "author": { + "name": "Termix" + }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Access & Security", + "icon": "Lock", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": [ + "system:tls", + "kv:own", + "secrets:own", + "network:outbound", + "network:serve", + "ui:surface" + ], + "contributes": { + "permissions": [ + { + "name": "manage", + "titleKey": "permissions.manage.title", + "descriptionKey": "permissions.manage.description", + "defaultRoles": ["admin"] + } + ], + "settings": { + "admin": [ + { + "key": "status", + "type": "custom", + "component": "status" + }, + { + "key": "autoRenew", + "type": "boolean", + "labelKey": "settings.autoRenew.label", + "descriptionKey": "settings.autoRenew.description", + "default": false + }, + { + "key": "domain", + "type": "string", + "labelKey": "settings.domain.label", + "descriptionKey": "settings.domain.description", + "placeholderKey": "settings.domain.placeholder", + "default": "" + }, + { + "key": "email", + "type": "string", + "labelKey": "settings.email.label", + "descriptionKey": "settings.email.description", + "placeholderKey": "settings.email.placeholder", + "default": "" + }, + { + "key": "provider", + "type": "select", + "labelKey": "settings.provider.label", + "descriptionKey": "settings.provider.description", + "default": "letsencrypt", + "options": [ + { + "value": "letsencrypt", + "labelKey": "settings.provider.letsencrypt" + }, + { + "value": "letsencrypt-staging", + "labelKey": "settings.provider.letsencryptStaging" + }, + { + "value": "custom", + "labelKey": "settings.provider.custom" + } + ] + }, + { + "key": "directoryUrl", + "type": "string", + "labelKey": "settings.directoryUrl.label", + "descriptionKey": "settings.directoryUrl.description", + "default": "" + }, + { + "key": "challengeType", + "type": "select", + "labelKey": "settings.challengeType.label", + "descriptionKey": "settings.challengeType.description", + "default": "http-01", + "options": [ + { + "value": "http-01", + "labelKey": "settings.challengeType.http" + }, + { + "value": "dns-cloudflare", + "labelKey": "settings.challengeType.cloudflare" + } + ] + }, + { + "key": "cloudflareToken", + "type": "secret", + "labelKey": "settings.cloudflareToken.label", + "descriptionKey": "settings.cloudflareToken.description" + } + ] + } + }, + "locales": "locales", + "platforms": ["linux", "win32", "darwin"] +} diff --git a/plugins/acme-ssl/package.json b/plugins/acme-ssl/package.json new file mode 100644 index 000000000..19d26f3af --- /dev/null +++ b/plugins/acme-ssl/package.json @@ -0,0 +1,18 @@ +{ + "name": "@termix-plugin/acme-ssl", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "dependencies": { + "acme-client": "^5.4.0" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/acme-ssl/src/backend/acme.ts b/plugins/acme-ssl/src/backend/acme.ts new file mode 100644 index 000000000..665b5315a --- /dev/null +++ b/plugins/acme-ssl/src/backend/acme.ts @@ -0,0 +1,217 @@ +/** + * Issuing a certificate with acme-client. Every request it makes goes through + * ctx.fetch, so ACME traffic gets core's outbound guard and audit like any + * other plugin request. + */ + +import * as acme from "acme-client"; +import type { PluginContext, PluginFetch } from "@termix/plugin-sdk/backend"; +import { createTxtRecord } from "./cloudflare.js"; +import { directoryUrlFor, type AcmeSettings } from "./settings.js"; + +const ACCOUNT_KEY = "account"; +const DNS_PROPAGATION_MS = 30_000; + +interface StoredAccount { + directoryUrl: string; + sealedKey: string; + url: string | null; +} + +export interface IssuedCertificate { + certificate: string; + privateKey: string; +} + +type AxiosLikeConfig = { + url?: string; + method?: string; + headers?: unknown; + data?: unknown; + timeout?: number; + validateStatus?: ((status: number) => boolean) | null; +}; + +function plainHeaders(headers: unknown): Record { + const source = + headers && typeof (headers as { toJSON?: unknown }).toJSON === "function" + ? (headers as { toJSON: () => Record }).toJSON() + : ((headers ?? {}) as Record); + const out: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (typeof value === "string" || typeof value === "number") { + out[key] = String(value); + } + } + return out; +} + +/** + * An axios adapter over ctx.fetch. It settles the way axios' own adapters do, + * because acme-client's retry interceptor relies on a rejected error that + * carries the response. + */ +export function createFetchAdapter( + fetch: PluginFetch, + allowPrivateHosts: readonly string[] = [], +) { + return async (config: AxiosLikeConfig) => { + const method = (config.method ?? "get").toUpperCase(); + const response = await fetch(String(config.url), { + method, + headers: plainHeaders(config.headers), + body: + config.data == null + ? undefined + : typeof config.data === "string" + ? config.data + : JSON.stringify(config.data), + timeoutMs: config.timeout || 30_000, + allowPrivateHosts, + }); + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + const result = { + data: method === "HEAD" ? "" : await response.text(), + status: response.status, + statusText: response.statusText, + headers, + config, + request: {}, + }; + const validate = config.validateStatus; + if (!result.status || !validate || validate(result.status)) return result; + throw Object.assign( + new Error(`Request failed with status code ${result.status}`), + { + isAxiosError: true, + code: result.status >= 500 ? "ERR_BAD_RESPONSE" : "ERR_BAD_REQUEST", + config, + response: result, + }, + ); + }; +} + +/** Private hosts a custom directory on the local network may use. */ +function privateHostsFor(settings: AcmeSettings): string[] { + if (settings.provider !== "custom") return []; + try { + return [new URL(settings.directoryUrl).hostname]; + } catch { + return []; + } +} + +async function loadAccount( + ctx: PluginContext, + directoryUrl: string, +): Promise<{ key: string; url: string | null }> { + const stored = (await ctx.kv.get(ACCOUNT_KEY)) as StoredAccount | null; + if (stored && stored.directoryUrl === directoryUrl) { + const key = await ctx.secrets.unseal(stored.sealedKey); + if (key) return { key, url: stored.url }; + } + const key = (await acme.crypto.createPrivateKey()).toString(); + await ctx.kv.set(ACCOUNT_KEY, { + directoryUrl, + sealedKey: await ctx.secrets.seal(key), + url: null, + } satisfies StoredAccount); + return { key, url: null }; +} + +async function saveAccountUrl( + ctx: PluginContext, + directoryUrl: string, + url: string, +): Promise { + const stored = (await ctx.kv.get(ACCOUNT_KEY)) as StoredAccount | null; + if (!stored || stored.directoryUrl !== directoryUrl || stored.url === url) { + return; + } + await ctx.kv.set(ACCOUNT_KEY, { ...stored, url }); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Runs one ACME order for `settings.domain` and returns the new pair. */ +export async function issueCertificate( + ctx: PluginContext, + settings: AcmeSettings, + options: { propagationMs?: number } = {}, +): Promise { + const directoryUrl = directoryUrlFor(settings); + acme.axios.defaults.adapter = createFetchAdapter( + ctx.fetch, + privateHostsFor(settings), + ) as never; + + const account = await loadAccount(ctx, directoryUrl); + const client = new acme.Client({ + directoryUrl, + accountKey: account.key, + ...(account.url ? { accountUrl: account.url } : {}), + }); + + const [key, csr] = await acme.crypto.createCsr({ + commonName: settings.domain, + altNames: [settings.domain], + }); + + const cleanups = new Map void | Promise>(); + const certificate = await client.auto({ + csr, + email: settings.email, + termsOfServiceAgreed: true, + // Checking our own answer first needs port 80 or DNS from inside the + // container, which often is not how the CA sees it. + skipChallengeVerification: true, + challengePriority: + settings.challengeType === "dns-cloudflare" ? ["dns-01"] : ["http-01"], + challengeCreateFn: async (authz, challenge, keyAuthorization) => { + if (challenge.type === "http-01") { + cleanups.set( + challenge.token, + await ctx.system.publishHttpChallenge( + challenge.token, + keyAuthorization, + ), + ); + return; + } + if (challenge.type === "dns-01") { + cleanups.set( + challenge.token, + await createTxtRecord( + ctx.fetch, + settings.cloudflareToken, + `_acme-challenge.${authz.identifier.value}`, + keyAuthorization, + ), + ); + await sleep(options.propagationMs ?? DNS_PROPAGATION_MS); + return; + } + throw new Error( + `Unsupported challenge type ${(challenge as { type: string }).type}`, + ); + }, + challengeRemoveFn: async (_authz, challenge) => { + const cleanup = cleanups.get(challenge.token); + cleanups.delete(challenge.token); + try { + await cleanup?.(); + } catch (error) { + ctx.log.warn( + `Could not clean up the ${challenge.type} challenge: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + }); + + await saveAccountUrl(ctx, directoryUrl, client.getAccountUrl()); + return { certificate, privateKey: key.toString() }; +} diff --git a/plugins/acme-ssl/src/backend/cloudflare.ts b/plugins/acme-ssl/src/backend/cloudflare.ts new file mode 100644 index 000000000..fdc73b679 --- /dev/null +++ b/plugins/acme-ssl/src/backend/cloudflare.ts @@ -0,0 +1,78 @@ +import type { PluginFetch } from "@termix/plugin-sdk/backend"; + +const API = "https://api.cloudflare.com/client/v4"; + +interface CloudflareEnvelope { + success: boolean; + errors?: Array<{ message?: string }>; + result: T; +} + +async function call( + fetch: PluginFetch, + token: string, + path: string, + init: { method?: string; body?: unknown } = {}, +): Promise { + const response = await fetch(`${API}${path}`, { + method: init.method ?? "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: init.body === undefined ? undefined : JSON.stringify(init.body), + }); + const payload = (await response + .json() + .catch(() => null)) as CloudflareEnvelope | null; + if (!response.ok || !payload?.success) { + const reason = + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join("; ") || `HTTP ${response.status}`; + throw new Error(`Cloudflare API error: ${reason}`); + } + return payload.result; +} + +/** The zone holding `name`, found by trying each parent domain. */ +export async function findZoneId( + fetch: PluginFetch, + token: string, + name: string, +): Promise { + const labels = name.replace(/\.$/, "").split("."); + for (let i = 0; i < labels.length - 1; i++) { + const candidate = labels.slice(i).join("."); + const zones = await call>( + fetch, + token, + `/zones?name=${encodeURIComponent(candidate)}`, + ); + const zone = zones.find((entry) => entry.name === candidate); + if (zone) return zone.id; + } + throw new Error(`No Cloudflare zone found for ${name}`); +} + +/** Creates the TXT record and returns a function that deletes it. */ +export async function createTxtRecord( + fetch: PluginFetch, + token: string, + name: string, + content: string, +): Promise<() => Promise> { + const zoneId = await findZoneId(fetch, token, name); + const record = await call<{ id: string }>( + fetch, + token, + `/zones/${zoneId}/dns_records`, + { method: "POST", body: { type: "TXT", name, content, ttl: 120 } }, + ); + return async () => { + await call(fetch, token, `/zones/${zoneId}/dns_records/${record.id}`, { + method: "DELETE", + }); + }; +} diff --git a/plugins/acme-ssl/src/backend/index.ts b/plugins/acme-ssl/src/backend/index.ts new file mode 100644 index 000000000..7f8507374 --- /dev/null +++ b/plugins/acme-ssl/src/backend/index.ts @@ -0,0 +1,50 @@ +import type { Router } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { issueCertificate } from "./acme.js"; +import { + CHECK_INTERVAL_MS, + createAcmeRunner, + watchSettings, + type IssueFn, +} from "./runner.js"; +import { registerRoutes } from "./routes.js"; + +/** `issue` is swapped out by tests; the plugin always uses acme-client. */ +export async function activateWith(ctx: PluginContext, issue: IssueFn) { + const runner = createAcmeRunner(ctx, issue); + + await runner.syncRenewer(); + watchSettings(ctx, () => { + runner + .syncRenewer() + .catch((error) => + ctx.log.warn( + `Could not update the renewal registration: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + }); + + // The first check lands within a minute of boot, after core's boot-time + // settings migrations, so it also picks up settings moved from 2.8. + ctx.schedule.every( + CHECK_INTERVAL_MS, + async () => { + await runner.syncRenewer(); + await runner.check(); + }, + { jitterMs: 60_000 }, + ); + + registerRoutes(ctx, ctx.http.router(), runner); + return runner; +} + +export async function activate(ctx: PluginContext) { + await activateWith(ctx, (pluginCtx, settings) => + issueCertificate(pluginCtx, settings), + ); +} + +export async function deactivate() { + // Everything above was registered through ctx and is disposed by core. +} diff --git a/plugins/acme-ssl/src/backend/renewal.ts b/plugins/acme-ssl/src/backend/renewal.ts new file mode 100644 index 000000000..08cb3a187 --- /dev/null +++ b/plugins/acme-ssl/src/backend/renewal.ts @@ -0,0 +1,28 @@ +import type { PluginTlsStatus } from "@termix/plugin-sdk/backend"; + +export const RENEW_BEFORE_MS = 30 * 86_400_000; + +export type RenewalReason = "missing" | "self-signed" | "domain" | "expiring"; + +/** + * Whether the served certificate should be replaced for `domain`: there is + * none, it is core's self-signed one, it does not cover the domain, or it + * expires within 30 days. + */ +export function renewalReason( + status: PluginTlsStatus, + domain: string, + now: Date = new Date(), +): RenewalReason | null { + const cert = status.certificate; + if (!cert) return "missing"; + if (cert.selfSigned) return "self-signed"; + const wanted = domain.toLowerCase(); + if (!cert.names.some((name) => name.toLowerCase() === wanted)) { + return "domain"; + } + if (new Date(cert.notAfter).getTime() - now.getTime() < RENEW_BEFORE_MS) { + return "expiring"; + } + return null; +} diff --git a/plugins/acme-ssl/src/backend/routes.ts b/plugins/acme-ssl/src/backend/routes.ts new file mode 100644 index 000000000..1b624a822 --- /dev/null +++ b/plugins/acme-ssl/src/backend/routes.ts @@ -0,0 +1,73 @@ +import type { Request, RequestHandler, Response, Router } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { AcmeNotConfiguredError, type AcmeRunner } from "./runner.js"; +import { missingSetting, readSettings } from "./settings.js"; + +export function registerRoutes( + ctx: PluginContext, + router: Router, + runner: AcmeRunner, +): void { + router.use(ctx.rbac.require("manage") as unknown as RequestHandler); + + /** + * @openapi + * /plugin-api/acme-ssl/status: + * get: + * summary: ACME certificate status + * description: The certificate core serves, whether ACME is configured, and the last issue attempt. + * tags: [ACME Certificates] + * responses: + * 200: + * description: Status. + * 403: + * description: Missing the acme-ssl.manage permission. + */ + router.get("/status", async (_req: Request, res: Response) => { + const settings = await readSettings(ctx); + res.json({ + tls: await ctx.system.tlsStatus(), + state: await runner.state(), + autoRenew: settings.autoRenew, + missing: missingSetting(settings), + }); + }); + + /** + * @openapi + * /plugin-api/acme-ssl/request: + * post: + * summary: Request a certificate now + * description: Runs an ACME order for the configured domain, installs the certificate and reloads HTTPS. + * tags: [ACME Certificates] + * responses: + * 200: + * description: Certificate issued and installed. + * 400: + * description: A required setting is missing. + * 403: + * description: Missing the acme-ssl.manage permission. + * 500: + * description: The ACME order failed. + */ + router.post("/request", async (_req: Request, res: Response) => { + try { + const result = await runner.issue(); + res.json({ + success: true, + reloadMessage: result.reload.message, + tls: result.tls, + state: await runner.state(), + }); + } catch (error) { + if (error instanceof AcmeNotConfiguredError) { + return res + .status(400) + .json({ error: error.message, missing: error.field }); + } + const message = error instanceof Error ? error.message : String(error); + ctx.log.warn(`Certificate request failed: ${message}`); + res.status(500).json({ error: `Certificate request failed: ${message}` }); + } + }); +} diff --git a/plugins/acme-ssl/src/backend/runner.ts b/plugins/acme-ssl/src/backend/runner.ts new file mode 100644 index 000000000..e90962fa8 --- /dev/null +++ b/plugins/acme-ssl/src/backend/runner.ts @@ -0,0 +1,162 @@ +import type { + PluginContext, + PluginTlsReloadResult, + PluginTlsStatus, +} from "@termix/plugin-sdk/backend"; +import type { IssuedCertificate } from "./acme.js"; +import { renewalReason } from "./renewal.js"; +import { + SETTING_KEYS, + missingSetting, + readSettings, + type AcmeSettings, +} from "./settings.js"; + +export const CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; +/** After a failed attempt, the scheduled check waits this long to retry. */ +export const RETRY_AFTER_MS = 6 * 60 * 60 * 1000; + +const STATE_KEY = "state"; + +export interface AcmeState { + lastAttemptAt: string | null; + lastIssuedAt: string | null; + lastError: string | null; +} + +export type IssueFn = ( + ctx: PluginContext, + settings: AcmeSettings, +) => Promise; + +export class AcmeNotConfiguredError extends Error { + constructor(readonly field: string) { + super(`ACME is not configured: ${field} is missing`); + } +} + +export interface IssueResult { + tls: PluginTlsStatus; + reload: PluginTlsReloadResult; +} + +export interface AcmeRunner { + state: () => Promise; + /** Issues now, whatever the served certificate looks like. */ + issue: () => Promise; + /** The scheduled check: issues only when renewal is on and due. */ + check: (now?: Date) => Promise; + /** Registers or drops the renewer to match the current settings. */ + syncRenewer: () => Promise; +} + +export function createAcmeRunner( + ctx: PluginContext, + issue: IssueFn, +): AcmeRunner { + let running: Promise | null = null; + let dropRenewer: (() => void) | null = null; + + const state = async (): Promise => { + const stored = (await ctx.kv.get(STATE_KEY)) as Partial | null; + return { + lastAttemptAt: stored?.lastAttemptAt ?? null, + lastIssuedAt: stored?.lastIssuedAt ?? null, + lastError: stored?.lastError ?? null, + }; + }; + + const record = async (patch: Partial) => { + await ctx.kv.set(STATE_KEY, { ...(await state()), ...patch }); + }; + + const runIssue = async (): Promise => { + const settings = await readSettings(ctx); + const missing = missingSetting(settings); + if (missing) throw new AcmeNotConfiguredError(missing); + + const attemptAt = new Date().toISOString(); + try { + const issued = await issue(ctx, settings); + await ctx.system.writeTlsCertificate( + issued.certificate, + issued.privateKey, + ); + const reload = await ctx.system.reloadTls(); + await record({ + lastAttemptAt: attemptAt, + lastIssuedAt: new Date().toISOString(), + lastError: null, + }); + ctx.log.info(`Certificate issued for ${settings.domain}`); + return { tls: await ctx.system.tlsStatus(), reload }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await record({ lastAttemptAt: attemptAt, lastError: message }); + throw error; + } + }; + + const runner: AcmeRunner = { + state, + + issue: () => { + running ??= runIssue().finally(() => { + running = null; + }); + return running; + }, + + check: async (now = new Date()) => { + const settings = await readSettings(ctx); + if (!settings.autoRenew || missingSetting(settings)) return false; + + const last = await state(); + if ( + last.lastError && + last.lastAttemptAt && + now.getTime() - new Date(last.lastAttemptAt).getTime() < RETRY_AFTER_MS + ) { + return false; + } + + const reason = renewalReason( + await ctx.system.tlsStatus(), + settings.domain, + now, + ); + if (!reason) return false; + + ctx.log.info( + `Renewing the certificate for ${settings.domain} (${reason})`, + ); + try { + await runner.issue(); + return true; + } catch (error) { + ctx.log.error( + "Certificate renewal failed", + error instanceof Error ? error : new Error(String(error)), + ); + return false; + } + }, + + syncRenewer: async () => { + const settings = await readSettings(ctx); + const wanted = settings.autoRenew && !missingSetting(settings); + if (wanted && !dropRenewer) { + dropRenewer = await ctx.system.registerTlsRenewer(); + } else if (!wanted && dropRenewer) { + dropRenewer(); + dropRenewer = null; + } + }, + }; + + return runner; +} + +export function watchSettings(ctx: PluginContext, onChange: () => void): void { + for (const key of SETTING_KEYS) ctx.settings.onChange(key, onChange); +} diff --git a/plugins/acme-ssl/src/backend/settings.ts b/plugins/acme-ssl/src/backend/settings.ts new file mode 100644 index 000000000..e71a01b4d --- /dev/null +++ b/plugins/acme-ssl/src/backend/settings.ts @@ -0,0 +1,77 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; + +export type AcmeProvider = "letsencrypt" | "letsencrypt-staging" | "custom"; +export type AcmeChallengeType = "http-01" | "dns-cloudflare"; + +export interface AcmeSettings { + autoRenew: boolean; + domain: string; + email: string; + provider: AcmeProvider; + directoryUrl: string; + challengeType: AcmeChallengeType; + cloudflareToken: string; +} + +export const SETTING_KEYS = [ + "autoRenew", + "domain", + "email", + "provider", + "directoryUrl", + "challengeType", + "cloudflareToken", +] as const; + +export const DIRECTORIES: Record, string> = { + letsencrypt: "https://acme-v02.api.letsencrypt.org/directory", + "letsencrypt-staging": + "https://acme-staging-v02.api.letsencrypt.org/directory", +}; + +const text = (value: unknown) => + typeof value === "string" ? value.trim() : ""; + +export async function readSettings(ctx: PluginContext): Promise { + const all = await ctx.settings.getAll("admin"); + const provider = text(all.provider); + const challengeType = text(all.challengeType); + return { + autoRenew: all.autoRenew === true, + domain: text(all.domain).toLowerCase(), + email: text(all.email), + provider: + provider === "letsencrypt-staging" || provider === "custom" + ? provider + : "letsencrypt", + directoryUrl: text(all.directoryUrl), + challengeType: + challengeType === "dns-cloudflare" ? challengeType : "http-01", + cloudflareToken: text(all.cloudflareToken), + }; +} + +export function directoryUrlFor(settings: AcmeSettings): string { + return settings.provider === "custom" + ? settings.directoryUrl + : DIRECTORIES[settings.provider]; +} + +/** The reason issuing cannot start, or null when everything is set. */ +export function missingSetting(settings: AcmeSettings): string | null { + if (!settings.domain) return "domain"; + if (!settings.email) return "email"; + if ( + settings.provider === "custom" && + !/^https:\/\//i.test(settings.directoryUrl) + ) { + return "directoryUrl"; + } + if ( + settings.challengeType === "dns-cloudflare" && + !settings.cloudflareToken + ) { + return "cloudflareToken"; + } + return null; +} diff --git a/plugins/acme-ssl/src/frontend/AcmeStatusSetting.tsx b/plugins/acme-ssl/src/frontend/AcmeStatusSetting.tsx new file mode 100644 index 000000000..1999b7798 --- /dev/null +++ b/plugins/acme-ssl/src/frontend/AcmeStatusSetting.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + usePluginApi, + useTranslation, + type SettingsComponentProps, +} from "@termix/plugin-sdk/frontend"; +import { Button } from "@termix/plugin-sdk/ui"; + +export interface AcmeStatus { + tls: { + enabled: boolean; + certificate: { + issuer: string; + names: string[]; + notAfter: string; + selfSigned: boolean; + } | null; + }; + state: { + lastAttemptAt: string | null; + lastIssuedAt: string | null; + lastError: string | null; + }; + autoRenew: boolean; + missing: string | null; +} + +function errorMessage(error: unknown): string { + const data = (error as { response?: { data?: { error?: string } } })?.response + ?.data; + if (data?.error) return data.error; + return error instanceof Error ? error.message : String(error); +} + +export function AcmeStatusSetting({ running }: SettingsComponentProps) { + const { t } = useTranslation(); + const api = usePluginApi(); + const [status, setStatus] = useState(null); + const [requesting, setRequesting] = useState(false); + + const load = useCallback(async () => { + try { + const { data } = await api.get("/status"); + setStatus(data); + } catch { + setStatus(null); + } + }, [api]); + + useEffect(() => { + if (running) void load(); + }, [running, load]); + + async function request() { + setRequesting(true); + try { + const { data } = await api.post<{ reloadMessage?: string }>("/request"); + toast.success(t("status.issued")); + if (data.reloadMessage) toast.info(data.reloadMessage); + } catch (error) { + toast.error(errorMessage(error)); + } finally { + setRequesting(false); + await load(); + } + } + + const cert = status?.tls.certificate; + + return ( +
+ {t("status.title")} + {cert ? ( + <> + + {t("status.names", { names: cert.names.join(", ") || "-" })} + + + {t("status.issuer", { issuer: cert.issuer })} + + + {t("status.expires", { + date: new Date(cert.notAfter).toLocaleDateString(), + })} + + + ) : ( + + {t("status.none")} + + )} + {status?.state.lastIssuedAt && ( + + {t("status.lastIssued", { + date: new Date(status.state.lastIssuedAt).toLocaleString(), + })} + + )} + {status?.state.lastError && ( + + {t("status.lastError", { error: status.state.lastError })} + + )} + {status?.missing && ( + + {t("status.missing")} + + )} + +
+ ); +} diff --git a/plugins/acme-ssl/src/frontend/index.tsx b/plugins/acme-ssl/src/frontend/index.tsx new file mode 100644 index 000000000..7c4d05f82 --- /dev/null +++ b/plugins/acme-ssl/src/frontend/index.tsx @@ -0,0 +1,10 @@ +import type { TermixApp } from "@termix/plugin-sdk/frontend"; +import { AcmeStatusSetting } from "./AcmeStatusSetting"; + +export function activate(app: TermixApp): void { + app.registerSettingsComponent("status", AcmeStatusSetting); +} + +export function deactivate(): void { + // Registrations through app are disposed by core. +} diff --git a/plugins/acme-ssl/tests/backend/acme-ssl.test.ts b/plugins/acme-ssl/tests/backend/acme-ssl.test.ts new file mode 100644 index 000000000..c5e945ec8 --- /dev/null +++ b/plugins/acme-ssl/tests/backend/acme-ssl.test.ts @@ -0,0 +1,252 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import express, { type Router } from "express"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createMockCtx, + type MockPluginContext, +} from "@termix/plugin-sdk/testing"; +import type { + PluginCapabilityError as CapabilityError, + PluginTlsCertificateInfo, + PluginTlsStatus, +} from "@termix/plugin-sdk/backend"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import manifestJson from "../../manifest.json"; +import { activateWith } from "../../src/backend/index.js"; +import { CHECK_INTERVAL_MS, RETRY_AFTER_MS } from "../../src/backend/runner.js"; +import { renewalReason } from "../../src/backend/renewal.js"; + +const manifest = manifestJson as unknown as PluginManifest; +const DAY = 86_400_000; + +const CONFIGURED = { + autoRenew: true, + domain: "termix.example.com", + email: "admin@example.com", +}; + +function cert( + overrides: Partial = {}, +): PluginTlsCertificateInfo { + return { + subject: "CN=termix.example.com", + issuer: "CN=R11, O=Let's Encrypt", + names: ["termix.example.com"], + notBefore: new Date(Date.now() - 10 * DAY).toISOString(), + notAfter: new Date(Date.now() + 60 * DAY).toISOString(), + selfSigned: false, + fingerprint: "AA", + ...overrides, + }; +} + +const issue = vi.fn(async () => ({ + certificate: "-----BEGIN CERTIFICATE-----new", + privateKey: "-----BEGIN PRIVATE KEY-----new", +})); + +let servers: http.Server[] = []; + +afterEach(async () => { + issue.mockClear(); + await Promise.all( + servers.map( + (s) => new Promise((resolve) => s.close(() => resolve())), + ), + ); + servers = []; +}); + +async function setup( + options: { + settings?: Record; + certificate?: PluginTlsCertificateInfo | null; + permissions?: string[]; + capabilities?: string[]; + validateTls?: () => PluginTlsCertificateInfo; + } = {}, +) { + let router: Router | null = null; + const tlsStatus: Omit = { + enabled: true, + certificate: options.certificate === undefined ? null : options.certificate, + }; + const mock: MockPluginContext = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities: options.capabilities ?? manifest.capabilities, + router: () => (router = express.Router()), + settings: options.settings, + permissions: options.permissions ?? ["manage"], + tlsStatus, + validateTls: options.validateTls, + }); + const runner = await activateWith(mock.ctx, issue); + + const app = express(); + app.use((req, res, next) => router!(req, res, next)); + const server = http.createServer(app); + servers.push(server); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + const call = async (method: string, path: string) => { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + }); + return { status: response.status, body: await response.json() }; + }; + return { mock, runner, call }; +} + +describe("renewalReason", () => { + const status = (certificate: PluginTlsCertificateInfo | null) => ({ + enabled: true, + certificate, + renewal: null, + }); + + it("renews when missing, self-signed, for another domain or expiring", () => { + expect(renewalReason(status(null), "termix.example.com")).toBe("missing"); + expect( + renewalReason(status(cert({ selfSigned: true })), "termix.example.com"), + ).toBe("self-signed"); + expect(renewalReason(status(cert()), "other.example.com")).toBe("domain"); + expect( + renewalReason( + status( + cert({ notAfter: new Date(Date.now() + 10 * DAY).toISOString() }), + ), + "termix.example.com", + ), + ).toBe("expiring"); + expect(renewalReason(status(cert()), "TERMIX.example.com")).toBeNull(); + }); +}); + +describe("scheduled renewal", () => { + it("checks on a 12 hour timer and renews an expiring certificate", async () => { + const { mock } = await setup({ + settings: CONFIGURED, + certificate: cert({ + notAfter: new Date(Date.now() + 5 * DAY).toISOString(), + }), + }); + const job = mock.scheduled.find((entry) => entry.kind === "every"); + expect(job?.ms).toBe(CHECK_INTERVAL_MS); + + await mock.runScheduled(); + + expect(issue).toHaveBeenCalledOnce(); + expect(mock.tls.writes).toEqual([ + { + certificatePem: "-----BEGIN CERTIFICATE-----new", + privateKeyPem: "-----BEGIN PRIVATE KEY-----new", + }, + ]); + const write = mock.tls.calls.indexOf("writeTlsCertificate"); + expect(mock.tls.calls.indexOf("reloadTls")).toBeGreaterThan(write); + expect(mock.tls.reloads).toBe(1); + }); + + it("leaves a valid certificate alone", async () => { + const { mock } = await setup({ settings: CONFIGURED, certificate: cert() }); + await mock.runScheduled(); + expect(issue).not.toHaveBeenCalled(); + expect(mock.tls.writes).toEqual([]); + }); + + it("does nothing and registers no renewer while renewal is off", async () => { + const { mock } = await setup({ + settings: { ...CONFIGURED, autoRenew: false }, + }); + await mock.runScheduled(); + expect(issue).not.toHaveBeenCalled(); + expect(mock.tls.renewers).toBe(0); + }); + + it("registers as the renewer when configured and drops it when turned off", async () => { + const { mock } = await setup({ settings: CONFIGURED, certificate: cert() }); + expect(mock.tls.renewers).toBe(1); + expect((await mock.ctx.system.tlsStatus()).renewal?.pluginId).toBe( + "acme-ssl", + ); + + await mock.ctx.settings.set("autoRenew", false); + await vi.waitFor(() => expect(mock.tls.renewers).toBe(0)); + }); + + it("waits before retrying after a failed attempt", async () => { + const { mock, runner } = await setup({ settings: CONFIGURED }); + issue.mockRejectedValueOnce(new Error("rate limited")); + expect(await runner.check()).toBe(false); + expect((await runner.state()).lastError).toBe("rate limited"); + + expect(await runner.check()).toBe(false); + expect(issue).toHaveBeenCalledOnce(); + + expect( + await runner.check(new Date(Date.now() + RETRY_AFTER_MS + 1000)), + ).toBe(true); + expect(issue).toHaveBeenCalledTimes(2); + expect(mock.tls.reloads).toBe(1); + }); + + it("does not reload when core rejects the certificate", async () => { + const { mock, runner } = await setup({ + settings: CONFIGURED, + validateTls: () => { + throw new Error("The certificate and private key do not match"); + }, + }); + await expect(runner.issue()).rejects.toThrow(/do not match/); + expect(mock.tls.reloads).toBe(0); + expect((await runner.state()).lastError).toMatch(/do not match/); + }); +}); + +describe("routes", () => { + it("reports status and issues on request", async () => { + const { call, mock } = await setup({ settings: CONFIGURED }); + const status = await call("GET", "/status"); + expect(status.status).toBe(200); + expect(status.body.missing).toBeNull(); + expect(status.body.tls.certificate).toBeNull(); + + const issued = await call("POST", "/request"); + expect(issued.status).toBe(200); + expect(issued.body.success).toBe(true); + expect(issued.body.tls.certificate).not.toBeNull(); + expect(mock.tls.reloads).toBe(1); + }); + + it("answers 400 when a setting is missing", async () => { + const { call } = await setup({ settings: { domain: "a.example" } }); + const result = await call("POST", "/request"); + expect(result.status).toBe(400); + expect(result.body.missing).toBe("email"); + expect(issue).not.toHaveBeenCalled(); + }); + + it("refuses a user without acme-ssl.manage on every route", async () => { + const { call } = await setup({ settings: CONFIGURED, permissions: [] }); + expect((await call("GET", "/status")).status).toBe(403); + expect((await call("POST", "/request")).status).toBe(403); + expect(issue).not.toHaveBeenCalled(); + }); +}); + +describe("capabilities", () => { + it("fails closed without system:tls", async () => { + await expect( + setup({ + settings: CONFIGURED, + capabilities: manifest.capabilities.filter( + (capability) => capability !== "system:tls", + ), + }), + ).rejects.toMatchObject({ + capability: "system:tls", + } satisfies Partial); + }); +}); diff --git a/plugins/acme-ssl/tests/backend/transport.test.ts b/plugins/acme-ssl/tests/backend/transport.test.ts new file mode 100644 index 000000000..edcbe9623 --- /dev/null +++ b/plugins/acme-ssl/tests/backend/transport.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginFetchInit } from "@termix/plugin-sdk/backend"; +import { createFetchAdapter } from "../../src/backend/acme.js"; +import { createTxtRecord } from "../../src/backend/cloudflare.js"; + +describe("createFetchAdapter", () => { + it("sends acme-client requests through ctx.fetch", async () => { + const fetch = vi.fn( + async (_url: string, _init?: PluginFetchInit) => + new Response('{"ok":true}', { + status: 201, + headers: { "replay-nonce": "n1", location: "https://ca/acct/1" }, + }), + ); + const adapter = createFetchAdapter(fetch, ["ca.internal"]); + const result = await adapter({ + url: "https://ca.internal/new-acct", + method: "post", + headers: { "Content-Type": "application/jose+json" }, + data: '{"payload":"x"}', + validateStatus: null, + }); + + expect(fetch).toHaveBeenCalledWith( + "https://ca.internal/new-acct", + expect.objectContaining({ + method: "POST", + body: '{"payload":"x"}', + allowPrivateHosts: ["ca.internal"], + headers: { "Content-Type": "application/jose+json" }, + }), + ); + expect(result.status).toBe(201); + expect(result.data).toBe('{"ok":true}'); + expect(result.headers["replay-nonce"]).toBe("n1"); + }); + + it("rejects with the response attached when validateStatus says no", async () => { + const adapter = createFetchAdapter( + async () => new Response("slow down", { status: 429 }), + ); + await expect( + adapter({ url: "https://ca/x", validateStatus: () => false }), + ).rejects.toMatchObject({ + code: "ERR_BAD_REQUEST", + response: { status: 429, data: "slow down" }, + }); + }); +}); + +describe("createTxtRecord", () => { + it("finds the zone by parent domain, creates the record and deletes it", async () => { + const calls: Array<{ url: string; method: string; body?: string }> = []; + const fetch = vi.fn(async (url: string, init?: PluginFetchInit) => { + calls.push({ url, method: init?.method ?? "GET", body: init?.body }); + if (url.includes("/zones?name=example.com")) { + return Response.json({ + success: true, + result: [{ id: "zone-1", name: "example.com" }], + }); + } + if (url.includes("/zones?name=")) { + return Response.json({ success: true, result: [] }); + } + if (init?.method === "POST") { + return Response.json({ success: true, result: { id: "rec-1" } }); + } + return Response.json({ success: true, result: {} }); + }); + + const remove = await createTxtRecord( + fetch, + "token", + "_acme-challenge.termix.example.com", + "digest", + ); + const post = calls.find((call) => call.method === "POST")!; + expect(post.url).toContain("/zones/zone-1/dns_records"); + expect(JSON.parse(post.body!)).toMatchObject({ + type: "TXT", + name: "_acme-challenge.termix.example.com", + content: "digest", + }); + + await remove(); + expect(calls.at(-1)).toMatchObject({ + method: "DELETE", + url: expect.stringContaining("/zones/zone-1/dns_records/rec-1"), + }); + }); + + it("surfaces the Cloudflare error message", async () => { + const fetch = async () => + Response.json( + { success: false, errors: [{ message: "Invalid token" }] }, + { status: 403 }, + ); + await expect( + createTxtRecord(fetch, "bad", "_acme-challenge.a.example", "d"), + ).rejects.toThrow(/Invalid token/); + }); +}); diff --git a/plugins/acme-ssl/tests/frontend/activate.test.tsx b/plugins/acme-ssl/tests/frontend/activate.test.tsx new file mode 100644 index 000000000..1c71c3c34 --- /dev/null +++ b/plugins/acme-ssl/tests/frontend/activate.test.tsx @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { + renderWithApp, + type RenderedPluginApp, +} from "@termix/plugin-sdk/testing"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import * as plugin from "../../src/frontend/index"; +import manifestJson from "../../manifest.json"; +import locales from "../../locales/en.json"; + +const api = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); + +vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + t: (key: string, values?: Record) => + values ? `${key} ${Object.values(values).join(" ")}` : key, + }), + usePluginApi: () => api, +})); + +const { AcmeStatusSetting } = + await import("../../src/frontend/AcmeStatusSetting"); + +const manifest = manifestJson as unknown as PluginManifest; + +let rendered: RenderedPluginApp | null = null; + +afterEach(async () => { + await rendered?.deactivate(); + rendered = null; + api.get.mockReset(); +}); + +describe("acme-ssl activate", () => { + it("registers the status settings component and removes it on deactivate", async () => { + rendered = await renderWithApp(plugin, { manifest, locales }); + expect(rendered.registered.settingsComponents()).toEqual(["status"]); + await rendered.deactivate(); + expect(rendered.registered.settingsComponents()).toEqual([]); + rendered = null; + }); +}); + +describe("AcmeStatusSetting", () => { + it("shows the served certificate and the last error", async () => { + api.get.mockResolvedValue({ + data: { + tls: { + enabled: true, + certificate: { + issuer: "CN=R11", + names: ["termix.example.com"], + notAfter: "2030-01-01T00:00:00.000Z", + selfSigned: false, + }, + }, + state: { + lastAttemptAt: null, + lastIssuedAt: null, + lastError: "rate limited", + }, + autoRenew: true, + missing: null, + }, + }); + render( + {}} + running + />, + ); + await waitFor(() => + expect(screen.getByText("status.names termix.example.com")).toBeTruthy(), + ); + expect(screen.getByText("status.lastError rate limited")).toBeTruthy(); + expect(api.get).toHaveBeenCalledWith("/status"); + }); +}); diff --git a/plugins/acme-ssl/tsconfig.json b/plugins/acme-ssl/tsconfig.json new file mode 100644 index 000000000..7b35314c3 --- /dev/null +++ b/plugins/acme-ssl/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@termix/plugin-sdk/tsconfig.plugin.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] +} diff --git a/plugins/acme-ssl/vitest.config.ts b/plugins/acme-ssl/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/acme-ssl/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/plugins/termix-identity/tests/frontend/activate.test.tsx b/plugins/termix-identity/tests/frontend/activate.test.tsx index 78201ce1c..c31772e78 100644 --- a/plugins/termix-identity/tests/frontend/activate.test.tsx +++ b/plugins/termix-identity/tests/frontend/activate.test.tsx @@ -14,12 +14,12 @@ const manifest = manifestJson as unknown as PluginManifest; let rendered: RenderedPluginApp | null = null; -type ElectronWindow = Window & { electronAPI?: unknown }; +type ElectronWindow = { electronAPI?: unknown }; afterEach(async () => { await rendered?.deactivate(); rendered = null; - delete (window as ElectronWindow).electronAPI; + delete (window as unknown as ElectronWindow).electronAPI; }); const IDENTITY = { @@ -95,7 +95,7 @@ describe(`${manifest.id} activate`, () => { }); it("hides the rail item in a standalone desktop app", async () => { - (window as ElectronWindow).electronAPI = { + (window as unknown as ElectronWindow).electronAPI = { isElectron: true, invoke: vi.fn(async () => null), onRemoteSyncStatusChanged: () => () => {}, @@ -111,7 +111,7 @@ describe(`${manifest.id} activate`, () => { it("shows the rail item once the desktop app has a remote server", async () => { let notify = () => {}; let serverUrl: string | null = null; - (window as ElectronWindow).electronAPI = { + (window as unknown as ElectronWindow).electronAPI = { isElectron: true, invoke: vi.fn(async () => (serverUrl ? { serverUrl } : null)), onRemoteSyncStatusChanged: (callback: () => void) => { diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 0374a422e..4dff1cfd0 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -41,7 +41,8 @@ import { DataCrypto } from "../utils/data-crypto.js"; import { DatabaseFileEncryption } from "../utils/database-file-encryption.js"; import { DatabaseMigration } from "../utils/database-migration.js"; import { UserDataExport } from "../utils/user-data-export.js"; -import { AutoSSLSetup } from "../utils/auto-ssl-setup.js"; +import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js"; +import { acmeChallengeHandler } from "../tls/acme-challenges.js"; import { createCurrentCredentialRepository, createCurrentDismissedAlertRepository, @@ -302,6 +303,28 @@ app.get("/health", (req, res) => { res.json({ status: "ok" }); }); +/** + * @openapi + * /.well-known/acme-challenge/{token}: + * get: + * summary: Answer an ACME http-01 challenge + * description: Public. Serves the key authorization a plugin published through ctx.system.publishHttpChallenge while it proves control of the domain. + * tags: + * - General + * parameters: + * - in: path + * name: token + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The key authorization, as text/plain. + * 404: + * description: No challenge is published for this token. + */ +app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler); + /** * @openapi * /version: @@ -2089,7 +2112,7 @@ export const serverReady = new Promise((resolve) => { }); }); -const sslConfig = AutoSSLSetup.getSSLConfig(); +const sslConfig = getTlsConfig(); if (sslConfig.enabled) { databaseLogger.info(`SSL is enabled`, { operation: "ssl_info", @@ -2098,59 +2121,39 @@ if (sslConfig.enabled) { }); } -if ( - sslConfig.enabled && - process.env.TERMIX_SSL_TERMINATED_BY_NGINX !== "true" -) { - try { - const httpsServer = https.createServer( - { - cert: fs.readFileSync(sslConfig.certPath), - key: fs.readFileSync(sslConfig.keyPath), - }, - app, - ); +// Built through the TLS service so a new certificate can be swapped in, or +// HTTPS started, without a restart. +void configureDirectHttps((options) => { + const port = getTlsConfig().port; + const httpsServer = https.createServer(options, app); - attachPluginWebSockets(httpsServer); + attachPluginWebSockets(httpsServer); - httpsServer.on("error", (err: NodeJS.ErrnoException) => { - if (err.code === "EADDRINUSE") { - databaseLogger.error( - `SSL port ${sslConfig.port} is already in use. Kill the existing process and retry.`, - err, - { - operation: "https_server_port_conflict", - port: sslConfig.port, - }, - ); - return; - } - databaseLogger.error("HTTPS server error", err, { - operation: "https_server_error", - }); - }); - - httpsServer.listen(sslConfig.port, "127.0.0.1", () => { - databaseLogger.success( - `Backend is now also listening for HTTPS directly`, + httpsServer.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + databaseLogger.error( + `SSL port ${port} is already in use. Kill the existing process and retry.`, + err, { - operation: "https_server_started", - port: sslConfig.port, + operation: "https_server_port_conflict", + port, }, ); + return; + } + databaseLogger.error("HTTPS server error", err, { + operation: "https_server_error", }); - } catch (error) { - databaseLogger.error( - "Failed to start HTTPS server with configured SSL certificate", - error, - { - operation: "https_server_start_failed", - cert_path: sslConfig.certPath, - key_path: sslConfig.keyPath, - }, - ); - } -} + }); + + httpsServer.listen(port, "127.0.0.1", () => { + databaseLogger.success(`Backend is now also listening for HTTPS directly`, { + operation: "https_server_started", + port, + }); + }); + return httpsServer; +}); /** * Status check columns from an export row. Exports from before 2.9.0 only diff --git a/src/backend/database/routes/acme-ssl-routes.ts b/src/backend/database/routes/acme-ssl-routes.ts deleted file mode 100644 index 431af8ccc..000000000 --- a/src/backend/database/routes/acme-ssl-routes.ts +++ /dev/null @@ -1,586 +0,0 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import { execFileSync } from "child_process"; -import { promises as fs } from "fs"; -import path from "path"; -import type { AuthenticatedRequest } from "../../../types/index.js"; -import type { RequestHandler, Router } from "express"; -import { authLogger } from "../../utils/logger.js"; -import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; -import { reloadNginxWithSSL } from "../../utils/nginx-ssl-reload.js"; -import { - createCurrentSettingsRepository, - createCurrentUserRepository, -} from "../repositories/factory.js"; -import type { UserRecord } from "../repositories/user-repository.js"; - -const DATA_DIR = process.env.DATA_DIR || "./db/data"; -const SSL_DIR = path.join(DATA_DIR, "ssl"); -const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot"); -const CERTBOT_DIR = path.join(DATA_DIR, "certbot"); -const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config"); -const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work"); -const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs"); -const CLOUDFLARE_CREDENTIALS_FILE = path.join( - DATA_DIR, - "ssl", - "cloudflare.ini", -); - -export type AcmeSettings = { - enabled: boolean; - domain: string; - email: string; - challengeType: "http-webroot" | "dns-cloudflare" | "manual"; - cloudflareToken: string; - lastIssuedAt: string | null; - certStatus: "none" | "valid" | "expiring" | "expired"; - certExpiresAt: string | null; -}; - -async function getAdminActor( - userId: string | undefined, -): Promise { - if (!userId) return null; - const user = await createCurrentUserRepository().findById(userId); - return user?.isAdmin ? user : null; -} - -function getCertInfo(): { - status: "none" | "valid" | "expiring" | "expired"; - expiresAt: string | null; -} { - const certFile = path.join(SSL_DIR, "termix.crt"); - try { - execFileSync("openssl", ["x509", "-in", certFile, "-noout"], { - stdio: "pipe", - }); - } catch { - return { status: "none", expiresAt: null }; - } - - try { - const endDateRaw = execFileSync( - "openssl", - ["x509", "-in", certFile, "-noout", "-enddate"], - { stdio: "pipe" }, - ) - .toString() - .trim() - .replace("notAfter=", ""); - const expiresAt = new Date(endDateRaw).toISOString(); - - try { - execFileSync( - "openssl", - ["x509", "-in", certFile, "-checkend", "0", "-noout"], - { - stdio: "pipe", - }, - ); - } catch { - return { status: "expired", expiresAt }; - } - - try { - execFileSync( - "openssl", - ["x509", "-in", certFile, "-checkend", "2592000", "-noout"], - { - stdio: "pipe", - }, - ); - return { status: "valid", expiresAt }; - } catch { - return { status: "expiring", expiresAt }; - } - } catch { - return { status: "none", expiresAt: null }; - } -} - -async function getAcmeSettings(): Promise { - const { status, expiresAt } = getCertInfo(); - const value = - await createCurrentSettingsRepository().get("acme_ssl_settings"); - const stored = value ? JSON.parse(value) : {}; - - return { - enabled: stored.enabled ?? false, - domain: stored.domain ?? "", - email: stored.email ?? "", - challengeType: stored.challengeType ?? "http-webroot", - cloudflareToken: stored.cloudflareToken - ? `${stored.cloudflareToken.slice(0, 4)}${"*".repeat(Math.max(0, stored.cloudflareToken.length - 4))}` - : "", - lastIssuedAt: stored.lastIssuedAt ?? null, - certStatus: status, - certExpiresAt: expiresAt, - }; -} - -export function registerAcmeSSLRoutes( - router: Router, - authenticateJWT: RequestHandler, -): void { - /** - * @openapi - * /users/acme-ssl-settings: - * get: - * summary: Get ACME SSL settings - * description: Returns current ACME/Let's Encrypt configuration and certificate status. - * tags: - * - Users - * responses: - * 200: - * description: ACME SSL settings and certificate status. - * 500: - * description: Failed to get ACME SSL settings. - */ - router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => { - try { - res.json(await getAcmeSettings()); - } catch (err) { - authLogger.error("Failed to get ACME SSL settings", err); - res.status(500).json({ error: "Failed to get ACME SSL settings" }); - } - }); - - /** - * @openapi - * /users/acme-ssl-settings: - * patch: - * summary: Update ACME SSL settings (admin only) - * description: Saves ACME/Let's Encrypt configuration. - * tags: - * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * enabled: - * type: boolean - * domain: - * type: string - * email: - * type: string - * challengeType: - * type: string - * enum: [http-webroot, dns-cloudflare, manual] - * cloudflareToken: - * type: string - * responses: - * 200: - * description: ACME SSL settings updated. - * 403: - * description: Not authorized. - * 500: - * description: Failed to update ACME SSL settings. - */ - router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - try { - const actor = await getAdminActor(userId); - if (!actor) { - return res.status(403).json({ error: "Not authorized" }); - } - - const settingsRepository = createCurrentSettingsRepository(); - const existing = await settingsRepository.get("acme_ssl_settings"); - const current = existing ? JSON.parse(existing) : {}; - - const { enabled, domain, email, challengeType, cloudflareToken } = - req.body; - - const updated = { - ...current, - ...(typeof enabled === "boolean" && { enabled }), - ...(typeof domain === "string" && { domain }), - ...(typeof email === "string" && { email }), - ...(typeof challengeType === "string" && { challengeType }), - ...(typeof cloudflareToken === "string" && - cloudflareToken && - !cloudflareToken.includes("*") && { cloudflareToken }), - }; - - await settingsRepository.set( - "acme_ssl_settings", - JSON.stringify(updated), - ); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: actor.username ?? userId, - action: "update_acme_ssl_settings", - resourceType: "setting", - details: JSON.stringify({ - enabled, - domain, - email, - challengeType, - hasCloudflareToken: !!updated.cloudflareToken, - }), - ipAddress, - userAgent, - success: true, - }); - - res.json(await getAcmeSettings()); - } catch (err) { - authLogger.error("Failed to update ACME SSL settings", err); - res.status(500).json({ error: "Failed to update ACME SSL settings" }); - } - }); - - /** - * @openapi - * /users/acme-ssl-request: - * post: - * summary: Request or renew Let's Encrypt certificate (admin only) - * description: Triggers certbot to issue or renew a certificate using the configured challenge method. - * tags: - * - Users - * responses: - * 200: - * description: Certificate issued or renewed successfully. - * 400: - * description: Invalid configuration. - * 403: - * description: Not authorized. - * 500: - * description: Certificate issuance failed. - */ - router.post("/acme-ssl-request", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - const actor = await getAdminActor(userId); - try { - if (!actor) { - return res.status(403).json({ error: "Not authorized" }); - } - - const settingsValue = - await createCurrentSettingsRepository().get("acme_ssl_settings"); - - if (!settingsValue) { - return res.status(400).json({ error: "ACME settings not configured" }); - } - - const settings = JSON.parse(settingsValue); - const { domain, email, challengeType, cloudflareToken } = settings; - - if (!domain || !email) { - return res.status(400).json({ error: "Domain and email are required" }); - } - - try { - execFileSync("certbot", ["--version"], { stdio: "pipe" }); - } catch { - return res - .status(500) - .json({ error: "certbot is not available in this environment" }); - } - - await fs.mkdir(SSL_DIR, { recursive: true }); - await fs.mkdir(ACME_WEBROOT, { recursive: true }); - await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true }); - await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true }); - await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true }); - - const certbotDirArgs = [ - "--config-dir", - CERTBOT_CONFIG_DIR, - "--work-dir", - CERTBOT_WORK_DIR, - "--logs-dir", - CERTBOT_LOGS_DIR, - ]; - - let certbotArgs: string[]; - - if (challengeType === "dns-cloudflare") { - if (!cloudflareToken) { - return res.status(400).json({ - error: "Cloudflare API token is required for DNS challenge", - }); - } - - await fs.mkdir(path.dirname(CLOUDFLARE_CREDENTIALS_FILE), { - recursive: true, - }); - await fs.writeFile( - CLOUDFLARE_CREDENTIALS_FILE, - `dns_cloudflare_api_token = ${cloudflareToken}\n`, - { mode: 0o600 }, - ); - - certbotArgs = [ - "certonly", - "--non-interactive", - "--agree-tos", - "--dns-cloudflare", - "--dns-cloudflare-credentials", - CLOUDFLARE_CREDENTIALS_FILE, - "--dns-cloudflare-propagation-seconds", - "30", - "-d", - domain, - "--email", - email, - "--cert-name", - "termix", - ...certbotDirArgs, - ]; - } else { - certbotArgs = [ - "certonly", - "--non-interactive", - "--agree-tos", - "--webroot", - "-w", - ACME_WEBROOT, - "-d", - domain, - "--email", - email, - "--cert-name", - "termix", - ...certbotDirArgs, - ]; - } - - authLogger.info("Requesting Let's Encrypt certificate", { - domain, - challengeType, - operation: "acme_cert_request", - }); - - execFileSync("certbot", certbotArgs, { - stdio: "pipe", - timeout: 120000, - }); - - const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix"); - const fullchainSrc = path.join(liveDir, "fullchain.pem"); - const privkeySrc = path.join(liveDir, "privkey.pem"); - const certDest = path.join(SSL_DIR, "termix.crt"); - const keyDest = path.join(SSL_DIR, "termix.key"); - - await fs.copyFile(fullchainSrc, certDest); - await fs.copyFile(privkeySrc, keyDest); - await fs.chmod(keyDest, 0o600); - await fs.chmod(certDest, 0o644); - - const updated = { ...settings, lastIssuedAt: new Date().toISOString() }; - await createCurrentSettingsRepository().set( - "acme_ssl_settings", - JSON.stringify(updated), - ); - - authLogger.info("Let's Encrypt certificate issued and installed", { - domain, - operation: "acme_cert_installed", - }); - - const reload = reloadNginxWithSSL(); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: actor.username ?? userId, - action: "acme_ssl_request", - resourceType: "setting", - details: JSON.stringify({ domain, challengeType, success: true }), - ipAddress, - userAgent, - success: true, - }); - - res.json({ - success: true, - reloadMessage: reload.message, - ...(await getAcmeSettings()), - }); - } catch (err) { - const message = getErrorMessage(err); - authLogger.error("ACME certificate request failed", err); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: actor?.username ?? userId, - action: "acme_ssl_request", - resourceType: "setting", - details: JSON.stringify({ error: message }), - ipAddress, - userAgent, - success: false, - }); - - res.status(500).json({ error: `Certificate request failed: ${message}` }); - } - }); - - /** - * @openapi - * /users/manual-ssl-upload: - * post: - * summary: Upload a manual/custom SSL certificate and key (admin only) - * description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate. - * tags: - * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * certificate: - * type: string - * privateKey: - * type: string - * responses: - * 200: - * description: Certificate uploaded and installed successfully. - * 400: - * description: Invalid or missing certificate/key. - * 403: - * description: Not authorized. - * 500: - * description: Certificate installation failed. - */ - router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - const actor = await getAdminActor(userId); - try { - if (!actor) { - return res.status(403).json({ error: "Not authorized" }); - } - - const { certificate, privateKey } = req.body; - - if ( - typeof certificate !== "string" || - typeof privateKey !== "string" || - !certificate.includes("BEGIN CERTIFICATE") || - !privateKey.includes("PRIVATE KEY") - ) { - return res.status(400).json({ - error: "A valid PEM certificate and private key are required", - }); - } - - await fs.mkdir(SSL_DIR, { recursive: true }); - - const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp"); - const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp"); - - try { - await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 }); - await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 }); - - try { - execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], { - stdio: "pipe", - }); - execFileSync( - "openssl", - ["pkey", "-in", tmpKeyFile, "-noout", "-check"], - { stdio: "pipe" }, - ); - } catch { - return res.status(400).json({ - error: - "The provided certificate or private key is not valid PEM data", - }); - } - - const certPubkey = execFileSync( - "openssl", - ["x509", "-in", tmpCertFile, "-noout", "-pubkey"], - { stdio: "pipe" }, - ); - const keyPubkey = execFileSync( - "openssl", - ["pkey", "-in", tmpKeyFile, "-pubout"], - { stdio: "pipe" }, - ); - - if (!certPubkey.equals(keyPubkey)) { - return res - .status(400) - .json({ error: "The certificate and private key do not match" }); - } - - const certDest = path.join(SSL_DIR, "termix.crt"); - const keyDest = path.join(SSL_DIR, "termix.key"); - await fs.rename(tmpCertFile, certDest); - await fs.rename(tmpKeyFile, keyDest); - await fs.chmod(keyDest, 0o600); - await fs.chmod(certDest, 0o644); - } finally { - await fs.rm(tmpCertFile, { force: true }); - await fs.rm(tmpKeyFile, { force: true }); - } - - const settingsRepository = createCurrentSettingsRepository(); - const existing = await settingsRepository.get("acme_ssl_settings"); - const current = existing ? JSON.parse(existing) : {}; - const updated = { - ...current, - challengeType: "manual", - lastIssuedAt: new Date().toISOString(), - }; - await settingsRepository.set( - "acme_ssl_settings", - JSON.stringify(updated), - ); - - authLogger.info("Manual SSL certificate installed", { - operation: "manual_ssl_installed", - }); - - const reload = reloadNginxWithSSL(); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: actor.username ?? userId, - action: "manual_ssl_upload", - resourceType: "setting", - details: JSON.stringify({ success: true }), - ipAddress, - userAgent, - success: true, - }); - - res.json({ - success: true, - reloadMessage: reload.message, - ...(await getAcmeSettings()), - }); - } catch (err) { - const message = getErrorMessage(err); - authLogger.error("Manual SSL certificate upload failed", err); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: actor?.username ?? userId, - action: "manual_ssl_upload", - resourceType: "setting", - details: JSON.stringify({ error: message }), - ipAddress, - userAgent, - success: false, - }); - - res - .status(500) - .json({ error: `Certificate installation failed: ${message}` }); - } - }); -} diff --git a/src/backend/database/routes/tls-routes.ts b/src/backend/database/routes/tls-routes.ts new file mode 100644 index 000000000..e5e3fcaaa --- /dev/null +++ b/src/backend/database/routes/tls-routes.ts @@ -0,0 +1,128 @@ +import { getErrorMessage } from "../../utils/error-message.js"; +import type { AuthenticatedRequest } from "../../../types/index.js"; +import type { RequestHandler, Router } from "express"; +import { authLogger } from "../../utils/logger.js"; +import { logAudit, getRequestMeta } from "../../utils/audit-logger.js"; +import { createCurrentUserRepository } from "../repositories/factory.js"; +import type { UserRecord } from "../repositories/user-repository.js"; +import { TlsValidationError } from "../../tls/certificate.js"; +import { + getTlsStatus, + reloadTls, + writeTlsCertificate, +} from "../../tls/tls-service.js"; + +async function getAdminActor( + userId: string | undefined, +): Promise { + if (!userId) return null; + const user = await createCurrentUserRepository().findById(userId); + return user?.isAdmin ? user : null; +} + +export function registerTlsRoutes( + router: Router, + authenticateJWT: RequestHandler, +): void { + /** + * @openapi + * /users/tls-certificate: + * get: + * summary: Get the served TLS certificate (admin only) + * description: Returns whether HTTPS is on, the certificate core serves, and the plugin that renews it, if any. + * tags: + * - Users + * responses: + * 200: + * description: Certificate status. + * 403: + * description: Not authorized. + * 500: + * description: Failed to read the certificate. + */ + router.get("/tls-certificate", authenticateJWT, async (req, res) => { + try { + const actor = await getAdminActor((req as AuthenticatedRequest).userId); + if (!actor) { + return res.status(403).json({ error: "Not authorized" }); + } + res.json(await getTlsStatus()); + } catch (err) { + authLogger.error("Failed to read TLS certificate status", err); + res.status(500).json({ error: "Failed to read TLS certificate status" }); + } + }); + + /** + * @openapi + * /users/tls-certificate: + * post: + * summary: Upload a TLS certificate and key (admin only) + * description: Validates a PEM certificate chain and private key, installs them as the served certificate and reloads HTTPS without a restart. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * certificate: + * type: string + * privateKey: + * type: string + * responses: + * 200: + * description: Certificate installed. Carries the new status and a reload message. + * 400: + * description: Missing, unreadable, expired or mismatched certificate and key. + * 403: + * description: Not authorized. + * 500: + * description: Certificate installation failed. + */ + router.post("/tls-certificate", authenticateJWT, async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const actor = await getAdminActor(userId); + if (!actor) { + return res.status(403).json({ error: "Not authorized" }); + } + + const { ipAddress, userAgent } = getRequestMeta(req); + const audit = (success: boolean, details: object, errorMessage?: string) => + logAudit({ + userId, + username: actor.username ?? userId, + action: "tls_certificate_upload", + resourceType: "setting", + details: JSON.stringify(details), + ipAddress, + userAgent, + success, + errorMessage, + }); + + try { + const { certificate, privateKey } = req.body ?? {}; + const info = await writeTlsCertificate(certificate, privateKey); + const reload = await reloadTls(); + await audit(true, { subject: info.subject, notAfter: info.notAfter }); + res.json({ + success: true, + reloadMessage: reload.message, + ...(await getTlsStatus()), + }); + } catch (err) { + const message = getErrorMessage(err); + await audit(false, {}, message); + if (err instanceof TlsValidationError) { + return res.status(400).json({ error: message }); + } + authLogger.error("TLS certificate upload failed", err); + res + .status(500) + .json({ error: `Certificate installation failed: ${message}` }); + } + }); +} diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 34273ccae..c2e7786a6 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -18,7 +18,7 @@ import { PermissionManager } from "../../utils/permission-manager.js"; import { registerUserApiKeyRoutes } from "./user-api-key-routes.js"; import { registerBrandingRoutes } from "./branding-routes.js"; import { registerUserSettingsRoutes } from "./user-settings-routes.js"; -import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js"; +import { registerTlsRoutes } from "./tls-routes.js"; import { registerUserSessionRoutes } from "./user-session-routes.js"; import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js"; import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js"; @@ -1648,7 +1648,7 @@ registerUserOidcAccountRoutes(router, { }); registerUserSettingsRoutes(router, authenticateJWT); -registerAcmeSSLRoutes(router, authenticateJWT); +registerTlsRoutes(router, authenticateJWT); registerUserApiKeyRoutes(router, requireAdmin); registerBrandingRoutes(router, requireAdmin); diff --git a/src/backend/plugins/ctx-system.ts b/src/backend/plugins/ctx-system.ts new file mode 100644 index 000000000..b0360392d --- /dev/null +++ b/src/backend/plugins/ctx-system.ts @@ -0,0 +1,129 @@ +/** + * ctx.system: the server certificate. Core serves it; a plugin under + * system:tls may replace it, reload it, answer ACME http-01 challenges and + * say that it renews it. Every call is audited. + */ + +import type { PluginSystem } from "@termix/plugin-sdk/backend"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import { assertCapability } from "./permissions.js"; +import type { DisposableBag } from "./disposables.js"; + +type AuditFn = ( + action: string, + details: string, + outcome: { success: boolean; errorMessage?: string }, +) => Promise; + +interface Deps { + manifest: PluginManifest; + bag: DisposableBag; + audit: AuditFn; +} + +const CAPABILITY = "system:tls"; + +async function audited( + deps: Deps, + action: string, + details: string, + fn: () => Promise, +): Promise { + try { + await assertCapability( + deps.manifest.id, + CAPABILITY, + deps.manifest.capabilities, + ); + const result = await fn(); + await deps.audit(action, details, { success: true }); + return result; + } catch (error) { + await deps.audit(action, details, { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +/** + * Puts an undo in the plugin's bag and hands back one that also takes it out + * again, so it runs once whether the plugin or deactivate gets there first. + */ +function tracked(deps: Deps, undo: () => void, label: string): () => void { + let done = false; + const run = () => { + if (done) return; + done = true; + undo(); + }; + const untrack = deps.bag.add(run, label); + return () => { + untrack(); + run(); + }; +} + +export function createPluginSystem(deps: Deps): PluginSystem { + return { + tlsStatus: () => + audited(deps, "tls_status", "read certificate status", async () => { + const { getTlsStatus } = await import("../tls/tls-service.js"); + return getTlsStatus(); + }), + + writeTlsCertificate: (certificatePem, privateKeyPem) => + audited( + deps, + "tls_write", + "replaced the server certificate", + async () => { + const { writeTlsCertificate } = await import("../tls/tls-service.js"); + return writeTlsCertificate(certificatePem, privateKeyPem); + }, + ), + + reloadTls: () => + audited( + deps, + "tls_reload", + "reloaded the server certificate", + async () => { + const { reloadTls } = await import("../tls/tls-service.js"); + return reloadTls(); + }, + ), + + publishHttpChallenge: (token, content) => + audited( + deps, + "tls_challenge", + "published an ACME challenge", + async () => { + const { publishChallenge } = + await import("../tls/acme-challenges.js"); + return tracked( + deps, + publishChallenge(token, content), + "acme challenge", + ); + }, + ), + + registerTlsRenewer: () => + audited( + deps, + "tls_renewer", + "renews the server certificate", + async () => { + const { registerTlsRenewer } = await import("../tls/tls-service.js"); + const undo = registerTlsRenewer({ + pluginId: deps.manifest.id, + pluginName: deps.manifest.name, + }); + return tracked(deps, undo, "tls renewer"); + }, + ), + }; +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 83075079c..1ca8589ed 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -57,6 +57,7 @@ import { createPluginHosts } from "./ctx-hosts.js"; import { createPluginSchedule } from "./schedule.js"; import { createPluginFetch, createPluginNotify } from "./ctx-notify.js"; import { createPluginProcess } from "./ctx-process.js"; +import { createPluginSystem } from "./ctx-system.js"; import { createPluginCredentials } from "./ctx-credentials.js"; import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js"; @@ -828,6 +829,7 @@ export function createPluginContext( audit: auditCall, }), auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }), + system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }), desktop: { openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request), diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 68946de96..a715058a6 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -3,7 +3,7 @@ import dotenv from "dotenv"; import { promises as fs, readFileSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; -import { AutoSSLSetup } from "./utils/auto-ssl-setup.js"; +import { AutoSSLSetup } from "./tls/self-signed.js"; import { AuthManager } from "./utils/auth-manager.js"; import { DataCrypto } from "./utils/data-crypto.js"; import { ensureDatabaseLayerPreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js"; @@ -381,6 +381,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { await import("./utils/crypto-migration/step-ca-settings-migration.js"); await runStepCaSettingsMigration(); + const { runAcmeSslSettingsMigration } = + await import("./utils/crypto-migration/acme-ssl-settings-migration.js"); + await runAcmeSslSettingsMigration(); + const { runVaultSettingsMigration } = await import("./utils/crypto-migration/vault-settings-migration.js"); await runVaultSettingsMigration(); diff --git a/src/backend/tests/database/routes/tls-routes.test.ts b/src/backend/tests/database/routes/tls-routes.test.ts new file mode 100644 index 000000000..b0bd953a5 --- /dev/null +++ b/src/backend/tests/database/routes/tls-routes.test.ts @@ -0,0 +1,155 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import express, { type RequestHandler } from "express"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +const state = vi.hoisted(() => ({ + admins: new Set(["admin-1"]), + writes: [] as Array<[unknown, unknown]>, + reloads: 0, + audits: [] as Array<{ action: string; success: boolean }>, +})); + +vi.mock("../../../utils/logger.js", () => ({ + authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})); + +vi.mock("../../../utils/audit-logger.js", () => ({ + logAudit: async (entry: { action: string; success: boolean }) => { + state.audits.push({ action: entry.action, success: entry.success }); + }, + getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), +})); + +vi.mock("../../../database/repositories/factory.js", () => ({ + createCurrentUserRepository: () => ({ + findById: async (id: string) => ({ + id, + username: id, + isAdmin: state.admins.has(id), + }), + }), +})); + +vi.mock("../../../tls/tls-service.js", async () => { + const { TlsValidationError } = await import("../../../tls/certificate.js"); + return { + getTlsStatus: async () => ({ + enabled: true, + certificate: null, + renewal: null, + }), + writeTlsCertificate: async (cert: unknown, key: unknown) => { + if (key === "mismatch") { + throw new TlsValidationError( + "The certificate and private key do not match", + ); + } + state.writes.push([cert, key]); + return { subject: "CN=x", notAfter: "2030-01-01T00:00:00.000Z" }; + }, + reloadTls: async () => { + state.reloads++; + return { applied: true, message: "reloaded" }; + }, + }; +}); + +const { registerTlsRoutes } = + await import("../../../database/routes/tls-routes.js"); + +const authenticate: RequestHandler = (req, _res, next) => { + (req as unknown as { userId?: string }).userId = + req.header("x-user") ?? undefined; + next(); +}; + +let server: http.Server; +let base: string; + +beforeAll(async () => { + const router = express.Router(); + registerTlsRoutes(router, authenticate); + const app = express(); + app.use(express.json()); + app.use("/users", router); + server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +beforeEach(() => { + state.writes = []; + state.reloads = 0; + state.audits = []; +}); + +const upload = (user: string, body: unknown) => + fetch(`${base}/users/tls-certificate`, { + method: "POST", + headers: { "content-type": "application/json", "x-user": user }, + body: JSON.stringify(body), + }); + +describe("TLS certificate routes", () => { + it("returns the status to an admin only", async () => { + const ok = await fetch(`${base}/users/tls-certificate`, { + headers: { "x-user": "admin-1" }, + }); + expect(ok.status).toBe(200); + expect(await ok.json()).toMatchObject({ enabled: true, renewal: null }); + + const denied = await fetch(`${base}/users/tls-certificate`, { + headers: { "x-user": "user-1" }, + }); + expect(denied.status).toBe(403); + }); + + it("installs an uploaded pair and reloads", async () => { + const response = await upload("admin-1", { + certificate: "CERT", + privateKey: "KEY", + }); + expect(response.status).toBe(200); + expect((await response.json()).reloadMessage).toBe("reloaded"); + expect(state.writes).toEqual([["CERT", "KEY"]]); + expect(state.reloads).toBe(1); + expect(state.audits).toEqual([ + { action: "tls_certificate_upload", success: true }, + ]); + }); + + it("answers 400 for a mismatched pair without reloading", async () => { + const response = await upload("admin-1", { + certificate: "CERT", + privateKey: "mismatch", + }); + expect(response.status).toBe(400); + expect((await response.json()).error).toMatch(/do not match/); + expect(state.reloads).toBe(0); + expect(state.audits).toEqual([ + { action: "tls_certificate_upload", success: false }, + ]); + }); + + it("refuses a non-admin upload", async () => { + const response = await upload("user-1", { + certificate: "CERT", + privateKey: "KEY", + }); + expect(response.status).toBe(403); + expect(state.writes).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/ctx-system.test.ts b/src/backend/tests/plugins/ctx-system.test.ts new file mode 100644 index 000000000..bca0f91c9 --- /dev/null +++ b/src/backend/tests/plugins/ctx-system.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ + granted: new Set(), + writes: [] as Array<[string, string]>, + reloads: 0, + renewers: [] as Array<{ pluginId: string; pluginName: string }>, + challenges: new Map(), +})); + +vi.mock("../../plugins/permissions.js", async () => { + const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); + return { + assertCapability: async ( + pluginId: string, + capability: string, + declared: readonly string[], + ) => { + if (!declared.includes(capability) || !h.granted.has(capability)) { + throw new PluginCapabilityError(pluginId, capability); + } + }, + }; +}); + +vi.mock("../../tls/tls-service.js", () => ({ + getTlsStatus: async () => ({ + enabled: true, + certificate: null, + renewal: h.renewers[0] ?? null, + }), + writeTlsCertificate: async (cert: string, key: string) => { + if (key === "wrong") + throw new Error("The certificate and private key do not match"); + h.writes.push([cert, key]); + return { subject: "CN=x" }; + }, + reloadTls: async () => { + h.reloads++; + return { applied: true, message: "ok" }; + }, + registerTlsRenewer: (renewer: { pluginId: string; pluginName: string }) => { + h.renewers.push(renewer); + return () => { + h.renewers = h.renewers.filter((entry) => entry !== renewer); + }; + }, +})); + +vi.mock("../../tls/acme-challenges.js", () => ({ + publishChallenge: (token: string, content: string) => { + h.challenges.set(token, content); + return () => h.challenges.delete(token); + }, +})); + +const { createPluginSystem } = await import("../../plugins/ctx-system.js"); +const { DisposableBag } = await import("../../plugins/disposables.js"); +const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); + +let audits: Array<{ action: string; success: boolean }>; + +function create(capabilities = ["system:tls"]) { + const bag = new DisposableBag("acme-ssl"); + const system = createPluginSystem({ + manifest: { + id: "acme-ssl", + name: "ACME Certificates", + version: "1.0.0", + capabilities, + } as never, + bag, + audit: async (action, _details, outcome) => { + audits.push({ action, success: outcome.success }); + }, + }); + return { system, bag }; +} + +beforeEach(() => { + h.granted = new Set(["system:tls"]); + h.writes = []; + h.reloads = 0; + h.renewers = []; + h.challenges = new Map(); + audits = []; +}); + +describe("ctx.system", () => { + it("writes, reloads and audits every call", async () => { + const { system } = create(); + await system.writeTlsCertificate("CERT", "KEY"); + await system.reloadTls(); + expect(h.writes).toEqual([["CERT", "KEY"]]); + expect(h.reloads).toBe(1); + expect(audits).toEqual([ + { action: "tls_write", success: true }, + { action: "tls_reload", success: true }, + ]); + }); + + it("passes a rejected pair back to the plugin and audits the failure", async () => { + const { system } = create(); + await expect(system.writeTlsCertificate("CERT", "wrong")).rejects.toThrow( + /do not match/, + ); + expect(audits).toEqual([{ action: "tls_write", success: false }]); + }); + + it("refuses without system:tls, declared or granted", async () => { + await expect(create([]).system.reloadTls()).rejects.toBeInstanceOf( + PluginCapabilityError, + ); + h.granted.clear(); + await expect(create().system.tlsStatus()).rejects.toBeInstanceOf( + PluginCapabilityError, + ); + expect(h.reloads).toBe(0); + expect(audits.every((entry) => !entry.success)).toBe(true); + }); + + it("drops the renewer and challenges when the plugin is disabled", async () => { + const { system, bag } = create(); + await system.registerTlsRenewer(); + await system.publishHttpChallenge("tok", "tok.key"); + expect((await system.tlsStatus()).renewal?.pluginId).toBe("acme-ssl"); + expect(h.challenges.get("tok")).toBe("tok.key"); + + await bag.disposeAll(); + expect(h.renewers).toEqual([]); + expect(h.challenges.size).toBe(0); + }); + + it("lets the plugin withdraw a challenge itself, once", async () => { + const { system, bag } = create(); + const remove = await system.publishHttpChallenge("tok", "tok.key"); + remove(); + remove(); + expect(h.challenges.size).toBe(0); + expect(bag.size).toBe(0); + }); +}); diff --git a/src/backend/tests/tls/certificate.test.ts b/src/backend/tests/tls/certificate.test.ts new file mode 100644 index 000000000..c03a8a0e5 --- /dev/null +++ b/src/backend/tests/tls/certificate.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { + TlsValidationError, + readCertificateInfo, + validateCertificatePair, +} from "../../tls/certificate.js"; +import { caIssuedPair, selfSignedPair } from "./test-certs.js"; + +describe("validateCertificatePair", () => { + it("accepts a matching pair and describes the leaf", async () => { + const pair = await caIssuedPair("termix.example.com"); + const info = validateCertificatePair(pair.certificate, pair.privateKey); + expect(info.names).toEqual(["termix.example.com"]); + expect(info.selfSigned).toBe(false); + expect(info.issuer).toContain("Test CA"); + expect(new Date(info.notAfter).getTime()).toBeGreaterThan(Date.now()); + }); + + it("rejects a key that belongs to another certificate", async () => { + const one = await caIssuedPair(); + const other = await caIssuedPair(); + expect(() => + validateCertificatePair(one.certificate, other.privateKey), + ).toThrow(/do not match/); + }); + + it("rejects garbage and an expired certificate", async () => { + const pair = await caIssuedPair(); + expect(() => validateCertificatePair("nope", pair.privateKey)).toThrow( + TlsValidationError, + ); + expect(() => + validateCertificatePair(pair.certificate, "not a key"), + ).toThrow(/private key/); + + const expired = await caIssuedPair("old.example", { daysLeft: -1 }); + expect(() => + validateCertificatePair(expired.certificate, expired.privateKey), + ).toThrow(/expired/); + }); + + it("reads the leaf of a full chain", async () => { + const leaf = await caIssuedPair("chain.example"); + const ca = await selfSignedPair("ca.example"); + const info = validateCertificatePair( + leaf.certificate + ca.certificate, + leaf.privateKey, + ); + expect(info.names).toEqual(["chain.example"]); + }); +}); + +describe("readCertificateInfo", () => { + it("marks core's own certificate as self-signed", async () => { + const pair = await selfSignedPair("localhost"); + expect(readCertificateInfo(pair.certificate)?.selfSigned).toBe(true); + expect(readCertificateInfo("")).toBeNull(); + }); +}); diff --git a/src/backend/tests/tls/test-certs.ts b/src/backend/tests/tls/test-certs.ts new file mode 100644 index 000000000..b2aa4121e --- /dev/null +++ b/src/backend/tests/tls/test-certs.ts @@ -0,0 +1,82 @@ +/** Throwaway certificates for the TLS tests, made in-process. */ + +import "reflect-metadata"; + +import { webcrypto } from "node:crypto"; +import * as x509 from "@peculiar/x509"; + +x509.cryptoProvider.set(webcrypto as unknown as Crypto); + +const ALG = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }; +const DAY = 86_400_000; + +export interface TestPair { + certificate: string; + privateKey: string; +} + +async function keys(): Promise { + return (await webcrypto.subtle.generateKey(ALG, true, [ + "sign", + "verify", + ])) as webcrypto.CryptoKeyPair; +} + +async function keyPem(key: webcrypto.CryptoKey): Promise { + const der = Buffer.from(await webcrypto.subtle.exportKey("pkcs8", key)); + const body = der + .toString("base64") + .match(/.{1,64}/g)! + .join("\n"); + return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`; +} + +function sans(names: string[]) { + return new x509.SubjectAlternativeNameExtension( + names.map((value) => ({ type: "dns" as const, value })), + ); +} + +export async function selfSignedPair( + name = "localhost", + options: { daysLeft?: number } = {}, +): Promise { + const pair = await keys(); + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + serialNumber: "01", + name: `CN=${name}`, + notBefore: new Date(Date.now() - DAY), + notAfter: new Date(Date.now() + (options.daysLeft ?? 365) * DAY), + keys: pair, + signingAlgorithm: ALG, + extensions: [sans([name])], + }); + return { + certificate: cert.toString("pem"), + privateKey: await keyPem(pair.privateKey), + }; +} + +/** A leaf signed by a throwaway CA, like one from Let's Encrypt. */ +export async function caIssuedPair( + name = "termix.example.com", + options: { daysLeft?: number } = {}, +): Promise { + const caKeys = await keys(); + const leafKeys = await keys(); + const cert = await x509.X509CertificateGenerator.create({ + serialNumber: "02", + subject: `CN=${name}`, + issuer: "CN=Test CA", + notBefore: new Date(Date.now() - 2 * DAY), + notAfter: new Date(Date.now() + (options.daysLeft ?? 90) * DAY), + signingKey: caKeys.privateKey, + publicKey: leafKeys.publicKey, + signingAlgorithm: ALG, + extensions: [sans([name])], + }); + return { + certificate: cert.toString("pem"), + privateKey: await keyPem(leafKeys.privateKey), + }; +} diff --git a/src/backend/tests/tls/tls-service.test.ts b/src/backend/tests/tls/tls-service.test.ts new file mode 100644 index 000000000..f164a0563 --- /dev/null +++ b/src/backend/tests/tls/tls-service.test.ts @@ -0,0 +1,199 @@ +/** + * Core keeps serving HTTPS on its own: a certificate on disk is served with + * no plugin renewing it, a validated replacement is swapped in without a + * restart, and nginx is reloaded instead when it terminates TLS. + */ + +import fs from "node:fs/promises"; +import https from "node:https"; +import os from "node:os"; +import path from "node:path"; +import tls from "node:tls"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { caIssuedPair, selfSignedPair } from "./test-certs.js"; + +const nginx = vi.hoisted(() => ({ + reload: vi.fn(() => ({ applied: true, message: "nginx reloaded" })), +})); + +vi.mock("../../utils/logger.js", () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }; + return { systemLogger: logger, authLogger: logger }; +}); + +vi.mock("../../tls/nginx-reload.js", () => ({ + reloadNginxWithSSL: nginx.reload, + persistSSLEnv: vi.fn(), +})); + +const service = await import("../../tls/tls-service.js"); +const challenges = await import("../../tls/acme-challenges.js"); +const { X509Certificate } = await import("node:crypto"); + +let dir: string; +let servers: https.Server[] = []; +const savedEnv = { ...process.env }; + +async function install(pair: { certificate: string; privateKey: string }) { + await fs.mkdir(path.join(dir, "ssl"), { recursive: true }); + await fs.writeFile(path.join(dir, "ssl", "termix.crt"), pair.certificate); + await fs.writeFile(path.join(dir, "ssl", "termix.key"), pair.privateKey); +} + +function fingerprint(pem: string): string { + return new X509Certificate(pem).fingerprint256; +} + +async function startServer(): Promise { + let resolvePort: (port: number) => void = () => {}; + const port = new Promise((resolve) => (resolvePort = resolve)); + await service.configureDirectHttps((options) => { + const server = https.createServer(options, (_req, res) => res.end("ok")); + servers.push(server); + server.listen(0, "127.0.0.1", () => + resolvePort((server.address() as AddressInfo).port), + ); + return server; + }); + return port; +} + +function servedFingerprint(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = tls.connect( + { host: "127.0.0.1", port, rejectUnauthorized: false }, + () => { + const cert = socket.getPeerCertificate(); + socket.end(); + resolve(cert.fingerprint256); + }, + ); + socket.on("error", reject); + }); +} + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "tls-service-")); + process.env.DATA_DIR = dir; + process.env.ENABLE_SSL = "true"; + delete process.env.SSL_CERT_PATH; + delete process.env.SSL_KEY_PATH; + delete process.env.TERMIX_SSL_TERMINATED_BY_NGINX; + service.resetTlsServiceForTests(); + challenges.clearChallengesForTests(); + nginx.reload.mockClear(); +}); + +afterEach(async () => { + await Promise.all( + servers.map( + (server) => new Promise((resolve) => server.close(() => resolve())), + ), + ); + servers = []; + process.env = { ...savedEnv }; + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe("serving with no renewer", () => { + it("serves the certificate on disk when no plugin renews it", async () => { + const pair = await caIssuedPair("termix.example.com"); + await install(pair); + + const port = await startServer(); + expect(await servedFingerprint(port)).toBe(fingerprint(pair.certificate)); + + const status = await service.getTlsStatus(); + expect(status.renewal).toBeNull(); + expect(status.certificate?.names).toEqual(["termix.example.com"]); + expect(status.certificate?.selfSigned).toBe(false); + }); + + it("reports the plugin that renews it until it goes away", async () => { + const undo = service.registerTlsRenewer({ + pluginId: "acme-ssl", + pluginName: "ACME Certificates", + }); + expect((await service.getTlsStatus()).renewal?.pluginId).toBe("acme-ssl"); + undo(); + undo(); + expect((await service.getTlsStatus()).renewal).toBeNull(); + }); +}); + +describe("writeTlsCertificate and reloadTls", () => { + it("swaps a new certificate in without a restart", async () => { + await install(await selfSignedPair("localhost")); + const port = await startServer(); + + const next = await caIssuedPair("termix.example.com"); + await service.writeTlsCertificate(next.certificate, next.privateKey); + const result = await service.reloadTls(); + + expect(result.applied).toBe(true); + expect(await servedFingerprint(port)).toBe(fingerprint(next.certificate)); + expect(nginx.reload).not.toHaveBeenCalled(); + }); + + it("refuses a mismatched key and leaves the files alone", async () => { + const current = await caIssuedPair("keep.example"); + await install(current); + const one = await caIssuedPair("new.example"); + const other = await caIssuedPair("new.example"); + + await expect( + service.writeTlsCertificate(one.certificate, other.privateKey), + ).rejects.toThrow(/do not match/); + expect(await fs.readFile(path.join(dir, "ssl", "termix.crt"), "utf8")).toBe( + current.certificate, + ); + }); + + it("reloads nginx when nginx terminates TLS", async () => { + process.env.TERMIX_SSL_TERMINATED_BY_NGINX = "true"; + const pair = await caIssuedPair(); + await service.writeTlsCertificate(pair.certificate, pair.privateKey); + const result = await service.reloadTls(); + expect(nginx.reload).toHaveBeenCalledOnce(); + expect(result.message).toBe("nginx reloaded"); + }); +}); + +describe("ACME http-01 challenges", () => { + it("answers a published token until it is withdrawn", () => { + const undo = challenges.publishChallenge("abc_DEF-1", "abc_DEF-1.thumb"); + expect(challenges.lookupChallenge("abc_DEF-1")).toBe("abc_DEF-1.thumb"); + + const res = { + statusCode: 0, + body: "", + status(code: number) { + this.statusCode = code; + return this; + }, + type() { + return this; + }, + send(body: string) { + this.body = body; + return this; + }, + }; + challenges.acmeChallengeHandler( + { params: { token: "abc_DEF-1" } } as never, + res as never, + ); + expect(res.statusCode).toBe(200); + expect(res.body).toBe("abc_DEF-1.thumb"); + + undo(); + expect(challenges.lookupChallenge("abc_DEF-1")).toBeNull(); + expect(() => challenges.publishChallenge("../etc", "x")).toThrow(); + }); +}); diff --git a/src/backend/tests/utils/acme-ssl-settings-migration.test.ts b/src/backend/tests/utils/acme-ssl-settings-migration.test.ts new file mode 100644 index 000000000..c12f3a47e --- /dev/null +++ b/src/backend/tests/utils/acme-ssl-settings-migration.test.ts @@ -0,0 +1,144 @@ +/** + * The ACME settings migration: a 2.8 acme_ssl_settings row comes through into + * the acme-ssl plugin's admin settings, the Cloudflare token stays encrypted + * and leaves the old row, and a second run changes nothing. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + legacy: {} as Record, + pluginRows: [] as Array<{ + key: string; + value: string | null; + encrypted: boolean; + }>, + pluginInstalled: true, +})); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => + state.pluginInstalled && id === "acme-ssl" ? { id } : null, + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => state.legacy[key] ?? null, + set: async (key: string, value: string) => { + state.legacy[key] = value; + }, + }), + createCurrentPluginSettingsRepository: () => ({ + get: async ( + _pluginId: string, + _scope: string, + _scopeId: string | null, + key: string, + ) => state.pluginRows.find((row) => row.key === key) ?? null, + set: async ( + _pluginId: string, + _scope: string, + _scopeId: string | null, + key: string, + value: string | null, + encrypted = false, + ) => { + state.pluginRows = state.pluginRows.filter((row) => row.key !== key); + state.pluginRows.push({ key, value, encrypted }); + }, + }), +})); + +vi.mock("../../utils/system-secret-crypto.js", () => ({ + encryptSystemSecret: async (value: string) => `enc:${value}`, +})); + +vi.mock("../../utils/logger.js", () => ({ + databaseLogger: { info: vi.fn(), warn: vi.fn() }, +})); + +import { runAcmeSslSettingsMigration } from "../../utils/crypto-migration/acme-ssl-settings-migration.js"; + +const value = (key: string) => { + const row = state.pluginRows.find((candidate) => candidate.key === key); + return row?.value == null ? undefined : JSON.parse(row.value); +}; + +beforeEach(() => { + state.legacy = { + acme_ssl_settings: JSON.stringify({ + enabled: true, + domain: "termix.example.com", + email: "admin@example.com", + challengeType: "dns-cloudflare", + cloudflareToken: "cf-token", + lastIssuedAt: "2026-01-01T00:00:00.000Z", + }), + }; + state.pluginRows = []; + state.pluginInstalled = true; +}); + +describe("runAcmeSslSettingsMigration", () => { + it("moves every field and encrypts the token", async () => { + const result = await runAcmeSslSettingsMigration(); + expect(result.moved.sort()).toEqual( + [ + "autoRenew", + "challengeType", + "cloudflareToken", + "domain", + "email", + ].sort(), + ); + expect(value("domain")).toBe("termix.example.com"); + expect(value("email")).toBe("admin@example.com"); + expect(value("challengeType")).toBe("dns-cloudflare"); + expect(value("autoRenew")).toBe(true); + expect(value("cloudflareToken")).toBe("enc:cf-token"); + expect( + state.pluginRows.find((row) => row.key === "cloudflareToken")?.encrypted, + ).toBe(true); + + const left = JSON.parse(state.legacy.acme_ssl_settings); + expect(left.cloudflareToken).toBeUndefined(); + expect(left.domain).toBe("termix.example.com"); + }); + + it("maps the webroot challenge and turns renewal off for manual", async () => { + state.legacy.acme_ssl_settings = JSON.stringify({ + enabled: true, + domain: "a.example", + email: "a@example.com", + challengeType: "http-webroot", + }); + await runAcmeSslSettingsMigration(); + expect(value("challengeType")).toBe("http-01"); + expect(value("autoRenew")).toBe(true); + + state.pluginRows = []; + state.legacy.acme_ssl_settings = JSON.stringify({ + enabled: true, + challengeType: "manual", + }); + await runAcmeSslSettingsMigration(); + expect(value("autoRenew")).toBe(false); + expect(value("challengeType")).toBeUndefined(); + }); + + it("changes nothing on a second run or over values already set", async () => { + await runAcmeSslSettingsMigration(); + const snapshot = JSON.stringify(state.pluginRows); + const second = await runAcmeSslSettingsMigration(); + expect(second.moved).toEqual([]); + expect(JSON.stringify(state.pluginRows)).toBe(snapshot); + }); + + it("does nothing before the plugin row exists or without a legacy row", async () => { + state.pluginInstalled = false; + expect((await runAcmeSslSettingsMigration()).moved).toEqual([]); + state.pluginInstalled = true; + state.legacy = {}; + expect((await runAcmeSslSettingsMigration()).moved).toEqual([]); + expect(state.pluginRows).toEqual([]); + }); +}); diff --git a/src/backend/tls/acme-challenges.ts b/src/backend/tls/acme-challenges.ts new file mode 100644 index 000000000..189d7af7a --- /dev/null +++ b/src/backend/tls/acme-challenges.ts @@ -0,0 +1,51 @@ +/** + * ACME http-01 answers. The CA asks for /.well-known/acme-challenge/ + * on port 80 of the domain, which no plugin route can own, so core serves + * whatever a plugin published through ctx.system.publishHttpChallenge. + */ + +import type { Request, Response } from "express"; + +const MAX_AGE_MS = 60 * 60 * 1000; +const TOKEN = /^[A-Za-z0-9_-]{1,256}$/; + +const challenges = new Map(); + +/** Returns the undo. A challenge also expires on its own after an hour. */ +export function publishChallenge(token: string, content: string): () => void { + if (!TOKEN.test(token)) { + throw new Error("ACME challenge tokens are base64url"); + } + if (typeof content !== "string" || content.length === 0) { + throw new Error("ACME challenge content must be a non-empty string"); + } + const entry = { content, expiresAt: Date.now() + MAX_AGE_MS }; + challenges.set(token, entry); + return () => { + if (challenges.get(token) === entry) challenges.delete(token); + }; +} + +export function lookupChallenge(token: string): string | null { + const entry = challenges.get(token); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + challenges.delete(token); + return null; + } + return entry.content; +} + +export function clearChallengesForTests(): void { + challenges.clear(); +} + +/** GET /.well-known/acme-challenge/:token, public by design. */ +export function acmeChallengeHandler(req: Request, res: Response): void { + const content = lookupChallenge(String(req.params.token ?? "")); + if (content === null) { + res.status(404).type("text/plain").send("Not found"); + return; + } + res.status(200).type("text/plain").send(content); +} diff --git a/src/backend/tls/certificate.ts b/src/backend/tls/certificate.ts new file mode 100644 index 000000000..4abf1148e --- /dev/null +++ b/src/backend/tls/certificate.ts @@ -0,0 +1,123 @@ +/** + * Reading and checking PEM certificates with node's own X509 parser, so none + * of this needs the openssl binary. + */ + +import { X509Certificate, createPrivateKey } from "crypto"; + +export interface TlsCertificateInfo { + subject: string; + issuer: string; + names: string[]; + notBefore: string; + notAfter: string; + selfSigned: boolean; + fingerprint: string; +} + +export class TlsValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "TlsValidationError"; + } +} + +const PEM_CERT = + /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g; + +/** The leaf is the first certificate in a full chain. */ +export function splitPemChain(pem: string): string[] { + return pem.match(PEM_CERT) ?? []; +} + +function parseNames(subjectAltName: string | undefined): string[] { + if (!subjectAltName) return []; + return subjectAltName + .split(",") + .map((entry) => entry.trim()) + .map((entry) => { + const match = /^(DNS|IP Address):(.+)$/.exec(entry); + return match ? match[2].trim() : null; + }) + .filter((name): name is string => !!name); +} + +function isSelfSigned(cert: X509Certificate): boolean { + if (cert.subject !== cert.issuer) return false; + try { + return cert.checkIssued(cert) && cert.verify(cert.publicKey); + } catch { + return false; + } +} + +export function describeCertificate(cert: X509Certificate): TlsCertificateInfo { + return { + subject: cert.subject.replace(/\n/g, ", "), + issuer: cert.issuer.replace(/\n/g, ", "), + names: parseNames(cert.subjectAltName), + notBefore: new Date(cert.validFrom).toISOString(), + notAfter: new Date(cert.validTo).toISOString(), + selfSigned: isSelfSigned(cert), + fingerprint: cert.fingerprint256, + }; +} + +/** Parses the leaf of a PEM chain. Null when there is nothing readable. */ +export function readCertificateInfo(pem: string): TlsCertificateInfo | null { + const [leaf] = splitPemChain(pem); + if (!leaf) return null; + try { + return describeCertificate(new X509Certificate(leaf)); + } catch { + return null; + } +} + +/** + * Throws TlsValidationError unless the chain parses, the leaf is still valid + * and the key belongs to it. Returns the leaf's details. + */ +export function validateCertificatePair( + certificatePem: string, + privateKeyPem: string, + now: Date = new Date(), +): TlsCertificateInfo { + if (typeof certificatePem !== "string" || typeof privateKeyPem !== "string") { + throw new TlsValidationError( + "A PEM certificate and private key are required", + ); + } + + const chain = splitPemChain(certificatePem); + if (chain.length === 0) { + throw new TlsValidationError("The certificate is not PEM data"); + } + + let leaf: X509Certificate; + try { + leaf = new X509Certificate(chain[0]); + for (const extra of chain.slice(1)) new X509Certificate(extra); + } catch { + throw new TlsValidationError("The certificate could not be parsed"); + } + + let key: ReturnType; + try { + key = createPrivateKey(privateKeyPem); + } catch { + throw new TlsValidationError("The private key could not be parsed"); + } + + if (!leaf.checkPrivateKey(key)) { + throw new TlsValidationError( + "The certificate and private key do not match", + ); + } + + if (new Date(leaf.validTo).getTime() <= now.getTime()) { + throw new TlsValidationError("The certificate has expired"); + } + + return describeCertificate(leaf); +} diff --git a/src/backend/utils/nginx-ssl-reload.ts b/src/backend/tls/nginx-reload.ts similarity index 95% rename from src/backend/utils/nginx-ssl-reload.ts rename to src/backend/tls/nginx-reload.ts index ee72c2120..197ce185a 100644 --- a/src/backend/utils/nginx-ssl-reload.ts +++ b/src/backend/tls/nginx-reload.ts @@ -1,7 +1,7 @@ import { execFileSync } from "child_process"; import { existsSync, readFileSync, writeFileSync } from "fs"; import path from "path"; -import { authLogger } from "./logger.js"; +import { authLogger } from "../utils/logger.js"; const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template"; const NGINX_CONF = "/tmp/nginx/nginx.conf"; @@ -11,7 +11,11 @@ const DATA_DIR = process.env.DATA_DIR || "./db/data"; const SSL_DIR = path.join(DATA_DIR, "ssl"); const ENV_FILE = path.join(DATA_DIR, ".env"); -function persistSSLEnv(sslPort: string, certPath: string, keyPath: string) { +export function persistSSLEnv( + sslPort: string, + certPath: string, + keyPath: string, +) { const vars: Record = { ENABLE_SSL: "true", SSL_PORT: sslPort, diff --git a/src/backend/utils/auto-ssl-setup.ts b/src/backend/tls/self-signed.ts similarity index 75% rename from src/backend/utils/auto-ssl-setup.ts rename to src/backend/tls/self-signed.ts index 240f6e373..60d42603d 100644 --- a/src/backend/utils/auto-ssl-setup.ts +++ b/src/backend/tls/self-signed.ts @@ -1,8 +1,11 @@ -import { getErrorMessage } from "./error-message.js"; +import { getErrorMessage } from "../utils/error-message.js"; import { execSync } from "child_process"; import { promises as fs } from "fs"; import path from "path"; -import { systemLogger } from "./logger.js"; +import { systemLogger } from "../utils/logger.js"; +import { readCertificateInfo } from "./certificate.js"; + +const RENEW_WINDOW_MS = 30 * 86_400_000; export class AutoSSLSetup { private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data"; @@ -28,29 +31,12 @@ export class AutoSSLSetup { } try { - if (await this.isSSLConfigured()) { - await this.logCertificateInfo(); - await this.setupEnvironmentVariables(); - return; - } - - try { - await fs.access(this.CERT_FILE); - await fs.access(this.KEY_FILE); - - systemLogger.info("SSL certificates found from entrypoint script", { - operation: "ssl_cert_found_entrypoint", - cert_path: this.CERT_FILE, - key_path: this.KEY_FILE, - }); - - await this.logCertificateInfo(); - await this.setupEnvironmentVariables(); - return; - } catch { + if (await this.needsSelfSigned()) { await this.generateSSLCertificates(); - await this.setupEnvironmentVariables(); + } else { + await this.logCertificateInfo(); } + await this.setupEnvironmentVariables(); } catch (error) { systemLogger.error("Failed to initialize SSL configuration", error, { operation: "ssl_auto_init_failed", @@ -62,40 +48,44 @@ export class AutoSSLSetup { } } - private static async isSSLConfigured(): Promise { + /** + * Only core's own certificate is ever replaced here. One a CA issued keeps + * being served until it expires, even when nothing renews it. + */ + private static async needsSelfSigned(): Promise { + let pem: string; try { - await fs.access(this.CERT_FILE); await fs.access(this.KEY_FILE); - - execSync( - `openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`, - { - stdio: "pipe", - }, - ); - + pem = await fs.readFile(this.CERT_FILE, "utf8"); + } catch { + systemLogger.info("SSL certificate not found, will generate one", { + operation: "ssl_cert_missing", + cert_path: this.CERT_FILE, + }); return true; - } catch (error) { - if (error instanceof Error && error.message.includes("checkend")) { - systemLogger.warn( - "SSL certificate is expired or expiring soon, will regenerate", - { - operation: "ssl_cert_expired", - cert_path: this.CERT_FILE, - error: error.message, - }, - ); - } else { - systemLogger.info( - "SSL certificate not found or invalid, will generate new one", - { - operation: "ssl_cert_missing", - cert_path: this.CERT_FILE, - }, - ); - } - return false; } + + const info = readCertificateInfo(pem); + if (!info) { + systemLogger.warn("SSL certificate is unreadable, will generate one", { + operation: "ssl_cert_invalid", + cert_path: this.CERT_FILE, + }); + return true; + } + + if ( + info.selfSigned && + new Date(info.notAfter).getTime() - Date.now() < RENEW_WINDOW_MS + ) { + systemLogger.warn( + "Self-signed SSL certificate is expiring, will regenerate", + { operation: "ssl_cert_expired", cert_path: this.CERT_FILE }, + ); + return true; + } + + return false; } private static async generateSSLCertificates(): Promise { @@ -210,7 +200,6 @@ IP.3 = 0.0.0.0 issuer: issuer.replace("issuer=", ""), valid_from: notBefore.replace("notBefore=", ""), valid_until: notAfter.replace("notAfter=", ""), - note: "Certificate will auto-renew 30 days before expiration", }); } catch (error) { systemLogger.warn("Could not retrieve certificate information", { @@ -270,14 +259,4 @@ IP.3 = 0.0.0.0 process.env[key] = value; } } - - static getSSLConfig() { - return { - enabled: process.env.ENABLE_SSL === "true", - port: parseInt(process.env.SSL_PORT || "8443"), - certPath: process.env.SSL_CERT_PATH || this.CERT_FILE, - keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE, - domain: process.env.SSL_DOMAIN || "localhost", - }; - } } diff --git a/src/backend/tls/tls-service.ts b/src/backend/tls/tls-service.ts new file mode 100644 index 000000000..672093b96 --- /dev/null +++ b/src/backend/tls/tls-service.ts @@ -0,0 +1,227 @@ +/** + * Core's side of HTTPS: where the certificate lives, replacing it, and + * serving a new one without a restart. Getting a certificate (ACME) is a + * plugin's job; it reaches this through ctx.system. + */ + +import { promises as fs } from "fs"; +import https from "https"; +import path from "path"; +import { systemLogger } from "../utils/logger.js"; +import { + readCertificateInfo, + validateCertificatePair, + type TlsCertificateInfo, +} from "./certificate.js"; +import { persistSSLEnv, reloadNginxWithSSL } from "./nginx-reload.js"; + +export interface TlsConfig { + enabled: boolean; + port: number; + certPath: string; + keyPath: string; + domain: string; + /** In Docker nginx serves HTTPS and the backend only speaks HTTP. */ + terminatedByNginx: boolean; +} + +export interface TlsRenewer { + pluginId: string; + pluginName: string; +} + +export interface TlsStatus { + enabled: boolean; + certificate: TlsCertificateInfo | null; + renewal: TlsRenewer | null; +} + +export interface TlsReloadResult { + applied: boolean; + message: string; +} + +export function getTlsConfig(env: NodeJS.ProcessEnv = process.env): TlsConfig { + const sslDir = path.join(env.DATA_DIR || "./db/data", "ssl"); + return { + enabled: env.ENABLE_SSL === "true", + port: parseInt(env.SSL_PORT || "8443"), + certPath: env.SSL_CERT_PATH || path.join(sslDir, "termix.crt"), + keyPath: env.SSL_KEY_PATH || path.join(sslDir, "termix.key"), + domain: env.SSL_DOMAIN || "localhost", + terminatedByNginx: env.TERMIX_SSL_TERMINATED_BY_NGINX === "true", + }; +} + +type HttpsFactory = (options: https.ServerOptions) => https.Server; + +let directServer: https.Server | null = null; +let directFactory: HttpsFactory | null = null; + +/** + * Called once by the backend with how to build its direct HTTPS server. Starts + * it now when HTTPS is on and nginx is not the one serving it. + */ +export async function configureDirectHttps( + factory: HttpsFactory, +): Promise { + directFactory = factory; + const config = getTlsConfig(); + if (!config.enabled || config.terminatedByNginx) return null; + return startDirectServer(config); +} + +async function readPair( + config: TlsConfig, +): Promise<{ cert: Buffer; key: Buffer }> { + const [cert, key] = await Promise.all([ + fs.readFile(config.certPath), + fs.readFile(config.keyPath), + ]); + return { cert, key }; +} + +async function startDirectServer( + config: TlsConfig, +): Promise { + if (!directFactory) return null; + try { + directServer = directFactory(await readPair(config)); + return directServer; + } catch (error) { + systemLogger.error( + "Failed to start HTTPS server with configured SSL certificate", + error, + { + operation: "https_server_start_failed", + cert_path: config.certPath, + key_path: config.keyPath, + }, + ); + return null; + } +} + +/** Only for tests: forget the server and factory. */ +export function resetTlsServiceForTests(): void { + directServer = null; + directFactory = null; + renewers.clear(); +} + +const renewers = new Map(); + +/** Records that a plugin renews the certificate. Returns the undo. */ +export function registerTlsRenewer(renewer: TlsRenewer): () => void { + const entry = renewers.get(renewer.pluginId) ?? { renewer, count: 0 }; + entry.count++; + renewers.set(renewer.pluginId, entry); + let live = true; + return () => { + if (!live) return; + live = false; + const current = renewers.get(renewer.pluginId); + if (!current) return; + current.count--; + if (current.count <= 0) renewers.delete(renewer.pluginId); + }; +} + +export function currentTlsRenewer(): TlsRenewer | null { + const first = renewers.values().next(); + return first.done ? null : first.value.renewer; +} + +export async function getTlsStatus(): Promise { + const config = getTlsConfig(); + let certificate: TlsCertificateInfo | null = null; + try { + certificate = readCertificateInfo( + await fs.readFile(config.certPath, "utf8"), + ); + } catch { + certificate = null; + } + return { + enabled: config.enabled, + certificate, + renewal: currentTlsRenewer(), + }; +} + +/** + * Validates the pair, then swaps both files in with a rename so nginx or the + * HTTPS server never reads half a certificate. Does not reload. + */ +export async function writeTlsCertificate( + certificatePem: string, + privateKeyPem: string, +): Promise { + const info = validateCertificatePair(certificatePem, privateKeyPem); + const config = getTlsConfig(); + + await fs.mkdir(path.dirname(config.certPath), { recursive: true }); + await fs.mkdir(path.dirname(config.keyPath), { recursive: true }); + + const certTmp = `${config.certPath}.tmp`; + const keyTmp = `${config.keyPath}.tmp`; + try { + await fs.writeFile(certTmp, certificatePem, { mode: 0o644 }); + await fs.writeFile(keyTmp, privateKeyPem, { mode: 0o600 }); + await fs.rename(keyTmp, config.keyPath); + await fs.rename(certTmp, config.certPath); + await fs.chmod(config.keyPath, 0o600).catch(() => undefined); + await fs.chmod(config.certPath, 0o644).catch(() => undefined); + } finally { + await fs.rm(certTmp, { force: true }); + await fs.rm(keyTmp, { force: true }); + } + + systemLogger.info("TLS certificate replaced", { + operation: "tls_certificate_written", + subject: info.subject, + not_after: info.notAfter, + }); + return info; +} + +/** Serves the certificate on disk now, through nginx or the direct server. */ +export async function reloadTls(): Promise { + const config = getTlsConfig(); + + if (config.terminatedByNginx) { + return reloadNginxWithSSL(); + } + + try { + const pair = await readPair(config); + if (directServer) { + directServer.setSecureContext(pair); + return { applied: true, message: "The new certificate is being served." }; + } + + process.env.ENABLE_SSL = "true"; + const server = await startDirectServer(getTlsConfig()); + if (!server) { + return { + applied: false, + message: + "Certificate installed, but HTTPS could not be started. Restart Termix with ENABLE_SSL=true to apply it.", + }; + } + persistSSLEnv(String(config.port), config.certPath, config.keyPath); + return { + applied: true, + message: `HTTPS is now active on port ${config.port}.`, + }; + } catch (error) { + systemLogger.error("Failed to reload the TLS certificate", error, { + operation: "tls_reload_failed", + }); + return { + applied: false, + message: + "Certificate installed, but it could not be loaded. Restart Termix to apply it.", + }; + } +} diff --git a/src/backend/utils/crypto-migration/acme-ssl-settings-migration.ts b/src/backend/utils/crypto-migration/acme-ssl-settings-migration.ts new file mode 100644 index 000000000..bd6daab56 --- /dev/null +++ b/src/backend/utils/crypto-migration/acme-ssl-settings-migration.ts @@ -0,0 +1,127 @@ +/** + * Moves the 2.8 ACME configuration into the acme-ssl plugin. + * + * 2.8 kept it as one JSON row, `acme_ssl_settings`, in core settings. Each + * field becomes a plugin admin setting. "manual" was never ACME (it meant an + * uploaded certificate, which core still handles), so it turns renewal off. + * The Cloudflare token was plaintext and lands as an encrypted secret; it is + * then removed from the old row. The rest of the row stays for one release. + * + * Idempotent: a field already set in plugin_settings is never overwritten. + */ + +import { databaseLogger } from "../logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, + createCurrentSettingsRepository, +} from "../../database/repositories/factory.js"; +import { encryptSystemSecret } from "../system-secret-crypto.js"; + +const PLUGIN_ID = "acme-ssl"; +const LEGACY_KEY = "acme_ssl_settings"; + +export interface AcmeSslSettingsMigrationResult { + moved: string[]; +} + +interface LegacyAcmeSettings { + enabled?: unknown; + domain?: unknown; + email?: unknown; + challengeType?: unknown; + cloudflareToken?: unknown; +} + +const text = (value: unknown) => + typeof value === "string" && value.trim() ? value.trim() : null; + +export async function runAcmeSslSettingsMigration(): Promise { + const result: AcmeSslSettingsMigrationResult = { moved: [] }; + + try { + const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID); + if (!plugin) return result; + + const settings = createCurrentSettingsRepository(); + const raw = await settings.get(LEGACY_KEY); + if (!raw) return result; + + let legacy: LegacyAcmeSettings; + try { + legacy = JSON.parse(raw) as LegacyAcmeSettings; + } catch { + return result; + } + if (!legacy || typeof legacy !== "object") return result; + + const pluginSettings = createCurrentPluginSettingsRepository(); + const writeIfUnset = async ( + field: string, + value: unknown, + secret = false, + ) => { + const existing = await pluginSettings.get( + PLUGIN_ID, + "admin", + null, + field, + ); + if (existing && existing.value !== null) return; + await pluginSettings.set( + PLUGIN_ID, + "admin", + null, + field, + JSON.stringify(value), + secret, + ); + result.moved.push(field); + }; + + const challenge = legacy.challengeType; + const manual = challenge === "manual"; + + const domain = text(legacy.domain); + if (domain) await writeIfUnset("domain", domain); + const email = text(legacy.email); + if (email) await writeIfUnset("email", email); + if (challenge === "dns-cloudflare") { + await writeIfUnset("challengeType", "dns-cloudflare"); + } else if (challenge === "http-webroot") { + await writeIfUnset("challengeType", "http-01"); + } + if (typeof legacy.enabled === "boolean") { + await writeIfUnset("autoRenew", legacy.enabled && !manual); + } + + const token = text(legacy.cloudflareToken); + if (token) { + await writeIfUnset( + "cloudflareToken", + await encryptSystemSecret(token), + true, + ); + const { cloudflareToken: _dropped, ...rest } = legacy; + await settings.set(LEGACY_KEY, JSON.stringify(rest)); + } + + if (result.moved.length > 0) { + databaseLogger.info( + `Moved ${result.moved.length} ACME setting(s) into plugin settings`, + { + operation: "acme_ssl_settings_migration", + moved: result.moved.join(", "), + }, + ); + } + } catch (error) { + // Must not stop the backend: the plugin then shows "not configured". + databaseLogger.warn("ACME settings migration failed", { + operation: "acme_ssl_settings_migration", + error: error instanceof Error ? error.message : String(error), + }); + } + + return result; +} diff --git a/src/ui/api/acme-ssl-api.ts b/src/ui/api/acme-ssl-api.ts deleted file mode 100644 index 405bf99e4..000000000 --- a/src/ui/api/acme-ssl-api.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { authApi, handleApiError } from "@/main-axios"; - -export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual"; - -export type AcmeSettings = { - enabled: boolean; - domain: string; - email: string; - challengeType: AcmeChallengeType; - cloudflareToken: string; - lastIssuedAt: string | null; - certStatus: "none" | "valid" | "expiring" | "expired"; - certExpiresAt: string | null; -}; - -export async function getAcmeSslSettings(): Promise { - try { - const response = await authApi.get("/users/acme-ssl-settings"); - return response.data; - } catch (error) { - handleApiError(error, "fetch ACME SSL settings"); - } -} - -export async function updateAcmeSslSettings( - settings: Partial< - Omit - >, -): Promise { - try { - const response = await authApi.patch("/users/acme-ssl-settings", settings); - return response.data; - } catch (error) { - handleApiError(error, "update ACME SSL settings"); - } -} - -export async function requestAcmeCertificate(): Promise< - AcmeSettings & { success: boolean; reloadMessage?: string } -> { - try { - const response = await authApi.post("/users/acme-ssl-request", {}); - return response.data; - } catch (error) { - handleApiError(error, "request ACME certificate"); - } -} - -export async function uploadManualSslCertificate(payload: { - certificate: string; - privateKey: string; -}): Promise { - try { - const response = await authApi.post("/users/manual-ssl-upload", payload); - return response.data; - } catch (error) { - handleApiError(error, "upload manual SSL certificate"); - } -} diff --git a/src/ui/api/tls-api.ts b/src/ui/api/tls-api.ts new file mode 100644 index 000000000..2c958679e --- /dev/null +++ b/src/ui/api/tls-api.ts @@ -0,0 +1,38 @@ +import { authApi, handleApiError } from "@/main-axios"; + +export type TlsCertificateInfo = { + subject: string; + issuer: string; + names: string[]; + notBefore: string; + notAfter: string; + selfSigned: boolean; + fingerprint: string; +}; + +export type TlsStatus = { + enabled: boolean; + certificate: TlsCertificateInfo | null; + renewal: { pluginId: string; pluginName: string } | null; +}; + +export async function getTlsStatus(): Promise { + try { + const response = await authApi.get("/users/tls-certificate"); + return response.data; + } catch (error) { + handleApiError(error, "fetch TLS certificate status"); + } +} + +export async function uploadTlsCertificate(payload: { + certificate: string; + privateKey: string; +}): Promise { + try { + const response = await authApi.post("/users/tls-certificate", payload); + return response.data; + } catch (error) { + handleApiError(error, "upload TLS certificate"); + } +} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index b48856b65..bd2722330 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1554,7 +1554,7 @@ "sectionDatabase": "Database", "sectionApiKeys": "API Keys", "sectionAuditLog": "Audit Log", - "sectionSsl": "SSL / Let's Encrypt", + "sectionSsl": "HTTPS certificate", "sectionBranding": "White Label", "brandingAppName": "Application Name", "brandingAppNameDesc": "Shown on the login screen and browser tab. Leave empty to use \"Termix\".", @@ -1567,38 +1567,15 @@ "brandingLogoInvalid": "Invalid image. Use a PNG, JPEG or WEBP file up to 750 KB.", "brandingSaved": "Branding settings saved", "brandingSaveFailed": "Failed to save branding settings", - "sslDescription": "Automatically issue and renew a trusted SSL certificate from Let's Encrypt. Requires a public domain and port 80 or DNS access.", + "sslDescription": "The certificate Termix serves over HTTPS. Upload your own here, or let the ACME Certificates plugin get and renew one from Let's Encrypt.", "sslDocsLink": "View SSL docs", - "sslDomain": "Domain", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "The public domain name for the certificate.", - "sslEmail": "Email", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Contact email for Let's Encrypt notifications and account.", - "sslChallengeType": "Challenge Type", - "sslChallengeTypeDesc": "How to prove domain ownership to Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - requires port 80 accessible from the internet", - "sslChallengeDns": "DNS (Cloudflare) - requires a Cloudflare API token", - "sslCloudflareToken": "Cloudflare API Token", - "sslCloudflareTokenPlaceholder": "Enter token...", - "sslCloudflareTokenDesc": "Scoped token with Zone:DNS:Edit permission for your domain.", "sslCertStatus": "Certificate Status", "sslCertStatusNone": "No certificate", "sslCertStatusValid": "Valid", "sslCertStatusExpiring": "Expiring soon", "sslCertStatusExpired": "Expired", "sslCertExpiresAt": "Expires {{date}}", - "sslLastIssued": "Last issued {{date}}", - "sslRequestCert": "Issue / Renew Certificate", - "sslRequestCertLoading": "Requesting certificate...", - "sslRequestCertSuccess": "Certificate issued and installed successfully", - "sslRequestCertFailed": "Certificate request failed", - "sslSave": "Save Settings", - "sslSaved": "SSL settings saved", - "sslSaveFailed": "Failed to save SSL settings", - "sslRequiresDomain": "Domain and email are required", "sslInfoNote": "After issuing or uploading a certificate, Termix will automatically enable HTTPS on SSL_PORT (default 8443) in addition to the existing HTTP port. Make sure that port is published/mapped to this container so it is reachable.", - "sslManualOption": "Manual (upload certificate)", "sslManualCert": "Certificate (PEM)", "sslManualCertPlaceholder": "-----BEGIN CERTIFICATE-----", "sslManualKey": "Private Key (PEM)", @@ -1921,7 +1898,13 @@ "resetSecondFactorsConfirm": "Remove every second factor for {{username}}? They can sign in with their password or SSO and enrol again.", "secondFactorsResetSuccess": "Second factors reset", "secondFactorsResetFailed": "Failed to reset second factors", - "passwordLoginForced": "Password login is turned off, but no other login method is enabled, so it stays on for now. Nobody gets locked out." + "passwordLoginForced": "Password login is turned off, but no other login method is enabled, so it stays on for now. Nobody gets locked out.", + "sslNotRenewed": "Nothing renews this certificate. It expires {{date}}. Turn on the ACME Certificates plugin and its automatic renewal, or upload a new one before then.", + "sslSelfSigned": "Self-signed certificate made by Termix. Browsers will warn about it.", + "sslRenewedBy": "Renewed automatically by {{plugin}}", + "sslIssuer": "Issued by {{issuer}}", + "sslNames": "Covers {{names}}", + "sslManualTitle": "Upload a certificate" }, "newUi": { "sidebar": { @@ -2513,7 +2496,7 @@ }, "system:tls": { "title": "Manage certificates", - "consequence": "It can request and replace the server certificate." + "consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities." }, "device:serial": { "title": "Access serial and USB devices", diff --git a/src/ui/locales/translated/af_ZA.json b/src/ui/locales/translated/af_ZA.json index adb23057f..bfb7cd2bb 100644 --- a/src/ui/locales/translated/af_ZA.json +++ b/src/ui/locales/translated/af_ZA.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Kon nie handelsmerkinstellings stoor nie", "sslDescription": "Reik outomaties 'n vertroude SSL-sertifikaat uit en hernu dit van Let's Encrypt. Vereis 'n publieke domein en poort 80 of DNS-toegang.", "sslDocsLink": "Bekyk SSL-dokumente", - "sslDomain": "Domein", - "sslDomainPlaceholder": "termix.voorbeeld.com", - "sslDomainDesc": "Die publieke domeinnaam vir die sertifikaat.", - "sslEmail": "E-pos", - "sslEmailPlaceholder": "admin@voorbeeld.com", - "sslEmailDesc": "Kontak-e-posadres vir Let's Encrypt-kennisgewings en -rekening.", - "sslChallengeType": "Uitdagingsoort", - "sslChallengeTypeDesc": "Hoe om domein-eienaarskap aan Let's Encrypt te bewys.", - "sslChallengeHttp": "HTTP (webroot) - vereis poort 80 wat vanaf die internet toeganklik is", - "sslChallengeDns": "DNS (Cloudflare) - vereis 'n Cloudflare API-token", - "sslCloudflareToken": "Cloudflare API-token", - "sslCloudflareTokenPlaceholder": "Voer teken in...", - "sslCloudflareTokenDesc": "Omvangryke token met Zone:DNS:Wysig toestemming vir jou domein.", "sslCertStatus": "Sertifikaatstatus", "sslCertStatusNone": "Geen sertifikaat nie", "sslCertStatusValid": "Geldig", "sslCertStatusExpiring": "Verval binnekort", "sslCertStatusExpired": "Verstryk", "sslCertExpiresAt": "Vervaldatum {{date}}", - "sslLastIssued": "Laas uitgereik {{date}}", - "sslRequestCert": "Uitreik / Hernu Sertifikaat", - "sslRequestCertLoading": "Versoek sertifikaat...", - "sslRequestCertSuccess": "Sertifikaat suksesvol uitgereik en geïnstalleer", - "sslRequestCertFailed": "Sertifikaatversoek het misluk", - "sslSave": "Stoor instellings", - "sslSaved": "SSL-instellings gestoor", - "sslSaveFailed": "Kon nie SSL-instellings stoor nie", - "sslRequiresDomain": "Domein en e-posadres word vereis", "sslInfoNote": "Nadat 'n sertifikaat uitgereik of opgelaai is, sal Termix outomaties HTTPS op SSL_PORT (standaard 8443) aktiveer, benewens die bestaande HTTP-poort. Maak seker dat die poort gepubliseer/gekarteer is aan hierdie houer sodat dit bereikbaar is.", - "sslManualOption": "Handleiding (laai sertifikaat op)", "sslManualCert": "Sertifikaat (PEM)", "sslManualCertPlaceholder": "-----BEGIN SERTIFIKAAT-----", "sslManualKey": "Privaatsleutel (PEM)", diff --git a/src/ui/locales/translated/ar_SA.json b/src/ui/locales/translated/ar_SA.json index 4cefdbc2c..ecdb2c9f0 100644 --- a/src/ui/locales/translated/ar_SA.json +++ b/src/ui/locales/translated/ar_SA.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "فشل حفظ إعدادات العلامة التجارية", "sslDescription": "إصدار وتجديد شهادة SSL موثوقة تلقائيًا من Let's Encrypt. يتطلب نطاقًا عامًا والمنفذ 80 أو وصول DNS.", "sslDocsLink": "عرض مستندات SSL", - "sslDomain": "النطاق", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "اسم النطاق العام للشهادة.", - "sslEmail": "البريد الإلكتروني", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "بريد الاتصال لإشعارات Let's Encrypt والحساب.", - "sslChallengeType": "نوع التحدي", - "sslChallengeTypeDesc": "كيفية إثبات ملكية النطاق لـ Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - يتطلب المنفذ 80 متاحًا من الإنترنت", - "sslChallengeDns": "DNS (Cloudflare) - يتطلب رمز Cloudflare API", - "sslCloudflareToken": "رمز Cloudflare API", - "sslCloudflareTokenPlaceholder": "أدخل الرمز...", - "sslCloudflareTokenDesc": "رمز مقيد بصلاحية Zone:DNS:Edit للنطاق الخاص بك.", "sslCertStatus": "حالة الشهادة", "sslCertStatusNone": "لا توجد شهادة", "sslCertStatusValid": "صالحة", "sslCertStatusExpiring": "ستنتهي قريبًا", "sslCertStatusExpired": "منتهية", "sslCertExpiresAt": "تنتهي في {{date}}", - "sslLastIssued": "آخر إصدار {{date}}", - "sslRequestCert": "إصدار / تجديد الشهادة", - "sslRequestCertLoading": "جارٍ طلب الشهادة...", - "sslRequestCertSuccess": "تم إصدار الشهادة وتثبيتها بنجاح", - "sslRequestCertFailed": "فشل طلب الشهادة", - "sslSave": "حفظ الإعدادات", - "sslSaved": "تم حفظ إعدادات SSL", - "sslSaveFailed": "فشل حفظ إعدادات SSL", - "sslRequiresDomain": "النطاق والبريد الإلكتروني مطلوبان", "sslInfoNote": "بعد إصدار الشهادة أو تحميلها، سيقوم Termix تلقائيًا بتفعيل بروتوكول HTTPS على منفذ SSL_PORT (المنفذ الافتراضي 8443) بالإضافة إلى منفذ HTTP الحالي. تأكد من نشر هذا المنفذ/ربطه بهذه الحاوية لضمان إمكانية الوصول إليه.", - "sslManualOption": "يدوي (تحميل الشهادة)", "sslManualCert": "شهادة (PEM)", "sslManualCertPlaceholder": "-----بداية الشهادة-----", "sslManualKey": "المفتاح الخاص (PEM)", diff --git a/src/ui/locales/translated/bg_BG.json b/src/ui/locales/translated/bg_BG.json index 04909d97f..eed0800ed 100644 --- a/src/ui/locales/translated/bg_BG.json +++ b/src/ui/locales/translated/bg_BG.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Запазването на настройките за брандиране не бе успешно", "sslDescription": "Автоматично издаване и подновяване на надежден SSL сертификат от Let's Encrypt. Изисква публичен домейн и порт 80 или DNS достъп.", "sslDocsLink": "Преглед на SSL документите", - "sslDomain": "Домейн", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Публичното име на домейна за сертификата.", - "sslEmail": "Имейл", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Контактен имейл за известия и акаунт от Let's Encrypt.", - "sslChallengeType": "Тип предизвикателство", - "sslChallengeTypeDesc": "Как да докажете собствеността на домейн на Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - изисква порт 80, достъпен от интернет", - "sslChallengeDns": "DNS (Cloudflare) - изисква Cloudflare API токен", - "sslCloudflareToken": "API токен на Cloudflare", - "sslCloudflareTokenPlaceholder": "Въведете токен...", - "sslCloudflareTokenDesc": "Ограничен токен с разрешение Zone:DNS:Edit за вашия домейн.", "sslCertStatus": "Статус на сертификата", "sslCertStatusNone": "Няма сертификат", "sslCertStatusValid": "Валиден", "sslCertStatusExpiring": "Изтича скоро", "sslCertStatusExpired": "Изтекъл", "sslCertExpiresAt": "Изтича {{date}}", - "sslLastIssued": "Последно издадено {{date}}", - "sslRequestCert": "Издаване / Подновяване на сертификат", - "sslRequestCertLoading": "Заявка за сертификат...", - "sslRequestCertSuccess": "Сертификатът е издаден и инсталиран успешно", - "sslRequestCertFailed": "Заявката за сертификат е неуспешна", - "sslSave": "Запазване на настройките", - "sslSaved": "Настройките на SSL са запазени", - "sslSaveFailed": "Запазването на настройките за SSL не бе успешно", - "sslRequiresDomain": "Домейнът и имейлът са задължителни", "sslInfoNote": "След издаване или качване на сертификат, Termix автоматично ще активира HTTPS на SSL_PORT (по подразбиране 8443) в допълнение към съществуващия HTTP порт. Уверете се, че този порт е публикуван/свързан с този контейнер, така че да е достъпен.", - "sslManualOption": "Ръководство (сертификат за качване)", "sslManualCert": "Сертификат (PEM)", "sslManualCertPlaceholder": "-----ЗАПОЧВАНЕ НА СЕРТИФИКАТ-----", "sslManualKey": "Частен ключ (PEM)", diff --git a/src/ui/locales/translated/bn_BD.json b/src/ui/locales/translated/bn_BD.json index 61e49d8fa..4554f69e9 100644 --- a/src/ui/locales/translated/bn_BD.json +++ b/src/ui/locales/translated/bn_BD.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "ব্র্যান্ডিং সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে", "sslDescription": "Let's Encrypt থেকে স্বয়ংক্রিয়ভাবে একটি বিশ্বস্ত SSL সার্টিফিকেট ইস্যু ও নবায়ন করুন। এর জন্য একটি পাবলিক ডোমেইন এবং পোর্ট ৮০ অথবা DNS অ্যাক্সেস প্রয়োজন।", "sslDocsLink": "SSL ডকুমেন্টেশন দেখুন", - "sslDomain": "ডোমেইন", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "সার্টিফিকেটটির পাবলিক ডোমেইন নাম।", - "sslEmail": "ইমেল", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Let's Encrypt-এর বিজ্ঞপ্তি এবং অ্যাকাউন্টের জন্য যোগাযোগের ইমেল।", - "sslChallengeType": "চ্যালেঞ্জের ধরণ", - "sslChallengeTypeDesc": "Let's Encrypt-কে ডোমেইনের মালিকানা কীভাবে প্রমাণ করবেন।", - "sslChallengeHttp": "HTTP (webroot) - এর জন্য ইন্টারনেট থেকে অ্যাক্সেসযোগ্য পোর্ট ৮০ প্রয়োজন।", - "sslChallengeDns": "ডিএনএস (ক্লাউডফ্লেয়ার) - এর জন্য একটি ক্লাউডফ্লেয়ার এপিআই টোকেন প্রয়োজন।", - "sslCloudflareToken": "ক্লাউডফ্লেয়ার এপিআই টোকেন", - "sslCloudflareTokenPlaceholder": "টোকেন প্রবেশ করান...", - "sslCloudflareTokenDesc": "আপনার ডোমেনের জন্য Zone:DNS:Edit অনুমতিসহ স্কোপড টোকেন।", "sslCertStatus": "সার্টিফিকেটের অবস্থা", "sslCertStatusNone": "কোন সার্টিফিকেট নেই", "sslCertStatusValid": "বৈধ", "sslCertStatusExpiring": "শীঘ্রই মেয়াদ শেষ হবে", "sslCertStatusExpired": "মেয়াদোত্তীর্ণ", "sslCertExpiresAt": "মেয়াদ শেষ হবে {{date}}", - "sslLastIssued": "সর্বশেষ প্রকাশিত {{date}}", - "sslRequestCert": "সার্টিফিকেট ইস্যু / নবায়ন করুন", - "sslRequestCertLoading": "সার্টিফিকেটের জন্য অনুরোধ...", - "sslRequestCertSuccess": "সার্টিফিকেট সফলভাবে ইস্যু এবং ইনস্টল করা হয়েছে।", - "sslRequestCertFailed": "সার্টিফিকেট অনুরোধ ব্যর্থ হয়েছে", - "sslSave": "সেটিংস সংরক্ষণ করুন", - "sslSaved": "SSL সেটিংস সংরক্ষিত হয়েছে", - "sslSaveFailed": "SSL সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে", - "sslRequiresDomain": "ডোমেইন এবং ইমেল প্রয়োজন", "sslInfoNote": "সার্টিফিকেট ইস্যু বা আপলোড করার পরে, Termix বিদ্যমান HTTP পোর্টের পাশাপাশি SSL_PORT (ডিফল্ট ৮৪৪৩)-এ স্বয়ংক্রিয়ভাবে HTTPS চালু করে দেবে। নিশ্চিত করুন যে পোর্টটি এই কন্টেইনারে পাবলিশ/ম্যাপ করা আছে, যাতে এটি অ্যাক্সেসযোগ্য হয়।", - "sslManualOption": "ম্যানুয়াল (শংসাপত্র আপলোড করুন)", "sslManualCert": "সার্টিফিকেট (পিইএম)", "sslManualCertPlaceholder": "-----সনদপত্র শুরু-----", "sslManualKey": "ব্যক্তিগত কী (পিইএম)", diff --git a/src/ui/locales/translated/ca_ES.json b/src/ui/locales/translated/ca_ES.json index 734c697c4..f6e36bafd 100644 --- a/src/ui/locales/translated/ca_ES.json +++ b/src/ui/locales/translated/ca_ES.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "No s'ha pogut desar la configuració de marca", "sslDescription": "Emet i renova automàticament un certificat SSL de confiança de Let's Encrypt. Requereix un domini públic i el port 80 o accés DNS.", "sslDocsLink": "Veure documents SSL", - "sslDomain": "Domini", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "El nom de domini públic del certificat.", - "sslEmail": "Correu electrònic", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Correu electrònic de contacte per a les notificacions i el compte de Let's Encrypt.", - "sslChallengeType": "Tipus de repte", - "sslChallengeTypeDesc": "Com demostrar la propietat del domini a Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot): requereix el port 80 accessible des d'Internet", - "sslChallengeDns": "DNS (Cloudflare): requereix un token d'API de Cloudflare", - "sslCloudflareToken": "Token de l'API de Cloudflare", - "sslCloudflareTokenPlaceholder": "Introduïu el testimoni...", - "sslCloudflareTokenDesc": "Token amb àmbit i permís Zone:DNS:Edit per al vostre domini.", "sslCertStatus": "Estat del certificat", "sslCertStatusNone": "Sense certificat", "sslCertStatusValid": "Vàlid", "sslCertStatusExpiring": "Caduca aviat", "sslCertStatusExpired": "Caducat", "sslCertExpiresAt": "Caduca {{date}}", - "sslLastIssued": "Última emissió {{date}}", - "sslRequestCert": "Emetre / Renovar el certificat", - "sslRequestCertLoading": "Sol·licitant certificat...", - "sslRequestCertSuccess": "Certificat emès i instal·lat correctament", - "sslRequestCertFailed": "La sol·licitud de certificat ha fallat", - "sslSave": "Desa la configuració", - "sslSaved": "Configuració SSL desada", - "sslSaveFailed": "No s'ha pogut desar la configuració SSL", - "sslRequiresDomain": "Cal el domini i el correu electrònic", "sslInfoNote": "Després d'emetre o carregar un certificat, Termix habilitarà automàticament HTTPS a SSL_PORT (per defecte 8443) a més del port HTTP existent. Assegureu-vos que el port estigui publicat/mapejat a aquest contenidor perquè sigui accessible.", - "sslManualOption": "Manual (pujar certificat)", "sslManualCert": "Certificat (PEM)", "sslManualCertPlaceholder": "-----CERTIFICAT D'INICI-----", "sslManualKey": "Clau privada (PEM)", diff --git a/src/ui/locales/translated/cs_CZ.json b/src/ui/locales/translated/cs_CZ.json index a7dd62284..1d9d1cd40 100644 --- a/src/ui/locales/translated/cs_CZ.json +++ b/src/ui/locales/translated/cs_CZ.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Uložení nastavení brandingu se nezdařilo", "sslDescription": "Automaticky vydá a obnoví důvěryhodný SSL certifikát od Let's Encrypt. Vyžaduje veřejnou doménu a port 80 nebo přístup k DNS.", "sslDocsLink": "Zobrazit dokumentaci k SSL", - "sslDomain": "Doména", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Název veřejné domény pro certifikát.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Kontaktní e-mail pro oznámení a účet Let's Encrypt.", - "sslChallengeType": "Typ výzvy", - "sslChallengeTypeDesc": "Jak prokázat vlastnictví domény službě Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) – vyžaduje port 80 přístupný z internetu", - "sslChallengeDns": "DNS (Cloudflare) – vyžaduje token Cloudflare API", - "sslCloudflareToken": "Token API Cloudflare", - "sslCloudflareTokenPlaceholder": "Zadejte token...", - "sslCloudflareTokenDesc": "Token s vymezeným rozsahem a oprávněním Zóna:DNS:Úpravy pro vaši doménu.", "sslCertStatus": "Stav certifikátu", "sslCertStatusNone": "Žádný certifikát", "sslCertStatusValid": "Platný", "sslCertStatusExpiring": "Platnost brzy vyprší", "sslCertStatusExpired": "Platnost vypršela", "sslCertExpiresAt": "Platnost vyprší {{date}}", - "sslLastIssued": "Naposledy vydáno {{date}}", - "sslRequestCert": "Vydání / Obnova certifikátu", - "sslRequestCertLoading": "Žádost o certifikát...", - "sslRequestCertSuccess": "Certifikát byl úspěšně vydán a nainstalován.", - "sslRequestCertFailed": "Žádost o certifikát selhala", - "sslSave": "Uložit nastavení", - "sslSaved": "Nastavení SSL uloženo", - "sslSaveFailed": "Nepodařilo se uložit nastavení SSL", - "sslRequiresDomain": "Doména a e-mail jsou povinné", "sslInfoNote": "Po vydání nebo nahrání certifikátu Termix automaticky povolí HTTPS na SSL_PORT (výchozí 8443) a to vedle stávajícího HTTP portu. Ujistěte se, že je tento port publikován/mapován na tento kontejner, aby byl dostupný.", - "sslManualOption": "Manuál (nahrání certifikátu)", "sslManualCert": "Certifikát (PEM)", "sslManualCertPlaceholder": "-----ZAČÁTEK CERTIFIKÁTU-----", "sslManualKey": "Soukromý klíč (PEM)", diff --git a/src/ui/locales/translated/da_DK.json b/src/ui/locales/translated/da_DK.json index abc3fda26..0905a2e71 100644 --- a/src/ui/locales/translated/da_DK.json +++ b/src/ui/locales/translated/da_DK.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Brandingindstillingerne kunne ikke gemmes", "sslDescription": "Udsted og forny automatisk et betroet SSL-certifikat fra Let's Encrypt. Kræver et offentligt domæne og port 80 eller DNS-adgang.", "sslDocsLink": "Se SSL-dokumenter", - "sslDomain": "Domæne", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Det offentlige domænenavn for certifikatet.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@eksempel.com", - "sslEmailDesc": "Kontakt-e-mailadresse for Let's Encrypt-notifikationer og konto.", - "sslChallengeType": "Udfordringstype", - "sslChallengeTypeDesc": "Sådan beviser du domæneejerskab over for Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - kræver port 80, der er tilgængelig fra internettet", - "sslChallengeDns": "DNS (Cloudflare) - kræver et Cloudflare API-token", - "sslCloudflareToken": "Cloudflare API-token", - "sslCloudflareTokenPlaceholder": "Indtast token...", - "sslCloudflareTokenDesc": "Scoped-token med Zone:DNS:Rediger-tilladelse for dit domæne.", "sslCertStatus": "Certifikatstatus", "sslCertStatusNone": "Intet certifikat", "sslCertStatusValid": "Gyldig", "sslCertStatusExpiring": "Udløber snart", "sslCertStatusExpired": "Udløbet", "sslCertExpiresAt": "Udløber {{date}}", - "sslLastIssued": "Sidst udstedt {{date}}", - "sslRequestCert": "Udstede/forny certifikat", - "sslRequestCertLoading": "Anmoder om certifikat...", - "sslRequestCertSuccess": "Certifikat udstedt og installeret", - "sslRequestCertFailed": "Certifikatanmodning mislykkedes", - "sslSave": "Gem indstillinger", - "sslSaved": "SSL-indstillinger gemt", - "sslSaveFailed": "Kunne ikke gemme SSL-indstillinger", - "sslRequiresDomain": "Domæne og e-mail er påkrævet", "sslInfoNote": "Efter udstedelse eller upload af et certifikat vil Termix automatisk aktivere HTTPS på SSL_PORT (standard 8443) ud over den eksisterende HTTP-port. Sørg for, at porten er publiceret/tilknyttet denne container, så den er tilgængelig.", - "sslManualOption": "Manual (upload certifikat)", "sslManualCert": "Certifikat (PEM)", "sslManualCertPlaceholder": "-----STARTCERTIFIKAT-----", "sslManualKey": "Privat nøgle (PEM)", diff --git a/src/ui/locales/translated/de_DE.json b/src/ui/locales/translated/de_DE.json index 9f1ed44bb..0b3fbd185 100644 --- a/src/ui/locales/translated/de_DE.json +++ b/src/ui/locales/translated/de_DE.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Branding-Einstellungen konnten nicht gespeichert werden", "sslDescription": "Automatisch ein vertrauenswürdiges SSL-Zertifikat von Let's Encrypt ausstellen und erneuern. Erfordert eine öffentliche Domain und Port 80 oder DNS-Zugriff.", "sslDocsLink": "SSL-Dokumentation anzeigen", - "sslDomain": "Domain", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Der öffentliche Domainname für das Zertifikat.", - "sslEmail": "E-Mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Kontakt-E-Mail für Let's Encrypt-Benachrichtigungen und das Konto.", - "sslChallengeType": "Challenge-Typ", - "sslChallengeTypeDesc": "Wie der Domainbesitz gegenüber Let's Encrypt nachgewiesen wird.", - "sslChallengeHttp": "HTTP (Webroot) – erfordert einen aus dem Internet erreichbaren Port 80", - "sslChallengeDns": "DNS (Cloudflare) – erfordert einen Cloudflare-API-Token", - "sslCloudflareToken": "Cloudflare-API-Token", - "sslCloudflareTokenPlaceholder": "Token eingeben...", - "sslCloudflareTokenDesc": "Bereichsbezogener Token mit Zone:DNS:Edit-Berechtigung für Ihre Domain.", "sslCertStatus": "Zertifikatsstatus", "sslCertStatusNone": "Kein Zertifikat", "sslCertStatusValid": "Gültig", "sslCertStatusExpiring": "Läuft bald ab", "sslCertStatusExpired": "Abgelaufen", "sslCertExpiresAt": "Läuft ab {{date}}", - "sslLastIssued": "Zuletzt ausgestellt {{date}}", - "sslRequestCert": "Zertifikat ausstellen / erneuern", - "sslRequestCertLoading": "Zertifikat wird angefordert...", - "sslRequestCertSuccess": "Zertifikat erfolgreich ausgestellt und installiert", - "sslRequestCertFailed": "Zertifikatsanforderung fehlgeschlagen", - "sslSave": "Einstellungen speichern", - "sslSaved": "SSL-Einstellungen gespeichert", - "sslSaveFailed": "Fehler beim Speichern der SSL-Einstellungen", - "sslRequiresDomain": "Domain und E-Mail sind erforderlich", "sslInfoNote": "Nach der Ausstellung oder dem Hochladen eines Zertifikats aktiviert Termix automatisch HTTPS auf dem SSL_PORT (Standard: 8443) zusätzlich zum bestehenden HTTP-Port. Stellen Sie sicher, dass dieser Port für diesen Container freigegeben/zugeordnet ist, damit er erreichbar ist.", - "sslManualOption": "Handbuch (Zertifikat hochladen)", "sslManualCert": "Zertifikat (PEM)", "sslManualCertPlaceholder": "-----BEGINN DES ZERTIFIKATS-----", "sslManualKey": "Privater Schlüssel (PEM)", diff --git a/src/ui/locales/translated/el_GR.json b/src/ui/locales/translated/el_GR.json index 8ce74b9cc..d6cc4b424 100644 --- a/src/ui/locales/translated/el_GR.json +++ b/src/ui/locales/translated/el_GR.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Αποτυχία αποθήκευσης ρυθμίσεων επωνυμίας", "sslDescription": "Αυτόματη έκδοση και ανανέωση ενός αξιόπιστου πιστοποιητικού SSL από το Let's Encrypt. Απαιτείται δημόσιος τομέας και πρόσβαση στη θύρα 80 ή DNS.", "sslDocsLink": "Προβολή εγγράφων SSL", - "sslDomain": "Πεδίο ορισμού", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Το όνομα δημόσιου τομέα για το πιστοποιητικό.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Ηλεκτρονική διεύθυνση ηλεκτρονικού ταχυδρομείου επικοινωνίας για τις ειδοποιήσεις και τον λογαριασμό Let's Encrypt.", - "sslChallengeType": "Τύπος πρόκλησης", - "sslChallengeTypeDesc": "Πώς να αποδείξετε την κατοχή του domain στο Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - απαιτεί θύρα 80 προσβάσιμη από το διαδίκτυο", - "sslChallengeDns": "DNS (Cloudflare) - απαιτεί διακριτικό API Cloudflare", - "sslCloudflareToken": "Διακριτικό API Cloudflare", - "sslCloudflareTokenPlaceholder": "Εισαγάγετε διακριτικό...", - "sslCloudflareTokenDesc": "Διακριτικό πεδίου εφαρμογής με δικαίωμα Zone:DNS:Edit για τον τομέα σας.", "sslCertStatus": "Κατάσταση πιστοποιητικού", "sslCertStatusNone": "Δεν υπάρχει πιστοποιητικό", "sslCertStatusValid": "Εγκυρος", "sslCertStatusExpiring": "Λήγει σύντομα", "sslCertStatusExpired": "Λήξη", "sslCertExpiresAt": "Λήγει {{date}}", - "sslLastIssued": "Τελευταία έκδοση {{date}}", - "sslRequestCert": "Έκδοση / Ανανέωση Πιστοποιητικού", - "sslRequestCertLoading": "Αίτημα πιστοποιητικού...", - "sslRequestCertSuccess": "Το πιστοποιητικό εκδόθηκε και εγκαταστάθηκε με επιτυχία", - "sslRequestCertFailed": "Το αίτημα πιστοποιητικού απέτυχε", - "sslSave": "Αποθήκευση ρυθμίσεων", - "sslSaved": "Οι ρυθμίσεις SSL αποθηκεύτηκαν", - "sslSaveFailed": "Αποτυχία αποθήκευσης ρυθμίσεων SSL", - "sslRequiresDomain": "Απαιτούνται ο τομέας και το email", "sslInfoNote": "Μετά την έκδοση ή την αποστολή ενός πιστοποιητικού, το Termix θα ενεργοποιήσει αυτόματα το HTTPS στο SSL_PORT (προεπιλογή 8443) επιπλέον της υπάρχουσας θύρας HTTP. Βεβαιωθείτε ότι η θύρα έχει δημοσιευτεί/αντιστοιχιστεί σε αυτό το κοντέινερ, ώστε να είναι προσβάσιμη.", - "sslManualOption": "Εγχειρίδιο (μεταφόρτωση πιστοποιητικού)", "sslManualCert": "Πιστοποιητικό (PEM)", "sslManualCertPlaceholder": "-----ΕΝΑΡΞΗ ΠΙΣΤΟΠΟΙΗΤΙΚΟΥ-----", "sslManualKey": "Ιδιωτικό Κλειδί (PEM)", diff --git a/src/ui/locales/translated/es_ES.json b/src/ui/locales/translated/es_ES.json index da5d1b60f..5c5f9500b 100644 --- a/src/ui/locales/translated/es_ES.json +++ b/src/ui/locales/translated/es_ES.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "No se pudieron guardar los ajustes de marca.", "sslDescription": "Emitir y renovar automáticamente un certificado SSL de confianza de Let's Encrypt. Requiere un dominio público y acceso al puerto 80 o DNS.", "sslDocsLink": "Ver documentación SSL", - "sslDomain": "Dominio", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "El nombre de dominio público para el certificado.", - "sslEmail": "Correo electrónico", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Correo electrónico de contacto para notificaciones y cuenta de Let's Encrypt.", - "sslChallengeType": "Tipo de desafío", - "sslChallengeTypeDesc": "Cómo demostrar la propiedad del dominio a Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - requiere el puerto 80 accesible desde internet", - "sslChallengeDns": "DNS (Cloudflare) - requiere un token de API de Cloudflare", - "sslCloudflareToken": "Token de API de Cloudflare", - "sslCloudflareTokenPlaceholder": "Introducir token...", - "sslCloudflareTokenDesc": "Token con permiso Zone:DNS:Edit limitado para tu dominio.", "sslCertStatus": "Estado del certificado", "sslCertStatusNone": "Sin certificado", "sslCertStatusValid": "Válido", "sslCertStatusExpiring": "Caduca pronto", "sslCertStatusExpired": "Caducado", "sslCertExpiresAt": "Caduca {{date}}", - "sslLastIssued": "Última emisión {{date}}", - "sslRequestCert": "Emitir / Renovar certificado", - "sslRequestCertLoading": "Solicitando certificado...", - "sslRequestCertSuccess": "Certificado emitido e instalado correctamente", - "sslRequestCertFailed": "La solicitud del certificado falló", - "sslSave": "Guardar configuración", - "sslSaved": "Configuración SSL guardada", - "sslSaveFailed": "Error al guardar la configuración SSL", - "sslRequiresDomain": "Se requieren dominio y correo electrónico", "sslInfoNote": "Tras emitir o cargar un certificado, Termix habilitará automáticamente HTTPS en SSL_PORT (por defecto, 8443), además del puerto HTTP existente. Asegúrese de que dicho puerto esté publicado o asignado a este contenedor para que sea accesible.", - "sslManualOption": "Manual (subir certificado)", "sslManualCert": "Certificado (PEM)", "sslManualCertPlaceholder": "-----INICIO DEL CERTIFICADO-----", "sslManualKey": "Clave privada (PEM)", diff --git a/src/ui/locales/translated/fi_FI.json b/src/ui/locales/translated/fi_FI.json index 6bb6f3cf8..1b1d3a1db 100644 --- a/src/ui/locales/translated/fi_FI.json +++ b/src/ui/locales/translated/fi_FI.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Brändäysasetusten tallentaminen epäonnistui", "sslDescription": "Myönnä ja uusi automaattisesti Let's Encryptin luotettava SSL-varmenne. Vaatii julkisen verkkotunnuksen ja portin 80 tai DNS-yhteyden.", "sslDocsLink": "Näytä SSL-dokumentit", - "sslDomain": "Verkkotunnus", - "sslDomainPlaceholder": "termix.esimerkki.com", - "sslDomainDesc": "Varmenteen julkinen verkkotunnus.", - "sslEmail": "Sähköposti", - "sslEmailPlaceholder": "admin@esimerkki.com", - "sslEmailDesc": "Let's Encrypt -ilmoitusten ja -tilin yhteystiedot.", - "sslChallengeType": "Haasteen tyyppi", - "sslChallengeTypeDesc": "Kuinka todistaa verkkotunnuksen omistajuus Let's Encryptille.", - "sslChallengeHttp": "HTTP (webroot) – vaatii internetistä saatavilla olevan portin 80", - "sslChallengeDns": "DNS (Cloudflare) – vaatii Cloudflare API -tunnuksen", - "sslCloudflareToken": "Cloudflare API-tunnus", - "sslCloudflareTokenPlaceholder": "Syötä tunnus...", - "sslCloudflareTokenDesc": "Laajuusaluetunnus, jolla on Zone:DNS:Edit-käyttöoikeus verkkotunnuksellesi.", "sslCertStatus": "Sertifikaatin tila", "sslCertStatusNone": "Ei todistusta", "sslCertStatusValid": "Voimassa", "sslCertStatusExpiring": "Vanhenee pian", "sslCertStatusExpired": "Vanhentunut", "sslCertExpiresAt": "Vanhenee {{date}}", - "sslLastIssued": "Viimeksi myönnetty {{date}}", - "sslRequestCert": "Myönnä / Uudista sertifikaatti", - "sslRequestCertLoading": "Pyydetään varmennetta...", - "sslRequestCertSuccess": "Varmenne myönnetty ja asennettu onnistuneesti", - "sslRequestCertFailed": "Varmennepyyntö epäonnistui", - "sslSave": "Tallenna asetukset", - "sslSaved": "SSL-asetukset tallennettu", - "sslSaveFailed": "SSL-asetusten tallentaminen epäonnistui", - "sslRequiresDomain": "Verkkotunnus ja sähköpostiosoite vaaditaan", "sslInfoNote": "Kun varmenne on myönnetty tai ladattu, Termix ottaa automaattisesti käyttöön HTTPS:n SSL_PORT-portissa (oletusarvo 8443) olemassa olevan HTTP-portin lisäksi. Varmista, että portti on julkaistu/yhdistetty tähän säilöön, jotta se on tavoitettavissa.", - "sslManualOption": "Manuaalinen (lataa varmenne)", "sslManualCert": "Todistus (PEM)", "sslManualCertPlaceholder": "-----ALKUTODISTUS-----", "sslManualKey": "Yksityinen avain (PEM)", diff --git a/src/ui/locales/translated/fr_FR.json b/src/ui/locales/translated/fr_FR.json index 0baebae38..2f5fd0a85 100644 --- a/src/ui/locales/translated/fr_FR.json +++ b/src/ui/locales/translated/fr_FR.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Impossible d'enregistrer les paramètres de marque", "sslDescription": "Émettre et renouveler automatiquement un certificat SSL de confiance via Let's Encrypt. Requiert un domaine public et un accès au port 80 ou au DNS.", "sslDocsLink": "Voir la documentation SSL", - "sslDomain": "Domaine", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Le nom de domaine public pour le certificat.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Adresse e-mail de contact pour les notifications et le compte Let's Encrypt.", - "sslChallengeType": "Type de challenge", - "sslChallengeTypeDesc": "Méthode pour prouver la propriété du domaine à Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - nécessite le port 80 accessible depuis Internet", - "sslChallengeDns": "DNS (Cloudflare) - nécessite un jeton API Cloudflare", - "sslCloudflareToken": "Jeton API Cloudflare", - "sslCloudflareTokenPlaceholder": "Saisissez le jeton...", - "sslCloudflareTokenDesc": "Jeton limité avec l'autorisation Zone:DNS:Edit pour votre domaine.", "sslCertStatus": "Statut du certificat", "sslCertStatusNone": "Aucun certificat", "sslCertStatusValid": "Valide", "sslCertStatusExpiring": "Expiration prochaine", "sslCertStatusExpired": "Expiré", "sslCertExpiresAt": "Expire le {{date}}", - "sslLastIssued": "Dernière émission le {{date}}", - "sslRequestCert": "Émettre / Renouveler le certificat", - "sslRequestCertLoading": "Demande de certificat en cours...", - "sslRequestCertSuccess": "Certificat émis et installé avec succès", - "sslRequestCertFailed": "Échec de la demande de certificat", - "sslSave": "Enregistrer les paramètres", - "sslSaved": "Paramètres SSL enregistrés", - "sslSaveFailed": "Échec de l'enregistrement des paramètres SSL", - "sslRequiresDomain": "Le domaine et l'e-mail sont requis", "sslInfoNote": "Après l'émission ou le chargement d'un certificat, Termix activera automatiquement le protocole HTTPS sur le port SSL (8443 par défaut), en plus du port HTTP existant. Assurez-vous que ce port est publié/associé à ce conteneur afin qu'il soit accessible.", - "sslManualOption": "Manuel (télécharger le certificat)", "sslManualCert": "Certificat (PEM)", "sslManualCertPlaceholder": "-----DÉBUT DU CERTIFICAT-----", "sslManualKey": "Clé privée (PEM)", diff --git a/src/ui/locales/translated/he_IL.json b/src/ui/locales/translated/he_IL.json index 68c82f580..8266df124 100644 --- a/src/ui/locales/translated/he_IL.json +++ b/src/ui/locales/translated/he_IL.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "שמירת הגדרות המיתוג נכשלה", "sslDescription": "הנפקה וחידוש אוטומטית של תעודת SSL מהימנה מ-Let's Encrypt. דורשת גישת דומיין ציבורי ופורט 80 או גישת DNS.", "sslDocsLink": "הצג מסמכי SSL", - "sslDomain": "תְחוּם", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "שם התחום הציבורי עבור התעודה.", - "sslEmail": "אֶלֶקטרוֹנִי", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "כתובת דוא\"ל ליצירת קשר עבור התראות וחשבון של Let's Encrypt.", - "sslChallengeType": "סוג האתגר", - "sslChallengeTypeDesc": "כיצד להוכיח בעלות על דומיין ל-Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - דורש פורט 80 נגיש מהאינטרנט", - "sslChallengeDns": "DNS (Cloudflare) - דורש אסימון API של Cloudflare", - "sslCloudflareToken": "אסימון API של Cloudflare", - "sslCloudflareTokenPlaceholder": "הזן אסימון...", - "sslCloudflareTokenDesc": "אסימון בטווח עם הרשאת עריכה של אזור:DNS: עבור הדומיין שלך.", "sslCertStatus": "סטטוס התעודה", "sslCertStatusNone": "אין תעודה", "sslCertStatusValid": "תָקֵף", "sslCertStatusExpiring": "פג תוקף בקרוב", "sslCertStatusExpired": "פג תוקף", "sslCertExpiresAt": "פג תוקף {{date}}", - "sslLastIssued": "הוצאה אחרונה {{date}}", - "sslRequestCert": "הנפקה / חידוש תעודה", - "sslRequestCertLoading": "מבקש תעודה...", - "sslRequestCertSuccess": "התעודה הונפקה והותקנה בהצלחה", - "sslRequestCertFailed": "בקשת האישור נכשלה", - "sslSave": "שמור הגדרות", - "sslSaved": "הגדרות SSL נשמרו", - "sslSaveFailed": "שמירת הגדרות SSL נכשלה", - "sslRequiresDomain": "נדרשים דומיין וכתובת אימייל", "sslInfoNote": "לאחר הנפקה או העלאה של אישור, Termix יאפשר אוטומטית HTTPS ב-SSL_PORT (ברירת מחדל 8443) בנוסף לפורט HTTP הקיים. ודא שהפורט מפורסם/ממופה למכולה זו כך שניתן יהיה להגיע אליו.", - "sslManualOption": "מדריך (העלאת תעודה)", "sslManualCert": "תעודה (PEM)", "sslManualCertPlaceholder": "-----התחלת תעודה-----", "sslManualKey": "מפתח פרטי (PEM)", diff --git a/src/ui/locales/translated/hi_IN.json b/src/ui/locales/translated/hi_IN.json index 97980fcf7..bb156951d 100644 --- a/src/ui/locales/translated/hi_IN.json +++ b/src/ui/locales/translated/hi_IN.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "ब्रांडिंग सेटिंग्स सहेजने में विफल", "sslDescription": "Let's Encrypt से एक विश्वसनीय SSL प्रमाणपत्र स्वचालित रूप से जारी करें और नवीनीकृत करें। इसके लिए सार्वजनिक डोमेन और पोर्ट 80 या DNS पहुँच आवश्यक है।", "sslDocsLink": "SSL दस्तावेज़ देखें", - "sslDomain": "डोमेन", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "प्रमाणपत्र के लिए सार्वजनिक डोमेन नाम।", - "sslEmail": "ईमेल", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Let's Encrypt सूचनाओं और खाते के लिए संपर्क ईमेल।", - "sslChallengeType": "चुनौती प्रकार", - "sslChallengeTypeDesc": "Let's Encrypt को डोमेन स्वामित्व साबित करने का तरीका।", - "sslChallengeHttp": "HTTP (webroot) - इंटरनेट से पोर्ट 80 सुलभ होना आवश्यक है", - "sslChallengeDns": "DNS (Cloudflare) - Cloudflare API टोकन आवश्यक है", - "sslCloudflareToken": "Cloudflare API टोकन", - "sslCloudflareTokenPlaceholder": "टोकन दर्ज करें...", - "sslCloudflareTokenDesc": "आपके डोमेन के लिए Zone:DNS:Edit अनुमति वाला स्कोप्ड टोकन।", "sslCertStatus": "प्रमाणपत्र स्थिति", "sslCertStatusNone": "कोई प्रमाणपत्र नहीं", "sslCertStatusValid": "मान्य", "sslCertStatusExpiring": "जल्द समाप्त हो रहा है", "sslCertStatusExpired": "समाप्त", "sslCertExpiresAt": "{{date}} को समाप्त होगा", - "sslLastIssued": "अंतिम जारी: {{date}}", - "sslRequestCert": "प्रमाणपत्र जारी करें / नवीनीकरण करें", - "sslRequestCertLoading": "प्रमाणपत्र अनुरोध किया जा रहा है...", - "sslRequestCertSuccess": "प्रमाणपत्र सफलतापूर्वक जारी और स्थापित कर दिया गया", - "sslRequestCertFailed": "प्रमाणपत्र अनुरोध विफल", - "sslSave": "सेटिंग सहेजें", - "sslSaved": "SSL सेटिंग सहेजी गई", - "sslSaveFailed": "SSL सेटिंग सहेजने में विफल", - "sslRequiresDomain": "डोमेन और ईमेल आवश्यक हैं", "sslInfoNote": "सर्टिफिकेट जारी करने या अपलोड करने के बाद, टर्मिक्स मौजूदा HTTP पोर्ट के अलावा SSL_PORT (डिफ़ॉल्ट 8443) पर HTTPS को स्वचालित रूप से सक्षम कर देगा। सुनिश्चित करें कि यह पोर्ट इस कंटेनर पर प्रकाशित/मैप किया गया है ताकि यह पहुंच योग्य हो।", - "sslManualOption": "मैन्युअल (प्रमाणपत्र अपलोड करें)", "sslManualCert": "प्रमाणपत्र (पीईएम)", "sslManualCertPlaceholder": "-----प्रमाणपत्र प्रारंभ-----", "sslManualKey": "निजी कुंजी (पीईएम)", diff --git a/src/ui/locales/translated/hu_HU.json b/src/ui/locales/translated/hu_HU.json index 9ed2d7841..f96d9b688 100644 --- a/src/ui/locales/translated/hu_HU.json +++ b/src/ui/locales/translated/hu_HU.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Nem sikerült menteni a márkabeállításokat", "sslDescription": "Automatikusan kibocsát és megújít egy megbízható SSL-tanúsítványt a Let's Encrypt-től. Nyilvános domaint és 80-as portot vagy DNS-hozzáférést igényel.", "sslDocsLink": "SSL-dokumentációk megtekintése", - "sslDomain": "Domain", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "A tanúsítvány nyilvános domainneve.", - "sslEmail": "Email", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Kapcsolattartási e-mail cím a Let's Encrypt értesítéseivel és fiókjával kapcsolatban.", - "sslChallengeType": "Kihívás típusa", - "sslChallengeTypeDesc": "Hogyan lehet igazolni a domain tulajdonjogát a Let's Encrypt számára?", - "sslChallengeHttp": "HTTP (webroot) – internetről elérhető 80-as portot igényel", - "sslChallengeDns": "DNS (Cloudflare) – Cloudflare API tokent igényel", - "sslCloudflareToken": "Cloudflare API token", - "sslCloudflareTokenPlaceholder": "Token megadása...", - "sslCloudflareTokenDesc": "Hatókörbe tartozó token Zóna:DNS:Szerkesztés engedéllyel a domainhez.", "sslCertStatus": "Tanúsítvány állapota", "sslCertStatusNone": "Nincs tanúsítvány", "sslCertStatusValid": "Érvényes", "sslCertStatusExpiring": "Hamarosan lejár", "sslCertStatusExpired": "Lejárt", "sslCertExpiresAt": "Lejár {{date}}", - "sslLastIssued": "Utolsó kiadás {{date}}", - "sslRequestCert": "Tanúsítvány kiállítása / megújítása", - "sslRequestCertLoading": "Tanúsítvány kérése...", - "sslRequestCertSuccess": "A tanúsítvány kiadva és sikeresen telepítve", - "sslRequestCertFailed": "Tanúsítványkérés sikertelen", - "sslSave": "Beállítások mentése", - "sslSaved": "SSL-beállítások mentve", - "sslSaveFailed": "Nem sikerült menteni az SSL-beállításokat", - "sslRequiresDomain": "Domain és email cím megadása kötelező", "sslInfoNote": "Tanúsítvány kiállítása vagy feltöltése után a Termix automatikusan engedélyezi a HTTPS-t az SSL_PORT-on (alapértelmezett: 8443) a meglévő HTTP port mellett. Győződjön meg arról, hogy a port közzé van téve/hozzá van rendelve ehhez a konténerhez, hogy elérhető legyen.", - "sslManualOption": "Kézikönyv (tanúsítvány feltöltése)", "sslManualCert": "Tanúsítvány (PEM)", "sslManualCertPlaceholder": "-----TANÚSÍTVÁNY KEZDETE-----", "sslManualKey": "Privát kulcs (PEM)", diff --git a/src/ui/locales/translated/id_ID.json b/src/ui/locales/translated/id_ID.json index 9050d1c6c..33aef99dd 100644 --- a/src/ui/locales/translated/id_ID.json +++ b/src/ui/locales/translated/id_ID.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Gagal menyimpan pengaturan merek.", "sslDescription": "Terbitkan dan perbarui sertifikat SSL tepercaya dari Let's Encrypt secara otomatis. Membutuhkan domain publik dan port 80 atau akses DNS.", "sslDocsLink": "Lihat dokumentasi SSL", - "sslDomain": "Domain", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Nama domain publik untuk sertifikat tersebut.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Alamat email kontak untuk notifikasi dan akun Let's Encrypt.", - "sslChallengeType": "Jenis Tantangan", - "sslChallengeTypeDesc": "Bagaimana cara membuktikan kepemilikan domain kepada Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - memerlukan port 80 yang dapat diakses dari internet.", - "sslChallengeDns": "DNS (Cloudflare) - memerlukan token API Cloudflare", - "sslCloudflareToken": "Token API Cloudflare", - "sslCloudflareTokenPlaceholder": "Masukkan token...", - "sslCloudflareTokenDesc": "Token terlingkup dengan izin Zone:DNS:Edit untuk domain Anda.", "sslCertStatus": "Status Sertifikat", "sslCertStatusNone": "Tidak ada sertifikat", "sslCertStatusValid": "Sah", "sslCertStatusExpiring": "Segera berakhir", "sslCertStatusExpired": "Kedaluwarsa", "sslCertExpiresAt": "Berakhir {{date}}", - "sslLastIssued": "Terakhir diterbitkan {{date}}", - "sslRequestCert": "Menerbitkan/Memperpanjang Sertifikat", - "sslRequestCertLoading": "Meminta sertifikat...", - "sslRequestCertSuccess": "Sertifikat telah diterbitkan dan diinstal dengan sukses.", - "sslRequestCertFailed": "Permintaan sertifikat gagal.", - "sslSave": "Simpan Pengaturan", - "sslSaved": "Pengaturan SSL tersimpan", - "sslSaveFailed": "Gagal menyimpan pengaturan SSL", - "sslRequiresDomain": "Nama domain dan email wajib diisi.", "sslInfoNote": "Setelah menerbitkan atau mengunggah sertifikat, Termix akan secara otomatis mengaktifkan HTTPS pada SSL_PORT (default 8443) di samping port HTTP yang sudah ada. Pastikan port tersebut dipublikasikan/dipetakan ke kontainer ini agar dapat dijangkau.", - "sslManualOption": "Manual (unggah sertifikat)", "sslManualCert": "Sertifikat (PEM)", "sslManualCertPlaceholder": "-----MULAI SERTIFIKAT-----", "sslManualKey": "Kunci Pribadi (PEM)", diff --git a/src/ui/locales/translated/it_IT.json b/src/ui/locales/translated/it_IT.json index 8b3fbe53f..50aaac6ca 100644 --- a/src/ui/locales/translated/it_IT.json +++ b/src/ui/locales/translated/it_IT.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Impossibile salvare le impostazioni del marchio.", "sslDescription": "Emettere e rinnovare automaticamente un certificato SSL attendibile da Let's Encrypt. Richiede un dominio pubblico e accesso alla porta 80 o al DNS.", "sslDocsLink": "Visualizza documentazione SSL", - "sslDomain": "Dominio", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Il nome di dominio pubblico per il certificato.", - "sslEmail": "Email", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Email di contatto per notifiche e account Let's Encrypt.", - "sslChallengeType": "Tipo di challenge", - "sslChallengeTypeDesc": "Come dimostrare la proprietà del dominio a Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - richiede la porta 80 accessibile da internet", - "sslChallengeDns": "DNS (Cloudflare) - richiede un token API Cloudflare", - "sslCloudflareToken": "Token API Cloudflare", - "sslCloudflareTokenPlaceholder": "Inserisci token...", - "sslCloudflareTokenDesc": "Token con autorizzazione Zone:DNS:Edit per il tuo dominio.", "sslCertStatus": "Stato del certificato", "sslCertStatusNone": "Nessun certificato", "sslCertStatusValid": "Valido", "sslCertStatusExpiring": "In scadenza", "sslCertStatusExpired": "Scaduto", "sslCertExpiresAt": "Scade il {{date}}", - "sslLastIssued": "Ultimo rilascio il {{date}}", - "sslRequestCert": "Emetti / Rinnova certificato", - "sslRequestCertLoading": "Richiesta certificato in corso...", - "sslRequestCertSuccess": "Certificato emesso e installato con successo", - "sslRequestCertFailed": "Richiesta certificato fallita", - "sslSave": "Salva impostazioni", - "sslSaved": "Impostazioni SSL salvate", - "sslSaveFailed": "Salvataggio impostazioni SSL fallito", - "sslRequiresDomain": "Dominio ed email sono obbligatori", "sslInfoNote": "Dopo aver emesso o caricato un certificato, Termix abiliterà automaticamente HTTPS sulla porta SSL_PORT (predefinita 8443) oltre alla porta HTTP esistente. Assicurarsi che tale porta sia pubblicata/mappata a questo container in modo che sia raggiungibile.", - "sslManualOption": "Manuale (caricamento del certificato)", "sslManualCert": "Certificato (PEM)", "sslManualCertPlaceholder": "-----INIZIO CERTIFICATO-----", "sslManualKey": "Chiave privata (PEM)", diff --git a/src/ui/locales/translated/ja_JP.json b/src/ui/locales/translated/ja_JP.json index a215eff2d..e04c5b497 100644 --- a/src/ui/locales/translated/ja_JP.json +++ b/src/ui/locales/translated/ja_JP.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "ブランド設定の保存に失敗しました", "sslDescription": "Let's Encryptから信頼できるSSL証明書を自動的に発行・更新します。公開ドメインとポート80、またはDNSアクセスが必要です。", "sslDocsLink": "SSLドキュメントを表示", - "sslDomain": "ドメイン", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "証明書の公開ドメイン名。", - "sslEmail": "メールアドレス", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Let's Encryptの通知およびアカウント用の連絡先メールアドレス。", - "sslChallengeType": "チャレンジタイプ", - "sslChallengeTypeDesc": "Let's Encryptにドメイン所有権を証明する方法。", - "sslChallengeHttp": "HTTP(webroot)- インターネットからポート80にアクセスできる必要があります", - "sslChallengeDns": "DNS(Cloudflare)- Cloudflare APIトークンが必要です", - "sslCloudflareToken": "Cloudflare APIトークン", - "sslCloudflareTokenPlaceholder": "トークンを入力...", - "sslCloudflareTokenDesc": "ドメインに対するZone:DNS:Edit権限を持つスコープ付きトークン。", "sslCertStatus": "証明書ステータス", "sslCertStatusNone": "証明書なし", "sslCertStatusValid": "有効", "sslCertStatusExpiring": "間もなく期限切れ", "sslCertStatusExpired": "期限切れ", "sslCertExpiresAt": "有効期限 {{date}}", - "sslLastIssued": "最終発行日 {{date}}", - "sslRequestCert": "証明書を発行/更新", - "sslRequestCertLoading": "証明書をリクエスト中...", - "sslRequestCertSuccess": "証明書が正常に発行され、インストールされました", - "sslRequestCertFailed": "証明書リクエストに失敗しました", - "sslSave": "設定を保存", - "sslSaved": "SSL設定が保存されました", - "sslSaveFailed": "SSL設定の保存に失敗しました", - "sslRequiresDomain": "ドメインとメールアドレスが必要です", "sslInfoNote": "証明書の発行またはアップロード後、Termixは既存のHTTPポートに加えて、SSL_PORT(デフォルトは8443)でHTTPSを自動的に有効にします。このポートがコンテナに公開/マッピングされ、アクセス可能であることを確認してください。", - "sslManualOption": "手動(証明書のアップロード)", "sslManualCert": "証明書(PEM)", "sslManualCertPlaceholder": "-----証明書開始-----", "sslManualKey": "秘密鍵(PEM形式)", diff --git a/src/ui/locales/translated/ko_KR.json b/src/ui/locales/translated/ko_KR.json index a85830ced..7f7135e86 100644 --- a/src/ui/locales/translated/ko_KR.json +++ b/src/ui/locales/translated/ko_KR.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "브랜딩 설정을 저장하는 데 실패했습니다.", "sslDescription": "Let's Encrypt에서 신뢰할 수 있는 SSL 인증서를 자동으로 발급 및 갱신합니다. 공개 도메인과 포트 80 또는 DNS 액세스가 필요합니다.", "sslDocsLink": "SSL 문서 보기", - "sslDomain": "도메인", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "인증서에 사용할 공개 도메인 이름입니다.", - "sslEmail": "이메일", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Let's Encrypt 알림 및 계정용 연락처 이메일입니다.", - "sslChallengeType": "인증 방식", - "sslChallengeTypeDesc": "Let's Encrypt에 도메인 소유권을 증명하는 방법입니다.", - "sslChallengeHttp": "HTTP(webroot) - 인터넷에서 포트 80에 액세스할 수 있어야 함", - "sslChallengeDns": "DNS(Cloudflare) - Cloudflare API 토큰 필요", - "sslCloudflareToken": "Cloudflare API 토큰", - "sslCloudflareTokenPlaceholder": "토큰 입력...", - "sslCloudflareTokenDesc": "도메인에 대한 Zone:DNS:Edit 권한이 있는 범위 지정 토큰입니다.", "sslCertStatus": "인증서 상태", "sslCertStatusNone": "인증서 없음", "sslCertStatusValid": "유효함", "sslCertStatusExpiring": "곧 만료됨", "sslCertStatusExpired": "만료됨", "sslCertExpiresAt": "만료일: {{date}}", - "sslLastIssued": "마지막 발급일: {{date}}", - "sslRequestCert": "인증서 발급 / 갱신", - "sslRequestCertLoading": "인증서 요청 중...", - "sslRequestCertSuccess": "인증서가 성공적으로 발급 및 설치되었습니다.", - "sslRequestCertFailed": "인증서 요청에 실패했습니다.", - "sslSave": "설정 저장", - "sslSaved": "SSL 설정이 저장되었습니다.", - "sslSaveFailed": "SSL 설정 저장에 실패했습니다.", - "sslRequiresDomain": "도메인과 이메일은 필수입니다.", "sslInfoNote": "인증서를 발급하거나 업로드하면 Termix는 기존 HTTP 포트 외에도 SSL_PORT(기본값 8443)에서 HTTPS를 자동으로 활성화합니다. 해당 포트가 컨테이너에 접근 가능하도록 게시/매핑되어 있는지 확인하십시오.", - "sslManualOption": "수동 업로드 (인증서 업로드)", "sslManualCert": "인증서(PEM)", "sslManualCertPlaceholder": "-----시작 인증서-----", "sslManualKey": "개인 키(PEM)", diff --git a/src/ui/locales/translated/nl_NL.json b/src/ui/locales/translated/nl_NL.json index e948a11aa..8723b3add 100644 --- a/src/ui/locales/translated/nl_NL.json +++ b/src/ui/locales/translated/nl_NL.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Het opslaan van de merkinstellingen is mislukt.", "sslDescription": "Automatisch een vertrouwd SSL-certificaat van Let's Encrypt aanvragen en verlengen. Vereist een publiek domein en toegang tot poort 80 of DNS.", "sslDocsLink": "Bekijk de SSL-documentatie", - "sslDomain": "Domein", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "De publieke domeinnaam voor het certificaat.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Contact-e-mailadres voor Let's Encrypt-meldingen en accountinformatie.", - "sslChallengeType": "Uitdagingstype", - "sslChallengeTypeDesc": "Hoe bewijs je domeineigendom aan Let's Encrypt?", - "sslChallengeHttp": "HTTP (webroot) - vereist poort 80 die toegankelijk is vanaf internet.", - "sslChallengeDns": "DNS (Cloudflare) - vereist een Cloudflare API-token", - "sslCloudflareToken": "Cloudflare API-token", - "sslCloudflareTokenPlaceholder": "Voer token in...", - "sslCloudflareTokenDesc": "Een scoped token met Zone:DNS:Edit-machtiging voor uw domein.", "sslCertStatus": "Certificaatstatus", "sslCertStatusNone": "Geen certificaat", "sslCertStatusValid": "Geldig", "sslCertStatusExpiring": "Verloopt binnenkort", "sslCertStatusExpired": "Verlopen", "sslCertExpiresAt": "Verloopt {{date}}", - "sslLastIssued": "Laatst uitgegeven {{date}}", - "sslRequestCert": "Certificaat aanvragen/verlengen", - "sslRequestCertLoading": "Certificaat aanvragen...", - "sslRequestCertSuccess": "Certificaat uitgegeven en succesvol geïnstalleerd", - "sslRequestCertFailed": "Certificaatverzoek mislukt", - "sslSave": "Instellingen opslaan", - "sslSaved": "SSL-instellingen opgeslagen", - "sslSaveFailed": "Het opslaan van de SSL-instellingen is mislukt.", - "sslRequiresDomain": "Domein en e-mailadres zijn verplicht.", "sslInfoNote": "Na het uitgeven of uploaden van een certificaat zal Termix automatisch HTTPS inschakelen op SSL_PORT (standaard 8443) naast de bestaande HTTP-poort. Zorg ervoor dat deze poort is gepubliceerd/toegewezen aan deze container, zodat deze bereikbaar is.", - "sslManualOption": "Handmatig (certificaat uploaden)", "sslManualCert": "Certificaat (PEM)", "sslManualCertPlaceholder": "-----BEGIN CERTIFICAAT-----", "sslManualKey": "Privésleutel (PEM)", diff --git a/src/ui/locales/translated/no_NO.json b/src/ui/locales/translated/no_NO.json index a992df317..eb807e659 100644 --- a/src/ui/locales/translated/no_NO.json +++ b/src/ui/locales/translated/no_NO.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Kunne ikke lagre merkevareinnstillingene", "sslDescription": "Utsted og forny automatisk et klarert SSL-sertifikat fra Let's Encrypt. Krever et offentlig domene og port 80 eller DNS-tilgang.", "sslDocsLink": "Vis SSL-dokumenter", - "sslDomain": "Domene", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Det offentlige domenenavnet for sertifikatet.", - "sslEmail": "E-post", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Kontakt-e-postadresse for Let's Encrypt-varsler og konto.", - "sslChallengeType": "Utfordringstype", - "sslChallengeTypeDesc": "Slik beviser du domeneeierskap til Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) – krever port 80 tilgjengelig fra internett", - "sslChallengeDns": "DNS (Cloudflare) – krever et Cloudflare API-token", - "sslCloudflareToken": "Cloudflare API-token", - "sslCloudflareTokenPlaceholder": "Skriv inn token...", - "sslCloudflareTokenDesc": "Scope-token med Zone:DNS:Rediger-tillatelse for domenet ditt.", "sslCertStatus": "Sertifikatstatus", "sslCertStatusNone": "Ingen sertifikat", "sslCertStatusValid": "Gyldig", "sslCertStatusExpiring": "Utløper snart", "sslCertStatusExpired": "Utløpt", "sslCertExpiresAt": "Utløper {{date}}", - "sslLastIssued": "Sist utstedt {{date}}", - "sslRequestCert": "Utstede/fornye sertifikat", - "sslRequestCertLoading": "Be om sertifikat...", - "sslRequestCertSuccess": "Sertifikatet er utstedt og installert", - "sslRequestCertFailed": "Sertifikatforespørsel mislyktes", - "sslSave": "Lagre innstillinger", - "sslSaved": "SSL-innstillinger lagret", - "sslSaveFailed": "Kunne ikke lagre SSL-innstillingene", - "sslRequiresDomain": "Domene og e-post er obligatorisk", "sslInfoNote": "Etter utstedelse eller opplasting av et sertifikat, vil Termix automatisk aktivere HTTPS på SSL_PORT (standard 8443) i tillegg til den eksisterende HTTP-porten. Sørg for at porten er publisert/tilordnet til denne containeren slik at den er tilgjengelig.", - "sslManualOption": "Manual (last opp sertifikat)", "sslManualCert": "Sertifikat (PEM)", "sslManualCertPlaceholder": "-----STARTSERTIFIKAT-----", "sslManualKey": "Privat nøkkel (PEM)", diff --git a/src/ui/locales/translated/pl_PL.json b/src/ui/locales/translated/pl_PL.json index 7cf14565b..b634fd397 100644 --- a/src/ui/locales/translated/pl_PL.json +++ b/src/ui/locales/translated/pl_PL.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Nie udało się zapisać ustawień marki", "sslDescription": "Automatycznie wystawiaj i odnawiaj zaufany certyfikat SSL od Let's Encrypt. Wymaga domeny publicznej i portu 80 lub dostępu DNS.", "sslDocsLink": "Zobacz dokumentację SSL", - "sslDomain": "Domena", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Publiczna nazwa domeny certyfikatu.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Adres e-mail kontaktowy do powiadomień i konta Let's Encrypt.", - "sslChallengeType": "Typ wyzwania", - "sslChallengeTypeDesc": "Jak udowodnić Let's Encrypt własność domeny.", - "sslChallengeHttp": "HTTP (webroot) - wymaga portu 80 dostępnego z internetu", - "sslChallengeDns": "DNS (Cloudflare) - wymaga tokenu API Cloudflare", - "sslCloudflareToken": "Token API Cloudflare", - "sslCloudflareTokenPlaceholder": "Wprowadź token...", - "sslCloudflareTokenDesc": "Token zakresowy z uprawnieniem Zone:DNS:Edit dla Twojej domeny.", "sslCertStatus": "Status certyfikatu", "sslCertStatusNone": "Brak certyfikatu", "sslCertStatusValid": "Ważny", "sslCertStatusExpiring": "Wkrótce wygasa", "sslCertStatusExpired": "Wygasły", "sslCertExpiresAt": "Wygasa {{date}}", - "sslLastIssued": "Ostatnio wystawiono {{date}}", - "sslRequestCert": "Wystaw / odnów certyfikat", - "sslRequestCertLoading": "Żądanie certyfikatu...", - "sslRequestCertSuccess": "Pomyślnie wystawiono i zainstalowano certyfikat", - "sslRequestCertFailed": "Żądanie certyfikatu nie powiodło się", - "sslSave": "Zapisz ustawienia", - "sslSaved": "Ustawienia SSL zapisane", - "sslSaveFailed": "Nie udało się zapisać ustawień SSL", - "sslRequiresDomain": "Wymagane są domena i adres e-mail", "sslInfoNote": "Po wystawieniu lub przesłaniu certyfikatu, Termix automatycznie włączy protokół HTTPS na porcie SSL_PORT (domyślnie 8443) oprócz istniejącego portu HTTP. Upewnij się, że port ten jest opublikowany/zamapowany w tym kontenerze, aby był osiągalny.", - "sslManualOption": "Ręcznie (prześlij certyfikat)", "sslManualCert": "Certyfikat (PEM)", "sslManualCertPlaceholder": "-----BEGIN CERTIFICATE-----", "sslManualKey": "Klucz prywatny (PEM)", diff --git a/src/ui/locales/translated/pt_BR.json b/src/ui/locales/translated/pt_BR.json index d1f3c65e1..67e8a41d2 100644 --- a/src/ui/locales/translated/pt_BR.json +++ b/src/ui/locales/translated/pt_BR.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Falha ao salvar as configurações de marca", "sslDescription": "Emita e renove automaticamente um certificado SSL confiável da Let's Encrypt. Requer um domínio público e acesso à porta 80 ou DNS.", "sslDocsLink": "Ver documentação SSL", - "sslDomain": "Domínio", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "O nome de domínio público do certificado.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "E-mail de contato para notificações e conta do Let's Encrypt.", - "sslChallengeType": "Tipo de desafio", - "sslChallengeTypeDesc": "Como comprovar a propriedade de um domínio para o Let's Encrypt.", - "sslChallengeHttp": "HTTP (raiz web) - requer que a porta 80 esteja acessível pela internet.", - "sslChallengeDns": "DNS (Cloudflare) - requer um token de API do Cloudflare", - "sslCloudflareToken": "Token da API do Cloudflare", - "sslCloudflareTokenPlaceholder": "Insira o token...", - "sslCloudflareTokenDesc": "Token com escopo definido e permissão Zone:DNS:Edit para o seu domínio.", "sslCertStatus": "Status do certificado", "sslCertStatusNone": "Sem certificado", "sslCertStatusValid": "Válido", "sslCertStatusExpiring": "Expira em breve", "sslCertStatusExpired": "Expirado", "sslCertExpiresAt": "Expira em {{date}}", - "sslLastIssued": "Última edição {{date}}", - "sslRequestCert": "Emitir/Renovar Certificado", - "sslRequestCertLoading": "Solicitar certificado...", - "sslRequestCertSuccess": "Certificado emitido e instalado com sucesso.", - "sslRequestCertFailed": "A solicitação de certificado falhou", - "sslSave": "Salvar configurações", - "sslSaved": "Configurações SSL salvas", - "sslSaveFailed": "Falha ao salvar as configurações SSL", - "sslRequiresDomain": "Domínio e e-mail são obrigatórios.", "sslInfoNote": "Após a emissão ou o carregamento de um certificado, o Termix habilitará automaticamente o HTTPS na porta SSL_PORT (padrão 8443), além da porta HTTP existente. Certifique-se de que essa porta esteja publicada/mapeada para este contêiner, de forma que esteja acessível.", - "sslManualOption": "Manual (envio do certificado)", "sslManualCert": "Certificado (PEM)", "sslManualCertPlaceholder": "-----INÍCIO DO CERTIFICADO-----", "sslManualKey": "Chave privada (PEM)", diff --git a/src/ui/locales/translated/pt_PT.json b/src/ui/locales/translated/pt_PT.json index 5541ce819..6d3a425eb 100644 --- a/src/ui/locales/translated/pt_PT.json +++ b/src/ui/locales/translated/pt_PT.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Não foi possível guardar as definições de marca.", "sslDescription": "Emitir e renovar automaticamente um certificado SSL confiável da Let's Encrypt. Requer um domínio público e acesso à porta 80 ou DNS.", "sslDocsLink": "Ver documentação SSL", - "sslDomain": "Domínio", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "O nome de domínio público para o certificado.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "E-mail de contacto para notificações e conta Let's Encrypt.", - "sslChallengeType": "Tipo de desafio", - "sslChallengeTypeDesc": "Como provar a propriedade do domínio à Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - requer que a porta 80 esteja acessível a partir da internet", - "sslChallengeDns": "DNS (Cloudflare) - requer um token de API da Cloudflare", - "sslCloudflareToken": "Token de API da Cloudflare", - "sslCloudflareTokenPlaceholder": "Introduzir token...", - "sslCloudflareTokenDesc": "Token com âmbito e permissão Zone:DNS:Edit para o seu domínio.", "sslCertStatus": "Estado do certificado", "sslCertStatusNone": "Sem certificado", "sslCertStatusValid": "Válido", "sslCertStatusExpiring": "A expirar em breve", "sslCertStatusExpired": "Expirado", "sslCertExpiresAt": "Expira a {{date}}", - "sslLastIssued": "Última emissão a {{date}}", - "sslRequestCert": "Emitir / Renovar certificado", - "sslRequestCertLoading": "A solicitar certificado...", - "sslRequestCertSuccess": "Certificado emitido e instalado com sucesso", - "sslRequestCertFailed": "Pedido de certificado falhou", - "sslSave": "Guardar definições", - "sslSaved": "Definições SSL guardadas", - "sslSaveFailed": "Falha ao guardar definições SSL", - "sslRequiresDomain": "Domínio e e-mail são obrigatórios", "sslInfoNote": "Após a emissão ou o carregamento de um certificado, o Termix irá ativar automaticamente o HTTPS na porta SSL_PORT (padrão 8443), para além da porta HTTP existente. Certifique-se de que esta porta está publicada/mapeada para este contentor, de forma a que esteja acessível.", - "sslManualOption": "Manual (envio do certificado)", "sslManualCert": "Certificado (PEM)", "sslManualCertPlaceholder": "-----INÍCIO DO CERTIFICADO-----", "sslManualKey": "Chave privada (PEM)", diff --git a/src/ui/locales/translated/ro_RO.json b/src/ui/locales/translated/ro_RO.json index fbf059031..d0747dc8d 100644 --- a/src/ui/locales/translated/ro_RO.json +++ b/src/ui/locales/translated/ro_RO.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Setările de branding nu au putut fi salvate", "sslDescription": "Emite și reînnoiește automat un certificat SSL de încredere de la Let's Encrypt. Necesită un domeniu public și portul 80 sau acces DNS.", "sslDocsLink": "Vizualizați documentele SSL", - "sslDomain": "Domeniu", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Numele de domeniu public pentru certificat.", - "sslEmail": "E-mail", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Adresă de e-mail de contact pentru notificările și contul Let's Encrypt.", - "sslChallengeType": "Tipul provocării", - "sslChallengeTypeDesc": "Cum să dovedești dreptul de proprietate asupra domeniului către Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - necesită portul 80 accesibil de pe internet", - "sslChallengeDns": "DNS (Cloudflare) - necesită un token API Cloudflare", - "sslCloudflareToken": "Tokenul API Cloudflare", - "sslCloudflareTokenPlaceholder": "Introduceți tokenul...", - "sslCloudflareTokenDesc": "Token cu scop specific și permisiune Zone:DNS:Edit pentru domeniul dvs.", "sslCertStatus": "Starea certificatului", "sslCertStatusNone": "Fără certificat", "sslCertStatusValid": "Valabil", "sslCertStatusExpiring": "Expiră în curând", "sslCertStatusExpired": "Expirat", "sslCertExpiresAt": "Expiră la {{date}}", - "sslLastIssued": "Ultima emisiune {{date}}", - "sslRequestCert": "Emitere / Reînnoire certificat", - "sslRequestCertLoading": "Se solicită certificatul...", - "sslRequestCertSuccess": "Certificat emis și instalat cu succes", - "sslRequestCertFailed": "Solicitarea de certificat a eșuat", - "sslSave": "Salvați setările", - "sslSaved": "Setările SSL au fost salvate", - "sslSaveFailed": "Setările SSL nu au putut fi salvate.", - "sslRequiresDomain": "Domeniul și adresa de e-mail sunt obligatorii", "sslInfoNote": "După emiterea sau încărcarea unui certificat, Termix va activa automat HTTPS pe SSL_PORT (implicit 8443), pe lângă portul HTTP existent. Asigurați-vă că portul este publicat/mapat la acest container, astfel încât să fie accesibil.", - "sslManualOption": "Manual (încărcare certificat)", "sslManualCert": "Certificat (PEM)", "sslManualCertPlaceholder": "-----ÎNCEPERE CERTIFICAT-----", "sslManualKey": "Cheie privată (PEM)", diff --git a/src/ui/locales/translated/ru_RU.json b/src/ui/locales/translated/ru_RU.json index 56d3944d3..0490c0d9f 100644 --- a/src/ui/locales/translated/ru_RU.json +++ b/src/ui/locales/translated/ru_RU.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Не удалось сохранить настройки брендинга.", "sslDescription": "Автоматически выпускать и обновлять доверенный SSL-сертификат от Let’s Encrypt. Требуется общедоступный домен и доступ к порту 80 или DNS.", "sslDocsLink": "Документация SSL", - "sslDomain": "Домен", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Общедоступное доменное имя для сертификата.", - "sslEmail": "Email", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Контактный email для уведомлений Let's Encrypt и учетной записи.", - "sslChallengeType": "Тип проверки", - "sslChallengeTypeDesc": "Способ подтверждения владения доменом для Let’s Encrypt.", - "sslChallengeHttp": "HTTP (webroot) — требуется доступ к порту 80 из интернета", - "sslChallengeDns": "DNS (Cloudflare) — требуется API-токен Cloudflare", - "sslCloudflareToken": "API-токен Cloudflare", - "sslCloudflareTokenPlaceholder": "Введите токен...", - "sslCloudflareTokenDesc": "Токен с ограниченной областью действия и разрешением Zone:DNS:Edit для вашего домена.", "sslCertStatus": "Статус сертификата", "sslCertStatusNone": "Сертификат отсутствует", "sslCertStatusValid": "Действительный", "sslCertStatusExpiring": "Скоро истекает", "sslCertStatusExpired": "Истек", "sslCertExpiresAt": "Истекает {{date}}", - "sslLastIssued": "Последний выпуск {{date}}", - "sslRequestCert": "Выпустить / Обновить сертификат", - "sslRequestCertLoading": "Запрос сертификата...", - "sslRequestCertSuccess": "Сертификат успешно выпущен и установлен", - "sslRequestCertFailed": "Ошибка запроса сертификата", - "sslSave": "Сохранить настройки", - "sslSaved": "Настройки SSL сохранены", - "sslSaveFailed": "Не удалось сохранить настройки SSL", - "sslRequiresDomain": "Требуются домен и email", "sslInfoNote": "После выдачи или загрузки сертификата Termix автоматически включит HTTPS на SSL_PORT (по умолчанию 8443) в дополнение к существующему HTTP-порту. Убедитесь, что этот порт опубликован/сопоставлен с данным контейнером, чтобы он был доступен.", - "sslManualOption": "Ручная загрузка сертификата", "sslManualCert": "Сертификат (PEM)", "sslManualCertPlaceholder": "-----НАЧАЛО ПОЛУЧЕНИЯ СЕРТИФИКАТА-----", "sslManualKey": "Закрытый ключ (PEM)", diff --git a/src/ui/locales/translated/sr_SP.json b/src/ui/locales/translated/sr_SP.json index 9d893522e..10294cb0d 100644 --- a/src/ui/locales/translated/sr_SP.json +++ b/src/ui/locales/translated/sr_SP.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Чување подешавања брендирања није успело", "sslDescription": "Аутоматски издајте и обновите поуздани SSL сертификат од Let's Encrypt. Потребан је јавни домен и порт 80 или DNS приступ.", "sslDocsLink": "Погледајте SSL документацију", - "sslDomain": "Домен", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Јавно име домена за сертификат.", - "sslEmail": "Е-пошта", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Контакт имејл за обавештења и налог од Let's Encrypt-а.", - "sslChallengeType": "Тип изазова", - "sslChallengeTypeDesc": "Како доказати власништво над доменом компанији Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - захтева порт 80 доступан са интернета", - "sslChallengeDns": "DNS (Cloudflare) - захтева Cloudflare API токен", - "sslCloudflareToken": "Клаудфлејр АПИ токен", - "sslCloudflareTokenPlaceholder": "Унесите токен...", - "sslCloudflareTokenDesc": "Ограничени токен са дозволом Zone:DNS:Edit за ваш домен.", "sslCertStatus": "Статус сертификата", "sslCertStatusNone": "Без сертификата", "sslCertStatusValid": "Важи", "sslCertStatusExpiring": "Ускоро истиче", "sslCertStatusExpired": "Истекло", "sslCertExpiresAt": "Истиче {{date}}", - "sslLastIssued": "Последње издање {{date}}", - "sslRequestCert": "Издавање / обнављање сертификата", - "sslRequestCertLoading": "Захтева се сертификат...", - "sslRequestCertSuccess": "Сертификат издат и успешно инсталиран", - "sslRequestCertFailed": "Захтев за сертификат није успео", - "sslSave": "Сачувај подешавања", - "sslSaved": "SSL подешавања су сачувана", - "sslSaveFailed": "Чување SSL подешавања није успело", - "sslRequiresDomain": "Домен и имејл су обавезни", "sslInfoNote": "Након издавања или отпремања сертификата, Termix ће аутоматски омогућити HTTPS на SSL_PORT (подразумевано 8443) поред постојећег HTTP порта. Уверите се да је тај порт објављен/мапиран на овај контејнер како би био доступан.", - "sslManualOption": "Ручно (отпремање сертификата)", "sslManualCert": "Сертификат (ПЕМ)", "sslManualCertPlaceholder": "-----ПОЧЕТАК СЕРТИФИКАТА-----", "sslManualKey": "Приватни кључ (PEM)", diff --git a/src/ui/locales/translated/sv_SE.json b/src/ui/locales/translated/sv_SE.json index ef9847bdb..0653494ee 100644 --- a/src/ui/locales/translated/sv_SE.json +++ b/src/ui/locales/translated/sv_SE.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Det gick inte att spara varumärkesinställningarna", "sslDescription": "Utfärda och förnya automatiskt ett betrott SSL-certifikat från Let's Encrypt. Kräver en allmän domän och port 80 eller DNS-åtkomst.", "sslDocsLink": "Visa SSL-dokument", - "sslDomain": "Domän", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Det offentliga domännamnet för certifikatet.", - "sslEmail": "E-post", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Kontakta e-postadressen för Let's Encrypt-aviseringar och konto.", - "sslChallengeType": "Utmaningstyp", - "sslChallengeTypeDesc": "Hur man bevisar domänägande för Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - kräver port 80 åtkomlig från internet", - "sslChallengeDns": "DNS (Cloudflare) – kräver en Cloudflare API-token", - "sslCloudflareToken": "Cloudflare API-token", - "sslCloudflareTokenPlaceholder": "Ange token...", - "sslCloudflareTokenDesc": "Scoped-token med Zone:DNS:Edit-behörighet för din domän.", "sslCertStatus": "Certifikatstatus", "sslCertStatusNone": "Inget certifikat", "sslCertStatusValid": "Giltig", "sslCertStatusExpiring": "Utgår snart", "sslCertStatusExpired": "Utgången", "sslCertExpiresAt": "Utgångsdatum {{date}}", - "sslLastIssued": "Senast utfärdad {{date}}", - "sslRequestCert": "Utfärda/förnya certifikat", - "sslRequestCertLoading": "Begär certifikat...", - "sslRequestCertSuccess": "Certifikat utfärdat och installerat", - "sslRequestCertFailed": "Certifikatbegäran misslyckades", - "sslSave": "Spara inställningar", - "sslSaved": "SSL-inställningar sparade", - "sslSaveFailed": "Kunde inte spara SSL-inställningarna", - "sslRequiresDomain": "Domän och e-postadress krävs", "sslInfoNote": "Efter att ett certifikat utfärdats eller laddats upp kommer Termix automatiskt att aktivera HTTPS på SSL_PORT (standard 8443) utöver den befintliga HTTP-porten. Se till att porten är publicerad/mappad till den här containern så att den är nåbar.", - "sslManualOption": "Manual (ladda upp certifikat)", "sslManualCert": "Certifikat (PEM)", "sslManualCertPlaceholder": "-----BEGIN CERTIFIKAT-----", "sslManualKey": "Privat nyckel (PEM)", diff --git a/src/ui/locales/translated/th_TH.json b/src/ui/locales/translated/th_TH.json index be7836f68..ad6024bb7 100644 --- a/src/ui/locales/translated/th_TH.json +++ b/src/ui/locales/translated/th_TH.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "ไม่สามารถบันทึกการตั้งค่าแบรนด์ได้", "sslDescription": "ออกและต่ออายุใบรับรอง SSL ที่เชื่อถือได้จาก Let's Encrypt โดยอัตโนมัติ ต้องใช้โดเมนสาธารณะและพอร์ต 80 หรือการเข้าถึง DNS", "sslDocsLink": "ดูเอกสาร SSL", - "sslDomain": "โดเมน", - "sslDomainPlaceholder": "เทอร์มิกซ์.ตัวอย่าง.com", - "sslDomainDesc": "ชื่อโดเมนสาธารณะสำหรับใบรับรอง", - "sslEmail": "อีเมล", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "อีเมลสำหรับติดต่อเพื่อรับการแจ้งเตือนและข้อมูลเกี่ยวกับบัญชี Let's Encrypt", - "sslChallengeType": "ประเภทความท้าทาย", - "sslChallengeTypeDesc": "วิธีพิสูจน์ความเป็นเจ้าของโดเมนต่อ Let's Encrypt", - "sslChallengeHttp": "HTTP (webroot) - ต้องใช้พอร์ต 80 ที่สามารถเข้าถึงได้จากอินเทอร์เน็ต", - "sslChallengeDns": "DNS (Cloudflare) - ต้องใช้โทเค็น API ของ Cloudflare", - "sslCloudflareToken": "โทเค็น API ของ Cloudflare", - "sslCloudflareTokenPlaceholder": "ป้อนรหัส...", - "sslCloudflareTokenDesc": "โทเค็นแบบจำกัดขอบเขตพร้อมสิทธิ์ Zone:DNS:Edit สำหรับโดเมนของคุณ", "sslCertStatus": "สถานะใบรับรอง", "sslCertStatusNone": "ไม่มีใบรับรอง", "sslCertStatusValid": "ถูกต้อง", "sslCertStatusExpiring": "กำลังจะหมดอายุ", "sslCertStatusExpired": "หมดอายุ", "sslCertExpiresAt": "หมดอายุ {{date}}", - "sslLastIssued": "ออกครั้งล่าสุด {{date}}", - "sslRequestCert": "ออก/ต่ออายุใบรับรอง", - "sslRequestCertLoading": "ขอใบรับรอง...", - "sslRequestCertSuccess": "ออกและติดตั้งใบรับรองเรียบร้อยแล้ว", - "sslRequestCertFailed": "การขอใบรับรองล้มเหลว", - "sslSave": "บันทึกการตั้งค่า", - "sslSaved": "บันทึกการตั้งค่า SSL แล้ว", - "sslSaveFailed": "ไม่สามารถบันทึกการตั้งค่า SSL ได้", - "sslRequiresDomain": "ต้องระบุโดเมนและอีเมล", "sslInfoNote": "หลังจากออกหรืออัปโหลดใบรับรองแล้ว Termix จะเปิดใช้งาน HTTPS บน SSL_PORT (ค่าเริ่มต้นคือ 8443) โดยอัตโนมัติ นอกเหนือจากพอร์ต HTTP ที่มีอยู่ โปรดตรวจสอบให้แน่ใจว่าพอร์ตดังกล่าวได้รับการเผยแพร่/แมปไปยังคอนเทนเนอร์นี้แล้ว เพื่อให้สามารถเข้าถึงได้", - "sslManualOption": "คู่มือ (อัปโหลดใบรับรอง)", "sslManualCert": "ใบรับรอง (PEM)", "sslManualCertPlaceholder": "-----เริ่มต้นใบรับรอง-----", "sslManualKey": "กุญแจส่วนตัว (PEM)", diff --git a/src/ui/locales/translated/tr_TR.json b/src/ui/locales/translated/tr_TR.json index 537c50336..15282d6ed 100644 --- a/src/ui/locales/translated/tr_TR.json +++ b/src/ui/locales/translated/tr_TR.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Markalama ayarları kaydedilemedi.", "sslDescription": "Let's Encrypt'ten güvenilir bir SSL sertifikasını otomatik olarak alın ve yenileyin. Genel bir alan adı ve 80 numaralı bağlantı noktası veya DNS erişimi gerektirir.", "sslDocsLink": "SSL belgelerini görüntüle", - "sslDomain": "Alan Adı", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Sertifika için genel alan adı.", - "sslEmail": "E-posta", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Let's Encrypt bildirimleri ve hesabı için iletişim e-postası.", - "sslChallengeType": "Doğrulama Türü", - "sslChallengeTypeDesc": "Let's Encrypt'e alan adı sahipliğini kanıtlama yöntemi.", - "sslChallengeHttp": "HTTP (webroot) - 80 numaralı bağlantı noktasının internetten erişilebilir olmasını gerektirir", - "sslChallengeDns": "DNS (Cloudflare) - bir Cloudflare API belirteci gerektirir", - "sslCloudflareToken": "Cloudflare API Belirteci", - "sslCloudflareTokenPlaceholder": "Belirteci girin...", - "sslCloudflareTokenDesc": "Alan adınız için Zone:DNS:Edit iznine sahip kapsamlı belirteç.", "sslCertStatus": "Sertifika Durumu", "sslCertStatusNone": "Sertifika yok", "sslCertStatusValid": "Geçerli", "sslCertStatusExpiring": "Yakında süresi dolacak", "sslCertStatusExpired": "Süresi dolmuş", "sslCertExpiresAt": "Son kullanma: {{date}}", - "sslLastIssued": "Son veriliş: {{date}}", - "sslRequestCert": "Sertifika Ver / Yenile", - "sslRequestCertLoading": "Sertifika isteniyor...", - "sslRequestCertSuccess": "Sertifika başarıyla verildi ve kuruldu", - "sslRequestCertFailed": "Sertifika talebi başarısız oldu", - "sslSave": "Ayarları Kaydet", - "sslSaved": "SSL ayarları kaydedildi", - "sslSaveFailed": "SSL ayarları kaydedilemedi", - "sslRequiresDomain": "Alan adı ve e-posta gereklidir", "sslInfoNote": "Sertifika oluşturulduktan veya yüklendikten sonra, Termix mevcut HTTP portuna ek olarak SSL_PORT (varsayılan 8443) üzerinden HTTPS'yi otomatik olarak etkinleştirecektir. Bu portun erişilebilir olması için bu konteynere yayınlandığından/eşlendiğinden emin olun.", - "sslManualOption": "Manuel (sertifika yükleme)", "sslManualCert": "Sertifika (PEM)", "sslManualCertPlaceholder": "-----SERTİFİKA BAŞLANGICI-----", "sslManualKey": "Özel Anahtar (PEM)", diff --git a/src/ui/locales/translated/uk_UA.json b/src/ui/locales/translated/uk_UA.json index 84f4be859..00b3d7706 100644 --- a/src/ui/locales/translated/uk_UA.json +++ b/src/ui/locales/translated/uk_UA.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Не вдалося зберегти налаштування брендингу", "sslDescription": "Автоматично видавати та поновлювати довірений SSL-сертифікат від Let's Encrypt. Потрібен публічний домен і порт 80 або доступ до DNS.", "sslDocsLink": "Переглянути документацію SSL", - "sslDomain": "Домен", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Ім'я публічного домену для сертифіката.", - "sslEmail": "Електронна пошта", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Контактна електронна адреса для сповіщень та облікового запису Let's Encrypt.", - "sslChallengeType": "Тип виклику", - "sslChallengeTypeDesc": "Як довести право власності на домен для Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) – потрібен порт 80, доступний з Інтернету", - "sslChallengeDns": "DNS (Cloudflare) – потрібен токен API Cloudflare", - "sslCloudflareToken": "Токен API Cloudflare", - "sslCloudflareTokenPlaceholder": "Введіть токен...", - "sslCloudflareTokenDesc": "Токен з обмеженою областю дії та дозволом Zone:DNS:Edit для вашого домену.", "sslCertStatus": "Статус сертифіката", "sslCertStatusNone": "Без сертифіката", "sslCertStatusValid": "Дійсний", "sslCertStatusExpiring": "Термін дії скоро закінчується", "sslCertStatusExpired": "Термін дії минув", "sslCertExpiresAt": "Термін дії закінчується {{date}}", - "sslLastIssued": "Останній виданий {{date}}", - "sslRequestCert": "Видача / поновлення сертифіката", - "sslRequestCertLoading": "Запит сертифіката...", - "sslRequestCertSuccess": "Сертифікат видано та встановлено успішно", - "sslRequestCertFailed": "Запит на сертифікат не вдалося", - "sslSave": "Зберегти налаштування", - "sslSaved": "Налаштування SSL збережено", - "sslSaveFailed": "Не вдалося зберегти налаштування SSL", - "sslRequiresDomain": "Домен та електронна адреса обов'язкові", "sslInfoNote": "Після видачі або завантаження сертифіката Termix автоматично ввімкне HTTPS на SSL_PORT (за замовчуванням 8443) на додаток до існуючого HTTP-порту. Переконайтеся, що цей порт опубліковано/зіставлено з цим контейнером, щоб він був доступним.", - "sslManualOption": "Інструкція (сертифікат завантаження)", "sslManualCert": "Сертифікат (PEM)", "sslManualCertPlaceholder": "-----ПОЧАТИ СЕРТИФІКАТ-----", "sslManualKey": "Закритий ключ (PEM)", diff --git a/src/ui/locales/translated/vi_VN.json b/src/ui/locales/translated/vi_VN.json index f067a49e6..f42961cb5 100644 --- a/src/ui/locales/translated/vi_VN.json +++ b/src/ui/locales/translated/vi_VN.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "Không thể lưu cài đặt thương hiệu.", "sslDescription": "Tự động cấp và gia hạn chứng chỉ SSL tin cậy từ Let's Encrypt. Yêu cầu tên miền công khai và quyền truy cập cổng 80 hoặc DNS.", "sslDocsLink": "Xem tài liệu SSL", - "sslDomain": "Tên miền", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "Tên miền công khai để cấp chứng chỉ.", - "sslEmail": "Email", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "Email liên hệ để nhận thông báo và tài khoản từ Let's Encrypt.", - "sslChallengeType": "Loại thử thách", - "sslChallengeTypeDesc": "Cách chứng minh quyền sở hữu tên miền với Let's Encrypt.", - "sslChallengeHttp": "HTTP (webroot) - yêu cầu cổng 80 có thể truy cập từ internet", - "sslChallengeDns": "DNS (Cloudflare) - yêu cầu Token API Cloudflare", - "sslCloudflareToken": "Token API Cloudflare", - "sslCloudflareTokenPlaceholder": "Nhập token...", - "sslCloudflareTokenDesc": "Token có phạm vi với quyền Zone:DNS:Edit cho tên miền của bạn.", "sslCertStatus": "Trạng thái chứng chỉ", "sslCertStatusNone": "Không có chứng chỉ", "sslCertStatusValid": "Hợp lệ", "sslCertStatusExpiring": "Sắp hết hạn", "sslCertStatusExpired": "Đã hết hạn", "sslCertExpiresAt": "Hết hạn {{date}}", - "sslLastIssued": "Cấp lần cuối {{date}}", - "sslRequestCert": "Cấp / Gia hạn chứng chỉ", - "sslRequestCertLoading": "Đang yêu cầu chứng chỉ...", - "sslRequestCertSuccess": "Chứng chỉ đã được cấp và cài đặt thành công", - "sslRequestCertFailed": "Yêu cầu chứng chỉ thất bại", - "sslSave": "Lưu cài đặt", - "sslSaved": "Đã lưu cài đặt SSL", - "sslSaveFailed": "Không thể lưu cài đặt SSL", - "sslRequiresDomain": "Cần có tên miền và email", "sslInfoNote": "Sau khi cấp hoặc tải lên chứng chỉ, Termix sẽ tự động bật HTTPS trên SSL_PORT (mặc định là 8443) bên cạnh cổng HTTP hiện có. Hãy đảm bảo rằng cổng đó đã được công bố/ánh xạ tới vùng chứa này để có thể truy cập được.", - "sslManualOption": "Hướng dẫn thủ công (tải lên chứng chỉ)", "sslManualCert": "Chứng chỉ (PEM)", "sslManualCertPlaceholder": "-----BẮT ĐẦU GIẤY CHỨNG NHẬN-----", "sslManualKey": "Khóa riêng tư (PEM)", diff --git a/src/ui/locales/translated/zh_CN.json b/src/ui/locales/translated/zh_CN.json index 0f64b58ab..abd834e9a 100644 --- a/src/ui/locales/translated/zh_CN.json +++ b/src/ui/locales/translated/zh_CN.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "品牌设置保存失败", "sslDescription": "自动从 Let's Encrypt 签发并续期受信任的 SSL 证书。需要公共域名以及端口 80 或 DNS 访问权限。", "sslDocsLink": "查看 SSL 文档", - "sslDomain": "域名", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "证书的公共域名。", - "sslEmail": "邮箱", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "用于 Let's Encrypt 通知和账户的联系邮箱。", - "sslChallengeType": "验证类型", - "sslChallengeTypeDesc": "如何向 Let's Encrypt 证明域名所有权。", - "sslChallengeHttp": "HTTP (webroot) - 需要从互联网可访问端口 80", - "sslChallengeDns": "DNS (Cloudflare) - 需要 Cloudflare API 令牌", - "sslCloudflareToken": "Cloudflare API 令牌", - "sslCloudflareTokenPlaceholder": "输入令牌...", - "sslCloudflareTokenDesc": "具有您域名的 Zone:DNS:Edit 权限的限定作用域令牌。", "sslCertStatus": "证书状态", "sslCertStatusNone": "无证书", "sslCertStatusValid": "有效", "sslCertStatusExpiring": "即将过期", "sslCertStatusExpired": "已过期", "sslCertExpiresAt": "过期于 {{date}}", - "sslLastIssued": "上次签发于 {{date}}", - "sslRequestCert": "签发 / 续期证书", - "sslRequestCertLoading": "正在请求证书...", - "sslRequestCertSuccess": "证书签发并安装成功", - "sslRequestCertFailed": "证书请求失败", - "sslSave": "保存设置", - "sslSaved": "SSL 设置已保存", - "sslSaveFailed": "保存 SSL 设置失败", - "sslRequiresDomain": "域名和邮箱为必填项", "sslInfoNote": "签发或上传证书后,Termix 会在现有 HTTP 端口的基础上,自动在 SSL_PORT(默认 8443)上启用 HTTPS。请确保已将该端口发布/映射到此容器,以便其可访问。", - "sslManualOption": "手动(上传证书)", "sslManualCert": "证书(PEM)", "sslManualCertPlaceholder": "-----BEGIN CERTIFICATE-----", "sslManualKey": "私钥(PEM)", diff --git a/src/ui/locales/translated/zh_TW.json b/src/ui/locales/translated/zh_TW.json index ec7ac9439..6748f5920 100644 --- a/src/ui/locales/translated/zh_TW.json +++ b/src/ui/locales/translated/zh_TW.json @@ -1692,36 +1692,13 @@ "brandingSaveFailed": "品牌設定保存失敗", "sslDescription": "自動從 Let's Encrypt 簽發並更新受信任的 SSL 憑證。需要公開網域及通訊埠 80 或 DNS 存取權。", "sslDocsLink": "檢視 SSL 文件", - "sslDomain": "網域", - "sslDomainPlaceholder": "termix.example.com", - "sslDomainDesc": "憑證使用的公開網域名稱。", - "sslEmail": "電子郵件", - "sslEmailPlaceholder": "admin@example.com", - "sslEmailDesc": "用於 Let's Encrypt 通知及帳戶的聯絡電子郵件。", - "sslChallengeType": "驗證類型", - "sslChallengeTypeDesc": "如何向 Let's Encrypt 證明網域所有權。", - "sslChallengeHttp": "HTTP (webroot) - 需要可從網際網路存取的連接埠 80", - "sslChallengeDns": "DNS (Cloudflare) - 需要 Cloudflare API 權杖", - "sslCloudflareToken": "Cloudflare API 權杖", - "sslCloudflareTokenPlaceholder": "輸入權杖...", - "sslCloudflareTokenDesc": "具有您網域 Zone:DNS:Edit 權限的限定範圍權杖。", "sslCertStatus": "憑證狀態", "sslCertStatusNone": "無憑證", "sslCertStatusValid": "有效", "sslCertStatusExpiring": "即將到期", "sslCertStatusExpired": "已過期", "sslCertExpiresAt": "到期日 {{date}}", - "sslLastIssued": "上次簽發日期 {{date}}", - "sslRequestCert": "簽發 / 續期憑證", - "sslRequestCertLoading": "請求憑證中...", - "sslRequestCertSuccess": "憑證已成功簽發並安裝", - "sslRequestCertFailed": "憑證請求失敗", - "sslSave": "儲存設定", - "sslSaved": "SSL 設定已儲存", - "sslSaveFailed": "儲存 SSL 設定失敗", - "sslRequiresDomain": "網域和電子郵件為必填", "sslInfoNote": "頒發或上傳憑證後,Termix 會在現有 HTTP 連接埠的基礎上,自動在 SSL_PORT(預設 8443)上啟用 HTTPS。請確保已將該連接埠發布/映射到此容器,以便其可存取。", - "sslManualOption": "手動(上傳證書)", "sslManualCert": "證書(PEM)", "sslManualCertPlaceholder": "-----開始憑證-----", "sslManualKey": "私鑰(PEM)", diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 613ff35c5..e32fb703c 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -49,12 +49,10 @@ import { type BrandingSettings, } from "@/api/settings-api"; import { - getAcmeSslSettings, - updateAcmeSslSettings, - requestAcmeCertificate, - uploadManualSslCertificate, - type AcmeSettings, -} from "@/api/acme-ssl-api"; + getTlsStatus, + uploadTlsCertificate, + type TlsStatus, +} from "@/api/tls-api"; import { type ApiKey, type CreatedApiKey, @@ -193,21 +191,7 @@ export function AdminSettingsPanel({ const [exportLoading, setExportLoading] = useState(false); const [importLoading, setImportLoading] = useState(false); - // ACME SSL state - const defaultAcmeSettings: AcmeSettings = { - enabled: false, - domain: "", - email: "", - challengeType: "http-webroot", - cloudflareToken: "", - lastIssuedAt: null, - certStatus: "none", - certExpiresAt: null, - }; - const [acmeSettings, setAcmeSettings] = - useState(defaultAcmeSettings); - const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState(""); - const [acmeRequesting, setAcmeRequesting] = useState(false); + const [tlsStatus, setTlsStatus] = useState(null); const [manualCertDraft, setManualCertDraft] = useState(""); const [manualKeyDraft, setManualKeyDraft] = useState(""); const [manualUploading, setManualUploading] = useState(false); @@ -369,8 +353,8 @@ export function AdminSettingsPanel({ .then((d) => setHostDefaults(d)) .catch(() => {}); - getAcmeSslSettings() - .then((s) => setAcmeSettings(s)) + getTlsStatus() + .then((s) => setTlsStatus(s)) .catch(() => {}); } @@ -556,53 +540,6 @@ export function AdminSettingsPanel({ } } - async function handleSaveAcmeSettings() { - try { - const payload: Parameters[0] = { - enabled: acmeSettings.enabled, - domain: acmeSettings.domain, - email: acmeSettings.email, - challengeType: acmeSettings.challengeType, - ...(cloudflareTokenDraft && { cloudflareToken: cloudflareTokenDraft }), - }; - const updated = await updateAcmeSslSettings(payload); - setAcmeSettings(updated); - setCloudflareTokenDraft(""); - toast.success(t("admin.sslSaved")); - } catch { - toast.error(t("admin.sslSaveFailed")); - } - } - - async function handleRequestAcmeCertificate() { - if (!acmeSettings.domain || !acmeSettings.email) { - toast.error(t("admin.sslRequiresDomain")); - return; - } - setAcmeRequesting(true); - try { - if (cloudflareTokenDraft) { - await updateAcmeSslSettings({ - domain: acmeSettings.domain, - email: acmeSettings.email, - challengeType: acmeSettings.challengeType, - cloudflareToken: cloudflareTokenDraft, - }); - setCloudflareTokenDraft(""); - } - const result = await requestAcmeCertificate(); - setAcmeSettings(result); - toast.success(t("admin.sslRequestCertSuccess")); - if (result.reloadMessage) { - toast.info(result.reloadMessage); - } - } catch (e) { - toast.error(apiErrorMessage(e, t("admin.sslRequestCertFailed"))); - } finally { - setAcmeRequesting(false); - } - } - async function handleManualSslUpload() { if (!manualCertDraft.trim() || !manualKeyDraft.trim()) { toast.error(t("admin.sslManualRequiresFields")); @@ -610,11 +547,11 @@ export function AdminSettingsPanel({ } setManualUploading(true); try { - const result = await uploadManualSslCertificate({ + const result = await uploadTlsCertificate({ certificate: manualCertDraft, privateKey: manualKeyDraft, }); - setAcmeSettings(result); + setTlsStatus(result); setManualCertDraft(""); setManualKeyDraft(""); toast.success(t("admin.sslManualUploadSuccess")); @@ -997,13 +934,7 @@ export function AdminSettingsPanel({ toggle("ssl")} - settings={acmeSettings} - setSettings={setAcmeSettings} - cloudflareTokenDraft={cloudflareTokenDraft} - setCloudflareTokenDraft={setCloudflareTokenDraft} - requesting={acmeRequesting} - handleSave={handleSaveAcmeSettings} - handleRequest={handleRequestAcmeCertificate} + status={tlsStatus} manualCertDraft={manualCertDraft} setManualCertDraft={setManualCertDraft} manualKeyDraft={manualKeyDraft} diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index d85f4309b..ce4036ebe 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -4,11 +4,10 @@ import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { PasswordInput } from "@/components/password-input"; import { SettingRow } from "@/components/section-card"; -import { Select2 } from "@/components/select2"; import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react"; import { AccordionSection, AdminToggle } from "./AdminSettingsShared"; import type { HostDefaults } from "@/api/settings-api"; -import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api"; +import type { TlsStatus } from "@/api/tls-api"; type GeneralSettingsSectionProps = { open: boolean; @@ -541,7 +540,18 @@ export function AdminHostDefaultsSection({ ); } -const CERT_STATUS_STYLES: Record = { +function certState( + status: TlsStatus | null, +): "none" | "valid" | "expiring" | "expired" { + const cert = status?.certificate; + if (!cert) return "none"; + const left = new Date(cert.notAfter).getTime() - Date.now(); + if (left <= 0) return "expired"; + if (left < 30 * 86_400_000) return "expiring"; + return "valid"; +} + +const CERT_STATUS_STYLES: Record, string> = { none: "text-muted-foreground", valid: "text-green-500", expiring: "text-yellow-500", @@ -551,13 +561,7 @@ const CERT_STATUS_STYLES: Record = { type AdminSSLSectionProps = { open: boolean; onToggle: () => void; - settings: AcmeSettings; - setSettings: Dispatch>; - cloudflareTokenDraft: string; - setCloudflareTokenDraft: Dispatch>; - requesting: boolean; - handleSave: () => void; - handleRequest: () => void; + status: TlsStatus | null; manualCertDraft: string; setManualCertDraft: Dispatch>; manualKeyDraft: string; @@ -569,13 +573,7 @@ type AdminSSLSectionProps = { export function AdminSSLSection({ open, onToggle, - settings, - setSettings, - cloudflareTokenDraft, - setCloudflareTokenDraft, - requesting, - handleSave, - handleRequest, + status, manualCertDraft, setManualCertDraft, manualKeyDraft, @@ -584,8 +582,11 @@ export function AdminSSLSection({ handleManualUpload, }: AdminSSLSectionProps) { const { t } = useTranslation(); + const cert = status?.certificate ?? null; + const state = certState(status); + const expiry = cert ? new Date(cert.notAfter).toLocaleDateString() : ""; - const certStatusLabel: Record = { + const certStatusLabel: Record, string> = { none: t("admin.sslCertStatusNone"), valid: t("admin.sslCertStatusValid"), expiring: t("admin.sslCertStatusExpiring"), @@ -618,182 +619,97 @@ export function AdminSSLSection({ {t("admin.sslCertStatus")} - {certStatusLabel[settings.certStatus]} + {certStatusLabel[state]} - {settings.certExpiresAt && ( - - {t("admin.sslCertExpiresAt", { - date: new Date(settings.certExpiresAt).toLocaleDateString(), - })} - - )} - {settings.lastIssuedAt && ( - - {t("admin.sslLastIssued", { - date: new Date(settings.lastIssuedAt).toLocaleString(), - })} - + {cert && ( + <> + + {t("admin.sslNames", { names: cert.names.join(", ") || "-" })} + + + {t("admin.sslIssuer", { issuer: cert.issuer })} + + + {t("admin.sslCertExpiresAt", { date: expiry })} + + {cert.selfSigned && ( + + {t("admin.sslSelfSigned")} + + )} + {status?.renewal && ( + + {t("admin.sslRenewedBy", { + plugin: status.renewal.pluginName, + })} + + )} + )} + {cert && !cert.selfSigned && !status?.renewal && ( +
+ {t("admin.sslNotRenewed", { date: expiry })} +
+ )} + + + {t("admin.sslManualTitle")} + + +
+ +