mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move acme certificate automation into a plugin (C7)
This commit is contained in:
1 parent
4dbce08379
commit
8c1e02af89
123 files changed
+4808
-1912
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Generated
+216
-66
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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": "請求憑證中..."
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "ব্যক্তিগত কী (পিইএম)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user