Compare commits

..
Author SHA1 Message Date
66e8ce6df7 v26.10.2 (#1672)
* fix: keep concurrent ssh pool connects from overwriting each other (#1619)

* fix: stop auto-accepting changed jump host keys (#1620)

A jump hop whose key no longer matched the pinned fingerprint was
silently accepted and the pin overwritten. Background status probes
reconnect to jump hosts every cycle, so a MITM on that hop got the
credentials with no user interaction. Changed jump host keys now go
through the same path as any other host: prompt when there is a
terminal socket, otherwise refuse.

* fix(electron): validate external login URL scheme and callback port (#1621)

* fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622)

The consent check read the first manifest.json with a hand-written tar
reader, while the unpack keeps the last one and also follows pax and
long-name headers. An archive with two manifests could pass the check
with one capability list and get granted another. Read the manifest
with node-tar, count only plain files, and refuse archives that hold
more than one manifest.json.

* fix: only trust the server's origin in the desktop login frame (#1624)

The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it.

The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent.

* fix: check trusted proxy login against nginx's peer, not nginx (#1625)

The backend only sees the bundled nginx on loopback, so the allowlist
could only work by trusting 127.0.0.1, which let any client that reached
nginx log in as anyone by sending the identity headers. nginx now passes
its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and
loopback connections are judged by that address instead.

* fix: keep foreign keys on during user data import (#1626)

Import credentials before hosts and remap the file's credential ids to
the ones created here, so hosts and protocol logins no longer point at
another server's ids. With rows written in dependency order, the import
no longer turns off SQLite foreign keys on the shared connection. The
uploaded file is now removed on every exit path.

* fix: enforce unique usernames (#1628)

* fix: enforce unique usernames

Registration checked for a taken name, hashed the password, then
inserted, with nothing in the schema enforcing uniqueness. Concurrent
sign-ups could create two accounts with one name, after which login,
password reset, proxy login and admin lookups by username picked
between them arbitrarily.

Add a unique index on users.username for every dialect. Existing
duplicates are renamed (later rows get a -<id> suffix, the oldest keeps
the name) rather than deleted. Registration and admin user creation now
return the same 409 when the insert loses the race.

* fix: avoid username collisions during migration

* fix: don't auto-enable synced plugins signed only by a server-sent key (#1623)

* fix: don't auto-enable synced plugins signed only by a server-sent key

Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust.

* fix: enforce local plugin trust across remote source labels

* fix: require positive local trust for synced plugin keys

* fix(desktop): harden external editor and session cookie IPC (#1627)

* fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie

open-external-editor now launches only the editor saved from a file dialog
and refuses to hand executable file types to the OS opener. The unused
get-session-cookie and wait-session-cookie channels, which returned the
httpOnly jwt, are removed.

* fix: validate setting keys before editor authorization

* fix(sidebar): sort hosts by IP address numerically (#1629)

* fix(desktop): find spawn-helper in universal macOS builds (#1630)

The universal build ships app-arm64.asar and app-x64.asar instead of
app.asar, so node-pty's helper path rewrite never pointed at the
unpacked copy and every local terminal failed with posix_spawnp failed.

* fix(sync): send shared hosts with text columns in their stored form (#1631)

resolveHostById turns forceKeyboardInteractive into a boolean, and the
shared-host export passed it on as is. A desktop could not bind the boolean
into the text column, so every shared host failed to apply.

* fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633)

The vault plugin renames vault_profiles to p_vault_profiles, and SQLite
rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then
dropped p_vault_profiles and left ssh_data pointing at a missing table, so
creating, cloning and deleting hosts failed with "no such table:
main.p_vault_profiles". Postgres and MySQL refused the uninstall instead.

The column was already copied into the vault plugin's host settings by 2.9,
so retire it on every engine. The SQLite boot drop also repairs databases
already in the broken state; MySQL drops the column's foreign key first.

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632)

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own

* fix(sync): switch off an untrusted mirrored plugin when the server updates it

* fix: require local consent for mirrored plugin activation

* docs: describe consent fields on the plugin state endpoint

* fix(i18n): keep Indonesian technical terms consistent (#1636)

Use the English terms Indonesian users know for tunnel, snippet, deploy,
file manager, remote desktop, jump host, passphrase, sidebar, toolbar,
endpoint, browser and SSH agent, instead of mixing literal translations
(terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the
English words. Match the split shortcuts to the split screen menu.

Also fix the user data export description, which said secrets are
encrypted while the English says they are decrypted.

* fix(settings): leave user manage view when switching admin sections (#1649)

The admin panel stays mounted across Settings pages, so an open user
manage view kept replacing every other admin section until Back to users
was clicked. Reset it whenever the selected section changes.

* fix(sync): don't delete a local row when the server can't send its copy (#1650)

* fix(sync): don't delete a local row when the server can't send its copy

A push conflict on a live record whose server row failed to serialize
came back as row: null, deleted: false, and the desktop treated that as
a delete. The server now rejects that op as unavailable, the desktop
never deletes on a conflict without a row, and serialization failures
are logged.

* fix(sync): retry temporarily unavailable records

* fix: smooth host editor jumps to lower sections

* chore: add changelog entries for 26.10.2 fixes

* fix: stop building ssh2 native crypto in the docker image (#1644)

* chore: test that an update keeps a changed auth type (#1601)

* feat: option to hide the sidebar after opening a host (#1652)

* feat: sort the dashboard host status card (Plugin-Host-Metrics#6)

* fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2)

* fix: say the server is down and keep retrying instead of a database error (#1637)

* fix: show why a client tunnel failed to start (Plugin-Tunnels#2)

* fix(sdk): skip comment-only chunks when splitting migrations

* feat: setting to let the hover side menu push content again

* fix: use the vite proxy for plugin sockets on any dev port

* feat(sdk): let a plugin raise its public route rate limit

* chore: update translations

* chore: add the overview video to the readme

* chore: update the overview video and add a play button to its thumbnail

* feat: plugin builds bundle worker entries and import wasm and fonts as bytes

* feat: plugin packs include LICENSE and NOTICE

* chore: drop guacd from compose files and dev runner

* chore: update remote desktop origin comment for the built-in engine

* 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>

* 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.

* chore: changelog for merged plugin name and host auth type PRs

* fix: move macOS split pane navigation to Cmd+Option+Arrow

* fix: pick a free port for the desktop backend when 30001 is taken (#1663)

* feat: build the Windows desktop app for ARM64 (#1615)

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* chore: changelog for merged sync, audit, event and desktop PRs

* feat: add shell.rightEdge slot so plugin panels can push the page over

* chore: pin bundled plugins to the latest releases

* chore: lint, format, and bump version to 26.10.2

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zkl205305@gmail.com>
Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
2026-10-18 00:34:50 -05:00
LukeGus 3517b2f0ed chore: update the overview video and add a play button to its thumbnail 2026-10-16 18:20:46 -05:00
LukeGus 220f5c8676 chore: add the overview video to the readme 2026-10-16 16:23:59 -05:00
LukeGus 4dfad3efb3 chore: match banner logo to app icon 2026-10-14 22:04:11 -05:00
e152bacc7c fix(sw): stamp __TERMIX_SW_BASE_PATH__ and cache version into sw.js at build time (#1618)
* fix(sw): stamp __TERMIX_SW_BASE_PATH__ and version into sw.js at build time

vite copies public/ verbatim, so the placeholder in public/sw.js reached
dist/sw.js untouched. The service worker then requested
__TERMIX_SW_BASE_PATH__/favicon.ico and friends, its install promise
rejected, and it never activated - while a worker from an older release
kept serving its cached frontend against a newer backend.

scripts/stamp-sw-build-info.cjs now runs right after vite build and
replaces both placeholders in dist/sw.js:

- __TERMIX_SW_BASE_PATH__ -> the base path the build used (VITE_BASE_PATH,
  or root-relative by default, matching vite.config.ts)
- __TERMIX_CACHE_VERSION__ -> package.json version, so CACHE_NAME changes
  per release and the activate handler evicts the previous build's cache

Closes #1617

* chore: format stamp-sw-build-info.cjs to prettier style

* fix: preserve runtime service worker base paths

---------

Co-authored-by: inontz <inontz@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
2026-10-14 08:17:40 +08:00
ShadyNabil8andClaude Opus 5.5 8d9c83b0f6 fix: skip existing hosts on JSON and SSH config import (#1607)
* fix: skip existing hosts on JSON and SSH config import

Import (skip existing) only loaded existing hosts when overwriting, so
every host was created again. Always load them, and skip a match unless
overwrite is set. A skipped host still resolves as a jump host for the
hosts imported after it.

Fixes #1606

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: use .js extension for test import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:13:20 +08:00
99 changed files with 2809 additions and 350 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+33 -4
View File
@@ -143,12 +143,12 @@ jobs:
- name: Build Windows (All Architectures) - name: Build Windows (All Architectures)
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
# The combined x64+ia32 installer is never uploaded. # The combined multi-arch installer is never uploaded.
- name: Fix Windows update files - 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 - name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -166,6 +166,14 @@ jobs:
path: release/termix_windows_ia32_nsis.exe path: release/termix_windows_ia32_nsis.exe
retention-days: 30 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 - name: Upload Windows x64 MSI Installer
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none' if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
@@ -182,6 +190,14 @@ jobs:
path: release/termix_windows_ia32_msi.msi path: release/termix_windows_ia32_msi.msi
retention-days: 30 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 - name: Create Windows x64 Portable zip
if: hashFiles('release/win-unpacked/*') != '' if: hashFiles('release/win-unpacked/*') != ''
run: | run: |
@@ -192,6 +208,11 @@ jobs:
run: | run: |
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip" 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 - name: Upload Windows update files
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none' if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
@@ -219,6 +240,14 @@ jobs:
path: termix_windows_ia32_portable.zip path: termix_windows_ia32_portable.zip
retention-days: 30 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: build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404 runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit' 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 - 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 - 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 ### 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 - 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 ### Fixed
@@ -34,6 +40,13 @@
- nginx answered 404 instead of 502 while the backend was down - 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 - 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 - 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 ## 26.10.1
+3 -22
View File
@@ -18,6 +18,8 @@
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a> <a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
</p> </p>
<a href="https://www.youtube.com/watch?v=vhtkP-UqV5U"><img src="./.github/readme/overview.jpg" width="720" alt="Watch the Termix overview on YouTube" /></a>
</div> </div>
<br /> <br />
@@ -135,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. 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 ```yaml
services: services:
@@ -149,31 +151,10 @@ services:
- termix-data:/app/data - termix-data:/app/data
environment: environment:
PORT: "8080" 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: volumes:
termix-data: termix-data:
driver: local driver: local
networks:
termix-net:
driver: bridge
``` ```
### Cloud Hosting ### Cloud Hosting
+1 -1
View File
@@ -26,7 +26,7 @@ COPY . .
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
RUN npm cache clean --force && \ RUN npm cache clean --force && \
NODE_OPTIONS="--max-old-space-size=3072" npm run build TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build
# Stage 3: Build backend # Stage 3: Build backend
FROM deps AS backend-builder FROM deps AS backend-builder
+68 -68
View File
@@ -3,20 +3,20 @@
{ {
"id": "acme-ssl", "id": "acme-ssl",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e" "sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
}, },
{ {
"id": "ai", "id": "ai",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7" "sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
}, },
{ {
"id": "alerts", "id": "alerts",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a", "sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -25,14 +25,14 @@
{ {
"id": "automations", "id": "automations",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d" "sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
}, },
{ {
"id": "docker", "id": "docker",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3", "sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -41,8 +41,8 @@
{ {
"id": "file-manager", "id": "file-manager",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6", "sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -51,20 +51,20 @@
{ {
"id": "fleets", "id": "fleets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b" "sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
}, },
{ {
"id": "homepage", "id": "homepage",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32" "sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
}, },
{ {
"id": "host-metrics", "id": "host-metrics",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5", "sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -73,32 +73,32 @@
{ {
"id": "ldap", "id": "ldap",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c" "sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
}, },
{ {
"id": "network-topology", "id": "network-topology",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca" "sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
}, },
{ {
"id": "opkssh", "id": "opkssh",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f" "sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
}, },
{ {
"id": "proxmox", "id": "proxmox",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7" "sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
}, },
{ {
"id": "remote-desktop", "id": "remote-desktop",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39", "sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -107,14 +107,14 @@
{ {
"id": "secret-sources", "id": "secret-sources",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a" "sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
}, },
{ {
"id": "serial", "id": "serial",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121", "sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"onboarding": { "onboarding": {
"recommended": "desktop", "recommended": "desktop",
"consent": false "consent": false
@@ -123,20 +123,20 @@
{ {
"id": "session-recording", "id": "session-recording",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7" "sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
}, },
{ {
"id": "session-sharing", "id": "session-sharing",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1" "sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
}, },
{ {
"id": "snippets", "id": "snippets",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f", "sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -145,8 +145,8 @@
{ {
"id": "ssh-terminal", "id": "ssh-terminal",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f", "sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -155,8 +155,8 @@
{ {
"id": "sso", "id": "sso",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b", "sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"onboarding": { "onboarding": {
"recommended": false, "recommended": false,
"consent": false, "consent": false,
@@ -166,20 +166,20 @@
{ {
"id": "step-ca", "id": "step-ca",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8" "sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
}, },
{ {
"id": "tailscale", "id": "tailscale",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f" "sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
}, },
{ {
"id": "telemetry", "id": "telemetry",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469", "sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": true "consent": true
@@ -188,20 +188,20 @@
{ {
"id": "termix-identity", "id": "termix-identity",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0" "sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
}, },
{ {
"id": "tmux-monitor", "id": "tmux-monitor",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6" "sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
}, },
{ {
"id": "totp", "id": "totp",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474", "sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -210,8 +210,8 @@
{ {
"id": "tunnels", "id": "tunnels",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860", "sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -220,32 +220,32 @@
{ {
"id": "vault", "id": "vault",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a" "sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
}, },
{ {
"id": "wake-on-lan", "id": "wake-on-lan",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402" "sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
}, },
{ {
"id": "warpgate", "id": "warpgate",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8" "sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
}, },
{ {
"id": "web-endpoint", "id": "web-endpoint",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637" "sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
}, },
{ {
"id": "webauthn", "id": "webauthn",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464", "sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
@@ -254,8 +254,8 @@
{ {
"id": "workspaces", "id": "workspaces",
"source": "tmxplug", "source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug", "url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153", "sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"onboarding": { "onboarding": {
"recommended": true, "recommended": true,
"consent": false "consent": false
-15
View File
@@ -12,24 +12,9 @@ services:
environment: environment:
PORT: "8080" PORT: "8080"
NODE_ENV: development 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" # REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab" # TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca" # 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: networks:
- termix-dev-net - termix-dev-net
-17
View File
@@ -9,12 +9,6 @@ services:
- termix-data:/app/data - termix-data:/app/data
environment: environment:
PORT: "8080" 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 # Multi-instance deployments should point every backend at the same
# Redis. Without it, collaboration rooms keep their single-instance # Redis. Without it, collaboration rooms keep their single-instance
# in-memory fallback. # in-memory fallback.
@@ -36,17 +30,6 @@ services:
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}' # TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username" # TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role" # 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: networks:
- termix-net - termix-net
+2 -2
View File
@@ -46,11 +46,11 @@
"target": [ "target": [
{ {
"target": "nsis", "target": "nsis",
"arch": ["x64", "ia32"] "arch": ["x64", "ia32", "arm64"]
}, },
{ {
"target": "msi", "target": "msi",
"arch": ["x64", "ia32"] "arch": ["x64", "ia32", "arm64"]
} }
], ],
"icon": "public/icon.ico", "icon": "public/icon.ico",
+8 -1
View File
@@ -6,9 +6,16 @@
let linked = null; 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) { function getOrigin(url) {
try { 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 { } catch {
return null; 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, // path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
// which keeps every transfer on the backend's plugin routes. // which keeps every transfer on the backend's plugin routes.
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/; 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; let registeredApiPath = null;
function setTransferApiPath(apiPath) { function setTransferApiPath(apiPath) {
@@ -403,7 +407,8 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
} }
return { return {
url: `${normalizeHttpBase( url: `${normalizeHttpBase(
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`, localBaseUrl ??
`http://localhost:${localBackendPort}${resolveApiPath()}`,
"Local backend URL", "Local backend URL",
)}${routePath}`, )}${routePath}`,
headers, headers,
@@ -1050,6 +1055,7 @@ module.exports = {
createLocalFileHandlers, createLocalFileHandlers,
createTargetResolver, createTargetResolver,
setTransferApiPath, setTransferApiPath,
setLocalBackendPort,
publishDownload, publishDownload,
defaultPublishFs, defaultPublishFs,
assertWithinRoot, assertWithinRoot,
+39 -11
View File
@@ -35,7 +35,10 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs"); const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs"); const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs"); const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
const { registerLocalFileHandlers } = require("./local-files.cjs"); const {
registerLocalFileHandlers,
setLocalBackendPort,
} = require("./local-files.cjs");
const { const {
EXTERNAL_EDITOR_SETTING, EXTERNAL_EDITOR_SETTING,
isLaunchableFileName, isLaunchableFileName,
@@ -439,14 +442,6 @@ function isInsecureModeEnabled() {
); );
} }
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function isPrivateNetworkHost(hostname) { function isPrivateNetworkHost(hostname) {
if ( if (
hostname === "localhost" || hostname === "localhost" ||
@@ -490,7 +485,7 @@ function isInvalidCertificateAllowedForUrl(url) {
// allows a self-signed certificate. // allows a self-signed certificate.
const linked = linkedServer.getLinkedServer(); const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false; if (!linked?.allowInvalidCertificate) return false;
return getOrigin(url) === linked.origin; return linkedServer.isLinkedOrigin(url);
} }
function getTlsVerificationOptions(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() { function startBackendServer() {
reapOrphanedBackendProcess(); reapOrphanedBackendProcess();
backendFailure = null; backendFailure = null;
@@ -1164,7 +1188,8 @@ function startBackendServer() {
DATA_DIR: dataDir, DATA_DIR: dataDir,
NODE_ENV: "production", NODE_ENV: "production",
ELECTRON_EMBEDDED: "true", ELECTRON_EMBEDDED: "true",
PORT: "30001", PORT: String(backendPort),
EMBEDDED_BACKEND_PORT: String(backendPort),
VERSION: app.getVersion(), VERSION: app.getVersion(),
}, },
stdio: ["pipe", "pipe", "pipe", "ipc"], stdio: ["pipe", "pipe", "pipe", "ipc"],
@@ -1429,6 +1454,7 @@ function createWindow() {
sandbox: true, sandbox: true,
webSecurity: true, webSecurity: true,
preload: path.join(__dirname, "preload.js"), preload: path.join(__dirname, "preload.js"),
additionalArguments: [`--termix-backend-port=${backendPort}`],
partition: termixSessionPartition, partition: termixSessionPartition,
allowRunningInsecureContent: false, allowRunningInsecureContent: false,
webviewTag: false, webviewTag: false,
@@ -3814,6 +3840,8 @@ app.whenReady().then(async () => {
await clearElectronJwtCookiesAtStartup(); await clearElectronJwtCookiesAtStartup();
if (!isDev) { if (!isDev) {
backendPort = await findBackendPort();
setLocalBackendPort(backendPort);
const result = await startBackendServer(); const result = await startBackendServer();
logToFile("startBackendServer result:", result); logToFile("startBackendServer result:", result);
} else { } else {
+9
View File
@@ -14,7 +14,16 @@ function invokeAllowed(channel, ...args) {
return ipcRenderer.invoke(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", { contextBridge.exposeInMainWorld("electronAPI", {
backendPort,
getAppVersion: () => ipcRenderer.invoke("get-app-version"), getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied. // Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => { setZoomFactor: (factor) => {
+6 -10
View File
@@ -71,7 +71,6 @@
"group": "server", "group": "server",
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket." "description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
}, },
{ {
"name": "ENABLE_SSL", "name": "ENABLE_SSL",
"group": "https", "group": "https",
@@ -108,7 +107,6 @@
"internal": true, "internal": true,
"description": "Set by the image when nginx serves HTTPS in front of the backend." "description": "Set by the image when nginx serves HTTPS in front of the backend."
}, },
{ {
"name": "DATABASE_DIALECT", "name": "DATABASE_DIALECT",
"group": "database", "group": "database",
@@ -161,7 +159,6 @@
"internal": true, "internal": true,
"description": "Where database migrations are read from. Set by the image." "description": "Where database migrations are read from. Set by the image."
}, },
{ {
"name": "JWT_SECRET", "name": "JWT_SECRET",
"group": "secrets", "group": "secrets",
@@ -204,7 +201,6 @@
"secret": true, "secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it." "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", "name": "ALLOW_PASSWORD_LOGIN",
"group": "auth", "group": "auth",
@@ -283,7 +279,6 @@
"group": "auth", "group": "auth",
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on." "description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
}, },
{ {
"name": "TRUSTED_PROXIES", "name": "TRUSTED_PROXIES",
"group": "proxy", "group": "proxy",
@@ -315,7 +310,6 @@
"group": "proxy", "group": "proxy",
"description": "Comma separated hosts that skip the proxy." "description": "Comma separated hosts that skip the proxy."
}, },
{ {
"name": "LOG_LEVEL", "name": "LOG_LEVEL",
"group": "logging", "group": "logging",
@@ -355,7 +349,6 @@
"deprecated": "the session-recording plugin setting", "deprecated": "the session-recording plugin setting",
"description": "Only read once to carry a 2.8 value into the session-recording plugin." "description": "Only read once to carry a 2.8 value into the session-recording plugin."
}, },
{ {
"name": "TERMIX_PLUGIN_REGISTRY_URL", "name": "TERMIX_PLUGIN_REGISTRY_URL",
"group": "plugins", "group": "plugins",
@@ -406,7 +399,6 @@
"internal": true, "internal": true,
"description": "Set by npm run dev." "description": "Set by npm run dev."
}, },
{ {
"name": "ENABLE_INSECURE_MODE", "name": "ENABLE_INSECURE_MODE",
"group": "desktop", "group": "desktop",
@@ -435,6 +427,12 @@
"internal": true, "internal": true,
"description": "Set by the desktop app when it runs its own backend." "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", "name": "TERMIX_DATA_DIR",
"group": "desktop", "group": "desktop",
@@ -459,14 +457,12 @@
"internal": true, "internal": true,
"description": "Set by Flatpak. The desktop app links new versions instead of installing them." "description": "Set by Flatpak. The desktop app links new versions instead of installing them."
}, },
{ {
"name": "DESKTOP_SESSION", "name": "DESKTOP_SESSION",
"group": "desktop", "group": "desktop",
"internal": true, "internal": true,
"description": "Read on Linux to pick the password store." "description": "Read on Linux to pick the password store."
}, },
{ {
"name": "VITE_BASE_PATH", "name": "VITE_BASE_PATH",
"group": "internal", "group": "internal",
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "26.10.1", "version": "26.10.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "26.10.1", "version": "26.10.2",
"hasInstallScript": true, "hasInstallScript": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@@ -15058,7 +15058,7 @@
}, },
"packages/plugin-sdk": { "packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.8", "version": "1.0.11",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2", "@deadendjs/swagger-jsdoc": "^8.1.2",
+2 -2
View File
@@ -4,7 +4,7 @@
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
"version": "26.10.1", "version": "26.10.2",
"description": "Self-hosted, plugin-based server management.", "description": "Self-hosted, plugin-based server management.",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",
@@ -30,7 +30,7 @@
"dev:electron": "node scripts/dev.mjs electron", "dev:electron": "node scripts/dev.mjs electron",
"dev:docker": "node scripts/dev.mjs docker", "dev:docker": "node scripts/dev.mjs docker",
"dev:frontend": "vite", "dev:frontend": "vite",
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", "build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", "build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs", "generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty", "electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
+26
View File
@@ -1,5 +1,31 @@
# Changelog # 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
- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/<name>.js`
- A `.wasm`, `.ttf` or `.otf` import gives the file's bytes, in `termix-plugin build` and in plugin tests
### Fixed
- The plugin guide listed jszip, guacamole-lite and @anthropic-ai/sdk as shared packages, but they are bundled
## 1.0.8 ## 1.0.8
### Added ### Added
+93 -51
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.8 | 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 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. `@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 typecheck # tsc
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs 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 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 # Prettier
npm run format:check npm run format:check
``` ```
@@ -105,7 +105,7 @@ Before you call a change finished:
- [ ] `npm run build` passes - [ ] `npm run build` passes
- [ ] `npm run format:check` passes - [ ] `npm run format:check` passes
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables) - [ ] `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 routes have an `@openapi` block and a test, including without the permission
- [ ] New env vars are listed in `env` in the manifest - [ ] 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 - [ ] `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 | | `audit.record` | none | Write your own audit line |
| `sync` | none | Sync your tables to a linked desktop | | `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types | | `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 | | `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed | | `plugins.list()` | `plugins:read` | Which plugins are installed |
| `asUser(userId, fn)` | always audited | Run background work as a user | | `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 work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
that user's permissions. 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
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
@@ -334,8 +343,27 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
### Shared packages ### Shared packages
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`, These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
`cookie-parser`, `axios`, `jszip`, `guacamole-lite`, `@anthropic-ai/sdk`, `drizzle-orm`, `sharp` and every `cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in
Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled. `peerDependencies`. Any other npm package is bundled.
### Workers and WebAssembly
Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name
each worker entry in `package.json`:
```json
"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
```
`build` bundles it alone into `dist/workers/engine.js`; start it with
`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and
can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with
`postMessage`.
Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships
inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module`
and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }`
to a `.d.ts` in the plugin.
## Database ## Database
@@ -465,7 +493,7 @@ your own host settings, under `host.pluginSettings[<id>]`.
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`, Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`, `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. https://docs.termix.site/develop/more-extension-points.
## Looking native ## Looking native
@@ -519,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. `locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
Untranslated strings show in English. 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
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use `docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
@@ -683,20 +725,20 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## termix-plugin CLI ## termix-plugin CLI
| Command | What it does | | Command | What it does |
| ------------------------------ | ---------------------------------------------------------------------------------------------- | | ------------------------------ | ----------------------------------------------------------------------------------------------- |
| `build` | Bundle `dist/backend.js` and `dist/frontend.js`, CSS, locales, migrations, `dist/openapi.json` | | `build` | Bundle `dist/backend.js`, `dist/frontend.js`, workers, CSS, locales, migrations, `openapi.json` |
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` | | `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
| `test` | Apply patches, run Vitest | | `test` | Apply patches, run Vitest |
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing | | `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks | | `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` | | `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` | | `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` | | `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
| `keygen` | Write a new signing key pair. `--out <dir>` | | `keygen` | Write a new signing key pair. `--out <dir>` |
| `changelog` | Print one version's notes. `--version x.y.z` | | `changelog` | Print one version's notes. `--version x.y.z` |
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) | | `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` | | `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
## SDK entry points ## SDK entry points
@@ -721,37 +763,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## Capabilities ## Capabilities
| Capability | Risk | What it allows | | Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------ | | -------------------- | -------- | ------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach | | `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks | | `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access | | `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server | | `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles | | `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Run background work as another user, audited | | `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) | | `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types | | `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server | | `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) | | `notify:hub` | high | Receive every alert (the alerts hub only) |
| `hosts:write` | medium | Add and edit hosts | | `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them | | `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials | | `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` | | `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets | | `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN | | `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames and roles | | `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events | | `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts | | `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log | | `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app | | `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see | | `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables | | `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state | | `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk | | `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets | | `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings | | `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins | | `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings | | `ui:surface` | low | Add tabs, panels and settings |
An update that adds a capability waits for an admin to approve it. An update that adds a capability waits for an admin to approve it.
+54 -3
View File
@@ -52,6 +52,43 @@ export function externalRequireInterop(externals) {
}; };
} }
// A .wasm or font import becomes its bytes (a Uint8Array) inside the bundle,
// so it ships in backend.js or a worker with no separate file to find.
export const BINARY_EXTENSIONS = [".wasm", ".ttf", ".otf"];
const BINARY_LOADERS = Object.fromEntries(
BINARY_EXTENSIONS.map((ext) => [ext, "binary"]),
);
const REQUIRE_BANNER =
"import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);";
/**
* package.json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
* bundles each entry on its own into dist/workers/<name>.js, for
* new Worker(new URL("./workers/<name>.js", import.meta.url)). Nothing is left
* external: core's import fallback only runs on the main thread, so a worker
* can't rely on host packages and must not import the SDK.
*/
export function readWorkerEntries(cwd, pluginId) {
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const workers = pkg.termix?.workers ?? {};
return Object.entries(workers).map(([name, entry]) => {
if (!/^[a-z0-9-]+$/.test(name)) {
throw new Error(`${pluginId}: worker name "${name}" must be lowercase`);
}
const file = path.resolve(cwd, String(entry));
if (!fs.existsSync(file)) {
throw new Error(
`${pluginId}: worker "${name}" entry ${entry} is missing`,
);
}
return { name, file };
});
}
const SDK_PATCHES = path.join( const SDK_PATCHES = path.join(
path.dirname(fileURLToPath(import.meta.url)), path.dirname(fileURLToPath(import.meta.url)),
"..", "..",
@@ -216,13 +253,27 @@ export async function build({ cwd }) {
// node_modules import instead, resolved at runtime like a host-provided // node_modules import instead, resolved at runtime like a host-provided
// package. // package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])], external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
loader: BINARY_LOADERS,
// Bundled CJS deps still call require() for Node builtins at runtime. // Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one. // ESM has no ambient require, so give esbuild's require shim a real one.
banner: { banner: { js: REQUIRE_BANNER },
js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
},
}); });
for (const worker of readWorkerEntries(cwd, pluginId)) {
await esbuild.build({
entryPoints: [worker.file],
outfile: path.join(outDir, "workers", `${worker.name}.js`),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
loader: BINARY_LOADERS,
banner: { js: REQUIRE_BANNER },
});
}
// Without this Node finds the host's typeless package.json, tries backend.js // Without this Node finds the host's typeless package.json, tries backend.js
// as CommonJS first and warns before reparsing it as ESM. // as CommonJS first and warns before reparsing it as ESM.
fs.writeFileSync( fs.writeFileSync(
+3
View File
@@ -11,6 +11,9 @@ export const TMXPLUG_CONTENTS = [
"README.md", "README.md",
"CHANGELOG.md", "CHANGELOG.md",
"icon.svg", "icon.svg",
// Licence texts, so third-party notices travel with the plugin.
"LICENSE",
"NOTICE",
]; ];
const BLOCK = 512; const BLOCK = 512;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.8", "version": "1.0.11",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
+43 -3
View File
@@ -1464,6 +1464,20 @@ export interface PluginSecondFactor {
reset?: (userId: string) => Promise<void>; 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 * Login methods, second factors and SSH auth types. Every register call needs
* auth:provide and a matching contributes.auth entry, and is undone on * 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. * delete a provider people still sign in with.
*/ */
countLinkedUsers: (provider: string) => Promise<number>; 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 { export interface PluginOpenIsolatedWindowRequest {
@@ -2013,8 +2047,13 @@ export interface PluginContext {
readonly hosts: PluginHosts; readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */ /** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh; 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; 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. */ /** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop; readonly desktop: PluginDesktop;
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */ /** 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 * no request behind it. Always audited, and every core API inside still
* applies that user's RBAC. * applies that user's RBAC.
* *
* Needs users:impersonate. That and a `userId` option on services.get or * Needs users:impersonate. That, auth.issueUserToken and a `userId` option
* secrets.getShared are the only ways a plugin can name a user. * 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>; 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 * "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server. * 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. */ /** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String( export const SUPPORTED_PLUGIN_API_VERSION = String(
+33 -1
View File
@@ -174,6 +174,8 @@ export interface FakeContextOptions {
linkedUsers?: Record<string, number>; linkedUsers?: Record<string, number>;
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */ /** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number; loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
locales?: Record<string, string>;
} }
export interface FakeProcessOptions { export interface FakeProcessOptions {
@@ -247,6 +249,8 @@ export interface FakeAuthRegistrations {
}>; }>;
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */ /** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
loginFailures: Map<string, number>; loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
} }
export interface FakePluginContext { export interface FakePluginContext {
@@ -574,6 +578,7 @@ export function createFakeContext(
completedLogins: [], completedLogins: [],
revokedSessions: [], revokedSessions: [],
loginFailures: new Map(), loginFailures: new Map(),
userTokens: [],
}; };
const sshClient = options.sshClient ?? {}; const sshClient = options.sshClient ?? {};
let actor = options.actor; let actor = options.actor;
@@ -1140,6 +1145,18 @@ export function createFakeContext(
}, },
countLinkedUsers: async (provider) => countLinkedUsers: async (provider) =>
options.linkedUsers?.[provider] ?? 0, 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: { desktop: {
@@ -1442,6 +1459,8 @@ export interface MockContextOptions {
linkedUsers?: Record<string, number>; linkedUsers?: Record<string, number>;
/** See FakeContextOptions. */ /** See FakeContextOptions. */
loginAttemptLimit?: number; loginAttemptLimit?: number;
/** See FakeContextOptions. */
locales?: Record<string, string>;
} }
export interface MockPluginContext extends FakePluginContext { 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 * topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, * 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 * 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. * ungated. As the SDK grows a member, add its gate here in the same shape.
*/ */
export function createMockCtx( export function createMockCtx(
@@ -1495,6 +1515,7 @@ export function createMockCtx(
baseUrl: options.baseUrl, baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers, linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit, loginAttemptLimit: options.loginAttemptLimit,
locales: options.locales,
manifest: { manifest: {
capabilities: options.capabilities ?? [], capabilities: options.capabilities ?? [],
...options.manifest, ...options.manifest,
@@ -1786,6 +1807,17 @@ export function createMockCtx(
require("auth:provide"); require("auth:provide");
return ctx.auth.countLinkedUsers(provider); 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: { desktop: {
+24
View File
@@ -30,6 +30,25 @@ function findRepoRoot(startDir: string): string | null {
} }
} }
/**
* termix-plugin build turns a .wasm or font import into its bytes. Tests get the same,
* so code that imports a module compiles it the same way under vitest.
*/
export function wasmBinaryPlugin() {
return {
name: "termix-wasm-binary",
enforce: "pre" as const,
load(id: string) {
const file = id.split("?")[0];
if (![".wasm", ".ttf", ".otf"].some((ext) => file.endsWith(ext))) {
return null;
}
const base64 = fs.readFileSync(file).toString("base64");
return `export default new Uint8Array(Buffer.from(${JSON.stringify(base64)}, "base64"));`;
},
};
}
export interface PluginVitestOptions { export interface PluginVitestOptions {
/** Extra aliases merged over the defaults. */ /** Extra aliases merged over the defaults. */
alias?: Record<string, string>; alias?: Record<string, string>;
@@ -96,7 +115,10 @@ export function pluginVitestConfig(
// unexpectedly". Threads don't. // unexpectedly". Threads don't.
const pool = "threads" as const; const pool = "threads" as const;
const plugins = [wasmBinaryPlugin()];
return { return {
plugins,
resolve: { alias }, resolve: { alias },
test: { test: {
globals: true, globals: true,
@@ -104,6 +126,7 @@ export function pluginVitestConfig(
pool, pool,
projects: [ projects: [
{ {
plugins,
resolve: { alias }, resolve: { alias },
test: { test: {
name: `${pluginId}:backend`, name: `${pluginId}:backend`,
@@ -116,6 +139,7 @@ export function pluginVitestConfig(
}, },
}, },
{ {
plugins,
resolve: { alias }, resolve: { alias },
test: { test: {
name: `${pluginId}:frontend`, name: `${pluginId}:frontend`,
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+6 -1
View File
@@ -1,4 +1,9 @@
const CACHE_NAME = "termix-static-v2"; // CACHE_NAME carries the package version so a new release always gets a fresh
// cache: the activate handler below deletes every cache whose name differs
// from this one, which is what evicts a previous build's entries. Both
// placeholders on these lines are stamped by scripts/stamp-sw-build-info.cjs
// during `npm run build` - vite copies public/ verbatim and never transforms it.
const CACHE_NAME = "termix-static-" + "__TERMIX_CACHE_VERSION__";
const BASE_PATH = "__TERMIX_SW_BASE_PATH__"; const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
const STATIC_ASSETS = [ const STATIC_ASSETS = [
`${BASE_PATH}/favicon.ico`, `${BASE_PATH}/favicon.ico`,
+2
View File
@@ -42,6 +42,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix", DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
GUACD_HOST: "127.0.0.1", GUACD_HOST: "127.0.0.1",
GUACD_PORT: "4822", GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
}); });
}); });
@@ -53,6 +54,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix", DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
GUACD_HOST: "termix-dev-guacd", GUACD_HOST: "termix-dev-guacd",
GUACD_PORT: "4822", 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 * mounted. A plugin change rebuilds it and restarts the container. Core
* changes wait for "r", since an image build is slow. * 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"; import fs from "node:fs";
+1
View File
@@ -53,6 +53,7 @@ function start() {
isDev: false, isDev: false,
appRoot: "/app", appRoot: "/app",
BACKEND_STDERR_TAIL_LIMIT: 4096, BACKEND_STDERR_TAIL_LIMIT: 4096,
backendPort: 30001,
handleBackendRequest, handleBackendRequest,
setTimeout, setTimeout,
clearTimeout, clearTimeout,
+1 -1
View File
@@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) {
`| Architecture | Windows | Linux | Mac | Android | iOS |`, `| 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")}) | - | - |`, `| **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")}) | - | - | - |`, `| **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")}) | - | - | - | - |`, `| **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) | - | - |`, `| **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; const guacd = SERVICES.guacd;
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1"; env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort); 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; return env;
} }
+1 -1
View File
@@ -125,7 +125,7 @@ Options
--plugins <dir> Plugin repos to build and watch (default ../Plugins) --plugins <dir> Plugin repos to build and watch (default ../Plugins)
--no-local Use only the plugins pinned in docker/bundled-plugins.json --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 --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) --fresh Start from empty data (first run, onboarding)
--data <dir> Data folder for the backend (default db/data, db/data-desktop for --data <dir> Data folder for the backend (default db/data, db/data-desktop for
electron, plus -<engine> for postgres or mysql) electron, plus -<engine> for postgres or mysql)
+68
View File
@@ -92,6 +92,74 @@ describe("termix-plugin build", () => {
}); });
}); });
describe("termix-plugin build workers and wasm", () => {
// The smallest valid module: magic and version, no sections.
const EMPTY_WASM = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0, 0, 0]);
it("inlines a .wasm import as its bytes", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
fs.writeFileSync(path.join(dir, "src", "backend", "mod.wasm"), EMPTY_WASM);
fs.writeFileSync(
path.join(dir, "src", "backend", "index.ts"),
'import bytes from "./mod.wasm";\nexport const valid = WebAssembly.validate(bytes);\nexport function activate() {}\n',
);
await build({ cwd: dir });
const mod = await import(
pathToFileURL(path.join(dir, "dist", "backend.js")).href
);
expect(mod.valid).toBe(true);
});
it("bundles each worker entry on its own into dist/workers", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ termix: { workers: { engine: "src/worker.ts" } } }),
);
const dep = path.join(dir, "node_modules", "worker-dep");
fs.mkdirSync(dep, { recursive: true });
fs.writeFileSync(
path.join(dep, "package.json"),
JSON.stringify({ name: "worker-dep", main: "index.js" }),
);
fs.writeFileSync(path.join(dep, "index.js"), "exports.answer = 42;\n");
fs.writeFileSync(
path.join(dir, "src", "worker.ts"),
'import { parentPort } from "node:worker_threads";\nimport { answer } from "worker-dep";\nparentPort?.postMessage(answer);\n',
);
await build({ cwd: dir });
const file = path.join(dir, "dist", "workers", "engine.js");
expect(fs.readFileSync(file, "utf8")).not.toContain('from "worker-dep"');
const { Worker } = await import("node:worker_threads");
const answer = await new Promise((resolve, reject) => {
const worker = new Worker(file);
worker.once("message", (value) => {
void worker.terminate();
resolve(value);
});
worker.once("error", reject);
});
expect(answer).toBe(42);
});
it("refuses a worker whose entry is missing", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ termix: { workers: { engine: "src/nope.ts" } } }),
);
await expect(build({ cwd: dir })).rejects.toThrow(/entry src\/nope.ts/);
});
});
describe("plugin CSS layers", () => { describe("plugin CSS layers", () => {
it("renames the Tailwind layers so core's utilities always win", () => { it("renames the Tailwind layers so core's utilities always win", () => {
expect( expect(
+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, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
writeFile(dir, "README.md", "# Fixture\n"); writeFile(dir, "README.md", "# Fixture\n");
writeFile(dir, "icon.svg", "<svg/>\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"); writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
return dir; return dir;
} }
@@ -91,6 +93,8 @@ describe("termix-plugin pack", () => {
const names = await entries(file); const names = await entries(file);
expect(names).toEqual([ expect(names).toEqual([
"LICENSE",
"NOTICE",
"README.md", "README.md",
"dist/backend.js", "dist/backend.js",
"dist/frontend.js", "dist/frontend.js",
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/** Stamp the cache version and static-build base path.
* Docker keeps the base-path placeholder for entrypoint.sh to fill at startup.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SW_PATH = path.join(ROOT, "dist", "sw.js");
const VERSION_PLACEHOLDER = "__TERMIX_CACHE_VERSION__";
const BASE_PATH_PLACEHOLDER = "__TERMIX_SW_BASE_PATH__";
const pkg = JSON.parse(
fs.readFileSync(path.join(ROOT, "package.json"), "utf8"),
);
const cacheVersion = String(pkg.version || "0.0.0").replace(/\./g, "-");
const rawBasePath = process.env.VITE_BASE_PATH || "./";
// "./" is what vite emits for a root deployment; cache URLs stay root-relative.
const basePath =
rawBasePath === "./"
? ""
: `/${rawBasePath.replace(/^\/+/, "").replace(/\/+$/, "")}`;
if (!fs.existsSync(SW_PATH)) {
console.error(`[stamp-sw] dist/sw.js not found - run vite build first`);
process.exit(1);
}
let sw = fs.readFileSync(SW_PATH, "utf8");
const rel = path.relative(ROOT, SW_PATH);
const replacements = [
[VERSION_PLACEHOLDER, cacheVersion],
...(process.env.TERMIX_RUNTIME_BASE_PATH === "true"
? []
: [[BASE_PATH_PLACEHOLDER, basePath]]),
];
let changed = false;
for (const [placeholder, value] of replacements) {
if (sw.includes(placeholder)) {
sw = sw.split(placeholder).join(value);
changed = true;
console.log(
`[stamp-sw] ${rel}: ${placeholder} -> ${JSON.stringify(value)}`,
);
}
}
if (changed) {
fs.writeFileSync(SW_PATH, sw);
}
+59
View File
@@ -0,0 +1,59 @@
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
readFileSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, it } from "vitest";
it.each([
[false, "", ""],
[false, "/termix/", "/termix"],
[true, "", "__TERMIX_SW_BASE_PATH__"],
])(
"stamps service worker paths (runtime=%s, base=%s)",
(runtime, base, expected) => {
const dir = mkdtempSync(path.join(tmpdir(), "termix-sw-"));
try {
mkdirSync(path.join(dir, "scripts"));
mkdirSync(path.join(dir, "dist"));
writeFileSync(
path.join(dir, "scripts/stamp-sw-build-info.cjs"),
readFileSync("scripts/stamp-sw-build-info.cjs"),
);
writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ version: "26.10.2" }),
);
writeFileSync(path.join(dir, "dist/sw.js"), readFileSync("public/sw.js"));
execFileSync(
process.execPath,
[path.join(dir, "scripts/stamp-sw-build-info.cjs")],
{
env: {
...process.env,
VITE_BASE_PATH: base,
TERMIX_RUNTIME_BASE_PATH: String(runtime),
},
},
);
const sw = readFileSync(path.join(dir, "dist/sw.js"), "utf8");
expect(sw).toContain('"26-10-2"');
expect(sw).toContain(`const BASE_PATH = "${expected}";`);
if (runtime) {
expect(sw.replaceAll("__TERMIX_SW_BASE_PATH__", "/termix")).toContain(
'const BASE_PATH = "/termix";',
);
expect(readFileSync("docker/Dockerfile", "utf8")).toContain(
"TERMIX_RUNTIME_BASE_PATH=true",
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
+6 -1
View File
@@ -38,6 +38,7 @@ import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js"; import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js"; import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js"; import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { createCorsMiddleware } from "../utils/cors-config.js"; import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js"; import { createCompressionMiddleware } from "../utils/compression-config.js";
import fs from "fs"; import fs from "fs";
@@ -1388,6 +1389,9 @@ app.post(
} }
} }
await applyDefaultsAfterHostWrites(importedHostIds); await applyDefaultsAfterHostWrites(importedHostIds);
for (const hostId of importedHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
} catch { } catch {
apiLogger.info("ssh_data table not found in import file, skipping"); 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() { async function initializeSecurity() {
try { try {
+55 -35
View File
@@ -140,7 +140,14 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
* Tells status checks and other pollers about hosts an import wrote, the way * Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does. * 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) { for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId }); pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
} }
@@ -568,26 +575,26 @@ export function registerHostBulkRoutes(
); );
} }
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
let existingHostMap: Map<string, { id: number }> | undefined; let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository(); const hostRepository = createCurrentHostRepository();
if (overwrite) { try {
try { const allHosts =
const allHosts = await createCurrentHostResolutionRepository().findHostsByUserId(
await createCurrentHostResolutionRepository().findHostsByUserId( userId,
userId, );
); existingHostMap = new Map();
existingHostMap = new Map(); for (const h of allHosts) {
for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`;
const key = `${h.ip}:${h.port}:${h.username}`; existingHostMap.set(key, { id: h.id as number });
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
} }
} catch {
existingHostMap = undefined;
} }
const knownAuthTypes = listKnownAuthTypes(); const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = []; const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) { for (const { host: hostData, index: i, exportId } of orderedHosts) {
try { try {
const effectiveConnectionType = hostData.connectionType || "ssh"; const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -624,6 +631,15 @@ export function registerHostBulkRoutes(
continue; continue;
} }
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
// Hosts that jump through this one still resolve to the kept host.
if (exportId !== undefined) importedIds.set(exportId, existing.id);
results.skipped++;
continue;
}
if ( if (
effectiveConnectionType === "ssh" && effectiveConnectionType === "ssh" &&
hostData.authType && hostData.authType &&
@@ -769,8 +785,6 @@ export function registerHostBulkRoutes(
hostData.authType === "key" ? hostData.keyType || "auto" : null; hostData.authType === "key" ? hostData.keyType || "auto" : null;
} }
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults( await withHostDefaults(
userId, userId,
existing?.id ?? null, existing?.id ?? null,
@@ -797,6 +811,7 @@ export function registerHostBulkRoutes(
); );
if (exportId !== undefined) importedIds.set(exportId, saved.id); if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id; savedHostId = saved.id;
createdHostIds.push(saved.id);
results.success++; results.success++;
} }
@@ -828,10 +843,10 @@ export function registerHostBulkRoutes(
} }
await applyDefaultsAfterHostWrites(writtenHostIds); await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds); notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({ res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
success: results.success, success: results.success,
updated: results.updated, updated: results.updated,
skipped: results.skipped, skipped: results.skipped,
@@ -931,23 +946,22 @@ export function registerHostBulkRoutes(
let existingHostMap: Map<string, { id: number }> | undefined; let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository(); const hostRepository = createCurrentHostRepository();
if (overwrite) { try {
try { const allHosts =
const allHosts = await createCurrentHostResolutionRepository().findHostsByUserId(
await createCurrentHostResolutionRepository().findHostsByUserId( userId,
userId, );
); existingHostMap = new Map();
existingHostMap = new Map(); for (const h of allHosts) {
for (const h of allHosts) { const key = `${h.ip}:${h.port}:${h.username}`;
const key = `${h.ip}:${h.port}:${h.username}`; existingHostMap.set(key, { id: h.id as number });
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
} }
} catch {
existingHostMap = undefined;
} }
const writtenHostIds: number[] = []; const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) { for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost( const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>, hostsToImport[i] as Record<string, unknown>,
@@ -962,6 +976,13 @@ export function registerHostBulkRoutes(
continue; continue;
} }
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
if (existing && !overwrite) {
results.skipped++;
continue;
}
const sshDataObj: Record<string, unknown> = { const sshDataObj: Record<string, unknown> = {
userId, userId,
connectionType: "ssh", connectionType: "ssh",
@@ -999,8 +1020,6 @@ export function registerHostBulkRoutes(
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults( await withHostDefaults(
userId, userId,
existing?.id ?? null, existing?.id ?? null,
@@ -1023,6 +1042,7 @@ export function registerHostBulkRoutes(
sshDataObj, sshDataObj,
); );
writtenHostIds.push(saved.id); writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++; results.success++;
} }
} catch (error) { } catch (error) {
@@ -1034,10 +1054,10 @@ export function registerHostBulkRoutes(
} }
await applyDefaultsAfterHostWrites(writtenHostIds); await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds); notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({ res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
success: results.success, success: results.success,
updated: results.updated, updated: results.updated,
skipped: results.skipped, skipped: results.skipped,
@@ -88,6 +88,23 @@ export function keepsStoredSshAuth(authType: unknown): boolean {
return authType === undefined || authType === null; 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 = " / "; const FOLDER_PATH_SEPARATOR = " / ";
/** /**
+18 -2
View File
@@ -31,8 +31,10 @@ import {
import { import {
applyHostKeyTypeUpdate, applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate, containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
keepsStoredSshAuth, keepsStoredSshAuth,
isNonEmptyString, isNonEmptyString,
isOptionalAuthType,
isOptionalBoolean, isOptionalBoolean,
isValidPort, isValidPort,
normalizeProtocolEnableFields, normalizeProtocolEnableFields,
@@ -259,7 +261,9 @@ router.post(
!isNonEmptyString(ip) || !isNonEmptyString(ip) ||
!isValidPort(port) || !isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) || !isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh) !isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod)
) { ) {
sshLogger.warn("Invalid SSH data input validation failed", { sshLogger.warn("Invalid SSH data input validation failed", {
operation: "host_create", operation: "host_create",
@@ -292,7 +296,9 @@ router.post(
const effectiveAuthType = const effectiveAuthType =
authType || authType ||
authMethod || authMethod ||
(effectiveConnectionType !== "ssh" ? "password" : undefined); (effectiveConnectionType !== "ssh"
? "password"
: impliedSshAuthType(hostData));
const effectiveUsername = const effectiveUsername =
username || firstProtocolUsername(protocolAuthPatch); username || firstProtocolUsername(protocolAuthPatch);
const effectiveName = const effectiveName =
@@ -403,6 +409,13 @@ router.post(
body: hostData, 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( const result = await createCurrentHostRepository().createEncryptedForUser(
userId, userId,
sshDataObj, sshDataObj,
@@ -473,6 +486,7 @@ router.post(
}); });
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost))); res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create"); notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
} catch (err) { } catch (err) {
sshLogger.error("Failed to save SSH host to database", err, { sshLogger.error("Failed to save SSH host to database", err, {
@@ -827,6 +841,8 @@ router.put(
!isValidPort(port) || !isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) || !isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh) || !isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod) ||
!hostId !hostId
) { ) {
sshLogger.warn("Invalid SSH data input validation failed for update", { 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 { parseSshOptions } from "../hosts/ssh-options.js";
import { hostTerminalExport } from "../database/routes/host-normalizers.js"; import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js"; import type { DisposableBag } from "./disposables.js";
import { pluginEvents, TOPICS } from "./events.js";
import { pluginLogger } from "../utils/logger.js"; import { pluginLogger } from "../utils/logger.js";
type AuditFn = ( type AuditFn = (
@@ -502,6 +503,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await writeSettings(created.id, writes); await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes); await markSettingsOwn(created.id, writes);
await audit("hosts_create", `host ${created.id}`, { success: true }); await audit("hosts_create", `host ${created.id}`, { success: true });
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
return ( return (
await toRecords([created as unknown as Record<string, unknown>]) await toRecords([created as unknown as Record<string, unknown>])
)[0]; )[0];
+3
View File
@@ -25,6 +25,7 @@ import {
import { getActor, getActorSessionId } from "./actor.js"; import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js"; import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js"; import { redactHostSecrets } from "./ctx-hosts.js";
import { issueUserToken } from "./ctx-users.js";
import { import {
getSshAuthProvider, getSshAuthProvider,
listSshAuthProviders, listSshAuthProviders,
@@ -901,5 +902,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
await import("../database/repositories/factory.js"); await import("../database/repositories/factory.js");
return createCurrentUserAuthRepository().countUsersForProvider(provider); 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 { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js"; import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js"; import { createPluginPlugins } from "./ctx-plugins.js";
import { createPluginUsers } from "./ctx-users.js";
import { createPluginCredentials } from "./ctx-credentials.js"; import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js"; import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -957,6 +958,7 @@ export function createPluginContext(
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }), auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }), system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
plugins: createPluginPlugins(manifest), plugins: createPluginPlugins(manifest),
users: createPluginUsers(manifest),
desktop: { desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request), openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
+2
View File
@@ -26,6 +26,8 @@ export const TOPICS = {
hostStatus: "host.status", hostStatus: "host.status",
/** A host's key was accepted again after it changed. */ /** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated", 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. */ /** A host's connection details changed and pollers should re-read them. */
hostUpdated: "host.updated", hostUpdated: "host.updated",
/** A host was deleted and any poller holding it should drop it. */ /** 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); all.push(...result.changes);
next = result.nextCursor; next = result.nextCursor;
this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type)); this.learnTypes(result);
this.state.serverTypes = result.entityTypes.map((entry) => ({
type: entry.type,
owner: entry.owner,
readOnly: entry.readOnly,
}));
if (!result.hasMore) break; if (!result.hasMore) break;
} }
return { changes: all, cursor: next }; 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> { private async pull(link: SyncLink): Promise<void> {
const { changes, cursor } = await this.fetchChanges(link, link.cursor); const { changes, cursor } = await this.fetchChanges(link, link.cursor);
const known = new Set(link.knownTypes); const known = new Set(link.knownTypes);
@@ -528,7 +532,14 @@ export class SyncEngine {
/** Pulls every row of types this device has not pulled before. */ /** Pulls every row of types this device has not pulled before. */
private async backfill(link: SyncLink): Promise<void> { private async backfill(link: SyncLink): Promise<void> {
if (!this.serverTypes) { 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() const fresh = listEntities()
.map((entity) => entity.type) .map((entity) => entity.type)
+5 -1
View File
@@ -22,6 +22,7 @@ import {
import { FieldCrypto } from "../utils/field-crypto.js"; import { FieldCrypto } from "../utils/field-crypto.js";
import { syncLogger } from "../utils/logger.js"; import { syncLogger } from "../utils/logger.js";
import { getPluginRuntime } from "../plugins/index.js"; import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { singletonSyncId } from "./wire.js"; import { singletonSyncId } from "./wire.js";
import { parseDefaultOverrides } from "../../types/host-defaults.js"; import { parseDefaultOverrides } from "../../types/host-defaults.js";
import { 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; if (id === null) return;
// The server owns host defaults: it keeps what the host set itself // 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. // and resolves the rest. A desktop takes the server's values as sent.
@@ -195,6 +196,9 @@ export function registerCoreSyncEntities(): void {
const { SharedHostSecretsManager } = const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js"); await import("../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHost(id); await SharedHostSecretsManager.getInstance().resyncHost(id);
if (created) {
pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId });
}
}, },
remove: async (row, userId) => { remove: async (row, userId) => {
const { deleteOwnedHost } = await import("../hosts/delete-host.js"); 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); 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( export async function listChanges(
userId: string, userId: string,
cursor: number, cursor: number,
@@ -148,7 +151,8 @@ export async function listChanges(
): Promise<SyncRecord[]> { ): Promise<SyncRecord[]> {
const conditions = [ const conditions = [
eq(syncRecords.userId, userId), 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) { if (types && types.length > 0) {
conditions.push(inArray(syncRecords.entityType, types)); conditions.push(inArray(syncRecords.entityType, types));
+19 -5
View File
@@ -37,6 +37,17 @@ function userOf(req: Request): string {
return (req as AuthenticatedRequest).userId; 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 * @openapi
* /sync/v2/info: * /sync/v2/info:
@@ -224,6 +235,8 @@ router.post(
* responses: * responses:
* 200: * 200:
* description: A page of changes, the next cursor and whether more remain. * 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: * 500:
* description: Failed to read changes. * description: Failed to read changes.
*/ */
@@ -232,11 +245,12 @@ router.get(
authenticateJWT, authenticateJWT,
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = userOf(req); const userId = userOf(req);
const cursor = Math.max(0, Number(req.query.cursor) || 0); const cursor = queryInt(req.query.cursor, 0);
const limit = Math.min( const pageSize = queryInt(req.query.limit, MAX_PAGE);
MAX_PAGE, if (cursor === null || pageSize === null) {
Math.max(1, Number(req.query.limit) || MAX_PAGE), return res.status(400).json({ error: "Invalid cursor or limit" });
); }
const limit = Math.min(MAX_PAGE, Math.max(1, pageSize));
const types = const types =
typeof req.query.types === "string" && req.query.types typeof req.query.types === "string" && req.query.types
? req.query.types.split(",").filter(Boolean) ? req.query.types.split(",").filter(Boolean)
@@ -48,6 +48,7 @@ function exportFile(): string {
async function invoke(path: string, user: unknown = { isExternal: false }) { async function invoke(path: string, user: unknown = { isExternal: false }) {
const writes: string[] = []; const writes: string[] = [];
const events: Array<[string, unknown]> = [];
const hosts: Array<Record<string, unknown>> = []; const hosts: Array<Record<string, unknown>> = [];
const importHostProtocolLogins = vi.fn(); const importHostProtocolLogins = vi.fn();
const handler = runInNewContext( const handler = runInNewContext(
@@ -98,6 +99,11 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
importHostDefaults: async () => ({ imported: 0, skipped: 0 }), importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
DatabaseSaveTrigger: { forceSave: async () => {} }, DatabaseSaveTrigger: { forceSave: async () => {} },
getErrorMessage: (error: Error) => error.message, 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() }; 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" } }, { userId: "user-1", file: { path, originalname: "export.db" } },
res, res,
); );
return { res, writes, hosts, importHostProtocolLogins }; return { res, writes, hosts, events, importHostProtocolLogins };
} }
it("points imported hosts at the credentials imported with them", async () => { 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 () => { it("removes the uploaded file when the user is gone", async () => {
const path = exportFile(); const path = exportFile();
const { res } = await invoke(path, null); 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); seen.push(payload);
}); });
try { try {
notifyImportedHosts("user-1", [4, 7]); notifyImportedHosts("user-1", [4, 7], []);
} finally { } finally {
off(); 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 { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload"; import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
let handler: RequestHandler; let handler: RequestHandler;
let sshConfigHandler: RequestHandler;
const router = { const router = {
post: (path: string, ...handlers: RequestHandler[]) => { post: (path: string, ...handlers: RequestHandler[]) => {
if (path === "/bulk-import") handler = handlers.at(-1)!; if (path === "/bulk-import") handler = handlers.at(-1)!;
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
}, },
put: () => {}, put: () => {},
patch: () => {}, patch: () => {},
@@ -66,12 +69,13 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mocks.create.mockResolvedValue({ id: 71 }); mocks.create.mockResolvedValue({ id: 71 });
mocks.update.mockResolvedValue({ id: 19 }); mocks.update.mockResolvedValue({ id: 19 });
mocks.list.mockResolvedValue([{ ...host, id: 19 }]); mocks.list.mockResolvedValue([]);
}); });
it.each([false, true])( it.each([false, true])(
"carries plugin host settings through export selection and import (overwrite=%s)", "carries plugin host settings through export selection and import (overwrite=%s)",
async (overwrite) => { async (overwrite) => {
if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const payload = buildExportPayload( const payload = buildExportPayload(
{ hosts: [host] }, { hosts: [host] },
null, null,
@@ -168,6 +172,60 @@ it.each([20, "20"])(
}, },
); );
it("skips hosts that already exist when not overwriting", async () => {
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const res = await importHosts([host]);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: 0, updated: 0, skipped: 1, failed: 0 }),
);
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.applyPluginSettings).not.toHaveBeenCalled();
});
it("maps a skipped jump host reference to its existing destination ID", async () => {
mocks.list.mockResolvedValue([{ ...host, ip: "192.0.2.2", id: 801 }]);
const res = await importHosts([
{ ...host, exportId: 10, jumpHosts: [{ hostId: 20 }] },
{ ...host, ip: "192.0.2.2", exportId: 20 },
]);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ success: 1, skipped: 1, failed: 0 }),
);
expect(mocks.create).toHaveBeenCalledTimes(1);
expect(mocks.update).not.toHaveBeenCalled();
expect(JSON.parse(mocks.create.mock.calls[0][1].jumpHosts)).toEqual([
{ hostId: 801 },
]);
});
it.each([
[false, { success: 0, updated: 0, skipped: 1 }],
[true, { success: 0, updated: 1, skipped: 0 }],
])(
"SSH config import with an existing host (overwrite=%s)",
async (overwrite, counts) => {
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const res = { json: vi.fn(), status: vi.fn().mockReturnThis() };
await sshConfigHandler(
{
userId: "user-1",
body: {
content: "Host web\n HostName 192.0.2.1\n User alice\n Port 22\n",
overwrite,
},
} as unknown as Request,
res as unknown as Response,
vi.fn(),
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ ...counts, failed: 0 }),
);
expect(mocks.create).not.toHaveBeenCalled();
expect(mocks.update).toHaveBeenCalledTimes(overwrite ? 1 : 0);
},
);
it.each([ it.each([
[{ ...host, jumpHosts: [{ hostId: 20 }] }], [{ ...host, jumpHosts: [{ hostId: 20 }] }],
[{ ...host, jumpHosts: [{ hostId: "20" }] }], [{ ...host, jumpHosts: [{ hostId: "20" }] }],
@@ -202,3 +260,53 @@ it("does not save dependent hosts when importing their jump host fails", async (
); );
expect(mocks.create).toHaveBeenCalledTimes(1); 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 { import {
applyHostKeyTypeUpdate, applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate, containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
isNonEmptyString, isNonEmptyString,
keepsStoredSshAuth, keepsStoredSshAuth,
isOptionalBoolean, isOptionalBoolean,
@@ -515,3 +516,16 @@ describe("keepsStoredSshAuth", () => {
expect(keepsStoredSshAuth("none")).toBe(false); 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" }); ).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", () => { it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
linked.setLinkedServer({ linked.setLinkedServer({
origin: ORIGIN, origin: ORIGIN,
@@ -207,6 +207,7 @@ vi.mock("../../hosts/delete-host.js", () => ({
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js"); const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
const { PluginCapabilityError } = const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend"); await import("@termix-ssh/plugin-sdk/backend");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
function manifest(capabilities: string[]) { function manifest(capabilities: string[]) {
return { 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 () => { it("refuses a field the SDK does not take instead of dropping it", async () => {
h.granted = new Set(["hosts:write"]); h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({ 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.every": "the plugin's own timers",
"schedule.after": "the plugin's own timers", "schedule.after": "the plugin's own timers",
"audit.record": "writes under the runtime's actor, never a plugin value", "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", currentActor: "reads the actor, cannot set it",
"desktop.available": "a yes/no about the environment", "desktop.available": "a yes/no about the environment",
"http.baseUrl": "reads the request's public origin", "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 }, server: null as null | { drizzle: unknown },
allow: true, allow: true,
sharedHostIds: new Set<number>(), sharedHostIds: new Set<number>(),
paths: [] as string[],
})); }));
const SERVER_USER = "srv-user"; const SERVER_USER = "srv-user";
@@ -123,6 +124,7 @@ vi.mock("../../sync/client/http.js", async (importOriginal) => {
path: string, path: string,
init: { method?: string; body?: unknown } = {}, init: { method?: string; body?: unknown } = {},
) => { ) => {
state.paths.push(path);
const previous = state.current; const previous = state.current;
state.current = state.server; state.current = state.server;
try { try {
@@ -161,6 +163,7 @@ const { createResolvers, writeWireRow } = await import("../../sync/store.js");
const { getEntity } = await import("../../plugins/sync-registry.js"); const { getEntity } = await import("../../plugins/sync-registry.js");
const { FieldCrypto } = await import("../../utils/field-crypto.js"); const { FieldCrypto } = await import("../../utils/field-crypto.js");
const { syncLogger } = await import("../../utils/logger.js"); const { syncLogger } = await import("../../utils/logger.js");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
const app = express(); const app = express();
app.use(express.json({ limit: "5mb" })); app.use(express.json({ limit: "5mb" }));
@@ -249,6 +252,7 @@ beforeEach(async () => {
resetSeqAllocator(); resetSeqAllocator();
state.allow = true; state.allow = true;
state.sharedHostIds.clear(); state.sharedHostIds.clear();
state.paths = [];
server = await side(SERVER_USER); server = await side(SERVER_USER);
state.server = server.ctx; state.server = server.ctx;
desktops = [await side("desk-a"), await side("desk-b")]; desktops = [await side("desk-a"), await side("desk-b")];
@@ -317,6 +321,60 @@ describe("linking a desktop", () => {
expect(host.ip).toBe("new"); expect(host.ip).toBe("new");
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]); 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", () => { 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); 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", () => { describe("getRequestMeta", () => {
it("uses the proxy-validated Express IP", () => { it("uses the proxy-validated Express IP", () => {
const req = { const req = {
+42
View File
@@ -35,7 +35,49 @@ export interface AuditLogParams {
errorMessage?: string; 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> { 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 // Local storage is the source of truth and runs first; forwarding is a copy
// and must never delay or fail the audited operation. // and must never delay or fail the audited operation.
void forwardAuditEntry(params).catch(() => {}); void forwardAuditEntry(params).catch(() => {});
+79
View File
@@ -15,6 +15,7 @@ import bcrypt from "bcryptjs";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { inArray, isNotNull } from "drizzle-orm"; import { inArray, isNotNull } from "drizzle-orm";
import type { ExternalSessionRef } from "../auth/types.js"; import type { ExternalSessionRef } from "../auth/types.js";
import { listSecondFactors } from "../auth/registry.js";
import type { DeviceType } from "./user-agent-parser.js"; import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js"; import { sessions } from "../database/db/schema.js";
@@ -44,6 +45,11 @@ interface JWTPayload {
pendingTOTP?: boolean; pendingTOTP?: boolean;
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */ /** Set on a short-lived socket ticket, which only a socket upgrade takes. */
purpose?: "ws"; 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; dataKeyWrap?: WrappedDataKey;
iat?: number; iat?: number;
exp?: 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-]*)(\/|$)/; 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; let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
/** Set by the plugin runtime: whether a running plugin opted in. */ /** Set by the plugin runtime: whether a running plugin opted in. */
@@ -407,6 +433,38 @@ class AuthManager {
} as jwt.SignOptions); } 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( async verifyJWTToken(
token: string, token: string,
options: { allowSocketTicket?: boolean } = {}, options: { allowSocketTicket?: boolean } = {},
@@ -440,6 +498,8 @@ class AuthManager {
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) { if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
return null; return null;
} }
payload.pluginSession =
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
await this.migrateDataKeyFromPayload(payload); await this.migrateDataKeyFromPayload(payload);
} catch (dbError) { } catch (dbError) {
@@ -787,6 +847,8 @@ class AuthManager {
}); });
} }
if (this.refusePluginSession(req, res, payload)) return;
if (payload.sessionId) { if (payload.sessionId) {
try { try {
const sessionRepository = createCurrentSessionRepository(); const sessionRepository = createCurrentSessionRepository();
@@ -953,6 +1015,21 @@ class AuthManager {
return false; 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() { createDataAccessMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
const authReq = req as AuthenticatedRequest; const authReq = req as AuthenticatedRequest;
@@ -1007,6 +1084,8 @@ class AuthManager {
}); });
} }
if (this.refusePluginSession(req, res, payload)) return;
try { try {
const user = await createCurrentUserRepository().findById( const user = await createCurrentUserRepository().findById(
payload.userId, payload.userId,
+27 -2
View File
@@ -6,6 +6,8 @@ import "./ui/index.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import "./ui/i18n/i18n"; import "./ui/i18n/i18n";
import { isElectron } from "@/lib/electron"; 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 { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
import { Toaster } from "@/components/sonner"; import { Toaster } from "@/components/sonner";
import { 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() { function FullscreenAppGate() {
const { t } = useTranslation(); const { t } = useTranslation();
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@@ -109,7 +120,16 @@ function FullscreenAppGate() {
if (!cancelled) setReady(true); if (!cancelled) setReady(true);
}) })
.catch(() => { .catch(() => {
if (!cancelled) setAuthFailed(true); if (cancelled) return;
if (signsInForViewLink()) {
rememberViewLink();
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
}
setAuthFailed(true);
}); });
return () => { return () => {
@@ -118,7 +138,7 @@ function FullscreenAppGate() {
}, []); }, []);
if (authFailed) { if (authFailed) {
return <FullscreenApp />; return signsInForViewLink() ? <App /> : <FullscreenApp />;
} }
if (!ready) { if (!ready) {
@@ -181,6 +201,7 @@ function App() {
// Non-fatal: WebSocket connections will fall back to cookie auth // Non-fatal: WebSocket connections will fall back to cookie auth
} }
} }
if (resumeViewLink()) return;
setPhase("loading-app"); setPhase("loading-app");
await Promise.all([ await Promise.all([
startPluginRuntime() startPluginRuntime()
@@ -262,6 +283,10 @@ function App() {
function handleLogin(u: string) { function handleLogin(u: string) {
loggingOutRef.current = false; loggingOutRef.current = false;
clearDesktopManualLogout(); clearDesktopManualLogout();
if (resumeViewLink()) {
setPhase("loading-app");
return;
}
setAuthUsername(u); setAuthUsername(u);
setPhase("loading-app"); setPhase("loading-app");
void (async () => { void (async () => {
+9 -14
View File
@@ -125,6 +125,7 @@ import type {
} from "@/types/ui-types"; } from "@/types/ui-types";
import { applyAccentColor, applyFontSize } from "@/lib/theme"; import { applyAccentColor, applyFontSize } from "@/lib/theme";
import { globalShortcutHandler } from "@/lib/global-shortcut-handler"; import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey"; import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
import { useTheme } from "@/components/theme-provider"; import { useTheme } from "@/components/theme-provider";
import { import {
@@ -829,20 +830,11 @@ export function AppShell({
} }
} }
// Alt+Arrow moves between panes of the active split // Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) { const paneDirection = getPaneNavDirection(e);
const direction = ( if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) {
{ e.preventDefault();
ArrowLeft: "left", return;
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down",
} as Record<string, NavDirection>
)[e.code];
if (direction && splitActionsRef.current?.navigate(direction)) {
e.preventDefault();
return;
}
} }
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position // 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)} {renderSidebarPanels(rightRailView, false)}
</DockPanel> </DockPanel>
)} )}
{/* Plugin panels pinned to the right edge, sized by the plugin */}
<ComponentSlot slotId="shell.rightEdge" />
</div> </div>
</div> </div>
+4 -5
View File
@@ -20,11 +20,10 @@ interface OriginResolvableHost {
* the desktop-wide default. * the desktop-wide default.
* *
* `defaultRemote` makes a host left on Default resolve to "remote" instead of * `defaultRemote` makes a host left on Default resolve to "remote" instead of
* following the desktop-wide setting. Remote desktop passes it: it needs a * following the desktop-wide setting. Remote desktop passes it when this
* guacd, which the desktop does not ship, so originating it here only works * desktop is set to use an external guacd, so a host is not moved onto a
* once the user has pointed Termix at one of their own. Making that opt-in * guacd that isn't there (see Termix-SSH/Support#1240). With the built-in
* per host keeps an upgrade from moving working connections onto a guacd * engine it runs locally like SSH.
* that isn't there -- see Termix-SSH/Support#1240.
*/ */
export async function resolveConnectionOrigin( export async function resolveConnectionOrigin(
host: OriginResolvableHost, host: OriginResolvableHost,
+3 -1
View File
@@ -1,4 +1,5 @@
import { getBasePath } from "./base-path"; import { getBasePath } from "./base-path";
import { localBackendPort } from "./electron";
type BrowserLocation = Pick< type BrowserLocation = Pick<
Location, Location,
@@ -16,7 +17,8 @@ export function getDatabaseTransferUrl(
{ electron, configuredServerUrl, location }: DatabaseTransferUrlOptions, { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions,
): string { ): string {
if (electron) { if (electron) {
const serverUrl = configuredServerUrl || "http://localhost:30001"; const serverUrl =
configuredServerUrl || `http://localhost:${localBackendPort()}`;
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`; return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
} }
+18
View File
@@ -3,6 +3,7 @@ type ElectronWindow = Window &
IS_ELECTRON?: boolean; IS_ELECTRON?: boolean;
electronAPI?: { electronAPI?: {
isElectron?: boolean; isElectron?: boolean;
backendPort?: number;
}; };
}; };
@@ -16,3 +17,20 @@ export function isElectron(): boolean {
return hasISElectron || hasElectronAPI || isElectronProp; 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. * Where a plugin's i18n keys resolve from.
* *
@@ -19,3 +21,25 @@ export function pluginKey(
if (!key) return key; if (!key) return key;
return key.includes(":") ? key : `${pluginId}:${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 type { AxiosInstance } from "axios";
import { authApi, createRemoteOriginApiInstance } from "@/main-axios"; import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron"; import { isElectron, localBackendPort } from "@/lib/electron";
import { getDeviceId } from "@/lib/device-id"; import { getDeviceId } from "@/lib/device-id";
import { websocketAuthProtocols } from "@/lib/ws-auth"; import { websocketAuthProtocols } from "@/lib/ws-auth";
import { import {
@@ -165,7 +165,7 @@ export async function pluginWsUrl(
if (isElectron()) { if (isElectron()) {
return buildOriginWsUrl({ return buildOriginWsUrl({
origin: options.origin ?? "local", origin: options.origin ?? "local",
localPort: BACKEND_PORT, localPort: localBackendPort(),
localPath: route, localPath: route,
remotePath: 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 { toast } from "sonner";
import i18n from "i18next"; import i18n from "i18next";
import { getBasePath } from "@/lib/base-path"; import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron"; import { isElectron, localBackendPort } from "@/lib/electron";
import { clearTermixSessionStorage } from "@/shell/TabContext"; import { clearTermixSessionStorage } from "@/shell/TabContext";
import type { SSHHost } from "@/types/index"; import type { SSHHost } from "@/types/index";
@@ -603,7 +603,7 @@ function getApiUrl(path: string, defaultPort: number): string {
if (electronMode) { if (electronMode) {
// The desktop app always talks to its embedded backend. The server it // The desktop app always talks to its embedded backend. The server it
// may be linked to is reached through createRemoteOriginApiInstance. // may be linked to is reached through createRemoteOriginApiInstance.
return `http://localhost:${defaultPort}${path}`; return `http://localhost:${localBackendPort()}${path}`;
} else if (devMode) { } else if (devMode) {
if (!import.meta.env.VITE_API_HOST) { if (!import.meta.env.VITE_API_HOST) {
return `/__termix_api/${defaultPort}${path}`; return `/__termix_api/${defaultPort}${path}`;
+10 -7
View File
@@ -20,6 +20,7 @@ import {
recommendedChoices, recommendedChoices,
} from "../plugin-choices"; } from "../plugin-choices";
import { DocsLink } from "@/components/docs-link"; import { DocsLink } from "@/components/docs-link";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
const CATEGORY_ORDER = [ const CATEGORY_ORDER = [
"Terminal", "Terminal",
@@ -79,8 +80,8 @@ export function PluginsStep({
}, [list, choices]); }, [list, choices]);
const names = useMemo( const names = useMemo(
() => new Map(list?.plugins.map((p) => [p.id, p.name]) ?? []), () => new Map(list?.plugins.map((p) => [p.id, pluginName(t, p)]) ?? []),
[list], [list, t],
); );
const changed = useMemo(() => { const changed = useMemo(() => {
@@ -260,7 +261,7 @@ export function PluginsStep({
/> />
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium"> <span className="flex items-center gap-1.5 text-xs font-medium">
{plugin.name} {pluginName(t, plugin)}
{plugin.recommended && ( {plugin.recommended && (
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand"> <span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
{t("onboarding.pluginsRecommended")} {t("onboarding.pluginsRecommended")}
@@ -268,7 +269,7 @@ export function PluginsStep({
)} )}
</span> </span>
<span className="text-[10px] leading-snug text-muted-foreground"> <span className="text-[10px] leading-snug text-muted-foreground">
{plugin.description} {pluginDescription(t, plugin)}
</span> </span>
{forcedBy.has(plugin.id) && ( {forcedBy.has(plugin.id) && (
<span className="text-[10px] text-accent-brand"> <span className="text-[10px] text-accent-brand">
@@ -281,7 +282,7 @@ export function PluginsStep({
</div> </div>
<div <div
role="radiogroup" role="radiogroup"
aria-label={plugin.name} aria-label={pluginName(t, plugin)}
className="flex shrink-0 border border-border" className="flex shrink-0 border border-border"
> >
{CHOICES.map((option) => ( {CHOICES.map((option) => (
@@ -335,9 +336,11 @@ export function PluginsStep({
} }
/> />
<div className="flex flex-col gap-0.5"> <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"> <span className="text-[10px] leading-snug text-muted-foreground">
{plugin.description} {pluginDescription(t, plugin)}
</span> </span>
<span className="text-[10px] text-muted-foreground/80"> <span className="text-[10px] text-muted-foreground/80">
{t("onboarding.pluginsConsentNote")} {t("onboarding.pluginsConsentNote")}
+2 -1
View File
@@ -2,6 +2,7 @@ import { CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { usePermissions } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions";
import { pluginName } from "@/lib/plugin-i18n";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins"; import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { usePluginStore } from "./plugin-store"; import { usePluginStore } from "./plugin-store";
import { unregisteredViewStatus, type ViewKind } from "./view-ownership"; import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
@@ -27,7 +28,7 @@ export function PluginViewPlaceholder({
usePluginStore(); usePluginStore();
const { has } = usePermissions(); const { has } = usePermissions();
const { status, owner } = unregisteredViewStatus(kind, viewId); const { status, owner } = unregisteredViewStatus(kind, viewId);
const name = owner?.summary.name ?? viewId; const name = owner ? pluginName(t, owner.summary) : viewId;
if (status === "loading") { if (status === "loading") {
return ( return (
+3 -1
View File
@@ -10,6 +10,7 @@ import {
} from "react"; } from "react";
import { scopeHostFields } from "./host-scope"; import { scopeHostFields } from "./host-scope";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { pluginName } from "@/lib/plugin-i18n";
import { getPluginRecord } from "./plugin-store"; import { getPluginRecord } from "./plugin-store";
const PluginScopeContext = createContext<string | null>(null); const PluginScopeContext = createContext<string | null>(null);
@@ -27,7 +28,8 @@ function CrashNotice({
onRetry: () => void; onRetry: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const name = getPluginRecord(pluginId)?.summary.name ?? pluginId; const summary = getPluginRecord(pluginId)?.summary;
const name = summary ? pluginName(t, summary) : pluginId;
return ( return (
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground"> <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> <span>{t("plugins.runtime.crashed", { name })}</span>
+10 -2
View File
@@ -25,6 +25,7 @@ import {
type RegistryListing, type RegistryListing,
} from "@/api/plugins-api"; } from "@/api/plugins-api";
import { useConfirm } from "@/components/surface/surface-scope"; import { useConfirm } from "@/components/surface/surface-scope";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model"; import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
import type { ConsentRequest } from "./PluginConsentPrompt"; import type { ConsentRequest } from "./PluginConsentPrompt";
@@ -109,9 +110,16 @@ export function usePluginsManager() {
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange); 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( 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( const run = useCallback(
+3 -7
View File
@@ -11,7 +11,7 @@ import { toast } from "sonner";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card"; import { SectionCard } from "@/components/section-card";
import { AccordionSection } from "@/sidebar/AdminSettingsShared"; 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 { PluginIcon } from "@/lib/plugin-icon";
import { import {
getPluginAdminSettings, getPluginAdminSettings,
@@ -68,9 +68,7 @@ export function FeatureSettingsSection({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<AccordionSection <AccordionSection
label={t(pluginKey(plugin.id, "plugin.name"), { label={pluginName(t, plugin)}
defaultValue: plugin.name,
})}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />} icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open} open={open}
onToggle={onToggle} onToggle={onToggle}
@@ -198,9 +196,7 @@ export function FeatureSettingsForm({
{!running && ( {!running && (
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> <div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", { {t("settings.featureUnavailable", {
name: t(pluginKey(plugin.id, "plugin.name"), { name: pluginName(t, plugin),
defaultValue: plugin.name,
}),
})} })}
</div> </div>
)} )}
+5 -2
View File
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card"; import { SectionCard } from "@/components/section-card";
import { DocsLink } from "@/components/docs-link"; import { DocsLink } from "@/components/docs-link";
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { pluginName } from "@/lib/plugin-i18n";
import { type PluginSummary } from "@/api/plugins-api"; import { type PluginSummary } from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins"; import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
import { usePluginScope } from "@/plugin-host/scope"; import { usePluginScope } from "@/plugin-host/scope";
@@ -141,13 +142,15 @@ function HostPluginSection({
return ( return (
<SectionCard <SectionCard
title={plugin.name} title={pluginName(t, plugin)}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />} icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
action={<DocsLink href={plugin.docs} />} action={<DocsLink href={plugin.docs} />}
> >
{!running && ( {!running && (
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"> <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> </div>
)} )}
+2 -4
View File
@@ -18,7 +18,7 @@ import { EmptyState } from "@/components/empty-state";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { reportCoreIssueUrl } from "@/lib/issue-url"; import { reportCoreIssueUrl } from "@/lib/issue-url";
import { cn } from "@/lib/utils"; 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 { PluginIcon } from "@/lib/plugin-icon";
import type { Host } from "@/types/ui-types"; import type { Host } from "@/types/ui-types";
import type { UserPreferences } from "@/main-axios"; import type { UserPreferences } from "@/main-axios";
@@ -121,9 +121,7 @@ export function SettingsTab({
id: `plugin:${plugin.id}`, id: `plugin:${plugin.id}`,
band: "plugins", band: "plugins",
labelKey: pluginKey(plugin.id, "plugin.name"), labelKey: pluginKey(plugin.id, "plugin.name"),
label: t(pluginKey(plugin.id, "plugin.name"), { label: pluginName(t, plugin),
defaultValue: plugin.name,
}),
icon: SettingsIcon, icon: SettingsIcon,
pluginId: plugin.id, pluginId: plugin.id,
searchKeys: fields.flatMap((field) => searchKeys: fields.flatMap((field) =>
+2 -1
View File
@@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model"; import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins"; import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs"; import { docsUrl } from "@/lib/docs";
import { pluginName } from "@/lib/plugin-i18n";
import { DesktopUpdatesCard } from "./DesktopUpdatesCard"; import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta"; const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
@@ -262,7 +263,7 @@ function InstanceUpdates() {
{betaPlugins.map((plugin) => ( {betaPlugins.map((plugin) => (
<SettingRow <SettingRow
key={plugin.id} key={plugin.id}
label={plugin.name} label={pluginName(t, plugin)}
description={ description={
plugin.channel === "beta" plugin.channel === "beta"
? `v${plugin.version}` ? `v${plugin.version}`
+2 -1
View File
@@ -7,6 +7,7 @@
*/ */
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { pluginKey } from "@/lib/plugin-i18n";
import { import {
hostEditorSectionList, hostEditorSectionList,
registerHostEditorSection, registerHostEditorSection,
@@ -95,7 +96,7 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
pluginId: plugin.id, pluginId: plugin.id,
group: placement?.group ?? host.editorGroup ?? "top", group: placement?.group ?? host.editorGroup ?? "top",
order: placement?.order ?? host.editorOrder ?? 100, order: placement?.order ?? host.editorOrder ?? 100,
labelKey: plugin.name, labelKey: pluginKey(plugin.id, "plugin.name"),
label: plugin.name, label: plugin.name,
// Nothing but the plugin's host settings, so it works for defaults too. // Nothing but the plugin's host settings, so it works for defaults too.
defaults: ownSections.has(plugin.id) ? "only" : true, defaults: ownSections.has(plugin.id) ? "only" : true,
+2 -1
View File
@@ -18,6 +18,7 @@ import {
type SplitPresetId, type SplitPresetId,
} from "./split-presets"; } from "./split-presets";
import { MAX_PANES, type PaneEdge } from "./split-tree"; import { MAX_PANES, type PaneEdge } from "./split-tree";
import { paneNavHotkeyLabel } from "@/lib/pane-nav-hotkey";
function NodeThumb({ node }: { node: LayoutNode }) { function NodeThumb({ node }: { node: LayoutNode }) {
if (node.kind === "pane") { 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 flex-col gap-1 px-2 pb-1.5 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span>{t("splitScreen.shortcutNavigate")}</span> <span>{t("splitScreen.shortcutNavigate")}</span>
<kbd className="font-mono text-[10px]">Alt+Arrows</kbd> <kbd className="font-mono text-[10px]">{paneNavHotkeyLabel()}</kbd>
</div> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span>{t("splitScreen.zoom")}</span> <span>{t("splitScreen.zoom")}</span>
+1 -1
View File
@@ -730,7 +730,7 @@ export function HostEditor({
<EditorSection <EditorSection
key={id} key={id}
id={id} id={id}
label={section.label ?? t(section.labelKey)} label={t(section.labelKey, { defaultValue: section.label })}
> >
<LazySection> <LazySection>
<Section <Section
+6 -6
View File
@@ -45,7 +45,7 @@ export interface HostEditorSectionDef {
pluginId?: string; pluginId?: string;
group: "top" | "ssh"; group: "top" | "ssh";
labelKey: string; labelKey: string;
/** Already-translated label, used instead of labelKey when set. */ /** Shown when labelKey has no translation. */
label?: string; label?: string;
icon?: ComponentType<{ className?: string }>; icon?: ComponentType<{ className?: string }>;
/** Position among the group's tabs, core ones included. */ /** Position among the group's tabs, core ones included. */
@@ -88,7 +88,7 @@ function sectionVisible(
function mergeByOrder( function mergeByOrder(
core: (HostTab & { order: number })[], core: (HostTab & { order: number })[],
group: "top" | "ssh", group: "top" | "ssh",
t: (key: string) => string, t: (key: string, options?: { defaultValue?: string }) => string,
protocols: Record<string, boolean> | undefined, protocols: Record<string, boolean> | undefined,
defaultsMode = false, defaultsMode = false,
): HostTab[] { ): HostTab[] {
@@ -104,7 +104,7 @@ function mergeByOrder(
const Icon = section.icon; const Icon = section.icon;
return { return {
id: section.id, id: section.id,
label: section.label ?? t(section.labelKey), label: t(section.labelKey, { defaultValue: section.label }),
icon: Icon ? <Icon className="size-3" /> : null, icon: Icon ? <Icon className="size-3" /> : null,
order: section.order ?? 1000, order: section.order ?? 1000,
}; };
@@ -119,7 +119,7 @@ function mergeByOrder(
* `visible`; without, every registered top tab is listed. * `visible`; without, every registered top tab is listed.
*/ */
export function makeHostTabs( export function makeHostTabs(
t: (key: string) => string, t: (key: string, options?: { defaultValue?: string }) => string,
protocols?: Record<string, boolean>, protocols?: Record<string, boolean>,
defaultsMode = false, defaultsMode = false,
): HostTab[] { ): HostTab[] {
@@ -146,7 +146,7 @@ export function makeHostTabs(
} }
export function makeHostSshSubTabs( export function makeHostSshSubTabs(
t: (key: string) => string, t: (key: string, options?: { defaultValue?: string }) => string,
protocols?: Record<string, boolean>, protocols?: Record<string, boolean>,
defaultsMode = false, defaultsMode = false,
): HostTab[] { ): HostTab[] {
@@ -167,7 +167,7 @@ export function makeHostSshSubTabs(
} }
export function makeCredentialTabs( export function makeCredentialTabs(
t: (key: string) => string, t: (key: string, options?: { defaultValue?: string }) => string,
): CredentialTab[] { ): CredentialTab[] {
return [ 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 { sortHostTree, type SortKey } from "@/sidebar/host-sort";
import { withLiveStatus } from "@/sidebar/live-host-status"; import { withLiveStatus } from "@/sidebar/live-host-status";
import { useServerStatus } from "@/lib/ServerStatusContext"; import { useServerStatus } from "@/lib/ServerStatusContext";
import { pluginName } from "@/lib/plugin-i18n";
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences"; import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock"; import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import type { import type {
@@ -816,7 +817,7 @@ export function HostsPanel({
} }
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
{plugin.name} {pluginName(t, plugin)}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} ))}
{allTags.length > 0 && ( {allTags.length > 0 && (
+3 -1
View File
@@ -12,6 +12,7 @@ import {
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext"; import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset"; import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { pluginName } from "@/lib/plugin-i18n";
import { usePluginStore } from "@/plugin-host/plugin-store"; import { usePluginStore } from "@/plugin-host/plugin-store";
import { import {
UI_AREA_KEYS, UI_AREA_KEYS,
@@ -67,9 +68,10 @@ export function InterfacePresetSettings({
) )
.map((area) => { .map((area) => {
const pluginId = area.slice("plugin:".length); const pluginId = area.slice("plugin:".length);
const summary = pluginStore.records.get(pluginId)?.summary;
return { return {
key: area, key: area,
label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId, label: summary ? pluginName(t, summary) : pluginId,
}; };
}); });
return [...core, ...plugins]; return [...core, ...plugins];
+3 -2
View File
@@ -11,6 +11,7 @@ import {
import { updatePluginHostSettings } from "@/api/plugins-api"; import { updatePluginHostSettings } from "@/api/plugins-api";
import { resetHostToDefaults } from "@/api/host-defaults-api"; import { resetHostToDefaults } from "@/api/host-defaults-api";
import { PluginIcon } from "@/lib/plugin-icon"; import { PluginIcon } from "@/lib/plugin-icon";
import { pluginName } from "@/lib/plugin-i18n";
import { usePluginHostSections } from "@/settings/HostPluginSections"; import { usePluginHostSections } from "@/settings/HostPluginSections";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
@@ -1284,7 +1285,7 @@ export function SidebarTree({
value value
? "hosts.enablePluginFeature" ? "hosts.enablePluginFeature"
: "hosts.disablePluginFeature", : "hosts.disablePluginFeature",
{ name: plugin.name }, { name: pluginName(t, plugin) },
)} )}
</DropdownMenuItem> </DropdownMenuItem>
)), )),
@@ -1307,7 +1308,7 @@ export function SidebarTree({
{ id: "core", label: t("hostDefaults.resetCore") }, { id: "core", label: t("hostDefaults.resetCore") },
...hostSettingPlugins.map((plugin) => ({ ...hostSettingPlugins.map((plugin) => ({
id: plugin.id, id: plugin.id,
label: plugin.name, label: pluginName(t, plugin),
})), })),
].map((option) => ( ].map((option) => (
<DropdownMenuItem <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 { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react"; 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"; import type { OnboardingPluginList } from "@/api/plugins-api";
const api = vi.hoisted(() => ({ const api = vi.hoisted(() => ({
@@ -186,4 +188,38 @@ describe("PluginsStep", () => {
expect(ok).toBe(true); expect(ok).toBe(true);
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1); 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", () => ({ vi.mock("@/components/surface/surface-scope", () => ({
useConfirm: () => vi.fn(async () => true), useConfirm: () => vi.fn(async () => true),
})); }));
vi.mock("react-i18next", () => ({ const i18n = vi.hoisted(() => {
useTranslation: () => ({ t: (key: string) => key }), 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() } })); vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
import { usePluginsManager } from "@/plugins/use-plugins-manager"; 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"] }, { 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", () => ({ 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 { function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
@@ -75,9 +78,10 @@ describe("syncHostFeatureTabs", () => {
"feature:docker", "feature:docker",
"feature:warpgate", "feature:warpgate",
]); ]);
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe( expect(getHostEditorSection(hostFeatureTabId("docker"))).toMatchObject({
"Docker", labelKey: "docker:plugin.name",
); label: "Docker",
});
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined(); expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
}); });