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

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

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
- ACME certificates moved out of core. Termix no longer runs certbot; the
plugin talks to the CA directly.
- The domain, email, challenge type and Cloudflare token moved from Admin
Settings into this plugin's settings. The token is now stored encrypted.
- Renewal is automatic: a check every 12 hours renews within 30 days of
expiry.
- The 2.8 certbot account is not reused. A new ACME account is registered on
the first request.
- Uploading your own certificate stays in Admin Settings > HTTPS certificate.
+36
View File
@@ -0,0 +1,36 @@
# ACME Certificates
Gets the certificate Termix serves over HTTPS from Let's Encrypt, or any other
ACME certificate authority, and renews it before it expires.
Termix itself serves HTTPS. This plugin only gets certificates: it hands each
one to core, which checks that the key matches and swaps it in without a
restart. Turn the plugin off and the current certificate keeps being served,
but nothing renews it. Admin Settings > HTTPS certificate then warns with the
expiry date.
## Settings
Admin settings, under Settings > Plugins > ACME Certificates:
- **Renew automatically**: check twice a day and get a new certificate when
there is none, it is the self-signed one, it does not cover the domain, or
it expires within 30 days.
- **Domain** and **Email**.
- **Certificate authority**: Let's Encrypt, Let's Encrypt staging (for
testing without rate limits) or a custom ACME directory URL.
- **Challenge type**:
- **HTTP**: the CA fetches `http://<domain>/.well-known/acme-challenge/...`,
so port 80 must reach Termix. Core answers it.
- **DNS (Cloudflare)**: needs an API token with Zone:DNS:Edit. Works when
port 80 is closed.
The status box shows the served certificate and the last attempt, and has a
button to request a certificate now.
## How it works
It runs [acme-client](https://github.com/publishlab/node-acme-client) with
every request sent through `ctx.fetch`. The ACME account key is sealed with
`ctx.secrets.seal` and kept in `ctx.kv`. Certificates are written and loaded
through `ctx.system` (`system:tls`).
+58
View File
@@ -0,0 +1,58 @@
{
"permissions": {
"manage": {
"title": "Manage ACME certificates",
"description": "See the certificate status and request a new certificate now."
}
},
"settings": {
"autoRenew": {
"label": "Renew automatically",
"description": "Check twice a day and get a new certificate when there is none, it does not cover the domain, or it expires within 30 days."
},
"domain": {
"label": "Domain",
"description": "The public domain name for the certificate.",
"placeholder": "termix.example.com"
},
"email": {
"label": "Email",
"description": "Contact email for the ACME account and expiry notices.",
"placeholder": "admin@example.com"
},
"provider": {
"label": "Certificate authority",
"description": "Where to get the certificate. Use staging to test without hitting Let's Encrypt rate limits.",
"letsencrypt": "Let's Encrypt",
"letsencryptStaging": "Let's Encrypt (staging)",
"custom": "Custom ACME directory"
},
"directoryUrl": {
"label": "ACME directory URL",
"description": "Only for a custom certificate authority. Must be an https URL."
},
"challengeType": {
"label": "Challenge type",
"description": "How to prove you own the domain.",
"http": "HTTP - needs port 80 reachable from the internet",
"cloudflare": "DNS (Cloudflare) - needs a Cloudflare API token"
},
"cloudflareToken": {
"label": "Cloudflare API token",
"description": "Scoped token with Zone:DNS:Edit permission for your domain."
}
},
"status": {
"title": "Certificate status",
"none": "No certificate",
"names": "Covers {{names}}",
"issuer": "Issued by {{issuer}}",
"expires": "Expires {{date}}",
"lastIssued": "Last issued {{date}}",
"lastError": "Last attempt failed: {{error}}",
"missing": "Set the domain, email and the fields your challenge type needs, then save, before requesting a certificate.",
"request": "Request certificate now",
"requesting": "Requesting certificate...",
"issued": "Certificate issued and installed"
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domein",
"placeholder": "termix.voorbeeld.com",
"description": "Die publieke domeinnaam vir die sertifikaat."
},
"email": {
"label": "E-pos",
"placeholder": "admin@voorbeeld.com"
},
"challengeType": {
"label": "Uitdagingsoort",
"cloudflare": "DNS (Cloudflare) - vereis 'n Cloudflare API-token"
},
"cloudflareToken": {
"label": "Cloudflare API-token",
"description": "Omvangryke token met Zone:DNS:Wysig toestemming vir jou domein."
}
},
"status": {
"title": "Sertifikaatstatus",
"none": "Geen sertifikaat nie",
"expires": "Vervaldatum {{date}}",
"lastIssued": "Laas uitgereik {{date}}",
"request": "Uitreik / Hernu Sertifikaat",
"requesting": "Versoek sertifikaat..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "النطاق",
"placeholder": "termix.example.com",
"description": "اسم النطاق العام للشهادة."
},
"email": {
"label": "البريد الإلكتروني",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "نوع التحدي",
"cloudflare": "DNS (Cloudflare) - يتطلب رمز Cloudflare API"
},
"cloudflareToken": {
"label": "رمز Cloudflare API",
"description": "رمز مقيد بصلاحية Zone:DNS:Edit للنطاق الخاص بك."
}
},
"status": {
"title": "حالة الشهادة",
"none": "لا توجد شهادة",
"expires": "تنتهي في {{date}}",
"lastIssued": "آخر إصدار {{date}}",
"request": "إصدار / تجديد الشهادة",
"requesting": "جارٍ طلب الشهادة..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Домейн",
"placeholder": "termix.example.com",
"description": "Публичното име на домейна за сертификата."
},
"email": {
"label": "Имейл",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Тип предизвикателство",
"cloudflare": "DNS (Cloudflare) - изисква Cloudflare API токен"
},
"cloudflareToken": {
"label": "API токен на Cloudflare",
"description": "Ограничен токен с разрешение Zone:DNS:Edit за вашия домейн."
}
},
"status": {
"title": "Статус на сертификата",
"none": "Няма сертификат",
"expires": "Изтича {{date}}",
"lastIssued": "Последно издадено {{date}}",
"request": "Издаване / Подновяване на сертификат",
"requesting": "Заявка за сертификат..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "ডোমেইন",
"placeholder": "termix.example.com",
"description": "সার্টিফিকেটটির পাবলিক ডোমেইন নাম।"
},
"email": {
"label": "ইমেল",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "চ্যালেঞ্জের ধরণ",
"cloudflare": "ডিএনএস (ক্লাউডফ্লেয়ার) - এর জন্য একটি ক্লাউডফ্লেয়ার এপিআই টোকেন প্রয়োজন।"
},
"cloudflareToken": {
"label": "ক্লাউডফ্লেয়ার এপিআই টোকেন",
"description": "আপনার ডোমেনের জন্য Zone:DNS:Edit অনুমতিসহ স্কোপড টোকেন।"
}
},
"status": {
"title": "সার্টিফিকেটের অবস্থা",
"none": "কোন সার্টিফিকেট নেই",
"expires": "মেয়াদ শেষ হবে {{date}}",
"lastIssued": "সর্বশেষ প্রকাশিত {{date}}",
"request": "সার্টিফিকেট ইস্যু / নবায়ন করুন",
"requesting": "সার্টিফিকেটের জন্য অনুরোধ..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domini",
"placeholder": "termix.example.com",
"description": "El nom de domini públic del certificat."
},
"email": {
"label": "Correu electrònic",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Tipus de repte",
"cloudflare": "DNS (Cloudflare): requereix un token d'API de Cloudflare"
},
"cloudflareToken": {
"label": "Token de l'API de Cloudflare",
"description": "Token amb àmbit i permís Zone:DNS:Edit per al vostre domini."
}
},
"status": {
"title": "Estat del certificat",
"none": "Sense certificat",
"expires": "Caduca {{date}}",
"lastIssued": "Última emissió {{date}}",
"request": "Emetre / Renovar el certificat",
"requesting": "Sol·licitant certificat..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Doména",
"placeholder": "termix.example.com",
"description": "Název veřejné domény pro certifikát."
},
"email": {
"label": "E-mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Typ výzvy",
"cloudflare": "DNS (Cloudflare) – vyžaduje token Cloudflare API"
},
"cloudflareToken": {
"label": "Token API Cloudflare",
"description": "Token s vymezeným rozsahem a oprávněním Zóna:DNS:Úpravy pro vaši doménu."
}
},
"status": {
"title": "Stav certifikátu",
"none": "Žádný certifikát",
"expires": "Platnost vyprší {{date}}",
"lastIssued": "Naposledy vydáno {{date}}",
"request": "Vydání / Obnova certifikátu",
"requesting": "Žádost o certifikát..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domæne",
"placeholder": "termix.example.com",
"description": "Det offentlige domænenavn for certifikatet."
},
"email": {
"label": "E-mail",
"placeholder": "admin@eksempel.com"
},
"challengeType": {
"label": "Udfordringstype",
"cloudflare": "DNS (Cloudflare) - kræver et Cloudflare API-token"
},
"cloudflareToken": {
"label": "Cloudflare API-token",
"description": "Scoped-token med Zone:DNS:Rediger-tilladelse for dit domæne."
}
},
"status": {
"title": "Certifikatstatus",
"none": "Intet certifikat",
"expires": "Udløber {{date}}",
"lastIssued": "Sidst udstedt {{date}}",
"request": "Udstede/forny certifikat",
"requesting": "Anmoder om certifikat..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domain",
"placeholder": "termix.example.com",
"description": "Der öffentliche Domainname für das Zertifikat."
},
"email": {
"label": "E-Mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Challenge-Typ",
"cloudflare": "DNS (Cloudflare) – erfordert einen Cloudflare-API-Token"
},
"cloudflareToken": {
"label": "Cloudflare-API-Token",
"description": "Bereichsbezogener Token mit Zone:DNS:Edit-Berechtigung für Ihre Domain."
}
},
"status": {
"title": "Zertifikatsstatus",
"none": "Kein Zertifikat",
"expires": "Läuft ab {{date}}",
"lastIssued": "Zuletzt ausgestellt {{date}}",
"request": "Zertifikat ausstellen / erneuern",
"requesting": "Zertifikat wird angefordert..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Πεδίο ορισμού",
"placeholder": "termix.example.com",
"description": "Το όνομα δημόσιου τομέα για το πιστοποιητικό."
},
"email": {
"label": "E-mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Τύπος πρόκλησης",
"cloudflare": "DNS (Cloudflare) - απαιτεί διακριτικό API Cloudflare"
},
"cloudflareToken": {
"label": "Διακριτικό API Cloudflare",
"description": "Διακριτικό πεδίου εφαρμογής με δικαίωμα Zone:DNS:Edit για τον τομέα σας."
}
},
"status": {
"title": "Κατάσταση πιστοποιητικού",
"none": "Δεν υπάρχει πιστοποιητικό",
"expires": "Λήγει {{date}}",
"lastIssued": "Τελευταία έκδοση {{date}}",
"request": "Έκδοση / Ανανέωση Πιστοποιητικού",
"requesting": "Αίτημα πιστοποιητικού..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Dominio",
"placeholder": "termix.example.com",
"description": "El nombre de dominio público para el certificado."
},
"email": {
"label": "Correo electrónico",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Tipo de desafío",
"cloudflare": "DNS (Cloudflare) - requiere un token de API de Cloudflare"
},
"cloudflareToken": {
"label": "Token de API de Cloudflare",
"description": "Token con permiso Zone:DNS:Edit limitado para tu dominio."
}
},
"status": {
"title": "Estado del certificado",
"none": "Sin certificado",
"expires": "Caduca {{date}}",
"lastIssued": "Última emisión {{date}}",
"request": "Emitir / Renovar certificado",
"requesting": "Solicitando certificado..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Verkkotunnus",
"placeholder": "termix.esimerkki.com",
"description": "Varmenteen julkinen verkkotunnus."
},
"email": {
"label": "Sähköposti",
"placeholder": "admin@esimerkki.com"
},
"challengeType": {
"label": "Haasteen tyyppi",
"cloudflare": "DNS (Cloudflare) – vaatii Cloudflare API -tunnuksen"
},
"cloudflareToken": {
"label": "Cloudflare API-tunnus",
"description": "Laajuusaluetunnus, jolla on Zone:DNS:Edit-käyttöoikeus verkkotunnuksellesi."
}
},
"status": {
"title": "Sertifikaatin tila",
"none": "Ei todistusta",
"expires": "Vanhenee {{date}}",
"lastIssued": "Viimeksi myönnetty {{date}}",
"request": "Myönnä / Uudista sertifikaatti",
"requesting": "Pyydetään varmennetta..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domaine",
"placeholder": "termix.example.com",
"description": "Le nom de domaine public pour le certificat."
},
"email": {
"label": "E-mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Type de challenge",
"cloudflare": "DNS (Cloudflare) - nécessite un jeton API Cloudflare"
},
"cloudflareToken": {
"label": "Jeton API Cloudflare",
"description": "Jeton limité avec l'autorisation Zone:DNS:Edit pour votre domaine."
}
},
"status": {
"title": "Statut du certificat",
"none": "Aucun certificat",
"expires": "Expire le {{date}}",
"lastIssued": "Dernière émission le {{date}}",
"request": "Émettre / Renouveler le certificat",
"requesting": "Demande de certificat en cours..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "תְחוּם",
"placeholder": "termix.example.com",
"description": "שם התחום הציבורי עבור התעודה."
},
"email": {
"label": "אֶלֶקטרוֹנִי",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "סוג האתגר",
"cloudflare": "DNS (Cloudflare) - דורש אסימון API של Cloudflare"
},
"cloudflareToken": {
"label": "אסימון API של Cloudflare",
"description": "אסימון בטווח עם הרשאת עריכה של אזור:DNS: עבור הדומיין שלך."
}
},
"status": {
"title": "סטטוס התעודה",
"none": "אין תעודה",
"expires": "פג תוקף {{date}}",
"lastIssued": "הוצאה אחרונה {{date}}",
"request": "הנפקה / חידוש תעודה",
"requesting": "מבקש תעודה..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "डोमेन",
"placeholder": "termix.example.com",
"description": "प्रमाणपत्र के लिए सार्वजनिक डोमेन नाम।"
},
"email": {
"label": "ईमेल",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "चुनौती प्रकार",
"cloudflare": "DNS (Cloudflare) - Cloudflare API टोकन आवश्यक है"
},
"cloudflareToken": {
"label": "Cloudflare API टोकन",
"description": "आपके डोमेन के लिए Zone:DNS:Edit अनुमति वाला स्कोप्ड टोकन।"
}
},
"status": {
"title": "प्रमाणपत्र स्थिति",
"none": "कोई प्रमाणपत्र नहीं",
"expires": "{{date}} को समाप्त होगा",
"lastIssued": "अंतिम जारी: {{date}}",
"request": "प्रमाणपत्र जारी करें / नवीनीकरण करें",
"requesting": "प्रमाणपत्र अनुरोध किया जा रहा है..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domain",
"placeholder": "termix.example.com",
"description": "A tanúsítvány nyilvános domainneve."
},
"email": {
"label": "Email",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Kihívás típusa",
"cloudflare": "DNS (Cloudflare) – Cloudflare API tokent igényel"
},
"cloudflareToken": {
"label": "Cloudflare API token",
"description": "Hatókörbe tartozó token Zóna:DNS:Szerkesztés engedéllyel a domainhez."
}
},
"status": {
"title": "Tanúsítvány állapota",
"none": "Nincs tanúsítvány",
"expires": "Lejár {{date}}",
"lastIssued": "Utolsó kiadás {{date}}",
"request": "Tanúsítvány kiállítása / megújítása",
"requesting": "Tanúsítvány kérése..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domain",
"placeholder": "termix.example.com",
"description": "Nama domain publik untuk sertifikat tersebut."
},
"email": {
"label": "E-mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Jenis Tantangan",
"cloudflare": "DNS (Cloudflare) - memerlukan token API Cloudflare"
},
"cloudflareToken": {
"label": "Token API Cloudflare",
"description": "Token terlingkup dengan izin Zone:DNS:Edit untuk domain Anda."
}
},
"status": {
"title": "Status Sertifikat",
"none": "Tidak ada sertifikat",
"expires": "Berakhir {{date}}",
"lastIssued": "Terakhir diterbitkan {{date}}",
"request": "Menerbitkan/Memperpanjang Sertifikat",
"requesting": "Meminta sertifikat..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Dominio",
"placeholder": "termix.example.com",
"description": "Il nome di dominio pubblico per il certificato."
},
"email": {
"label": "Email",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Tipo di challenge",
"cloudflare": "DNS (Cloudflare) - richiede un token API Cloudflare"
},
"cloudflareToken": {
"label": "Token API Cloudflare",
"description": "Token con autorizzazione Zone:DNS:Edit per il tuo dominio."
}
},
"status": {
"title": "Stato del certificato",
"none": "Nessun certificato",
"expires": "Scade il {{date}}",
"lastIssued": "Ultimo rilascio il {{date}}",
"request": "Emetti / Rinnova certificato",
"requesting": "Richiesta certificato in corso..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "ドメイン",
"placeholder": "termix.example.com",
"description": "証明書の公開ドメイン名。"
},
"email": {
"label": "メールアドレス",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "チャレンジタイプ",
"cloudflare": "DNS(Cloudflare)- Cloudflare APIトークンが必要です"
},
"cloudflareToken": {
"label": "Cloudflare APIトークン",
"description": "ドメインに対するZone:DNS:Edit権限を持つスコープ付きトークン。"
}
},
"status": {
"title": "証明書ステータス",
"none": "証明書なし",
"expires": "有効期限 {{date}}",
"lastIssued": "最終発行日 {{date}}",
"request": "証明書を発行/更新",
"requesting": "証明書をリクエスト中..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "도메인",
"placeholder": "termix.example.com",
"description": "인증서에 사용할 공개 도메인 이름입니다."
},
"email": {
"label": "이메일",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "인증 방식",
"cloudflare": "DNS(Cloudflare) - Cloudflare API 토큰 필요"
},
"cloudflareToken": {
"label": "Cloudflare API 토큰",
"description": "도메인에 대한 Zone:DNS:Edit 권한이 있는 범위 지정 토큰입니다."
}
},
"status": {
"title": "인증서 상태",
"none": "인증서 없음",
"expires": "만료일: {{date}}",
"lastIssued": "마지막 발급일: {{date}}",
"request": "인증서 발급 / 갱신",
"requesting": "인증서 요청 중..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domein",
"placeholder": "termix.example.com",
"description": "De publieke domeinnaam voor het certificaat."
},
"email": {
"label": "E-mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Uitdagingstype",
"cloudflare": "DNS (Cloudflare) - vereist een Cloudflare API-token"
},
"cloudflareToken": {
"label": "Cloudflare API-token",
"description": "Een scoped token met Zone:DNS:Edit-machtiging voor uw domein."
}
},
"status": {
"title": "Certificaatstatus",
"none": "Geen certificaat",
"expires": "Verloopt {{date}}",
"lastIssued": "Laatst uitgegeven {{date}}",
"request": "Certificaat aanvragen/verlengen",
"requesting": "Certificaat aanvragen..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domene",
"placeholder": "termix.example.com",
"description": "Det offentlige domenenavnet for sertifikatet."
},
"email": {
"label": "E-post",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Utfordringstype",
"cloudflare": "DNS (Cloudflare) – krever et Cloudflare API-token"
},
"cloudflareToken": {
"label": "Cloudflare API-token",
"description": "Scope-token med Zone:DNS:Rediger-tillatelse for domenet ditt."
}
},
"status": {
"title": "Sertifikatstatus",
"none": "Ingen sertifikat",
"expires": "Utløper {{date}}",
"lastIssued": "Sist utstedt {{date}}",
"request": "Utstede/fornye sertifikat",
"requesting": "Be om sertifikat..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domena",
"placeholder": "termix.example.com",
"description": "Publiczna nazwa domeny certyfikatu."
},
"email": {
"label": "E-mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Typ wyzwania",
"cloudflare": "DNS (Cloudflare) - wymaga tokenu API Cloudflare"
},
"cloudflareToken": {
"label": "Token API Cloudflare",
"description": "Token zakresowy z uprawnieniem Zone:DNS:Edit dla Twojej domeny."
}
},
"status": {
"title": "Status certyfikatu",
"none": "Brak certyfikatu",
"expires": "Wygasa {{date}}",
"lastIssued": "Ostatnio wystawiono {{date}}",
"request": "Wystaw / odnów certyfikat",
"requesting": "Żądanie certyfikatu..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domínio",
"placeholder": "termix.example.com",
"description": "O nome de domínio público do certificado."
},
"email": {
"label": "E-mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Tipo de desafio",
"cloudflare": "DNS (Cloudflare) - requer um token de API do Cloudflare"
},
"cloudflareToken": {
"label": "Token da API do Cloudflare",
"description": "Token com escopo definido e permissão Zone:DNS:Edit para o seu domínio."
}
},
"status": {
"title": "Status do certificado",
"none": "Sem certificado",
"expires": "Expira em {{date}}",
"lastIssued": "Última edição {{date}}",
"request": "Emitir/Renovar Certificado",
"requesting": "Solicitar certificado..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domínio",
"placeholder": "termix.example.com",
"description": "O nome de domínio público para o certificado."
},
"email": {
"label": "E-mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Tipo de desafio",
"cloudflare": "DNS (Cloudflare) - requer um token de API da Cloudflare"
},
"cloudflareToken": {
"label": "Token de API da Cloudflare",
"description": "Token com âmbito e permissão Zone:DNS:Edit para o seu domínio."
}
},
"status": {
"title": "Estado do certificado",
"none": "Sem certificado",
"expires": "Expira a {{date}}",
"lastIssued": "Última emissão a {{date}}",
"request": "Emitir / Renovar certificado",
"requesting": "A solicitar certificado..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domeniu",
"placeholder": "termix.example.com",
"description": "Numele de domeniu public pentru certificat."
},
"email": {
"label": "E-mail",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Tipul provocării",
"cloudflare": "DNS (Cloudflare) - necesită un token API Cloudflare"
},
"cloudflareToken": {
"label": "Tokenul API Cloudflare",
"description": "Token cu scop specific și permisiune Zone:DNS:Edit pentru domeniul dvs."
}
},
"status": {
"title": "Starea certificatului",
"none": "Fără certificat",
"expires": "Expiră la {{date}}",
"lastIssued": "Ultima emisiune {{date}}",
"request": "Emitere / Reînnoire certificat",
"requesting": "Se solicită certificatul..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Домен",
"placeholder": "termix.example.com",
"description": "Общедоступное доменное имя для сертификата."
},
"email": {
"label": "Email",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Тип проверки",
"cloudflare": "DNS (Cloudflare) — требуется API-токен Cloudflare"
},
"cloudflareToken": {
"label": "API-токен Cloudflare",
"description": "Токен с ограниченной областью действия и разрешением Zone:DNS:Edit для вашего домена."
}
},
"status": {
"title": "Статус сертификата",
"none": "Сертификат отсутствует",
"expires": "Истекает {{date}}",
"lastIssued": "Последний выпуск {{date}}",
"request": "Выпустить / Обновить сертификат",
"requesting": "Запрос сертификата..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Домен",
"placeholder": "termix.example.com",
"description": "Јавно име домена за сертификат."
},
"email": {
"label": "Е-пошта",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Тип изазова",
"cloudflare": "DNS (Cloudflare) - захтева Cloudflare API токен"
},
"cloudflareToken": {
"label": "Клаудфлејр АПИ токен",
"description": "Ограничени токен са дозволом Zone:DNS:Edit за ваш домен."
}
},
"status": {
"title": "Статус сертификата",
"none": "Без сертификата",
"expires": "Истиче {{date}}",
"lastIssued": "Последње издање {{date}}",
"request": "Издавање / обнављање сертификата",
"requesting": "Захтева се сертификат..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Domän",
"placeholder": "termix.example.com",
"description": "Det offentliga domännamnet för certifikatet."
},
"email": {
"label": "E-post",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Utmaningstyp",
"cloudflare": "DNS (Cloudflare) – kräver en Cloudflare API-token"
},
"cloudflareToken": {
"label": "Cloudflare API-token",
"description": "Scoped-token med Zone:DNS:Edit-behörighet för din domän."
}
},
"status": {
"title": "Certifikatstatus",
"none": "Inget certifikat",
"expires": "Utgångsdatum {{date}}",
"lastIssued": "Senast utfärdad {{date}}",
"request": "Utfärda/förnya certifikat",
"requesting": "Begär certifikat..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "โดเมน",
"placeholder": "เทอร์มิกซ์.ตัวอย่าง.com",
"description": "ชื่อโดเมนสาธารณะสำหรับใบรับรอง"
},
"email": {
"label": "อีเมล",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "ประเภทความท้าทาย",
"cloudflare": "DNS (Cloudflare) - ต้องใช้โทเค็น API ของ Cloudflare"
},
"cloudflareToken": {
"label": "โทเค็น API ของ Cloudflare",
"description": "โทเค็นแบบจำกัดขอบเขตพร้อมสิทธิ์ Zone:DNS:Edit สำหรับโดเมนของคุณ"
}
},
"status": {
"title": "สถานะใบรับรอง",
"none": "ไม่มีใบรับรอง",
"expires": "หมดอายุ {{date}}",
"lastIssued": "ออกครั้งล่าสุด {{date}}",
"request": "ออก/ต่ออายุใบรับรอง",
"requesting": "ขอใบรับรอง..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Alan Adı",
"placeholder": "termix.example.com",
"description": "Sertifika için genel alan adı."
},
"email": {
"label": "E-posta",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Doğrulama Türü",
"cloudflare": "DNS (Cloudflare) - bir Cloudflare API belirteci gerektirir"
},
"cloudflareToken": {
"label": "Cloudflare API Belirteci",
"description": "Alan adınız için Zone:DNS:Edit iznine sahip kapsamlı belirteç."
}
},
"status": {
"title": "Sertifika Durumu",
"none": "Sertifika yok",
"expires": "Son kullanma: {{date}}",
"lastIssued": "Son veriliş: {{date}}",
"request": "Sertifika Ver / Yenile",
"requesting": "Sertifika isteniyor..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Домен",
"placeholder": "termix.example.com",
"description": "Ім'я публічного домену для сертифіката."
},
"email": {
"label": "Електронна пошта",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Тип виклику",
"cloudflare": "DNS (Cloudflare) – потрібен токен API Cloudflare"
},
"cloudflareToken": {
"label": "Токен API Cloudflare",
"description": "Токен з обмеженою областю дії та дозволом Zone:DNS:Edit для вашого домену."
}
},
"status": {
"title": "Статус сертифіката",
"none": "Без сертифіката",
"expires": "Термін дії закінчується {{date}}",
"lastIssued": "Останній виданий {{date}}",
"request": "Видача / поновлення сертифіката",
"requesting": "Запит сертифіката..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "Tên miền",
"placeholder": "termix.example.com",
"description": "Tên miền công khai để cấp chứng chỉ."
},
"email": {
"label": "Email",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "Loại thử thách",
"cloudflare": "DNS (Cloudflare) - yêu cầu Token API Cloudflare"
},
"cloudflareToken": {
"label": "Token API Cloudflare",
"description": "Token có phạm vi với quyền Zone:DNS:Edit cho tên miền của bạn."
}
},
"status": {
"title": "Trạng thái chứng chỉ",
"none": "Không có chứng chỉ",
"expires": "Hết hạn {{date}}",
"lastIssued": "Cấp lần cuối {{date}}",
"request": "Cấp / Gia hạn chứng chỉ",
"requesting": "Đang yêu cầu chứng chỉ..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "域名",
"placeholder": "termix.example.com",
"description": "证书的公共域名。"
},
"email": {
"label": "邮箱",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "验证类型",
"cloudflare": "DNS (Cloudflare) - 需要 Cloudflare API 令牌"
},
"cloudflareToken": {
"label": "Cloudflare API 令牌",
"description": "具有您域名的 Zone:DNS:Edit 权限的限定作用域令牌。"
}
},
"status": {
"title": "证书状态",
"none": "无证书",
"expires": "过期于 {{date}}",
"lastIssued": "上次签发于 {{date}}",
"request": "签发 / 续期证书",
"requesting": "正在请求证书..."
}
}
@@ -0,0 +1,29 @@
{
"settings": {
"domain": {
"label": "網域",
"placeholder": "termix.example.com",
"description": "憑證使用的公開網域名稱。"
},
"email": {
"label": "電子郵件",
"placeholder": "admin@example.com"
},
"challengeType": {
"label": "驗證類型",
"cloudflare": "DNS (Cloudflare) - 需要 Cloudflare API 權杖"
},
"cloudflareToken": {
"label": "Cloudflare API 權杖",
"description": "具有您網域 Zone:DNS:Edit 權限的限定範圍權杖。"
}
},
"status": {
"title": "憑證狀態",
"none": "無憑證",
"expires": "到期日 {{date}}",
"lastIssued": "上次簽發日期 {{date}}",
"request": "簽發 / 續期憑證",
"requesting": "請求憑證中..."
}
}
+120
View File
@@ -0,0 +1,120 @@
{
"id": "acme-ssl",
"name": "ACME Certificates",
"version": "1.0.0",
"description": "Gets and renews the Termix HTTPS certificate from Let's Encrypt or another ACME CA, with the http-01 or Cloudflare dns-01 challenge. Termix keeps serving the current certificate when this plugin is off, but it is no longer renewed.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Access & Security",
"icon": "Lock",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"system:tls",
"kv:own",
"secrets:own",
"network:outbound",
"network:serve",
"ui:surface"
],
"contributes": {
"permissions": [
{
"name": "manage",
"titleKey": "permissions.manage.title",
"descriptionKey": "permissions.manage.description",
"defaultRoles": ["admin"]
}
],
"settings": {
"admin": [
{
"key": "status",
"type": "custom",
"component": "status"
},
{
"key": "autoRenew",
"type": "boolean",
"labelKey": "settings.autoRenew.label",
"descriptionKey": "settings.autoRenew.description",
"default": false
},
{
"key": "domain",
"type": "string",
"labelKey": "settings.domain.label",
"descriptionKey": "settings.domain.description",
"placeholderKey": "settings.domain.placeholder",
"default": ""
},
{
"key": "email",
"type": "string",
"labelKey": "settings.email.label",
"descriptionKey": "settings.email.description",
"placeholderKey": "settings.email.placeholder",
"default": ""
},
{
"key": "provider",
"type": "select",
"labelKey": "settings.provider.label",
"descriptionKey": "settings.provider.description",
"default": "letsencrypt",
"options": [
{
"value": "letsencrypt",
"labelKey": "settings.provider.letsencrypt"
},
{
"value": "letsencrypt-staging",
"labelKey": "settings.provider.letsencryptStaging"
},
{
"value": "custom",
"labelKey": "settings.provider.custom"
}
]
},
{
"key": "directoryUrl",
"type": "string",
"labelKey": "settings.directoryUrl.label",
"descriptionKey": "settings.directoryUrl.description",
"default": ""
},
{
"key": "challengeType",
"type": "select",
"labelKey": "settings.challengeType.label",
"descriptionKey": "settings.challengeType.description",
"default": "http-01",
"options": [
{
"value": "http-01",
"labelKey": "settings.challengeType.http"
},
{
"value": "dns-cloudflare",
"labelKey": "settings.challengeType.cloudflare"
}
]
},
{
"key": "cloudflareToken",
"type": "secret",
"labelKey": "settings.cloudflareToken.label",
"descriptionKey": "settings.cloudflareToken.description"
}
]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@termix-plugin/acme-ssl",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"dependencies": {
"acme-client": "^5.4.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Issuing a certificate with acme-client. Every request it makes goes through
* ctx.fetch, so ACME traffic gets core's outbound guard and audit like any
* other plugin request.
*/
import * as acme from "acme-client";
import type { PluginContext, PluginFetch } from "@termix/plugin-sdk/backend";
import { createTxtRecord } from "./cloudflare.js";
import { directoryUrlFor, type AcmeSettings } from "./settings.js";
const ACCOUNT_KEY = "account";
const DNS_PROPAGATION_MS = 30_000;
interface StoredAccount {
directoryUrl: string;
sealedKey: string;
url: string | null;
}
export interface IssuedCertificate {
certificate: string;
privateKey: string;
}
type AxiosLikeConfig = {
url?: string;
method?: string;
headers?: unknown;
data?: unknown;
timeout?: number;
validateStatus?: ((status: number) => boolean) | null;
};
function plainHeaders(headers: unknown): Record<string, string> {
const source =
headers && typeof (headers as { toJSON?: unknown }).toJSON === "function"
? (headers as { toJSON: () => Record<string, unknown> }).toJSON()
: ((headers ?? {}) as Record<string, unknown>);
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
if (typeof value === "string" || typeof value === "number") {
out[key] = String(value);
}
}
return out;
}
/**
* An axios adapter over ctx.fetch. It settles the way axios' own adapters do,
* because acme-client's retry interceptor relies on a rejected error that
* carries the response.
*/
export function createFetchAdapter(
fetch: PluginFetch,
allowPrivateHosts: readonly string[] = [],
) {
return async (config: AxiosLikeConfig) => {
const method = (config.method ?? "get").toUpperCase();
const response = await fetch(String(config.url), {
method,
headers: plainHeaders(config.headers),
body:
config.data == null
? undefined
: typeof config.data === "string"
? config.data
: JSON.stringify(config.data),
timeoutMs: config.timeout || 30_000,
allowPrivateHosts,
});
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const result = {
data: method === "HEAD" ? "" : await response.text(),
status: response.status,
statusText: response.statusText,
headers,
config,
request: {},
};
const validate = config.validateStatus;
if (!result.status || !validate || validate(result.status)) return result;
throw Object.assign(
new Error(`Request failed with status code ${result.status}`),
{
isAxiosError: true,
code: result.status >= 500 ? "ERR_BAD_RESPONSE" : "ERR_BAD_REQUEST",
config,
response: result,
},
);
};
}
/** Private hosts a custom directory on the local network may use. */
function privateHostsFor(settings: AcmeSettings): string[] {
if (settings.provider !== "custom") return [];
try {
return [new URL(settings.directoryUrl).hostname];
} catch {
return [];
}
}
async function loadAccount(
ctx: PluginContext,
directoryUrl: string,
): Promise<{ key: string; url: string | null }> {
const stored = (await ctx.kv.get(ACCOUNT_KEY)) as StoredAccount | null;
if (stored && stored.directoryUrl === directoryUrl) {
const key = await ctx.secrets.unseal(stored.sealedKey);
if (key) return { key, url: stored.url };
}
const key = (await acme.crypto.createPrivateKey()).toString();
await ctx.kv.set(ACCOUNT_KEY, {
directoryUrl,
sealedKey: await ctx.secrets.seal(key),
url: null,
} satisfies StoredAccount);
return { key, url: null };
}
async function saveAccountUrl(
ctx: PluginContext,
directoryUrl: string,
url: string,
): Promise<void> {
const stored = (await ctx.kv.get(ACCOUNT_KEY)) as StoredAccount | null;
if (!stored || stored.directoryUrl !== directoryUrl || stored.url === url) {
return;
}
await ctx.kv.set(ACCOUNT_KEY, { ...stored, url });
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** Runs one ACME order for `settings.domain` and returns the new pair. */
export async function issueCertificate(
ctx: PluginContext,
settings: AcmeSettings,
options: { propagationMs?: number } = {},
): Promise<IssuedCertificate> {
const directoryUrl = directoryUrlFor(settings);
acme.axios.defaults.adapter = createFetchAdapter(
ctx.fetch,
privateHostsFor(settings),
) as never;
const account = await loadAccount(ctx, directoryUrl);
const client = new acme.Client({
directoryUrl,
accountKey: account.key,
...(account.url ? { accountUrl: account.url } : {}),
});
const [key, csr] = await acme.crypto.createCsr({
commonName: settings.domain,
altNames: [settings.domain],
});
const cleanups = new Map<string, () => void | Promise<void>>();
const certificate = await client.auto({
csr,
email: settings.email,
termsOfServiceAgreed: true,
// Checking our own answer first needs port 80 or DNS from inside the
// container, which often is not how the CA sees it.
skipChallengeVerification: true,
challengePriority:
settings.challengeType === "dns-cloudflare" ? ["dns-01"] : ["http-01"],
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
if (challenge.type === "http-01") {
cleanups.set(
challenge.token,
await ctx.system.publishHttpChallenge(
challenge.token,
keyAuthorization,
),
);
return;
}
if (challenge.type === "dns-01") {
cleanups.set(
challenge.token,
await createTxtRecord(
ctx.fetch,
settings.cloudflareToken,
`_acme-challenge.${authz.identifier.value}`,
keyAuthorization,
),
);
await sleep(options.propagationMs ?? DNS_PROPAGATION_MS);
return;
}
throw new Error(
`Unsupported challenge type ${(challenge as { type: string }).type}`,
);
},
challengeRemoveFn: async (_authz, challenge) => {
const cleanup = cleanups.get(challenge.token);
cleanups.delete(challenge.token);
try {
await cleanup?.();
} catch (error) {
ctx.log.warn(
`Could not clean up the ${challenge.type} challenge: ${error instanceof Error ? error.message : String(error)}`,
);
}
},
});
await saveAccountUrl(ctx, directoryUrl, client.getAccountUrl());
return { certificate, privateKey: key.toString() };
}
@@ -0,0 +1,78 @@
import type { PluginFetch } from "@termix/plugin-sdk/backend";
const API = "https://api.cloudflare.com/client/v4";
interface CloudflareEnvelope<T> {
success: boolean;
errors?: Array<{ message?: string }>;
result: T;
}
async function call<T>(
fetch: PluginFetch,
token: string,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<T> {
const response = await fetch(`${API}${path}`, {
method: init.method ?? "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: init.body === undefined ? undefined : JSON.stringify(init.body),
});
const payload = (await response
.json()
.catch(() => null)) as CloudflareEnvelope<T> | null;
if (!response.ok || !payload?.success) {
const reason =
payload?.errors
?.map((error) => error.message)
.filter(Boolean)
.join("; ") || `HTTP ${response.status}`;
throw new Error(`Cloudflare API error: ${reason}`);
}
return payload.result;
}
/** The zone holding `name`, found by trying each parent domain. */
export async function findZoneId(
fetch: PluginFetch,
token: string,
name: string,
): Promise<string> {
const labels = name.replace(/\.$/, "").split(".");
for (let i = 0; i < labels.length - 1; i++) {
const candidate = labels.slice(i).join(".");
const zones = await call<Array<{ id: string; name: string }>>(
fetch,
token,
`/zones?name=${encodeURIComponent(candidate)}`,
);
const zone = zones.find((entry) => entry.name === candidate);
if (zone) return zone.id;
}
throw new Error(`No Cloudflare zone found for ${name}`);
}
/** Creates the TXT record and returns a function that deletes it. */
export async function createTxtRecord(
fetch: PluginFetch,
token: string,
name: string,
content: string,
): Promise<() => Promise<void>> {
const zoneId = await findZoneId(fetch, token, name);
const record = await call<{ id: string }>(
fetch,
token,
`/zones/${zoneId}/dns_records`,
{ method: "POST", body: { type: "TXT", name, content, ttl: 120 } },
);
return async () => {
await call(fetch, token, `/zones/${zoneId}/dns_records/${record.id}`, {
method: "DELETE",
});
};
}
+50
View File
@@ -0,0 +1,50 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { issueCertificate } from "./acme.js";
import {
CHECK_INTERVAL_MS,
createAcmeRunner,
watchSettings,
type IssueFn,
} from "./runner.js";
import { registerRoutes } from "./routes.js";
/** `issue` is swapped out by tests; the plugin always uses acme-client. */
export async function activateWith(ctx: PluginContext, issue: IssueFn) {
const runner = createAcmeRunner(ctx, issue);
await runner.syncRenewer();
watchSettings(ctx, () => {
runner
.syncRenewer()
.catch((error) =>
ctx.log.warn(
`Could not update the renewal registration: ${error instanceof Error ? error.message : String(error)}`,
),
);
});
// The first check lands within a minute of boot, after core's boot-time
// settings migrations, so it also picks up settings moved from 2.8.
ctx.schedule.every(
CHECK_INTERVAL_MS,
async () => {
await runner.syncRenewer();
await runner.check();
},
{ jitterMs: 60_000 },
);
registerRoutes(ctx, ctx.http.router<Router>(), runner);
return runner;
}
export async function activate(ctx: PluginContext) {
await activateWith(ctx, (pluginCtx, settings) =>
issueCertificate(pluginCtx, settings),
);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+28
View File
@@ -0,0 +1,28 @@
import type { PluginTlsStatus } from "@termix/plugin-sdk/backend";
export const RENEW_BEFORE_MS = 30 * 86_400_000;
export type RenewalReason = "missing" | "self-signed" | "domain" | "expiring";
/**
* Whether the served certificate should be replaced for `domain`: there is
* none, it is core's self-signed one, it does not cover the domain, or it
* expires within 30 days.
*/
export function renewalReason(
status: PluginTlsStatus,
domain: string,
now: Date = new Date(),
): RenewalReason | null {
const cert = status.certificate;
if (!cert) return "missing";
if (cert.selfSigned) return "self-signed";
const wanted = domain.toLowerCase();
if (!cert.names.some((name) => name.toLowerCase() === wanted)) {
return "domain";
}
if (new Date(cert.notAfter).getTime() - now.getTime() < RENEW_BEFORE_MS) {
return "expiring";
}
return null;
}
+73
View File
@@ -0,0 +1,73 @@
import type { Request, RequestHandler, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { AcmeNotConfiguredError, type AcmeRunner } from "./runner.js";
import { missingSetting, readSettings } from "./settings.js";
export function registerRoutes(
ctx: PluginContext,
router: Router,
runner: AcmeRunner,
): void {
router.use(ctx.rbac.require("manage") as unknown as RequestHandler);
/**
* @openapi
* /plugin-api/acme-ssl/status:
* get:
* summary: ACME certificate status
* description: The certificate core serves, whether ACME is configured, and the last issue attempt.
* tags: [ACME Certificates]
* responses:
* 200:
* description: Status.
* 403:
* description: Missing the acme-ssl.manage permission.
*/
router.get("/status", async (_req: Request, res: Response) => {
const settings = await readSettings(ctx);
res.json({
tls: await ctx.system.tlsStatus(),
state: await runner.state(),
autoRenew: settings.autoRenew,
missing: missingSetting(settings),
});
});
/**
* @openapi
* /plugin-api/acme-ssl/request:
* post:
* summary: Request a certificate now
* description: Runs an ACME order for the configured domain, installs the certificate and reloads HTTPS.
* tags: [ACME Certificates]
* responses:
* 200:
* description: Certificate issued and installed.
* 400:
* description: A required setting is missing.
* 403:
* description: Missing the acme-ssl.manage permission.
* 500:
* description: The ACME order failed.
*/
router.post("/request", async (_req: Request, res: Response) => {
try {
const result = await runner.issue();
res.json({
success: true,
reloadMessage: result.reload.message,
tls: result.tls,
state: await runner.state(),
});
} catch (error) {
if (error instanceof AcmeNotConfiguredError) {
return res
.status(400)
.json({ error: error.message, missing: error.field });
}
const message = error instanceof Error ? error.message : String(error);
ctx.log.warn(`Certificate request failed: ${message}`);
res.status(500).json({ error: `Certificate request failed: ${message}` });
}
});
}
+162
View File
@@ -0,0 +1,162 @@
import type {
PluginContext,
PluginTlsReloadResult,
PluginTlsStatus,
} from "@termix/plugin-sdk/backend";
import type { IssuedCertificate } from "./acme.js";
import { renewalReason } from "./renewal.js";
import {
SETTING_KEYS,
missingSetting,
readSettings,
type AcmeSettings,
} from "./settings.js";
export const CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
/** After a failed attempt, the scheduled check waits this long to retry. */
export const RETRY_AFTER_MS = 6 * 60 * 60 * 1000;
const STATE_KEY = "state";
export interface AcmeState {
lastAttemptAt: string | null;
lastIssuedAt: string | null;
lastError: string | null;
}
export type IssueFn = (
ctx: PluginContext,
settings: AcmeSettings,
) => Promise<IssuedCertificate>;
export class AcmeNotConfiguredError extends Error {
constructor(readonly field: string) {
super(`ACME is not configured: ${field} is missing`);
}
}
export interface IssueResult {
tls: PluginTlsStatus;
reload: PluginTlsReloadResult;
}
export interface AcmeRunner {
state: () => Promise<AcmeState>;
/** Issues now, whatever the served certificate looks like. */
issue: () => Promise<IssueResult>;
/** The scheduled check: issues only when renewal is on and due. */
check: (now?: Date) => Promise<boolean>;
/** Registers or drops the renewer to match the current settings. */
syncRenewer: () => Promise<void>;
}
export function createAcmeRunner(
ctx: PluginContext,
issue: IssueFn,
): AcmeRunner {
let running: Promise<IssueResult> | null = null;
let dropRenewer: (() => void) | null = null;
const state = async (): Promise<AcmeState> => {
const stored = (await ctx.kv.get(STATE_KEY)) as Partial<AcmeState> | null;
return {
lastAttemptAt: stored?.lastAttemptAt ?? null,
lastIssuedAt: stored?.lastIssuedAt ?? null,
lastError: stored?.lastError ?? null,
};
};
const record = async (patch: Partial<AcmeState>) => {
await ctx.kv.set(STATE_KEY, { ...(await state()), ...patch });
};
const runIssue = async (): Promise<IssueResult> => {
const settings = await readSettings(ctx);
const missing = missingSetting(settings);
if (missing) throw new AcmeNotConfiguredError(missing);
const attemptAt = new Date().toISOString();
try {
const issued = await issue(ctx, settings);
await ctx.system.writeTlsCertificate(
issued.certificate,
issued.privateKey,
);
const reload = await ctx.system.reloadTls();
await record({
lastAttemptAt: attemptAt,
lastIssuedAt: new Date().toISOString(),
lastError: null,
});
ctx.log.info(`Certificate issued for ${settings.domain}`);
return { tls: await ctx.system.tlsStatus(), reload };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await record({ lastAttemptAt: attemptAt, lastError: message });
throw error;
}
};
const runner: AcmeRunner = {
state,
issue: () => {
running ??= runIssue().finally(() => {
running = null;
});
return running;
},
check: async (now = new Date()) => {
const settings = await readSettings(ctx);
if (!settings.autoRenew || missingSetting(settings)) return false;
const last = await state();
if (
last.lastError &&
last.lastAttemptAt &&
now.getTime() - new Date(last.lastAttemptAt).getTime() < RETRY_AFTER_MS
) {
return false;
}
const reason = renewalReason(
await ctx.system.tlsStatus(),
settings.domain,
now,
);
if (!reason) return false;
ctx.log.info(
`Renewing the certificate for ${settings.domain} (${reason})`,
);
try {
await runner.issue();
return true;
} catch (error) {
ctx.log.error(
"Certificate renewal failed",
error instanceof Error ? error : new Error(String(error)),
);
return false;
}
},
syncRenewer: async () => {
const settings = await readSettings(ctx);
const wanted = settings.autoRenew && !missingSetting(settings);
if (wanted && !dropRenewer) {
dropRenewer = await ctx.system.registerTlsRenewer();
} else if (!wanted && dropRenewer) {
dropRenewer();
dropRenewer = null;
}
},
};
return runner;
}
export function watchSettings(ctx: PluginContext, onChange: () => void): void {
for (const key of SETTING_KEYS) ctx.settings.onChange(key, onChange);
}
+77
View File
@@ -0,0 +1,77 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
export type AcmeProvider = "letsencrypt" | "letsencrypt-staging" | "custom";
export type AcmeChallengeType = "http-01" | "dns-cloudflare";
export interface AcmeSettings {
autoRenew: boolean;
domain: string;
email: string;
provider: AcmeProvider;
directoryUrl: string;
challengeType: AcmeChallengeType;
cloudflareToken: string;
}
export const SETTING_KEYS = [
"autoRenew",
"domain",
"email",
"provider",
"directoryUrl",
"challengeType",
"cloudflareToken",
] as const;
export const DIRECTORIES: Record<Exclude<AcmeProvider, "custom">, string> = {
letsencrypt: "https://acme-v02.api.letsencrypt.org/directory",
"letsencrypt-staging":
"https://acme-staging-v02.api.letsencrypt.org/directory",
};
const text = (value: unknown) =>
typeof value === "string" ? value.trim() : "";
export async function readSettings(ctx: PluginContext): Promise<AcmeSettings> {
const all = await ctx.settings.getAll("admin");
const provider = text(all.provider);
const challengeType = text(all.challengeType);
return {
autoRenew: all.autoRenew === true,
domain: text(all.domain).toLowerCase(),
email: text(all.email),
provider:
provider === "letsencrypt-staging" || provider === "custom"
? provider
: "letsencrypt",
directoryUrl: text(all.directoryUrl),
challengeType:
challengeType === "dns-cloudflare" ? challengeType : "http-01",
cloudflareToken: text(all.cloudflareToken),
};
}
export function directoryUrlFor(settings: AcmeSettings): string {
return settings.provider === "custom"
? settings.directoryUrl
: DIRECTORIES[settings.provider];
}
/** The reason issuing cannot start, or null when everything is set. */
export function missingSetting(settings: AcmeSettings): string | null {
if (!settings.domain) return "domain";
if (!settings.email) return "email";
if (
settings.provider === "custom" &&
!/^https:\/\//i.test(settings.directoryUrl)
) {
return "directoryUrl";
}
if (
settings.challengeType === "dns-cloudflare" &&
!settings.cloudflareToken
) {
return "cloudflareToken";
}
return null;
}
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
usePluginApi,
useTranslation,
type SettingsComponentProps,
} from "@termix/plugin-sdk/frontend";
import { Button } from "@termix/plugin-sdk/ui";
export interface AcmeStatus {
tls: {
enabled: boolean;
certificate: {
issuer: string;
names: string[];
notAfter: string;
selfSigned: boolean;
} | null;
};
state: {
lastAttemptAt: string | null;
lastIssuedAt: string | null;
lastError: string | null;
};
autoRenew: boolean;
missing: string | null;
}
function errorMessage(error: unknown): string {
const data = (error as { response?: { data?: { error?: string } } })?.response
?.data;
if (data?.error) return data.error;
return error instanceof Error ? error.message : String(error);
}
export function AcmeStatusSetting({ running }: SettingsComponentProps) {
const { t } = useTranslation();
const api = usePluginApi();
const [status, setStatus] = useState<AcmeStatus | null>(null);
const [requesting, setRequesting] = useState(false);
const load = useCallback(async () => {
try {
const { data } = await api.get<AcmeStatus>("/status");
setStatus(data);
} catch {
setStatus(null);
}
}, [api]);
useEffect(() => {
if (running) void load();
}, [running, load]);
async function request() {
setRequesting(true);
try {
const { data } = await api.post<{ reloadMessage?: string }>("/request");
toast.success(t("status.issued"));
if (data.reloadMessage) toast.info(data.reloadMessage);
} catch (error) {
toast.error(errorMessage(error));
} finally {
setRequesting(false);
await load();
}
}
const cert = status?.tls.certificate;
return (
<div className="flex flex-col gap-2 border border-border bg-background/50 p-2">
<span className="text-xs font-medium">{t("status.title")}</span>
{cert ? (
<>
<span className="text-[10px] text-muted-foreground">
{t("status.names", { names: cert.names.join(", ") || "-" })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("status.issuer", { issuer: cert.issuer })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("status.expires", {
date: new Date(cert.notAfter).toLocaleDateString(),
})}
</span>
</>
) : (
<span className="text-[10px] text-muted-foreground">
{t("status.none")}
</span>
)}
{status?.state.lastIssuedAt && (
<span className="text-[10px] text-muted-foreground">
{t("status.lastIssued", {
date: new Date(status.state.lastIssuedAt).toLocaleString(),
})}
</span>
)}
{status?.state.lastError && (
<span className="text-[10px] text-destructive">
{t("status.lastError", { error: status.state.lastError })}
</span>
)}
{status?.missing && (
<span className="text-[10px] text-muted-foreground">
{t("status.missing")}
</span>
)}
<Button
type="button"
variant="outline"
className="rounded-none self-start"
disabled={!running || requesting || !!status?.missing}
onClick={request}
>
{requesting ? t("status.requesting") : t("status.request")}
</Button>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { AcmeStatusSetting } from "./AcmeStatusSetting";
export function activate(app: TermixApp): void {
app.registerSettingsComponent("status", AcmeStatusSetting);
}
export function deactivate(): void {
// Registrations through app are disposed by core.
}
@@ -0,0 +1,252 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express, { type Router } from "express";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type {
PluginCapabilityError as CapabilityError,
PluginTlsCertificateInfo,
PluginTlsStatus,
} from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activateWith } from "../../src/backend/index.js";
import { CHECK_INTERVAL_MS, RETRY_AFTER_MS } from "../../src/backend/runner.js";
import { renewalReason } from "../../src/backend/renewal.js";
const manifest = manifestJson as unknown as PluginManifest;
const DAY = 86_400_000;
const CONFIGURED = {
autoRenew: true,
domain: "termix.example.com",
email: "admin@example.com",
};
function cert(
overrides: Partial<PluginTlsCertificateInfo> = {},
): PluginTlsCertificateInfo {
return {
subject: "CN=termix.example.com",
issuer: "CN=R11, O=Let's Encrypt",
names: ["termix.example.com"],
notBefore: new Date(Date.now() - 10 * DAY).toISOString(),
notAfter: new Date(Date.now() + 60 * DAY).toISOString(),
selfSigned: false,
fingerprint: "AA",
...overrides,
};
}
const issue = vi.fn(async () => ({
certificate: "-----BEGIN CERTIFICATE-----new",
privateKey: "-----BEGIN PRIVATE KEY-----new",
}));
let servers: http.Server[] = [];
afterEach(async () => {
issue.mockClear();
await Promise.all(
servers.map(
(s) => new Promise<void>((resolve) => s.close(() => resolve())),
),
);
servers = [];
});
async function setup(
options: {
settings?: Record<string, unknown>;
certificate?: PluginTlsCertificateInfo | null;
permissions?: string[];
capabilities?: string[];
validateTls?: () => PluginTlsCertificateInfo;
} = {},
) {
let router: Router | null = null;
const tlsStatus: Omit<PluginTlsStatus, "renewal"> = {
enabled: true,
certificate: options.certificate === undefined ? null : options.certificate,
};
const mock: MockPluginContext = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: options.capabilities ?? manifest.capabilities,
router: () => (router = express.Router()),
settings: options.settings,
permissions: options.permissions ?? ["manage"],
tlsStatus,
validateTls: options.validateTls,
});
const runner = await activateWith(mock.ctx, issue);
const app = express();
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
const call = async (method: string, path: string) => {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
});
return { status: response.status, body: await response.json() };
};
return { mock, runner, call };
}
describe("renewalReason", () => {
const status = (certificate: PluginTlsCertificateInfo | null) => ({
enabled: true,
certificate,
renewal: null,
});
it("renews when missing, self-signed, for another domain or expiring", () => {
expect(renewalReason(status(null), "termix.example.com")).toBe("missing");
expect(
renewalReason(status(cert({ selfSigned: true })), "termix.example.com"),
).toBe("self-signed");
expect(renewalReason(status(cert()), "other.example.com")).toBe("domain");
expect(
renewalReason(
status(
cert({ notAfter: new Date(Date.now() + 10 * DAY).toISOString() }),
),
"termix.example.com",
),
).toBe("expiring");
expect(renewalReason(status(cert()), "TERMIX.example.com")).toBeNull();
});
});
describe("scheduled renewal", () => {
it("checks on a 12 hour timer and renews an expiring certificate", async () => {
const { mock } = await setup({
settings: CONFIGURED,
certificate: cert({
notAfter: new Date(Date.now() + 5 * DAY).toISOString(),
}),
});
const job = mock.scheduled.find((entry) => entry.kind === "every");
expect(job?.ms).toBe(CHECK_INTERVAL_MS);
await mock.runScheduled();
expect(issue).toHaveBeenCalledOnce();
expect(mock.tls.writes).toEqual([
{
certificatePem: "-----BEGIN CERTIFICATE-----new",
privateKeyPem: "-----BEGIN PRIVATE KEY-----new",
},
]);
const write = mock.tls.calls.indexOf("writeTlsCertificate");
expect(mock.tls.calls.indexOf("reloadTls")).toBeGreaterThan(write);
expect(mock.tls.reloads).toBe(1);
});
it("leaves a valid certificate alone", async () => {
const { mock } = await setup({ settings: CONFIGURED, certificate: cert() });
await mock.runScheduled();
expect(issue).not.toHaveBeenCalled();
expect(mock.tls.writes).toEqual([]);
});
it("does nothing and registers no renewer while renewal is off", async () => {
const { mock } = await setup({
settings: { ...CONFIGURED, autoRenew: false },
});
await mock.runScheduled();
expect(issue).not.toHaveBeenCalled();
expect(mock.tls.renewers).toBe(0);
});
it("registers as the renewer when configured and drops it when turned off", async () => {
const { mock } = await setup({ settings: CONFIGURED, certificate: cert() });
expect(mock.tls.renewers).toBe(1);
expect((await mock.ctx.system.tlsStatus()).renewal?.pluginId).toBe(
"acme-ssl",
);
await mock.ctx.settings.set("autoRenew", false);
await vi.waitFor(() => expect(mock.tls.renewers).toBe(0));
});
it("waits before retrying after a failed attempt", async () => {
const { mock, runner } = await setup({ settings: CONFIGURED });
issue.mockRejectedValueOnce(new Error("rate limited"));
expect(await runner.check()).toBe(false);
expect((await runner.state()).lastError).toBe("rate limited");
expect(await runner.check()).toBe(false);
expect(issue).toHaveBeenCalledOnce();
expect(
await runner.check(new Date(Date.now() + RETRY_AFTER_MS + 1000)),
).toBe(true);
expect(issue).toHaveBeenCalledTimes(2);
expect(mock.tls.reloads).toBe(1);
});
it("does not reload when core rejects the certificate", async () => {
const { mock, runner } = await setup({
settings: CONFIGURED,
validateTls: () => {
throw new Error("The certificate and private key do not match");
},
});
await expect(runner.issue()).rejects.toThrow(/do not match/);
expect(mock.tls.reloads).toBe(0);
expect((await runner.state()).lastError).toMatch(/do not match/);
});
});
describe("routes", () => {
it("reports status and issues on request", async () => {
const { call, mock } = await setup({ settings: CONFIGURED });
const status = await call("GET", "/status");
expect(status.status).toBe(200);
expect(status.body.missing).toBeNull();
expect(status.body.tls.certificate).toBeNull();
const issued = await call("POST", "/request");
expect(issued.status).toBe(200);
expect(issued.body.success).toBe(true);
expect(issued.body.tls.certificate).not.toBeNull();
expect(mock.tls.reloads).toBe(1);
});
it("answers 400 when a setting is missing", async () => {
const { call } = await setup({ settings: { domain: "a.example" } });
const result = await call("POST", "/request");
expect(result.status).toBe(400);
expect(result.body.missing).toBe("email");
expect(issue).not.toHaveBeenCalled();
});
it("refuses a user without acme-ssl.manage on every route", async () => {
const { call } = await setup({ settings: CONFIGURED, permissions: [] });
expect((await call("GET", "/status")).status).toBe(403);
expect((await call("POST", "/request")).status).toBe(403);
expect(issue).not.toHaveBeenCalled();
});
});
describe("capabilities", () => {
it("fails closed without system:tls", async () => {
await expect(
setup({
settings: CONFIGURED,
capabilities: manifest.capabilities.filter(
(capability) => capability !== "system:tls",
),
}),
).rejects.toMatchObject({
capability: "system:tls",
} satisfies Partial<CapabilityError>);
});
});
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginFetchInit } from "@termix/plugin-sdk/backend";
import { createFetchAdapter } from "../../src/backend/acme.js";
import { createTxtRecord } from "../../src/backend/cloudflare.js";
describe("createFetchAdapter", () => {
it("sends acme-client requests through ctx.fetch", async () => {
const fetch = vi.fn(
async (_url: string, _init?: PluginFetchInit) =>
new Response('{"ok":true}', {
status: 201,
headers: { "replay-nonce": "n1", location: "https://ca/acct/1" },
}),
);
const adapter = createFetchAdapter(fetch, ["ca.internal"]);
const result = await adapter({
url: "https://ca.internal/new-acct",
method: "post",
headers: { "Content-Type": "application/jose+json" },
data: '{"payload":"x"}',
validateStatus: null,
});
expect(fetch).toHaveBeenCalledWith(
"https://ca.internal/new-acct",
expect.objectContaining({
method: "POST",
body: '{"payload":"x"}',
allowPrivateHosts: ["ca.internal"],
headers: { "Content-Type": "application/jose+json" },
}),
);
expect(result.status).toBe(201);
expect(result.data).toBe('{"ok":true}');
expect(result.headers["replay-nonce"]).toBe("n1");
});
it("rejects with the response attached when validateStatus says no", async () => {
const adapter = createFetchAdapter(
async () => new Response("slow down", { status: 429 }),
);
await expect(
adapter({ url: "https://ca/x", validateStatus: () => false }),
).rejects.toMatchObject({
code: "ERR_BAD_REQUEST",
response: { status: 429, data: "slow down" },
});
});
});
describe("createTxtRecord", () => {
it("finds the zone by parent domain, creates the record and deletes it", async () => {
const calls: Array<{ url: string; method: string; body?: string }> = [];
const fetch = vi.fn(async (url: string, init?: PluginFetchInit) => {
calls.push({ url, method: init?.method ?? "GET", body: init?.body });
if (url.includes("/zones?name=example.com")) {
return Response.json({
success: true,
result: [{ id: "zone-1", name: "example.com" }],
});
}
if (url.includes("/zones?name=")) {
return Response.json({ success: true, result: [] });
}
if (init?.method === "POST") {
return Response.json({ success: true, result: { id: "rec-1" } });
}
return Response.json({ success: true, result: {} });
});
const remove = await createTxtRecord(
fetch,
"token",
"_acme-challenge.termix.example.com",
"digest",
);
const post = calls.find((call) => call.method === "POST")!;
expect(post.url).toContain("/zones/zone-1/dns_records");
expect(JSON.parse(post.body!)).toMatchObject({
type: "TXT",
name: "_acme-challenge.termix.example.com",
content: "digest",
});
await remove();
expect(calls.at(-1)).toMatchObject({
method: "DELETE",
url: expect.stringContaining("/zones/zone-1/dns_records/rec-1"),
});
});
it("surfaces the Cloudflare error message", async () => {
const fetch = async () =>
Response.json(
{ success: false, errors: [{ message: "Invalid token" }] },
{ status: 403 },
);
await expect(
createTxtRecord(fetch, "bad", "_acme-challenge.a.example", "d"),
).rejects.toThrow(/Invalid token/);
});
});
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const api = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) =>
values ? `${key} ${Object.values(values).join(" ")}` : key,
}),
usePluginApi: () => api,
}));
const { AcmeStatusSetting } =
await import("../../src/frontend/AcmeStatusSetting");
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
api.get.mockReset();
});
describe("acme-ssl activate", () => {
it("registers the status settings component and removes it on deactivate", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.settingsComponents()).toEqual(["status"]);
await rendered.deactivate();
expect(rendered.registered.settingsComponents()).toEqual([]);
rendered = null;
});
});
describe("AcmeStatusSetting", () => {
it("shows the served certificate and the last error", async () => {
api.get.mockResolvedValue({
data: {
tls: {
enabled: true,
certificate: {
issuer: "CN=R11",
names: ["termix.example.com"],
notAfter: "2030-01-01T00:00:00.000Z",
selfSigned: false,
},
},
state: {
lastAttemptAt: null,
lastIssuedAt: null,
lastError: "rate limited",
},
autoRenew: true,
missing: null,
},
});
render(
<AcmeStatusSetting
pluginId="acme-ssl"
values={{}}
setValue={() => {}}
running
/>,
);
await waitFor(() =>
expect(screen.getByText("status.names termix.example.com")).toBeTruthy(),
);
expect(screen.getByText("status.lastError rate limited")).toBeTruthy();
expect(api.get).toHaveBeenCalledWith("/status");
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
@@ -14,12 +14,12 @@ const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
type ElectronWindow = Window & { electronAPI?: unknown };
type ElectronWindow = { electronAPI?: unknown };
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
delete (window as ElectronWindow).electronAPI;
delete (window as unknown as ElectronWindow).electronAPI;
});
const IDENTITY = {
@@ -95,7 +95,7 @@ describe(`${manifest.id} activate`, () => {
});
it("hides the rail item in a standalone desktop app", async () => {
(window as ElectronWindow).electronAPI = {
(window as unknown as ElectronWindow).electronAPI = {
isElectron: true,
invoke: vi.fn(async () => null),
onRemoteSyncStatusChanged: () => () => {},
@@ -111,7 +111,7 @@ describe(`${manifest.id} activate`, () => {
it("shows the rail item once the desktop app has a remote server", async () => {
let notify = () => {};
let serverUrl: string | null = null;
(window as ElectronWindow).electronAPI = {
(window as unknown as ElectronWindow).electronAPI = {
isElectron: true,
invoke: vi.fn(async () => (serverUrl ? { serverUrl } : null)),
onRemoteSyncStatusChanged: (callback: () => void) => {