feat: move acme certificate automation into a plugin (C7)

This commit is contained in:
LukeGus committed 2026-09-24 21:19:27 -05:00
1 parent 4dbce08379
commit 8c1e02af89
123 files changed
+4808 -1912

No files matched your search

+1 -1
View File
@@ -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 && \
+11 -3
View File
@@ -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..."
+17 -4
View File
@@ -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 {
+5 -3
View File
@@ -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 {
+216 -66
View File
@@ -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",
+2
View File
@@ -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",
+30 -1
View File
@@ -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/<token>`, 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
+16
View File
@@ -671,3 +671,19 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`/termix-id/u/<handle>` resolver URL. It keeps working through the 308,
but the docs should show `/plugin-api/termix-identity/u/<handle>`. 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.
+61
View File
@@ -1631,6 +1631,65 @@ export interface PluginSchedule {
after: (delayMs: number, fn: () => void | Promise<void>) => () => 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<PluginTlsStatus>;
/**
* 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<PluginTlsCertificateInfo>;
/** Serves the certificate on disk without a restart. */
reloadTls: () => Promise<PluginTlsReloadResult>;
/**
* Answers GET /.well-known/acme-challenge/<token> 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
+1 -1
View File
@@ -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"),
+122 -1
View File
@@ -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/<pluginId>/bin/<name>".
*/
process?: FakeProcessOptions;
/**
* What ctx.system.tlsStatus answers before any write. renewal is filled in
* from registerTlsRenewer calls.
*/
tlsStatus?: Omit<PluginTlsStatus, "renewal">;
/**
* 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<string, string>;
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<PluginTlsStatus, "renewal"> = options.tlsStatus ?? {
enabled: true,
certificate: null,
};
const secretStore = new Map<string, string>();
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) => {
+15
View File
@@ -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.
+36
View File
@@ -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://<domain>/.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`).
+58
View File
@@ -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"
}
}
@@ -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..."
}
}
@@ -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": "جارٍ طلب الشهادة..."
}
}
@@ -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": "Заявка за сертификат..."
}
}
@@ -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": "সার্টিফিকেটের জন্য অনুরোধ..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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": "Αίτημα πιστοποιητικού..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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": "מבקש תעודה..."
}
}
@@ -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": "प्रमाणपत्र अनुरोध किया जा रहा है..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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": "証明書をリクエスト中..."
}
}
@@ -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": "인증서 요청 중..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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..."
}
}
@@ -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": "Запрос сертификата..."
}
}
@@ -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": "Захтева се сертификат..."
}
}
@@ -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..."
}
}
@@ -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": "ขอใบรับรอง..."
}
}
@@ -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..."
}
}
@@ -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": "Запит сертифіката..."
}
}
@@ -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ỉ..."
}
}
@@ -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": "正在请求证书..."
}
}
@@ -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": "請求憑證中..."
}
}
+120
View File
@@ -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"]
}
+18
View File
@@ -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": "*"
}
}
+217
View File
@@ -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<string, string> {
const source =
headers && typeof (headers as { toJSON?: unknown }).toJSON === "function"
? (headers as { toJSON: () => Record<string, unknown> }).toJSON()
: ((headers ?? {}) as Record<string, unknown>);
const out: Record<string, string> = {};
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<string, string> = {};
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<void> {
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<IssuedCertificate> {
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<string, () => void | Promise<void>>();
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() };
}
@@ -0,0 +1,78 @@
import type { PluginFetch } from "@termix/plugin-sdk/backend";
const API = "https://api.cloudflare.com/client/v4";
interface CloudflareEnvelope<T> {
success: boolean;
errors?: Array<{ message?: string }>;
result: T;
}
async function call<T>(
fetch: PluginFetch,
token: string,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
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<T> | 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<string> {
const labels = name.replace(/\.$/, "").split(".");
for (let i = 0; i < labels.length - 1; i++) {
const candidate = labels.slice(i).join(".");
const zones = await call<Array<{ id: string; name: string }>>(
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<void>> {
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",
});
};
}
+50
View File
@@ -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<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.
}
+28
View File
@@ -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;
}
+73
View File
@@ -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}` });
}
});
}
+162
View File
@@ -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<IssuedCertificate>;
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<AcmeState>;
/** Issues now, whatever the served certificate looks like. */
issue: () => Promise<IssueResult>;
/** The scheduled check: issues only when renewal is on and due. */
check: (now?: Date) => Promise<boolean>;
/** Registers or drops the renewer to match the current settings. */
syncRenewer: () => Promise<void>;
}
export function createAcmeRunner(
ctx: PluginContext,
issue: IssueFn,
): AcmeRunner {
let running: Promise<IssueResult> | null = null;
let dropRenewer: (() => void) | null = null;
const state = async (): Promise<AcmeState> => {
const stored = (await ctx.kv.get(STATE_KEY)) as Partial<AcmeState> | null;
return {
lastAttemptAt: stored?.lastAttemptAt ?? null,
lastIssuedAt: stored?.lastIssuedAt ?? null,
lastError: stored?.lastError ?? null,
};
};
const record = async (patch: Partial<AcmeState>) => {
await ctx.kv.set(STATE_KEY, { ...(await state()), ...patch });
};
const runIssue = async (): Promise<IssueResult> => {
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);
}
+77
View File
@@ -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<Exclude<AcmeProvider, "custom">, 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<AcmeSettings> {
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;
}
@@ -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<AcmeStatus | null>(null);
const [requesting, setRequesting] = useState(false);
const load = useCallback(async () => {
try {
const { data } = await api.get<AcmeStatus>("/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 (
<div className="flex flex-col gap-2 border border-border bg-background/50 p-2">
<span className="text-xs font-medium">{t("status.title")}</span>
{cert ? (
<>
<span className="text-[10px] text-muted-foreground">
{t("status.names", { names: cert.names.join(", ") || "-" })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("status.issuer", { issuer: cert.issuer })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("status.expires", {
date: new Date(cert.notAfter).toLocaleDateString(),
})}
</span>
</>
) : (
<span className="text-[10px] text-muted-foreground">
{t("status.none")}
</span>
)}
{status?.state.lastIssuedAt && (
<span className="text-[10px] text-muted-foreground">
{t("status.lastIssued", {
date: new Date(status.state.lastIssuedAt).toLocaleString(),
})}
</span>
)}
{status?.state.lastError && (
<span className="text-[10px] text-destructive">
{t("status.lastError", { error: status.state.lastError })}
</span>
)}
{status?.missing && (
<span className="text-[10px] text-muted-foreground">
{t("status.missing")}
</span>
)}
<Button
type="button"
variant="outline"
className="rounded-none self-start"
disabled={!running || requesting || !!status?.missing}
onClick={request}
>
{requesting ? t("status.requesting") : t("status.request")}
</Button>
</div>
);
}
+10
View File
@@ -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.
}
@@ -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> = {},
): 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<void>((resolve) => s.close(() => resolve())),
),
);
servers = [];
});
async function setup(
options: {
settings?: Record<string, unknown>;
certificate?: PluginTlsCertificateInfo | null;
permissions?: string[];
capabilities?: string[];
validateTls?: () => PluginTlsCertificateInfo;
} = {},
) {
let router: Router | null = null;
const tlsStatus: Omit<PluginTlsStatus, "renewal"> = {
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<void>((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<CapabilityError>);
});
});
@@ -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/);
});
});
@@ -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<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) =>
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(
<AcmeStatusSetting
pluginId="acme-ssl"
values={{}}
setValue={() => {}}
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");
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
@@ -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) => {
+52 -49
View File
@@ -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<void>((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
@@ -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<UserRecord | null> {
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<AcmeSettings> {
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}` });
}
});
}
+128
View File
@@ -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<UserRecord | null> {
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}` });
}
});
}
+2 -2
View File
@@ -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);
+129
View File
@@ -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<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
const CAPABILITY = "system:tls";
async function audited<T>(
deps: Deps,
action: string,
details: string,
fn: () => Promise<T>,
): Promise<T> {
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");
},
),
};
}
+2
View File
@@ -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),
+5 -1
View File
@@ -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<void> {
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();
@@ -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<string>(["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<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((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([]);
});
});
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
granted: new Set<string>(),
writes: [] as Array<[string, string]>,
reloads: 0,
renewers: [] as Array<{ pluginId: string; pluginName: string }>,
challenges: new Map<string, string>(),
}));
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);
});
});
+59
View File
@@ -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();
});
});
+82
View File
@@ -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<webcrypto.CryptoKeyPair> {
return (await webcrypto.subtle.generateKey(ALG, true, [
"sign",
"verify",
])) as webcrypto.CryptoKeyPair;
}
async function keyPem(key: webcrypto.CryptoKey): Promise<string> {
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<TestPair> {
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<TestPair> {
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),
};
}
+199
View File
@@ -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<number> {
let resolvePort: (port: number) => void = () => {};
const port = new Promise<number>((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<string> {
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<void>((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();
});
});
@@ -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<string, string>,
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([]);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* ACME http-01 answers. The CA asks for /.well-known/acme-challenge/<token>
* 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<string, { content: string; expiresAt: number }>();
/** 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);
}
+123
View File
@@ -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<typeof createPrivateKey>;
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);
}
@@ -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<string, string> = {
ENABLE_SSL: "true",
SSL_PORT: sslPort,
@@ -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<boolean> {
/**
* 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<boolean> {
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<void> {
@@ -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",
};
}
}
+227
View File
@@ -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<https.Server | null> {
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<https.Server | null> {
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<string, { renewer: TlsRenewer; count: number }>();
/** 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<TlsStatus> {
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<TlsCertificateInfo> {
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<TlsReloadResult> {
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.",
};
}
}
@@ -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<AcmeSslSettingsMigrationResult> {
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;
}
-59
View File
@@ -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<AcmeSettings> {
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<AcmeSettings, "certStatus" | "certExpiresAt" | "lastIssuedAt">
>,
): Promise<AcmeSettings> {
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<AcmeSettings & { success: boolean; reloadMessage?: string }> {
try {
const response = await authApi.post("/users/manual-ssl-upload", payload);
return response.data;
} catch (error) {
handleApiError(error, "upload manual SSL certificate");
}
}
+38
View File
@@ -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<TlsStatus> {
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<TlsStatus & { success: boolean; reloadMessage?: string }> {
try {
const response = await authApi.post("/users/tls-certificate", payload);
return response.data;
} catch (error) {
handleApiError(error, "upload TLS certificate");
}
}
+10 -27
View File
@@ -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",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
-23
View File
@@ -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": "ব্যক্তিগত কী (পিইএম)",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
-23
View File
@@ -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)",
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff. Show more