Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: enforce unique usernames (#1628)

* fix: enforce unique usernames

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

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

* fix: avoid username collisions during migration

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

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

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

* fix: enforce local plugin trust across remote source labels

* fix: require positive local trust for synced plugin keys

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

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

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

* fix: validate setting keys before editor authorization

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

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

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

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

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

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

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

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

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

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

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

* fix: require local consent for mirrored plugin activation

* docs: describe consent fields on the plugin state endpoint

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

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

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

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

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

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

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

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

* fix(sync): retry temporarily unavailable records

* fix: smooth host editor jumps to lower sections

* chore: add changelog entries for 26.10.2 fixes

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

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

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

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

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

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

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

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

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

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

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

* chore: update translations

* chore: add the overview video to the readme

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

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

* feat: plugin packs include LICENSE and NOTICE

* chore: drop guacd from compose files and dev runner

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

* fix: show plugin names and descriptions in the UI language (#1658)

* fix: show plugin names and descriptions in the UI language

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

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

---------

Co-authored-by: ZacharyZcR <zkl205305@gmail.com>

* fix(hosts): reject or infer a missing authType on host create (#1664)

Creating an SSH host through the API without authType hit the NOT NULL
auth_type column and came back as a 500. The create route now infers the
type from the credential, key or password in the body, falls back to the
host defaults, and otherwise answers 400. A non-string authType is a 400
on create and update.

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

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

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

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

* fix(sync): keep the changes cursor within the 32-bit seq column (#1665)

The desktop learned the server's entity types by asking for changes after
Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres,
which rejects that bound value, so every pass needing the probe failed with
22003 out of range.

The server now clamps the cursor to the largest seq the column holds and
answers 400 for a cursor or limit that is not a non-negative integer. The
desktop asks for a one-row page from its saved cursor instead.

* fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667)

Successful plugin_as_user and plugin_service_* entries written by a plugin
with the same user, action, resource and details are recorded once per
10 minutes. Failures and every other action are still written each time.

* feat(plugins): publish host.created when a host is added (#1668)

Plugins that keep an inventory of hosts had no way to hear about a new
one short of polling. Core now publishes host.created with the same
{ hostId, userId } payload as host.updated wherever a host row is
created: the host create route, ctx.hosts.create, JSON and SSH config
bulk import, database file import, and sync writing a host it did not
have yet.

* fix: show the login for signed-out full-screen view links and return to them (#1669)

A ?view= link opened without a session spun forever: the gate rendered
the bare view when the session check failed, so the plugin runtime never
started and the view never registered. A regular browser now keeps the
link in sessionStorage, shows the normal login and opens the link again
after any login, including redirect logins. Only same-origin paths with
a view are restored. The desktop app, embedded sign-in frames and guest
views keep their current behavior.

* fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670)

* fix(desktop): send linked server proxy headers on WebSocket upgrades

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

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

isInvalidCertificateAllowedForUrl compared the URL with a local
getOrigin copy, so wss://host never matched the link's https://host
origin and an allowed self-signed certificate did not cover the
terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin,
which maps ws and wss the same way as the proxy headers, and the local
copy is removed.

* feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666)

ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background
work a session for calling Termix's own API as an existing user. It needs
users:impersonate, defaults to 300s and is capped at 900s, is stored as a
normal session row marked as plugin-issued, and every issue is audited.
Such sessions are refused on routes that change the user's password,
second factors, API keys or linked logins.

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

SDK 1.0.11, plugin API 1.3.0.

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

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

* chore: pin bundled plugins to the latest releases

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

---------

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

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

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

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

Closes #1617

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

* fix: preserve runtime service worker base paths

---------

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

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

Fixes #1606

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

* chore: use .js extension for test import

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:13:20 +08:00
LukeGus 2fe7530cd4 chore: update paths for renamed local folders 2026-10-13 16:33:20 -05:00
LukeGus 4389bf2013 chore: simplify readme and docs wording 2026-10-12 12:46:26 -05:00
LukeGus 50f80b975d fix: cancel pending app store review before submitting a new version 2026-10-12 12:41:09 -05:00
LukeGus 1ccb30f0a7 chore: only build update files for the release's own channel 2026-10-12 12:30:52 -05:00
LukeGus d44a504c04 chore: bump Homebrew cask to 26.10.1 2026-10-12 17:27:17 +00:00
62ac931468 v26.10.1 (#1599)
* feat: AI translations at release, 7 new languages and regional fallback

* fix: proper plural forms for count strings and full translations

* fix: plural forms for selected, hidden and active counts

* fix: synced plugin updates failing on desktop and server-only plugins shown as stopped

* fix: restore rounded corners on app icons and favicon

* feat: desktop app auto updates with automatic, ask first or never modes

* feat: community and custom plugin registries

* feat: plugin guide for AI tools in the SDK with a lint check

* fix: keep the terminal WebSocket open past nginx's 60s default (#1588)

* chore: update changelog

* feat: show plugin download counts only and stop CI builds inflating them

* feat: hide installed plugins in the browse tab by default

* chore: pin bundled plugins to latest releases

* chore: update translations

* fix: focusing a saved password field and saving no longer erases it

* fix: tell desktop users when their 2.8 data needs 2.9 first

* fix: point windows update info at the x64 installer

* fix: keep desktop client tunnel sockets alive behind proxies

* chore: pin bundled plugins to latest releases

* fix: cover client tunnel keepalive in the c2s runtime tests

* fix: typing after opening a host no longer goes to the host search

* fix: accept tab and = separators in SSH config import (#1598)

* chore: update changelog

* chore: drop plugin versions from core changelog

* chore: update translations

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

---------

Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com>
Co-authored-by: Iain <Iain1970@protonmail.com>
2026-10-12 11:35:48 -05:00
260 changed files with 38576 additions and 2103 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+112 -3
View File
@@ -117,6 +117,15 @@ jobs:
- name: Install dependencies
run: npm ci
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
@@ -134,11 +143,12 @@ jobs:
- name: Build Windows (All Architectures)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build && npx electron-builder --win --x64 --ia32 --publish never
run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never
# The updater only installs the x64 NSIS build; 32-bit installs get a link.
# The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
# The combined multi-arch installer is never uploaded.
- name: Fix Windows update files
run: node scripts/fix-update-info.cjs --drop ia32 release/latest.yml release/beta.yml release/alpha.yml
run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
- name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v7
@@ -156,6 +166,14 @@ jobs:
path: release/termix_windows_ia32_nsis.exe
retention-days: 30
- name: Upload Windows arm64 NSIS Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_nsis
path: release/termix_windows_arm64_nsis.exe
retention-days: 30
- name: Upload Windows x64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
@@ -172,6 +190,14 @@ jobs:
path: release/termix_windows_ia32_msi.msi
retention-days: 30
- name: Upload Windows arm64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_msi.msi') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_msi
path: release/termix_windows_arm64_msi.msi
retention-days: 30
- name: Create Windows x64 Portable zip
if: hashFiles('release/win-unpacked/*') != ''
run: |
@@ -182,6 +208,11 @@ jobs:
run: |
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
- name: Create Windows arm64 Portable zip
if: hashFiles('release/win-arm64-unpacked/*') != ''
run: |
Compress-Archive -Path "release\win-arm64-unpacked\*" -DestinationPath "termix_windows_arm64_portable.zip"
- name: Upload Windows update files
uses: actions/upload-artifact@v7
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
@@ -209,6 +240,14 @@ jobs:
path: termix_windows_ia32_portable.zip
retention-days: 30
- name: Upload Windows arm64 Portable
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_arm64_portable.zip') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_portable
path: termix_windows_arm64_portable.zip
retention-days: 30
build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
@@ -240,6 +279,15 @@ jobs:
npm install --force @rollup/rollup-linux-arm64-gnu
npm install --force @rollup/rollup-linux-arm-gnueabihf
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
@@ -467,6 +515,15 @@ jobs:
npm install dmg-license
node scripts/install-macos-sharp.cjs
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
# A beta build has to know it is a beta, or it reports the stable version.
- name: Stamp beta version
if: inputs.version_override != ''
@@ -954,6 +1011,15 @@ jobs:
npm install dmg-license
node scripts/install-macos-sharp.cjs
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
- name: Check for Code Signing Certificates
id: check_certs
run: |
@@ -1116,6 +1182,49 @@ jobs:
--locales "en-US"
echo "metadata_path=$META_DIR" >> "$GITHUB_OUTPUT"
# Apple won't create a new version while the last one is still in
# review. Pull it out so deliver can rename it to this version.
- name: Cancel pending App Store review
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
run: |
LANE_DIR="$RUNNER_TEMP/asc_cancel/fastlane"
mkdir -p "$LANE_DIR"
cat > "$LANE_DIR/Fastfile" <<'EOF'
default_platform(:mac)
lane :cancel_pending_review do
Spaceship::ConnectAPI.token = Spaceship::ConnectAPI::Token.from_json_file("/tmp/asc_keys/api_key.json")
app = Spaceship::ConnectAPI::App.find("com.karmaa.termix")
UI.user_error!("App not found") unless app
app.get_app_store_versions(filter: { platform: "MAC_OS" }, limit: 5).each do |v|
UI.message("Version #{v.version_string}: #{v.app_version_state || v.app_store_state}")
end
active = %w[WAITING_FOR_REVIEW IN_REVIEW UNRESOLVED_ISSUES]
canceled = false
app.get_review_submissions(filter: { platform: "MAC_OS" }).each do |sub|
UI.message("Review submission #{sub.id}: #{sub.state}")
next unless active.include?(sub.state)
sub.cancel_submission
canceled = true
UI.important("Canceled review submission #{sub.id}")
end
next unless canceled
30.times do
busy = app.get_review_submissions(filter: { platform: "MAC_OS" }).any? do |sub|
(active + %w[CANCELING]).include?(sub.state)
end
break unless busy
sleep(10)
end
end
EOF
cd "$RUNNER_TEMP/asc_cancel" && fastlane cancel_pending_review
- name: Upload and submit to Mac App Store
if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
run: |
+9
View File
@@ -24,6 +24,15 @@ jobs:
- name: Install dependencies
run: npm ci
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
- name: Build plugin SDK
run: npm run build:sdk
+9
View File
@@ -228,6 +228,15 @@ jobs:
- name: Install dependencies
run: npm ci
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
- name: Cache bundled plugins
uses: actions/cache@v6
with:
path: node_modules/.cache/termix-bundled-plugins
key: bundled-plugins-${{ hashFiles('docker/bundled-plugins.json') }}
enableCrossOsArchive: true
- name: Build plugin SDK
run: npm run build:sdk
+59 -1
View File
@@ -1,5 +1,53 @@
# Changelog
## 26.10.2
### Added
- 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
- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there
- Sorting hosts by IP address put 10.0.0.210 before 10.0.0.3
- The local terminal failed to start in the universal macOS build
- Hosts shared with you never showed up on a linked desktop
- A linked desktop could never turn on an unsigned plugin the server had enabled
- Creating, cloning or deleting hosts failed with "no such table: main.p_vault_profiles" after the Vault plugin was removed
- Opening a user in Settings, Users blocked the other admin sections until you went back to the user list
- A linked desktop could keep showing the same sync conflicts, or delete a local host, when the server could not send its copy
- Usernames are now unique
- Importing user data keeps foreign keys on
- Trusted proxy login checks the address nginx saw, not nginx itself
- The desktop login frame only trusts the server's own origin
- External login URLs and callback ports are checked on the desktop app
- A changed jump host key is no longer accepted without asking
- Synced plugins signed only by a key the server sent are no longer turned on by themselves
- Two connects to the same host at once could overwrite each other's connection
- Indonesian uses the same technical terms everywhere
- When the server was down or still starting, the login page said it could not connect to the database and stopped trying. It now says the server is not responding and continues on its own once the server is back
- 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
### Added
@@ -13,13 +61,23 @@
- Every language is now fully translated, in Termix and in every plugin, and stays up to date with each release
- Languages with more than two plural forms, like Russian, Polish and Arabic, now use the right one
- The Plugins tab shows how many times each plugin was downloaded, in a Downloads column
- Plugins, Browse hides plugins you already have. Turn on Show installed to see them
### Fixed
- Regional browser languages like `es-MX`, `pt` or `zh-HK` showed English instead of the closest translation
- Counts like "1 keys" or "Moved 1 host(s)" now use the singular
- Plugin updates synced to the desktop app failed to start with "Cannot find package"
- Plugin updates on the desktop app, synced or installed from the Plugins tab, failed to start with "Cannot find package"
- The desktop app showed plugins that only run on the server as not running and out of date
- Keep the terminal WebSocket open past nginx's 60s default
- Plugin download counts now show in the Plugins tab without waiting for the background check
- Clicking into a saved password, key passphrase or sudo password field and saving without typing erased it, which stopped sudo autofill
- The desktop app now says when its data is from 2.8 or older and needs 2.9 first, instead of only saying the local server could not start
- Client tunnels on the desktop app stopped with "Endpoint SSH connection closed" when a reverse proxy dropped their idle connection
- Typing right after opening a host from the sidebar went to the host search instead of the new session
- SSH config import failed on files that put a tab or `=` between a keyword and its value
- App icons and the favicon have rounded corners again
## 26.10.0
+8 -8
View File
@@ -25,7 +25,7 @@ Termix is a small core plus plugins. This repo is the core: hosts, credentials,
npm run dev
```
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Termix-Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
This builds everything, starts the backend and then the Vite server at `http://localhost:5173/`. Backend changes restart the backend, and plugin changes in `../Plugins` rebuild and reload that plugin. `npm run dev:electron` does the same with the desktop app, and `npm run dev -- --help` lists the other options.
## Useful commands
@@ -38,21 +38,21 @@ npm run format # format the code with Prettier
## Making a change
1. **Fork the repository**: Click "Fork" at the top right of the [repository page](https://github.com/Termix-SSH/Termix).
2. **Create a branch**:
1. Fork the [repo](https://github.com/Termix-SSH/Termix).
2. Create a branch:
```sh
git checkout -b feature/my-new-feature
```
3. **Make your changes** and add or update tests for them.
4. **Commit your changes**:
3. Make your changes and add or update tests for them.
4. Commit your changes:
```sh
git commit -m "feat: add my new feature"
```
5. **Push to your fork**:
5. Push to your fork:
```sh
git push origin feature/my-new-feature
```
6. **Open a pull request** against the current `dev-*` branch with a clear description.
6. Open a pull request against the current `dev-*` branch with a clear description.
## Guidelines
@@ -72,4 +72,4 @@ Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Tem
## Support
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Please be as detailed as possible, preferably in English. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
Bugs and ideas for Termix itself go in [this repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Plugin problems go in that plugin's own `Plugin-<Name>` repo. Add as much detail as you can, in English if possible. You can also ask in the [Discord](https://discord.gg/jVQGdvHDrf) server.
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "26.10.0"
sha256 "66a76a62fbea3306bdf35b0951e9b1200fd1a084a95b4fee9d40edd6e326e11a"
version "26.10.1"
sha256 "08a8fdc6ce3f29bd9031b050a4f6364f3ca446f17cb272392f1b3b488cea5d30"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
+22 -41
View File
@@ -18,36 +18,38 @@
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
</p>
<a href="https://www.youtube.com/watch?v=vhtkP-UqV5U"><img src="./.github/readme/overview.jpg" width="720" alt="Watch the Termix overview on YouTube" /></a>
</div>
<br />
## Overview
Termix is a free, open source, self-hosted platform for managing your servers, built on plugins. The core is small. It keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, turn off or remove.
Termix is a free, open source, self-hosted server management platform built on plugins. The core keeps your hosts, credentials, users, sharing and sessions. Everything else, like the SSH terminal, remote desktop, file manager, Docker and metrics, is a plugin you can turn on, off or remove.
It runs on web, desktop and mobile, and it is a self-hosted alternative to Termius that stays free forever.
It runs on web, desktop and mobile. Think of it as a free, self-hosted Termius.
<br />
## Try the Demo
Want to see it before you install it? Try the demo at [demo.termix.site](https://demo.termix.site/). Any username and password works.
Try it at [demo.termix.site](https://demo.termix.site/). Any username and password works.
<br />
## Core
What every Termix install has, with or without plugins:
What every install has, with or without plugins:
- **Hosts:** Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
- **Credentials:** Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
- **Sharing and Roles:** Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
- **Accounts:** Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
- **Security:** Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
- **Desktop Sync:** The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
- **Interface:** Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
- **Databases:** SQLite by default, with PostgreSQL and MySQL supported too
- Hosts: Save hosts in nested folders with tags and parent hosts, bulk edit, import and export, and use Quick Connect for one-off connections
- Credentials: Reuse saved passwords and keys across hosts, with host defaults you can set for the server, a user or a folder
- Sharing and Roles: Share hosts, credentials and folders with users or roles at four levels: connect, view, edit and manage
- Accounts: Local accounts, sessions you can see and revoke, trusted devices, API keys and an audit log
- Security: Passwords, keys and other secrets are encrypted per user, and the database can be encrypted on disk
- Desktop Sync: The desktop app runs on its own and can link to a Termix server to keep an offline copy of your account
- Interface: Tabs and split screen, a command palette (double tap left shift), rebindable shortcuts, themes, branding and around 30 languages
- Databases: SQLite by default, with PostgreSQL and MySQL supported too
<br />
@@ -133,9 +135,9 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
## Installation
Visit the [Termix Docs](https://docs.termix.site/install) for full install steps on every platform.
See the [docs](https://docs.termix.site/install) for install steps on every platform.
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
```yaml
services:
@@ -149,44 +151,23 @@ 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
You can run Termix on a VPS instead of inside your own network. If Termix runs on the network it manages, an outage takes Termix down with it, right when you need it to fix things. Running it somewhere else keeps it reachable, gives you a static IP and lets you get in from anywhere without a VPN or port forward.
If Termix runs on the network it manages, an outage takes it down right when you need it. A VPS keeps it reachable, gives you a static IP and works from anywhere without a VPN or port forward.
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix, and the docs have a step by step guide for their VPS platform.
[GINERNET](https://docs.termix.site/install/ginernet) sponsors Termix and the docs have a setup guide for their VPS.
<br />
## Donate
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains and development time.
Termix has no paid plans. Donations cover servers, domains and development time.
[Donate](https://donate.termix.site/)
@@ -194,7 +175,7 @@ Termix is free and open source with no subscriptions or paid plans. If you find
## Sponsors
Interested in a paid placement to support development? Email [mail@termix.site](mailto:mail@termix.site).
For a paid sponsor spot, email [mail@termix.site](mailto:mail@termix.site).
<!-- SPONSORS:START -->
@@ -242,7 +223,7 @@ Bugs and ideas for Termix itself (login, hosts, credentials, sharing, sync, the
Every plugin has its own repo, so a problem with the terminal, file manager, Docker, remote desktop or any other plugin goes in that plugin's repo (see the table above, or use Report an issue on the plugin's page in the app, which fills in your versions for you). Not sure where it goes? Open it here and it will be moved.
You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
You need a GitHub account. Add as much detail as you can, in English if possible.
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server. Update videos are on [YouTube](https://www.youtube.com/@TermixSSH/videos).
@@ -250,4 +231,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
## License
Distributed under the Apache License Version 2.0. See [LICENSE](LICENSE) for more information.
Apache License 2.0. See [LICENSE](LICENSE).
+8 -3
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 && \
NODE_OPTIONS="--max-old-space-size=3072" npm run build
TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build
# Stage 3: Build backend
FROM deps AS backend-builder
@@ -34,7 +34,9 @@ WORKDIR /app
COPY . .
RUN npm run build:backend
# Bundled plugin downloads show in the plugin store, so keep them between builds.
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
npm run build:backend
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:24-slim AS opkssh-downloader
@@ -72,12 +74,15 @@ COPY vendor ./vendor
COPY scripts/patch-ssh2-agent.cjs ./scripts/
# ssh2's native crypto crashes Node 24 when a cipher is freed, so ssh2 uses
# OpenSSL instead (chacha20-poly1305 is then left out).
RUN npm ci --omit=dev --ignore-scripts && \
node scripts/patch-ssh2-agent.cjs && \
rm -rf node_modules/better-sqlite3/prebuilds && \
npm run build-release --prefix node_modules/better-sqlite3 && \
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
npm rebuild bcryptjs ssh2 && \
npm rebuild bcryptjs && \
rm -rf node_modules/ssh2/lib/protocol/crypto/build && \
npm cache clean --force
# Stage 6: Final optimized image
+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.0/acme-ssl-1.0.0.tmxplug",
"sha256": "34aa172b2f6bc6a773ebdff2ec0f590d411ce258fd43176bdddb4c30d1fe30da"
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
"sha256": "ad291a2c6f6f01b72ebd275e8d202b78f55bc13a1e1a40f419d04e71b2fbd06b"
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
"sha256": "3fe008f9d2a32e2b7111399054a2264ea5cf5d71264cc31e85cdab3e909cc62f",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"onboarding": {
"recommended": true,
"consent": false
@@ -25,14 +25,14 @@
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
"sha256": "76d4cff6e78d40ec67e899f0c7b2aca43d35a47b9a15071d0a8f5a8d939af9a9"
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
"sha256": "641e564ba915a72efed4236272d63f6fff43904309833870f3554d3e9bd28dfa",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
"onboarding": {
"recommended": true,
"consent": false
@@ -41,8 +41,8 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
"sha256": "1cafccfe86a29e2f318026ca7bc20629df94331c81209607976dafd7feac0ca4",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
"onboarding": {
"recommended": true,
"consent": false
@@ -51,20 +51,20 @@
{
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
"sha256": "1ebeccd6b2a8e9510ab56658374d0b87f652149101d14956752a696ca920f611"
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
"sha256": "65cd21802f6a71f3b3c582bc0007bfc91160abc161b2390bce715fd2ed987f99"
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
},
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
"sha256": "1afce5489038e5e30b55210e8e43ba93e0446fe087e2952af75a402dbf809ea9",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
"onboarding": {
"recommended": true,
"consent": false
@@ -73,32 +73,32 @@
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
"sha256": "1df08a931457043ba500689a73b4b5ab2bfb43630f377557e0fb13280b996334"
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
},
{
"id": "network-topology",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug",
"sha256": "dc3ad943c1753b352b4d80c09836a293a8767293061ddf2e641575bae97124e9"
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
"sha256": "91cc1575aa1ff84632e0b6a4da68df3a7df4b8e18431b9c0378d314b886ed55d"
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
"sha256": "09891a3ab67a5b6589d8ab9e0fc88d8c63ff9d4e52ed8e7cbd0801a5e64e593f"
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
},
{
"id": "remote-desktop",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
"sha256": "7b24691953550458ee1609372443eeb5580d20091b02c95011c0427268350efc",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
"onboarding": {
"recommended": true,
"consent": false
@@ -107,14 +107,14 @@
{
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug",
"sha256": "04e9cfca70ffafc41546ddf0f1f6411743a2e0e9835b26ad5c0b91ac11028485"
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
"sha256": "2470acbd6c4c343c3d89dada972051f1ee7aace88af1e6719b92f19a4a6e46ea",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"onboarding": {
"recommended": "desktop",
"consent": false
@@ -123,20 +123,20 @@
{
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug",
"sha256": "6b630ca0e08d79be7cb49366c898586ee3416a518f8e200157a6191c2c7c9ab7"
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
},
{
"id": "session-sharing",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug",
"sha256": "0c0d3f2d63184d4a4b2a90b11a1ee247ddc937abba76af74eb39e27048b412f9"
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
"sha256": "47872d08cdf4a15b50c8f90ca125276a92579147a27cb6b5639a84a01160abe5",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"onboarding": {
"recommended": true,
"consent": false
@@ -145,8 +145,8 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
"sha256": "5b5a357bbf9a503c6b68725245712a99fa8ea0eec0baff64319be28950acac79",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
"onboarding": {
"recommended": true,
"consent": false
@@ -155,8 +155,8 @@
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.1/sso-1.0.1.tmxplug",
"sha256": "ba7485ba058c784975609f6960bbaf7ec2018bcdce40b47308efc92ca96fdab5",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"onboarding": {
"recommended": false,
"consent": false,
@@ -166,20 +166,20 @@
{
"id": "step-ca",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug",
"sha256": "05209afbaeb104ff77fecb5cf26a7823b9ed66031eac7557d3216fc3b861f7a6"
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
"sha256": "e9840bddef696b24354b9f04f2c97e164c3879f3fd1b337347de728db522874e"
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
"sha256": "b41210d638b93470891ab238e3d1180a0e0d0f0c697b1c2079156416dec05966",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"onboarding": {
"recommended": true,
"consent": true
@@ -188,20 +188,20 @@
{
"id": "termix-identity",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug",
"sha256": "facd9f1c6036202e7c6b1020408ce49c7ba4bc687a8f8b93ccf60e97f473f897"
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
},
{
"id": "tmux-monitor",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug",
"sha256": "d453e0c5f1bf54160615d5df4e12eac0d97fea3c78c0aad5ebe8552e1f2de42c"
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
"sha256": "a2d389048d16e31c089cf211fe2fb37fcfb33369497b58ee445e8626e9ec494d",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"onboarding": {
"recommended": true,
"consent": false
@@ -210,8 +210,8 @@
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
"sha256": "dc6c2942490c07ec1f9a4065850288cbb00c42dae64397937d2551d689314939",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
"onboarding": {
"recommended": true,
"consent": false
@@ -220,32 +220,32 @@
{
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
"sha256": "bdad88ed576ed30b464c9cbddab4d9bb60696581dc1b815b76e1b4a08500f870"
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
},
{
"id": "wake-on-lan",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug",
"sha256": "3e7d4c8a9a20cde95d8594d4ae8e1fa0469d0751be9f00249f6431b10ada9ea9"
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
"sha256": "18ed1c8238ecf57b5776ad93c6533d7040b988ae765012c5da591b85faa02d2e"
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
},
{
"id": "web-endpoint",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug",
"sha256": "7f69e608eccd8cc6fa2436bffb3e2c3c8aae6cebebf7341913f15e7f9be58e42"
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
"sha256": "dafcd7bfffc39b60a14333164dfd8ca70986da1390285359669d0293cd050121",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
"onboarding": {
"recommended": true,
"consent": false
@@ -254,8 +254,8 @@
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
"sha256": "1ee3a07bd8d61b46b6fbe118d2a424f8027575282287622b467e83a4356e6412",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"onboarding": {
"recommended": true,
"consent": false
-15
View File
@@ -12,24 +12,9 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
GUACD_HOST: "guacd-dev"
GUACD_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on:
- guacd-dev
networks:
- termix-dev-net
guacd-dev:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
volumes:
- termix-dev-data:/termix-data
networks:
- termix-dev-net
+5 -18
View File
@@ -9,12 +9,6 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# Where guacd stores files for RDP drive redirection (one folder per
# user is created underneath). Must be writable by guacd's user.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# Multi-instance deployments should point every backend at the same
# Redis. Without it, collaboration rooms keep their single-instance
# in-memory fallback.
@@ -28,21 +22,14 @@ services:
# Trusted reverse-proxy authentication is disabled by default. When
# enabled, do not expose this container directly to untrusted clients.
# TRUSTED_PROXY_AUTH_ENABLED: "true"
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.16.0.0/12"
# List the address your authenticating proxy connects from, as this
# container sees it (for a proxy container on termix-net, its IP there).
# Keep it narrow: published ports can reach the container from the
# Docker gateway, so do not trust whole private ranges.
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.20.0.10"
# 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
+17 -1
View File
@@ -200,6 +200,9 @@ http {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
# The TCP peer before set_real_ip_from rewrites $remote_addr from
# X-Forwarded-For; trusted proxy login checks this address.
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
@@ -425,6 +428,16 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
# A terminal is quiet most of the time - waiting on a prompt, reading
# output, running something long. Every console plugin dials this route
# (terminal, serial, remote desktop), and nginx's default
# proxy_read_timeout of 60s closed the socket about a minute into any
# pause, which reaches the user as the SSH server disconnecting. The
# plugin WebSocket routes below already ask for 86400s; this is the same
# class of socket, so it asks for the same.
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
}
location /host/ {
@@ -463,9 +476,12 @@ http {
}
error_page 500 502 503 504 /50x.html;
# Only nginx's own errors land here (the backend is down or too slow).
# There is no 50x page, so answer directly instead of a 404.
location = /50x.html {
root /app/html;
internal;
default_type application/json;
return 502 '{"error":"The Termix server is not responding"}';
}
}
}
+17 -1
View File
@@ -169,6 +169,9 @@ http {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
# The TCP peer before set_real_ip_from rewrites $remote_addr from
# X-Forwarded-For; trusted proxy login checks this address.
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
@@ -394,6 +397,16 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
# A terminal is quiet most of the time - waiting on a prompt, reading
# output, running something long. Every console plugin dials this route
# (terminal, serial, remote desktop), and nginx's default
# proxy_read_timeout of 60s closed the socket about a minute into any
# pause, which reaches the user as the SSH server disconnecting. The
# plugin WebSocket routes below already ask for 86400s; this is the same
# class of socket, so it asks for the same.
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
proxy_buffering off;
}
location /host/ {
@@ -432,9 +445,12 @@ http {
}
error_page 500 502 503 504 /50x.html;
# Only nginx's own errors land here (the backend is down or too slow).
# There is no 50x page, so answer directly instead of a 404.
location = /50x.html {
root /app/html;
internal;
default_type application/json;
return 502 '{"error":"The Termix server is not responding"}';
}
}
}
@@ -0,0 +1 @@
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
+19
View File
@@ -0,0 +1,19 @@
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
WITH ranked AS (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE CONCAT(CONCAT('termix-recovered-', candidate, '-'), '%')
)
)
UPDATE users JOIN ranked ON ranked.id = users.id CROSS JOIN available
SET users.username = CONCAT('termix-recovered-', available.candidate, '-', ranked.sequence)
WHERE ranked.occurrence > 1;--> statement-breakpoint
ALTER TABLE `users` MODIFY COLUMN `username` varchar(255) NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD CONSTRAINT `idx_users_username` UNIQUE(`username`);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -498,6 +498,20 @@
"when": 1791550497926,
"tag": "0070_drop_donation_modal",
"breakpoints": true
},
{
"idx": 71,
"version": "5",
"when": 1791755801066,
"tag": "0071_drop_install_count_source",
"breakpoints": true
},
{
"idx": 72,
"version": "5",
"when": 1791935283211,
"tag": "0072_unique_username",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE "plugin_install_counts" DROP COLUMN "source";
+18
View File
@@ -0,0 +1,18 @@
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
WITH ranked AS MATERIALIZED (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
)
)
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "username" SET DATA TYPE varchar(255);--> statement-breakpoint
CREATE UNIQUE INDEX "idx_users_username" ON "users" USING btree ("username");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -498,6 +498,20 @@
"when": 1791550496661,
"tag": "0070_drop_donation_modal",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1791755800419,
"tag": "0071_drop_install_count_source",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1791935282340,
"tag": "0072_unique_username",
"breakpoints": true
}
]
}
@@ -0,0 +1 @@
ALTER TABLE `plugin_install_counts` DROP COLUMN `source`;
+17
View File
@@ -0,0 +1,17 @@
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
WITH ranked AS MATERIALIZED (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
)
)
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`);
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -463,6 +463,20 @@
"when": 1791550495357,
"tag": "0065_drop_donation_modal",
"breakpoints": true
},
{
"idx": 66,
"version": "6",
"when": 1791755799726,
"tag": "0066_drop_install_count_source",
"breakpoints": true
},
{
"idx": 67,
"version": "6",
"when": 1791935281343,
"tag": "0067_unique_username",
"breakpoints": true
}
]
}
+3 -3
View File
@@ -46,11 +46,11 @@
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
"arch": ["x64", "ia32", "arm64"]
},
{
"target": "msi",
"arch": ["x64", "ia32"]
"arch": ["x64", "ia32", "arm64"]
}
],
"icon": "public/icon.ico",
@@ -156,6 +156,6 @@
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
}
},
"generateUpdatesFilesForAllChannels": true,
"generateUpdatesFilesForAllChannels": false,
"msiProjectCreated": "packaging/build/msi-shortcuts.cjs"
}
+6
View File
@@ -35,7 +35,13 @@ function parseConflictingPort(stderr) {
* stderr. Always returns a failure -- see the note above on why every exit
* that reaches this point counts as one.
*/
// Part of PRE_29_DATABASE_ERROR in src/backend/database/db/upgrade-guard.ts.
const OLD_DATABASE_MARKER = "This database is from Termix 2.8 or older";
function classifyBackendFailure(stderr = "") {
if (stderr.includes(OLD_DATABASE_MARKER)) {
return { reason: "old-database", port: null };
}
if (stderr.includes("EADDRINUSE")) {
return { reason: "port-in-use", port: parseConflictingPort(stderr) };
}
+70
View File
@@ -0,0 +1,70 @@
const path = require("path");
// Saved by the file manager after the user picks an editor in a file dialog.
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
// Extensions the OS would run rather than open in an editor. With no editor
// configured the temp copy goes to shell.openPath, and its name comes from
// the remote server.
const LAUNCHABLE_EXTENSIONS = new Set([
// Windows
"application",
"appref-ms",
"bat",
"chm",
"cmd",
"com",
"cpl",
"exe",
"gadget",
"hta",
"inf",
"jar",
"js",
"jse",
"library-ms",
"lnk",
"msc",
"msi",
"msp",
"mst",
"pif",
"ps1",
"psd1",
"psm1",
"py",
"pyw",
"reg",
"scf",
"scr",
"search-ms",
"settingcontent-ms",
"url",
"vbe",
"vbs",
"ws",
"wsf",
"wsh",
"xll",
// macOS
"app",
"command",
"pkg",
"terminal",
"tool",
"workflow",
// Linux
"appimage",
"desktop",
"run",
"sh",
]);
function isLaunchableFileName(fileName) {
// Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe.
const name = path.basename(String(fileName || "")).replace(/[. ]+$/, "");
const extension = path.extname(name).slice(1).toLowerCase();
return LAUNCHABLE_EXTENSIONS.has(extension);
}
module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName };
+20
View File
@@ -0,0 +1,20 @@
/**
* The login URL a server hands the desktop app goes to shell.openExternal,
* so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...)
* would launch whatever OS handler is registered for it.
*/
function isExternalLoginUrl(value) {
try {
const { protocol } = new URL(value);
return protocol === "https:" || protocol === "http:";
} catch {
return false;
}
}
/** A non-privileged port for the loopback callback listener. */
function isCallbackPort(value) {
return Number.isInteger(value) && value >= 1024 && value <= 65535;
}
module.exports = { isExternalLoginUrl, isCallbackPort };
+8 -1
View File
@@ -6,9 +6,16 @@
let linked = null;
const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" };
// A WebSocket to the linked server (wss://host/...) counts as the link's
// https://host origin, so its upgrade carries the proxy headers too.
function getOrigin(url) {
try {
return new URL(url).origin;
const parsed = new URL(url);
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
if (httpProtocol) parsed.protocol = httpProtocol;
return parsed.origin;
} catch {
return null;
}
+8 -2
View File
@@ -330,7 +330,11 @@ const AUTH_TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
// path, so nothing here names it. Only a /plugin-api/<id> path is accepted,
// which keeps every transfer on the backend's plugin routes.
const API_PATH_PATTERN = /^\/plugin-api\/[a-z0-9][a-z0-9-]*$/;
const LOCAL_BACKEND = "http://localhost:30001";
let localBackendPort = 30001;
function setLocalBackendPort(port) {
localBackendPort = port;
}
let registeredApiPath = null;
function setTransferApiPath(apiPath) {
@@ -403,7 +407,8 @@ function createTargetResolver({ localBaseUrl, getLinkedServer, apiPath }) {
}
return {
url: `${normalizeHttpBase(
localBaseUrl ?? `${LOCAL_BACKEND}${resolveApiPath()}`,
localBaseUrl ??
`http://localhost:${localBackendPort}${resolveApiPath()}`,
"Local backend URL",
)}${routePath}`,
headers,
@@ -1050,6 +1055,7 @@ module.exports = {
createLocalFileHandlers,
createTargetResolver,
setTransferApiPath,
setLocalBackendPort,
publishDownload,
defaultPublishFs,
assertWithinRoot,
+5 -1
View File
@@ -39,7 +39,11 @@ function resolveLocalShell(
* how many helpers were fixed.
*/
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked");
// A universal macOS build ships app-arm64.asar and app-x64.asar.
const dir = ptyDir.replace(
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
"$1.asar.unpacked",
);
const candidates = [
path.join(dir, "build", "Release", "spawn-helper"),
path.join(
+106 -115
View File
@@ -35,7 +35,15 @@ const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
const { resolveLocalShell, repairSpawnHelpers } = require("./local-shell.cjs");
const { registerLocalFileHandlers } = require("./local-files.cjs");
const {
registerLocalFileHandlers,
setLocalBackendPort,
} = require("./local-files.cjs");
const {
EXTERNAL_EDITOR_SETTING,
isLaunchableFileName,
} = require("./external-editor.cjs");
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
const localTerminalSessions = new Map();
@@ -434,14 +442,6 @@ function isInsecureModeEnabled() {
);
}
function getOrigin(url) {
try {
return new URL(url).origin;
} catch {
return null;
}
}
function isPrivateNetworkHost(hostname) {
if (
hostname === "localhost" ||
@@ -485,7 +485,7 @@ function isInvalidCertificateAllowedForUrl(url) {
// allows a self-signed certificate.
const linked = linkedServer.getLinkedServer();
if (!linked?.allowInvalidCertificate) return false;
return getOrigin(url) === linked.origin;
return linkedServer.isLinkedOrigin(url);
}
function getTlsVerificationOptions(url) {
@@ -700,6 +700,9 @@ let tray = null;
let isQuitting = false;
const tempFiles = new Map();
const externalEditorSessions = new Map();
// Paths the user picked in an open dialog this session. Only these may be
// saved as the external editor, so the renderer cannot name an executable.
const dialogChosenPaths = new Set();
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
@@ -1116,6 +1119,35 @@ function reapOrphanedBackendProcess() {
}
}
const DEFAULT_BACKEND_PORT = 30001;
let backendPort = DEFAULT_BACKEND_PORT;
function canListenOn(port) {
return new Promise((resolve) => {
const probe = net.createServer();
probe.once("error", () => resolve(null));
probe.listen(port, "127.0.0.1", () => {
const { port: bound } = probe.address();
probe.close(() => resolve(bound));
});
});
}
// 30001 when it is free, otherwise any free port. Only the desktop app talks
// to this backend, so the number itself does not matter.
async function findBackendPort() {
const preferred = await canListenOn(DEFAULT_BACKEND_PORT);
if (preferred) return preferred;
const fallback = await canListenOn(0);
if (fallback) {
logToFile(
`Port ${DEFAULT_BACKEND_PORT} is in use, starting the backend on ${fallback}`,
);
return fallback;
}
return DEFAULT_BACKEND_PORT;
}
function startBackendServer() {
reapOrphanedBackendProcess();
backendFailure = null;
@@ -1156,7 +1188,8 @@ function startBackendServer() {
DATA_DIR: dataDir,
NODE_ENV: "production",
ELECTRON_EMBEDDED: "true",
PORT: "30001",
PORT: String(backendPort),
EMBEDDED_BACKEND_PORT: String(backendPort),
VERSION: app.getVersion(),
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
@@ -1421,6 +1454,7 @@ function createWindow() {
sandbox: true,
webSecurity: true,
preload: path.join(__dirname, "preload.js"),
additionalArguments: [`--termix-backend-port=${backendPort}`],
partition: termixSessionPartition,
allowRunningInsecureContent: false,
webviewTag: false,
@@ -1663,6 +1697,12 @@ ipcMain.handle("get-embedded-server-status", () => {
ipcMain.handle(
"external-browser-login",
async (_event, authUrl, callbackPort) => {
if (!isExternalLoginUrl(authUrl)) {
return { success: false, error: "Invalid authentication URL" };
}
if (!isCallbackPort(callbackPort)) {
return { success: false, error: "Invalid callback port" };
}
const http = require("http");
return new Promise((resolve) => {
@@ -1984,6 +2024,16 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
// A proxy in front of the server may drop an idle WebSocket, which stopped
// the tunnel. Pings count as traffic.
function keepC2SSocketAlive(ws) {
const timer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.ping();
}, 25_000);
timer.unref?.();
ws.once("close", () => clearInterval(timer));
}
// C2S (client-to-server) tunnels relay through the server this desktop is
// linked to, not the embedded backend.
function getC2SRemoteBaseUrl() {
@@ -2252,6 +2302,7 @@ async function openC2SRelay(
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
keepC2SSocketAlive(ws);
const pendingChunks = [];
let ready = false;
let closed = false;
@@ -2359,10 +2410,13 @@ async function testC2SRelay(
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
keepC2SSocketAlive(ws);
if (runtime) runtime.authSocket = ws;
return new Promise((resolve) => {
let settled = false;
// The relay sends its reason and then closes; keep the reason.
let relayError = null;
const settle = (result) => {
if (settled) return;
settled = true;
@@ -2399,6 +2453,10 @@ async function testC2SRelay(
settle({ success: true });
} else if (message.type === "error") {
clearTimeout(timer);
relayError = normalizeC2SErrorMessage(
message.error,
"Tunnel test failed",
);
settle(createC2SFailure(message.error, "Tunnel test failed"));
}
} catch (error) {
@@ -2412,7 +2470,10 @@ async function testC2SRelay(
});
ws.on("close", () => {
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
setC2STunnelError(
tunnel.name,
relayError || "Endpoint SSH connection closed",
);
}
clearTimeout(timer);
settle({ success: false, error: "Tunnel test connection closed" });
@@ -2669,6 +2730,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
getWebSocketOptions(relayUrl, { headers }),
);
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
keepC2SSocketAlive(ws);
const sockets = new Map();
let closed = false;
@@ -3143,18 +3205,16 @@ ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
return `[${date}] ${computerName} (${platform} ${release})`;
});
function readSetting(key) {
const settingsPath = path.join(app.getPath("userData"), "settings.json");
if (!fs.existsSync(settingsPath)) return null;
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
return settings[key] !== undefined ? settings[key] : null;
}
ipcMain.handle("get-setting", (event, key) => {
try {
const userDataPath = app.getPath("userData");
const settingsPath = path.join(userDataPath, "settings.json");
if (!fs.existsSync(settingsPath)) {
return null;
}
const settingsData = fs.readFileSync(settingsPath, "utf8");
const settings = JSON.parse(settingsData);
return settings[key] !== undefined ? settings[key] : null;
return readSetting(key);
} catch (error) {
console.error("Error reading setting:", error);
return null;
@@ -3162,6 +3222,16 @@ ipcMain.handle("get-setting", (event, key) => {
});
ipcMain.handle("set-setting", (event, key, value) => {
if (typeof key !== "string") {
return { success: false, error: "Invalid setting key" };
}
if (
key === EXTERNAL_EDITOR_SETTING &&
value &&
!dialogChosenPaths.has(value)
) {
return { success: false, error: "Choose the editor in the file dialog" };
}
try {
const userDataPath = app.getPath("userData");
const settingsPath = path.join(userDataPath, "settings.json");
@@ -3185,94 +3255,6 @@ ipcMain.handle("set-setting", (event, key, value) => {
}
});
ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => {
try {
const ses = mainWindow?.webContents?.session;
if (!ses) return null;
const cookies = await ses.cookies.get({
name,
...(targetUrl ? { url: targetUrl } : {}),
});
const cookie = cookies.find((candidate) =>
cookieMatchesUrl(candidate, targetUrl),
);
return (
cookie?.value ||
getRememberedElectronAuthCookie(name, targetUrl)?.value ||
null
);
} catch (error) {
console.error("Failed to get session cookie:", error);
return getRememberedElectronAuthCookie(name, targetUrl)?.value || null;
}
});
function cookieMatchesUrl(cookie, targetUrl) {
if (!targetUrl) return true;
try {
const targetHost = new URL(targetUrl).hostname;
const cookieDomain = (cookie.domain || "").replace(/^\./, "");
return (
cookieDomain === targetHost ||
targetHost.endsWith(`.${cookieDomain}`) ||
(!cookieDomain && targetHost === "localhost")
);
} catch {
return true;
}
}
ipcMain.handle(
"wait-session-cookie",
async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => {
const ses = mainWindow?.webContents?.session;
if (!ses) return { success: false, error: "No Electron session" };
const existingCookies = await ses.cookies.get({
name,
...(targetUrl ? { url: targetUrl } : {}),
});
const existingCookie = existingCookies.find((cookie) =>
cookieMatchesUrl(cookie, targetUrl),
);
if (existingCookie?.value && existingCookie.value !== previousValue) {
return { success: true, value: existingCookie.value };
}
const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl);
if (rememberedCookie?.value && rememberedCookie.value !== previousValue) {
return { success: true, value: rememberedCookie.value };
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
ses.cookies.off("changed", onCookieChanged);
resolve({ success: false, error: "Timed out waiting for cookie" });
}, timeoutMs);
function onCookieChanged(_event, cookie, _cause, removed) {
if (
removed ||
cookie.name !== name ||
!cookie.value ||
cookie.value === previousValue ||
!cookieMatchesUrl(cookie, targetUrl)
) {
return;
}
clearTimeout(timeout);
ses.cookies.off("changed", onCookieChanged);
resolve({ success: true, value: cookie.value });
}
ses.cookies.on("changed", onCookieChanged);
});
},
);
ipcMain.handle("clear-session-cookies", async () => {
try {
clearPersistedElectronAuthCookies();
@@ -3400,7 +3382,9 @@ ipcMain.handle("show-save-dialog", async (_event, options) => {
});
ipcMain.handle("show-open-dialog", async (_event, options) => {
return dialog.showOpenDialog(mainWindow, options || {});
const result = await dialog.showOpenDialog(mainWindow, options || {});
for (const filePath of result.filePaths) dialogChosenPaths.add(filePath);
return result;
});
function toLocalFileType(stat) {
@@ -3723,6 +3707,15 @@ ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
ipcMain.handle("open-external-editor", async (_event, fileData) => {
try {
// The editor comes from the saved setting, never from the caller.
const editorPath = readSetting(EXTERNAL_EDITOR_SETTING) || null;
if (!editorPath && isLaunchableFileName(fileData?.fileName)) {
return {
success: false,
error: "Choose an external editor to open this file type",
};
}
const result = createManagedTempFile(
fileData?.fileName,
fileData?.content,
@@ -3744,10 +3737,6 @@ ipcMain.handle("open-external-editor", async (_event, fileData) => {
lastMtimeMs: stat.mtimeMs,
});
const editorPath =
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
? fileData.editorPath.trim()
: null;
const openError = await openPathWithEditor(result.path, editorPath);
if (openError) {
closeExternalEditorSession(editId);
@@ -3851,6 +3840,8 @@ app.whenReady().then(async () => {
await clearElectronJwtCookiesAtStartup();
if (!isDev) {
backendPort = await findBackendPort();
setLocalBackendPort(backendPort);
const result = await startBackendServer();
logToFile("startBackendServer result:", result);
} else {
+9 -10
View File
@@ -14,7 +14,16 @@ function invokeAllowed(channel, ...args) {
return ipcRenderer.invoke(channel, ...args);
}
// The embedded backend's port, picked by the main process at startup.
const backendPortArg = process.argv.find((arg) =>
arg.startsWith("--termix-backend-port="),
);
const backendPort = backendPortArg
? Number(backendPortArg.split("=")[1])
: undefined;
contextBridge.exposeInMainWorld("electronAPI", {
backendPort,
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => {
@@ -87,16 +96,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
getSessionCookie: (name, targetUrl) =>
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
ipcRenderer.invoke(
"wait-session-cookie",
name,
targetUrl,
previousValue,
timeoutMs,
),
externalBrowserLogin: (authUrl, callbackPort) =>
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
+7 -11
View File
@@ -71,7 +71,6 @@
"group": "server",
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
},
{
"name": "ENABLE_SSL",
"group": "https",
@@ -108,7 +107,6 @@
"internal": true,
"description": "Set by the image when nginx serves HTTPS in front of the backend."
},
{
"name": "DATABASE_DIALECT",
"group": "database",
@@ -161,7 +159,6 @@
"internal": true,
"description": "Where database migrations are read from. Set by the image."
},
{
"name": "JWT_SECRET",
"group": "secrets",
@@ -204,7 +201,6 @@
"secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
},
{
"name": "ALLOW_PASSWORD_LOGIN",
"group": "auth",
@@ -283,7 +279,6 @@
"group": "auth",
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
},
{
"name": "TRUSTED_PROXIES",
"group": "proxy",
@@ -315,7 +310,6 @@
"group": "proxy",
"description": "Comma separated hosts that skip the proxy."
},
{
"name": "LOG_LEVEL",
"group": "logging",
@@ -355,7 +349,6 @@
"deprecated": "the session-recording plugin setting",
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
},
{
"name": "TERMIX_PLUGIN_REGISTRY_URL",
"group": "plugins",
@@ -369,7 +362,7 @@
{
"name": "TERMIX_PLUGIN_STATS_URL",
"group": "plugins",
"description": "Where install counts are read from. Defaults to stats.json next to the registry index."
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
},
{
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
@@ -406,7 +399,6 @@
"internal": true,
"description": "Set by npm run dev."
},
{
"name": "ENABLE_INSECURE_MODE",
"group": "desktop",
@@ -435,6 +427,12 @@
"internal": true,
"description": "Set by the desktop app when it runs its own backend."
},
{
"name": "EMBEDDED_BACKEND_PORT",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it."
},
{
"name": "TERMIX_DATA_DIR",
"group": "desktop",
@@ -459,14 +457,12 @@
"internal": true,
"description": "Set by Flatpak. The desktop app links new versions instead of installing them."
},
{
"name": "DESKTOP_SESSION",
"group": "desktop",
"internal": true,
"description": "Read on Linux to pick the password store."
},
{
"name": "VITE_BASE_PATH",
"group": "internal",
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.0",
"version": "26.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.0",
"version": "26.10.2",
"hasInstallScript": true,
"workspaces": [
"packages/*"
@@ -15058,7 +15058,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.6",
"version": "1.0.11",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
+2 -2
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.0",
"version": "26.10.2",
"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 && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
+38
View File
@@ -1,5 +1,43 @@
# Changelog
## 1.0.11
Plugin API 1.3.0.
### Added
- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
## 1.0.10
### Changed
- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
## 1.0.9
### Added
- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/<name>.js`
- A `.wasm`, `.ttf` or `.otf` import gives the file's bytes, in `termix-plugin build` and in plugin tests
### Fixed
- The plugin guide listed jszip, guacamole-lite and @anthropic-ai/sdk as shared packages, but they are bundled
## 1.0.8
### Added
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
## 1.0.7
### Fixed
- A migration file, or a part of one, holding only comments made core fail to start the plugin
## 1.0.6
### Added
+95 -52
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.6 | Plugin API 1.2.0
SDK 1.0.11 | Plugin API 1.3.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -90,7 +90,7 @@ npm test # termix-plugin test: Vitest
npm run typecheck # tsc
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
npm run format # Prettier
npm run format:check
```
@@ -105,7 +105,7 @@ Before you call a change finished:
- [ ] `npm run build` passes
- [ ] `npm run format:check` passes
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
- [ ] New strings are in `locales/en.json`
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
- [ ] New routes have an `@openapi` block and a test, including without the permission
- [ ] New env vars are listed in `env` in the manifest
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
@@ -225,6 +225,8 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
| `audit.record` | none | Write your own audit line |
| `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed |
| `asUser(userId, fn)` | always audited | Run background work as a user |
@@ -237,6 +239,13 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()`
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
that user's permissions.
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
UI language, like `zh_CN`, or null, for writing to them in it.
### Routes
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
@@ -283,7 +292,8 @@ router.post("/notes", async (req, res) => {
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
`dist/openapi.json` and the docs site renders them.
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
have no actor: check their token yourself and rate limit them.
have no actor: check their token yourself. Core allows 120 public requests a minute per address;
`publicRateLimit` raises that for a proxied page that loads its own assets.
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
`conn.isDataUnlocked()` per message.
@@ -333,8 +343,27 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
### Shared packages
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
`cookie-parser`, `axios`, `jszip`, `guacamole-lite`, `@anthropic-ai/sdk`, `drizzle-orm`, `sharp` and every
Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled.
`cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in
`peerDependencies`. Any other npm package is bundled.
### Workers and WebAssembly
Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name
each worker entry in `package.json`:
```json
"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
```
`build` bundles it alone into `dist/workers/engine.js`; start it with
`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and
can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with
`postMessage`.
Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships
inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module`
and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }`
to a `.d.ts` in the plugin.
## Database
@@ -464,7 +493,7 @@ your own host settings, under `host.pluginSettings[<id>]`.
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
`hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
https://docs.termix.site/develop/more-extension-points.
## Looking native
@@ -518,6 +547,20 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
Untranslated strings show in English.
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
every language.
```json
{
"plugin": {
"name": "Host Metrics",
"description": "Live CPU, memory, disk, network and GPU stats."
}
}
```
## Docs
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
@@ -682,20 +725,20 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## termix-plugin CLI
| Command | What it does |
| ------------------------------ | ---------------------------------------------------------------------------------------------- |
| `build` | Bundle `dist/backend.js` and `dist/frontend.js`, CSS, locales, migrations, `dist/openapi.json` |
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
| `test` | Apply patches, run Vitest |
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
| `keygen` | Write a new signing key pair. `--out <dir>` |
| `changelog` | Print one version's notes. `--version x.y.z` |
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
| Command | What it does |
| ------------------------------ | ----------------------------------------------------------------------------------------------- |
| `build` | Bundle `dist/backend.js`, `dist/frontend.js`, workers, CSS, locales, migrations, `openapi.json` |
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
| `test` | Apply patches, run Vitest |
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
| `keygen` | Write a new signing key pair. `--out <dir>` |
| `changelog` | Print one version's notes. `--version x.y.z` |
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
## SDK entry points
@@ -720,37 +763,37 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## Capabilities
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------ |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Run background work as another user, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames and roles |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `device:serial` | high | Open serial and USB ports on the server |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
An update that adds a capability waits for an admin to approve it.
+2 -2
View File
@@ -50,7 +50,7 @@ in place of the flags. Reload the page to pick up frontend changes.
## Support
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). You need to be logged in to GitHub. Please be as detailed as possible, preferably in English.
Bugs and ideas for the SDK or the `termix-plugin` CLI go in the [Termix repo](https://github.com/Termix-SSH/Termix/issues/new/choose). Add as much detail as you can, in English if possible.
For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf) server.
@@ -58,4 +58,4 @@ For discussions and questions, join the [Discord](https://discord.gg/jVQGdvHDrf)
## License
Distributed under the Apache License Version 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE) for more information.
Apache License 2.0. See [LICENSE](https://github.com/Termix-SSH/Termix/blob/main/LICENSE).
+54 -3
View File
@@ -52,6 +52,43 @@ export function externalRequireInterop(externals) {
};
}
// A .wasm or font import becomes its bytes (a Uint8Array) inside the bundle,
// so it ships in backend.js or a worker with no separate file to find.
export const BINARY_EXTENSIONS = [".wasm", ".ttf", ".otf"];
const BINARY_LOADERS = Object.fromEntries(
BINARY_EXTENSIONS.map((ext) => [ext, "binary"]),
);
const REQUIRE_BANNER =
"import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);";
/**
* package.json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
* bundles each entry on its own into dist/workers/<name>.js, for
* new Worker(new URL("./workers/<name>.js", import.meta.url)). Nothing is left
* external: core's import fallback only runs on the main thread, so a worker
* can't rely on host packages and must not import the SDK.
*/
export function readWorkerEntries(cwd, pluginId) {
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const workers = pkg.termix?.workers ?? {};
return Object.entries(workers).map(([name, entry]) => {
if (!/^[a-z0-9-]+$/.test(name)) {
throw new Error(`${pluginId}: worker name "${name}" must be lowercase`);
}
const file = path.resolve(cwd, String(entry));
if (!fs.existsSync(file)) {
throw new Error(
`${pluginId}: worker "${name}" entry ${entry} is missing`,
);
}
return { name, file };
});
}
const SDK_PATCHES = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
@@ -216,13 +253,27 @@ export async function build({ cwd }) {
// node_modules import instead, resolved at runtime like a host-provided
// package.
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
loader: BINARY_LOADERS,
// Bundled CJS deps still call require() for Node builtins at runtime.
// ESM has no ambient require, so give esbuild's require shim a real one.
banner: {
js: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
},
banner: { js: REQUIRE_BANNER },
});
for (const worker of readWorkerEntries(cwd, pluginId)) {
await esbuild.build({
entryPoints: [worker.file],
outfile: path.join(outDir, "workers", `${worker.name}.js`),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
loader: BINARY_LOADERS,
banner: { js: REQUIRE_BANNER },
});
}
// Without this Node finds the host's typeless package.json, tries backend.js
// as CommonJS first and warns before reparsing it as ESM.
fs.writeFileSync(
+3
View File
@@ -11,6 +11,9 @@ export const TMXPLUG_CONTENTS = [
"README.md",
"CHANGELOG.md",
"icon.svg",
// Licence texts, so third-party notices travel with the plugin.
"LICENSE",
"NOTICE",
];
const BLOCK = 512;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.6",
"version": "1.0.11",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
+50 -3
View File
@@ -357,6 +357,13 @@ export interface PluginRouterOptions {
* (multer, a raw body for signature checking, a proxied stream).
*/
rawBody?: boolean;
/**
* Requests per minute one address may make to this plugin's public routes.
* Defaults to 120, which a proxied page loading its own scripts and images
* can pass on its own. Capped at 10000, and an admin's
* TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it.
*/
publicRateLimit?: number;
}
/**
@@ -1457,6 +1464,20 @@ export interface PluginSecondFactor {
reset?: (userId: string) => Promise<void>;
}
export interface PluginUserTokenOptions {
/** Why the token exists, kept in the audit log and on the session. */
purpose: string;
/** Seconds until it expires. Defaults to 300 and is capped at 900. */
ttlSeconds?: number;
}
export interface PluginUserToken {
/** Send as `Authorization: Bearer <token>` to core's own API. */
token: string;
/** ISO time the session ends. */
expiresAt: string;
}
/**
* Login methods, second factors and SSH auth types. Every register call needs
* auth:provide and a matching contributes.auth entry, and is undone on
@@ -1518,6 +1539,26 @@ export interface PluginAuth {
* delete a provider people still sign in with.
*/
countLinkedUsers: (provider: string) => Promise<number>;
/**
* A short-lived session for an existing user, for calling core's own HTTP
* API as them from work with no request behind it, like a scheduled task
* driving the Termix CLI. The user's RBAC applies unchanged. Needs
* users:impersonate, not auth:provide, and every call is audited.
*
* The session is marked with the plugin and purpose. It cannot change the
* user's password, second factors, API keys or linked logins, so it can
* never outlive itself.
*/
issueUserToken: (
userId: string,
options: PluginUserTokenOptions,
) => Promise<PluginUserToken>;
}
/** What a plugin may learn about a user. Needs users:read. */
export interface PluginUsers {
/** The user's UI language, like "zh_CN", or null when they never chose one. */
getLocale: (userId: string) => Promise<string | null>;
}
export interface PluginOpenIsolatedWindowRequest {
@@ -2006,8 +2047,13 @@ export interface PluginContext {
readonly hosts: PluginHosts;
/** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */
readonly ssh: PluginSsh;
/** Login methods, second factors and SSH auth types. Needs auth:provide. */
/**
* Login methods, second factors and SSH auth types. Needs auth:provide,
* except issueUserToken, which needs users:impersonate.
*/
readonly auth: PluginAuth;
/** What a plugin may learn about a user. Needs users:read. */
readonly users: PluginUsers;
/** Opens Electron windows outside the main renderer. Needs desktop:window. */
readonly desktop: PluginDesktop;
/** Host protocol credentials and the user's saved SSH keys. See PluginCredentials. */
@@ -2032,8 +2078,9 @@ export interface PluginContext {
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* Needs users:impersonate. That and a `userId` option on services.get or
* secrets.getShared are the only ways a plugin can name a user.
* Needs users:impersonate. That, auth.issueUserToken and a `userId` option
* on services.get or secrets.getShared are the only ways a plugin can act
* as a user it names.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
+12 -2
View File
@@ -309,17 +309,27 @@ export function splitStatements(source: string): string[] {
continue;
}
if (char === ";") {
if (current.trim()) statements.push(current.trim());
if (hasSql(current)) statements.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) statements.push(current.trim());
if (hasSql(current)) statements.push(current.trim());
return statements;
}
/** False for a chunk that is only comments, which a driver refuses to run. */
function hasSql(chunk: string): boolean {
return (
chunk
.replace(/--[^\n]*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.trim() !== ""
);
}
/** DROP statement for a definition, for removePluginData. */
export function dropTableSql(
dialect: SqlDialect,
+1 -1
View File
@@ -36,7 +36,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.2.0";
export const PLUGIN_API_VERSION = "1.3.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
+33 -1
View File
@@ -174,6 +174,8 @@ export interface FakeContextOptions {
linkedUsers?: Record<string, number>;
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
locales?: Record<string, string>;
}
export interface FakeProcessOptions {
@@ -247,6 +249,8 @@ export interface FakeAuthRegistrations {
}>;
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
}
export interface FakePluginContext {
@@ -574,6 +578,7 @@ export function createFakeContext(
completedLogins: [],
revokedSessions: [],
loginFailures: new Map(),
userTokens: [],
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -1140,6 +1145,18 @@ export function createFakeContext(
},
countLinkedUsers: async (provider) =>
options.linkedUsers?.[provider] ?? 0,
issueUserToken: async (userId, tokenOptions) => {
auth.userTokens.push({ userId, ...tokenOptions });
const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
return {
token: `fake-token-${auth.userTokens.length}`,
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
};
},
},
users: {
getLocale: async (userId) => options.locales?.[userId] ?? null,
},
desktop: {
@@ -1442,6 +1459,8 @@ export interface MockContextOptions {
linkedUsers?: Record<string, number>;
/** See FakeContextOptions. */
loginAttemptLimit?: number;
/** See FakeContextOptions. */
locales?: Record<string, string>;
}
export interface MockPluginContext extends FakePluginContext {
@@ -1458,7 +1477,8 @@ export interface MockPluginContext extends FakePluginContext {
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
export function createMockCtx(
@@ -1495,6 +1515,7 @@ export function createMockCtx(
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
locales: options.locales,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1786,6 +1807,17 @@ export function createMockCtx(
require("auth:provide");
return ctx.auth.countLinkedUsers(provider);
},
issueUserToken: async (userId, tokenOptions) => {
require("users:impersonate");
return ctx.auth.issueUserToken(userId, tokenOptions);
},
},
users: {
getLocale: async (userId) => {
require("users:read");
return ctx.users.getLocale(userId);
},
},
desktop: {
+24
View File
@@ -30,6 +30,25 @@ function findRepoRoot(startDir: string): string | null {
}
}
/**
* termix-plugin build turns a .wasm or font import into its bytes. Tests get the same,
* so code that imports a module compiles it the same way under vitest.
*/
export function wasmBinaryPlugin() {
return {
name: "termix-wasm-binary",
enforce: "pre" as const,
load(id: string) {
const file = id.split("?")[0];
if (![".wasm", ".ttf", ".otf"].some((ext) => file.endsWith(ext))) {
return null;
}
const base64 = fs.readFileSync(file).toString("base64");
return `export default new Uint8Array(Buffer.from(${JSON.stringify(base64)}, "base64"));`;
},
};
}
export interface PluginVitestOptions {
/** Extra aliases merged over the defaults. */
alias?: Record<string, string>;
@@ -96,7 +115,10 @@ export function pluginVitestConfig(
// unexpectedly". Threads don't.
const pool = "threads" as const;
const plugins = [wasmBinaryPlugin()];
return {
plugins,
resolve: { alias },
test: {
globals: true,
@@ -104,6 +126,7 @@ export function pluginVitestConfig(
pool,
projects: [
{
plugins,
resolve: { alias },
test: {
name: `${pluginId}:backend`,
@@ -116,6 +139,7 @@ export function pluginVitestConfig(
},
},
{
plugins,
resolve: { alias },
test: {
name: `${pluginId}:frontend`,
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+6 -1
View File
@@ -1,4 +1,9 @@
const CACHE_NAME = "termix-static-v2";
// CACHE_NAME carries the package version so a new release always gets a fresh
// cache: the activate handler below deletes every cache whose name differs
// from this one, which is what evicts a previous build's entries. Both
// placeholders on these lines are stamped by scripts/stamp-sw-build-info.cjs
// during `npm run build` - vite copies public/ verbatim and never transforms it.
const CACHE_NAME = "termix-static-" + "__TERMIX_CACHE_VERSION__";
const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
const STATIC_ASSETS = [
`${BASE_PATH}/favicon.ico`,
+1 -1
View File
@@ -7,7 +7,7 @@
* pinned sha256 and unpacked.
*
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
* folder of plugin repos like ../Termix-Plugins. A plugin there whose sources
* folder of plugin repos like ../Plugins. A plugin there whose sources
* changed since its last build is rebuilt first with this checkout's SDK, so
* SDK edits apply without publishing, then any built one (has
* dist/) is copied in place of its pin, or added when it has no pin.
+2
View File
@@ -42,6 +42,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
GUACD_HOST: "127.0.0.1",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
@@ -53,6 +54,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
GUACD_HOST: "termix-dev-guacd",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
});
+4 -5
View File
@@ -3,7 +3,7 @@
* and options.
*
* Every target builds the SDK and stages plugins (local builds from
* ../Termix-Plugins when it exists), then:
* ../Plugins when it exists), then:
*
* - web: compiles the backend in watch mode, starts it, then starts Vite. A
* backend or SDK change restarts the backend. A plugin change rebuilds that
@@ -14,7 +14,8 @@
* mounted. A plugin change rebuilds it and restarts the container. Core
* changes wait for "r", since an image build is slow.
*
* --db postgres|mysql and --guacd run those in Docker next to it.
* --db postgres|mysql and --guacd run those in Docker next to it; --guacd also
* switches remote desktop from its built-in engine to that guacd.
*/
import fs from "node:fs";
@@ -652,9 +653,7 @@ async function main() {
const pluginsDir = options.local
? path.resolve(
root,
options.pluginsDir ??
process.env.TERMIX_LOCAL_PLUGINS ??
"../Termix-Plugins",
options.pluginsDir ?? process.env.TERMIX_LOCAL_PLUGINS ?? "../Plugins",
)
: null;
const localPlugins =
+11
View File
@@ -1,5 +1,6 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import { PRE_29_DATABASE_ERROR } from "../src/backend/database/db/upgrade-guard.js";
const require = createRequire(import.meta.url);
const { classifyBackendFailure } =
@@ -46,6 +47,16 @@ describe("classifyBackendFailure", () => {
});
});
it("identifies a 2.8 database the backend refused to open", () => {
const stderr = `[ERROR] [SYSTEM] Failed to initialize backend services [op:startup_failed]
Error: ${PRE_29_DATABASE_ERROR}
at assertNotPre29Database (upgrade-guard.js:14:11)`;
expect(classifyBackendFailure(stderr)).toEqual({
reason: "old-database",
port: null,
});
});
it("reports a crash for any other non-zero exit", () => {
expect(classifyBackendFailure("TypeError: boom")).toEqual({
reason: "crashed",
+1
View File
@@ -53,6 +53,7 @@ function start() {
isDev: false,
appRoot: "/app",
BACKEND_STDERR_TAIL_LIMIT: 4096,
backendPort: 30001,
handleBackendRequest,
setTimeout,
clearTimeout,
+40
View File
@@ -0,0 +1,40 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { isLaunchableFileName } = require("../electron/external-editor.cjs") as {
isLaunchableFileName: (fileName: unknown) => boolean;
};
describe("Electron external editor", () => {
it("refuses remote file names the OS would run instead of open", () => {
for (const name of [
"payload.exe",
"run.BAT",
"script.ps1",
"a.exe. ",
"a.cmd...",
"../../evil.vbs",
"start.command",
"launch.desktop",
"install.sh",
]) {
expect(isLaunchableFileName(name)).toBe(true);
}
});
it("opens ordinary text files", () => {
for (const name of [
"nginx.conf",
"README.md",
"Dockerfile",
".bashrc",
"config.json",
"index.ts",
"",
undefined,
]) {
expect(isLaunchableFileName(name)).toBe(false);
}
});
});
+41
View File
@@ -0,0 +1,41 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { isExternalLoginUrl, isCallbackPort } =
require("../electron/external-login.cjs") as {
isExternalLoginUrl: (value: unknown) => boolean;
isCallbackPort: (value: unknown) => boolean;
};
describe("Electron external login", () => {
it.each([
"https://idp.example.com/authorize?client_id=termix",
"http://keycloak.lan:8080/realms/home/protocol/openid-connect/auth",
])("opens web login URLs: %s", (url) => {
expect(isExternalLoginUrl(url)).toBe(true);
});
it.each([
"file:///C:/Windows/System32/calc.exe",
"smb://attacker/share",
"ms-msdt:/id PCWDiagnostic",
"javascript:alert(1)",
"not a url",
undefined,
])("refuses other schemes: %s", (url) => {
expect(isExternalLoginUrl(url)).toBe(false);
});
it("accepts the ports the login screen picks", () => {
expect(isCallbackPort(17832)).toBe(true);
expect(isCallbackPort(17931)).toBe(true);
});
it.each([0, 80, 65536, 17832.5, "17832", NaN, undefined])(
"refuses callback port %s",
(port) => {
expect(isCallbackPort(port)).toBe(false);
},
);
});
@@ -32,9 +32,62 @@ describe("Electron security boundary", () => {
}
});
it("never hands the session cookie to the renderer over IPC", () => {
for (const channel of ["get-session-cookie", "wait-session-cookie"]) {
expect(main).not.toContain(`"${channel}"`);
expect(preload).not.toContain(`"${channel}"`);
}
});
it("launches only the editor saved from a file dialog", () => {
expect(main).not.toContain("fileData?.editorPath");
expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)");
expect(main).toContain("dialogChosenPaths.has(value)");
});
it("scopes proxy headers and basic auth to the linked origin", () => {
expect(main).toContain('"sync-proxy-config"');
expect(main).toContain("linkedServer.applyLinkHeaders(");
expect(main).toContain("linkedServer.answerLogin(");
});
});
it("rejects setting keys that coerce to the protected editor key", async () => {
const { runInNewContext } = await import("node:vm");
const { vi } = await import("vitest");
const writeFileSync = vi.fn();
let handler: (event: unknown, key: unknown, value: unknown) => unknown;
runInNewContext(
main.slice(
main.indexOf('ipcMain.handle("set-setting"'),
main.indexOf('ipcMain.handle("clear-session-cookies"'),
),
{
ipcMain: {
handle: (_: string, callback: typeof handler) => {
handler = callback;
},
},
EXTERNAL_EDITOR_SETTING: "fileManager.externalEditorPath",
dialogChosenPaths: new Set(["/chosen/editor"]),
app: { getPath: () => "/settings" },
path: { join: (...parts: string[]) => parts.join("/") },
fs: { existsSync: () => false, mkdirSync: vi.fn(), writeFileSync },
console,
},
);
for (const key of [["fileManager.externalEditorPath"], null, {}, 42]) {
expect(handler!({}, key, "/unapproved/editor")).toEqual({
success: false,
error: "Invalid setting key",
});
}
expect(writeFileSync).not.toHaveBeenCalled();
expect(
handler!({}, "fileManager.externalEditorPath", "/unapproved/editor"),
).toEqual({ success: false, error: "Choose the editor in the file dialog" });
expect(
handler!({}, "fileManager.externalEditorPath", "/chosen/editor"),
).toEqual({ success: true });
expect(writeFileSync).toHaveBeenCalledOnce();
});
+6 -2
View File
@@ -4,7 +4,8 @@
* build, so the desktop updater gets the right download:
*
* --drop <text> remove entries whose file name contains text
* (the ia32 installer, the universal mac zip)
* (the ia32 installer, the combined Windows
* installer, the universal mac zip)
* --rename <old=new> follow an artifact the workflow renamed
*
* Usage: node scripts/fix-update-info.cjs [--drop x] [--rename a=b] files...
@@ -15,9 +16,12 @@ const yaml = require("js-yaml");
function fixUpdateInfo(info, { drop = [], rename = {} } = {}) {
const rewrite = (name) => rename[name] ?? name;
const seen = new Set();
// electron-builder can list the same file more than once.
const files = (info.files ?? [])
.filter((file) => !drop.some((text) => file.url.includes(text)))
.map((file) => ({ ...file, url: rewrite(file.url) }));
.map((file) => ({ ...file, url: rewrite(file.url) }))
.filter((file) => !seen.has(file.url) && seen.add(file.url));
if (files.length === 0) {
throw new Error("no files left in update info");
}
+20
View File
@@ -26,6 +26,26 @@ describe("fixUpdateInfo", () => {
expect(fixed.version).toBe("26.10.2");
});
it("drops the combined Windows installer and duplicate entries", () => {
const fixed = fixUpdateInfo(
{
...info,
files: [
{ url: "termix_windows_nsis.exe", sha512: "c", size: 3 },
{ url: "termix_windows_ia32_nsis.exe", sha512: "a", size: 1 },
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
],
path: "termix_windows_nsis.exe",
},
{ drop: ["ia32", "termix_windows_nsis.exe"] },
);
expect(fixed.files).toEqual([
{ url: "termix_windows_x64_nsis.exe", sha512: "b", size: 2 },
]);
expect(fixed.path).toBe("termix_windows_x64_nsis.exe");
});
it("follows renamed artifacts", () => {
const fixed = fixUpdateInfo(
{
+1 -1
View File
@@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) {
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
`| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **AArch64 (ARM64)** | [EXE](${win("termix_windows_arm64_nsis.exe")}) · [MSI](${win("termix_windows_arm64_msi.msi")}) · [Portable](${win("termix_windows_arm64_portable.zip")}) | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
+2
View File
@@ -85,6 +85,8 @@ export function serviceEnv(options, inDocker) {
const guacd = SERVICES.guacd;
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
// Remote desktop runs built in unless told to use guacd.
env.REMOTE_DESKTOP_ENGINE = "guacd";
}
return env;
}
+2 -2
View File
@@ -122,10 +122,10 @@ Targets
docker Build the production image and run it with your local plugins
Options
--plugins <dir> Plugin repos to build and watch (default ../Termix-Plugins)
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
--no-local Use only the plugins pinned in docker/bundled-plugins.json
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
--guacd Run guacd in Docker for remote desktop
--guacd Run guacd in Docker and use it instead of the built-in remote desktop
--fresh Start from empty data (first run, onboarding)
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
electron, plus -<engine> for postgres or mysql)
+48 -34
View File
@@ -3,43 +3,45 @@ const path = require("node:path");
const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty");
if (!fs.existsSync(nodePtyDir)) {
console.log("[patch-node-pty] node-pty not found, skipping");
process.exit(0);
}
const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js");
const originalHelperRewrite = [
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
].join("\n");
// Older installs may still carry the first version of this patch.
const knownHelperRewrites = [
[
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
].join("\n"),
[
"if (!helperPath.includes('app.asar.unpacked')) {",
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"}",
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
"}",
].join("\n"),
];
const patchedHelperRewrite = [
"if (!helperPath.includes('app.asar.unpacked')) {",
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"}",
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
"}",
].join("\n");
// A universal macOS build ships app-arm64.asar and app-x64.asar, not app.asar.
const patchedHelperRewrite =
"helperPath = helperPath.replace(/\\b(app(?:-[a-z0-9]+)?|node_modules)\\.asar(?!\\.unpacked)/g, '$1.asar.unpacked');";
function patchUnixTerminalHelperRewrite() {
if (!fs.existsSync(unixTerminalPath)) {
function patchUnixTerminalHelperRewrite(file = unixTerminalPath) {
if (!fs.existsSync(file)) {
return false;
}
const source = fs.readFileSync(unixTerminalPath, "utf8");
const source = fs.readFileSync(file, "utf8");
if (source.includes(patchedHelperRewrite)) {
return false;
}
if (!source.includes(originalHelperRewrite)) {
const known = knownHelperRewrites.find((rewrite) => source.includes(rewrite));
if (!known) {
return false;
}
fs.writeFileSync(
unixTerminalPath,
source.replace(originalHelperRewrite, patchedHelperRewrite),
file,
source.replace(known, () => patchedHelperRewrite),
);
return true;
}
@@ -67,19 +69,31 @@ function chmodSpawnHelpers(rootDir) {
return fixed;
}
const helperRewritePatched = patchUnixTerminalHelperRewrite();
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
// Tests and afterPack require this file; only a direct run patches node_modules.
function main() {
if (!fs.existsSync(nodePtyDir)) {
console.log("[patch-node-pty] node-pty not found, skipping");
return;
}
if (helperRewritePatched) {
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
const helperRewritePatched = patchUnixTerminalHelperRewrite();
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
if (helperRewritePatched) {
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
}
if (helpersChmodded > 0) {
console.log(
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
);
}
if (!helperRewritePatched && helpersChmodded === 0) {
console.log("[patch-node-pty] Already patched or target files not found");
}
}
if (helpersChmodded > 0) {
console.log(
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
);
}
if (!helperRewritePatched && helpersChmodded === 0) {
console.log("[patch-node-pty] Already patched or target files not found");
if (require.main === module) {
main();
}
module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite };
+68
View File
@@ -92,6 +92,74 @@ describe("termix-plugin build", () => {
});
});
describe("termix-plugin build workers and wasm", () => {
// The smallest valid module: magic and version, no sections.
const EMPTY_WASM = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0, 0, 0]);
it("inlines a .wasm import as its bytes", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
fs.writeFileSync(path.join(dir, "src", "backend", "mod.wasm"), EMPTY_WASM);
fs.writeFileSync(
path.join(dir, "src", "backend", "index.ts"),
'import bytes from "./mod.wasm";\nexport const valid = WebAssembly.validate(bytes);\nexport function activate() {}\n',
);
await build({ cwd: dir });
const mod = await import(
pathToFileURL(path.join(dir, "dist", "backend.js")).href
);
expect(mod.valid).toBe(true);
});
it("bundles each worker entry on its own into dist/workers", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ termix: { workers: { engine: "src/worker.ts" } } }),
);
const dep = path.join(dir, "node_modules", "worker-dep");
fs.mkdirSync(dep, { recursive: true });
fs.writeFileSync(
path.join(dep, "package.json"),
JSON.stringify({ name: "worker-dep", main: "index.js" }),
);
fs.writeFileSync(path.join(dep, "index.js"), "exports.answer = 42;\n");
fs.writeFileSync(
path.join(dir, "src", "worker.ts"),
'import { parentPort } from "node:worker_threads";\nimport { answer } from "worker-dep";\nparentPort?.postMessage(answer);\n',
);
await build({ cwd: dir });
const file = path.join(dir, "dist", "workers", "engine.js");
expect(fs.readFileSync(file, "utf8")).not.toContain('from "worker-dep"');
const { Worker } = await import("node:worker_threads");
const answer = await new Promise((resolve, reject) => {
const worker = new Worker(file);
worker.once("message", (value) => {
void worker.terminate();
resolve(value);
});
worker.once("error", reject);
});
expect(answer).toBe(42);
});
it("refuses a worker whose entry is missing", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ termix: { workers: { engine: "src/nope.ts" } } }),
);
await expect(build({ cwd: dir })).rejects.toThrow(/entry src\/nope.ts/);
});
});
describe("plugin CSS layers", () => {
it("renames the Tailwind layers so core's utilities always win", () => {
expect(
+4
View File
@@ -46,6 +46,8 @@ function fixturePlugin(manifest: Record<string, unknown> = {}): string {
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
writeFile(dir, "README.md", "# Fixture\n");
writeFile(dir, "icon.svg", "<svg/>\n");
writeFile(dir, "LICENSE", "Apache-2.0\n");
writeFile(dir, "NOTICE", "Third-party notices\n");
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
return dir;
}
@@ -91,6 +93,8 @@ describe("termix-plugin pack", () => {
const names = await entries(file);
expect(names).toEqual([
"LICENSE",
"NOTICE",
"README.md",
"dist/backend.js",
"dist/frontend.js",
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/** Stamp the cache version and static-build base path.
* Docker keeps the base-path placeholder for entrypoint.sh to fill at startup.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SW_PATH = path.join(ROOT, "dist", "sw.js");
const VERSION_PLACEHOLDER = "__TERMIX_CACHE_VERSION__";
const BASE_PATH_PLACEHOLDER = "__TERMIX_SW_BASE_PATH__";
const pkg = JSON.parse(
fs.readFileSync(path.join(ROOT, "package.json"), "utf8"),
);
const cacheVersion = String(pkg.version || "0.0.0").replace(/\./g, "-");
const rawBasePath = process.env.VITE_BASE_PATH || "./";
// "./" is what vite emits for a root deployment; cache URLs stay root-relative.
const basePath =
rawBasePath === "./"
? ""
: `/${rawBasePath.replace(/^\/+/, "").replace(/\/+$/, "")}`;
if (!fs.existsSync(SW_PATH)) {
console.error(`[stamp-sw] dist/sw.js not found - run vite build first`);
process.exit(1);
}
let sw = fs.readFileSync(SW_PATH, "utf8");
const rel = path.relative(ROOT, SW_PATH);
const replacements = [
[VERSION_PLACEHOLDER, cacheVersion],
...(process.env.TERMIX_RUNTIME_BASE_PATH === "true"
? []
: [[BASE_PATH_PLACEHOLDER, basePath]]),
];
let changed = false;
for (const [placeholder, value] of replacements) {
if (sw.includes(placeholder)) {
sw = sw.split(placeholder).join(value);
changed = true;
console.log(
`[stamp-sw] ${rel}: ${placeholder} -> ${JSON.stringify(value)}`,
);
}
}
if (changed) {
fs.writeFileSync(SW_PATH, sw);
}
+59
View File
@@ -0,0 +1,59 @@
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
readFileSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, it } from "vitest";
it.each([
[false, "", ""],
[false, "/termix/", "/termix"],
[true, "", "__TERMIX_SW_BASE_PATH__"],
])(
"stamps service worker paths (runtime=%s, base=%s)",
(runtime, base, expected) => {
const dir = mkdtempSync(path.join(tmpdir(), "termix-sw-"));
try {
mkdirSync(path.join(dir, "scripts"));
mkdirSync(path.join(dir, "dist"));
writeFileSync(
path.join(dir, "scripts/stamp-sw-build-info.cjs"),
readFileSync("scripts/stamp-sw-build-info.cjs"),
);
writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ version: "26.10.2" }),
);
writeFileSync(path.join(dir, "dist/sw.js"), readFileSync("public/sw.js"));
execFileSync(
process.execPath,
[path.join(dir, "scripts/stamp-sw-build-info.cjs")],
{
env: {
...process.env,
VITE_BASE_PATH: base,
TERMIX_RUNTIME_BASE_PATH: String(runtime),
},
},
);
const sw = readFileSync(path.join(dir, "dist/sw.js"), "utf8");
expect(sw).toContain('"26-10-2"');
expect(sw).toContain(`const BASE_PATH = "${expected}";`);
if (runtime) {
expect(sw.replaceAll("__TERMIX_SW_BASE_PATH__", "/termix")).toContain(
'const BASE_PATH = "/termix";',
);
expect(readFileSync("docker/Dockerfile", "utf8")).toContain(
"TERMIX_RUNTIME_BASE_PATH=true",
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
+219 -223
View File
@@ -38,6 +38,7 @@ import { attachPluginWebSockets } from "../plugins/ws.js";
import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import { createCompressionMiddleware } from "../utils/compression-config.js";
import fs from "fs";
@@ -79,7 +80,6 @@ import {
createCurrentSshCredentialUsageRepository,
createCurrentUserRepository,
} from "./repositories/factory.js";
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
import {
selectExportableSettings,
@@ -1235,227 +1235,221 @@ app.post(
};
try {
await withCurrentSqliteForeignKeysDisabled(async () => {
try {
const importedHosts = importDb
.prepare("SELECT * FROM ssh_data")
.all();
const importedHostIds: number[] = [];
for (const host of importedHosts) {
try {
const hostRepository = createCurrentHostRepository();
const exists = await hostRepository.existsForImportIdentity(
userId,
host.ip,
host.port,
host.username,
);
if (exists) {
result.summary.skippedItems++;
continue;
}
const hostData = {
userId: userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags: host.tags,
pin: Boolean(host.pin),
authType: host.auth_type,
forceKeyboardInteractive: host.force_keyboard_interactive,
password: host.password,
key: host.key,
keyPassword: host.key_password,
keyType: host.key_type,
sudoPassword: host.sudo_password,
credentialId: host.credential_id || null,
overrideCredentialUsername: Boolean(
host.override_credential_username,
),
jumpHosts: host.jump_hosts,
...legacyStatusCheck(host),
// Exports from before 2.9.0 carry these in terminal_config.
sshOptions:
host.ssh_options ??
sshOptionsForWrite({
terminalConfig: host.terminal_config,
}) ??
null,
notes: host.notes,
useSocks5: Boolean(host.use_socks5),
socks5Host: host.socks5_host,
socks5Port: host.socks5_port,
socks5Username: host.socks5_username,
socks5Password: host.socks5_password,
socks5ProxyChain: host.socks5_proxy_chain,
createdAt: host.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
(hostData as Record<string, unknown>).defaultOverrides =
JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId: null,
columns: hostData as Record<string, unknown>,
body: hostData as Record<string, unknown>,
}),
);
const created = await hostRepository.createEncryptedForUser(
userId,
hostData,
);
await applyPluginHostImportSettings(
Number(created.id),
importedHostPluginSettings(importDb, host),
);
importedHostIds.push(Number(created.id));
await importHostProtocolLogins(
importDb,
host,
Number(created.id),
userId,
);
result.summary.sshHostsImported++;
} catch (hostError) {
result.summary.errors.push(
`SSH host import error: ${hostError.message}`,
);
}
}
await applyDefaultsAfterHostWrites(importedHostIds);
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
try {
const importedCreds = importDb
.prepare("SELECT * FROM ssh_credentials")
.all();
for (const cred of importedCreds) {
try {
const credentialRepository =
createCurrentCredentialRepository();
const exists =
await credentialRepository.existsForImportIdentity(
userId,
cred.name,
cred.username,
);
if (exists) {
result.summary.skippedItems++;
continue;
}
const credData = {
userId: userId,
name: cred.name,
description: cred.description,
folder: cred.folder,
tags: cred.tags,
authType: cred.auth_type,
username: cred.username,
password: cred.password,
key: cred.key,
privateKey: cred.private_key,
publicKey: cred.public_key,
keyPassword: cred.key_password,
keyType: cred.key_type,
detectedKeyType: cred.detected_key_type,
usageCount: cred.usage_count || 0,
lastUsed: cred.last_used,
createdAt: cred.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await credentialRepository.createEncryptedForUser(
userId,
credData,
);
result.summary.sshCredentialsImported++;
} catch (credError) {
result.summary.errors.push(
`SSH credential import error: ${credError.message}`,
);
}
}
} catch {
apiLogger.info(
"ssh_credentials table not found in import file, skipping",
);
}
const pluginRows = await importUserPluginRows(importDb, userId);
result.summary.pluginItemsImported += pluginRows.imported;
result.summary.skippedItems += pluginRows.skipped;
result.summary.errors.push(...pluginRows.errors);
const hostDefaults = await importHostDefaults(importDb, userId);
result.summary.skippedItems += hostDefaults.skipped;
if (hostDefaults.imported > 0) {
await recompute({ userIds: [userId] });
}
const targetUser = await userRepository.findById(userId);
if (targetUser?.isAdmin) {
// The file's credential ids belong to the server that wrote it.
const credentialIds = new Map<number, number>();
try {
const importedCreds = importDb
.prepare("SELECT * FROM ssh_credentials")
.all();
for (const cred of importedCreds) {
try {
const importedSettings = readImportedSettings(importDb);
for (const setting of importedSettings) {
try {
await upsertImportedSetting(setting);
result.summary.settingsImported++;
} catch (settingError) {
result.summary.errors.push(
`Setting import error (${setting.key}): ${settingError.message}`,
);
}
const credentialRepository = createCurrentCredentialRepository();
const existingId =
await credentialRepository.findIdForImportIdentity(
userId,
cred.name,
cred.username,
);
if (existingId !== null) {
credentialIds.set(cred.id, existingId);
result.summary.skippedItems++;
continue;
}
} catch {
apiLogger.info(
"settings table not found in import file, skipping",
const credData = {
userId: userId,
name: cred.name,
description: cred.description,
folder: cred.folder,
tags: cred.tags,
authType: cred.auth_type,
username: cred.username,
password: cred.password,
key: cred.key,
privateKey: cred.private_key,
publicKey: cred.public_key,
keyPassword: cred.key_password,
keyType: cred.key_type,
detectedKeyType: cred.detected_key_type,
usageCount: cred.usage_count || 0,
lastUsed: cred.last_used,
createdAt: cred.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const created = await credentialRepository.createEncryptedForUser(
userId,
credData,
);
credentialIds.set(cred.id, Number(created.id));
result.summary.sshCredentialsImported++;
} catch (credError) {
result.summary.errors.push(
`SSH credential import error: ${credError.message}`,
);
}
} else {
apiLogger.info(
"Settings import skipped - only admin users can import settings",
);
}
} catch {
apiLogger.info(
"ssh_credentials table not found in import file, skipping",
);
}
result.success = true;
try {
await DatabaseSaveTrigger.forceSave("database_import");
} catch (saveError) {
apiLogger.error(
"Failed to persist imported data to disk",
saveError,
{
operation: "import_force_save_failed",
try {
const importedHosts = importDb
.prepare("SELECT * FROM ssh_data")
.all();
const importedHostIds: number[] = [];
for (const host of importedHosts) {
try {
const hostRepository = createCurrentHostRepository();
const exists = await hostRepository.existsForImportIdentity(
userId,
},
);
host.ip,
host.port,
host.username,
);
if (exists) {
result.summary.skippedItems++;
continue;
}
const hostData = {
userId: userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags: host.tags,
pin: Boolean(host.pin),
authType: host.auth_type,
forceKeyboardInteractive: host.force_keyboard_interactive,
password: host.password,
key: host.key,
keyPassword: host.key_password,
keyType: host.key_type,
sudoPassword: host.sudo_password,
credentialId: credentialIds.get(host.credential_id) ?? null,
overrideCredentialUsername: Boolean(
host.override_credential_username,
),
jumpHosts: host.jump_hosts,
...legacyStatusCheck(host),
// Exports from before 2.9.0 carry these in terminal_config.
sshOptions:
host.ssh_options ??
sshOptionsForWrite({
terminalConfig: host.terminal_config,
}) ??
null,
notes: host.notes,
useSocks5: Boolean(host.use_socks5),
socks5Host: host.socks5_host,
socks5Port: host.socks5_port,
socks5Username: host.socks5_username,
socks5Password: host.socks5_password,
socks5ProxyChain: host.socks5_proxy_chain,
createdAt: host.created_at || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
(hostData as Record<string, unknown>).defaultOverrides =
JSON.stringify(
await applyHostDefaultsToWrite({
ownerId: userId,
hostId: null,
columns: hostData as Record<string, unknown>,
body: hostData as Record<string, unknown>,
}),
);
const created = await hostRepository.createEncryptedForUser(
userId,
hostData,
);
await applyPluginHostImportSettings(
Number(created.id),
importedHostPluginSettings(importDb, host),
);
importedHostIds.push(Number(created.id));
await importHostProtocolLogins(
importDb,
host,
Number(created.id),
userId,
credentialIds,
);
result.summary.sshHostsImported++;
} catch (hostError) {
result.summary.errors.push(
`SSH host import error: ${hostError.message}`,
);
}
}
});
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");
}
const pluginRows = await importUserPluginRows(importDb, userId);
result.summary.pluginItemsImported += pluginRows.imported;
result.summary.skippedItems += pluginRows.skipped;
result.summary.errors.push(...pluginRows.errors);
const hostDefaults = await importHostDefaults(importDb, userId);
result.summary.skippedItems += hostDefaults.skipped;
if (hostDefaults.imported > 0) {
await recompute({ userIds: [userId] });
}
const targetUser = await userRepository.findById(userId);
if (targetUser?.isAdmin) {
try {
const importedSettings = readImportedSettings(importDb);
for (const setting of importedSettings) {
try {
await upsertImportedSetting(setting);
result.summary.settingsImported++;
} catch (settingError) {
result.summary.errors.push(
`Setting import error (${setting.key}): ${settingError.message}`,
);
}
}
} catch {
apiLogger.info("settings table not found in import file, skipping");
}
} else {
apiLogger.info(
"Settings import skipped - only admin users can import settings",
);
}
result.success = true;
try {
await DatabaseSaveTrigger.forceSave("database_import");
} catch (saveError) {
apiLogger.error(
"Failed to persist imported data to disk",
saveError,
{
operation: "import_force_save_failed",
userId,
},
);
}
} finally {
if (importDb) {
importDb.close();
}
}
try {
fs.unlinkSync(req.file.path);
} catch {
apiLogger.warn("Failed to clean up uploaded file", {
operation: "file_cleanup_warning",
filePath: req.file.path,
});
}
res.json({
success: result.success,
message: result.success
@@ -1472,17 +1466,6 @@ app.post(
});
}
} catch (error) {
if (req.file?.path && fs.existsSync(req.file.path)) {
try {
fs.unlinkSync(req.file.path);
} catch {
apiLogger.warn("Failed to clean up uploaded file after error", {
operation: "file_cleanup_error",
filePath: req.file.path,
});
}
}
apiLogger.error("SQLite import failed", error, {
operation: "sqlite_import_api_failed",
userId: (req as AuthenticatedRequest).userId,
@@ -1491,6 +1474,17 @@ app.post(
error: "Failed to import SQLite data",
details: getErrorMessage(error),
});
} finally {
if (req.file) {
try {
fs.rmSync(req.file.path, { force: true });
} catch {
apiLogger.warn("Failed to clean up uploaded file", {
operation: "file_cleanup_warning",
filePath: req.file.path,
});
}
}
}
}),
);
@@ -1726,7 +1720,8 @@ app.use(
},
);
const HTTP_PORT = 30001;
// The desktop app moves its embedded backend off 30001 when that is taken.
const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
async function initializeSecurity() {
try {
@@ -2012,14 +2007,15 @@ function legacyStatusCheck(row: Record<string, unknown>): {
/**
* A host's plugin protocol logins from an export. The file's credential ids
* belong to the server that wrote it, so a login keeps one only when it is
* a credential the importing user can use.
* belong to the server that wrote it, so a login keeps one only when it maps
* to a credential imported alongside it.
*/
async function importHostProtocolLogins(
importDb: Database.Database,
host: Record<string, unknown>,
hostId: number,
userId: string,
credentialIds: Map<number, number>,
): Promise<void> {
let rows: Array<Record<string, unknown>>;
try {
@@ -2040,7 +2036,7 @@ async function importHostProtocolLogins(
}
protocolAuth[String(row.protocol)] = {
authType: row.auth_type,
credentialId: row.credential_id,
credentialId: credentialIds.get(row.credential_id as number) ?? null,
username: row.username,
password: row.password,
fields,
+54 -1
View File
@@ -542,7 +542,6 @@ async function initializeCompleteDatabase(): Promise<void> {
plugin_id TEXT NOT NULL,
registry_id TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT 'aggregate-telemetry',
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (plugin_id, registry_id)
);
@@ -926,6 +925,38 @@ const dropRetiredColumns = () => {
}
};
// Registration checked for a taken username and then inserted with nothing
// enforcing it, so concurrent sign-ups could create two accounts with one
// name, and every lookup by username picked between them arbitrarily. Later
// duplicates are renamed rather than deleted, so no account is lost.
const enforceUniqueUsernames = () => {
try {
sqlite.exec(`
WITH ranked AS MATERIALIZED (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
)
)
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);
`);
} catch (error) {
databaseLogger.warn("Failed to enforce unique usernames", {
operation: "schema_migration",
error: getErrorMessage(error),
});
}
};
const migrateSchema = () => {
repairSnippetsColumns(sqlite);
addColumnIfNotExists("user_preferences", "theme", "TEXT");
@@ -997,6 +1028,7 @@ const migrateSchema = () => {
);
}
}
enforceUniqueUsernames();
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
addColumnIfNotExists("ssh_data", "name", "TEXT");
@@ -1894,6 +1926,23 @@ async function moveRemoteLegacySudoPasswords(remote: {
}
}
async function dropMysqlForeignKeys(
remote: { execute: (query: SQL) => Promise<unknown> },
table: string,
column: string,
): Promise<void> {
const keys = resultRows<{ name: string }>(
await remote.execute(
sql`SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${table} AND COLUMN_NAME = ${column} AND REFERENCED_TABLE_NAME IS NOT NULL`,
),
);
for (const key of keys) {
await remote.execute(
sql`ALTER TABLE ${sql.identifier(table)} DROP FOREIGN KEY ${sql.identifier(key.name)}`,
);
}
}
/**
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
* longer knows them. Dropped by hand, only where they still exist.
@@ -1929,6 +1978,10 @@ async function dropRemoteRetired(
for (const column of columns) {
if (!existing.has(`${table}.${column}`)) continue;
try {
// Postgres drops a column's foreign keys with it; MySQL refuses.
if (dialect === "mysql") {
await dropMysqlForeignKeys(remote, table, column);
}
await remote.execute(
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
);
+8 -2
View File
@@ -5,8 +5,11 @@
*
* Kept on purpose: columns a move only reads once the owner's data key opens
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
* domain), so a user who never signed in to 2.9 still gets them, and
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
* domain), so a user who never signed in to 2.9 still gets them.
*
* ssh_data.vault_profile_id goes because its foreign key follows the vault
* plugin's rename of vault_profiles: once the plugin is uninstalled, SQLite
* refuses every write to ssh_data and the other engines refuse the uninstall.
*/
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
@@ -52,6 +55,7 @@ export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
"telnet_port",
"tunnel_connections",
"use_warpgate",
"vault_profile_id",
"vnc_port",
"web_ui_config",
"wol_broadcast_address",
@@ -80,6 +84,8 @@ export const RETIRED_COLUMNS: Record<string, string[]> = {
"donation_modal_dismissed",
],
sessions: ["oidc_sub", "oidc_sid", "sso_provider_id"],
// 26.10.1 shows downloads only.
plugin_install_counts: ["source"],
user_preferences: [
"command_autocomplete",
"terminal_defaults",
+16 -12
View File
@@ -21,13 +21,19 @@ import {
} from "drizzle-orm/mysql-core";
import { sql } from "drizzle-orm";
export const users = mysqlTable("users", {
id: varchar("id", { length: 255 }).primaryKey(),
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
});
export const users = mysqlTable(
"users",
{
id: varchar("id", { length: 255 }).primaryKey(),
username: varchar("username", { length: 255 }).notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [uniqueIndex("idx_users_username").on(table.username)],
);
export const settings = mysqlTable("settings", {
key: varchar("key", { length: 255 }).primaryKey(),
@@ -1055,10 +1061,9 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
});
/**
* Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state.
* Download counts from each registry's stats.json, written by a background
* job rather than by installs, kept separate from `plugins` so that job can
* overwrite counts without touching install state.
*/
export const pluginInstallCounts = mysqlTable(
"plugin_install_counts",
@@ -1067,7 +1072,6 @@ export const pluginInstallCounts = mysqlTable(
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
registryId: varchar("registry_id", { length: 255 }).notNull(),
count: int("count").notNull().default(0),
source: text("source").notNull().default("aggregate-telemetry"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
+16 -12
View File
@@ -21,13 +21,19 @@ import {
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
export const users = pgTable("users", {
id: varchar("id", { length: 255 }).primaryKey(),
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const users = pgTable(
"users",
{
id: varchar("id", { length: 255 }).primaryKey(),
username: varchar("username", { length: 255 }).notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: boolean("is_admin").notNull().default(false),
registeredAt: text("registered_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [uniqueIndex("idx_users_username").on(table.username)],
);
export const settings = pgTable("settings", {
key: varchar("key", { length: 255 }).primaryKey(),
@@ -1055,10 +1061,9 @@ export const pluginRegistries = pgTable("plugin_registries", {
});
/**
* Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state.
* Download counts from each registry's stats.json, written by a background
* job rather than by installs, kept separate from `plugins` so that job can
* overwrite counts without touching install state.
*/
export const pluginInstallCounts = pgTable(
"plugin_install_counts",
@@ -1067,7 +1072,6 @@ export const pluginInstallCounts = pgTable(
pluginId: varchar("plugin_id", { length: 255 }).notNull(),
registryId: varchar("registry_id", { length: 255 }).notNull(),
count: integer("count").notNull().default(0),
source: text("source").notNull().default("aggregate-telemetry"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
+16 -12
View File
@@ -9,13 +9,19 @@ import {
} from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const users = sqliteTable(
"users",
{
id: text("id").primaryKey(),
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
registeredAt: text("registered_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [uniqueIndex("idx_users_username").on(table.username)],
);
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
@@ -1051,10 +1057,9 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
});
/**
* Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state.
* Download counts from each registry's stats.json, written by a background
* job rather than by installs, kept separate from `plugins` so that job can
* overwrite counts without touching install state.
*/
export const pluginInstallCounts = sqliteTable(
"plugin_install_counts",
@@ -1063,7 +1068,6 @@ export const pluginInstallCounts = sqliteTable(
pluginId: text("plugin_id").notNull(),
registryId: text("registry_id").notNull(),
count: integer("count").notNull().default(0),
source: text("source").notNull().default("aggregate-telemetry"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -103,11 +103,11 @@ export class CredentialRepository {
.orderBy(desc(sshCredentials.updatedAt));
}
async existsForImportIdentity(
async findIdForImportIdentity(
userId: string,
name: string,
username: string | null,
): Promise<boolean> {
): Promise<number | null> {
const rows = await this.context.drizzle
.select({ id: sshCredentials.id })
.from(sshCredentials)
@@ -120,7 +120,7 @@ export class CredentialRepository {
)
.limit(1);
return rows.length > 0;
return rows[0]?.id ?? null;
}
async findDecryptedByIdForUser(
@@ -7,10 +7,9 @@ export type PluginInstallCountRecord = typeof pluginInstallCounts.$inferSelect;
export interface PluginInstallCountInput {
pluginId: string;
count: number;
source: string;
}
/** Popularity numbers for the store, one row per plugin and registry. */
/** Download counts for the store, one row per plugin and registry. */
export class PluginInstallCountRepository {
constructor(
private readonly context: DatabaseContext,
@@ -38,13 +37,11 @@ export class PluginInstallCountRepository {
let written = 0;
for (const entry of entries) {
const row = existing.get(entry.pluginId);
if (row && row.count === entry.count && row.source === entry.source) {
continue;
}
if (row && row.count === entry.count) continue;
if (row) {
await this.context.drizzle
.update(pluginInstallCounts)
.set({ count: entry.count, source: entry.source, updatedAt: now })
.set({ count: entry.count, updatedAt: now })
.where(
and(
eq(pluginInstallCounts.pluginId, entry.pluginId),
@@ -56,7 +53,6 @@ export class PluginInstallCountRepository {
pluginId: entry.pluginId,
registryId,
count: entry.count,
source: entry.source,
updatedAt: now,
});
}
@@ -1,40 +0,0 @@
import { getCurrentRepositorySqlite } from "./factory.js";
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
export interface SqliteForeignKeyClient {
exec(sql: string): unknown;
}
export async function withSqliteForeignKeysDisabled<T>(
sqlite: SqliteForeignKeyClient,
operation: () => Promise<T>,
): Promise<T> {
sqlite.exec("PRAGMA foreign_keys = OFF");
try {
return await operation();
} finally {
sqlite.exec("PRAGMA foreign_keys = ON");
}
}
/**
* Runs a bulk import with foreign keys relaxed.
*
* Backup restore writes tables in an order that is not dependency-safe, so the
* constraints have to stand down for the duration.
*
* Postgres and MySQL keep their constraints enabled. The portable importer
* writes through repositories and handles individual row failures, so it must
* still be allowed to run there; only SQLite needs this connection-local
* relaxation for legacy backups whose rows are not dependency ordered.
*/
export async function withCurrentSqliteForeignKeysDisabled<T>(
operation: () => Promise<T>,
): Promise<T> {
const dialect = resolveDatabaseDialect();
if (!needsExplicitPersist(dialect)) {
return operation();
}
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
}
@@ -24,6 +24,27 @@ const userColumns = {
),
};
// The unique-violation codes of better-sqlite3, node-postgres and mysql2.
const UNIQUE_VIOLATION_CODES = new Set([
"SQLITE_CONSTRAINT_UNIQUE",
"23505",
"ER_DUP_ENTRY",
]);
/**
* Whether creating a user failed because the username is taken. The id is
* generated, so the username index is the only one a new row can collide on.
* drizzle wraps driver errors, so the code may sit on a cause.
*/
export function isUsernameTaken(error: unknown): boolean {
for (let e = error; e instanceof Error; e = e.cause) {
if (UNIQUE_VIOLATION_CODES.has((e as { code?: string }).code ?? "")) {
return true;
}
}
return false;
}
export class UserRepository {
constructor(
private readonly context: DatabaseContext,
+59 -39
View File
@@ -91,11 +91,11 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const spaceIdx = line.indexOf(" ");
if (spaceIdx === -1) continue;
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
if (!match) continue;
const key = line.slice(0, spaceIdx).toLowerCase();
const value = line.slice(spaceIdx + 1).trim();
const key = match[1].toLowerCase();
const value = match[2];
if (key === "host") {
if (current && current.hostname) results.push(current);
@@ -140,7 +140,14 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(userId: string, hostIds: number[]): void {
export function notifyImportedHosts(
userId: string,
hostIds: number[],
createdHostIds: number[],
): void {
for (const hostId of createdHostIds) {
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
}
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
@@ -568,26 +575,26 @@ export function registerHostBulkRoutes(
);
}
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
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;
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;
}
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -624,6 +631,15 @@ 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 &&
@@ -769,8 +785,6 @@ 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,
@@ -797,6 +811,7 @@ export function registerHostBulkRoutes(
);
if (exportId !== undefined) importedIds.set(exportId, saved.id);
savedHostId = saved.id;
createdHostIds.push(saved.id);
results.success++;
}
@@ -828,10 +843,10 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
@@ -931,23 +946,22 @@ export function registerHostBulkRoutes(
let existingHostMap: Map<string, { id: number }> | undefined;
const hostRepository = createCurrentHostRepository();
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;
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;
}
const writtenHostIds: number[] = [];
const createdHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
@@ -962,6 +976,13 @@ 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",
@@ -999,8 +1020,6 @@ 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,
@@ -1023,6 +1042,7 @@ export function registerHostBulkRoutes(
sshDataObj,
);
writtenHostIds.push(saved.id);
createdHostIds.push(saved.id);
results.success++;
}
} catch (error) {
@@ -1034,10 +1054,10 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
success: results.success,
updated: results.updated,
skipped: results.skipped,
@@ -88,6 +88,23 @@ export function keepsStoredSshAuth(authType: unknown): boolean {
return authType === undefined || authType === null;
}
export function isOptionalAuthType(value: unknown): boolean {
return value === undefined || value === null || typeof value === "string";
}
/**
* The auth type a new SSH host's secrets point to when the body names none,
* as an API client creating a host from a credential does.
*/
export function impliedSshAuthType(
body: Record<string, unknown>,
): string | undefined {
if (body.credentialId) return "credential";
if (body.key) return "key";
if (body.password) return "password";
return undefined;
}
const FOLDER_PATH_SEPARATOR = " / ";
/**
+18 -2
View File
@@ -31,8 +31,10 @@ import {
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
impliedSshAuthType,
keepsStoredSshAuth,
isNonEmptyString,
isOptionalAuthType,
isOptionalBoolean,
isValidPort,
normalizeProtocolEnableFields,
@@ -259,7 +261,9 @@ router.post(
!isNonEmptyString(ip) ||
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh)
!isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod)
) {
sshLogger.warn("Invalid SSH data input validation failed", {
operation: "host_create",
@@ -292,7 +296,9 @@ router.post(
const effectiveAuthType =
authType ||
authMethod ||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
(effectiveConnectionType !== "ssh"
? "password"
: impliedSshAuthType(hostData));
const effectiveUsername =
username || firstProtocolUsername(protocolAuthPatch);
const effectiveName =
@@ -403,6 +409,13 @@ router.post(
body: hostData,
}),
);
// Host defaults may have supplied it.
if (!sshDataObj.authType) {
return res.status(400).json({
error:
"authType is required (password, key, credential, agent or none)",
});
}
const result = await createCurrentHostRepository().createEncryptedForUser(
userId,
sshDataObj,
@@ -473,6 +486,7 @@ router.post(
});
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
} catch (err) {
sshLogger.error("Failed to save SSH host to database", err, {
@@ -827,6 +841,8 @@ router.put(
!isValidPort(port) ||
!isOptionalBoolean(shareSshAuth) ||
!isOptionalBoolean(enableSsh) ||
!isOptionalAuthType(authType) ||
!isOptionalAuthType(authMethod) ||
!hostId
) {
sshLogger.warn("Invalid SSH data input validation failed for update", {
+33 -7
View File
@@ -243,7 +243,9 @@ export async function listPreLoginPlugins(): Promise<
* frontends in the same order the server does.
*
* On a desktop linked to a server, `runsOnServer` marks plugins that
* only run on that server and stay off here on purpose.
* only run on that server and stay off here on purpose, and, for
* admins, `managedByServer` marks plugins whose on/off state the server
* decides.
* tags:
* - Plugins
* responses:
@@ -370,6 +372,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
features,
env,
signedBy: loaded?.signedBy ?? null,
managedByServer: linked && (await isManagedByLinkedServer(record.id)),
capabilities,
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
lastError: loaded?.lastError ?? record.lastError ?? null,
@@ -393,9 +396,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
});
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
const { getLink } = await import("../../sync/client/link-store.js");
if (!(await getLink())) return false;
if (!(await isLinkedDesktop())) return false;
const { isServerManaged } = await import("../../sync/client/plugins.js");
return isServerManaged(pluginId);
}
@@ -500,7 +501,7 @@ router.get(
* /plugins/onboarding/apply:
* post:
* summary: Apply the onboarding plugin picker
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would.
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
* tags:
* - Plugins
* requestBody:
@@ -887,7 +888,7 @@ router.delete(
* /plugins/{id}/state:
* patch:
* summary: Enable or disable a plugin
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would.
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
* tags:
* - Plugins
* parameters:
@@ -909,6 +910,12 @@ router.delete(
* properties:
* enabled:
* type: boolean
* version:
* type: string
* capabilities:
* type: array
* items:
* type: string
* responses:
* 200:
* description: The plugin's new state.
@@ -962,7 +969,26 @@ router.patch(
return;
}
const result = await setPluginState(pluginId, enabled);
let result;
if (enabled && (await isLinkedDesktop())) {
const version = readVersion(req.body);
const capabilities = readCapabilities(req.body);
if (version === null || capabilities === null) {
res
.status(400)
.json({ error: "Invalid plugin version or capabilities" });
return;
}
const { enablePluginWithConsent } =
await import("../../plugins/manage.js");
result = await enablePluginWithConsent(pluginId, {
version,
capabilities,
userId: (req as AuthenticatedRequest).userId as string,
});
} else {
result = await setPluginState(pluginId, enabled);
}
await auditPluginAction(
req,
enabled ? "enable_plugin" : "disable_plugin",
@@ -17,9 +17,10 @@ import {
listUserSecondFactors,
resetUserSecondFactors,
} from "../../auth/second-factor-admin.js";
import type {
UserRecord,
UserRepository,
import {
isUsernameTaken,
type UserRecord,
type UserRepository,
} from "../repositories/user-repository.js";
function isNonEmptyString(val: unknown): val is string {
@@ -541,6 +542,9 @@ export function registerUserAdminRoutes(
toast: { type: "success", message: `User created: ${username}` },
});
} catch (err) {
if (isUsernameTaken(err)) {
return res.status(409).json({ error: "Username already exists" });
}
authLogger.error("Failed to admin-create user", err);
res.status(500).json({ error: "Failed to create user" });
}
+10 -2
View File
@@ -41,9 +41,13 @@ import {
createCurrentUserAuthRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
import {
isUsernameTaken,
type UserRecord,
} from "../repositories/user-repository.js";
import {
getTrustedProxyAuthConfig,
getTrustedProxySourceAddress,
isTrustedProxyAddress,
isTrustedProxyAuthEnabled,
resolveTrustedProxyRoles,
@@ -264,6 +268,10 @@ router.post("/create", async (req, res) => {
toast: { type: "success", message: `User created: ${username}` },
});
} catch (err) {
// Lost a race with a concurrent sign-up for the same name.
if (isUsernameTaken(err)) {
return res.status(409).json({ error: "Username already exists" });
}
authLogger.error("Failed to create user", err);
res.status(500).json({ error: "Failed to create user" });
}
@@ -304,7 +312,7 @@ router.post("/proxy-login", async (req, res) => {
}
if (!config.enabled) return res.json({ enabled: false });
const sourceAddress = req.socket.remoteAddress;
const sourceAddress = getTrustedProxySourceAddress(req);
try {
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
authLogger.warn(
-22
View File
@@ -263,28 +263,6 @@ export class SSHHostKeyVerifier {
changeCount: host.hostKeyChangedCount || 0,
});
if (isJumpHost) {
await this.updateHostKey(
hostId,
fingerprint,
keyType,
algorithm,
host.hostKeyChangedCount || 0,
);
sshLogger.warn("Jump host key changed - auto-accepted", {
operation: "host_key_updated",
hostId,
ip,
port,
fingerprint,
keyType,
userId,
isJumpHost: true,
});
verify(true);
return;
}
if (!ws) {
sshLogger.error(
"Host key changed - please connect via Terminal to verify the new key",
+7 -5
View File
@@ -86,7 +86,6 @@ export class SSHConnectionPool {
private async createPooledClient(
key: string,
factory: () => Promise<Client>,
existing: PooledConnection[],
): Promise<Client> {
const generation = this.generations.get(key) || 0;
this.pendingConnections.set(
@@ -109,8 +108,11 @@ export class SSHConnectionPool {
inUse: true,
hostKey: key,
};
existing.push(pooled);
this.connections.set(key, existing);
// Re-read after the await: concurrent connects and removals may have
// replaced this key's array in the meantime.
const connections = this.connections.get(key) ?? [];
connections.push(pooled);
this.connections.set(key, connections);
client.on("end", () => {
this.removeConnection(key, client);
@@ -187,7 +189,7 @@ export class SSHConnectionPool {
if (queue.length === 0) this.waiters.delete(key);
else this.waiters.set(key, queue);
clearTimeout(waiter.timer);
this.createPooledClient(key, waiter.factory, connections)
this.createPooledClient(key, waiter.factory)
.then(waiter.resolve)
.catch(waiter.reject);
return;
@@ -225,7 +227,7 @@ export class SSHConnectionPool {
}
if (this.hasCapacity(key, connections)) {
return this.createPooledClient(key, factory, connections);
return this.createPooledClient(key, factory);
}
return this.enqueueWaiter(key, factory);
+41 -32
View File
@@ -1,50 +1,59 @@
import path from "node:path";
import zlib from "node:zlib";
import * as tar from "tar";
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
/**
* Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to
* disk. Returns null when the archive has no such file.
* Reads every copy of one file out of a .tmxplug (a gzipped tar) without
* unpacking it to disk. Uses the same tar parser as the loader's unpack, so
* pax and long-name headers resolve the same way, and only plain files count
* because they are the only kind the unpack writes. Names compare
* case-insensitively since a case-insensitive disk folds them together.
*/
export function readArtifactFile(
buffer: Buffer,
wanted: string,
): Buffer | null {
const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES });
let offset = 0;
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512);
if (header.every((byte) => byte === 0)) break;
const field = (start: number, length: number) =>
header
.subarray(start, start + length)
.toString("utf8")
.replace(/\0.*$/s, "");
const prefix = field(345, 155);
const name = (
prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)
).replace(/^\.\//, "");
const size = Number.parseInt(field(124, 12).trim() || "0", 8);
if (!Number.isFinite(size) || size < 0) break;
const start = offset + 512;
if (name === wanted) return tar.subarray(start, start + size);
offset = start + Math.ceil(size / 512) * 512;
}
return null;
export function readArtifactFiles(buffer: Buffer, wanted: string): Buffer[] {
const archive = zlib.gunzipSync(buffer, {
maxOutputLength: MAX_UNPACKED_BYTES,
});
const key = wanted.toLowerCase();
const found: Buffer[] = [];
const parser = new tar.Parser({
strict: true,
onReadEntry: (entry) => {
const name = path.posix.normalize(entry.path.replace(/\\/g, "/"));
if (entry.type !== "File" || name.toLowerCase() !== key) {
entry.resume();
return;
}
const chunks: Buffer[] = [];
entry.on("data", (chunk: Buffer) => chunks.push(chunk));
entry.on("end", () => found.push(Buffer.concat(chunks)));
},
});
// The parser runs synchronously on an in-memory buffer.
parser.end(archive);
return found;
}
/** The manifest.json packed in a .tmxplug, parsed but not validated. */
/**
* The manifest.json packed in a .tmxplug, parsed but not validated. An
* archive with more than one is refused: the unpack keeps the last copy, so
* whichever one this read the other could carry different capabilities.
*/
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
let raw: Buffer | null;
let copies: Buffer[];
try {
raw = readArtifactFile(buffer, "manifest.json");
copies = readArtifactFiles(buffer, "manifest.json");
} catch {
throw new Error("the file is not a valid .tmxplug archive");
}
if (!raw) throw new Error("the archive has no manifest.json");
if (copies.length === 0) throw new Error("the archive has no manifest.json");
if (copies.length > 1) {
throw new Error("the archive has more than one manifest.json");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString("utf8"));
parsed = JSON.parse(copies[0].toString("utf8"));
} catch {
throw new Error("manifest.json is not valid JSON");
}
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* The registry timer: refreshes the store's install counts and applies
* The registry timer: refreshes the store's download counts and applies
* plugin updates for plugins with auto-update on. An update that asks for a
* new capability is never applied here; admins get an alert and review it in
* the Plugins tab. Nothing is ever installed from here.
@@ -13,8 +13,8 @@ const CHECK_EVERY_MS = 6 * 60 * 60_000;
let timer: NodeJS.Timeout | null = null;
export async function runPluginAutoUpdate(): Promise<void> {
const { syncInstallCounts } = await import("./install-counts.js");
await syncInstallCounts();
const { syncDownloadCounts } = await import("./download-counts.js");
await syncDownloadCounts();
const { isLinkedDesktop, updateAllPlugins } = await import("./manage.js");
if (await isLinkedDesktop()) return;
+2
View File
@@ -30,6 +30,7 @@ import { hostStatusService } from "../hosts/status/host-status-service.js";
import { parseSshOptions } from "../hosts/ssh-options.js";
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js";
import { pluginEvents, TOPICS } from "./events.js";
import { pluginLogger } from "../utils/logger.js";
type AuditFn = (
@@ -502,6 +503,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes);
await audit("hosts_create", `host ${created.id}`, { success: true });
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
return (
await toRecords([created as unknown as Record<string, unknown>])
)[0];
+3
View File
@@ -25,6 +25,7 @@ import {
import { getActor, getActorSessionId } from "./actor.js";
import type { DisposableBag } from "./disposables.js";
import { redactHostSecrets } from "./ctx-hosts.js";
import { issueUserToken } from "./ctx-users.js";
import {
getSshAuthProvider,
listSshAuthProviders,
@@ -901,5 +902,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
await import("../database/repositories/factory.js");
return createCurrentUserAuthRepository().countUsersForProvider(provider);
},
issueUserToken: (userId, options) =>
issueUserToken(manifest, userId, options),
};
}
+94
View File
@@ -0,0 +1,94 @@
/**
* ctx.users, and ctx.auth.issueUserToken: what a plugin may learn about a
* user, and a short-lived session for calling core's own API as one.
*/
import type {
PluginUserToken,
PluginUserTokenOptions,
PluginUsers,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import { getActor } from "./actor.js";
const DEFAULT_TOKEN_TTL_SECONDS = 300;
const MAX_TOKEN_TTL_SECONDS = 900;
const MAX_PURPOSE_LENGTH = 200;
export function createPluginUsers(manifest: PluginManifest): PluginUsers {
return {
getLocale: async (userId) => {
await assertCapability(
manifest.id,
"users:read",
manifest.capabilities,
"users_get_locale",
);
const { createCurrentUserPreferenceRepository } =
await import("../database/repositories/factory.js");
const preferences =
await createCurrentUserPreferenceRepository().findByUserId(userId);
return preferences?.language ?? null;
},
};
}
export async function issueUserToken(
manifest: PluginManifest,
userId: string,
options: PluginUserTokenOptions,
): Promise<PluginUserToken> {
await assertCapability(
manifest.id,
"users:impersonate",
manifest.capabilities,
"user_token_issued",
);
const { logAudit } = await import("../utils/audit-logger.js");
const purpose = String(options?.purpose ?? "")
.trim()
.slice(0, MAX_PURPOSE_LENGTH);
const ttlSeconds = Math.min(
options?.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS,
MAX_TOKEN_TTL_SECONDS,
);
const audit = (success: boolean, errorMessage?: string) =>
logAudit({
userId: getActor() ?? null,
username: `plugin:${manifest.id}`,
action: "plugin_user_token_issued",
resourceType: "user",
resourceId: userId,
details: JSON.stringify({ pluginId: manifest.id, purpose, ttlSeconds }),
success,
errorMessage,
});
try {
if (!purpose) {
throw new Error("ctx.auth.issueUserToken needs a purpose");
}
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new Error("ttlSeconds must be a positive whole number");
}
const { createCurrentUserRepository } =
await import("../database/repositories/factory.js");
const user = userId
? await createCurrentUserRepository().findById(userId)
: null;
if (!user) throw new Error(`User "${userId}" does not exist`);
const { AuthManager } = await import("../utils/auth-manager.js");
const issued = await AuthManager.getInstance().issuePluginSession(user.id, {
pluginId: manifest.id,
purpose,
ttlSeconds,
});
await audit(true);
return issued;
} catch (error) {
await audit(false, error instanceof Error ? error.message : String(error));
throw error;
}
}
+2
View File
@@ -62,6 +62,7 @@ import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js";
import { createPluginUsers } from "./ctx-users.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -957,6 +958,7 @@ export function createPluginContext(
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
plugins: createPluginPlugins(manifest),
users: createPluginUsers(manifest),
desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
+103
View File
@@ -0,0 +1,103 @@
/**
* Download counts for the plugin store, copied from the registry's
* stats.json into plugin_install_counts.
*
* A count is how often the plugin's .tmxplug release files were downloaded.
* An instance that updates three times counts three times, and copies bundled
* into Docker images or the desktop app never count. Treat it as a ranking,
* never as a user count.
*/
import { pluginLogger } from "../utils/logger.js";
import {
OFFICIAL_REGISTRY_ID,
fetchRegistryStats,
officialSource,
type RegistrySource,
} from "./registry-index.js";
async function repository() {
const { createCurrentPluginInstallCountRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginInstallCountRepository();
}
const STALE_MS = 6 * 60 * 60_000;
const FORCED_MS = 60_000;
const WAIT_MS = 3_000;
let lastSyncAt = 0;
let running: Promise<number> | null = null;
/**
* Syncs when the counts are older than the registry timer's interval, or
* older than a minute with force (the Refresh button). Waits a few seconds
* at most so a slow registry never holds up the store; a sync that runs
* longer still finishes in the background.
*/
export async function refreshDownloadCounts(force = false): Promise<void> {
const age = Date.now() - lastSyncAt;
if (!running && age >= (force ? FORCED_MS : STALE_MS)) {
running = syncDownloadCounts().finally(() => {
running = null;
});
}
if (!running) return;
let timeout: NodeJS.Timeout | undefined;
await Promise.race([
running,
new Promise((resolve) => {
timeout = setTimeout(resolve, WAIT_MS);
}),
]);
clearTimeout(timeout);
}
/** Test seam. */
export function resetDownloadCountRefresh(): void {
lastSyncAt = 0;
running = null;
}
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
export async function syncDownloadCounts(): Promise<number> {
lastSyncAt = Date.now();
let changed = 0;
let sources: RegistrySource[] = [officialSource()];
try {
const { listSources } = await import("./registries.js");
sources = await listSources();
} catch {
// Official only when the registry choices cannot be read.
}
for (const source of sources) {
try {
const stats = await fetchRegistryStats(source);
if (!stats) continue;
const entries = [...stats.plugins].map(([pluginId, downloads]) => ({
pluginId,
count: downloads,
}));
changed += await (await repository()).upsertMany(source.id, entries);
} catch (error) {
pluginLogger.warn(
`Could not sync plugin download counts from ${source.id}: ${
error instanceof Error ? error.message : String(error)
}`,
{ operation: "plugin_registry" },
);
}
}
return changed;
}
export async function readDownloadCounts(
registryId: string = OFFICIAL_REGISTRY_ID,
): Promise<Map<string, number>> {
try {
const rows = await (await repository()).listByRegistry(registryId);
return new Map(rows.map((row) => [row.pluginId, row.count]));
} catch {
return new Map();
}
}
+2
View File
@@ -26,6 +26,8 @@ export const TOPICS = {
hostStatus: "host.status",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
/** A host was added and anything inventorying hosts should pick it up. */
hostCreated: "host.created",
/** A host's connection details changed and pollers should re-read them. */
hostUpdated: "host.updated",
/** A host was deleted and any poller holding it should drop it. */
+14 -4
View File
@@ -42,9 +42,16 @@ const routers = new Map<string, Router>();
* Requests per minute one address may make to one plugin's public routes.
* They run without a login, so they are the cheap way to hammer a plugin.
*/
function publicRateLimit(): number {
const MAX_PLUGIN_PUBLIC_RATE_LIMIT = 10_000;
/** The admin's limit when set, else the plugin's own, else 120. */
function publicRateLimit(pluginLimit?: number): number {
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
if (Number.isFinite(parsed) && parsed > 0) return parsed;
if (Number.isFinite(pluginLimit) && (pluginLimit as number) > 0) {
return Math.min(pluginLimit as number, MAX_PLUGIN_PUBLIC_RATE_LIMIT);
}
return 120;
}
const PUBLIC_RATE_WINDOW_MS = 60_000;
const publicHits = new Map<string, { count: number; resetAt: number }>();
@@ -54,6 +61,7 @@ export function overPublicRateLimit(
pluginId: string,
ip: string,
now = Date.now(),
pluginLimit?: number,
): boolean {
if (publicHits.size > 10_000) {
for (const [hitKey, entry] of publicHits) {
@@ -67,7 +75,7 @@ export function overPublicRateLimit(
return false;
}
entry.count++;
return entry.count > publicRateLimit();
return entry.count > publicRateLimit(pluginLimit);
}
/** Test seam. */
@@ -319,7 +327,9 @@ export function createPluginRouter({
outer.use((req: Request, res: Response, next: NextFunction) => {
if (isPublicRequest(req, publicPaths, inner)) {
const ip = req.ip || req.socket?.remoteAddress || "unknown";
if (overPublicRateLimit(pluginId, ip)) {
if (
overPublicRateLimit(pluginId, ip, Date.now(), options?.publicRateLimit)
) {
res.status(429).json({ error: "Too many requests" });
return;
}
-94
View File
@@ -1,94 +0,0 @@
/**
* Popularity numbers for the plugin store, copied from the registry's
* stats.json into plugin_install_counts.
*
* Two sources, and the store prefers the first:
* - "aggregate-telemetry": instances that reported the plugin enabled in the
* last 7 days through the Usage Statistics plugin. It undercounts by every
* instance that turned the report off or never installed that plugin.
* - "github-releases": downloads of the plugin's .tmxplug release assets.
* This counts downloads, not active installs: an instance that updates
* three times counts three times, and Docker images that bundle the plugin
* count once per build, not once per install.
*
* Treat both as a rough ranking, never as a user count.
*/
import { pluginLogger } from "../utils/logger.js";
import {
OFFICIAL_REGISTRY_ID,
fetchRegistryStats,
officialSource,
type RegistrySource,
} from "./registry-index.js";
export const TELEMETRY_SOURCE = "aggregate-telemetry";
export const DOWNLOADS_SOURCE = "github-releases";
export interface InstallCount {
count: number;
source: string;
}
async function repository() {
const { createCurrentPluginInstallCountRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginInstallCountRepository();
}
/** Picks the number to show: active installs when known, downloads otherwise. */
export function preferredCount(entry: {
downloads: number;
activeInstalls: number | null;
}): InstallCount {
return entry.activeInstalls !== null && entry.activeInstalls > 0
? { count: entry.activeInstalls, source: TELEMETRY_SOURCE }
: { count: entry.downloads, source: DOWNLOADS_SOURCE };
}
/** Returns how many rows changed. Never throws; a failed sync keeps the old numbers. */
export async function syncInstallCounts(): Promise<number> {
let changed = 0;
let sources: RegistrySource[] = [officialSource()];
try {
const { listSources } = await import("./registries.js");
sources = await listSources();
} catch {
// Official only when the registry choices cannot be read.
}
for (const source of sources) {
try {
const stats = await fetchRegistryStats(source);
if (!stats) continue;
const entries = [...stats.plugins].map(([pluginId, entry]) => ({
pluginId,
...preferredCount(entry),
}));
changed += await (await repository()).upsertMany(source.id, entries);
} catch (error) {
pluginLogger.warn(
`Could not sync plugin install counts from ${source.id}: ${
error instanceof Error ? error.message : String(error)
}`,
{ operation: "plugin_registry" },
);
}
}
return changed;
}
export async function readInstallCounts(
registryId: string = OFFICIAL_REGISTRY_ID,
): Promise<Map<string, InstallCount>> {
try {
const rows = await (await repository()).listByRegistry(registryId);
return new Map(
rows.map((row) => [
row.pluginId,
{ count: row.count, source: row.source },
]),
);
} catch {
return new Map();
}
}
Loaded 100 of 260 files, more files were not shown because too many files have changed in this diff. Show more