Compare commits

..
35 Commits
Author SHA1 Message Date
LukeGus 7066215bd9 feat: plugin builds bundle worker entries and import wasm and fonts as bytes 2026-10-16 21:43:07 -05:00
LukeGus 214372d104 chore: update the overview video and add a play button to its thumbnail 2026-10-16 18:20:53 -05:00
LukeGus bb9bca5303 chore: add the overview video to the readme 2026-10-16 16:24:11 -05:00
LukeGus cf62f108d2 chore: update translations 2026-10-16 15:14:15 +00:00
LukeGus 5cc65d87cd feat(sdk): let a plugin raise its public route rate limit 2026-10-15 22:18:48 -05:00
LukeGus e166994ec4 fix: use the vite proxy for plugin sockets on any dev port 2026-10-15 22:05:08 -05:00
LukeGus 3a851ad189 feat: setting to let the hover side menu push content again 2026-10-15 22:00:24 -05:00
LukeGus d2851982a6 fix(sdk): skip comment-only chunks when splitting migrations 2026-10-15 21:52:11 -05:00
LukeGus cc5dce18e2 fix: show why a client tunnel failed to start (Plugin-Tunnels#2) 2026-10-15 21:42:28 -05:00
LukeGus 6084ed4d3c fix: say the server is down and keep retrying instead of a database error (#1637) 2026-10-15 21:40:07 -05:00
LukeGus c3f6db69e9 fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2) 2026-10-15 21:15:36 -05:00
LukeGus 8e2221cb8f feat: sort the dashboard host status card (Plugin-Host-Metrics#6) 2026-10-15 21:11:58 -05:00
LukeGus 7324982f47 feat: option to hide the sidebar after opening a host (#1652) 2026-10-15 21:06:47 -05:00
LukeGus f20d64b4f2 chore: test that an update keeps a changed auth type (#1601) 2026-10-15 21:04:04 -05:00
LukeGus aa0b32a142 fix: stop building ssh2 native crypto in the docker image (#1644) 2026-10-15 21:02:54 -05:00
LukeGus c8a907538e chore: add changelog entries for 26.10.2 fixes 2026-10-15 21:02:13 -05:00
LukeGus dd5573cc10 fix: smooth host editor jumps to lower sections 2026-10-15 15:08:40 -05:00
ZacharyZcR 071711dafc 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
2026-10-15 14:59:59 +08:00
ZacharyZcR bc74fba3f5 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.
2026-10-15 14:55:00 +08:00
Ferdina Kusumah 694dc34b19 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.
2026-10-15 14:24:11 +08:00
ZacharyZcR b3b08b4ff2 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
2026-10-14 09:11:55 +08:00
ZacharyZcR 9b0af07607 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.
2026-10-14 08:59:57 +08:00
ZacharyZcR c044dbde87 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.
2026-10-14 08:59:52 +08:00
ZacharyZcR 1396b3a70c 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.
2026-10-14 08:59:46 +08:00
ZacharyZcR 4c9ee5923e fix(sidebar): sort hosts by IP address numerically (#1629) 2026-10-14 08:59:40 +08:00
ZacharyZcR 25e58a6425 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
2026-10-14 08:22:15 +08:00
ZacharyZcR 7d13b2a9fe 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
2026-10-14 08:20:09 +08:00
ZacharyZcR e610aba0e5 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
2026-10-14 08:17:47 +08:00
ZacharyZcR 3941dd4ecc 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.
2026-10-14 08:13:04 +08:00
ZacharyZcR 872e5eba73 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.
2026-10-14 08:12:58 +08:00
ZacharyZcR 8c3f816407 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.
2026-10-14 08:12:53 +08:00
ZacharyZcR 13f7aa95a3 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.
2026-10-14 08:12:47 +08:00
ZacharyZcR 8e5a50623c fix(electron): validate external login URL scheme and callback port (#1621) 2026-10-14 08:12:42 +08:00
ZacharyZcR d791e52d82 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.
2026-10-14 08:12:37 +08:00
ZacharyZcR fb04edc497 fix: keep concurrent ssh pool connects from overwriting each other (#1619) 2026-10-14 08:12:16 +08:00
95 changed files with 331 additions and 2615 deletions

No files matched your search

+4 -33
View File
@@ -143,12 +143,12 @@ jobs:
- name: Build Windows (All Architectures)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never
run: npm run build && npx electron-builder --win --x64 --ia32 --publish never
# The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
# The combined multi-arch installer is never uploaded.
# The updater only installs the x64 NSIS build; 32-bit installs get a link.
# The combined x64+ia32 installer is never uploaded.
- name: Fix Windows update files
run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
run: node scripts/fix-update-info.cjs --drop ia32 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
- name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v7
@@ -166,14 +166,6 @@ jobs:
path: release/termix_windows_ia32_nsis.exe
retention-days: 30
- name: Upload Windows arm64 NSIS Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_nsis
path: release/termix_windows_arm64_nsis.exe
retention-days: 30
- name: Upload Windows x64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
@@ -190,14 +182,6 @@ jobs:
path: release/termix_windows_ia32_msi.msi
retention-days: 30
- name: Upload Windows arm64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_msi.msi') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_msi
path: release/termix_windows_arm64_msi.msi
retention-days: 30
- name: Create Windows x64 Portable zip
if: hashFiles('release/win-unpacked/*') != ''
run: |
@@ -208,11 +192,6 @@ jobs:
run: |
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
- name: Create Windows arm64 Portable zip
if: hashFiles('release/win-arm64-unpacked/*') != ''
run: |
Compress-Archive -Path "release\win-arm64-unpacked\*" -DestinationPath "termix_windows_arm64_portable.zip"
- name: Upload Windows update files
uses: actions/upload-artifact@v7
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
@@ -240,14 +219,6 @@ jobs:
path: termix_windows_ia32_portable.zip
retention-days: 30
- name: Upload Windows arm64 Portable
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_arm64_portable.zip') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_portable
path: termix_windows_arm64_portable.zip
retention-days: 30
build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
-13
View File
@@ -6,16 +6,10 @@
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
- Sort the dashboard's Host Status card by name, IP address or status
- Windows on ARM builds of the desktop app (installer, MSI and portable)
- Plugins get an event when a host is added
- Plugins can start a short-lived session as a user and read the user's language, for tasks that run without a browser
- Plugins can pin a panel to the right edge of the window that pushes the page over
### Changed
- The Docker Compose files no longer start a guacd container. npm run dev -- --guacd also points remote desktop at the guacd it starts
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal
### Fixed
@@ -40,13 +34,6 @@
- nginx answered 404 instead of 502 while the backend was down
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
- Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language
- Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required
- The desktop app would not start its local server when another program used port 30001. It now picks a free port on its own
- A linked desktop could never finish syncing with a server on Postgres
- Routine plugin activity filled the audit log and pushed out real security entries
- Opening a full-screen view link while signed out showed a spinner forever. It now shows the login and opens the link after
- A linked desktop behind a proxy that needs extra headers, or with an allowed self-signed certificate, could not open remote terminals or tunnels
## 26.10.1
+22 -1
View File
@@ -137,7 +137,7 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
See the [docs](https://docs.termix.site/install) for install steps on every platform.
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
```yaml
services:
@@ -151,10 +151,31 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
volumes:
termix-data:
driver: local
networks:
termix-net:
driver: bridge
```
### Cloud Hosting
+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 npm cache clean --force && \
TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build
NODE_OPTIONS="--max-old-space-size=3072" npm run build
# Stage 3: Build backend
FROM deps AS backend-builder
+68 -68
View File
@@ -3,20 +3,20 @@
{
"id": "acme-ssl",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug",
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug",
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug",
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a",
"onboarding": {
"recommended": true,
"consent": false
@@ -25,14 +25,14 @@
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug",
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug",
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3",
"onboarding": {
"recommended": true,
"consent": false
@@ -41,8 +41,8 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug",
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6",
"onboarding": {
"recommended": true,
"consent": false
@@ -51,20 +51,20 @@
{
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug",
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug",
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32"
},
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug",
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5",
"onboarding": {
"recommended": true,
"consent": false
@@ -73,32 +73,32 @@
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug",
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c"
},
{
"id": "network-topology",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug",
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug",
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug",
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7"
},
{
"id": "remote-desktop",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug",
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39",
"onboarding": {
"recommended": true,
"consent": false
@@ -107,14 +107,14 @@
{
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug",
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug",
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121",
"onboarding": {
"recommended": "desktop",
"consent": false
@@ -123,20 +123,20 @@
{
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug",
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7"
},
{
"id": "session-sharing",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug",
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug",
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f",
"onboarding": {
"recommended": true,
"consent": false
@@ -145,8 +145,8 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug",
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f",
"onboarding": {
"recommended": true,
"consent": false
@@ -155,8 +155,8 @@
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug",
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b",
"onboarding": {
"recommended": false,
"consent": false,
@@ -166,20 +166,20 @@
{
"id": "step-ca",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug",
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug",
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug",
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469",
"onboarding": {
"recommended": true,
"consent": true
@@ -188,20 +188,20 @@
{
"id": "termix-identity",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug",
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0"
},
{
"id": "tmux-monitor",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug",
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug",
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474",
"onboarding": {
"recommended": true,
"consent": false
@@ -210,8 +210,8 @@
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug",
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860",
"onboarding": {
"recommended": true,
"consent": false
@@ -220,32 +220,32 @@
{
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug",
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a"
},
{
"id": "wake-on-lan",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug",
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug",
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8"
},
{
"id": "web-endpoint",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug",
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug",
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464",
"onboarding": {
"recommended": true,
"consent": false
@@ -254,8 +254,8 @@
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug",
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153",
"onboarding": {
"recommended": true,
"consent": false
+15
View File
@@ -12,9 +12,24 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
GUACD_HOST: "guacd-dev"
GUACD_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on:
- guacd-dev
networks:
- termix-dev-net
guacd-dev:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
volumes:
- termix-dev-data:/termix-data
networks:
- termix-dev-net
+17
View File
@@ -9,6 +9,12 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# Where guacd stores files for RDP drive redirection (one folder per
# user is created underneath). Must be writable by guacd's user.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# Multi-instance deployments should point every backend at the same
# Redis. Without it, collaboration rooms keep their single-instance
# in-memory fallback.
@@ -30,6 +36,17 @@ services:
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
+2 -2
View File
@@ -46,11 +46,11 @@
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32", "arm64"]
"arch": ["x64", "ia32"]
},
{
"target": "msi",
"arch": ["x64", "ia32", "arm64"]
"arch": ["x64", "ia32"]
}
],
"icon": "public/icon.ico",
+1 -8
View File
@@ -6,16 +6,9 @@
let linked = null;
const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" };
// A WebSocket to the linked server (wss://host/...) counts as the link's
// https://host origin, so its upgrade carries the proxy headers too.
function getOrigin(url) {
try {
const parsed = new URL(url);
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
if (httpProtocol) parsed.protocol = httpProtocol;
return parsed.origin;
return new URL(url).origin;
} catch {
return null;
}
+2 -8
View File
@@ -330,11 +330,7 @@ const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
// which keeps every transfer on the backend's plugin routes.
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
let localBackendPort = 30001;
function setLocalBackendPort(port) {
localBackendPort = port;
}
const LOCAL_BACKEND = "http://localhost:30001";
let registeredApiPath = null;
function setTransferApiPath(apiPath) {
@@ -407,8 +403,7 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
}
return {
url: `${normalizeHttpBase(
localBaseUrl ??
`http://localhost:${localBackendPort}${resolveApiPath()}`,
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
"Local backend URL",
)}${routePath}`,
headers,
@@ -1055,7 +1050,6 @@ module.exports = {
createLocalFileHandlers,
createTargetResolver,
setTransferApiPath,
setLocalBackendPort,
publishDownload,
defaultPublishFs,
assertWithinRoot,
+11 -39
View File
@@ -35,10 +35,7 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
const {
registerLocalFileHandlers,
setLocalBackendPort,
} = require("./local-files.cjs");
const { registerLocalFileHandlers } = require("./local-files.cjs");
const {
EXTERNAL_EDITOR_SETTING,
isLaunchableFileName,
@@ -442,6 +439,14 @@ function isInsecureModeEnabled() {
);
}
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function isPrivateNetworkHost(hostname) {
if (
hostname === "localhost" ||
@@ -485,7 +490,7 @@ function isInvalidCertificateAllowedForUrl(url) {
// allows a self-signed certificate.
const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false;
return linkedServer.isLinkedOrigin(url);
return getOrigin(url) === linked.origin;
}
function getTlsVerificationOptions(url) {
@@ -1119,35 +1124,6 @@ function reapOrphanedBackendProcess() {
}
}
const DEFAULT_BACKEND_PORT = 30001;
let backendPort = DEFAULT_BACKEND_PORT;
function canListenOn(port) {
return new Promise((resolve) => {
const probe = net.createServer();
probe.once("error", () => resolve(null));
probe.listen(port, "127.0.0.1", () => {
const { port: bound } = probe.address();
probe.close(() => resolve(bound));
});
});
}
// 30001 when it is free, otherwise any free port. Only the desktop app talks
// to this backend, so the number itself does not matter.
async function findBackendPort() {
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
if (preferred) return preferred;
const fallback = await canListenOn(0);
if (fallback) {
logToFile(
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
);
return fallback;
}
return DEFAULT_BACKEND_PORT;
}
function startBackendServer() {
reapOrphanedBackendProcess();
backendFailure = null;
@@ -1188,8 +1164,7 @@ function startBackendServer() {
DATA_DIR: dataDir,
NODE_ENV: "production",
ELECTRON_EMBEDDED: "true",
PORT: String(backendPort),
EMBEDDED_BACKEND_PORT: String(backendPort),
PORT: "30001",
VERSION: app.getVersion(),
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
@@ -1454,7 +1429,6 @@ function createWindow() {
sandbox: true,
webSecurity: true,
preload: path.join(__dirname, "preload.js"),
additionalArguments: [`--termix-backend-port=${backendPort}`],
partition: termixSessionPartition,
allowRunningInsecureContent: false,
webviewTag: false,
@@ -3840,8 +3814,6 @@ app.whenReady().then(async () => {
await clearElectronJwtCookiesAtStartup();
if (!isDev) {
backendPort = await findBackendPort();
setLocalBackendPort(backendPort);
const result = await startBackendServer();
logToFile("startBackendServer result:", result);
} else {
-9
View File
@@ -14,16 +14,7 @@ function invokeAllowed(channel, ...args) {
return ipcRenderer.invoke(channel, ...args);
}
// The embedded backend's port, picked by the main process at startup.
const backendPortArg = process.argv.find((arg) =>
arg.startsWith("--termix-backend-port="),
);
const backendPort = backendPortArg
? Number(backendPortArg.split("=")[1])
: undefined;
contextBridge.exposeInMainWorld("electronAPI", {
backendPort,
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => {
+10 -6
View File
@@ -71,6 +71,7 @@
"group": "server",
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
},
{
"name": "ENABLE_SSL",
"group": "https",
@@ -107,6 +108,7 @@
"internal": true,
"description": "Set by the image when nginx serves HTTPS in front of the backend."
},
{
"name": "DATABASE_DIALECT",
"group": "database",
@@ -159,6 +161,7 @@
"internal": true,
"description": "Where database migrations are read from. Set by the image."
},
{
"name": "JWT_SECRET",
"group": "secrets",
@@ -201,6 +204,7 @@
"secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
},
{
"name": "ALLOW_PASSWORD_LOGIN",
"group": "auth",
@@ -279,6 +283,7 @@
"group": "auth",
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
},
{
"name": "TRUSTED_PROXIES",
"group": "proxy",
@@ -310,6 +315,7 @@
"group": "proxy",
"description": "Comma separated hosts that skip the proxy."
},
{
"name": "LOG_LEVEL",
"group": "logging",
@@ -349,6 +355,7 @@
"deprecated": "the session-recording plugin setting",
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
},
{
"name": "TERMIX_PLUGIN_REGISTRY_URL",
"group": "plugins",
@@ -399,6 +406,7 @@
"internal": true,
"description": "Set by npm run dev."
},
{
"name": "ENABLE_INSECURE_MODE",
"group": "desktop",
@@ -427,12 +435,6 @@
"internal": true,
"description": "Set by the desktop app when it runs its own backend."
},
{
"name": "EMBEDDED_BACKEND_PORT",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it."
},
{
"name": "TERMIX_DATA_DIR",
"group": "desktop",
@@ -457,12 +459,14 @@
"internal": true,
"description": "Set by Flatpak. The desktop app links new versions instead of installing them."
},
{
"name": "DESKTOP_SESSION",
"group": "desktop",
"internal": true,
"description": "Read on Linux to pick the password store."
},
{
"name": "VITE_BASE_PATH",
"group": "internal",
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.2",
"version": "26.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.2",
"version": "26.10.1",
"hasInstallScript": true,
"workspaces": [
"packages/*"
@@ -15058,7 +15058,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.11",
"version": "1.0.9",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
+2 -2
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.2",
"version": "26.10.1",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -30,7 +30,7 @@
"dev:electron": "node scripts/dev.mjs electron",
"dev:docker": "node scripts/dev.mjs docker",
"dev:frontend": "vite",
"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": "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: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",
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
-15
View File
@@ -1,20 +1,5 @@
# 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
+35 -58
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.11 | Plugin API 1.3.0
SDK 1.0.9 | Plugin API 1.2.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -90,7 +90,7 @@ npm test # termix-plugin test: Vitest
npm run typecheck # tsc
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
npm run format # Prettier
npm run format:check
```
@@ -105,7 +105,7 @@ Before you call a change finished:
- [ ] `npm run build` passes
- [ ] `npm run format:check` passes
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
- [ ] New strings are in `locales/en.json`
- [ ] New routes have an `@openapi` block and a test, including without the permission
- [ ] New env vars are listed in `env` in the manifest
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
@@ -225,8 +225,6 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
| `audit.record` | none | Write your own audit line |
| `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed |
| `asUser(userId, fn)` | always audited | Run background work as a user |
@@ -239,13 +237,6 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()`
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
that user's permissions.
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
UI language, like `zh_CN`, or null, for writing to them in it.
### Routes
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
@@ -493,7 +484,7 @@ your own host settings, under `host.pluginSettings[<id>]`.
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
`hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
https://docs.termix.site/develop/more-extension-points.
## Looking native
@@ -547,20 +538,6 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
Untranslated strings show in English.
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
every language.
```json
{
"plugin": {
"name": "Host Metrics",
"description": "Live CPU, memory, disk, network and GPU stats."
}
}
```
## Docs
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
@@ -763,37 +740,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## Capabilities
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------ |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Run background work as another user, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames and roles |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
An update that adds a capability waits for an admin to approve it.
-3
View File
@@ -11,9 +11,6 @@ export const TMXPLUG_CONTENTS = [
"README.md",
"CHANGELOG.md",
"icon.svg",
// Licence texts, so third-party notices travel with the plugin.
"LICENSE",
"NOTICE",
];
const BLOCK = 512;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.11",
"version": "1.0.9",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
+3 -43
View File
@@ -1464,20 +1464,6 @@ export interface PluginSecondFactor {
reset?: (userId: string) => Promise<void>;
}
export interface PluginUserTokenOptions {
/** Why the token exists, kept in the audit log and on the session. */
purpose: string;
/** Seconds until it expires. Defaults to 300 and is capped at 900. */
ttlSeconds?: number;
}
export interface PluginUserToken {
/** Send as `Authorization: Bearer <token>` to core's own API. */
token: string;
/** ISO time the session ends. */
expiresAt: string;
}
/**
* Login methods, second factors and SSH auth types. Every register call needs
* auth:provide and a matching contributes.auth entry, and is undone on
@@ -1539,26 +1525,6 @@ export interface PluginAuth {
* delete a provider people still sign in with.
*/
countLinkedUsers: (provider: string) => Promise<number>;
/**
* A short-lived session for an existing user, for calling core's own HTTP
* API as them from work with no request behind it, like a scheduled task
* driving the Termix CLI. The user's RBAC applies unchanged. Needs
* users:impersonate, not auth:provide, and every call is audited.
*
* The session is marked with the plugin and purpose. It cannot change the
* user's password, second factors, API keys or linked logins, so it can
* never outlive itself.
*/
issueUserToken: (
userId: string,
options: PluginUserTokenOptions,
) => Promise<PluginUserToken>;
}
/** What a plugin may learn about a user. Needs users:read. */
export interface PluginUsers {
/** The user's UI language, like "zh_CN", or null when they never chose one. */
getLocale: (userId: string) => Promise<string | null>;
}
export interface PluginOpenIsolatedWindowRequest {
@@ -2047,13 +2013,8 @@ export interface PluginContext {
readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh;
/**
* Login methods, second factors and SSH auth types. Needs auth:provide,
* except issueUserToken, which needs users:impersonate.
*/
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
readonly auth: PluginAuth;
/** What a plugin may learn about a user. Needs users:read. */
readonly users: PluginUsers;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
@@ -2078,9 +2039,8 @@ export interface PluginContext {
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* Needs users:impersonate. That, auth.issueUserToken and a `userId` option
* on services.get or secrets.getShared are the only ways a plugin can act
* as a user it names.
* Needs users:impersonate. That and a `userId` option on services.get or
* secrets.getShared are the only ways a plugin can name a user.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
+1 -1
View File
@@ -36,7 +36,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.3.0";
export const PLUGIN_API_VERSION = "1.2.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+1 -33
View File
@@ -174,8 +174,6 @@ export interface FakeContextOptions {
linkedUsers?: Record<string, number>;
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
locales?: Record<string, string>;
}
export interface FakeProcessOptions {
@@ -249,8 +247,6 @@ export interface FakeAuthRegistrations {
}>;
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
}
export interface FakePluginContext {
@@ -578,7 +574,6 @@ export function createFakeContext(
completedLogins: [],
revokedSessions: [],
loginFailures: new Map(),
userTokens: [],
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -1145,18 +1140,6 @@ export function createFakeContext(
},
countLinkedUsers: async (provider) =>
options.linkedUsers?.[provider] ?? 0,
issueUserToken: async (userId, tokenOptions) => {
auth.userTokens.push({ userId, ...tokenOptions });
const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
return {
token: `fake-token-${auth.userTokens.length}`,
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
};
},
},
users: {
getLocale: async (userId) => options.locales?.[userId] ?? null,
},
desktop: {
@@ -1459,8 +1442,6 @@ export interface MockContextOptions {
linkedUsers?: Record<string, number>;
/** See FakeContextOptions. */
loginAttemptLimit?: number;
/** See FakeContextOptions. */
locales?: Record<string, string>;
}
export interface MockPluginContext extends FakePluginContext {
@@ -1477,8 +1458,7 @@ export interface MockPluginContext extends FakePluginContext {
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
* 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
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
@@ -1515,7 +1495,6 @@ export function createMockCtx(
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
locales: options.locales,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1807,17 +1786,6 @@ export function createMockCtx(
require("auth:provide");
return ctx.auth.countLinkedUsers(provider);
},
issueUserToken: async (userId, tokenOptions) => {
require("users:impersonate");
return ctx.auth.issueUserToken(userId, tokenOptions);
},
},
users: {
getLocale: async (userId) => {
require("users:read");
return ctx.users.getLocale(userId);
},
},
desktop: {
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

+1 -6
View File
@@ -1,9 +1,4 @@
// 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 CACHE_NAME = "termix-static-v2";
const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
const STATIC_ASSETS = [
`${BASE_PATH}/favicon.ico`,
-2
View File
@@ -42,7 +42,6 @@ describe("serviceEnv", () => {
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
GUACD_HOST: "127.0.0.1",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
@@ -54,7 +53,6 @@ describe("serviceEnv", () => {
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
GUACD_HOST: "termix-dev-guacd",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
});
+1 -2
View File
@@ -14,8 +14,7 @@
* mounted. A plugin change rebuilds it and restarts the container. Core
* changes wait for "r", since an image build is slow.
*
* --db postgres|mysql and --guacd run those in Docker next to it; --guacd also
* switches remote desktop from its built-in engine to that guacd.
* --db postgres|mysql and --guacd run those in Docker next to it.
*/
import fs from "node:fs";
-1
View File
@@ -53,7 +53,6 @@ function start() {
isDev: false,
appRoot: "/app",
BACKEND_STDERR_TAIL_LIMIT: 4096,
backendPort: 30001,
handleBackendRequest,
setTimeout,
clearTimeout,
+1 -1
View File
@@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) {
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
`| **AArch64 (ARM64)** | [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) |`,
`| **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) |`,
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
-2
View File
@@ -85,8 +85,6 @@ export function serviceEnv(options, inDocker) {
const guacd = SERVICES.guacd;
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
// Remote desktop runs built in unless told to use guacd.
env.REMOTE_DESKTOP_ENGINE = "guacd";
}
return env;
}
+1 -1
View File
@@ -125,7 +125,7 @@ Options
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
--no-local Use only the plugins pinned in docker/bundled-plugins.json
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
--guacd Run guacd in Docker and use it instead of the built-in remote desktop
--guacd Run guacd in Docker for remote desktop
--fresh Start from empty data (first run, onboarding)
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
electron, plus -<engine> for postgres or mysql)
-4
View File
@@ -46,8 +46,6 @@ function fixturePlugin(manifest: Record<string, unknown> = {}): string {
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
writeFile(dir, "README.md", "# Fixture\n");
writeFile(dir, "icon.svg", "<svg/>\n");
writeFile(dir, "LICENSE", "Apache-2.0\n");
writeFile(dir, "NOTICE", "Third-party notices\n");
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
return dir;
}
@@ -93,8 +91,6 @@ describe("termix-plugin pack", () => {
const names = await entries(file);
expect(names).toEqual([
"LICENSE",
"NOTICE",
"README.md",
"dist/backend.js",
"dist/frontend.js",
-54
View File
@@ -1,54 +0,0 @@
#!/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
@@ -1,59 +0,0 @@
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 });
}
},
);
+1 -6
View File
@@ -38,7 +38,6 @@ import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js";
import fs from "fs";
@@ -1389,9 +1388,6 @@ app.post(
}
}
await applyDefaultsAfterHostWrites(importedHostIds);
for (const hostId of importedHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
@@ -1720,8 +1716,7 @@ app.use(
},
);
// The desktop app moves its embedded backend off 30001 when that is taken.
const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
const HTTP_PORT = 30001;
async function initializeSecurity() {
try {
+35 -55
View File
@@ -140,14 +140,7 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(
userId: string,
hostIds: number[],
createdHostIds: number[],
): void {
for (const hostId of createdHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
export function notifyImportedHosts(userId: string, hostIds: number[]): void {
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
@@ -575,26 +568,26 @@ export function registerHostBulkRoutes(
);
}
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
if (overwrite) {
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
} catch {
existingHostMap = undefined;
}
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -631,15 +624,6 @@ export function registerHostBulkRoutes(
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 (
effectiveConnectionType === "ssh" &&
hostData.authType &&
@@ -785,6 +769,8 @@ export function registerHostBulkRoutes(
hostData.authType === "key" ? hostData.keyType || "auto" : null;
}
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
@@ -811,7 +797,6 @@ export function registerHostBulkRoutes(
);
if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id;
createdHostIds.push(saved.id);
results.success++;
}
@@ -843,10 +828,10 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
@@ -946,22 +931,23 @@ export function registerHostBulkRoutes(
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
if (overwrite) {
try {
const allHosts =
await createCurrentHostResolutionRepository().findHostsByUserId(
userId,
);
existingHostMap = new Map();
for (const h of allHosts) {
const key = `${h.ip}:${h.port}:${h.username}`;
existingHostMap.set(key, { id: h.id as number });
}
} catch {
existingHostMap = undefined;
}
} catch {
existingHostMap = undefined;
}
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
@@ -976,13 +962,6 @@ export function registerHostBulkRoutes(
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> = {
userId,
connectionType: "ssh",
@@ -1020,6 +999,8 @@ export function registerHostBulkRoutes(
updatedAt: new Date().toISOString(),
};
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
const existing = existingHostMap?.get(lookupKey);
await withHostDefaults(
userId,
existing?.id ?? null,
@@ -1042,7 +1023,6 @@ export function registerHostBulkRoutes(
sshDataObj,
);
writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++;
}
} catch (error) {
@@ -1054,10 +1034,10 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
@@ -88,23 +88,6 @@ export function keepsStoredSshAuth(authType: unknown): boolean {
return authType === undefined || authType === null;
}
export function isOptionalAuthType(value: unknown): boolean {
return value === undefined || value === null || typeof value === "string";
}
/**
* The auth type a new SSH host's secrets point to when the body names none,
* as an API client creating a host from a credential does.
*/
export function impliedSshAuthType(
body: Record<string, unknown>,
): string | undefined {
if (body.credentialId) return "credential";
if (body.key) return "key";
if (body.password) return "password";
return undefined;
}
const FOLDER_PATH_SEPARATOR = " / ";
/**
+2 -18
View File
@@ -31,10 +31,8 @@ import {
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
keepsStoredSshAuth,
isNonEmptyString,
isOptionalAuthType,
isOptionalBoolean,
isValidPort,
normalizeProtocolEnableFields,
@@ -261,9 +259,7 @@ router.post(
!isNonEmptyString(ip) ||
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod)
!isOptionalBoolean(enableSsh)
) {
sshLogger.warn("Invalid SSH data input validation failed", {
operation: "host_create",
@@ -296,9 +292,7 @@ router.post(
const effectiveAuthType =
authType ||
authMethod ||
(effectiveConnectionType !== "ssh"
? "password"
: impliedSshAuthType(hostData));
(effectiveConnectionType !== "ssh" ? "password" : undefined);
const effectiveUsername =
username || firstProtocolUsername(protocolAuthPatch);
const effectiveName =
@@ -409,13 +403,6 @@ router.post(
body: hostData,
}),
);
// Host defaults may have supplied it.
if (!sshDataObj.authType) {
return res.status(400).json({
error:
"authType is required (password, key, credential, agent or none)",
});
}
const result = await createCurrentHostRepository().createEncryptedForUser(
userId,
sshDataObj,
@@ -486,7 +473,6 @@ router.post(
});
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
@@ -841,8 +827,6 @@ router.put(
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod) ||
!hostId
) {
sshLogger.warn("Invalid SSH data input validation failed for update", {
-2
View File
@@ -30,7 +30,6 @@ import { hostStatusService } from "../hosts/status/host-status-service.js";
import { parseSshOptions } from "../hosts/ssh-options.js";
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js";
import { pluginEvents, TOPICS } from "./events.js";
import { pluginLogger } from "../utils/logger.js";
type AuditFn = (
@@ -503,7 +502,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes);
await audit("hosts_create", `host ${created.id}`, { success: true });
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
return (
await toRecords([created as unknown as Record<string, unknown>])
)[0];
-3
View File
@@ -25,7 +25,6 @@ import {
import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js";
import { issueUserToken } from "./ctx-users.js";
import {
getSshAuthProvider,
listSshAuthProviders,
@@ -902,7 +901,5 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
await import("../database/repositories/factory.js");
return createCurrentUserAuthRepository().countUsersForProvider(provider);
},
issueUserToken: (userId, options) =>
issueUserToken(manifest, userId, options),
};
}
-94
View File
@@ -1,94 +0,0 @@
/**
* 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,7 +62,6 @@ import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js";
import { createPluginUsers } from "./ctx-users.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -958,7 +957,6 @@ export function createPluginContext(
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
plugins: createPluginPlugins(manifest),
users: createPluginUsers(manifest),
desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
-2
View File
@@ -26,8 +26,6 @@ export const TOPICS = {
hostStatus: "host.status",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
/** A host was added and anything inventorying hosts should pick it up. */
hostCreated: "host.created",
/** A host's connection details changed and pollers should re-read them. */
hostUpdated: "host.updated",
/** A host was deleted and any poller holding it should drop it. */
+7 -18
View File
@@ -499,21 +499,17 @@ export class SyncEngine {
);
all.push(...result.changes);
next = result.nextCursor;
this.learnTypes(result);
this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type));
this.state.serverTypes = result.entityTypes.map((entry) => ({
type: entry.type,
owner: entry.owner,
readOnly: entry.readOnly,
}));
if (!result.hasMore) break;
}
return { changes: all, cursor: next };
}
private learnTypes(page: ChangesPage): void {
this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type));
this.state.serverTypes = page.entityTypes.map((entry) => ({
type: entry.type,
owner: entry.owner,
readOnly: entry.readOnly,
}));
}
private async pull(link: SyncLink): Promise<void> {
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
const known = new Set(link.knownTypes);
@@ -532,14 +528,7 @@ export class SyncEngine {
/** Pulls every row of types this device has not pulled before. */
private async backfill(link: SyncLink): Promise<void> {
if (!this.serverTypes) {
// 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 },
),
);
await this.fetchChanges(link, Number.MAX_SAFE_INTEGER);
}
const fresh = listEntities()
.map((entity) => entity.type)
+1 -5
View File
@@ -22,7 +22,6 @@ import {
import { FieldCrypto } from "../utils/field-crypto.js";
import { syncLogger } from "../utils/logger.js";
import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { singletonSyncId } from "./wire.js";
import { parseDefaultOverrides } from "../../types/host-defaults.js";
import {
@@ -175,7 +174,7 @@ export function registerCoreSyncEntities(): void {
)
: {},
}),
afterWrite: async ({ id, userId, wire, created, resolveId }) => {
afterWrite: async ({ id, userId, wire, resolveId }) => {
if (id === null) return;
// The server owns host defaults: it keeps what the host set itself
// and resolves the rest. A desktop takes the server's values as sent.
@@ -196,9 +195,6 @@ export function registerCoreSyncEntities(): void {
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
await SharedHostSecretsManager.getInstance().resyncHost(id);
if (created) {
pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId });
}
},
remove: async (row, userId) => {
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
+1 -5
View File
@@ -140,9 +140,6 @@ export async function listRecordErrors(userId: string): Promise<SyncRecord[]> {
return rows.filter((row) => !!row.error);
}
/** The largest seq the column holds: a 32-bit integer on Postgres and MySQL. */
const MAX_SEQ = 2_147_483_647;
export async function listChanges(
userId: string,
cursor: number,
@@ -151,8 +148,7 @@ export async function listChanges(
): Promise<SyncRecord[]> {
const conditions = [
eq(syncRecords.userId, userId),
// Postgres rejects a bound value the column cannot hold.
gt(syncRecords.seq, Math.min(cursor, MAX_SEQ)),
gt(syncRecords.seq, cursor),
];
if (types && types.length > 0) {
conditions.push(inArray(syncRecords.entityType, types));
+5 -19
View File
@@ -37,17 +37,6 @@ function userOf(req: Request): string {
return (req as AuthenticatedRequest).userId;
}
/** A non-negative integer query value, the fallback when absent, null when malformed. */
function queryInt(value: unknown, fallback: number): number | null {
if (value === undefined) return fallback;
const parsed = Number(value);
return typeof value === "string" &&
/^\d+$/.test(value) &&
Number.isSafeInteger(parsed)
? parsed
: null;
}
/**
* @openapi
* /sync/v2/info:
@@ -235,8 +224,6 @@ router.post(
* responses:
* 200:
* description: A page of changes, the next cursor and whether more remain.
* 400:
* description: The cursor or limit is not a non-negative integer.
* 500:
* description: Failed to read changes.
*/
@@ -245,12 +232,11 @@ router.get(
authenticateJWT,
async (req: Request, res: Response) => {
const userId = userOf(req);
const cursor = queryInt(req.query.cursor, 0);
const pageSize = queryInt(req.query.limit, MAX_PAGE);
if (cursor === null || pageSize === null) {
return res.status(400).json({ error: "Invalid cursor or limit" });
}
const limit = Math.min(MAX_PAGE, Math.max(1, pageSize));
const cursor = Math.max(0, Number(req.query.cursor) || 0);
const limit = Math.min(
MAX_PAGE,
Math.max(1, Number(req.query.limit) || MAX_PAGE),
);
const types =
typeof req.query.types === "string" && req.query.types
? req.query.types.split(",").filter(Boolean)
@@ -48,7 +48,6 @@ function exportFile(): string {
async function invoke(path: string, user: unknown = { isExternal: false }) {
const writes: string[] = [];
const events: Array<[string, unknown]> = [];
const hosts: Array<Record<string, unknown>> = [];
const importHostProtocolLogins = vi.fn();
const handler = runInNewContext(
@@ -99,11 +98,6 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
DatabaseSaveTrigger: { forceSave: async () => {} },
getErrorMessage: (error: Error) => error.message,
pluginEvents: {
emit: (topic: string, payload: unknown) =>
events.push([topic, payload]),
},
TOPICS: { hostCreated: "host.created" },
},
);
const res = { json: vi.fn(), status: vi.fn() };
@@ -112,7 +106,7 @@ async function invoke(path: string, user: unknown = { isExternal: false }) {
{ userId: "user-1", file: { path, originalname: "export.db" } },
res,
);
return { res, writes, hosts, events, importHostProtocolLogins };
return { res, writes, hosts, importHostProtocolLogins };
}
it("points imported hosts at the credentials imported with them", async () => {
@@ -128,17 +122,6 @@ it("points imported hosts at the credentials imported with them", async () => {
]);
});
it("publishes host.created once for each imported host", async () => {
const { events } = await invoke(exportFile());
expect(events).toEqual(
[501, 502, 503, 504].map((hostId) => [
"host.created",
{ hostId, userId: "user-1" },
]),
);
});
it("removes the uploaded file when the user is gone", async () => {
const path = exportFile();
const { res } = await invoke(path, null);
@@ -1,55 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { sql } from "drizzle-orm";
import { TestSqliteDatabase } from "./test-support.js";
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
const state = vi.hoisted(() => ({
context: null as DatabaseContext | null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentRepositoryContext: () => state.context,
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { triggerSave: vi.fn() },
}));
const { listChanges } = await import("../../../sync/records.js");
// Runs against Postgres and MySQL too in the database-dialects CI job, where
// sync_records.seq is a 32-bit integer.
describe("listChanges", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
state.context = null;
});
async function seed(): Promise<void> {
adapter = new TestSqliteDatabase();
state.context = await adapter.connect();
await adapter.run(
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user-1', '')`,
);
await adapter.run(
sql`INSERT INTO sync_records (user_id, entity_type, sync_id, revision, seq) VALUES ('user-1', 'hosts', 'a', 1, 1), ('user-1', 'hosts', 'b', 1, 2)`,
);
}
it("lists records after the cursor in seq order", async () => {
await seed();
const records = await listChanges("user-1", 1, 10);
expect(records.map((record) => record.syncId)).toEqual(["b"]);
});
it("answers no records for a cursor past what seq can hold", async () => {
await seed();
expect(await listChanges("user-1", Number.MAX_SAFE_INTEGER, 10)).toEqual(
[],
);
});
});
@@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => {
seen.push(payload);
});
try {
notifyImportedHosts("user-1", [4, 7], []);
notifyImportedHosts("user-1", [4, 7]);
} finally {
off();
}
@@ -1,216 +0,0 @@
/**
* 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,14 +37,11 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}));
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
let handler: RequestHandler;
let sshConfigHandler: RequestHandler;
const router = {
post: (path: string, ...handlers: RequestHandler[]) => {
if (path === "/bulk-import") handler = handlers.at(-1)!;
if (path === "/ssh-config-import") sshConfigHandler = handlers.at(-1)!;
},
put: () => {},
patch: () => {},
@@ -69,13 +66,12 @@ beforeEach(() => {
vi.clearAllMocks();
mocks.create.mockResolvedValue({ id: 71 });
mocks.update.mockResolvedValue({ id: 19 });
mocks.list.mockResolvedValue([]);
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
});
it.each([false, true])(
"carries plugin host settings through export selection and import (overwrite=%s)",
async (overwrite) => {
if (overwrite) mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
const payload = buildExportPayload(
{ hosts: [host] },
null,
@@ -172,60 +168,6 @@ 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([
[{ ...host, jumpHosts: [{ hostId: 20 }] }],
[{ ...host, jumpHosts: [{ hostId: "20" }] }],
@@ -260,53 +202,3 @@ it("does not save dependent hosts when importing their jump host fails", async (
);
expect(mocks.create).toHaveBeenCalledTimes(1);
});
function collectCreated() {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
return { seen, off };
}
it("publishes host.created once for each host a JSON import creates", async () => {
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
mocks.create.mockResolvedValueOnce({ id: 501 });
const { seen, off } = collectCreated();
try {
await importHosts([host, { ...host, ip: "192.0.2.2" }], true);
} finally {
off();
}
// The overwritten host already existed, so only the new one is announced.
expect(seen).toEqual([{ hostId: 501, userId: "user-1" }]);
});
it("publishes host.created once for each host an SSH config import creates", async () => {
mocks.create
.mockResolvedValueOnce({ id: 601 })
.mockResolvedValueOnce({ id: 602 });
const { seen, off } = collectCreated();
try {
await sshConfigHandler(
{
userId: "user-1",
body: {
content:
"Host a\n HostName 10.0.0.1\n User root\nHost b\n HostName 10.0.0.2\n User root\n",
},
} as unknown as Request,
{
json: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response,
vi.fn(),
);
} finally {
off();
}
expect(seen).toEqual([
{ hostId: 601, userId: "user-1" },
{ hostId: 602, userId: "user-1" },
]);
});
@@ -3,7 +3,6 @@ import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js"
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
isNonEmptyString,
keepsStoredSshAuth,
isOptionalBoolean,
@@ -516,16 +515,3 @@ describe("keepsStoredSshAuth", () => {
expect(keepsStoredSshAuth("none")).toBe(false);
});
});
describe("impliedSshAuthType", () => {
it("reads the auth type from the secret a create body carries", () => {
expect(impliedSshAuthType({ credentialId: 2, password: "pw" })).toBe(
"credential",
);
expect(impliedSshAuthType({ key: "-----BEGIN", password: "pw" })).toBe(
"key",
);
expect(impliedSshAuthType({ password: "pw" })).toBe("password");
expect(impliedSshAuthType({})).toBeUndefined();
});
});
@@ -1,65 +0,0 @@
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,48 +39,6 @@ describe("linked server in Electron main", () => {
).toEqual({ Accept: "x" });
});
it("treats WebSocket upgrades to the linked server as the linked origin", () => {
linked.setLinkedServer({ origin: ORIGIN });
expect(linked.isLinkedOrigin(`${ORIGIN}/host/status`)).toBe(true);
expect(
linked.isLinkedOrigin("wss://termix.example/plugin-ws/ssh-terminal"),
).toBe(true);
expect(linked.isLinkedOrigin("wss://termix.example:443/")).toBe(true);
expect(linked.isLinkedOrigin("ws://termix.example/")).toBe(false);
expect(linked.isLinkedOrigin("wss://termix.example:8443/")).toBe(false);
expect(linked.isLinkedOrigin("wss://other.example/")).toBe(false);
expect(linked.isLinkedOrigin("not a url")).toBe(false);
linked.setLinkedServer({ origin: "http://termix.example:8080" });
expect(linked.isLinkedOrigin("ws://termix.example:8080/ws")).toBe(true);
expect(linked.isLinkedOrigin("wss://termix.example:8080/ws")).toBe(false);
expect(linked.isLinkedOrigin("ws://termix.example/ws")).toBe(false);
});
it("adds proxy headers and basic auth to the terminal WebSocket", () => {
linked.setLinkedServer({
origin: ORIGIN,
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
basicAuth: { username: "gate", password: "pw" },
});
expect(
linked.applyLinkHeaders(
"wss://termix.example/plugin-ws/ssh-terminal/terminal",
{ Upgrade: "websocket" },
),
).toEqual({
Upgrade: "websocket",
"CF-Access-Client-Id": "id",
Authorization: `Basic ${Buffer.from("gate:pw").toString("base64")}`,
});
expect(
linked.answerLogin({}, "wss://termix.example/plugin-ws/ssh-terminal"),
).toEqual({ username: "gate", password: "pw" });
expect(
linked.applyLinkHeaders("wss://other.example/", { Upgrade: "websocket" }),
).toEqual({ Upgrade: "websocket" });
});
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
linked.setLinkedServer({
origin: ORIGIN,
@@ -207,7 +207,6 @@ vi.mock("../../hosts/delete-host.js", () => ({
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
function manifest(capabilities: string[]) {
return {
@@ -437,29 +436,6 @@ describe("ctx.hosts", () => {
]);
});
it("publishes host.created for a host it creates", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
try {
await hosts.create({
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
});
} finally {
off();
}
expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]);
});
it("refuses a field the SDK does not take instead of dropping it", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
-335
View File
@@ -1,335 +0,0 @@
/**
* 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,8 +75,7 @@ const UNGATED: Record<string, string> = {
"schedule.every": "the plugin's own timers",
"schedule.after": "the plugin's own timers",
"audit.record": "writes under the runtime's actor, never a plugin value",
asUser:
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
asUser: "always audited as plugin_as_user",
currentActor: "reads the actor, cannot set it",
"desktop.available": "a yes/no about the environment",
"http.baseUrl": "reads the request's public origin",
@@ -1,75 +0,0 @@
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,7 +15,6 @@ const state = vi.hoisted(() => ({
server: null as null | { drizzle: unknown },
allow: true,
sharedHostIds: new Set<number>(),
paths: [] as string[],
}));
const SERVER_USER = "srv-user";
@@ -124,7 +123,6 @@ vi.mock("../../sync/client/http.js", async (importOriginal) => {
path: string,
init: { method?: string; body?: unknown } = {},
) => {
state.paths.push(path);
const previous = state.current;
state.current = state.server;
try {
@@ -163,7 +161,6 @@ const { createResolvers, writeWireRow } = await import("../../sync/store.js");
const { getEntity } = await import("../../plugins/sync-registry.js");
const { FieldCrypto } = await import("../../utils/field-crypto.js");
const { syncLogger } = await import("../../utils/logger.js");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
const app = express();
app.use(express.json({ limit: "5mb" }));
@@ -252,7 +249,6 @@ beforeEach(async () => {
resetSeqAllocator();
state.allow = true;
state.sharedHostIds.clear();
state.paths = [];
server = await side(SERVER_USER);
state.server = server.ctx;
desktops = [await side("desk-a"), await side("desk-b")];
@@ -321,60 +317,6 @@ describe("linking a desktop", () => {
expect(host.ip).toBe("new");
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
});
it("never asks for a cursor past what the server's seq column holds", async () => {
const [desk] = desktops;
await on(server, () => addHost(server, { name: "srv" }));
const engine = await linkDesktop(desk);
await pass(desk, engine);
const cursors = state.paths
.filter((path) => path.startsWith("/sync/v2/changes?"))
.map((path) =>
Number(new URLSearchParams(path.split("?")[1]).get("cursor")),
);
expect(cursors.length).toBeGreaterThan(0);
expect(Math.max(...cursors)).toBeLessThanOrEqual(2_147_483_647);
});
});
describe("a synced host", () => {
it("publishes host.created when it is written for the first time only", async () => {
const [desk] = desktops;
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
seen.push(payload);
});
const wire = {
syncId: "host-new",
name: "new",
ip: "10.0.0.2",
port: 22,
username: "root",
authType: "password",
};
try {
await on(desk, async () => {
const resolvers = createResolvers(desk.userId);
const first = await writeWireRow(
getEntity("hosts")!,
desk.userId,
wire,
resolvers,
);
await writeWireRow(
getEntity("hosts")!,
desk.userId,
{ ...wire, name: "renamed" },
resolvers,
);
expect(seen).toEqual([{ hostId: first.id, userId: desk.userId }]);
});
} finally {
off();
}
});
});
describe("after linking", () => {
+1 -96
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
const createMock = vi.fn().mockResolvedValue(undefined);
@@ -60,101 +60,6 @@ describe("logAudit", () => {
});
});
describe("logAudit plugin routine coalescing", () => {
const WINDOW_MS = 10 * 60 * 1000;
let run = 0;
let details = "";
const routine = (overrides: Record<string, unknown> = {}) =>
logAudit({
userId: null,
username: "plugin:automations",
action: "plugin_as_user",
resourceType: "plugin",
resourceId: "automations",
details,
success: true,
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
createMock.mockResolvedValue(undefined);
vi.useFakeTimers();
// The coalescing memory is module state: a fresh key per test keeps them
// independent.
details = `automations ran background work as user-${++run}`;
});
afterEach(() => {
vi.useRealTimers();
});
it("writes one row for identical successful plugin_as_user repeats in the window", async () => {
await routine();
vi.advanceTimersByTime(WINDOW_MS - 1);
await routine();
await routine();
expect(createMock).toHaveBeenCalledOnce();
});
it("writes one row for identical successful plugin service calls in the window", async () => {
const service = {
action: "plugin_service_host_metrics_open",
resourceId: "host-metrics",
};
await routine(service);
await routine(service);
expect(createMock).toHaveBeenCalledOnce();
});
it("writes again once the window has passed", async () => {
await routine();
vi.advanceTimersByTime(WINDOW_MS);
await routine();
expect(createMock).toHaveBeenCalledTimes(2);
});
it("always writes failures", async () => {
await routine();
await routine({ success: false, errorMessage: "boom" });
await routine({ success: false, errorMessage: "boom" });
expect(createMock).toHaveBeenCalledTimes(3);
});
it("never coalesces non-plugin or non-routine actions", async () => {
const login = {
userId: "user-1",
username: "alice",
action: "login",
resourceType: "user",
};
await routine(login);
await routine(login);
const secretRead = { action: "plugin_secret_shared_read" };
await routine(secretRead);
await routine(secretRead);
const notPlugin = { username: "alice" };
await routine(notPlugin);
await routine(notPlugin);
expect(createMock).toHaveBeenCalledTimes(6);
});
it("does not coalesce entries with different details or resourceId", async () => {
await routine();
await routine({ details: `${details} (other)` });
await routine({ resourceId: "other-plugin" });
await routine({ userId: "user-2" });
expect(createMock).toHaveBeenCalledTimes(4);
});
});
describe("getRequestMeta", () => {
it("uses the proxy-validated Express IP", () => {
const req = {
-42
View File
@@ -35,49 +35,7 @@ export interface AuditLogParams {
errorMessage?: string;
}
// Plugin routine calls (a headless viewer's heartbeat through ctx.asUser and a
// service) repeat identically every few seconds; written each time they push
// real security entries out of the capped log. Only successful plugin_as_user
// and plugin_service_* lines are coalesced; failures and everything else are
// always written.
const ROUTINE_COALESCE_MS = 10 * 60 * 1000;
const ROUTINE_COALESCE_MAX_KEYS = 5000;
const routineLastWritten = new Map<string, number>();
function isCoalescedRoutine(params: AuditLogParams): boolean {
if (
!params.success ||
!params.username.startsWith("plugin:") ||
!(
params.action === "plugin_as_user" ||
params.action.startsWith("plugin_service_")
)
) {
return false;
}
const key = JSON.stringify([
params.userId,
params.username,
params.action,
params.resourceType,
params.resourceId,
params.details,
]);
const now = Date.now();
const last = routineLastWritten.get(key);
if (last !== undefined && now - last < ROUTINE_COALESCE_MS) return true;
if (routineLastWritten.size >= ROUTINE_COALESCE_MAX_KEYS) {
for (const [k, at] of routineLastWritten) {
if (now - at >= ROUTINE_COALESCE_MS) routineLastWritten.delete(k);
}
}
routineLastWritten.set(key, now);
return false;
}
export async function logAudit(params: AuditLogParams): Promise<void> {
if (isCoalescedRoutine(params)) return;
// Local storage is the source of truth and runs first; forwarding is a copy
// and must never delay or fail the audited operation.
void forwardAuditEntry(params).catch(() => {});
-79
View File
@@ -15,7 +15,6 @@ import bcrypt from "bcryptjs";
import { nanoid } from "nanoid";
import { inArray, isNotNull } from "drizzle-orm";
import type { ExternalSessionRef } from "../auth/types.js";
import { listSecondFactors } from "../auth/registry.js";
import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js";
@@ -45,11 +44,6 @@ interface JWTPayload {
pendingTOTP?: boolean;
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */
purpose?: "ws";
/**
* A plugin minted the session. Read from the session row on verify, never
* from the token, so a refreshed token stays marked.
*/
pluginSession?: boolean;
dataKeyWrap?: WrappedDataKey;
iat?: number;
exp?: number;
@@ -109,26 +103,6 @@ const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/];
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
/** sessions.device_type of a session a plugin minted with ctx.auth.issueUserToken. */
export const PLUGIN_SESSION_DEVICE_TYPE = "plugin";
// Routes that change how a user signs in or mint a credential that outlives
// the session. A plugin-minted session is refused on them, and on every
// route of a plugin that provides a second factor.
const PLUGIN_SESSION_BLOCKLIST = [
/^\/users\/(change-password|api-keys|link-external-to-password|unlink-external-from-password|admin\/reset-password)(\/|$)/,
/^\/users\/admin\/[^/]+\/second-factors(\/|$)/,
];
/** Whether a session a plugin minted may call this path. */
export function allowsPluginSession(path: string): boolean {
if (PLUGIN_SESSION_BLOCKLIST.some((pattern) => pattern.test(path))) {
return false;
}
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
}
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
/** Set by the plugin runtime: whether a running plugin opted in. */
@@ -433,38 +407,6 @@ class AuthManager {
} as jwt.SignOptions);
}
/**
* A session a plugin mints for a user, to call core's API as them. It is
* an ordinary session row, so it verifies, expires and shows up like a
* login, marked with the plugin and why.
*/
async issuePluginSession(
userId: string,
options: { pluginId: string; purpose: string; ttlSeconds: number },
): Promise<{ token: string; expiresAt: string }> {
const sessionId = nanoid();
const token = jwt.sign(
{ userId, sessionId } satisfies JWTPayload,
await this.systemCrypto.getJWTSecret(),
{ expiresIn: options.ttlSeconds },
);
const now = new Date();
const expiresAt = new Date(
now.getTime() + options.ttlSeconds * 1000,
).toISOString();
await createCurrentSessionRepository().create({
id: sessionId,
userId,
jwtToken: token,
deviceType: PLUGIN_SESSION_DEVICE_TYPE,
deviceInfo: `${options.pluginId}: ${options.purpose}`,
createdAt: now.toISOString(),
expiresAt,
lastActiveAt: now.toISOString(),
});
return { token, expiresAt };
}
async verifyJWTToken(
token: string,
options: { allowSocketTicket?: boolean } = {},
@@ -498,8 +440,6 @@ class AuthManager {
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
return null;
}
payload.pluginSession =
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
await this.migrateDataKeyFromPayload(payload);
} catch (dbError) {
@@ -847,8 +787,6 @@ class AuthManager {
});
}
if (this.refusePluginSession(req, res, payload)) return;
if (payload.sessionId) {
try {
const sessionRepository = createCurrentSessionRepository();
@@ -1015,21 +953,6 @@ class AuthManager {
return false;
}
/** Sends the 403 and returns true when a plugin session may not use this route. */
private refusePluginSession(
req: Request,
res: Response,
payload: JWTPayload,
): boolean {
const path = (req.originalUrl || req.url || "").split("?")[0];
if (!payload.pluginSession || allowsPluginSession(path)) return false;
res.status(403).json({
error: "A session a plugin issued cannot use this route",
code: "PLUGIN_SESSION_NOT_ALLOWED",
});
return true;
}
createDataAccessMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
const authReq = req as AuthenticatedRequest;
@@ -1084,8 +1007,6 @@ class AuthManager {
});
}
if (this.refusePluginSession(req, res, payload)) return;
try {
const user = await createCurrentUserRepository().findById(
payload.userId,
+2 -27
View File
@@ -6,8 +6,6 @@ import "./ui/index.css";
import { ThemeProvider } from "@/components/theme-provider";
import "./ui/i18n/i18n";
import { isElectron } from "@/lib/electron";
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
import { Toaster } from "@/components/sonner";
import {
@@ -86,15 +84,6 @@ function FullscreenApp() {
}
}
/**
* A signed-out browser gets the normal login for a full-screen link and comes
* back to it afterwards. The desktop app and the embedded sign-in frames
* hand a login to their own shell, so they keep the bare view.
*/
function signsInForViewLink(): boolean {
return !isElectron() && !isEmbeddedAuthFrame();
}
function FullscreenAppGate() {
const { t } = useTranslation();
const [ready, setReady] = useState(false);
@@ -120,16 +109,7 @@ function FullscreenAppGate() {
if (!cancelled) setReady(true);
})
.catch(() => {
if (cancelled) return;
if (signsInForViewLink()) {
rememberViewLink();
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
}
setAuthFailed(true);
if (!cancelled) setAuthFailed(true);
});
return () => {
@@ -138,7 +118,7 @@ function FullscreenAppGate() {
}, []);
if (authFailed) {
return signsInForViewLink() ? <App /> : <FullscreenApp />;
return <FullscreenApp />;
}
if (!ready) {
@@ -201,7 +181,6 @@ function App() {
// Non-fatal: WebSocket connections will fall back to cookie auth
}
}
if (resumeViewLink()) return;
setPhase("loading-app");
await Promise.all([
startPluginRuntime()
@@ -283,10 +262,6 @@ function App() {
function handleLogin(u: string) {
loggingOutRef.current = false;
clearDesktopManualLogout();
if (resumeViewLink()) {
setPhase("loading-app");
return;
}
setAuthUsername(u);
setPhase("loading-app");
void (async () => {
+14 -9
View File
@@ -125,7 +125,6 @@ import type {
} from "@/types/ui-types";
import { applyAccentColor, applyFontSize } from "@/lib/theme";
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
import { useTheme } from "@/components/theme-provider";
import {
@@ -830,11 +829,20 @@ export function AppShell({
}
}
// Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split
const paneDirection = getPaneNavDirection(e);
if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) {
e.preventDefault();
return;
// Alt+Arrow moves between panes of the active split
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
const direction = (
{
ArrowLeft: "left",
ArrowRight: "right",
ArrowUp: "up",
ArrowDown: "down",
} as Record<string, NavDirection>
)[e.code];
if (direction && splitActionsRef.current?.navigate(direction)) {
e.preventDefault();
return;
}
}
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
@@ -3126,9 +3134,6 @@ export function AppShell({
{renderSidebarPanels(rightRailView, false)}
</DockPanel>
)}
{/* Plugin panels pinned to the right edge, sized by the plugin */}
<ComponentSlot slotId="shell.rightEdge" />
</div>
</div>
+5 -4
View File
@@ -20,10 +20,11 @@ interface OriginResolvableHost {
* the desktop-wide default.
*
* `defaultRemote` makes a host left on Default resolve to "remote" instead of
* following the desktop-wide setting. Remote desktop passes it when this
* desktop is set to use an external guacd, so a host is not moved onto a
* guacd that isn't there (see Termix-SSH/Support#1240). With the built-in
* engine it runs locally like SSH.
* following the desktop-wide setting. Remote desktop passes it: it needs a
* guacd, which the desktop does not ship, so originating it here only works
* once the user has pointed Termix at one of their own. Making that opt-in
* per host keeps an upgrade from moving working connections onto a guacd
* that isn't there -- see Termix-SSH/Support#1240.
*/
export async function resolveConnectionOrigin(
host: OriginResolvableHost,
+1 -3
View File
@@ -1,5 +1,4 @@
import { getBasePath } from "./base-path";
import { localBackendPort } from "./electron";
type BrowserLocation = Pick<
Location,
@@ -17,8 +16,7 @@ export function getDatabaseTransferUrl(
{ electron, configuredServerUrl, location }: DatabaseTransferUrlOptions,
): string {
if (electron) {
const serverUrl =
configuredServerUrl || `http://localhost:${localBackendPort()}`;
const serverUrl = configuredServerUrl || "http://localhost:30001";
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
}
-18
View File
@@ -3,7 +3,6 @@ type ElectronWindow = Window &
IS_ELECTRON?: boolean;
electronAPI?: {
isElectron?: boolean;
backendPort?: number;
};
};
@@ -17,20 +16,3 @@ export function isElectron(): boolean {
return hasISElectron || hasElectronAPI || isElectronProp;
}
const DEFAULT_LOCAL_BACKEND_PORT = 30001;
/**
* The port of the desktop app's embedded backend. It moves off 30001 when
* another program holds that port.
*/
export function localBackendPort(): number {
if (typeof window === "undefined") return DEFAULT_LOCAL_BACKEND_PORT;
const port = (window as ElectronWindow).electronAPI?.backendPort;
return typeof port === "number" &&
Number.isInteger(port) &&
port > 0 &&
port < 65536
? port
: DEFAULT_LOCAL_BACKEND_PORT;
}
-25
View File
@@ -1,25 +0,0 @@
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,5 +1,3 @@
import type { TFunction } from "i18next";
/**
* Where a plugin's i18n keys resolve from.
*
@@ -21,25 +19,3 @@ export function pluginKey(
if (!key) return key;
return key.includes(":") ? key : `${pluginId}:${key}`;
}
/**
* A plugin's name in the UI language: "plugin.name" from its own locales,
* else the manifest's. A plugin whose strings are not loaded, like one only
* listed in a registry, shows the manifest name.
*/
export function pluginName(
t: TFunction,
plugin: { id: string; name: string },
): string {
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
}
/** Same as pluginName, for "plugin.description". */
export function pluginDescription(
t: TFunction,
plugin: { id: string; description?: string | null },
): string {
return t(pluginKey(plugin.id, "plugin.description"), {
defaultValue: plugin.description ?? "",
});
}
+2 -2
View File
@@ -19,7 +19,7 @@
import type { AxiosInstance } from "axios";
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
import { getBasePath } from "@/lib/base-path";
import { isElectron, localBackendPort } from "@/lib/electron";
import { isElectron } from "@/lib/electron";
import { getDeviceId } from "@/lib/device-id";
import { websocketAuthProtocols } from "@/lib/ws-auth";
import {
@@ -165,7 +165,7 @@ export async function pluginWsUrl(
if (isElectron()) {
return buildOriginWsUrl({
origin: options.origin ?? "local",
localPort: localBackendPort(),
localPort: BACKEND_PORT,
localPath: route,
remotePath: route,
});
-56
View File
@@ -1,56 +0,0 @@
/**
* Brings a signed-out full-screen `?view=` link back after sign-in. The link
* waits in sessionStorage, which survives a redirect login in the same tab,
* so no return path is ever sent to the server.
*/
const PENDING_VIEW_LINK_KEY = "termix-pending-view-link";
/** The value as a same-origin path with a `view` parameter, or null. */
export function toViewLinkPath(value: unknown): string | null {
if (typeof value !== "string") return null;
if (!value.startsWith("/") || value.startsWith("//")) return null;
let url: URL;
try {
url = new URL(value, window.location.origin);
} catch {
return null;
}
if (url.origin !== window.location.origin) return null;
if (!url.searchParams.get("view")) return null;
return `${url.pathname}${url.search}${url.hash}`;
}
export function rememberViewLink(): void {
const { pathname, search, hash } = window.location;
const path = toViewLinkPath(`${pathname}${search}${hash}`);
if (!path) return;
try {
sessionStorage.setItem(PENDING_VIEW_LINK_KEY, path);
} catch {
// Without storage the user just lands on the app after signing in.
}
}
/** The remembered link, cleared whether or not it is still valid. */
export function consumeViewLink(): string | null {
try {
const stored = sessionStorage.getItem(PENDING_VIEW_LINK_KEY);
sessionStorage.removeItem(PENDING_VIEW_LINK_KEY);
return toViewLinkPath(stored);
} catch {
return null;
}
}
/**
* Opens the remembered link, if there is one. The navigation waits a tick so
* history updates the login callback makes right after sign-in cannot cancel
* it.
*/
export function resumeViewLink(): boolean {
const path = consumeViewLink();
if (!path) return false;
setTimeout(() => window.location.replace(path), 0);
return true;
}
+2 -2
View File
@@ -8,7 +8,7 @@ import axios, {
import { toast } from "sonner";
import i18n from "i18next";
import { getBasePath } from "@/lib/base-path";
import { isElectron, localBackendPort } from "@/lib/electron";
import { isElectron } from "@/lib/electron";
import { clearTermixSessionStorage } from "@/shell/TabContext";
import type { SSHHost } from "@/types/index";
@@ -603,7 +603,7 @@ function getApiUrl(path: string, defaultPort: number): string {
if (electronMode) {
// The desktop app always talks to its embedded backend. The server it
// may be linked to is reached through createRemoteOriginApiInstance.
return `http://localhost:${localBackendPort()}${path}`;
return `http://localhost:${defaultPort}${path}`;
} else if (devMode) {
if (!import.meta.env.VITE_API_HOST) {
return `/__termix_api/${defaultPort}${path}`;
+7 -10
View File
@@ -20,7 +20,6 @@ import {
recommendedChoices,
} from "../plugin-choices";
import { DocsLink } from "@/components/docs-link";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
const CATEGORY_ORDER = [
"Terminal",
@@ -80,8 +79,8 @@ export function PluginsStep({
}, [list, choices]);
const names = useMemo(
() => new Map(list?.plugins.map((p) => [p.id, pluginName(t, p)]) ?? []),
[list, t],
() => new Map(list?.plugins.map((p) => [p.id, p.name]) ?? []),
[list],
);
const changed = useMemo(() => {
@@ -261,7 +260,7 @@ export function PluginsStep({
/>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
{pluginName(t, plugin)}
{plugin.name}
{plugin.recommended && (
<span className="border border-accent-brand/40 px-1 text-[9px] font-semibold uppercase tracking-wider text-accent-brand">
{t("onboarding.pluginsRecommended")}
@@ -269,7 +268,7 @@ export function PluginsStep({
)}
</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{pluginDescription(t, plugin)}
{plugin.description}
</span>
{forcedBy.has(plugin.id) && (
<span className="text-[10px] text-accent-brand">
@@ -282,7 +281,7 @@ export function PluginsStep({
</div>
<div
role="radiogroup"
aria-label={pluginName(t, plugin)}
aria-label={plugin.name}
className="flex shrink-0 border border-border"
>
{CHOICES.map((option) => (
@@ -336,11 +335,9 @@ export function PluginsStep({
}
/>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{pluginName(t, plugin)}
</span>
<span className="text-xs font-medium">{plugin.name}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{pluginDescription(t, plugin)}
{plugin.description}
</span>
<span className="text-[10px] text-muted-foreground/80">
{t("onboarding.pluginsConsentNote")}
+1 -2
View File
@@ -2,7 +2,6 @@ import { CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { usePermissions } from "@/hooks/use-permissions";
import { pluginName } from "@/lib/plugin-i18n";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { usePluginStore } from "./plugin-store";
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
@@ -28,7 +27,7 @@ export function PluginViewPlaceholder({
usePluginStore();
const { has } = usePermissions();
const { status, owner } = unregisteredViewStatus(kind, viewId);
const name = owner ? pluginName(t, owner.summary) : viewId;
const name = owner?.summary.name ?? viewId;
if (status === "loading") {
return (
+1 -3
View File
@@ -10,7 +10,6 @@ import {
} from "react";
import { scopeHostFields } from "./host-scope";
import { useTranslation } from "react-i18next";
import { pluginName } from "@/lib/plugin-i18n";
import { getPluginRecord } from "./plugin-store";
const PluginScopeContext = createContext<string | null>(null);
@@ -28,8 +27,7 @@ function CrashNotice({
onRetry: () => void;
}) {
const { t } = useTranslation();
const summary = getPluginRecord(pluginId)?.summary;
const name = summary ? pluginName(t, summary) : pluginId;
const name = getPluginRecord(pluginId)?.summary.name ?? pluginId;
return (
<div className="flex flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<span>{t("plugins.runtime.crashed", { name })}</span>
+2 -10
View File
@@ -25,7 +25,6 @@ import {
type RegistryListing,
} from "@/api/plugins-api";
import { useConfirm } from "@/components/surface/surface-scope";
import { pluginDescription, pluginName } from "@/lib/plugin-i18n";
import { mergePlugins, uploadEntry, type PluginEntry } from "./plugin-model";
import type { ConsentRequest } from "./PluginConsentPrompt";
@@ -110,16 +109,9 @@ export function usePluginsManager() {
window.removeEventListener(DEVELOPER_MODE_CHANGED_EVENT, onChange);
}, []);
// Named in the UI language once here, so lists, sorting, search, dialogs
// and toasts all show the same name.
const plugins = useMemo(
() =>
mergePlugins(installed, registry?.plugins ?? null).map((plugin) => ({
...plugin,
name: pluginName(t, plugin),
description: pluginDescription(t, plugin),
})),
[installed, registry, t],
() => mergePlugins(installed, registry?.plugins ?? null),
[installed, registry],
);
const run = useCallback(
+7 -3
View File
@@ -11,7 +11,7 @@ import { toast } from "sonner";
import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card";
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import {
getPluginAdminSettings,
@@ -68,7 +68,9 @@ export function FeatureSettingsSection({
const { t } = useTranslation();
return (
<AccordionSection
label={pluginName(t, plugin)}
label={t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
})}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open}
onToggle={onToggle}
@@ -196,7 +198,9 @@ export function FeatureSettingsForm({
{!running && (
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", {
name: pluginName(t, plugin),
name: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
})}
</div>
)}
+2 -5
View File
@@ -15,7 +15,6 @@ import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { DocsLink } from "@/components/docs-link";
import { PluginIcon } from "@/lib/plugin-icon";
import { pluginName } from "@/lib/plugin-i18n";
import { type PluginSummary } from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
import { usePluginScope } from "@/plugin-host/scope";
@@ -142,15 +141,13 @@ function HostPluginSection({
return (
<SectionCard
title={pluginName(t, plugin)}
title={plugin.name}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
action={<DocsLink href={plugin.docs} />}
>
{!running && (
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", {
name: pluginName(t, plugin),
})}
{t("settings.featureUnavailable", { name: plugin.name })}
</div>
)}
+4 -2
View File
@@ -18,7 +18,7 @@ import { EmptyState } from "@/components/empty-state";
import { isElectron } from "@/lib/electron";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { cn } from "@/lib/utils";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import type { Host } from "@/types/ui-types";
import type { UserPreferences } from "@/main-axios";
@@ -121,7 +121,9 @@ export function SettingsTab({
id: `plugin:${plugin.id}`,
band: "plugins",
labelKey: pluginKey(plugin.id, "plugin.name"),
label: pluginName(t, plugin),
label: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
icon: SettingsIcon,
pluginId: plugin.id,
searchKeys: fields.flatMap((field) =>
+1 -2
View File
@@ -22,7 +22,6 @@ import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs";
import { pluginName } from "@/lib/plugin-i18n";
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
@@ -263,7 +262,7 @@ function InstanceUpdates() {
{betaPlugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={pluginName(t, plugin)}
label={plugin.name}
description={
plugin.channel === "beta"
? `v${plugin.version}`
+1 -2
View File
@@ -7,7 +7,6 @@
*/
import { PluginIcon } from "@/lib/plugin-icon";
import { pluginKey } from "@/lib/plugin-i18n";
import {
hostEditorSectionList,
registerHostEditorSection,
@@ -96,7 +95,7 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
pluginId: plugin.id,
group: placement?.group ?? host.editorGroup ?? "top",
order: placement?.order ?? host.editorOrder ?? 100,
labelKey: pluginKey(plugin.id, "plugin.name"),
labelKey: plugin.name,
label: plugin.name,
// Nothing but the plugin's host settings, so it works for defaults too.
defaults: ownSections.has(plugin.id) ? "only" : true,
+1 -2
View File
@@ -18,7 +18,6 @@ import {
type SplitPresetId,
} from "./split-presets";
import { MAX_PANES, type PaneEdge } from "./split-tree";
import { paneNavHotkeyLabel } from "@/lib/pane-nav-hotkey";
function NodeThumb({ node }: { node: LayoutNode }) {
if (node.kind === "pane") {
@@ -130,7 +129,7 @@ export function SplitLayoutMenu({
<div className="flex flex-col gap-1 px-2 pb-1.5 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-2">
<span>{t("splitScreen.shortcutNavigate")}</span>
<kbd className="font-mono text-[10px]">{paneNavHotkeyLabel()}</kbd>
<kbd className="font-mono text-[10px]">Alt+Arrows</kbd>
</div>
<div className="flex items-center justify-between gap-2">
<span>{t("splitScreen.zoom")}</span>
+1 -1
View File
@@ -730,7 +730,7 @@ export function HostEditor({
<EditorSection
key={id}
id={id}
label={t(section.labelKey, { defaultValue: section.label })}
label={section.label ?? t(section.labelKey)}
>
<LazySection>
<Section
+6 -6
View File
@@ -45,7 +45,7 @@ export interface HostEditorSectionDef {
pluginId?: string;
group: "top" | "ssh";
labelKey: string;
/** Shown when labelKey has no translation. */
/** Already-translated label, used instead of labelKey when set. */
label?: string;
icon?: ComponentType<{ className?: string }>;
/** Position among the group's tabs, core ones included. */
@@ -88,7 +88,7 @@ function sectionVisible(
function mergeByOrder(
core: (HostTab & { order: number })[],
group: "top" | "ssh",
t: (key: string, options?: { defaultValue?: string }) => string,
t: (key: string) => string,
protocols: Record<string, boolean> | undefined,
defaultsMode = false,
): HostTab[] {
@@ -104,7 +104,7 @@ function mergeByOrder(
const Icon = section.icon;
return {
id: section.id,
label: t(section.labelKey, { defaultValue: section.label }),
label: section.label ?? t(section.labelKey),
icon: Icon ? <Icon className="size-3" /> : null,
order: section.order ?? 1000,
};
@@ -119,7 +119,7 @@ function mergeByOrder(
* `visible`; without, every registered top tab is listed.
*/
export function makeHostTabs(
t: (key: string, options?: { defaultValue?: string }) => string,
t: (key: string) => string,
protocols?: Record<string, boolean>,
defaultsMode = false,
): HostTab[] {
@@ -146,7 +146,7 @@ export function makeHostTabs(
}
export function makeHostSshSubTabs(
t: (key: string, options?: { defaultValue?: string }) => string,
t: (key: string) => string,
protocols?: Record<string, boolean>,
defaultsMode = false,
): HostTab[] {
@@ -167,7 +167,7 @@ export function makeHostSshSubTabs(
}
export function makeCredentialTabs(
t: (key: string, options?: { defaultValue?: string }) => string,
t: (key: string) => string,
): CredentialTab[] {
return [
{
+1 -2
View File
@@ -55,7 +55,6 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types";
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
import { withLiveStatus } from "@/sidebar/live-host-status";
import { useServerStatus } from "@/lib/ServerStatusContext";
import { pluginName } from "@/lib/plugin-i18n";
import { useHostSidebarPreferences } from "@/sidebar/tree/hooks/useHostSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import type {
@@ -817,7 +816,7 @@ export function HostsPanel({
}
onSelect={(e) => e.preventDefault()}
>
{pluginName(t, plugin)}
{plugin.name}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
+1 -3
View File
@@ -12,7 +12,6 @@ import {
import { Button } from "@/components/button";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { pluginName } from "@/lib/plugin-i18n";
import { usePluginStore } from "@/plugin-host/plugin-store";
import {
UI_AREA_KEYS,
@@ -68,10 +67,9 @@ export function InterfacePresetSettings({
)
.map((area) => {
const pluginId = area.slice("plugin:".length);
const summary = pluginStore.records.get(pluginId)?.summary;
return {
key: area,
label: summary ? pluginName(t, summary) : pluginId,
label: pluginStore.records.get(pluginId)?.summary.name ?? pluginId,
};
});
return [...core, ...plugins];
+2 -3
View File
@@ -11,7 +11,6 @@ import {
import { updatePluginHostSettings } from "@/api/plugins-api";
import { resetHostToDefaults } from "@/api/host-defaults-api";
import { PluginIcon } from "@/lib/plugin-icon";
import { pluginName } from "@/lib/plugin-i18n";
import { usePluginHostSections } from "@/settings/HostPluginSections";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
@@ -1285,7 +1284,7 @@ export function SidebarTree({
value
? "hosts.enablePluginFeature"
: "hosts.disablePluginFeature",
{ name: pluginName(t, plugin) },
{ name: plugin.name },
)}
</DropdownMenuItem>
)),
@@ -1308,7 +1307,7 @@ export function SidebarTree({
{ id: "core", label: t("hostDefaults.resetCore") },
...hostSettingPlugins.map((plugin) => ({
id: plugin.id,
label: pluginName(t, plugin),
label: plugin.name,
})),
].map((option) => (
<DropdownMenuItem
@@ -1,25 +0,0 @@
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
@@ -1,54 +0,0 @@
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
@@ -1,58 +0,0 @@
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
@@ -1,25 +0,0 @@
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
@@ -1,186 +0,0 @@
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,7 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import type { OnboardingPluginList } from "@/api/plugins-api";
const api = vi.hoisted(() => ({
@@ -188,38 +186,4 @@ describe("PluginsStep", () => {
expect(ok).toBe(true);
expect(api.applyOnboardingPlugins).toHaveBeenCalledTimes(1);
});
it("shows plugin names and descriptions in the UI language", async () => {
const i18n = i18next.createInstance();
await i18n.use(initReactI18next).init({
lng: "zh-CN",
fallbackNS: "translation",
resources: {
"zh-CN": {
translation: {},
term: { plugin: { name: "终端", description: "终端插件" } },
stats: { plugin: { name: "统计" } },
},
},
});
render(
<I18nextProvider i18n={i18n}>
<PluginsStep
mode="full"
isAdmin
isDesktop={false}
shellReady={false}
setCanContinue={() => {}}
setBeforeNext={() => {}}
/>
</I18nextProvider>,
);
expect(await screen.findByText("终端")).toBeTruthy();
expect(screen.getByText("终端插件")).toBeTruthy();
expect(screen.getByRole("radiogroup", { name: "终端" })).toBeTruthy();
expect(screen.getByText("统计")).toBeTruthy();
expect(screen.getByText("stats plugin")).toBeTruthy();
expect(screen.getByText("BASE")).toBeTruthy();
expect(screen.getByText("base plugin")).toBeTruthy();
});
});
@@ -35,17 +35,9 @@ vi.mock("@/api/plugins-api", async (importOriginal) => ({
vi.mock("@/components/surface/surface-scope", () => ({
useConfirm: () => vi.fn(async () => true),
}));
const i18n = vi.hoisted(() => {
const strings: Record<string, string> = {
"sample-plugin:plugin.name": "示例",
"sample-plugin:plugin.description": "一个示例插件",
};
return {
t: (key: string, options?: { defaultValue?: string }) =>
strings[key] ?? options?.defaultValue ?? key,
};
});
vi.mock("react-i18next", () => ({ useTranslation: () => i18n }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
import { usePluginsManager } from "@/plugins/use-plugins-manager";
@@ -77,33 +69,3 @@ it("waits for local consent and sends the reviewed version and capabilities", as
{ version: "1.0.0", capabilities: ["kv:own"] },
);
});
it("names plugins in the UI language, falling back to the manifest", async () => {
api.getPluginRegistry.mockResolvedValueOnce({
plugins: [
{
id: "browse-only",
name: "Browse Only",
description: "Not installed",
author: "someone",
category: "Tools",
latestVersion: "1.0.0",
versions: [],
bundled: false,
},
],
managedByServer: true,
registry: {},
} as never);
const { result } = renderHook(() => usePluginsManager());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(
result.current.plugins.map(({ name, description }) => ({
name,
description,
})),
).toEqual([
{ name: "示例", description: "一个示例插件" },
{ name: "Browse Only", description: "Not installed" },
]);
});
@@ -33,10 +33,7 @@ vi.mock("@/api/plugins-api", () => ({
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: { defaultValue?: string }) =>
options?.defaultValue ?? key,
}),
useTranslation: () => ({ t: (key: string) => key }),
}));
function plugin(overrides: Partial<PluginSummary> = {}): PluginSummary {
@@ -78,10 +75,9 @@ describe("syncHostFeatureTabs", () => {
"feature:docker",
"feature:warpgate",
]);
expect(getHostEditorSection(hostFeatureTabId("docker"))).toMatchObject({
labelKey: "docker:plugin.name",
label: "Docker",
});
expect(getHostEditorSection(hostFeatureTabId("docker"))?.label).toBe(
"Docker",
);
expect(getHostEditorSection(hostFeatureTabId("warpgate"))).toBeDefined();
});