mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
First release, bundled with Termix 2.9.0.
|
||||
|
||||
Gets the HTTPS certificate Termix serves from Let's Encrypt or another ACME provider and renews it before it expires. Supports the HTTP challenge and the Cloudflare DNS challenge.
|
||||
@@ -0,0 +1,41 @@
|
||||
# ACME Certificates
|
||||
|
||||
Gets and renews the Termix HTTPS certificate from Let's Encrypt or another ACME provider.
|
||||
|
||||
## Features
|
||||
|
||||
- Gets a certificate from Let's Encrypt, Let's Encrypt staging or any ACME directory URL.
|
||||
- Checks twice a day and renews when there is no certificate, the current one is self-signed, it does not cover the domain or it expires within 30 days.
|
||||
- Swaps in the new certificate without a restart.
|
||||
- Shows the current certificate and the last attempt, with a button to request one now.
|
||||
|
||||
## Setup
|
||||
|
||||
Pick a challenge type:
|
||||
|
||||
- HTTP: the provider fetches `http://<domain>/.well-known/acme-challenge/...`, so port 80 must reach Termix.
|
||||
- DNS (Cloudflare): needs a Cloudflare API token with Zone:DNS:Edit. Works when port 80 is closed.
|
||||
|
||||
If you turn this plugin off, Termix keeps serving the current certificate but nothing renews it. Admin Settings > HTTPS certificate warns you before it expires. Uploading your own certificate is still done there.
|
||||
|
||||
## Settings
|
||||
|
||||
### Admin
|
||||
|
||||
- Renew automatically: turn automatic renewal on or off.
|
||||
- Domain and Email: the domain to cover and the contact email for the provider.
|
||||
- Certificate authority: Let's Encrypt, Let's Encrypt staging or a custom ACME directory URL.
|
||||
- Challenge type: HTTP or DNS (Cloudflare).
|
||||
- Cloudflare API token: used by the DNS challenge. Stored encrypted.
|
||||
|
||||
## Permissions
|
||||
|
||||
- `acme-ssl.manage`: Manage ACME certificates. Only admins have it by default.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run build # build into dist/
|
||||
npm run test # run this plugin's tests
|
||||
npm run typecheck # type-check this plugin
|
||||
```
|
||||
@@ -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,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Bestuur ACME-sertifikate",
|
||||
"description": "Sien die sertifikaatstatus en versoek nou 'n nuwe sertifikaat."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Hernu outomaties",
|
||||
"description": "Kontroleer twee keer per dag en kry 'n nuwe sertifikaat wanneer daar geen is nie, dit nie die domein dek nie, of dit binne 30 dae verval."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domein",
|
||||
"description": "Die publieke domeinnaam vir die sertifikaat.",
|
||||
"placeholder": "termix.voorbeeld.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-pos",
|
||||
"description": "Kontak-e-posadres vir die ACME-rekening en vervalkennisgewings.",
|
||||
"placeholder": "admin@voorbeeld.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Sertifikaatowerheid",
|
||||
"description": "Waar om die sertifikaat te kry. Gebruik staging om te toets sonder om Let's Encrypt-tarieflimiete te bereik.",
|
||||
"letsencrypt": "Kom ons enkripteer",
|
||||
"letsencryptStaging": "Kom ons enkripteer (opvoering)",
|
||||
"custom": "Pasgemaakte ACME-gids"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "ACME-gids-URL",
|
||||
"description": "Slegs vir 'n persoonlike sertifikaatowerheid. Moet 'n https-URL wees."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Uitdagingsoort",
|
||||
"description": "Hoe om te bewys dat jy die domein besit.",
|
||||
"http": "HTTP - benodig poort 80 wat vanaf die internet bereikbaar is",
|
||||
"cloudflare": "DNS (Cloudflare) - benodig '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",
|
||||
"names": "Omslae {{names}}",
|
||||
"issuer": "Uitgereik deur {{issuer}}",
|
||||
"expires": "Vervaldatum {{date}}",
|
||||
"lastIssued": "Laas uitgereik {{date}}",
|
||||
"lastError": "Laaste poging het misluk: {{error}}",
|
||||
"missing": "Stel die domein, e-posadres en die velde wat jou uitdagingsoort benodig, en stoor dit dan voordat jy 'n sertifikaat aanvra.",
|
||||
"request": "Versoek nou 'n sertifikaat",
|
||||
"requesting": "Versoek sertifikaat...",
|
||||
"issued": "Sertifikaat uitgereik en geïnstalleer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "إدارة شهادات ACME",
|
||||
"description": "اطلع على حالة الشهادة واطلب شهادة جديدة الآن."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "يتم التجديد تلقائياً",
|
||||
"description": "تحقق مرتين في اليوم واحصل على شهادة جديدة عندما لا تكون هناك شهادة، أو عندما لا تغطي النطاق، أو عندما تنتهي صلاحيتها في غضون 30 يومًا."
|
||||
},
|
||||
"domain": {
|
||||
"label": "النطاق",
|
||||
"description": "اسم النطاق العام للشهادة.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "البريد الإلكتروني",
|
||||
"description": "البريد الإلكتروني للتواصل بخصوص حساب ACME وإشعارات انتهاء الصلاحية.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "المرجع المصدق (CA)",
|
||||
"description": "من أين أحصل على الشهادة؟ استخدم بيئة الاختبار لإجراء الاختبارات دون تجاوز حدود معدل Let's Encrypt.",
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"letsencryptStaging": "Let's Encrypt (مرحلة تجريبية)",
|
||||
"custom": "دليل ACME مخصص"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "رابط دليل ACME",
|
||||
"description": "مخصص فقط لهيئة إصدار شهادات مخصصة. يجب أن يكون عنوان URL من نوع https."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "نوع التحدي",
|
||||
"description": "كيفية إثبات ملكيتك للنطاق.",
|
||||
"http": "بروتوكول HTTP - يتطلب منفذ 80 يمكن الوصول إليه من الإنترنت",
|
||||
"cloudflare": "نظام أسماء النطاقات (Cloudflare) - يتطلب رمز API من Cloudflare"
|
||||
},
|
||||
"cloudflareToken": {
|
||||
"label": "رمز واجهة برمجة تطبيقات Cloudflare",
|
||||
"description": "رمز مقيد بصلاحية Zone:DNS:Edit للنطاق الخاص بك."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "حالة الشهادة",
|
||||
"none": "لا توجد شهادة",
|
||||
"names": "يغطي {{names}}",
|
||||
"issuer": "صادر عن {{issuer}}",
|
||||
"expires": "تنتهي في {{date}}",
|
||||
"lastIssued": "آخر إصدار {{date}}",
|
||||
"lastError": "فشلت المحاولة الأخيرة: {{error}}",
|
||||
"missing": "قم بتعيين النطاق والبريد الإلكتروني والحقول التي يحتاجها نوع التحدي الخاص بك، ثم احفظ التغييرات قبل طلب الشهادة.",
|
||||
"request": "اطلب الشهادة الآن",
|
||||
"requesting": "جارٍ طلب الشهادة...",
|
||||
"issued": "تم إصدار الشهادة وتركيبها"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Управление на ACME сертификати",
|
||||
"description": "Вижте състоянието на сертификата и заявете нов сертификат сега."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Подновяване автоматично",
|
||||
"description": "Проверявайте два пъти на ден и получавайте нов сертификат, когато няма такъв, не покрива домейна или изтича в рамките на 30 дни."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Домейн",
|
||||
"description": "Публичното домейн име за сертификата.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "Имейл",
|
||||
"description": "Контактен имейл за ACME акаунта и известия за изтичане.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Сертифициращ орган",
|
||||
"description": "Къде да взема сертификата. Използвайте етапно тестване, за да не се достигат ограниченията на скоростта на Let's Encrypt.",
|
||||
"letsencrypt": "Нека криптираме",
|
||||
"letsencryptStaging": "Нека криптираме (подготовка)",
|
||||
"custom": "Персонализирана ACME директория"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "URL адрес на директорията на ACME",
|
||||
"description": "Само за персонализиран сертифициращ орган. Трябва да е https URL адрес."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Тип предизвикателство",
|
||||
"description": "Как да докажете, че притежавате домейна.",
|
||||
"http": "HTTP - изисква порт 80, достъпен от интернет",
|
||||
"cloudflare": "DNS (Cloudflare) - изисква Cloudflare API токен"
|
||||
},
|
||||
"cloudflareToken": {
|
||||
"label": "Cloudflare API токен",
|
||||
"description": "Ограничен токен с разрешение Zone:DNS:Edit за вашия домейн."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Състояние на сертификата",
|
||||
"none": "Няма сертификат",
|
||||
"names": "Обхваща {{names}}",
|
||||
"issuer": "Издадено от {{issuer}}",
|
||||
"expires": "Изтича {{date}}",
|
||||
"lastIssued": "Последно издадено {{date}}",
|
||||
"lastError": "Последният опит не беше успешен: {{error}}",
|
||||
"missing": "Задайте домейна, имейла и полетата, необходими за вашия тип предизвикателство, след което ги запазете, преди да заявите сертификат.",
|
||||
"request": "Заявете сертификат сега",
|
||||
"requesting": "Заявка за сертификат...",
|
||||
"issued": "Сертификат издаден и инсталиран"
|
||||
}
|
||||
}
|
||||
@@ -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,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestionar certificats ACME",
|
||||
"description": "Consulta l'estat del certificat i sol·licita'n un de nou ara."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Renovar automàticament",
|
||||
"description": "Revisa dues vegades al dia i aconsegueix un certificat nou quan no n'hi ha cap, no cobreix el domini o caduca en 30 dies."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domini",
|
||||
"description": "El nom de domini públic del certificat.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "Correu electrònic",
|
||||
"description": "Correu electrònic de contacte per al compte ACME i avisos de caducitat.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Autoritat de certificació",
|
||||
"description": "On s'obté el certificat. Utilitzeu la configuració estadística per fer proves sense arribar als límits de velocitat de Let's Encrypt.",
|
||||
"letsencrypt": "Xifrem",
|
||||
"letsencryptStaging": "Xifrem (posada en escena)",
|
||||
"custom": "Directori ACME personalitzat"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "URL del directori ACME",
|
||||
"description": "Només per a una autoritat de certificació personalitzada. Ha de ser una URL https."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Tipus de repte",
|
||||
"description": "Com demostrar que ets el propietari del domini.",
|
||||
"http": "HTTP: necessita que el port 80 sigui accessible des d'Internet",
|
||||
"cloudflare": "DNS (Cloudflare): necessita 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",
|
||||
"names": "Cobertes {{names}}",
|
||||
"issuer": "Publicat per {{issuer}}",
|
||||
"expires": "Caduca {{date}}",
|
||||
"lastIssued": "Última emissió {{date}}",
|
||||
"lastError": "Últim intent fallit: {{error}}",
|
||||
"missing": "Definiu el domini, el correu electrònic i els camps que necessita el vostre tipus de repte i, a continuació, deseu-ho abans de sol·licitar un certificat.",
|
||||
"request": "Sol·licita el certificat ara",
|
||||
"requesting": "Sol·licitant certificat...",
|
||||
"issued": "Certificat emès i instal·lat"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Správa certifikátů ACME",
|
||||
"description": "Zkontrolujte stav certifikátu a požádejte o nový certifikát."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Automaticky obnovit",
|
||||
"description": "Kontrolujte dvakrát denně a požádejte o nový certifikát, pokud žádný neexistuje, pokud se nevztahuje na danou doménu nebo pokud jeho platnost vyprší do 30 dnů."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Doména",
|
||||
"description": "Název veřejné domény pro certifikát.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"description": "Kontaktní e-mail pro účet ACME a oznámení o vypršení platnosti.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Certifikační autorita",
|
||||
"description": "Kde získat certifikát. Použijte staging k testování bez překročení limitů rychlosti Let's Encrypt.",
|
||||
"letsencrypt": "Pojďme šifrovat",
|
||||
"letsencryptStaging": "Pojďme šifrovat (příprava)",
|
||||
"custom": "Vlastní adresář ACME"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "URL adresáře ACME",
|
||||
"description": "Pouze pro vlastní certifikační autoritu. Musí se jednat o URL adresu typu https."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Typ výzvy",
|
||||
"description": "Jak prokázat, že jste vlastníkem domény.",
|
||||
"http": "HTTP - vyžaduje dostupnost portu 80 z internetu",
|
||||
"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",
|
||||
"names": "Kryty {{names}}",
|
||||
"issuer": "Vydáno uživatelem {{issuer}}",
|
||||
"expires": "Platnost vyprší {{date}}",
|
||||
"lastIssued": "Naposledy vydáno {{date}}",
|
||||
"lastError": "Poslední pokus se nezdařil: {{error}}",
|
||||
"missing": "Před vyžádáním certifikátu nastavte doménu, e-mail a pole, která váš typ výzvy vyžaduje, a poté je uložte.",
|
||||
"request": "Vyžádejte si certifikát nyní",
|
||||
"requesting": "Žádost o certifikát...",
|
||||
"issued": "Certifikát vydán a nainstalován"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Administrer ACME-certifikater",
|
||||
"description": "Se certifikatstatussen, og anmod om et nyt certifikat nu."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Forny automatisk",
|
||||
"description": "Tjek to gange om dagen, og få et nyt certifikat, hvis der ikke er noget, det ikke dækker domænet, eller det udløber inden for 30 dage."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domæne",
|
||||
"description": "Det offentlige domænenavn for certifikatet.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"description": "Kontakt-e-mailadresse for ACME-kontoen og udløbsmeddelelser.",
|
||||
"placeholder": "admin@eksempel.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Certifikatmyndighed",
|
||||
"description": "Hvor kan man få certifikatet? Brug staging til at teste uden at nå Let's Encrypt-hastighedsgrænserne.",
|
||||
"letsencrypt": "Lad os kryptere",
|
||||
"letsencryptStaging": "Lad os kryptere (staging)",
|
||||
"custom": "Brugerdefineret ACME-mappe"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "ACME-katalogets URL",
|
||||
"description": "Kun for en brugerdefineret certifikatmyndighed. Skal være en https-URL."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Udfordringstype",
|
||||
"description": "Sådan beviser du, at du ejer domænet.",
|
||||
"http": "HTTP - kræver port 80, der kan nås fra internettet",
|
||||
"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",
|
||||
"names": "Dækker {{names}}",
|
||||
"issuer": "Udstedt af {{issuer}}",
|
||||
"expires": "Udløber {{date}}",
|
||||
"lastIssued": "Sidst udstedt {{date}}",
|
||||
"lastError": "Sidste forsøg mislykkedes: {{error}}",
|
||||
"missing": "Angiv domæne, e-mail og de felter, som din udfordringstype kræver, og gem derefter, før du anmoder om et certifikat.",
|
||||
"request": "Anmod om certifikat nu",
|
||||
"requesting": "Anmoder om certifikat...",
|
||||
"issued": "Certifikat udstedt og installeret"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "ACME-Zertifikate verwalten",
|
||||
"description": "Prüfen Sie den Zertifikatsstatus und fordern Sie jetzt ein neues Zertifikat an."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Automatische Verlängerung",
|
||||
"description": "Prüfen Sie zweimal täglich, ob ein Zertifikat vorhanden ist, ob das aktuelle Zertifikat die Domain nicht abdeckt oder ob es innerhalb von 30 Tagen abläuft."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domain",
|
||||
"description": "Der öffentliche Domainname für das Zertifikat.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-Mail",
|
||||
"description": "Kontakt-E-Mail-Adresse für das ACME-Konto und Benachrichtigungen zum Ablaufdatum.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Zertifizierungsstelle",
|
||||
"description": "Wo Sie das Zertifikat erhalten. Nutzen Sie die Staging-Umgebung, um zu testen, ohne die Ratenbegrenzungen von Let's Encrypt zu erreichen.",
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"letsencryptStaging": "Let's Encrypt (Staging)",
|
||||
"custom": "Benutzerdefiniertes ACME-Verzeichnis"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "ACME-Verzeichnis-URL",
|
||||
"description": "Nur für eine benutzerdefinierte Zertifizierungsstelle. Muss eine HTTPS-URL sein."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Herausforderungstyp",
|
||||
"description": "Wie Sie nachweisen, dass Sie die Domain besitzen.",
|
||||
"http": "HTTP – benötigt Port 80, der aus dem Internet erreichbar sein muss.",
|
||||
"cloudflare": "DNS (Cloudflare) – benötigt ein 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",
|
||||
"names": "Cover {{names}}",
|
||||
"issuer": "Herausgegeben von {{issuer}}",
|
||||
"expires": "Läuft ab {{date}}",
|
||||
"lastIssued": "Zuletzt ausgestellt {{date}}",
|
||||
"lastError": "Letzter Versuch fehlgeschlagen: {{error}}",
|
||||
"missing": "Legen Sie die Domain, die E-Mail-Adresse und die Felder fest, die Ihr Herausforderungstyp benötigt, und speichern Sie die Einstellungen, bevor Sie ein Zertifikat anfordern.",
|
||||
"request": "Zertifikat jetzt anfordern",
|
||||
"requesting": "Zertifikat wird angefordert...",
|
||||
"issued": "Zertifikat ausgestellt und installiert"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Διαχείριση πιστοποιητικών ACME",
|
||||
"description": "Δείτε την κατάσταση του πιστοποιητικού και ζητήστε ένα νέο πιστοποιητικό τώρα."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Αυτόματη ανανέωση",
|
||||
"description": "Ελέγχετε δύο φορές την ημέρα και λαμβάνετε ένα νέο πιστοποιητικό όταν δεν υπάρχει, δεν καλύπτει τον τομέα ή λήγει εντός 30 ημερών."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Πεδίο ορισμού",
|
||||
"description": "Το όνομα δημόσιου τομέα για το πιστοποιητικό.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"description": "Ηλεκτρονική διεύθυνση ηλεκτρονικού ταχυδρομείου επικοινωνίας για τον λογαριασμό ACME και τις ειδοποιήσεις λήξης.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Αρχή έκδοσης πιστοποιητικών",
|
||||
"description": "Πού να λάβετε το πιστοποιητικό. Χρησιμοποιήστε τη σταδιακή επεξεργασία για να δοκιμάσετε χωρίς να υπερβείτε τα όρια ρυθμού του Let's Encrypt.",
|
||||
"letsencrypt": "Ας κρυπτογραφήσουμε",
|
||||
"letsencryptStaging": "Ας κρυπτογραφήσουμε (σταδιοποίηση)",
|
||||
"custom": "Προσαρμοσμένος κατάλογος ACME"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "URL καταλόγου ACME",
|
||||
"description": "Μόνο για μια προσαρμοσμένη αρχή έκδοσης πιστοποιητικών. Πρέπει να είναι μια διεύθυνση URL https."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Τύπος πρόκλησης",
|
||||
"description": "Πώς να αποδείξετε ότι είστε ο κάτοχος του domain.",
|
||||
"http": "HTTP - χρειάζεται θύρα 80 προσβάσιμη από το διαδίκτυο",
|
||||
"cloudflare": "DNS (Cloudflare) - χρειάζεται ένα διακριτικό API Cloudflare"
|
||||
},
|
||||
"cloudflareToken": {
|
||||
"label": "Διακριτικό API Cloudflare",
|
||||
"description": "Διακριτικό πεδίου εφαρμογής με δικαίωμα Zone:DNS:Edit για τον τομέα σας."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Κατάσταση πιστοποιητικού",
|
||||
"none": "Δεν υπάρχει πιστοποιητικό",
|
||||
"names": "Καλύμματα {{names}}",
|
||||
"issuer": "Εκδόθηκε από {{issuer}}",
|
||||
"expires": "Λήγει {{date}}",
|
||||
"lastIssued": "Τελευταία έκδοση {{date}}",
|
||||
"lastError": "Η τελευταία προσπάθεια απέτυχε: {{error}}",
|
||||
"missing": "Ορίστε τον τομέα, το email και τα πεδία που χρειάζεται ο τύπος της πρόκλησής σας και, στη συνέχεια, αποθηκεύστε το, πριν ζητήσετε πιστοποιητικό.",
|
||||
"request": "Ζητήστε πιστοποιητικό τώρα",
|
||||
"requesting": "Αίτημα πιστοποιητικού...",
|
||||
"issued": "Έκδοση και εγκατάσταση πιστοποιητικού"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestionar certificados ACME",
|
||||
"description": "Consulte el estado del certificado y solicite uno nuevo ahora."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Renovación automática",
|
||||
"description": "Compruebe dos veces al día y obtenga un nuevo certificado si no tiene ninguno, si no cubre el dominio o si caduca en los próximos 30 días."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Dominio",
|
||||
"description": "El nombre de dominio público para el certificado.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "Correo electrónico",
|
||||
"description": "Correo electrónico de contacto para la cuenta ACME y avisos de vencimiento.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Autoridad de certificación",
|
||||
"description": "Dónde obtener el certificado. Utilice el entorno de pruebas para realizar pruebas sin alcanzar los límites de uso de Let's Encrypt.",
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"letsencryptStaging": "Let's Encrypt (en fase de pruebas)",
|
||||
"custom": "Directorio ACME personalizado"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "URL del directorio ACME",
|
||||
"description": "Solo para una autoridad de certificación personalizada. Debe ser una URL HTTPS."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Tipo de desafío",
|
||||
"description": "Cómo demostrar que usted es el propietario del dominio.",
|
||||
"http": "HTTP: necesita que el puerto 80 sea accesible desde Internet.",
|
||||
"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",
|
||||
"names": "Portadas {{names}}",
|
||||
"issuer": "Emitido por {{issuer}}",
|
||||
"expires": "Caduca {{date}}",
|
||||
"lastIssued": "Última emisión {{date}}",
|
||||
"lastError": "Último intento fallido: {{error}}",
|
||||
"missing": "Configure el dominio, el correo electrónico y los campos que requiere su tipo de desafío, luego guarde los cambios y, a continuación, solicite un certificado.",
|
||||
"request": "Solicita tu certificado ahora",
|
||||
"requesting": "Solicitando certificado...",
|
||||
"issued": "Certificado emitido e instalado"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Hallinnoi ACME-sertifikaatteja",
|
||||
"description": "Tarkista varmenteen tila ja pyydä uusi varmenne nyt."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Uudista automaattisesti",
|
||||
"description": "Tarkista kahdesti päivässä ja hanki uusi varmenne, jos sitä ei ole, se ei kata verkkotunnusta tai se vanhenee 30 päivän kuluessa."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Verkkotunnus",
|
||||
"description": "Varmenteen julkinen verkkotunnus.",
|
||||
"placeholder": "termix.esimerkki.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "Sähköposti",
|
||||
"description": "ACME-tilin yhteystiedot ja vanhenemisilmoitukset.",
|
||||
"placeholder": "admin@esimerkki.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Varmentaja",
|
||||
"description": "Mistä varmenteen saa? Käytä testausympäristöä Let's Encrypt -nopeusrajoitusten ylittämättä.",
|
||||
"letsencrypt": "Salataanpa",
|
||||
"letsencryptStaging": "Let's Encrypt (vaiheistus)",
|
||||
"custom": "Mukautettu ACME-hakemisto"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "ACME-hakemiston URL-osoite",
|
||||
"description": "Vain mukautetulle varmentajalle. URL-osoitteen on oltava https."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Haasteen tyyppi",
|
||||
"description": "Kuinka todistaa omistavasi verkkotunnuksen.",
|
||||
"http": "HTTP - vaatii portin 80, johon internetistä voi päästä käsiksi",
|
||||
"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",
|
||||
"names": "Kannet {{names}}",
|
||||
"issuer": "Myöntäjä {{issuer}}",
|
||||
"expires": "Vanhenee {{date}}",
|
||||
"lastIssued": "Viimeksi myönnetty {{date}}",
|
||||
"lastError": "Viimeisin yritys epäonnistui: {{error}}",
|
||||
"missing": "Aseta verkkotunnus, sähköpostiosoite ja haastetyyppisi tarvitsemat kentät ja tallenna ne ennen varmenteen pyytämistä.",
|
||||
"request": "Pyydä todistus nyt",
|
||||
"requesting": "Pyydetään varmennetta...",
|
||||
"issued": "Sertifikaatti myönnetty ja asennettu"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gérer les certificats ACME",
|
||||
"description": "Consultez l'état du certificat et demandez un nouveau certificat dès maintenant."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Renouvellement automatique",
|
||||
"description": "Vérifiez deux fois par jour et obtenez un nouveau certificat s'il n'y en a pas, s'il ne couvre pas le domaine ou s'il expire dans les 30 jours."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domaine",
|
||||
"description": "Le nom de domaine public pour le certificat.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"description": "Adresse électronique de contact pour le compte ACME et les avis d'expiration.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Autorité de certification",
|
||||
"description": "Où obtenir le certificat ? Utilisez un environnement de test pour effectuer des tests sans atteindre les limites de requêtes de Let's Encrypt.",
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"letsencryptStaging": "Let's Encrypt (phase de test)",
|
||||
"custom": "Répertoire ACME personnalisé"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "URL du répertoire ACME",
|
||||
"description": "Uniquement pour une autorité de certification personnalisée. Doit être une URL HTTPS."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Type de défi",
|
||||
"description": "Comment prouver que vous êtes propriétaire du domaine.",
|
||||
"http": "HTTP – nécessite que le port 80 soit accessible depuis Internet",
|
||||
"cloudflare": "DNS (Cloudflare) - nécessite un jeton d'API Cloudflare"
|
||||
},
|
||||
"cloudflareToken": {
|
||||
"label": "Jeton d'API Cloudflare",
|
||||
"description": "Jeton limité avec l'autorisation Zone:DNS:Edit pour votre domaine."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Statut du certificat",
|
||||
"none": "Aucun certificat",
|
||||
"names": "Couvertures {{names}}",
|
||||
"issuer": "Émis par {{issuer}}",
|
||||
"expires": "Expire le {{date}}",
|
||||
"lastIssued": "Dernière émission le {{date}}",
|
||||
"lastError": "Dernière tentative échouée : {{error}}",
|
||||
"missing": "Configurez le domaine, l'adresse e-mail et les champs requis par votre type de défi, puis enregistrez, avant de demander un certificat.",
|
||||
"request": "Demandez votre certificat dès maintenant",
|
||||
"requesting": "Demande de certificat en cours...",
|
||||
"issued": "Certificat délivré et installé"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "ניהול אישורי ACME",
|
||||
"description": "ראה את סטטוס האישור ובקש אישור חדש כעת."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "חידוש אוטומטי",
|
||||
"description": "בדקו פעמיים ביום וקבלו תעודה חדשה אם אין, אם היא לא מכסה את הדומיין, או אם היא פגה תוך 30 יום."
|
||||
},
|
||||
"domain": {
|
||||
"label": "תְחוּם",
|
||||
"description": "שם התחום הציבורי עבור התעודה.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "אֶלֶקטרוֹנִי",
|
||||
"description": "כתובת דוא\"ל ליצירת קשר עבור חשבון ACME והודעות תפוגה.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "רשות אישורים",
|
||||
"description": "היכן ניתן להשיג את האישור. השתמשו ב-staging כדי לבדוק מבלי להגיע למגבלות התעריף של Let's Encrypt.",
|
||||
"letsencrypt": "בואו נצפין",
|
||||
"letsencryptStaging": "בואו נצפין (שלבי)",
|
||||
"custom": "ספריית ACME מותאמת אישית"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "כתובת אתר של ספריית ACME",
|
||||
"description": "רק עבור רשות אישורים מותאמת אישית. חייבת להיות כתובת URL מסוג https."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "סוג האתגר",
|
||||
"description": "איך להוכיח שאתה הבעלים של הדומיין.",
|
||||
"http": "HTTP - דורש גישה לפורט 80 מהאינטרנט",
|
||||
"cloudflare": "DNS (Cloudflare) - דורש אסימון API של Cloudflare"
|
||||
},
|
||||
"cloudflareToken": {
|
||||
"label": "אסימון API של Cloudflare",
|
||||
"description": "אסימון בטווח עם הרשאת עריכה של אזור:DNS: עבור הדומיין שלך."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "סטטוס התעודה",
|
||||
"none": "אין תעודה",
|
||||
"names": "כריכות {{names}}",
|
||||
"issuer": "הונפק על ידי {{issuer}}",
|
||||
"expires": "פג תוקף {{date}}",
|
||||
"lastIssued": "הוצאה אחרונה {{date}}",
|
||||
"lastError": "ניסיון אחרון נכשל: {{error}}",
|
||||
"missing": "הגדר את הדומיין, האימייל והשדות הנדרשים עבור סוג האתגר שלך, ולאחר מכן שמור, לפני בקשת האישור.",
|
||||
"request": "בקשת תעודה עכשיו",
|
||||
"requesting": "מבקש תעודה...",
|
||||
"issued": "תעודה הונפקה והותקנה"
|
||||
}
|
||||
}
|
||||
@@ -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": "डोमेन",
|
||||
"description": "प्रमाणपत्र के लिए सार्वजनिक डोमेन नाम।",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "ईमेल",
|
||||
"description": "Contact email for the ACME account and expiry notices.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "प्रमाणपत्र प्राधिकरण",
|
||||
"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": "आपके डोमेन के लिए Zone:DNS:Edit अनुमति वाला स्कोप्ड टोकन।"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Certificate status",
|
||||
"none": "कोई प्रमाणपत्र नहीं",
|
||||
"names": "Covers {{names}}",
|
||||
"issuer": "Issued by {{issuer}}",
|
||||
"expires": "{{date}} को समाप्त होगा",
|
||||
"lastIssued": "अंतिम जारी: {{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": "प्रमाणपत्र अनुरोध किया जा रहा है...",
|
||||
"issued": "Certificate issued and installed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "ACME tanúsítványok kezelése",
|
||||
"description": "Tekintse meg a tanúsítvány állapotát, és igényeljen új tanúsítványt most."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Automatikus megújítás",
|
||||
"description": "Naponta kétszer ellenőrizd, és szerezz be új tanúsítványt, ha nincs még tanúsítvány, ha nem fedi le a domaint, vagy ha 30 napon belül lejár."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domain",
|
||||
"description": "A tanúsítvány nyilvános domainneve.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"description": "Az ACME fiókkal és a lejárati értesítésekkel kapcsolatos elérhetőségi e-mail cím.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Tanúsítványkibocsátó",
|
||||
"description": "Hol lehet beszerezni a tanúsítványt? Használjon előzetes tesztelést a Let's Encrypt sebességkorlátainak elérése nélkül.",
|
||||
"letsencrypt": "Titkosítsunk",
|
||||
"letsencryptStaging": "Titkosítsuk (előkészítés)",
|
||||
"custom": "Egyéni ACME címtár"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "ACME könyvtár URL-címe",
|
||||
"description": "Csak egyéni hitelesítésszolgáltató esetén. Https URL-címnek kell lennie."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Kihívás típusa",
|
||||
"description": "Hogyan bizonyíthatod, hogy te vagy a domain tulajdonosa?",
|
||||
"http": "HTTP – internetről elérhető 80-as port szükséges",
|
||||
"cloudflare": "DNS (Cloudflare) – Cloudflare API token szükséges"
|
||||
},
|
||||
"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",
|
||||
"names": "Borítók {{names}}",
|
||||
"issuer": "Kibocsátó: {{issuer}}",
|
||||
"expires": "Lejár {{date}}",
|
||||
"lastIssued": "Utolsó kiadás {{date}}",
|
||||
"lastError": "Utolsó kísérlet sikertelen: {{error}}",
|
||||
"missing": "Állítsa be a domaint, az e-mail címet és a kihívástípushoz szükséges mezőket, majd mentse el a beállításokat a tanúsítvány igénylése előtt.",
|
||||
"request": "Igényeljen tanúsítványt most",
|
||||
"requesting": "Tanúsítvány kérése...",
|
||||
"issued": "Tanúsítvány kiállítva és telepítve"
|
||||
}
|
||||
}
|
||||
@@ -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,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestire i certificati ACME",
|
||||
"description": "Verifica lo stato del certificato e richiedine uno nuovo ora."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Rinnovo automatico",
|
||||
"description": "Controlla due volte al giorno e richiedi un nuovo certificato se non ne trovi uno, se non copre il dominio o se scade entro 30 giorni."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Dominio",
|
||||
"description": "Il nome di dominio pubblico per il certificato.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"description": "Indirizzo email per contattare l'account ACME e per ricevere avvisi di scadenza.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Autorità di certificazione",
|
||||
"description": "Dove ottenere il certificato. Utilizza un ambiente di staging per effettuare dei test senza superare i limiti di frequenza di Let's Encrypt.",
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"letsencryptStaging": "Let's Encrypt (staging)",
|
||||
"custom": "Elenco personalizzato ACME"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "URL della directory ACME",
|
||||
"description": "Solo per un'autorità di certificazione personalizzata. Deve essere un URL HTTPS."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Tipo di sfida",
|
||||
"description": "Come dimostrare di essere il proprietario del dominio.",
|
||||
"http": "HTTP - richiede che la porta 80 sia raggiungibile da Internet",
|
||||
"cloudflare": "DNS (Cloudflare) - richiede un token API di Cloudflare"
|
||||
},
|
||||
"cloudflareToken": {
|
||||
"label": "Token API di Cloudflare",
|
||||
"description": "Token con autorizzazione Zone:DNS:Edit per il tuo dominio."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Stato del certificato",
|
||||
"none": "Nessun certificato",
|
||||
"names": "Copertine {{names}}",
|
||||
"issuer": "Emesso da {{issuer}}",
|
||||
"expires": "Scade il {{date}}",
|
||||
"lastIssued": "Ultimo rilascio il {{date}}",
|
||||
"lastError": "Ultimo tentativo fallito: {{error}}",
|
||||
"missing": "Imposta il dominio, l'indirizzo email e i campi richiesti dal tuo tipo di sfida, quindi salva prima di richiedere un certificato.",
|
||||
"request": "Richiedi subito il certificato",
|
||||
"requesting": "Richiesta certificato in corso...",
|
||||
"issued": "Certificato rilasciato e installato"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "ACME証明書を管理する",
|
||||
"description": "証明書のステータスを確認し、新しい証明書を今すぐリクエストしてください。"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "自動更新",
|
||||
"description": "1日に2回確認し、証明書がない場合、ドメインをカバーしていない場合、または有効期限が30日以内に切れる場合は、新しい証明書を取得してください。"
|
||||
},
|
||||
"domain": {
|
||||
"label": "ドメイン",
|
||||
"description": "証明書の公開ドメイン名。",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "メールアドレス",
|
||||
"description": "ACMEアカウントおよび有効期限通知に関する連絡先メールアドレス。",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "認証局",
|
||||
"description": "証明書の入手方法。Let's Encryptのレート制限に抵触しないよう、ステージング環境を使用してテストしてください。",
|
||||
"letsencrypt": "レッツ・エンクリプト",
|
||||
"letsencryptStaging": "Let's Encrypt(ステージング環境)",
|
||||
"custom": "カスタムACMEディレクトリ"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "ACMEディレクトリURL",
|
||||
"description": "カスタム認証局のみ対象です。URLはhttpsでなければなりません。"
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "チャレンジの種類",
|
||||
"description": "ドメインの所有権を証明する方法。",
|
||||
"http": "HTTP - インターネットからアクセス可能なポート80が必要です",
|
||||
"cloudflare": "DNS(Cloudflare) - Cloudflare APIトークンが必要です"
|
||||
},
|
||||
"cloudflareToken": {
|
||||
"label": "Cloudflare APIトークン",
|
||||
"description": "ドメインに対するZone:DNS:Edit権限を持つスコープ付きトークン。"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "証明書のステータス",
|
||||
"none": "証明書なし",
|
||||
"names": "カバー {{names}}",
|
||||
"issuer": "{{issuer}} によって発行されました",
|
||||
"expires": "有効期限 {{date}}",
|
||||
"lastIssued": "最終発行日 {{date}}",
|
||||
"lastError": "最後の試行が失敗しました: {{error}}",
|
||||
"missing": "証明書をリクエストする前に、ドメイン、メールアドレス、およびチャレンジタイプに必要なフィールドを設定して保存してください。",
|
||||
"request": "今すぐ証明書を申請する",
|
||||
"requesting": "証明書をリクエスト中...",
|
||||
"issued": "証明書が発行され、インストールされました。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "ACME 인증서를 관리하세요",
|
||||
"description": "인증서 상태를 확인하고 지금 바로 새 인증서를 신청하세요."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "자동으로 갱신됩니다",
|
||||
"description": "하루에 두 번 확인하고, 인증서가 없거나, 도메인을 포함하지 않거나, 30일 이내에 만료되는 경우 새 인증서를 발급받으세요."
|
||||
},
|
||||
"domain": {
|
||||
"label": "도메인",
|
||||
"description": "인증서에 사용할 공개 도메인 이름입니다.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "이메일",
|
||||
"description": "ACME 계정 및 만료 알림을 받을 수 있는 이메일 주소입니다.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "인증 기관",
|
||||
"description": "인증서는 어디서 발급받나요? Let's Encrypt의 사용량 제한에 걸리지 않고 테스트하려면 스테이징 환경을 사용하세요.",
|
||||
"letsencrypt": "렛츠인크립트",
|
||||
"letsencryptStaging": "렛츠인크립트(스테이징)",
|
||||
"custom": "사용자 지정 ACME 디렉토리"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "ACME 디렉토리 URL",
|
||||
"description": "사용자 지정 인증 기관에만 해당됩니다. URL은 HTTPS여야 합니다."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "도전 유형",
|
||||
"description": "도메인 소유권을 증명하는 방법.",
|
||||
"http": "HTTP - 인터넷에서 접근 가능한 80번 포트가 필요합니다.",
|
||||
"cloudflare": "DNS(클라우드플레어) - 클라우드플레어 API 토큰이 필요합니다."
|
||||
},
|
||||
"cloudflareToken": {
|
||||
"label": "클라우드플레어 API 토큰",
|
||||
"description": "도메인에 대한 Zone:DNS:Edit 권한이 있는 범위 지정 토큰입니다."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "인증서 상태",
|
||||
"none": "인증서 없음",
|
||||
"names": "{{names}}을 덮습니다.",
|
||||
"issuer": "{{issuer}}에서 발행함",
|
||||
"expires": "만료일: {{date}}",
|
||||
"lastIssued": "마지막 발급일: {{date}}",
|
||||
"lastError": "마지막 시도가 실패했습니다: {{error}}",
|
||||
"missing": "인증서를 요청하기 전에 도메인, 이메일 및 인증 유형에 필요한 필드를 설정하고 저장하세요.",
|
||||
"request": "지금 인증서를 요청하세요",
|
||||
"requesting": "인증서 요청 중...",
|
||||
"issued": "인증서가 발급 및 설치되었습니다."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Beheer ACME-certificaten",
|
||||
"description": "Bekijk de certificaatstatus en vraag nu een nieuw certificaat aan."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Automatisch verlengen",
|
||||
"description": "Controleer tweemaal per dag en vraag een nieuw certificaat aan als er geen certificaat is, het huidige certificaat het domein niet dekt of het huidige certificaat binnen 30 dagen verloopt."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domein",
|
||||
"description": "De publieke domeinnaam voor het certificaat.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"description": "Contact-e-mailadres voor het ACME-account en meldingen over vervaldatums.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Certificeringsinstantie",
|
||||
"description": "Waar kan ik het certificaat verkrijgen? Gebruik een testomgeving om te testen zonder de limieten van Let's Encrypt te overschrijden.",
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"letsencryptStaging": "Let's Encrypt (testomgeving)",
|
||||
"custom": "Aangepaste ACME-directory"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "ACME-directory-URL",
|
||||
"description": "Alleen voor een aangepaste certificeringsinstantie. Moet een https-URL zijn."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Uitdagingstype",
|
||||
"description": "Hoe bewijs je dat je de domeinnaam bezit?",
|
||||
"http": "HTTP - vereist poort 80 die bereikbaar is vanaf internet.",
|
||||
"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",
|
||||
"names": "Omslagen {{names}}",
|
||||
"issuer": "Uitgegeven door {{issuer}}",
|
||||
"expires": "Verloopt {{date}}",
|
||||
"lastIssued": "Laatst uitgegeven {{date}}",
|
||||
"lastError": "Laatste poging mislukt: {{error}}",
|
||||
"missing": "Stel het domein, het e-mailadres en de velden in die uw uitdagingstype vereist, sla de wijzigingen op en vraag vervolgens een certificaat aan.",
|
||||
"request": "Vraag nu uw certificaat aan",
|
||||
"requesting": "Certificaat aanvragen...",
|
||||
"issued": "Certificaat afgegeven en geïnstalleerd"
|
||||
}
|
||||
}
|
||||
@@ -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": "E-post",
|
||||
"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,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": "Domena",
|
||||
"description": "Publiczna nazwa domeny certyfikatu.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"description": "Contact email for the ACME account and expiry notices.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Urząd certyfikacji (CA)",
|
||||
"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": "Token zakresowy z uprawnieniem Zone:DNS:Edit dla Twojej domeny."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Certificate status",
|
||||
"none": "Brak certyfikatu",
|
||||
"names": "Covers {{names}}",
|
||||
"issuer": "Issued by {{issuer}}",
|
||||
"expires": "Wygasa {{date}}",
|
||||
"lastIssued": "Ostatnio wystawiono {{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": "Żądanie certyfikatu...",
|
||||
"issued": "Certificate issued and installed"
|
||||
}
|
||||
}
|
||||
@@ -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,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": "Domínio",
|
||||
"description": "O nome de domínio público para o certificado.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"description": "Contact email for the ACME account and expiry notices.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Autoridade de certificação",
|
||||
"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": "Token com âmbito e permissão Zone:DNS:Edit para o seu domínio."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Certificate status",
|
||||
"none": "Sem certificado",
|
||||
"names": "Covers {{names}}",
|
||||
"issuer": "Issued by {{issuer}}",
|
||||
"expires": "Expira a {{date}}",
|
||||
"lastIssued": "Última emissão a {{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": "A solicitar certificado...",
|
||||
"issued": "Certificate issued and installed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Gestionați certificatele ACME",
|
||||
"description": "Vedeți starea certificatului și solicitați un certificat nou acum."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"autoRenew": {
|
||||
"label": "Reînnoire automată",
|
||||
"description": "Verificați de două ori pe zi și obțineți un certificat nou atunci când nu există, nu acoperă domeniul sau expiră în termen de 30 de zile."
|
||||
},
|
||||
"domain": {
|
||||
"label": "Domeniu",
|
||||
"description": "Numele de domeniu public pentru certificat.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-mail",
|
||||
"description": "Adresă de e-mail de contact pentru contul ACME și notificări de expirare.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Autoritatea de certificare",
|
||||
"description": "Unde se obține certificatul. Folosește staging-ul pentru a testa fără a atinge limitele de rată Let's Encrypt.",
|
||||
"letsencrypt": "Să criptăm",
|
||||
"letsencryptStaging": "Să criptăm (punct în scenă)",
|
||||
"custom": "Director ACME personalizat"
|
||||
},
|
||||
"directoryUrl": {
|
||||
"label": "URL-ul directorului ACME",
|
||||
"description": "Numai pentru o autoritate de certificare personalizată. Trebuie să fie o adresă URL https."
|
||||
},
|
||||
"challengeType": {
|
||||
"label": "Tipul de provocare",
|
||||
"description": "Cum să dovedești că deții domeniul.",
|
||||
"http": "HTTP - necesită ca portul 80 să fie accesibil de pe internet",
|
||||
"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",
|
||||
"names": "Acoperă {{names}}",
|
||||
"issuer": "Emis de {{issuer}}",
|
||||
"expires": "Expiră la {{date}}",
|
||||
"lastIssued": "Ultima emisiune {{date}}",
|
||||
"lastError": "Ultima încercare a eșuat: {{error}}",
|
||||
"missing": "Setați domeniul, adresa de e-mail și câmpurile necesare tipului dvs. de provocare, apoi salvați, înainte de a solicita un certificat.",
|
||||
"request": "Solicitați certificatul acum",
|
||||
"requesting": "Se solicită certificatul...",
|
||||
"issued": "Certificat emis și instalat"
|
||||
}
|
||||
}
|
||||
@@ -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": "Домен",
|
||||
"description": "Общедоступное доменное имя для сертификата.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"description": "Contact email for the ACME account and expiry notices.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Центр сертификации",
|
||||
"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": "Токен с ограниченной областью действия и разрешением Zone:DNS:Edit для вашего домена."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Certificate status",
|
||||
"none": "Сертификат отсутствует",
|
||||
"names": "Covers {{names}}",
|
||||
"issuer": "Issued by {{issuer}}",
|
||||
"expires": "Истекает {{date}}",
|
||||
"lastIssued": "Последний выпуск {{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": "Запрос сертификата...",
|
||||
"issued": "Certificate issued and installed"
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,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": "Alan Adı",
|
||||
"description": "Sertifika için genel alan adı.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "E-posta",
|
||||
"description": "Contact email for the ACME account and expiry notices.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Sertifika yetkilisi",
|
||||
"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": "Alan adınız için Zone:DNS:Edit iznine sahip kapsamlı belirteç."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Certificate status",
|
||||
"none": "Sertifika yok",
|
||||
"names": "Covers {{names}}",
|
||||
"issuer": "Issued by {{issuer}}",
|
||||
"expires": "Son kullanma: {{date}}",
|
||||
"lastIssued": "Son veriliş: {{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": "Sertifika isteniyor...",
|
||||
"issued": "Certificate issued and installed"
|
||||
}
|
||||
}
|
||||
@@ -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,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": "Tên miền",
|
||||
"description": "Tên miền công khai để cấp chứng chỉ.",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "Email",
|
||||
"description": "Contact email for the ACME account and expiry notices.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "Cơ quan chứng thực",
|
||||
"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": "Token có phạm vi với quyền Zone:DNS:Edit cho tên miền của bạn."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Certificate status",
|
||||
"none": "Không có chứng chỉ",
|
||||
"names": "Covers {{names}}",
|
||||
"issuer": "Issued by {{issuer}}",
|
||||
"expires": "Hết hạn {{date}}",
|
||||
"lastIssued": "Cấp lần cuối {{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": "Đang yêu cầu chứng chỉ...",
|
||||
"issued": "Certificate issued and installed"
|
||||
}
|
||||
}
|
||||
@@ -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": "域名",
|
||||
"description": "证书的公共域名。",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "邮箱",
|
||||
"description": "Contact email for the ACME account and expiry notices.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "证书颁发机构",
|
||||
"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": "具有您域名的 Zone:DNS:Edit 权限的限定作用域令牌。"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Certificate status",
|
||||
"none": "无证书",
|
||||
"names": "Covers {{names}}",
|
||||
"issuer": "Issued by {{issuer}}",
|
||||
"expires": "过期于 {{date}}",
|
||||
"lastIssued": "上次签发于 {{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": "正在请求证书...",
|
||||
"issued": "Certificate issued and installed"
|
||||
}
|
||||
}
|
||||
@@ -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": "網域",
|
||||
"description": "憑證使用的公開網域名稱。",
|
||||
"placeholder": "termix.example.com"
|
||||
},
|
||||
"email": {
|
||||
"label": "電子郵件",
|
||||
"description": "Contact email for the ACME account and expiry notices.",
|
||||
"placeholder": "admin@example.com"
|
||||
},
|
||||
"provider": {
|
||||
"label": "憑證授權中心",
|
||||
"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": "具有您網域 Zone:DNS:Edit 權限的限定範圍權杖。"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Certificate status",
|
||||
"none": "無憑證",
|
||||
"names": "Covers {{names}}",
|
||||
"issuer": "Issued by {{issuer}}",
|
||||
"expires": "到期日 {{date}}",
|
||||
"lastIssued": "上次簽發日期 {{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": "請求憑證中...",
|
||||
"issued": "Certificate issued and installed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"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 provider.",
|
||||
"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",
|
||||
"notify:send",
|
||||
"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"],
|
||||
"desktop": "server"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"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 .",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"acme-client": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -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,173 @@
|
||||
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) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
ctx.log.error(
|
||||
"Certificate renewal failed",
|
||||
error instanceof Error ? error : new Error(message),
|
||||
);
|
||||
await ctx.notify
|
||||
.send({
|
||||
title: `Certificate renewal failed for ${settings.domain}`,
|
||||
body: message,
|
||||
severity: "critical",
|
||||
category: "acme-ssl.renewal_failed",
|
||||
audience: "admins",
|
||||
dedupeKey: `acme-ssl.renewal_failed:${settings.domain}`,
|
||||
})
|
||||
.catch(() => {});
|
||||
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,269 @@
|
||||
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("alerts every admin when a renewal fails", async () => {
|
||||
const { mock, runner } = await setup({ settings: CONFIGURED });
|
||||
issue.mockRejectedValueOnce(new Error("rate limited"));
|
||||
expect(await runner.check()).toBe(false);
|
||||
expect(mock.notifications).toEqual([
|
||||
{
|
||||
actor: undefined,
|
||||
notification: expect.objectContaining({
|
||||
body: "rate limited",
|
||||
severity: "critical",
|
||||
category: "acme-ssl.renewal_failed",
|
||||
audience: "admins",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
Reference in new issue
Block a user