Compare commits

..
Author SHA1 Message Date
LukeGus 677c693c87 chore: pin bundled plugins to the latest releases 2026-10-18 00:29:45 -05:00
LukeGus b9193be951 feat: add shell.rightEdge slot so plugin panels can push the page over 2026-10-17 23:05:00 -05:00
LukeGus ee42837fc6 chore: changelog for merged sync, audit, event and desktop PRs 2026-10-17 19:04:52 -05:00
ZacharyZcR b63852ee5c feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666)
ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background
work a session for calling Termix's own API as an existing user. It needs
users:impersonate, defaults to 300s and is capped at 900s, is stored as a
normal session row marked as plugin-issued, and every issue is audited.
Such sessions are refused on routes that change the user's password,
second factors, API keys or linked logins.

ctx.users.getLocale(userId) reads the user's UI language under users:read.

SDK 1.0.11, plugin API 1.3.0.
2026-10-17 19:03:19 -05:00
Lukas 3892381b95 fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670)
* fix(desktop): send linked server proxy headers on WebSocket upgrades

isLinkedOrigin compared the request origin with the link's http(s)
origin, so the terminal WebSocket (wss://host/...) never matched and its
upgrade went out without the proxy headers and basic auth. Behind a
forward-auth proxy that upgrade was redirected to the login and the
terminal closed with "connection closed unexpectedly". ws and wss now
count as http and https for the comparison, the same mapping the
WebSocket handshake itself uses, with host and port still matched
exactly.

* fix(desktop): allow a linked server's self-signed certificate for its WebSockets

isInvalidCertificateAllowedForUrl compared the URL with a local
getOrigin copy, so wss://host never matched the link's https://host
origin and an allowed self-signed certificate did not cover the
terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin,
which maps ws and wss the same way as the proxy headers, and the local
copy is removed.
2026-10-17 19:03:16 -05:00
Lukas 4a9c434cd1 fix: show the login for signed-out full-screen view links and return to them (#1669)
A ?view= link opened without a session spun forever: the gate rendered
the bare view when the session check failed, so the plugin runtime never
started and the view never registered. A regular browser now keeps the
link in sessionStorage, shows the normal login and opens the link again
after any login, including redirect logins. Only same-origin paths with
a view are restored. The desktop app, embedded sign-in frames and guest
views keep their current behavior.
2026-10-17 19:03:13 -05:00
ZacharyZcR 16f2f509f2 feat(plugins): publish host.created when a host is added (#1668)
Plugins that keep an inventory of hosts had no way to hear about a new
one short of polling. Core now publishes host.created with the same
{ hostId, userId } payload as host.updated wherever a host row is
created: the host create route, ctx.hosts.create, JSON and SSH config
bulk import, database file import, and sync writing a host it did not
have yet.
2026-10-17 19:03:08 -05:00
ZacharyZcR 893e7e5a4b fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667)
Successful plugin_as_user and plugin_service_* entries written by a plugin
with the same user, action, resource and details are recorded once per
10 minutes. Failures and every other action are still written each time.
2026-10-17 19:03:01 -05:00
ZacharyZcR 11f6910d51 fix(sync): keep the changes cursor within the 32-bit seq column (#1665)
The desktop learned the server's entity types by asking for changes after
Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres,
which rejects that bound value, so every pass needing the probe failed with
22003 out of range.

The server now clamps the cursor to the largest seq the column holds and
answers 400 for a cursor or limit that is not a non-negative integer. The
desktop asks for a one-row page from its saved cursor instead.
2026-10-17 19:02:56 -05:00
LukeGus 6ccee9b336 feat: build the Windows desktop app for ARM64 (#1615) 2026-10-16 23:42:18 -05:00
LukeGus 49f1914e6e fix: pick a free port for the desktop backend when 30001 is taken (#1663) 2026-10-16 23:37:00 -05:00
LukeGus 9949d0711e fix: move macOS split pane navigation to Cmd+Option+Arrow 2026-10-16 23:28:24 -05:00
LukeGus 6bacb17390 chore: changelog for merged plugin name and host auth type PRs 2026-10-16 23:10:28 -05:00
ZacharyZcR 65d10b37cf fix(hosts): reject or infer a missing authType on host create (#1664)
Creating an SSH host through the API without authType hit the NOT NULL
auth_type column and came back as a 500. The create route now infers the
type from the credential, key or password in the body, falls back to the
host defaults, and otherwise answers 400. A non-string authType is a 400
on create and update.
2026-10-16 23:09:55 -05:00
ZacharyZcRandZacharyZcR 764d034098 fix: show plugin names and descriptions in the UI language (#1658)
* fix: show plugin names and descriptions in the UI language

Read plugin.name and plugin.description from each plugin's own locale
namespace, falling back to the manifest, everywhere core shows a plugin's
name: the Plugins manager, onboarding picker, host editor tabs and
sections, host filters and bulk actions, beta updates, interface presets
and plugin crash/loading notices.

* docs(sdk): document plugin.name and plugin.description locale keys

---------

Co-authored-by: ZacharyZcR <zkl205305@gmail.com>
2026-10-16 23:09:50 -05:00
LukeGus fc77b6c756 chore: update remote desktop origin comment for the built-in engine 2026-10-16 23:04:28 -05:00
LukeGus f76a1adfb8 chore: drop guacd from compose files and dev runner 2026-10-16 23:01:57 -05:00
LukeGus 8e809aa760 feat: plugin packs include LICENSE and NOTICE 2026-10-16 22:21:44 -05:00
89 changed files with 2394 additions and 292 deletions

No files matched your search

+33 -4
View File
@@ -143,12 +143,12 @@ jobs:
- name: Build Windows (All Architectures)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build && npx electron-builder --win --x64 --ia32 --publish never
run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never
# The updater only installs the x64 NSIS build; 32-bit installs get a link.
# The combined x64+ia32 installer is never uploaded.
# The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
# The combined multi-arch installer is never uploaded.
- name: Fix Windows update files
run: node scripts/fix-update-info.cjs --drop ia32 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
- name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v7
@@ -166,6 +166,14 @@ jobs:
path: release/termix_windows_ia32_nsis.exe
retention-days: 30
- name: Upload Windows arm64 NSIS Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_nsis
path: release/termix_windows_arm64_nsis.exe
retention-days: 30
- name: Upload Windows x64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
@@ -182,6 +190,14 @@ jobs:
path: release/termix_windows_ia32_msi.msi
retention-days: 30
- name: Upload Windows arm64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_msi.msi') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_msi
path: release/termix_windows_arm64_msi.msi
retention-days: 30
- name: Create Windows x64 Portable zip
if: hashFiles('release/win-unpacked/*') != ''
run: |
@@ -192,6 +208,11 @@ jobs:
run: |
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
- name: Create Windows arm64 Portable zip
if: hashFiles('release/win-arm64-unpacked/*') != ''
run: |
Compress-Archive -Path "release\win-arm64-unpacked\*" -DestinationPath "termix_windows_arm64_portable.zip"
- name: Upload Windows update files
uses: actions/upload-artifact@v7
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
@@ -219,6 +240,14 @@ jobs:
path: termix_windows_ia32_portable.zip
retention-days: 30
- name: Upload Windows arm64 Portable
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_arm64_portable.zip') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_portable
path: termix_windows_arm64_portable.zip
retention-days: 30
build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
+13
View File
@@ -6,10 +6,16 @@
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
- Sort the dashboard's Host Status card by name, IP address or status
- Windows on ARM builds of the desktop app (installer, MSI and portable)
- Plugins get an event when a host is added
- Plugins can start a short-lived session as a user and read the user's language, for tasks that run without a browser
- Plugins can pin a panel to the right edge of the window that pushes the page over
### Changed
- The Docker Compose files no longer start a guacd container. npm run dev -- --guacd also points remote desktop at the guacd it starts
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal
### Fixed
@@ -34,6 +40,13 @@
- nginx answered 404 instead of 502 while the backend was down
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
- Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language
- Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required
- The desktop app would not start its local server when another program used port 30001. It now picks a free port on its own
- A linked desktop could never finish syncing with a server on Postgres
- Routine plugin activity filled the audit log and pushed out real security entries
- Opening a full-screen view link while signed out showed a spinner forever. It now shows the login and opens the link after
- A linked desktop behind a proxy that needs extra headers, or with an allowed self-signed certificate, could not open remote terminals or tunnels
## 26.10.1
+1 -22
View File
@@ -137,7 +137,7 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
See the [docs](https://docs.termix.site/install) for install steps on every platform.
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
```yaml
services:
@@ -151,31 +151,10 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
volumes:
termix-data:
driver: local
networks:
termix-net:
driver: bridge
```
### Cloud Hosting
+68 -68
View File
@@ -3,20 +3,20 @@
{
"id": "acme-ssl",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug",
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e"
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug",
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7"
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug",
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"onboarding": {
"recommended": true,
"consent": false
@@ -25,14 +25,14 @@
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug",
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d"
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug",
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
"onboarding": {
"recommended": true,
"consent": false
@@ -41,8 +41,8 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug",
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
"onboarding": {
"recommended": true,
"consent": false
@@ -51,20 +51,20 @@
{
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug",
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b"
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug",
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32"
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
},
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug",
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
"onboarding": {
"recommended": true,
"consent": false
@@ -73,32 +73,32 @@
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug",
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c"
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
},
{
"id": "network-topology",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug",
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca"
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug",
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f"
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug",
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7"
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
},
{
"id": "remote-desktop",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug",
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
"onboarding": {
"recommended": true,
"consent": false
@@ -107,14 +107,14 @@
{
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug",
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a"
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug",
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"onboarding": {
"recommended": "desktop",
"consent": false
@@ -123,20 +123,20 @@
{
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug",
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7"
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
},
{
"id": "session-sharing",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug",
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1"
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug",
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"onboarding": {
"recommended": true,
"consent": false
@@ -145,8 +145,8 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug",
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
"onboarding": {
"recommended": true,
"consent": false
@@ -155,8 +155,8 @@
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug",
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"onboarding": {
"recommended": false,
"consent": false,
@@ -166,20 +166,20 @@
{
"id": "step-ca",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug",
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8"
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug",
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f"
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug",
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"onboarding": {
"recommended": true,
"consent": true
@@ -188,20 +188,20 @@
{
"id": "termix-identity",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug",
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0"
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
},
{
"id": "tmux-monitor",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug",
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6"
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug",
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"onboarding": {
"recommended": true,
"consent": false
@@ -210,8 +210,8 @@
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug",
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
"onboarding": {
"recommended": true,
"consent": false
@@ -220,32 +220,32 @@
{
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug",
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a"
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
},
{
"id": "wake-on-lan",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug",
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402"
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug",
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8"
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
},
{
"id": "web-endpoint",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug",
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637"
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug",
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
"onboarding": {
"recommended": true,
"consent": false
@@ -254,8 +254,8 @@
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug",
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"onboarding": {
"recommended": true,
"consent": false
-15
View File
@@ -12,24 +12,9 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
GUACD_HOST: "guacd-dev"
GUACD_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on:
- guacd-dev
networks:
- termix-dev-net
guacd-dev:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
volumes:
- termix-dev-data:/termix-data
networks:
- termix-dev-net
-17
View File
@@ -9,12 +9,6 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# Where guacd stores files for RDP drive redirection (one folder per
# user is created underneath). Must be writable by guacd's user.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# Multi-instance deployments should point every backend at the same
# Redis. Without it, collaboration rooms keep their single-instance
# in-memory fallback.
@@ -36,17 +30,6 @@ services:
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
+2 -2
View File
@@ -46,11 +46,11 @@
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
"arch": ["x64", "ia32", "arm64"]
},
{
"target": "msi",
"arch": ["x64", "ia32"]
"arch": ["x64", "ia32", "arm64"]
}
],
"icon": "public/icon.ico",
+8 -1
View File
@@ -6,9 +6,16 @@
let linked = null;
const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" };
// A WebSocket to the linked server (wss://host/...) counts as the link's
// https://host origin, so its upgrade carries the proxy headers too.
function getOrigin(url) {
try {
return new URL(url).origin;
const parsed = new URL(url);
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
if (httpProtocol) parsed.protocol = httpProtocol;
return parsed.origin;
} catch {
return null;
}
+8 -2
View File
@@ -330,7 +330,11 @@ const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
// which keeps every transfer on the backend's plugin routes.
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
const LOCAL_BACKEND = "http://localhost:30001";
let localBackendPort = 30001;
function setLocalBackendPort(port) {
localBackendPort = port;
}
let registeredApiPath = null;
function setTransferApiPath(apiPath) {
@@ -403,7 +407,8 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
}
return {
url: `${normalizeHttpBase(
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
localBaseUrl ??
`http://localhost:${localBackendPort}${resolveApiPath()}`,
"Local backend URL",
)}${routePath}`,
headers,
@@ -1050,6 +1055,7 @@ module.exports = {
createLocalFileHandlers,
createTargetResolver,
setTransferApiPath,
setLocalBackendPort,
publishDownload,
defaultPublishFs,
assertWithinRoot,
+39 -11
View File
@@ -35,7 +35,10 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
const { registerLocalFileHandlers } = require("./local-files.cjs");
const {
registerLocalFileHandlers,
setLocalBackendPort,
} = require("./local-files.cjs");
const {
EXTERNAL_EDITOR_SETTING,
isLaunchableFileName,
@@ -439,14 +442,6 @@ function isInsecureModeEnabled() {
);
}
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function isPrivateNetworkHost(hostname) {
if (
hostname === "localhost" ||
@@ -490,7 +485,7 @@ function isInvalidCertificateAllowedForUrl(url) {
// allows a self-signed certificate.
const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false;
return getOrigin(url) === linked.origin;
return linkedServer.isLinkedOrigin(url);
}
function getTlsVerificationOptions(url) {
@@ -1124,6 +1119,35 @@ function reapOrphanedBackendProcess() {
}
}
const DEFAULT_BACKEND_PORT = 30001;
let backendPort = DEFAULT_BACKEND_PORT;
function canListenOn(port) {
return new Promise((resolve) => {
const probe = net.createServer();
probe.once("error", () => resolve(null));
probe.listen(port, "127.0.0.1", () => {
const { port: bound } = probe.address();
probe.close(() => resolve(bound));
});
});
}
// 30001 when it is free, otherwise any free port. Only the desktop app talks
// to this backend, so the number itself does not matter.
async function findBackendPort() {
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
if (preferred) return preferred;
const fallback = await canListenOn(0);
if (fallback) {
logToFile(
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
);
return fallback;
}
return DEFAULT_BACKEND_PORT;
}
function startBackendServer() {
reapOrphanedBackendProcess();
backendFailure = null;
@@ -1164,7 +1188,8 @@ function startBackendServer() {
DATA_DIR: dataDir,
NODE_ENV: "production",
ELECTRON_EMBEDDED: "true",
PORT: "30001",
PORT: String(backendPort),
EMBEDDED_BACKEND_PORT: String(backendPort),
VERSION: app.getVersion(),
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
@@ -1429,6 +1454,7 @@ function createWindow() {
sandbox: true,
webSecurity: true,
preload: path.join(__dirname, "preload.js"),
additionalArguments: [`--termix-backend-port=${backendPort}`],
partition: termixSessionPartition,
allowRunningInsecureContent: false,
webviewTag: false,
@@ -3814,6 +3840,8 @@ app.whenReady().then(async () => {
await clearElectronJwtCookiesAtStartup();
if (!isDev) {
backendPort = await findBackendPort();
setLocalBackendPort(backendPort);
const result = await startBackendServer();
logToFile("startBackendServer result:", result);
} else {
+9
View File
@@ -14,7 +14,16 @@ function invokeAllowed(channel, ...args) {
return ipcRenderer.invoke(channel, ...args);
}
// The embedded backend's port, picked by the main process at startup.
const backendPortArg = process.argv.find((arg) =>
arg.startsWith("--termix-backend-port="),
);
const backendPort = backendPortArg
? Number(backendPortArg.split("=")[1])
: undefined;
contextBridge.exposeInMainWorld("electronAPI", {
backendPort,
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => {
+6 -10
View File
@@ -71,7 +71,6 @@
"group": "server",
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
},
{
"name": "ENABLE_SSL",
"group": "https",
@@ -108,7 +107,6 @@
"internal": true,
"description": "Set by the image when nginx serves HTTPS in front of the backend."
},
{
"name": "DATABASE_DIALECT",
"group": "database",
@@ -161,7 +159,6 @@
"internal": true,
"description": "Where database migrations are read from. Set by the image."
},
{
"name": "JWT_SECRET",
"group": "secrets",
@@ -204,7 +201,6 @@
"secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
},
{
"name": "ALLOW_PASSWORD_LOGIN",
"group": "auth",
@@ -283,7 +279,6 @@
"group": "auth",
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
},
{
"name": "TRUSTED_PROXIES",
"group": "proxy",
@@ -315,7 +310,6 @@
"group": "proxy",
"description": "Comma separated hosts that skip the proxy."
},
{
"name": "LOG_LEVEL",
"group": "logging",
@@ -355,7 +349,6 @@
"deprecated": "the session-recording plugin setting",
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
},
{
"name": "TERMIX_PLUGIN_REGISTRY_URL",
"group": "plugins",
@@ -406,7 +399,6 @@
"internal": true,
"description": "Set by npm run dev."
},
{
"name": "ENABLE_INSECURE_MODE",
"group": "desktop",
@@ -435,6 +427,12 @@
"internal": true,
"description": "Set by the desktop app when it runs its own backend."
},
{
"name": "EMBEDDED_BACKEND_PORT",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it."
},
{
"name": "TERMIX_DATA_DIR",
"group": "desktop",
@@ -459,14 +457,12 @@
"internal": true,
"description": "Set by Flatpak. The desktop app links new versions instead of installing them."
},
{
"name": "DESKTOP_SESSION",
"group": "desktop",
"internal": true,
"description": "Read on Linux to pick the password store."
},
{
"name": "VITE_BASE_PATH",
"group": "internal",
+1 -1
View File
@@ -15058,7 +15058,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.9",
"version": "1.0.11",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
+15
View File
@@ -1,5 +1,20 @@
# Changelog
## 1.0.11
Plugin API 1.3.0.
### Added
- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
## 1.0.10
### Changed
- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
## 1.0.9
### Added
+58 -35
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.9 | Plugin API 1.2.0
SDK 1.0.11 | Plugin API 1.3.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -90,7 +90,7 @@ npm test # termix-plugin test: Vitest
npm run typecheck # tsc
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
npm run format # Prettier
npm run format:check
```
@@ -105,7 +105,7 @@ Before you call a change finished:
- [ ] `npm run build` passes
- [ ] `npm run format:check` passes
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
- [ ] New strings are in `locales/en.json`
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
- [ ] New routes have an `@openapi` block and a test, including without the permission
- [ ] New env vars are listed in `env` in the manifest
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
@@ -225,6 +225,8 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
| `audit.record` | none | Write your own audit line |
| `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed |
| `asUser(userId, fn)` | always audited | Run background work as a user |
@@ -237,6 +239,13 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()`
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
that user's permissions.
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
UI language, like `zh_CN`, or null, for writing to them in it.
### Routes
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
@@ -484,7 +493,7 @@ your own host settings, under `host.pluginSettings[<id>]`.
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
`hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
https://docs.termix.site/develop/more-extension-points.
## Looking native
@@ -538,6 +547,20 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
Untranslated strings show in English.
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
every language.
```json
{
"plugin": {
"name": "Host Metrics",
"description": "Live CPU, memory, disk, network and GPU stats."
}
}
```
## Docs
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
@@ -740,37 +763,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## Capabilities
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------ |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Run background work as another user, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames and roles |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
An update that adds a capability waits for an admin to approve it.
+3
View File
@@ -11,6 +11,9 @@ export const TMXPLUG_CONTENTS = [
"README.md",
"CHANGELOG.md",
"icon.svg",
// Licence texts, so third-party notices travel with the plugin.
"LICENSE",
"NOTICE",
];
const BLOCK = 512;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.9",
"version": "1.0.11",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
+43 -3
View File
@@ -1464,6 +1464,20 @@ export interface PluginSecondFactor {
reset?: (userId: string) => Promise<void>;
}
export interface PluginUserTokenOptions {
/** Why the token exists, kept in the audit log and on the session. */
purpose: string;
/** Seconds until it expires. Defaults to 300 and is capped at 900. */
ttlSeconds?: number;
}
export interface PluginUserToken {
/** Send as `Authorization: Bearer <token>` to core's own API. */
token: string;
/** ISO time the session ends. */
expiresAt: string;
}
/**
* Login methods, second factors and SSH auth types. Every register call needs
* auth:provide and a matching contributes.auth entry, and is undone on
@@ -1525,6 +1539,26 @@ export interface PluginAuth {
* delete a provider people still sign in with.
*/
countLinkedUsers: (provider: string) => Promise<number>;
/**
* A short-lived session for an existing user, for calling core's own HTTP
* API as them from work with no request behind it, like a scheduled task
* driving the Termix CLI. The user's RBAC applies unchanged. Needs
* users:impersonate, not auth:provide, and every call is audited.
*
* The session is marked with the plugin and purpose. It cannot change the
* user's password, second factors, API keys or linked logins, so it can
* never outlive itself.
*/
issueUserToken: (
userId: string,
options: PluginUserTokenOptions,
) => Promise<PluginUserToken>;
}
/** What a plugin may learn about a user. Needs users:read. */
export interface PluginUsers {
/** The user's UI language, like "zh_CN", or null when they never chose one. */
getLocale: (userId: string) => Promise<string | null>;
}
export interface PluginOpenIsolatedWindowRequest {
@@ -2013,8 +2047,13 @@ export interface PluginContext {
readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh;
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
/**
* Login methods, second factors and SSH auth types. Needs auth:provide,
* except issueUserToken, which needs users:impersonate.
*/
readonly auth: PluginAuth;
/** What a plugin may learn about a user. Needs users:read. */
readonly users: PluginUsers;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
@@ -2039,8 +2078,9 @@ export interface PluginContext {
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* Needs users:impersonate. That and a `userId` option on services.get or
* secrets.getShared are the only ways a plugin can name a user.
* Needs users:impersonate. That, auth.issueUserToken and a `userId` option
* on services.get or secrets.getShared are the only ways a plugin can act
* as a user it names.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
+1 -1
View File
@@ -36,7 +36,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.2.0";
export const PLUGIN_API_VERSION = "1.3.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+33 -1
View File
@@ -174,6 +174,8 @@ export interface FakeContextOptions {
linkedUsers?: Record<string, number>;
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
locales?: Record<string, string>;
}
export interface FakeProcessOptions {
@@ -247,6 +249,8 @@ export interface FakeAuthRegistrations {
}>;
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
}
export interface FakePluginContext {
@@ -574,6 +578,7 @@ export function createFakeContext(
completedLogins: [],
revokedSessions: [],
loginFailures: new Map(),
userTokens: [],
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -1140,6 +1145,18 @@ export function createFakeContext(
},
countLinkedUsers: async (provider) =>
options.linkedUsers?.[provider] ?? 0,
issueUserToken: async (userId, tokenOptions) => {
auth.userTokens.push({ userId, ...tokenOptions });
const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
return {
token: `fake-token-${auth.userTokens.length}`,
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
};
},
},
users: {
getLocale: async (userId) => options.locales?.[userId] ?? null,
},
desktop: {
@@ -1442,6 +1459,8 @@ export interface MockContextOptions {
linkedUsers?: Record<string, number>;
/** See FakeContextOptions. */
loginAttemptLimit?: number;
/** See FakeContextOptions. */
locales?: Record<string, string>;
}
export interface MockPluginContext extends FakePluginContext {
@@ -1458,7 +1477,8 @@ export interface MockPluginContext extends FakePluginContext {
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
@@ -1495,6 +1515,7 @@ export function createMockCtx(
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
locales: options.locales,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1786,6 +1807,17 @@ export function createMockCtx(
require("auth:provide");
return ctx.auth.countLinkedUsers(provider);
},
issueUserToken: async (userId, tokenOptions) => {
require("users:impersonate");
return ctx.auth.issueUserToken(userId, tokenOptions);
},
},
users: {
getLocale: async (userId) => {
require("users:read");
return ctx.users.getLocale(userId);
},
},
desktop: {
+2
View File
@@ -42,6 +42,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
GUACD_HOST: "127.0.0.1",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
@@ -53,6 +54,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
GUACD_HOST: "termix-dev-guacd",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
});
+2 -1
View File
@@ -14,7 +14,8 @@
* mounted. A plugin change rebuilds it and restarts the container. Core
* changes wait for "r", since an image build is slow.
*
* --db postgres|mysql and --guacd run those in Docker next to it.
* --db postgres|mysql and --guacd run those in Docker next to it; --guacd also
* switches remote desktop from its built-in engine to that guacd.
*/
import fs from "node:fs";
+1
View File
@@ -53,6 +53,7 @@ function start() {
isDev: false,
appRoot: "/app",
BACKEND_STDERR_TAIL_LIMIT: 4096,
backendPort: 30001,
handleBackendRequest,
setTimeout,
clearTimeout,
+1 -1
View File
@@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) {
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
`| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **AArch64 (ARM64)** | [EXE](${win("termix_windows_arm64_nsis.exe")}) · [MSI](${win("termix_windows_arm64_msi.msi")}) · [Portable](${win("termix_windows_arm64_portable.zip")}) | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
+2
View File
@@ -85,6 +85,8 @@ export function serviceEnv(options, inDocker) {
const guacd = SERVICES.guacd;
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
// Remote desktop runs built in unless told to use guacd.
env.REMOTE_DESKTOP_ENGINE = "guacd";
}
return env;
}
+1 -1
View File
@@ -125,7 +125,7 @@ Options
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
--no-local Use only the plugins pinned in docker/bundled-plugins.json
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
--guacd Run guacd in Docker for remote desktop
--guacd Run guacd in Docker and use it instead of the built-in remote desktop
--fresh Start from empty data (first run, onboarding)
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
electron, plus -<engine> for postgres or mysql)
+4
View File
@@ -46,6 +46,8 @@ function fixturePlugin(manifest: Record<string, unknown> = {}): string {
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
writeFile(dir, "README.md", "# Fixture\n");
writeFile(dir, "icon.svg", "<svg/>\n");
writeFile(dir, "LICENSE", "Apache-2.0\n");
writeFile(dir, "NOTICE", "Third-party notices\n");
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
return dir;
}
@@ -91,6 +93,8 @@ describe("termix-plugin pack", () => {
const names = await entries(file);
expect(names).toEqual([
"LICENSE",
"NOTICE",
"README.md",
"dist/backend.js",
"dist/frontend.js",
+6 -1
View File
@@ -38,6 +38,7 @@ import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js";
import fs from "fs";
@@ -1388,6 +1389,9 @@ app.post(
}
}
await applyDefaultsAfterHostWrites(importedHostIds);
for (const hostId of importedHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
@@ -1716,7 +1720,8 @@ app.use(
},
);
const HTTP_PORT = 30001;
// The desktop app moves its embedded backend off 30001 when that is taken.
const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
async function initializeSecurity() {
try {
@@ -140,7 +140,14 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(userId: string, hostIds: number[]): void {
export function notifyImportedHosts(
userId: string,
hostIds: number[],
createdHostIds: number[],
): void {
for (const hostId of createdHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
@@ -588,6 +595,7 @@ export function registerHostBulkRoutes(
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -797,6 +805,7 @@ export function registerHostBulkRoutes(
);
if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id;
createdHostIds.push(saved.id);
results.success++;
}
@@ -828,7 +837,7 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -948,6 +957,7 @@ export function registerHostBulkRoutes(
}
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
@@ -1023,6 +1033,7 @@ export function registerHostBulkRoutes(
sshDataObj,
);
writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++;
}
} catch (error) {
@@ -1034,7 +1045,7 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -88,6 +88,23 @@ export function keepsStoredSshAuth(authType: unknown): boolean {
return authType === undefined || authType === null;
}
export function isOptionalAuthType(value: unknown): boolean {
return value === undefined || value === null || typeof value === "string";
}
/**
* The auth type a new SSH host's secrets point to when the body names none,
* as an API client creating a host from a credential does.
*/
export function impliedSshAuthType(
body: Record<string, unknown>,
): string | undefined {
if (body.credentialId) return "credential";
if (body.key) return "key";
if (body.password) return "password";
return undefined;
}
const FOLDER_PATH_SEPARATOR = " / ";
/**
+18 -2
View File
@@ -31,8 +31,10 @@ import {
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
keepsStoredSshAuth,
isNonEmptyString,
isOptionalAuthType,
isOptionalBoolean,
isValidPort,
normalizeProtocolEnableFields,
@@ -259,7 +261,9 @@ router.post(
!isNonEmptyString(ip) ||
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh)
!isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod)
) {
sshLogger.warn("Invalid SSH data input validation failed", {
operation: "host_create",
@@ -292,7 +296,9 @@ router.post(
const effectiveAuthType =
authType ||
authMethod ||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
(effectiveConnectionType !== "ssh"
? "password"
: impliedSshAuthType(hostData));
const effectiveUsername =
username || firstProtocolUsername(protocolAuthPatch);
const effectiveName =
@@ -403,6 +409,13 @@ router.post(
body: hostData,
}),
);
// Host defaults may have supplied it.
if (!sshDataObj.authType) {
return res.status(400).json({
error:
"authType is required (password, key, credential, agent or none)",
});
}
const result = await createCurrentHostRepository().createEncryptedForUser(
userId,
sshDataObj,
@@ -473,6 +486,7 @@ router.post(
});
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
@@ -827,6 +841,8 @@ router.put(
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod) ||
!hostId
) {
sshLogger.warn("Invalid SSH data input validation failed for update", {
+2
View File
@@ -30,6 +30,7 @@ import { hostStatusService } from "../hosts/status/host-status-service.js";
import { parseSshOptions } from "../hosts/ssh-options.js";
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js";
import { pluginEvents, TOPICS } from "./events.js";
import { pluginLogger } from "../utils/logger.js";
type AuditFn = (
@@ -502,6 +503,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes);
await audit("hosts_create", `host ${created.id}`, { success: true });
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
return (
await toRecords([created as unknown as Record<string, unknown>])
)[0];
+3
View File
@@ -25,6 +25,7 @@ import {
import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js";
import { issueUserToken } from "./ctx-users.js";
import {
getSshAuthProvider,
listSshAuthProviders,
@@ -901,5 +902,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
await import("../database/repositories/factory.js");
return createCurrentUserAuthRepository().countUsersForProvider(provider);
},
issueUserToken: (userId, options) =>
issueUserToken(manifest, userId, options),
};
}
+94
View File
@@ -0,0 +1,94 @@
/**
* ctx.users, and ctx.auth.issueUserToken: what a plugin may learn about a
* user, and a short-lived session for calling core's own API as one.
*/
import type {
PluginUserToken,
PluginUserTokenOptions,
PluginUsers,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
const DEFAULT_TOKEN_TTL_SECONDS = 300;
const MAX_TOKEN_TTL_SECONDS = 900;
const MAX_PURPOSE_LENGTH = 200;
export function createPluginUsers(manifest: PluginManifest): PluginUsers {
return {
getLocale: async (userId) => {
await assertCapability(
manifest.id,
"users:read",
manifest.capabilities,
"users_get_locale",
);
const { createCurrentUserPreferenceRepository } =
await import("../database/repositories/factory.js");
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
return preferences?.language ?? null;
},
};
}
export async function issueUserToken(
manifest: PluginManifest,
userId: string,
options: PluginUserTokenOptions,
): Promise<PluginUserToken> {
await assertCapability(
manifest.id,
"users:impersonate",
manifest.capabilities,
"user_token_issued",
);
const { logAudit } = await import("../utils/audit-logger.js");
const purpose = String(options?.purpose ?? "")
.trim()
.slice(0, MAX_PURPOSE_LENGTH);
const ttlSeconds = Math.min(
options?.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS,
MAX_TOKEN_TTL_SECONDS,
);
const audit = (success: boolean, errorMessage?: string) =>
logAudit({
userId: getActor() ?? null,
username: `plugin:${manifest.id}`,
action: "plugin_user_token_issued",
resourceType: "user",
resourceId: userId,
details: JSON.stringify({ pluginId: manifest.id, purpose, ttlSeconds }),
success,
errorMessage,
});
try {
if (!purpose) {
throw new Error("ctx.auth.issueUserToken needs a purpose");
}
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new Error("ttlSeconds must be a positive whole number");
}
const { createCurrentUserRepository } =
await import("../database/repositories/factory.js");
const user = userId
? await createCurrentUserRepository().findById(userId)
: null;
if (!user) throw new Error(`User "${userId}" does not exist`);
const { AuthManager } = await import("../utils/auth-manager.js");
const issued = await AuthManager.getInstance().issuePluginSession(user.id, {
pluginId: manifest.id,
purpose,
ttlSeconds,
});
await audit(true);
return issued;
} catch (error) {
await audit(false, error instanceof Error ? error.message : String(error));
throw error;
}
}
+2
View File
@@ -62,6 +62,7 @@ import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js";
import { createPluginUsers } from "./ctx-users.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -957,6 +958,7 @@ export function createPluginContext(
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
plugins: createPluginPlugins(manifest),
users: createPluginUsers(manifest),
desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
+2
View File
@@ -26,6 +26,8 @@ export const TOPICS = {
hostStatus: "host.status",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
/** A host was added and anything inventorying hosts should pick it up. */
hostCreated: "host.created",
/** A host's connection details changed and pollers should re-read them. */
hostUpdated: "host.updated",
/** A host was deleted and any poller holding it should drop it. */
+18 -7
View File
@@ -499,17 +499,21 @@ export class SyncEngine {
);
all.push(...result.changes);
next = result.nextCursor;
this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type));
this.state.serverTypes = result.entityTypes.map((entry) => ({
type: entry.type,
owner: entry.owner,
readOnly: entry.readOnly,
}));
this.learnTypes(result);
if (!result.hasMore) break;
}
return { changes: all, cursor: next };
}
private learnTypes(page: ChangesPage): void {
this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type));
this.state.serverTypes = page.entityTypes.map((entry) => ({
type: entry.type,
owner: entry.owner,
readOnly: entry.readOnly,
}));
}
private async pull(link: SyncLink): Promise<void> {
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
const known = new Set(link.knownTypes);
@@ -528,7 +532,14 @@ export class SyncEngine {
/** Pulls every row of types this device has not pulled before. */
private async backfill(link: SyncLink): Promise<void> {
if (!this.serverTypes) {
await this.fetchChanges(link, Number.MAX_SAFE_INTEGER);
// Only the entity types are wanted, so a one-row page is enough.
this.learnTypes(
await remoteJson<ChangesPage>(
link,
`/sync/v2/changes?cursor=${link.cursor}&limit=1`,
{ timeoutMs: 120_000 },
),
);
}
const fresh = listEntities()
.map((entity) => entity.type)
+5 -1
View File
@@ -22,6 +22,7 @@ import {
import { FieldCrypto } from "../utils/field-crypto.js";
import { syncLogger } from "../utils/logger.js";
import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { singletonSyncId } from "./wire.js";
import { parseDefaultOverrides } from "../../types/host-defaults.js";
import {
@@ -174,7 +175,7 @@ export function registerCoreSyncEntities(): void {
)
: {},
}),
afterWrite: async ({ id, userId, wire, resolveId }) => {
afterWrite: async ({ id, userId, wire, created, resolveId }) => {
if (id === null) return;
// The server owns host defaults: it keeps what the host set itself
// and resolves the rest. A desktop takes the server's values as sent.
@@ -195,6 +196,9 @@ export function registerCoreSyncEntities(): void {
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHost(id);
if (created) {
pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId });
}
},
remove: async (row, userId) => {
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
+5 -1
View File
@@ -140,6 +140,9 @@ export async function listRecordErrors(userId: string): Promise<SyncRecord[]> {
return rows.filter((row) => !!row.error);
}
/** The largest seq the column holds: a 32-bit integer on Postgres and MySQL. */
const MAX_SEQ = 2_147_483_647;
export async function listChanges(
userId: string,
cursor: number,
@@ -148,7 +151,8 @@ export async function listChanges(
): Promise<SyncRecord[]> {
const conditions = [
eq(syncRecords.userId, userId),
gt(syncRecords.seq, cursor),
// Postgres rejects a bound value the column cannot hold.
gt(syncRecords.seq, Math.min(cursor, MAX_SEQ)),
];
if (types && types.length > 0) {
conditions.push(inArray(syncRecords.entityType, types));
+19 -5
View File
@@ -37,6 +37,17 @@ function userOf(req: Request): string {
return (req as AuthenticatedRequest).userId;
}
/** A non-negative integer query value, the fallback when absent, null when malformed. */
function queryInt(value: unknown, fallback: number): number | null {
if (value === undefined) return fallback;
const parsed = Number(value);
return typeof value === "string" &&
/^\d+$/.test(value) &&
Number.isSafeInteger(parsed)
? parsed
: null;
}
/**
* @openapi
* /sync/v2/info:
@@ -224,6 +235,8 @@ router.post(
* responses:
* 200:
* description: A page of changes, the next cursor and whether more remain.
* 400:
* description: The cursor or limit is not a non-negative integer.
* 500:
* description: Failed to read changes.
*/
@@ -232,11 +245,12 @@ router.get(
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
const cursor = Math.max(0, Number(req.query.cursor) || 0);
const limit = Math.min(
MAX_PAGE,
Math.max(1, Number(req.query.limit) || MAX_PAGE),
);
const cursor = queryInt(req.query.cursor, 0);
const pageSize = queryInt(req.query.limit, MAX_PAGE);
if (cursor === null || pageSize === null) {
return res.status(400).json({ error: "Invalid cursor or limit" });
}
const limit = Math.min(MAX_PAGE, Math.max(1, pageSize));
const types =
typeof req.query.types === "string" && req.query.types
? req.query.types.split(",").filter(Boolean)
@@ -48,6 +48,7 @@ function exportFile(): string {
async function invoke(path: string, user: unknown = { isExternal: false }) {
const writes: string[] = [];
const events: Array<[string, unknown]> = [];
const hosts: Array<Record<string, unknown>> = [];
const importHostProtocolLogins = vi.fn();
const handler = runInNewContext(
@@ -98,6 +99,11 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
DatabaseSaveTrigger: { forceSave: async () => {} },
getErrorMessage: (error: Error) => error.message,
pluginEvents: {
emit: (topic: string, payload: unknown) =>
events.push([topic, payload]),
},
TOPICS: { hostCreated: "host.created" },
},
);
const res = { json: vi.fn(), status: vi.fn() };
@@ -106,7 +112,7 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
{ userId: "user-1", file: { path, originalname: "export.db" } },
res,
);
return { res, writes, hosts, importHostProtocolLogins };
return { res, writes, hosts, events, importHostProtocolLogins };
}
it("points imported hosts at the credentials imported with them", async () => {
@@ -122,6 +128,17 @@ it("points imported hosts at the credentials imported with them", async () => {
]);
});
it("publishes host.created once for each imported host", async () => {
const { events } = await invoke(exportFile());
expect(events).toEqual(
[501, 502, 503, 504].map((hostId) => [
"host.created",
{ hostId, userId: "user-1" },
]),
);
});
it("removes the uploaded file when the user is gone", async () => {
const path = exportFile();
const { res } = await invoke(path, null);
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { sql } from "drizzle-orm";
import { TestSqliteDatabase } from "./test-support.js";
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
context: null as DatabaseContext | null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentRepositoryContext: () => state.context,
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { triggerSave: vi.fn() },
}));
const { listChanges } = await import("../../../sync/records.js");
// Runs against Postgres and MySQL too in the database-dialects CI job, where
// sync_records.seq is a 32-bit integer.
describe("listChanges", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
state.context = null;
});
async function seed(): Promise<void> {
adapter = new TestSqliteDatabase();
state.context = await adapter.connect();
await adapter.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user-1', '')`,
);
await adapter.run(
sql`INSERT INTO sync_records (user_id, entity_type, sync_id, revision, seq) VALUES ('user-1', 'hosts', 'a', 1, 1), ('user-1', 'hosts', 'b', 1, 2)`,
);
}
it("lists records after the cursor in seq order", async () => {
await seed();
const records = await listChanges("user-1", 1, 10);
expect(records.map((record) => record.syncId)).toEqual(["b"]);
});
it("answers no records for a cursor past what seq can hold", async () => {
await seed();
expect(await listChanges("user-1", Number.MAX_SAFE_INTEGER, 10)).toEqual(
[],
);
});
});
@@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => {
seen.push(payload);
});
try {
notifyImportedHosts("user-1", [4, 7]);
notifyImportedHosts("user-1", [4, 7], []);
} finally {
off();
}
@@ -0,0 +1,216 @@
/**
* The create and update host routes against a real SQLite database. A body
* that leaves out authType must never reach the NOT NULL auth_type column.
*/
import express, {
type NextFunction,
type Request,
type Response,
} from "express";
import request from "supertest";
import { eq, sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
ctx: null as null | { drizzle: unknown },
}));
const USER = "user-1";
const pass = (_req: Request, _res: Response, next: NextFunction) => next();
vi.mock("../../../database/db/index.js", () => ({
getDb: () => state.ctx!.drizzle,
getSqlite: () => null,
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
batched: <T>(work: T) => work,
},
}));
vi.mock("../../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({
getEncryptionKey: async () => Buffer.alloc(32, 7),
getJWTSecret: async () => "secret",
}),
},
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: () => Buffer.alloc(32, 3),
validateUserAccess: () => Buffer.alloc(32, 3),
encryptRecord: (_table: string, record: unknown) => record,
decryptRecord: (_table: string, record: unknown) => record,
decryptRecords: (_table: string, records: unknown) => records,
},
}));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: Request, _res: Response, next: NextFunction) => {
(req as Request & { userId: string }).userId = USER;
next();
},
createDataAccessMiddleware: () => pass,
createAdminMiddleware: () => pass,
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
requirePermission: () => pass,
canAccessHost: async () => ({ hasAccess: true, isOwner: true }),
hasPermission: async () => true,
isAdmin: async () => true,
getUserPermissions: async () => [],
}),
},
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async () => {},
getAuditUsername: async () => USER,
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const { TestSqliteDatabase } = await import("../repositories/test-support.js");
const { hosts, sshCredentials } =
await import("../../../database/db/schema.js");
const { saveLevel } = await import("../../../hosts/defaults/service.js");
const { default: hostRouter } =
await import("../../../database/routes/host.js");
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
const app = express();
app.use(express.json());
app.use("/host", hostRouter);
let db: InstanceType<typeof TestSqliteDatabase>;
let ctx: DatabaseContext;
beforeEach(async () => {
db = new TestSqliteDatabase("sqlite");
ctx = await db.connect();
state.ctx = ctx;
await ctx.drizzle.run(
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${USER}, ${USER}, '', 1)`,
);
});
afterEach(async () => {
await db.close();
});
async function addCredential(): Promise<number> {
const [row] = await ctx.drizzle
.insert(sshCredentials)
.values({
userId: USER,
name: "ops",
username: "root",
authType: "password",
password: "secret",
})
.returning({ id: sshCredentials.id });
return row.id;
}
async function storedAuthType(id: number) {
const [row] = await ctx.drizzle
.select({ authType: hosts.authType })
.from(hosts)
.where(eq(hosts.id, id));
return row?.authType;
}
const base = { ip: "10.0.0.5", port: 22, username: "root" };
describe("host create without authType", () => {
it("infers credential auth from a credentialId", async () => {
const credentialId = await addCredential();
const res = await request(app)
.post("/host/db/host")
.send({ ...base, credentialId });
expect(res.status).toBe(200);
expect(await storedAuthType(res.body.id)).toBe("credential");
});
it("infers password auth from a password", async () => {
const res = await request(app)
.post("/host/db/host")
.send({ ...base, password: "pw" });
expect(res.status).toBe(200);
expect(await storedAuthType(res.body.id)).toBe("password");
});
it("rejects a body that says nothing about authentication", async () => {
const res = await request(app).post("/host/db/host").send(base);
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/authType is required/);
});
it("takes the auth type from the user's host defaults", async () => {
const credentialId = await addCredential();
await saveLevel(
{ level: "user", userId: USER },
{
set: new Map([["core.auth", { authType: "credential", credentialId }]]),
unset: new Set(),
errors: {},
},
USER,
);
const res = await request(app).post("/host/db/host").send(base);
expect(res.status).toBe(200);
expect(await storedAuthType(res.body.id)).toBe("credential");
});
});
describe("host update authType", () => {
it("keeps the stored auth type when the update leaves it out", async () => {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
const res = await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(res.status).toBe(200);
expect(await storedAuthType(created.body.id)).toBe("password");
});
it("rejects an authType that is not a string", async () => {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
const res = await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, authType: { bad: true } });
expect(res.status).toBe(400);
});
});
describe("host.created", () => {
it("is published once when a host is created, and not on update", async () => {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
try {
const created = await request(app)
.post("/host/db/host")
.send({ ...base, authType: "password", password: "pw" });
expect(created.status).toBe(200);
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(seen).toEqual([{ hostId: created.body.id, userId: USER }]);
} finally {
off();
}
});
});
@@ -37,11 +37,14 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}));
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
let handler: RequestHandler;
let sshConfigHandler: RequestHandler;
const router = {
post: (path: string, ...handlers: RequestHandler[]) => {
if (path === "/bulk-import") handler = handlers.at(-1)!;
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
},
put: () => {},
patch: () => {},
@@ -202,3 +205,53 @@ it("does not save dependent hosts when importing their jump host fails", async (
);
expect(mocks.create).toHaveBeenCalledTimes(1);
});
function collectCreated() {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
return { seen, off };
}
it("publishes host.created once for each host a JSON import creates", async () => {
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
mocks.create.mockResolvedValueOnce({ id: 501 });
const { seen, off } = collectCreated();
try {
await importHosts([host, { ...host, ip: "192.0.2.2" }], true);
} finally {
off();
}
// The overwritten host already existed, so only the new one is announced.
expect(seen).toEqual([{ hostId: 501, userId: "user-1" }]);
});
it("publishes host.created once for each host an SSH config import creates", async () => {
mocks.create
.mockResolvedValueOnce({ id: 601 })
.mockResolvedValueOnce({ id: 602 });
const { seen, off } = collectCreated();
try {
await sshConfigHandler(
{
userId: "user-1",
body: {
content:
"Host a\n HostName 10.0.0.1\n User root\nHost b\n HostName 10.0.0.2\n User root\n",
},
} as unknown as Request,
{
json: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response,
vi.fn(),
);
} finally {
off();
}
expect(seen).toEqual([
{ hostId: 601, userId: "user-1" },
{ hostId: 602, userId: "user-1" },
]);
});
@@ -3,6 +3,7 @@ import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js"
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
isNonEmptyString,
keepsStoredSshAuth,
isOptionalBoolean,
@@ -515,3 +516,16 @@ describe("keepsStoredSshAuth", () => {
expect(keepsStoredSshAuth("none")).toBe(false);
});
});
describe("impliedSshAuthType", () => {
it("reads the auth type from the secret a create body carries", () => {
expect(impliedSshAuthType({ credentialId: 2, password: "pw" })).toBe(
"credential",
);
expect(impliedSshAuthType({ key: "-----BEGIN", password: "pw" })).toBe(
"key",
);
expect(impliedSshAuthType({ password: "pw" })).toBe("password");
expect(impliedSshAuthType({})).toBeUndefined();
});
});
@@ -0,0 +1,65 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import vm from "node:vm";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const linkedServer = require("../../../../electron/linked-server.cjs") as {
setLinkedServer: (config: unknown) => void;
};
const source = fs.readFileSync(
new URL("../../../../electron/main.cjs", import.meta.url),
"utf8",
);
const tls = source.slice(
source.indexOf("const INSECURE_MODE_VALUES"),
source.indexOf("function httpFetch("),
);
const ORIGIN = "https://termix.example";
const TERMINAL = "wss://termix.example/plugin-ws/ssh-terminal/terminal";
function load(link: Record<string, unknown> | null) {
linkedServer.setLinkedServer(link);
const context = vm.createContext({
URL,
process: { env: {} },
linkedServer,
});
vm.runInContext(tls, context);
return context as unknown as {
isInvalidCertificateAllowedForUrl: (url: string) => boolean;
getWebSocketOptions: (
url: string,
options?: Record<string, unknown>,
) => Record<string, unknown>;
};
}
describe("linked server certificate exception", () => {
it("covers WebSockets to the linked server", () => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true);
expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({
headers: {},
rejectUnauthorized: false,
});
});
it.each([
"ws://termix.example/relay",
"wss://termix.example:8443/relay",
"wss://elsewhere.example/relay",
])("keeps verifying %s", (url) => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
expect(main.isInvalidCertificateAllowedForUrl(url)).toBe(false);
});
it("keeps verifying when the link does not allow it", () => {
const main = load({ origin: ORIGIN, allowInvalidCertificate: false });
expect(main.getWebSocketOptions(TERMINAL)).toEqual({
rejectUnauthorized: true,
});
});
});
@@ -39,6 +39,48 @@ describe("linked server in Electron main", () => {
).toEqual({ Accept: "x" });
});
it("treats WebSocket upgrades to the linked server as the linked origin", () => {
linked.setLinkedServer({ origin: ORIGIN });
expect(linked.isLinkedOrigin(`${ORIGIN}/host/status`)).toBe(true);
expect(
linked.isLinkedOrigin("wss://termix.example/plugin-ws/ssh-terminal"),
).toBe(true);
expect(linked.isLinkedOrigin("wss://termix.example:443/")).toBe(true);
expect(linked.isLinkedOrigin("ws://termix.example/")).toBe(false);
expect(linked.isLinkedOrigin("wss://termix.example:8443/")).toBe(false);
expect(linked.isLinkedOrigin("wss://other.example/")).toBe(false);
expect(linked.isLinkedOrigin("not a url")).toBe(false);
linked.setLinkedServer({ origin: "http://termix.example:8080" });
expect(linked.isLinkedOrigin("ws://termix.example:8080/ws")).toBe(true);
expect(linked.isLinkedOrigin("wss://termix.example:8080/ws")).toBe(false);
expect(linked.isLinkedOrigin("ws://termix.example/ws")).toBe(false);
});
it("adds proxy headers and basic auth to the terminal WebSocket", () => {
linked.setLinkedServer({
origin: ORIGIN,
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
basicAuth: { username: "gate", password: "pw" },
});
expect(
linked.applyLinkHeaders(
"wss://termix.example/plugin-ws/ssh-terminal/terminal",
{ Upgrade: "websocket" },
),
).toEqual({
Upgrade: "websocket",
"CF-Access-Client-Id": "id",
Authorization: `Basic ${Buffer.from("gate:pw").toString("base64")}`,
});
expect(
linked.answerLogin({}, "wss://termix.example/plugin-ws/ssh-terminal"),
).toEqual({ username: "gate", password: "pw" });
expect(
linked.applyLinkHeaders("wss://other.example/", { Upgrade: "websocket" }),
).toEqual({ Upgrade: "websocket" });
});
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
linked.setLinkedServer({
origin: ORIGIN,
@@ -207,6 +207,7 @@ vi.mock("../../hosts/delete-host.js", () => ({
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
function manifest(capabilities: string[]) {
return {
@@ -436,6 +437,29 @@ describe("ctx.hosts", () => {
]);
});
it("publishes host.created for a host it creates", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
try {
await hosts.create({
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
});
} finally {
off();
}
expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]);
});
it("refuses a field the SDK does not take instead of dropping it", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
+335
View File
@@ -0,0 +1,335 @@
/**
* ctx.auth.issueUserToken mints a short-lived session core's own auth
* middleware accepts as the named user, under users:impersonate, audited and
* kept off account security routes. ctx.users.getLocale reads the user's UI
* language under users:read.
*/
import crypto from "crypto";
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
grants: [] as string[],
users: new Map<string, { id: string; username: string; isAdmin: boolean }>(),
sessions: new Map<string, Record<string, unknown>>(),
languages: new Map<string, string | null>(),
audits: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../database/db/index.js", () => ({
db: {},
getDb: () => ({}),
saveMemoryDatabaseToFile: vi.fn(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
state.grants.map((capability) => ({ pluginId, capability })),
}),
createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentSessionRepository: () => ({
create: async (session: Record<string, unknown>) => {
state.sessions.set(session.id as string, session);
return session;
},
findById: async (id: string) => state.sessions.get(id) ?? null,
touch: async () => {},
revoke: async (id: string) => state.sessions.delete(id),
}),
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
}),
createCurrentUserPreferenceRepository: () => ({
findByUserId: async (userId: string) =>
state.languages.has(userId)
? { userId, language: state.languages.get(userId) }
: null,
}),
createCurrentApiKeyRepository: () => ({}),
createCurrentTrustedDeviceRepository: () => ({}),
getCurrentSettingValue: () => null,
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
state.audits.push(entry);
},
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
vi.mock("../../utils/system-crypto.js", () => ({
SystemCrypto: {
getInstance: () => ({
getJWTSecret: async () => "a".repeat(64),
getEncryptionKey: async () => crypto.randomBytes(32),
}),
},
}));
vi.mock("../../utils/user-keys.js", () => ({
UserKeyManager: {
getInstance: () => ({
hasUserDEK: () => true,
tryGetUserDEK: () => null,
invalidate: vi.fn(),
}),
},
}));
vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({
adoptRecoveredDEK: vi.fn(async () => {}),
migratePasswordUserAtLogin: vi.fn(async () => true),
}));
const { createPluginContext, createPluginHandle } =
await import("../../plugins/ctx.js");
const { invalidatePluginPermissionCache } =
await import("../../plugins/permissions.js");
const { AuthManager } = await import("../../utils/auth-manager.js");
const { registerSecondFactor, resetAuthRegistryForTests } =
await import("../../auth/registry.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const authManager = AuthManager.getInstance();
const authenticate = authManager.createAuthMiddleware();
const requireAdmin = authManager.createAdminMiddleware();
function contextFor(capabilities: string[], grants = capabilities) {
state.grants = grants;
const manifest = {
id: "ai",
name: "AI",
version: "1.0.0",
capabilities,
} as never;
return createPluginContext(manifest, createPluginHandle("ai", {} as never));
}
type Middleware = typeof authenticate;
async function call(middleware: Middleware, token: string, url: string) {
const req = {
cookies: {},
headers: { authorization: `Bearer ${token}` },
method: "POST",
originalUrl: url,
url,
} as unknown as Parameters<Middleware>[0] & { userId?: string };
const res = {
statusCode: 200,
body: null as unknown,
status(code: number) {
res.statusCode = code;
return res;
},
json(body: unknown) {
res.body = body;
return res;
},
clearCookie() {
return res;
},
};
let passed = false;
await middleware(req, res as never, () => {
passed = true;
});
return { passed, status: res.statusCode, body: res.body, userId: req.userId };
}
beforeEach(() => {
state.users = new Map([
["u1", { id: "u1", username: "alice", isAdmin: false }],
["admin", { id: "admin", username: "root", isAdmin: true }],
]);
state.sessions.clear();
state.languages.clear();
state.audits = [];
invalidatePluginPermissionCache();
resetAuthRegistryForTests();
});
afterEach(() => {
vi.useRealTimers();
});
describe("ctx.auth.issueUserToken", () => {
it("mints a session core's auth middleware accepts as that user", async () => {
const ctx = contextFor(["users:impersonate"]);
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
purpose: "nightly report",
});
const result = await call(authenticate, token, "/host/db/host");
expect(result.passed).toBe(true);
expect(result.userId).toBe("u1");
const session = [...state.sessions.values()][0];
expect(session).toMatchObject({
userId: "u1",
deviceType: "plugin",
deviceInfo: "ai: nightly report",
expiresAt,
});
const payload = jwt.decode(token) as { exp: number; iat: number };
expect(payload.exp - payload.iat).toBe(300);
});
it("keeps the user's own permissions", async () => {
const ctx = contextFor(["users:impersonate"]);
const user = await ctx.auth.issueUserToken("u1", { purpose: "check" });
const admin = await ctx.auth.issueUserToken("admin", { purpose: "check" });
const refused = await call(requireAdmin, user.token, "/users/list");
expect(refused.passed).toBe(false);
expect(refused.status).toBe(403);
expect((await call(requireAdmin, admin.token, "/users/list")).passed).toBe(
true,
);
});
it("caps the lifetime at 900 seconds", async () => {
const ctx = contextFor(["users:impersonate"]);
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
purpose: "long task",
ttlSeconds: 86_400,
});
const payload = jwt.decode(token) as { exp: number; iat: number };
expect(payload.exp - payload.iat).toBe(900);
expect(new Date(expiresAt).getTime() - Date.now()).toBeLessThanOrEqual(
900_000,
);
});
it("stops working when the session expires", async () => {
const ctx = contextFor(["users:impersonate"]);
const { token } = await ctx.auth.issueUserToken("u1", {
purpose: "short",
ttlSeconds: 60,
});
vi.useFakeTimers({ now: Date.now() + 61_000 });
const result = await call(authenticate, token, "/host/db/host");
expect(result.passed).toBe(false);
expect(result.status).toBe(401);
});
it("audits every issue with the plugin, the user and the purpose", async () => {
const ctx = contextFor(["users:impersonate"]);
await ctx.auth.issueUserToken("u1", { purpose: "nightly report" });
const entry = state.audits.find(
(audit) => audit.action === "plugin_user_token_issued",
);
expect(entry).toMatchObject({
username: "plugin:ai",
resourceType: "user",
resourceId: "u1",
success: true,
});
expect(JSON.parse(entry!.details as string)).toEqual({
pluginId: "ai",
purpose: "nightly report",
ttlSeconds: 300,
});
});
it("is refused without a granted users:impersonate", async () => {
for (const ctx of [
contextFor([]),
contextFor(["users:impersonate"], []),
contextFor(["users:read"]),
]) {
await expect(
ctx.auth.issueUserToken("u1", { purpose: "x" }),
).rejects.toBeInstanceOf(PluginCapabilityError);
}
expect(state.sessions.size).toBe(0);
expect(state.audits.every((audit) => audit.success === false)).toBe(true);
});
it("is refused for a user that does not exist, or without a purpose", async () => {
const ctx = contextFor(["users:impersonate"]);
await expect(
ctx.auth.issueUserToken("ghost", { purpose: "x" }),
).rejects.toThrow(/does not exist/);
await expect(
ctx.auth.issueUserToken("u1", { purpose: " " }),
).rejects.toThrow(/purpose/);
expect(state.sessions.size).toBe(0);
expect(
state.audits.filter(
(audit) =>
audit.action === "plugin_user_token_issued" && !audit.success,
),
).toHaveLength(2);
});
it("cannot change how the user signs in or mint lasting credentials", async () => {
registerSecondFactor({ id: "totp", pluginId: "totp" } as never);
const ctx = contextFor(["users:impersonate"]);
const { token } = await ctx.auth.issueUserToken("admin", {
purpose: "ops",
});
for (const url of [
"/users/change-password",
"/users/link-external-to-password",
"/users/admin/reset-password",
"/users/admin/u1/second-factors",
"/plugin-api/totp/setup",
]) {
const result = await call(authenticate, token, url);
expect(result.passed, url).toBe(false);
expect(result.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
}
const apiKeys = await call(requireAdmin, token, "/users/api-keys");
expect(apiKeys.passed).toBe(false);
expect(apiKeys.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
expect(
(await call(authenticate, token, "/plugin-api/other/run")).passed,
).toBe(true);
});
it("leaves a normal login session free to use those routes", async () => {
const token = await authManager.generateJWTToken("u1", {
deviceType: "web",
deviceInfo: "Chrome",
});
expect(
(await call(authenticate, token, "/users/change-password")).passed,
).toBe(true);
});
});
describe("ctx.users.getLocale", () => {
it("returns the stored language, or null", async () => {
state.languages.set("u1", "zh_CN");
state.languages.set("admin", null);
const ctx = contextFor(["users:read"]);
await expect(ctx.users.getLocale("u1")).resolves.toBe("zh_CN");
await expect(ctx.users.getLocale("admin")).resolves.toBeNull();
await expect(ctx.users.getLocale("nobody")).resolves.toBeNull();
});
it("is refused without a granted users:read", async () => {
state.languages.set("u1", "zh_CN");
await expect(
contextFor(["users:read"], []).users.getLocale("u1"),
).rejects.toBeInstanceOf(PluginCapabilityError);
await expect(
contextFor(["users:impersonate"]).users.getLocale("u1"),
).rejects.toBeInstanceOf(PluginCapabilityError);
});
});
@@ -75,7 +75,8 @@ const UNGATED: Record<string, string> = {
"schedule.every": "the plugin's own timers",
"schedule.after": "the plugin's own timers",
"audit.record": "writes under the runtime's actor, never a plugin value",
asUser: "always audited as plugin_as_user",
asUser:
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
currentActor: "reads the actor, cannot set it",
"desktop.available": "a yes/no about the environment",
"http.baseUrl": "reads the request's public origin",
@@ -0,0 +1,75 @@
import { beforeEach, expect, it, vi } from "vitest";
const { listChanges } = vi.hoisted(() => ({ listChanges: vi.fn() }));
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
}));
vi.mock("../../../utils/logger.js", () => ({ syncLogger: { error: vi.fn() } }));
vi.mock("../../../utils/app-version.js", () => ({ getLocalVersion: vi.fn() }));
vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
vi.mock("../../../plugins/sync-registry.js", () => ({
getEntity: vi.fn(),
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
}));
vi.mock("../../../database/repositories/factory.js", () => ({}));
vi.mock("../../../sync/entities.js", () => ({
registerCoreSyncEntities: vi.fn(),
}));
vi.mock("../../../sync/records.js", () => ({
listChanges,
recordKey: vi.fn(),
}));
vi.mock("../../../sync/server/feed.js", () => ({
withUserLock: (_userId: string, fn: () => unknown) => fn(),
reconcileUser: async () => ({ wire: new Map() }),
}));
vi.mock("../../../sync/server/push.js", () => ({}));
vi.mock("../../../database/routes/branding-settings.js", () => ({}));
import router from "../../../sync/server/routes.js";
const route = router.stack.find(
(layer) => layer.route?.path === "/v2/changes",
)!.route!;
const handler = route.stack[route.stack.length - 1].handle;
beforeEach(() => {
vi.clearAllMocks();
listChanges.mockResolvedValue([]);
});
async function changes(query: Record<string, string>) {
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
await handler({ userId: "user-1", query } as never, res as never, vi.fn());
return res;
}
it("answers an empty page with the entity types past the newest change", async () => {
const cursor = String(Number.MAX_SAFE_INTEGER);
const res = await changes({ cursor, limit: "1000" });
expect(res.status).not.toHaveBeenCalled();
expect(res.json).toHaveBeenCalledWith({
changes: [],
nextCursor: Number.MAX_SAFE_INTEGER,
hasMore: false,
entityTypes: [{ type: "hosts", owner: "core", order: 10, readOnly: false }],
});
});
it("defaults a missing cursor and limit", async () => {
await changes({});
expect(listChanges).toHaveBeenCalledWith("user-1", 0, 1001, undefined);
});
it.each([
{ cursor: "abc" },
{ cursor: "-1" },
{ cursor: "1.5" },
{ cursor: "Infinity" },
{ limit: "1.5" },
{ limit: "-5" },
])("rejects a malformed query %o", async (query) => {
const res = await changes(query);
expect(res.status).toHaveBeenCalledWith(400);
expect(listChanges).not.toHaveBeenCalled();
});
+58
View File
@@ -15,6 +15,7 @@ const state = vi.hoisted(() => ({
server: null as null | { drizzle: unknown },
allow: true,
sharedHostIds: new Set<number>(),
paths: [] as string[],
}));
const SERVER_USER = "srv-user";
@@ -123,6 +124,7 @@ vi.mock("../../sync/client/http.js", async (importOriginal) => {
path: string,
init: { method?: string; body?: unknown } = {},
) => {
state.paths.push(path);
const previous = state.current;
state.current = state.server;
try {
@@ -161,6 +163,7 @@ const { createResolvers, writeWireRow } = await import("../../sync/store.js");
const { getEntity } = await import("../../plugins/sync-registry.js");
const { FieldCrypto } = await import("../../utils/field-crypto.js");
const { syncLogger } = await import("../../utils/logger.js");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
const app = express();
app.use(express.json({ limit: "5mb" }));
@@ -249,6 +252,7 @@ beforeEach(async () => {
resetSeqAllocator();
state.allow = true;
state.sharedHostIds.clear();
state.paths = [];
server = await side(SERVER_USER);
state.server = server.ctx;
desktops = [await side("desk-a"), await side("desk-b")];
@@ -317,6 +321,60 @@ describe("linking a desktop", () => {
expect(host.ip).toBe("new");
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
});
it("never asks for a cursor past what the server's seq column holds", async () => {
const [desk] = desktops;
await on(server, () => addHost(server, { name: "srv" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
const cursors = state.paths
.filter((path) => path.startsWith("/sync/v2/changes?"))
.map((path) =>
Number(new URLSearchParams(path.split("?")[1]).get("cursor")),
);
expect(cursors.length).toBeGreaterThan(0);
expect(Math.max(...cursors)).toBeLessThanOrEqual(2_147_483_647);
});
});
describe("a synced host", () => {
it("publishes host.created when it is written for the first time only", async () => {
const [desk] = desktops;
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
const wire = {
syncId: "host-new",
name: "new",
ip: "10.0.0.2",
port: 22,
username: "root",
authType: "password",
};
try {
await on(desk, async () => {
const resolvers = createResolvers(desk.userId);
const first = await writeWireRow(
getEntity("hosts")!,
desk.userId,
wire,
resolvers,
);
await writeWireRow(
getEntity("hosts")!,
desk.userId,
{ ...wire, name: "renamed" },
resolvers,
);
expect(seen).toEqual([{ hostId: first.id, userId: desk.userId }]);
});
} finally {
off();
}
});
});
describe("after linking", () => {
+96 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const createMock = vi.fn().mockResolvedValue(undefined);
@@ -60,6 +60,101 @@ describe("logAudit", () => {
});
});
describe("logAudit plugin routine coalescing", () => {
const WINDOW_MS = 10 * 60 * 1000;
let run = 0;
let details = "";
const routine = (overrides: Record<string, unknown> = {}) =>
logAudit({
userId: null,
username: "plugin:automations",
action: "plugin_as_user",
resourceType: "plugin",
resourceId: "automations",
details,
success: true,
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
createMock.mockResolvedValue(undefined);
vi.useFakeTimers();
// The coalescing memory is module state: a fresh key per test keeps them
// independent.
details = `automations ran background work as user-${++run}`;
});
afterEach(() => {
vi.useRealTimers();
});
it("writes one row for identical successful plugin_as_user repeats in the window", async () => {
await routine();
vi.advanceTimersByTime(WINDOW_MS - 1);
await routine();
await routine();
expect(createMock).toHaveBeenCalledOnce();
});
it("writes one row for identical successful plugin service calls in the window", async () => {
const service = {
action: "plugin_service_host_metrics_open",
resourceId: "host-metrics",
};
await routine(service);
await routine(service);
expect(createMock).toHaveBeenCalledOnce();
});
it("writes again once the window has passed", async () => {
await routine();
vi.advanceTimersByTime(WINDOW_MS);
await routine();
expect(createMock).toHaveBeenCalledTimes(2);
});
it("always writes failures", async () => {
await routine();
await routine({ success: false, errorMessage: "boom" });
await routine({ success: false, errorMessage: "boom" });
expect(createMock).toHaveBeenCalledTimes(3);
});
it("never coalesces non-plugin or non-routine actions", async () => {
const login = {
userId: "user-1",
username: "alice",
action: "login",
resourceType: "user",
};
await routine(login);
await routine(login);
const secretRead = { action: "plugin_secret_shared_read" };
await routine(secretRead);
await routine(secretRead);
const notPlugin = { username: "alice" };
await routine(notPlugin);
await routine(notPlugin);
expect(createMock).toHaveBeenCalledTimes(6);
});
it("does not coalesce entries with different details or resourceId", async () => {
await routine();
await routine({ details: `${details} (other)` });
await routine({ resourceId: "other-plugin" });
await routine({ userId: "user-2" });
expect(createMock).toHaveBeenCalledTimes(4);
});
});
describe("getRequestMeta", () => {
it("uses the proxy-validated Express IP", () => {
const req = {
+42
View File
@@ -35,7 +35,49 @@ export interface AuditLogParams {
errorMessage?: string;
}
// Plugin routine calls (a headless viewer's heartbeat through ctx.asUser and a
// service) repeat identically every few seconds; written each time they push
// real security entries out of the capped log. Only successful plugin_as_user
// and plugin_service_* lines are coalesced; failures and everything else are
// always written.
const ROUTINE_COALESCE_MS = 10 * 60 * 1000;
const ROUTINE_COALESCE_MAX_KEYS = 5000;
const routineLastWritten = new Map<string, number>();
function isCoalescedRoutine(params: AuditLogParams): boolean {
if (
!params.success ||
!params.username.startsWith("plugin:") ||
!(
params.action === "plugin_as_user" ||
params.action.startsWith("plugin_service_")
)
) {
return false;
}
const key = JSON.stringify([
params.userId,
params.username,
params.action,
params.resourceType,
params.resourceId,
params.details,
]);
const now = Date.now();
const last = routineLastWritten.get(key);
if (last !== undefined && now - last < ROUTINE_COALESCE_MS) return true;
if (routineLastWritten.size >= ROUTINE_COALESCE_MAX_KEYS) {
for (const [k, at] of routineLastWritten) {
if (now - at >= ROUTINE_COALESCE_MS) routineLastWritten.delete(k);
}
}
routineLastWritten.set(key, now);
return false;
}
export async function logAudit(params: AuditLogParams): Promise<void> {
if (isCoalescedRoutine(params)) return;
// Local storage is the source of truth and runs first; forwarding is a copy
// and must never delay or fail the audited operation.
void forwardAuditEntry(params).catch(() => {});
+79
View File
@@ -15,6 +15,7 @@ import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { inArray, isNotNull } from "drizzle-orm";
import type { ExternalSessionRef } from "../auth/types.js";
import { listSecondFactors } from "../auth/registry.js";
import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js";
@@ -44,6 +45,11 @@ interface JWTPayload {
pendingTOTP?: boolean;
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */
purpose?: "ws";
/**
* A plugin minted the session. Read from the session row on verify, never
* from the token, so a refreshed token stays marked.
*/
pluginSession?: boolean;
dataKeyWrap?: WrappedDataKey;
iat?: number;
exp?: number;
@@ -103,6 +109,26 @@ const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/];
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
/** sessions.device_type of a session a plugin minted with ctx.auth.issueUserToken. */
export const PLUGIN_SESSION_DEVICE_TYPE = "plugin";
// Routes that change how a user signs in or mint a credential that outlives
// the session. A plugin-minted session is refused on them, and on every
// route of a plugin that provides a second factor.
const PLUGIN_SESSION_BLOCKLIST = [
/^\/users\/(change-password|api-keys|link-external-to-password|unlink-external-from-password|admin\/reset-password)(\/|$)/,
/^\/users\/admin\/[^/]+\/second-factors(\/|$)/,
];
/** Whether a session a plugin minted may call this path. */
export function allowsPluginSession(path: string): boolean {
if (PLUGIN_SESSION_BLOCKLIST.some((pattern) => pattern.test(path))) {
return false;
}
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
}
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
/** Set by the plugin runtime: whether a running plugin opted in. */
@@ -407,6 +433,38 @@ class AuthManager {
} as jwt.SignOptions);
}
/**
* A session a plugin mints for a user, to call core's API as them. It is
* an ordinary session row, so it verifies, expires and shows up like a
* login, marked with the plugin and why.
*/
async issuePluginSession(
userId: string,
options: { pluginId: string; purpose: string; ttlSeconds: number },
): Promise<{ token: string; expiresAt: string }> {
const sessionId = nanoid();
const token = jwt.sign(
{ userId, sessionId } satisfies JWTPayload,
await this.systemCrypto.getJWTSecret(),
{ expiresIn: options.ttlSeconds },
);
const now = new Date();
const expiresAt = new Date(
now.getTime() + options.ttlSeconds * 1000,
).toISOString();
await createCurrentSessionRepository().create({
id: sessionId,
userId,
jwtToken: token,
deviceType: PLUGIN_SESSION_DEVICE_TYPE,
deviceInfo: `${options.pluginId}: ${options.purpose}`,
createdAt: now.toISOString(),
expiresAt,
lastActiveAt: now.toISOString(),
});
return { token, expiresAt };
}
async verifyJWTToken(
token: string,
options: { allowSocketTicket?: boolean } = {},
@@ -440,6 +498,8 @@ class AuthManager {
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
return null;
}
payload.pluginSession =
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
await this.migrateDataKeyFromPayload(payload);
} catch (dbError) {
@@ -787,6 +847,8 @@ class AuthManager {
});
}
if (this.refusePluginSession(req, res, payload)) return;
if (payload.sessionId) {
try {
const sessionRepository = createCurrentSessionRepository();
@@ -953,6 +1015,21 @@ class AuthManager {
return false;
}
/** Sends the 403 and returns true when a plugin session may not use this route. */
private refusePluginSession(
req: Request,
res: Response,
payload: JWTPayload,
): boolean {
const path = (req.originalUrl || req.url || "").split("?")[0];
if (!payload.pluginSession || allowsPluginSession(path)) return false;
res.status(403).json({
error: "A session a plugin issued cannot use this route",
code: "PLUGIN_SESSION_NOT_ALLOWED",
});
return true;
}
createDataAccessMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const authReq = req as AuthenticatedRequest;
@@ -1007,6 +1084,8 @@ class AuthManager {
});
}
if (this.refusePluginSession(req, res, payload)) return;
try {
const user = await createCurrentUserRepository().findById(
payload.userId,
+27 -2
View File
@@ -6,6 +6,8 @@ import "./ui/index.css";
import { ThemeProvider } from "@/components/theme-provider";
import "./ui/i18n/i18n";
import { isElectron } from "@/lib/electron";
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
import { Toaster } from "@/components/sonner";
import {
@@ -84,6 +86,15 @@ function FullscreenApp() {
}
}
/**
* A signed-out browser gets the normal login for a full-screen link and comes
* back to it afterwards. The desktop app and the embedded sign-in frames
* hand a login to their own shell, so they keep the bare view.
*/
function signsInForViewLink(): boolean {
return !isElectron() && !isEmbeddedAuthFrame();
}
function FullscreenAppGate() {
const { t } = useTranslation();
const [ready, setReady] = useState(false);
@@ -109,7 +120,16 @@ function FullscreenAppGate() {
if (!cancelled) setReady(true);
})
.catch(() => {
if (!cancelled) setAuthFailed(true);
if (cancelled) return;
if (signsInForViewLink()) {
rememberViewLink();
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
}
setAuthFailed(true);
});
return () => {
@@ -118,7 +138,7 @@ function FullscreenAppGate() {
}, []);
if (authFailed) {
return <FullscreenApp />;
return signsInForViewLink() ? <App /> : <FullscreenApp />;
}
if (!ready) {
@@ -181,6 +201,7 @@ function App() {
// Non-fatal: WebSocket connections will fall back to cookie auth
}
}
if (resumeViewLink()) return;
setPhase("loading-app");
await Promise.all([
startPluginRuntime()
@@ -262,6 +283,10 @@ function App() {
function handleLogin(u: string) {
loggingOutRef.current = false;
clearDesktopManualLogout();
if (resumeViewLink()) {
setPhase("loading-app");
return;
}
setAuthUsername(u);
setPhase("loading-app");
void (async () => {
+9 -14
View File
@@ -125,6 +125,7 @@ import type {
} from "@/types/ui-types";
import { applyAccentColor, applyFontSize } from "@/lib/theme";
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
import { useTheme } from "@/components/theme-provider";
import {
@@ -829,20 +830,11 @@ export function AppShell({
}
}
// Alt+Arrow moves between panes of the active split
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
const direction = (
{
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down",
} as Record<string, NavDirection>
)[e.code];
if (direction && splitActionsRef.current?.navigate(direction)) {
e.preventDefault();
return;
}
// Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split
const paneDirection = getPaneNavDirection(e);
if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) {
e.preventDefault();
return;
}
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
@@ -3134,6 +3126,9 @@ export function AppShell({
{renderSidebarPanels(rightRailView, false)}
</DockPanel>
)}
{/* Plugin panels pinned to the right edge, sized by the plugin */}
<ComponentSlot slotId="shell.rightEdge" />
</div>
</div>
+4 -5
View File
@@ -20,11 +20,10 @@ interface OriginResolvableHost {
* the desktop-wide default.
*
* `defaultRemote` makes a host left on Default resolve to "remote" instead of
* following the desktop-wide setting. Remote desktop passes it: it needs a
* guacd, which the desktop does not ship, so originating it here only works
* once the user has pointed Termix at one of their own. Making that opt-in
* per host keeps an upgrade from moving working connections onto a guacd
* that isn't there -- see Termix-SSH/Support#1240.
* following the desktop-wide setting. Remote desktop passes it when this
* desktop is set to use an external guacd, so a host is not moved onto a
* guacd that isn't there (see Termix-SSH/Support#1240). With the built-in
* engine it runs locally like SSH.
*/
export async function resolveConnectionOrigin(
host: OriginResolvableHost,
+3 -1
View File
@@ -1,4 +1,5 @@
import { getBasePath } from "./base-path";
import { localBackendPort } from "./electron";
type BrowserLocation = Pick<
Location,
@@ -16,7 +17,8 @@ export function getDatabaseTransferUrl(
{ electron, configuredServerUrl, location }: DatabaseTransferUrlOptions,
): string {
if (electron) {
const serverUrl = configuredServerUrl || "http://localhost:30001";
const serverUrl =
configuredServerUrl || `http://localhost:${localBackendPort()}`;
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
}
+18
View File
@@ -3,6 +3,7 @@ type ElectronWindow = Window &
IS_ELECTRON?: boolean;
electronAPI?: {
isElectron?: boolean;
backendPort?: number;
};
};
@@ -16,3 +17,20 @@ export function isElectron(): boolean {
return hasISElectron || hasElectronAPI || isElectronProp;
}
const DEFAULT_LOCAL_BACKEND_PORT = 30001;
/**
* The port of the desktop app's embedded backend. It moves off 30001 when
* another program holds that port.
*/
export function localBackendPort(): number {
if (typeof window === "undefined") return DEFAULT_LOCAL_BACKEND_PORT;
const port = (window as ElectronWindow).electronAPI?.backendPort;
return typeof port === "number" &&
Number.isInteger(port) &&
port > 0 &&
port < 65536
? port
: DEFAULT_LOCAL_BACKEND_PORT;
}
+25
View File
@@ -0,0 +1,25 @@
import { isMacPlatform } from "./tab-jump-hotkey";
export type PaneNavDirection = "left" | "right" | "up" | "down";
const ARROWS: Record<string, PaneNavDirection> = {
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down",
};
/**
* The split pane to move to for this key, if any. Cmd+Option+Arrow on macOS,
* where Option+Arrow moves the shell cursor by word, Alt+Arrow elsewhere.
*/
export function getPaneNavDirection(e: KeyboardEvent): PaneNavDirection | null {
const direction = ARROWS[e.code];
if (!direction || e.ctrlKey || e.shiftKey || !e.altKey) return null;
if (e.metaKey !== isMacPlatform()) return null;
return direction;
}
export function paneNavHotkeyLabel(): string {
return isMacPlatform() ? "Cmd+Option+Arrows" : "Alt+Arrows";
}
+24
View File
@@ -1,3 +1,5 @@
import type { TFunction } from "i18next";
/**
* Where a plugin's i18n keys resolve from.
*
@@ -19,3 +21,25 @@ export function pluginKey(
if (!key) return key;
return key.includes(":") ? key : `${pluginId}:${key}`;
}
/**
* A plugin's name in the UI language: "plugin.name" from its own locales,
* else the manifest's. A plugin whose strings are not loaded, like one only
* listed in a registry, shows the manifest name.
*/
export function pluginName(
t: TFunction,
plugin: { id: string; name: string },
): string {
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
}
/** Same as pluginName, for "plugin.description". */
export function pluginDescription(
t: TFunction,
plugin: { id: string; description?: string | null },
): string {
return t(pluginKey(plugin.id, "plugin.description"), {
defaultValue: plugin.description ?? "",
});
}
+2 -2
View File
@@ -19,7 +19,7 @@
import type { AxiosInstance } from "axios";
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { isElectron, localBackendPort } from "@/lib/electron";
import { getDeviceId } from "@/lib/device-id";
import { websocketAuthProtocols } from "@/lib/ws-auth";
import {
@@ -165,7 +165,7 @@ export async function pluginWsUrl(
if (isElectron()) {
return buildOriginWsUrl({
origin: options.origin ?? "local",
localPort: BACKEND_PORT,
localPort: localBackendPort(),
localPath: route,
remotePath: route,
});
+56
View File
@@ -0,0 +1,56 @@
/**
* Brings a signed-out full-screen `?view=` link back after sign-in. The link
* waits in sessionStorage, which survives a redirect login in the same tab,
* so no return path is ever sent to the server.
*/
const PENDING_VIEW_LINK_KEY = "termix-pending-view-link";
/** The value as a same-origin path with a `view` parameter, or null. */
export function toViewLinkPath(value: unknown): string | null {
if (typeof value !== "string") return null;
if (!value.startsWith("/") || value.startsWith("//")) return null;
let url: URL;
try {
url = new URL(value, window.location.origin);
} catch {
return null;
}
if (url.origin !== window.location.origin) return null;
if (!url.searchParams.get("view")) return null;
return `${url.pathname}${url.search}${url.hash}`;
}
export function rememberViewLink(): void {
const { pathname, search, hash } = window.location;
const path = toViewLinkPath(`${pathname}${search}${hash}`);
if (!path) return;
try {
sessionStorage.setItem(PENDING_VIEW_LINK_KEY, path);
} catch {
// Without storage the user just lands on the app after signing in.
}
}
/** The remembered link, cleared whether or not it is still valid. */
export function consumeViewLink(): string | null {
try {
const stored = sessionStorage.getItem(PENDING_VIEW_LINK_KEY);
sessionStorage.removeItem(PENDING_VIEW_LINK_KEY);
return toViewLinkPath(stored);
} catch {
return null;
}
}
/**
* Opens the remembered link, if there is one. The navigation waits a tick so
* history updates the login callback makes right after sign-in cannot cancel
* it.
*/
export function resumeViewLink(): boolean {
const path = consumeViewLink();
if (!path) return false;
setTimeout(() => window.location.replace(path), 0);
return true;
}
+2 -2
View File
@@ -8,7 +8,7 @@ import axios, {
import { toast } from "sonner";
import i18n from "i18next";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { isElectron, localBackendPort } from "@/lib/electron";
import { clearTermixSessionStorage } from "@/shell/TabContext";
import type { SSHHost } from "@/types/index";
@@ -603,7 +603,7 @@ function getApiUrl(path: string, defaultPort: number): string {
if (electronMode) {
// The desktop app always talks to its embedded backend. The server it
// may be linked to is reached through createRemoteOriginApiInstance.
return `http://localhost:${defaultPort}${path}`;
return `http://localhost:${localBackendPort()}${path}`;
} else if (devMode) {
if (!import.meta.env.VITE_API_HOST) {
return `/__termix_api/${defaultPort}${path}`;
+10 -7
View File
@@ -20,6 +20,7 @@ import {
recommendedChoices,
} from "../plugin-choices";
import { DocsLink } from "@/components/docs-link";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
const CATEGORY_ORDER = [
"Terminal",
@@ -79,8 +80,8 @@ export function PluginsStep({
}, [list, choices]);
const names = useMemo(
() => new Map(list?.plugins.map((p) => [p.id, p.name]) ?? []),
[list],
() => new Map(list?.plugins.map((p) => [p.id, pluginName(t, p)]) ?? []),
[list, t],
);
const changed = useMemo(() => {
@@ -260,7 +261,7 @@ export function PluginsStep({
/>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
{plugin.name}
{pluginName(t, plugin)}
{plugin.recommended && (
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
{t("onboarding.pluginsRecommended")}
@@ -268,7 +269,7 @@ export function PluginsStep({
)}
</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{plugin.description}
{pluginDescription(t, plugin)}
</span>
{forcedBy.has(plugin.id) && (
<span className="text-[10px] text-accent-brand">
@@ -281,7 +282,7 @@ export function PluginsStep({
</div>
<div
role="radiogroup"
aria-label={plugin.name}
aria-label={pluginName(t, plugin)}
className="flex shrink-0 border border-border"
>
{CHOICES.map((option) => (
@@ -335,9 +336,11 @@ export function PluginsStep({
}
/>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">{plugin.name}</span>
<span className="text-xs font-medium">
{pluginName(t, plugin)}
</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{plugin.description}
{pluginDescription(t, plugin)}
</span>
<span className="text-[10px] text-muted-foreground/80">
{t("onboarding.pluginsConsentNote")}
+2 -1
View File
@@ -2,6 +2,7 @@ import { CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { usePermissions } from "@/hooks/use-permissions";
import { pluginName } from "@/lib/plugin-i18n";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { usePluginStore } from "./plugin-store";
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
@@ -27,7 +28,7 @@ export function PluginViewPlaceholder({
usePluginStore();
const { has } = usePermissions();
const { status, owner } = unregisteredViewStatus(kind, viewId);
const name = owner?.summary.name ?? viewId;
const name = owner ? pluginName(t, owner.summary) : viewId;
if (status === "loading") {
return (
+3 -1
View File
@@ -10,6 +10,7 @@ import {
} from "react";
import { scopeHostFields } from "./host-scope";
import { useTranslation } from "react-i18next";
import { pluginName } from "@/lib/plugin-i18n";
import { getPluginRecord } from "./plugin-store";
const PluginScopeContext = createContext<string | null>(null);
@@ -27,7 +28,8 @@ function CrashNotice({
onRetry: () => void;
}) {
const { t } = useTranslation();
const name = getPluginRecord(pluginId)?.summary.name ?? pluginId;
const summary = getPluginRecord(pluginId)?.summary;
const name = summary ? pluginName(t, summary) : pluginId;
return (
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<span>{t("plugins.runtime.crashed", { name })}</span>
+10 -2
View File
@@ -25,6 +25,7 @@ import {
type RegistryListing,
} from "@/api/plugins-api";
import { useConfirm } from "@/components/surface/surface-scope";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
import type { ConsentRequest } from "./PluginConsentPrompt";
@@ -109,9 +110,16 @@ export function usePluginsManager() {
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
}, []);
// Named in the UI language once here, so lists, sorting, search, dialogs
// and toasts all show the same name.
const plugins = useMemo(
() => mergePlugins(installed, registry?.plugins ?? null),
[installed, registry],
() =>
mergePlugins(installed, registry?.plugins ?? null).map((plugin) => ({
...plugin,
name: pluginName(t, plugin),
description: pluginDescription(t, plugin),
})),
[installed, registry, t],
);
const run = useCallback(
+3 -7
View File
@@ -11,7 +11,7 @@ import { toast } from "sonner";
import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card";
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
import { pluginKey } from "@/lib/plugin-i18n";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import {
getPluginAdminSettings,
@@ -68,9 +68,7 @@ export function FeatureSettingsSection({
const { t } = useTranslation();
return (
<AccordionSection
label={t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
})}
label={pluginName(t, plugin)}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open}
onToggle={onToggle}
@@ -198,9 +196,7 @@ export function FeatureSettingsForm({
{!running && (
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", {
name: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
name: pluginName(t, plugin),
})}
</div>
)}
+5 -2
View File
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { DocsLink } from "@/components/docs-link";
import { PluginIcon } from "@/lib/plugin-icon";
import { pluginName } from "@/lib/plugin-i18n";
import { type PluginSummary } from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
import { usePluginScope } from "@/plugin-host/scope";
@@ -141,13 +142,15 @@ function HostPluginSection({
return (
<SectionCard
title={plugin.name}
title={pluginName(t, plugin)}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
action={<DocsLink href={plugin.docs} />}
>
{!running && (
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", { name: plugin.name })}
{t("settings.featureUnavailable", {
name: pluginName(t, plugin),
})}
</div>
)}
+2 -4
View File
@@ -18,7 +18,7 @@ import { EmptyState } from "@/components/empty-state";
import { isElectron } from "@/lib/electron";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { cn } from "@/lib/utils";
import { pluginKey } from "@/lib/plugin-i18n";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import type { Host } from "@/types/ui-types";
import type { UserPreferences } from "@/main-axios";
@@ -121,9 +121,7 @@ export function SettingsTab({
id: `plugin:${plugin.id}`,
band: "plugins",
labelKey: pluginKey(plugin.id, "plugin.name"),
label: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
label: pluginName(t, plugin),
icon: SettingsIcon,
pluginId: plugin.id,
searchKeys: fields.flatMap((field) =>
+2 -1
View File
@@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs";
import { pluginName } from "@/lib/plugin-i18n";
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
@@ -262,7 +263,7 @@ function InstanceUpdates() {
{betaPlugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={plugin.name}
label={pluginName(t, plugin)}
description={
plugin.channel === "beta"
? `v${plugin.version}`
+2 -1
View File
@@ -7,6 +7,7 @@
*/
import { PluginIcon } from "@/lib/plugin-icon";
import { pluginKey } from "@/lib/plugin-i18n";
import {
hostEditorSectionList,
registerHostEditorSection,
@@ -95,7 +96,7 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
pluginId: plugin.id,
group: placement?.group ?? host.editorGroup ?? "top",
order: placement?.order ?? host.editorOrder ?? 100,
labelKey: plugin.name,
labelKey: pluginKey(plugin.id, "plugin.name"),
label: plugin.name,
// Nothing but the plugin's host settings, so it works for defaults too.
defaults: ownSections.has(plugin.id) ? "only" : true,
+2 -1
View File
@@ -18,6 +18,7 @@ import {
type SplitPresetId,
} from "./split-presets";
import { MAX_PANES, type PaneEdge } from "./split-tree";
import { paneNavHotkeyLabel } from "@/lib/pane-nav-hotkey";
function NodeThumb({ node }: { node: LayoutNode }) {
if (node.kind === "pane") {
@@ -129,7 +130,7 @@ export function SplitLayoutMenu({
<div className="flex flex-col gap-1 px-2 pb-1.5 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-2">
<span>{t("splitScreen.shortcutNavigate")}</span>
<kbd className="font-mono text-[10px]">Alt+Arrows</kbd>
<kbd className="font-mono text-[10px]">{paneNavHotkeyLabel()}</kbd>
</div>
<div className="flex items-center justify-between gap-2">
<span>{t("splitScreen.zoom")}</span>
+1 -1
View File
@@ -730,7 +730,7 @@ export function HostEditor({
<EditorSection
key={id}
id={id}
label={section.label ?? t(section.labelKey)}
label={t(section.labelKey, { defaultValue: section.label })}
>
<LazySection>
<Section
+6 -6
View File
@@ -45,7 +45,7 @@ export interface HostEditorSectionDef {
pluginId?: string;
group: "top" | "ssh";
labelKey: string;
/** Already-translated label, used instead of labelKey when set. */
/** Shown when labelKey has no translation. */
label?: string;
icon?: ComponentType<{ className?: string }>;
/** Position among the group's tabs, core ones included. */
@@ -88,7 +88,7 @@ function sectionVisible(
function mergeByOrder(
core: (HostTab & { order: number })[],
group: "top" | "ssh",
t: (key: string) => string,
t: (key: string, options?: { defaultValue?: string }) => string,
protocols: Record<string, boolean> | undefined,
defaultsMode = false,
): HostTab[] {
@@ -104,7 +104,7 @@ function mergeByOrder(
const Icon = section.icon;
return {
id: section.id,
label: section.label ?? t(section.labelKey),
label: t(section.labelKey, { defaultValue: section.label }),
icon: Icon ? <Icon className="size-3" /> : null,
order: section.order ?? 1000,
};
@@ -119,7 +119,7 @@ function mergeByOrder(
* `visible`; without, every registered top tab is listed.
*/
export function makeHostTabs(
t: (key: string) => string,
t: (key: string, options?: { defaultValue?: string }) => string,
protocols?: Record<string, boolean>,
defaultsMode = false,
): HostTab[] {
@@ -146,7 +146,7 @@ export function makeHostTabs(
}
export function makeHostSshSubTabs(
t: (key: string) => string,
t: (key: string, options?: { defaultValue?: string }) => string,
protocols?: Record<string, boolean>,
defaultsMode = false,
): HostTab[] {
@@ -167,7 +167,7 @@ export function makeHostSshSubTabs(
}
export function makeCredentialTabs(
t: (key: string) => string,
t: (key: string, options?: { defaultValue?: string }) => string,
): CredentialTab[] {
return [
{
+2 -1
View File
@@ -55,6 +55,7 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types";
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
import { withLiveStatus } from "@/sidebar/live-host-status";
import { useServerStatus } from "@/lib/ServerStatusContext";
import { pluginName } from "@/lib/plugin-i18n";
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import type {
@@ -816,7 +817,7 @@ export function HostsPanel({
}
onSelect={(e) => e.preventDefault()}
>
{plugin.name}
{pluginName(t, plugin)}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
+3 -1
View File
@@ -12,6 +12,7 @@ import {
import { Button } from "@/components/button";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { pluginName } from "@/lib/plugin-i18n";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
UI_AREA_KEYS,
@@ -67,9 +68,10 @@ export function InterfacePresetSettings({
)
.map((area) => {
const pluginId = area.slice("plugin:".length);
const summary = pluginStore.records.get(pluginId)?.summary;
return {
key: area,
label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId,
label: summary ? pluginName(t, summary) : pluginId,
};
});
return [...core, ...plugins];
+3 -2
View File
@@ -11,6 +11,7 @@ import {
import { updatePluginHostSettings } from "@/api/plugins-api";
import { resetHostToDefaults } from "@/api/host-defaults-api";
import { PluginIcon } from "@/lib/plugin-icon";
import { pluginName } from "@/lib/plugin-i18n";
import { usePluginHostSections } from "@/settings/HostPluginSections";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
@@ -1284,7 +1285,7 @@ export function SidebarTree({
value
? "hosts.enablePluginFeature"
: "hosts.disablePluginFeature",
{ name: plugin.name },
{ name: pluginName(t, plugin) },
)}
</DropdownMenuItem>
)),
@@ -1307,7 +1308,7 @@ export function SidebarTree({
{ id: "core", label: t("hostDefaults.resetCore") },
...hostSettingPlugins.map((plugin) => ({
id: plugin.id,
label: plugin.name,
label: pluginName(t, plugin),
})),
].map((option) => (
<DropdownMenuItem
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it } from "vitest";
import { localBackendPort } from "@/lib/electron";
function setElectronAPI(api: unknown) {
(window as unknown as { electronAPI?: unknown }).electronAPI = api;
}
afterEach(() => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
describe("localBackendPort", () => {
it("uses the port the desktop app picked", () => {
setElectronAPI({ backendPort: 41234 });
expect(localBackendPort()).toBe(41234);
});
it("falls back to 30001", () => {
expect(localBackendPort()).toBe(30001);
setElectronAPI({ backendPort: Number.NaN });
expect(localBackendPort()).toBe(30001);
setElectronAPI({ backendPort: 70000 });
expect(localBackendPort()).toBe(30001);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getPaneNavDirection,
paneNavHotkeyLabel,
} from "../../lib/pane-nav-hotkey";
function keyEvent(init: Partial<KeyboardEvent>): KeyboardEvent {
return {
code: "ArrowLeft",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...init,
} as KeyboardEvent;
}
afterEach(() => vi.unstubAllGlobals());
describe("pane-nav-hotkey", () => {
it("uses Alt+Arrow outside macOS", () => {
vi.stubGlobal("navigator", { platform: "Win32" });
expect(getPaneNavDirection(keyEvent({ altKey: true }))).toBe("left");
expect(
getPaneNavDirection(keyEvent({ code: "ArrowDown", altKey: true })),
).toBe("down");
expect(
getPaneNavDirection(keyEvent({ altKey: true, metaKey: true })),
).toBeNull();
expect(paneNavHotkeyLabel()).toBe("Alt+Arrows");
});
it("leaves Option+Arrow to the shell on macOS", () => {
vi.stubGlobal("navigator", { platform: "MacIntel" });
expect(getPaneNavDirection(keyEvent({ altKey: true }))).toBeNull();
expect(
getPaneNavDirection(
keyEvent({ code: "ArrowRight", altKey: true, metaKey: true }),
),
).toBe("right");
expect(paneNavHotkeyLabel()).toBe("Cmd+Option+Arrows");
});
it("ignores other keys and modifiers", () => {
vi.stubGlobal("navigator", { platform: "Win32" });
expect(
getPaneNavDirection(keyEvent({ code: "KeyA", altKey: true })),
).toBeNull();
expect(
getPaneNavDirection(keyEvent({ altKey: true, shiftKey: true })),
).toBeNull();
expect(getPaneNavDirection(keyEvent({}))).toBeNull();
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import i18next from "i18next";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
async function zhCN() {
const i18n = i18next.createInstance();
await i18n.init({
lng: "zh-CN",
fallbackLng: "en",
fallbackNS: "translation",
resources: {
"zh-CN": {
translation: { common: { save: "保存" } },
"host-metrics": {
plugin: { name: "主机指标", description: "查看主机负载" },
},
"no-description": { plugin: { name: "无描述" } },
},
},
});
return i18n.t;
}
describe("pluginName", () => {
it("reads the name from the plugin's own namespace", async () => {
const t = await zhCN();
expect(pluginName(t, { id: "host-metrics", name: "Host Metrics" })).toBe(
"主机指标",
);
});
it("falls back to the manifest name when the plugin has no strings", async () => {
const t = await zhCN();
expect(pluginName(t, { id: "not-loaded", name: "Not Loaded" })).toBe(
"Not Loaded",
);
});
});
describe("pluginDescription", () => {
it("reads the description from the plugin's own namespace", async () => {
const t = await zhCN();
expect(
pluginDescription(t, {
id: "host-metrics",
description: "See host load",
}),
).toBe("查看主机负载");
});
it("falls back to the manifest description, or nothing", async () => {
const t = await zhCN();
expect(
pluginDescription(t, { id: "no-description", description: "Plain" }),
).toBe("Plain");
expect(pluginDescription(t, { id: "not-loaded" })).toBe("");
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { consumeViewLink, toViewLinkPath } from "@/lib/view-link-resume";
describe("toViewLinkPath", () => {
it.each([
["/?view=example-plugin.request&request=123", true],
["/termix/?view=example-plugin.request#top", true],
["//evil.com/?view=x", false],
["/\\evil.com/?view=x", false],
["https://evil.com/?view=x", false],
["/foo", false],
["/?view=", false],
["not a url", false],
])("%s -> %s", (value, accepted) => {
expect(toViewLinkPath(value)).toBe(accepted ? value : null);
});
});
describe("consumeViewLink", () => {
it("clears the stored link even when it is invalid", () => {
sessionStorage.setItem("termix-pending-view-link", "//evil.com/?view=x");
expect(consumeViewLink()).toBeNull();
expect(sessionStorage.getItem("termix-pending-view-link")).toBeNull();
});
});
+186
View File
@@ -0,0 +1,186 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const KEY = "termix-pending-view-link";
const mocks = vi.hoisted(() => ({
rendered: null as ReactNode,
isElectron: vi.fn(() => false),
getStoredAuth: vi.fn(),
getUserInfo: vi.fn(),
fetchGuestViews: vi.fn(),
startPluginRuntime: vi.fn(async () => {}),
standaloneViewFor: vi.fn(),
}));
vi.mock("react-dom/client", () => ({
createRoot: () => ({
render: (node: ReactNode) => {
mocks.rendered = node;
},
}),
}));
vi.mock("@/lib/client-cache-version", () => ({
prepareClientCacheVersion: () => Promise.resolve(),
}));
vi.mock("@/i18n/i18n", () => ({}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/components/theme-provider", () => ({
ThemeProvider: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/contexts/BrandingContext", () => ({
BrandingProvider: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/contexts/UiPreferencesContext", () => ({
UiPreferencesProvider: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/sonner", () => ({ Toaster: () => null }));
vi.mock("@/hooks/use-service-worker", () => ({ useServiceWorker: () => {} }));
vi.mock("@/lib/electron-wheel-zoom", () => ({
installElectronWheelZoomGuard: () => {},
}));
vi.mock("@/lib/electron", () => ({ isElectron: mocks.isElectron }));
vi.mock("@/lib/embedded-server-status", () => ({
getEmbeddedServerFailure: async () => null,
}));
vi.mock("@/auth/Auth", () => ({
Auth: ({ onLogin }: { onLogin: (username: string) => void }) => (
<button onClick={() => onLogin("alice")}>sign in</button>
),
getStoredAuth: mocks.getStoredAuth,
clearStoredAuth: vi.fn(),
markDesktopManualLogout: vi.fn(),
clearDesktopManualLogout: vi.fn(),
}));
vi.mock("@/main-axios", () => ({
getUserInfo: mocks.getUserInfo,
getCurrentToken: vi.fn(async () => null),
appReadyPromise: Promise.resolve(),
}));
vi.mock("@/plugin-host/loader", () => ({
fetchGuestViews: mocks.fetchGuestViews,
startPluginRuntime: mocks.startPluginRuntime,
stopPluginRuntime: vi.fn(async () => {}),
}));
vi.mock("@/plugin-host/plugin-store", () => ({
settledPromise: async () => {},
}));
vi.mock("@/plugin-host/shell-bridge", () => ({ resetShellBridge: vi.fn() }));
vi.mock("@/hooks/use-permissions", () => ({
preloadPermissions: async () => {},
}));
vi.mock("@/api/open-tabs-api", () => ({
getUserPreferences: async () => undefined,
}));
vi.mock("@/shell/tab-registry", () => ({
standaloneViewFor: mocks.standaloneViewFor,
}));
vi.mock("@/plugin-host/PluginViewPlaceholder", () => ({
PluginViewPlaceholder: ({ viewId }: { viewId: string }) => (
<div>placeholder:{viewId}</div>
),
}));
vi.mock("@/onboarding/startup", () => ({
resolveStartupOnboarding: async () => null,
}));
vi.mock("@/AppShell", () => ({ AppShell: () => <div>app shell</div> }));
vi.mock("@/onboarding/OnboardingStage", () => ({
OnboardingStage: () => null,
}));
const originalLocation = window.location;
let replace: ReturnType<typeof vi.fn>;
/** Loads the entry point fresh, the way a page load would. */
async function openPage(url: string) {
window.history.replaceState({}, "", url);
vi.resetModules();
mocks.rendered = null;
await import("../../main");
await vi.waitFor(() => expect(mocks.rendered).not.toBeNull());
render(<>{mocks.rendered}</>);
}
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
localStorage.clear();
mocks.isElectron.mockReturnValue(false);
mocks.getStoredAuth.mockReturnValue(null);
mocks.getUserInfo.mockRejectedValue({ status: 401 });
mocks.fetchGuestViews.mockResolvedValue(["example-plugin.guest"]);
mocks.standaloneViewFor.mockImplementation((view: string) => ({
standalone: () => <div>standalone:{view}</div>,
}));
replace = vi.fn();
Object.defineProperty(window, "location", {
configurable: true,
value: {
get origin() {
return originalLocation.origin;
},
get pathname() {
return originalLocation.pathname;
},
get search() {
return originalLocation.search;
},
get hash() {
return originalLocation.hash;
},
replace,
},
});
});
afterEach(() => {
cleanup();
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
});
describe("full-screen view links", () => {
it("shows the login when signed out and returns to the link after it", async () => {
await openPage("/?view=example-plugin.request&request=123");
const signIn = await screen.findByText("sign in");
expect(sessionStorage.getItem(KEY)).toBe(
"/?view=example-plugin.request&request=123",
);
fireEvent.click(signIn);
await vi.waitFor(() =>
expect(replace).toHaveBeenCalledWith(
"/?view=example-plugin.request&request=123",
),
);
expect(sessionStorage.getItem(KEY)).toBeNull();
});
it("keeps the bare view in the desktop app", async () => {
mocks.isElectron.mockReturnValue(true);
mocks.standaloneViewFor.mockReturnValue(undefined);
await openPage("/?view=example-plugin.request");
expect(
await screen.findByText("placeholder:example-plugin.request"),
).toBeTruthy();
expect(screen.queryByText("sign in")).toBeNull();
expect(sessionStorage.getItem(KEY)).toBeNull();
});
it("serves a guest view without a session or a login", async () => {
await openPage("/?view=example-plugin.guest&room=1");
expect(
await screen.findByText("standalone:example-plugin.guest"),
).toBeTruthy();
expect(mocks.getUserInfo).not.toHaveBeenCalled();
expect(sessionStorage.getItem(KEY)).toBeNull();
});
});
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import type { OnboardingPluginList } from "@/api/plugins-api";
const api = vi.hoisted(() => ({
@@ -186,4 +188,38 @@ describe("PluginsStep", () => {
expect(ok).toBe(true);
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1);
});
it("shows plugin names and descriptions in the UI language", async () => {
const i18n = i18next.createInstance();
await i18n.use(initReactI18next).init({
lng: "zh-CN",
fallbackNS: "translation",
resources: {
"zh-CN": {
translation: {},
term: { plugin: { name: "终端", description: "终端插件" } },
stats: { plugin: { name: "统计" } },
},
},
});
render(
<I18nextProvider i18n={i18n}>
<PluginsStep
mode="full"
isAdmin
isDesktop={false}
shellReady={false}
setCanContinue={() => {}}
setBeforeNext={() => {}}
/>
</I18nextProvider>,
);
expect(await screen.findByText("终端")).toBeTruthy();
expect(screen.getByText("终端插件")).toBeTruthy();
expect(screen.getByRole("radiogroup", { name: "终端" })).toBeTruthy();
expect(screen.getByText("统计")).toBeTruthy();
expect(screen.getByText("stats plugin")).toBeTruthy();
expect(screen.getByText("BASE")).toBeTruthy();
expect(screen.getByText("base plugin")).toBeTruthy();
});
});
@@ -35,9 +35,17 @@ vi.mock("@/api/plugins-api", async (importOriginal) => ({
vi.mock("@/components/surface/surface-scope", () => ({
useConfirm: () => vi.fn(async () => true),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const i18n = vi.hoisted(() => {
const strings: Record<string, string> = {
"sample-plugin:plugin.name": "示例",
"sample-plugin:plugin.description": "一个示例插件",
};
return {
t: (key: string, options?: { defaultValue?: string }) =>
strings[key] ?? options?.defaultValue ?? key,
};
});
vi.mock("react-i18next", () => ({ useTranslation: () => i18n }));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
import { usePluginsManager } from "@/plugins/use-plugins-manager";
@@ -69,3 +77,33 @@ it("waits for local consent and sends the reviewed version and capabilities", as
{ version: "1.0.0", capabilities: ["kv:own"] },
);
});
it("names plugins in the UI language, falling back to the manifest", async () => {
api.getPluginRegistry.mockResolvedValueOnce({
plugins: [
{
id: "browse-only",
name: "Browse Only",
description: "Not installed",
author: "someone",
category: "Tools",
latestVersion: "1.0.0",
versions: [],
bundled: false,
},
],
managedByServer: true,
registry: {},
} as never);
const { result } = renderHook(() => usePluginsManager());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(
result.current.plugins.map(({ name, description }) => ({
name,
description,
})),
).toEqual([
{ name: "示例", description: "一个示例插件" },
{ name: "Browse Only", description: "Not installed" },
]);
});
@@ -33,7 +33,10 @@ vi.mock("@/api/plugins-api", () => ({
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string, options?: { defaultValue?: string }) =>
options?.defaultValue ?? key,
}),
}));
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
@@ -75,9 +78,10 @@ describe("syncHostFeatureTabs", () => {
"feature:docker",
"feature:warpgate",
]);
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe(
"Docker",
);
expect(getHostEditorSection(hostFeatureTabId("docker"))).toMatchObject({
labelKey: "docker:plugin.name",
label: "Docker",
});
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
});