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
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user