Compare commits

..
16 Commits
Author SHA1 Message Date
f90621a54e v26.10.6 (#1739)
* feat: let the login page send the token back to the mobile app

* feat: add host-scripts entry to the plugin sdk

* feat: let plugins say how they show in the mobile app

* feat: frame host for running one plugin view outside the shell

* fix: build the SDK host modules in production mode

* chore: note the SDK host build fix in the changelog

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

* fix: deleting your own account removes all of its data

* fix(deps): update body-parser to 2.4.0 (#1736)

* fix: body-parser 보안 패치 반영

* chore: 자동 검사를 막던 기존 JSON 서식 정리

---------

Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>

* fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

* fix: honor shared credentials on shared hosts (#1730)

* fix: honor shared credentials on shared hosts and in personal SSH auth

* fix: snapshot shared SSH auth from a credential shared with the host owner

* fix: size DataView list columns by the table's own width (#1713)

* fix: size DataView list columns by the table's own width

The list grid was built from every column, so columns hidden below md/lg
left empty tracks and the visible cells landed under the wrong headers.
Each width now gets a template of only its visible columns (CSS variables
+ container queries on the table, so a narrow panel counts, not the window),
the header uses the same gap as the rows, and rows use a minimum height so
wrapped cells no longer overlap.

* test: wait for the title bar offset effect before asserting

The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height
is set in a later passive effect, so reading it right after findByText raced
(3-5 of 40 runs under load, on main too).

* chore: rerun CI against dev-26.10.6

* fix: unlock passphrase keys once per connect burst (#1737)

* feat: give ssh transports the host origin and sync id (#1717)

* fix: wake other clients after a sync push (Plugin-Sync#1)

* feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4)

* feat: rail items can show a status dot (Plugin-Sync#5)

* fix: add the desktop shared copy banner text (#1709)

* feat: sidebar preference for alternating rows (#1712)

* fix: striped rows text

* feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36)

* fix: show the logo in the macos menu bar (#1728)

* fix: number shortcuts skip the home tab (#1724)

* fix: show plugins that could not be downloaded as failed (#1714)

* fix: pass the host id as text when opening its editor

* fix: retry a plugin bundle that was not ready yet (#1708)

* fix: let commonjs plugins fall back to core packages on the desktop

* fix: let non docker installs switch nginx to https (#1711)

* feat: remove a folder share from its hosts too (#1719)

* chore: changelog for merged pull requests

* fix: accept the release .sig when installing a plugin from a file

* feat: folder logins for shared hosts, read only plugin sync, full desktop builds

* fix: test

* fix: stop dev runner restart loops and narrow plugin watchers

* chore: update translations and plugin pins

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

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
2026-10-29 17:59:55 -05:00
LukeGus 73c15f7c58 chore: bump Homebrew cask to 26.10.5 2026-10-22 19:26:51 +00:00
Luke GustafsonandLukas 96b8520fb4 v26.10.5 (#1710)
* feat: add an auto or fixed columns picker to card grids

* feat: add safe mode that only runs layout and theme plugins

* docs: add simple layout to plugin table

* fix: ask for local network access in the macOS app

* feat: allow plugin registries on listed private hosts (#1705)

* chore: add private registry hosts to the changelog

* feat: use the proxy for plugin registry downloads

* fix: allow changing connection origin on shared hosts

* chore: update translations and plugin pins

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

---------

Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
2026-10-22 13:30:48 -05:00
LukeGus 7b7b17e31f chore: add six new plugins to the readme table 2026-10-21 16:25:59 -05:00
LukeGus 53c4e5fe9c fix: let the repair screen turn the layout plugin back on 2026-10-21 11:55:50 -05:00
LukeGus 28cb1bc554 feat: make all buttons brand orange and lint against white ones 2026-10-21 10:44:53 -05:00
LukeGus b691d4f7cb fix: retry dmg detach in macOS sharp check 2026-10-21 09:54:43 -05:00
Luke Gustafson a65b4a5d48 v26.10.4 (#1701)
* Update package.json

* chore: lint, format, and bump version to 26.10.4
2026-10-21 08:40:42 -05:00
Luke Gustafson abaf89b732 Update package version from 26.10.3 to 26.10.4 2026-10-21 08:31:57 -05:00
Luke Gustafson 72d868afc7 Update CHANGELOG.md 2026-10-21 08:30:07 -05:00
LukeGus d35304e107 chore: bump Homebrew cask to 26.10.3 2026-10-21 01:06:39 +00:00
LukeGus f4d87ddd8d fix: pin classic layout 1.0.1 2026-10-20 18:58:00 -05:00
LukeGus 4d7385f51d fix: pin the classic layout title bar fix 2026-10-20 18:52:58 -05:00
LukeGus d3fc32928b chore: bump Homebrew cask to 26.10.3 2026-10-20 22:42:52 +00:00
LukeGus 1533c6837a fix: start npm through a shell in the windows sharp install 2026-10-20 16:56:04 -05:00
a89ccc6b07 v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
2026-10-20 16:03:29 -05:00
296 changed files with 48692 additions and 12114 deletions

No files matched your search

+15 -3
View File
@@ -41,9 +41,15 @@ on:
default: ""
jobs:
# One image per plugin profile (docker/bundled-plugins.json). standard keeps
# the plain tags; minimal and full add a suffix.
build:
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
profile: [standard, minimal, full]
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -80,12 +86,17 @@ jobs:
TAGS=()
ALL_TAGS=()
SUFFIX=""
if [ "${{ matrix.profile }}" != "standard" ]; then
SUFFIX="-${{ matrix.profile }}"
fi
if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("$VERSION" "latest")
TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX")
elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta" "$VERSION")
TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX")
else
TAGS+=("dev-$VERSION")
TAGS+=("dev-$VERSION$SUFFIX")
fi
for tag in "${TAGS[@]}"; do
@@ -124,6 +135,7 @@ jobs:
tags: ${{ env.ALL_TAGS }}
build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
PLUGIN_PROFILE=${{ matrix.profile }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
+72 -1
View File
@@ -115,7 +115,9 @@ jobs:
Write-Host "Spectre libs present: $($found.FullName)"
- name: Install dependencies
run: npm ci
run: |
npm ci
node scripts/install-windows-sharp.cjs
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
@@ -248,6 +250,27 @@ jobs:
path: termix_windows_arm64_portable.zip
retention-days: 30
# The same app with every official plugin, for installs without
# internet. Built last so nothing above packs the full set, and with
# no update files: a full install updates through the normal build and
# downloads the plugins it had on.
- name: Build Windows x64 full installer
if: inputs.artifact_destination != 'none'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TERMIX_PLUGIN_PROFILE: full
run: |
npm run build:plugins
npx electron-builder --win nsis --x64 --publish never '-c.nsis.artifactName=termix_windows_x64_full_nsis.${ext}'
- name: Upload Windows x64 full installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_full_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_x64_full_nsis
path: release/termix_windows_x64_full_nsis.exe
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'
@@ -486,6 +509,28 @@ jobs:
path: release/com.karmaa.termix.flatpakref
retention-days: 30
# Every official plugin, for installs without internet. No update files.
- name: Build Linux x64 full packages
if: inputs.artifact_destination != 'none'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TERMIX_PLUGIN_PROFILE: full
run: |
npm run build:plugins
npx electron-builder --linux AppImage deb --x64 --publish never \
'-c.appImage.artifactName=termix_linux_x64_full_appimage.${ext}' \
'-c.deb.artifactName=termix_linux_x64_full_deb.${ext}'
- name: Upload Linux x64 full packages
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_linux_x64_full_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
with:
name: termix_linux_x64_full
path: |
release/termix_linux_x64_full_appimage.AppImage
release/termix_linux_x64_full_deb.deb
retention-days: 30
build-macos:
runs-on: blacksmith-6vcpu-macos-latest
if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
@@ -761,6 +806,32 @@ jobs:
gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber
# Every official plugin, for installs without internet. Signed like the
# normal DMG, with no update files.
- name: Build macOS universal full DMG
if: inputs.artifact_destination != 'none'
env:
ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: --max-old-space-size=4096
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TERMIX_PLUGIN_PROFILE: full
run: |
npm run build:plugins
npx electron-builder --mac dmg --universal --publish never \
'-c.dmg.artifactName=termix_macos_${arch}_full_dmg.${ext}'
- name: Upload macOS universal full DMG
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_macos_universal_full_dmg.dmg') != '' && inputs.artifact_destination != 'none'
with:
name: termix_macos_universal_full_dmg
path: release/termix_macos_universal_full_dmg.dmg
retention-days: 30
- name: Clean up keychains
if: always()
run: |
+15 -7
View File
@@ -161,9 +161,10 @@ jobs:
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
# Translates new or changed strings with the Termix LLM server and commits
# them to the dev branch, so the beta or stable build includes them. If the
# server is down it warns and the release goes on with what is there.
# Translates new or changed strings with the Termix LLM server and pins the
# bundled plugins to their newest registry releases, then commits both to the
# dev branch so the beta or stable build includes them. If the server is down
# it warns and the release goes on with what is there.
translate:
needs: [prep]
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
@@ -191,19 +192,26 @@ jobs:
languages: src/ui/locales/languages.json
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
- name: Commit translations
- name: Install dependencies
run: npm ci --ignore-scripts
# Newest stable plugin release whose engine.termix allows this version.
- name: Pin bundled plugins
run: node scripts/pin-bundled-plugins.cjs --all --core-version "${{ needs.prep.outputs.version }}"
- name: Commit translations and plugin pins
id: commit
run: |
git add src/ui/locales
git add src/ui/locales docker/bundled-plugins.json
if git diff --cached --quiet; then
echo "No translation changes."
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would commit these translation changes:"
echo "DRY RUN: would commit these translation and pin changes:"
git --no-pager diff --cached --stat
else
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git commit -m "chore: update translations"
git commit -m "chore: update translations and plugin pins"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+77
View File
@@ -1,5 +1,63 @@
# Changelog
## 26.10.6
### Added
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
- Plugins can say how they show in the mobile app
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
- A sidebar setting to turn off alternating row colors
- TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF, TERMIX_NGINX_PID and TERMIX_APP_DIR let an install outside Docker, like the Proxmox LXC script, switch nginx to HTTPS when a certificate is added
- Removing a user or role from a shared folder can also remove their access from every host in the folder and its subfolders
- A personal login set once on a folder is used for every host shared with you in it and its subfolders, unless a host has its own
### Changed
- body-parser is updated to 2.4.0 for a security fix
### Fixed
- The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES
- Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind
- An SSH key with a passphrase is unlocked once and kept unlocked in memory for a short time, so connecting to many hosts with it no longer freezes the server for minutes
- A change pushed from one linked desktop or phone now reaches your other linked devices right away, instead of on their next check a few minutes later
- The macOS menu bar icon shows the Termix logo instead of an empty square
- Cmd+1 (Alt+1 on Windows and Linux) and the other number shortcuts pick the open tabs as numbered in the tab bar, not the home tab
- A plugin the app no longer ships and could not download (for example on a locked down network) shows as failed with the reason on the Plugins tab, instead of as installed
- The desktop app no longer shows This layout could not be shown when the layout is asked for before its plugin has finished starting; it tries again
- On the desktop, a plugin installed from the registry that uses core's packages through require (like ACME SSL and axios) starts instead of failing with Cannot find module
- Adding a certificate on an install where Termix cannot manage nginx says what to set instead of only asking for a restart
- A login someone shared with you works on a host shared with you, for personal SSH authentication and when the owner shares SSH authentication
- Lists in plugins (like the AI Assets table) use their full width on narrow screens, keep cells under the right header and no longer overlap rows
- Installing an official plugin from a file works offline: pick the .tmxplug and its .sig from the release together and it installs as official, so plugins like Fleets no longer stop with migration refused. It can also fill in a plugin that was on but could not be downloaded
## 26.10.5
### Added
- Safe mode: start the server with TERMIX_SAFE_MODE=1 to run only layout and theme plugins, so a broken plugin can be turned off
- TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS lets plugin registries live on a private address, like a registry in your LAN
- Plugin registry browsing and downloads use HTTP_PROXY and HTTPS_PROXY when they are set
- Docker image sizes: the default image now ships the most used plugins, the minimal tag ships only the look plugins and the full tag ships every official plugin
- Plugins an older image shipped and you had on are downloaded from the registry on the first start of a smaller image, so nothing goes missing
### Changed
- Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row
### Fixed
- The macOS app asks for Local Network access, so it can reach servers on your LAN
- The desktop app can change the connection origin of a host shared with you, instead of failing to save
## 26.10.4
### Fixed
- Pin classic layout plugin (fixes proxmox install)
## 26.10.3
### Added
@@ -19,9 +77,22 @@
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
- A user who must have a second factor but has none can sign in only to set one up
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
- A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app
- The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch
- The linked server list holds one link per account, with room for more connection options per server
- A plugin can carry SSH connections over its own stream, and core relays a host's connection over a socket for another Termix
- Plugins can create a folder with a color and icon and remove it when it is empty
- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user
- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine
- Pasting a security key file now explains how to use it instead of failing to decode it
- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it
- The desktop app can link to a server that requires a client certificate (mutual TLS). It uses a PFX or P12 file for sync and the certificate in your system store for the app window
- Plugins can show a web page in a native view in the desktop app, for sites that do not load in an iframe
- The desktop app draws its own title bar in the colors of your theme instead of the native white one
### Changed
- Server and desktop sync moved out of the core app into a plugin, including the Sync screen, the link wizard and the first run step. Existing links and offline copies keep working
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
@@ -37,6 +108,12 @@
- Importing hosts from a JSON or SSH config file and exporting hosts now come from the Import and Export plugin. Exports are now written to the audit log. Without the plugin there is no import or export
- Generating and checking SSH keys in the credential editor and deploying a key to a host now come from the Credential Tools plugin. Deploys are now written to the audit log. Without the plugin those buttons are hidden
### Fixed
- Linking the desktop app to a server no longer stalls on the server dashboard after sign in
- Exporting your data no longer fails when a plugin table has a text key, like saved passkeys
- The Windows on ARM desktop app starts again instead of failing with a local server error
### Removed
- The desktop app no longer has its own client tunnel and native Remote Desktop code
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "26.10.2"
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223"
version "26.10.5"
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
+8
View File
@@ -66,7 +66,9 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
| [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. |
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
@@ -76,6 +78,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
| [Kubernetes](https://github.com/Termix-SSH/Plugin-Kubernetes) | Manage Kubernetes clusters from your hosts over SSH, with logs, metrics, YAML and a shell. |
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
@@ -83,14 +86,19 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
| [Reverse Proxy](https://github.com/Termix-SSH/Plugin-Reverse-Proxy) | Reverse proxy for TCP, UDP, HTTP and HTTPS with free certificates and Termix login, managed per host. |
| [Samba Shares](https://github.com/Termix-SSH/Plugin-Samba) | Create and manage Samba (SMB) file shares, users and permissions on your hosts from one tab. |
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
| [Services](https://github.com/Termix-SSH/Plugin-Service-Manager) | Manage services, timers and logs on your hosts over SSH. |
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
+15 -4
View File
@@ -34,9 +34,20 @@ WORKDIR /app
COPY . .
RUN npm run build:backend:core
# Stage 3b: Stage the bundled plugins for one profile (minimal, standard or
# full, see docker/bundled-plugins.json). Only this stage and the final one
# differ between profiles, so the other stages are built once.
FROM deps AS plugins
WORKDIR /app
ARG PLUGIN_PROFILE=standard
COPY scripts/build-plugins.cjs ./scripts/build-plugins.cjs
COPY scripts/lib/bundled-plugins.cjs ./scripts/lib/bundled-plugins.cjs
COPY docker/bundled-plugins.json ./docker/bundled-plugins.json
# 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
node scripts/build-plugins.cjs --profile "$PLUGIN_PROFILE"
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:24-slim AS opkssh-downloader
@@ -117,9 +128,9 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
# Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by
# getBundledPluginsDir().
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
# Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved
# as dist/plugins by getBundledPluginsDir().
COPY --chown=node:node --from=plugins /app/dist/plugins ./dist/plugins
# Target of node_modules/@termix-ssh/plugin-sdk, which core and the plugins import.
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
+192 -58
View File
@@ -3,50 +3,85 @@
{
"id": "acme-ssl",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug",
"sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a",
"tier": "full"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug",
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6",
"tier": "full"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.2.0/alerts-1.2.0.tmxplug",
"sha256": "488947482eed0306893c2983f701a0e14c55975ca2a0c4bb0799a40fce9db054",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "audit-tools",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug",
"sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b",
"tier": "full"
},
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug",
"sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea",
"tier": "full"
},
{
"id": "classic-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.1.0/classic-layout-1.1.0.tmxplug",
"sha256": "06a94fdbc2eda2f7cdeb4a6bbdc6adb289d524abfe836d3a32ac9b38ad5f65b9",
"tier": "minimal",
"defaultLook": true
},
{
"id": "classic-themes",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
"sha256": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
"tier": "minimal",
"defaultLook": true
},
{
"id": "cloud-providers",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Cloud-Providers/releases/download/v1.1.0/cloud-providers-1.1.0.tmxplug",
"sha256": "7f61eafc3b99bdf1328fffa95791db1d27f8e1b683e5a5925603b5840057a9c7",
"tier": "full"
},
{
"id": "credential-tools",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce",
"tier": "standard"
},
{
"id": "databases",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.1/databases-1.1.1.tmxplug",
"sha256": "316e26c682f5b07b0809e87f8ea94e2b819a9c46fc0e22611b24069c804312d0",
"tier": "full"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.2/docker-1.2.2.tmxplug",
"sha256": "2e6ff0a68a70d83267c250547e6a4e4f6282d557b608500309136aca76881abf",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
@@ -55,8 +90,9 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.2/file-manager-1.1.2.tmxplug",
"sha256": "6769e58f23722c06b6e4c187c7ef5ba03f28bbdc19e859104b7a5ead8690c154",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
@@ -65,92 +101,168 @@
{
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.1.0/fleets-1.1.0.tmxplug",
"sha256": "5e0218c6145524ae8776b8fcbae043749c893d9b04fb67a9b3fe262ff76c0573",
"tier": "full"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug",
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00",
"tier": "full"
},
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.3.0/host-metrics-1.3.0.tmxplug",
"sha256": "b38352e4adebf4079918cd7e9e9badfb0a4a9811cc53940852ca71aef860dc26",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "host-status",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
"tier": "standard"
},
{
"id": "hosts-config",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug",
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad",
"tier": "full"
},
{
"id": "import-export",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
"tier": "standard"
},
{
"id": "kubernetes",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Kubernetes/releases/download/v1.0.1/kubernetes-1.0.1.tmxplug",
"sha256": "31d6b200579d326d07244712713ae291ff473270d44b478a884d07c863a394f0",
"tier": "full"
},
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03",
"tier": "full"
},
{
"id": "login-security",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug",
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa",
"tier": "full"
},
{
"id": "network-topology",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56",
"tier": "full"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219",
"tier": "full"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.2.0/proxmox-1.2.0.tmxplug",
"sha256": "154c9bc1535bb1d05ccad3ab2cb6d848a28ddbfeb6dc589c5683cd7e81ccd3c3",
"tier": "full"
},
{
"id": "remote-desktop",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.3.0/remote-desktop-1.3.0.tmxplug",
"sha256": "69177d2a642d549fbd4769833437fdb96065251a4965990b881d1dd583b2832a",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "reverse-proxy",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug",
"sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7",
"tier": "full"
},
{
"id": "samba",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Samba/releases/download/v1.0.0/samba-1.0.0.tmxplug",
"sha256": "6e131ccdca998a9d0802becdd439a88305b64fcd7f07335bec9c7ed6791c0f53",
"tier": "full"
},
{
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.1.0/secret-sources-1.1.0.tmxplug",
"sha256": "3ba67666a52a38245284bb0294fd7af52a5613921454e66d3b4972727d7ed5c0",
"tier": "full"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"tier": "standard",
"onboarding": {
"recommended": "desktop",
"consent": false
}
},
{
"id": "service-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.1/service-manager-1.1.1.tmxplug",
"sha256": "5731a04336d341a330416cba7b7999f0d8e6215752eabbf978ed58c4543c5036",
"tier": "full"
},
{
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug",
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a",
"tier": "full"
},
{
"id": "session-sharing",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.1.0/session-sharing-1.1.0.tmxplug",
"sha256": "2988b39221d93b3dbc5e2a81e104e5dc01ce264afb1e66df3407492658ab9fdc",
"tier": "full"
},
{
"id": "simple-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Simple-Layout/releases/download/v1.0.1/simple-layout-1.0.1.tmxplug",
"sha256": "838f08050335b44b62295b87006b50d1db1029699bfe6a35fa75808d4aea2cde",
"tier": "full"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.2.0/snippets-1.2.0.tmxplug",
"sha256": "a72c0e1f38e49cf86ed8993b6913a6fbbdad7814ae132d5186c097ee3ea09bc3",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
@@ -159,8 +271,9 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.2.0/ssh-terminal-1.2.0.tmxplug",
"sha256": "629b522ae6ff77a8e033b89bb2efa675ec60c147c378012548feec1a005f9533",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
@@ -169,8 +282,9 @@
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug",
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9",
"tier": "standard",
"onboarding": {
"recommended": false,
"consent": false,
@@ -181,19 +295,29 @@
"id": "step-ca",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a",
"tier": "full"
},
{
"id": "sync",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
"tier": "standard"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug",
"sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf",
"tier": "full"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": true
@@ -203,19 +327,22 @@
"id": "termix-identity",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927",
"tier": "full"
},
{
"id": "tmux-monitor",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug",
"sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006",
"tier": "full"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
@@ -224,8 +351,9 @@
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.2/tunnels-1.2.2.tmxplug",
"sha256": "92d89c5bf7fa43fff790cc5240ae75eaf05c3c9089c6a0dfb68bbc37c517bf04",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
@@ -235,31 +363,36 @@
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28",
"tier": "full"
},
{
"id": "wake-on-lan",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug",
"sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8",
"tier": "full"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47",
"tier": "full"
},
{
"id": "web-endpoint",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug",
"sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c",
"tier": "full"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.2.0/webauthn-1.2.0.tmxplug",
"sha256": "4e7e06d05a17b6e474fdb0d45c8fc320f2ea89375bda440f824636cc393d4615",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
@@ -268,8 +401,9 @@
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug",
"sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
@@ -0,0 +1,14 @@
CREATE TABLE `shared_folder_auth_overrides` (
`id` int AUTO_INCREMENT NOT NULL,
`user_id` varchar(255) NOT NULL,
`folder` varchar(255) NOT NULL,
`protocol` varchar(255) NOT NULL DEFAULT 'ssh',
`credential_id` int NOT NULL,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
CONSTRAINT `shared_folder_auth_overrides_id` PRIMARY KEY(`id`),
CONSTRAINT `shared_folder_auth_overrides_user_folder_protocol_unique` UNIQUE(`user_id`,`folder`,`protocol`)
);
--> statement-breakpoint
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `shared_folder_auth_overrides` ADD CONSTRAINT `shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk` FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -533,6 +533,20 @@
"when": 1792430772041,
"tag": "0075_session_policy",
"breakpoints": true
},
{
"idx": 76,
"version": "5",
"when": 1792945361809,
"tag": "0076_host_color_icon",
"breakpoints": true
},
{
"idx": 77,
"version": "5",
"when": 1793304929772,
"tag": "0077_shared_folder_auth_overrides",
"breakpoints": true
}
]
}
@@ -0,0 +1,2 @@
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
@@ -0,0 +1,13 @@
CREATE TABLE "shared_folder_auth_overrides" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" varchar(255) NOT NULL,
"folder" varchar(255) NOT NULL,
"protocol" varchar(255) DEFAULT 'ssh' NOT NULL,
"credential_id" integer NOT NULL,
"created_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL,
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "shared_folder_auth_overrides" ADD CONSTRAINT "shared_folder_auth_overrides_credential_id_ssh_credentials_id_fk" FOREIGN KEY ("credential_id") REFERENCES "public"."ssh_credentials"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "shared_folder_auth_overrides_user_folder_protocol_unique" ON "shared_folder_auth_overrides" USING btree ("user_id","folder","protocol");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -533,6 +533,20 @@
"when": 1792430770990,
"tag": "0075_session_policy",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1792945360507,
"tag": "0076_host_color_icon",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1793304928399,
"tag": "0077_shared_folder_auth_overrides",
"breakpoints": true
}
]
}
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
@@ -0,0 +1,13 @@
CREATE TABLE `shared_folder_auth_overrides` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` text NOT NULL,
`folder` text NOT NULL,
`protocol` text DEFAULT 'ssh' NOT NULL,
`credential_id` integer NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`credential_id`) REFERENCES `ssh_credentials`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `shared_folder_auth_overrides_user_folder_protocol_unique` ON `shared_folder_auth_overrides` (`user_id`,`folder`,`protocol`);
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": 1792430769899,
"tag": "0070_session_policy",
"breakpoints": true
},
{
"idx": 71,
"version": "6",
"when": 1792945434651,
"tag": "0071_host_color_icon",
"breakpoints": true
},
{
"idx": 72,
"version": "6",
"when": 1793304926942,
"tag": "0072_shared_folder_auth_overrides",
"breakpoints": true
}
]
}
+6
View File
@@ -128,6 +128,10 @@
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"extendInfo": {
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"]
},
"entitlements": "packaging/build/entitlements.mac.plist",
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution",
@@ -153,6 +157,8 @@
"artifactName": "termix_macos_${arch}_mas.${ext}",
"extendInfo": {
"ITSAppUsesNonExemptEncryption": false,
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"],
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
}
},
+165
View File
@@ -0,0 +1,165 @@
const { isolatedUrl } = require("./isolated-window.cjs");
function cleanBounds(bounds) {
const value = (n) => Math.max(0, Math.round(Number(n) || 0));
return {
x: value(bounds?.x),
y: value(bounds?.y),
width: value(bounds?.width),
height: value(bounds?.height),
};
}
/**
* Native web views drawn inside the main window, for pages that do not work
* in a sandboxed iframe. Each one is a WebContentsView in its own
* non-persistent session, with the same limits as an isolated window: no
* permissions, no downloads, http(s) and ws(s) only. The renderer owns the
* placement and sends the bounds of the element the view should cover.
*/
function createEmbeddedViews({ WebContentsView, session, getMainWindow }) {
const views = new Map();
function destroy(id) {
const entry = views.get(id);
if (!entry) return;
views.delete(id);
const main = getMainWindow();
if (main && !main.isDestroyed()) {
try {
main.contentView.removeChildView(entry.view);
} catch {
/* already detached */
}
}
if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
void Promise.allSettled([
entry.session.clearStorageData(),
entry.session.clearCache(),
entry.session.closeAllConnections(),
]);
}
function destroyAll() {
for (const id of [...views.keys()]) destroy(id);
}
function open(options = {}) {
const main = getMainWindow();
if (!main) throw new Error("No window to attach a view to");
const id = String(options.id || "");
if (!id) throw new Error("A view needs an id");
const url = isolatedUrl(options.url);
const existing = views.get(id);
if (existing) {
if (
existing.url === url.href &&
existing.ignoreCert === !!options.ignoreCert
) {
setBounds(id, options.bounds);
setVisible(id, options.visible !== false);
return { success: true };
}
destroy(id);
}
const isolated = session.fromPartition(
`embedded-view-${id}-${Date.now()}`,
{
cache: false,
},
);
isolated.setPermissionRequestHandler((_c, _p, callback) => callback(false));
isolated.setPermissionCheckHandler(() => false);
isolated.on("will-download", (event) => event.preventDefault());
isolated.webRequest.onBeforeRequest((details, callback) => {
const protocol = new URL(details.url).protocol;
callback({
cancel: !["http:", "https:", "ws:", "wss:"].includes(protocol),
});
});
const view = new WebContentsView({
webPreferences: {
session: isolated,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webviewTag: false,
webSecurity: true,
},
});
const canNavigate = (target) => {
try {
return target === "about:blank" || !!isolatedUrl(target);
} catch {
return false;
}
};
for (const name of ["will-navigate", "will-redirect"]) {
view.webContents.on(name, (event, target) => {
if (!canNavigate(target)) event.preventDefault();
});
}
view.webContents.setWindowOpenHandler(({ url: target }) =>
canNavigate(target)
? {
action: "allow",
overrideBrowserWindowOptions: {
webPreferences: { session: isolated, sandbox: true },
},
}
: { action: "deny" },
);
views.set(id, {
view,
session: isolated,
url: url.href,
origin: url.origin,
ignoreCert: !!options.ignoreCert,
});
main.contentView.addChildView(view);
setBounds(id, options.bounds);
setVisible(id, options.visible !== false);
view.webContents.loadURL(url.href).catch(() => {});
return { success: true };
}
function setBounds(id, bounds) {
const entry = views.get(id);
if (entry) entry.view.setBounds(cleanBounds(bounds));
}
function setVisible(id, visible) {
const entry = views.get(id);
if (entry) entry.view.setVisible(!!visible);
}
function handleCertificateError(event, contents, url, callback) {
for (const entry of views.values()) {
if (entry.view.webContents !== contents) continue;
event.preventDefault();
let allowed = false;
try {
allowed = entry.ignoreCert && new URL(url).origin === entry.origin;
} catch {
/* malformed URL is refused */
}
callback(allowed);
return true;
}
return false;
}
return {
open,
setBounds,
setVisible,
destroy,
destroyAll,
handleCertificateError,
};
}
module.exports = { createEmbeddedViews, cleanBounds };
+13
View File
@@ -34,6 +34,8 @@ function setLinkedServer(config) {
basicAuth:
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
allowInvalidCertificate: !!config.allowInvalidCertificate,
clientCert:
config.clientCert && config.clientCert.pfx ? config.clientCert : null,
};
}
@@ -45,6 +47,16 @@ function isLinkedOrigin(url) {
return !!linked && getOrigin(url) === linked.origin;
}
// The PFX of the link's client certificate, for TLS this process makes itself
// to the linked origin. Null for any other origin.
function clientCertOptionsFor(url) {
if (!isLinkedOrigin(url) || !linked.clientCert) return {};
return {
pfx: Buffer.from(linked.clientCert.pfx, "base64"),
passphrase: linked.clientCert.passphrase || "",
};
}
function basicAuthValue(basicAuth) {
return `Basic ${Buffer.from(
`${basicAuth.username}:${basicAuth.password || ""}`,
@@ -99,6 +111,7 @@ module.exports = {
setLinkedServer,
getLinkedServer,
isLinkedOrigin,
clientCertOptionsFor,
applyLinkHeaders,
linkedRequestHeaders,
answerLogin,
+143 -1
View File
@@ -1,6 +1,7 @@
const {
app,
BrowserWindow,
WebContentsView,
shell,
ipcMain,
dialog,
@@ -30,6 +31,10 @@ const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
// Saved by the file manager after the user picks an editor in a file dialog.
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
// The height of the title bar the app draws itself. The renderer uses the
// same number, so the bar and the window controls always line up.
const TITLE_BAR_HEIGHT = 40;
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
// The main process's Node.js networking (the global `fetch`) only
@@ -459,6 +464,7 @@ function isInvalidCertificateAllowedForUrl(url) {
function getTlsVerificationOptions(url) {
return {
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
...linkedServer.clientCertOptionsFor(url),
};
}
@@ -496,12 +502,38 @@ app.commandLine.appendSwitch("--enable-features=NetworkService");
let mainWindow = null;
const { createIsolatedWindows } = require("./isolated-window.cjs");
const { createEmbeddedViews } = require("./embedded-view.cjs");
const isolatedWindows = createIsolatedWindows({
BrowserWindow,
session,
getMainWindow: () => mainWindow,
});
const embeddedViews = createEmbeddedViews({
WebContentsView,
session,
getMainWindow: () => mainWindow,
});
// Only the app's own page may place native views, never a frame inside it.
function fromMainFrame(event) {
return !!mainWindow && event.senderFrame === mainWindow.webContents.mainFrame;
}
ipcMain.handle("embedded-view-open", (event, options) => {
if (!fromMainFrame(event)) throw new Error("Not allowed");
return embeddedViews.open(options);
});
ipcMain.handle("embedded-view-bounds", (event, id, bounds) => {
if (fromMainFrame(event)) embeddedViews.setBounds(String(id), bounds);
});
ipcMain.handle("embedded-view-visible", (event, id, visible) => {
if (fromMainFrame(event)) embeddedViews.setVisible(String(id), visible);
});
ipcMain.handle("embedded-view-close", (event, id) => {
if (fromMainFrame(event)) embeddedViews.destroy(String(id));
});
// Requests from the embedded backend over the fork IPC channel (the other
// direction from the "shutdown" message main already sends it). A plugin's
// ctx.desktop.openIsolatedWindow is the one caller today.
@@ -690,6 +722,39 @@ ipcMain.handle("allow-invalid-certificate-for-origin", (_event, origin) => {
}
});
// Mutual TLS for the linked server: Chromium asks which certificate from the
// OS store to present. Other origins keep Chromium's default (none).
const chosenClientCerts = new Map();
app.on(
"select-client-certificate",
async (event, _webContents, url, list, callback) => {
if (!linkedServer.isLinkedOrigin(url) || !list.length) return;
event.preventDefault();
const origin = new URL(url).origin;
const remembered = list.find(
(cert) => cert.fingerprint === chosenClientCerts.get(origin),
);
let chosen = remembered || (list.length === 1 ? list[0] : null);
if (!chosen) {
const { response } = await dialog.showMessageBox(mainWindow, {
type: "question",
title: "Client certificate",
message: "Choose the certificate for this server",
detail: origin,
buttons: [
...list.slice(0, 8).map((cert) => cert.subjectName),
"Cancel",
],
cancelId: Math.min(list.length, 8),
});
chosen = list[response] || null;
}
if (chosen) chosenClientCerts.set(origin, chosen.fingerprint);
callback(chosen || undefined);
},
);
app.on(
"certificate-error",
(event, _webContents, url, error, certificate, callback) => {
@@ -697,6 +762,10 @@ app.on(
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
)
return;
if (
embeddedViews.handleCertificateError(event, _webContents, url, callback)
)
return;
if (isWebEndpointCertificateAllowed(url)) {
event.preventDefault();
logToFile("Allowed invalid certificate for configured web endpoint", {
@@ -1177,8 +1246,9 @@ function createTray() {
let trayIcon;
if (process.platform === "darwin") {
const iconPath = path.join(publicRoot, "icons", "16x16.png");
// The logo is full color on a solid tile. As a template image macOS
// keeps only its outline, which showed as an empty square.
trayIcon = nativeImage.createFromPath(iconPath);
trayIcon.setTemplateImage(true);
} else if (process.platform === "win32") {
trayIcon = path.join(publicRoot, "icon.ico");
} else {
@@ -1262,12 +1332,27 @@ function createWindow() {
? "macOS"
: "Linux";
const isMac = process.platform === "darwin";
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: "Termix",
// The app draws its own title bar so it follows the active theme. macOS
// keeps the traffic lights on top of it, Windows and Linux get the
// native controls as an overlay whose colors the renderer keeps in sync.
titleBarStyle: isMac ? "hiddenInset" : "hidden",
...(isMac
? { trafficLightPosition: { x: 14, y: 16 } }
: {
titleBarOverlay: {
color: "#0f0f14",
symbolColor: "#e6e6ea",
height: TITLE_BAR_HEIGHT,
},
}),
icon: path.join(
appRoot,
"public",
@@ -1474,7 +1559,24 @@ function createWindow() {
}
});
mainWindow.webContents.on("did-start-navigation", (details) => {
if (details.isMainFrame && !details.isSameDocument) {
embeddedViews.destroyAll();
}
});
const sendTitleBarVisible = () => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send(
"title-bar-visible-changed",
titleBarVisible(mainWindow),
);
};
mainWindow.on("enter-full-screen", sendTitleBarVisible);
mainWindow.on("leave-full-screen", sendTitleBarVisible);
mainWindow.on("closed", () => {
embeddedViews.destroyAll();
mainWindow = null;
});
@@ -1502,10 +1604,50 @@ ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
/** The window an IPC call came from, so the main window and the extra
* windows (isolated views, sign-in) can each answer for themselves. */
function windowFor(event) {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && !win.isDestroyed()) return win;
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
return null;
}
ipcMain.handle("get-platform", () => {
return process.platform;
});
// Whether the app-drawn title bar shows in the window that asks. Only the
// main window hides the native bar, and in full screen macOS hides the
// traffic lights, so the bar goes away there too.
function titleBarVisible(win) {
return !!win && win === mainWindow && !win.isFullScreen();
}
ipcMain.handle("get-title-bar-visible", (event) => {
return titleBarVisible(windowFor(event));
});
// The renderer sends the colors of the active theme so the window controls
// the system draws (Windows and Linux) match the app.
ipcMain.handle("set-title-bar-overlay", (event, colors) => {
const win = windowFor(event);
if (!win || process.platform === "darwin") return;
const options = {};
if (typeof colors?.color === "string" && colors.color) {
options.color = colors.color;
}
if (typeof colors?.symbolColor === "string" && colors.symbolColor) {
options.symbolColor = colors.symbolColor;
}
if (Object.keys(options).length === 0) return;
try {
win.setTitleBarOverlay(options);
} catch (error) {
logToFile("setTitleBarOverlay failed:", error);
}
});
ipcMain.handle("get-embedded-server-status", () => {
return {
running:
+23
View File
@@ -32,6 +32,19 @@ contextBridge.exposeInMainWorld("electronAPI", {
webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value)));
},
getPlatform: () => ipcRenderer.invoke("get-platform"),
// Whether the app-drawn title bar shows in this window, and when it changes
// (full screen hides it).
getTitleBarVisible: () => ipcRenderer.invoke("get-title-bar-visible"),
onTitleBarVisibleChange: (callback) => {
const listener = (_event, visible) => callback(visible);
ipcRenderer.on("title-bar-visible-changed", listener);
return () =>
ipcRenderer.removeListener("title-bar-visible-changed", listener);
},
// Keeps the window controls the system draws in the colors of the theme.
setTitleBarOverlay: (colors) =>
ipcRenderer.invoke("set-title-bar-overlay", colors),
getEmbeddedServerStatus: () =>
ipcRenderer.invoke("get-embedded-server-status"),
@@ -80,6 +93,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("start-drag-to-desktop", dragData),
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
// Native web views placed over an element of the page (WebContentsView).
embeddedView: {
open: (options) => ipcRenderer.invoke("embedded-view-open", options),
setBounds: (id, bounds) =>
ipcRenderer.invoke("embedded-view-bounds", id, bounds),
setVisible: (id, visible) =>
ipcRenderer.invoke("embedded-view-visible", id, visible),
close: (id) => ipcRenderer.invoke("embedded-view-close", id),
},
invoke: invokeAllowed,
});
+36 -1
View File
@@ -83,6 +83,30 @@
"default": "8443",
"description": "Port for HTTPS."
},
{
"name": "TERMIX_NGINX_TEMPLATE",
"group": "https",
"default": "/app/nginx/nginx-https.conf.template",
"description": "The HTTPS nginx template Termix renders when a certificate is added. Set it on an install outside Docker."
},
{
"name": "TERMIX_NGINX_CONF",
"group": "https",
"default": "/tmp/nginx/nginx.conf",
"description": "Where Termix writes the rendered nginx config before it reloads nginx."
},
{
"name": "TERMIX_NGINX_PID",
"group": "https",
"default": "/tmp/nginx/nginx.pid",
"description": "The nginx pid file. Termix only reloads nginx when it exists."
},
{
"name": "TERMIX_APP_DIR",
"group": "https",
"default": "/app",
"description": "Where Termix is installed, used in place of /app in the nginx template."
},
{
"name": "SSL_CERT_PATH",
"group": "https",
@@ -298,7 +322,7 @@
{
"name": "HTTP_PROXY",
"group": "proxy",
"description": "Proxy for outbound HTTP requests, like registry downloads and webhooks."
"description": "Proxy for outbound HTTP requests, like plugin registry downloads and webhooks."
},
{
"name": "HTTPS_PROXY",
@@ -353,6 +377,11 @@
"group": "plugins",
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
},
{
"name": "TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS",
"group": "plugins",
"description": "Comma-separated host names or IP addresses that plugin registry downloads may reach on a private address, like a registry in your LAN. List bare hosts: entries with a scheme, port or path are ignored. Matched by name only, on any port, so only list names your own DNS answers for. With HTTPS_PROXY set, add them to NO_PROXY too."
},
{
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
"group": "plugins",
@@ -365,6 +394,12 @@
"default": "false",
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
},
{
"name": "TERMIX_SAFE_MODE",
"group": "plugins",
"default": "false",
"description": "Start only layout and theme plugins. Every other plugin stays off until a restart without it, so you can turn off a plugin that breaks the server or the app."
},
{
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
"group": "plugins",
+10 -10
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.3",
"version": "26.10.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.3",
"version": "26.10.6",
"hasInstallScript": true,
"workspaces": [
"packages/*"
@@ -19,7 +19,7 @@
"axios": "^1.19.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3",
"body-parser": "^2.3.0",
"body-parser": "^2.4.0",
"chalk": "^6.0.0",
"compression": "^1.8.2",
"cookie-parser": "^1.4.7",
@@ -7470,18 +7470,18 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"content-type": "^2.1.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"iconv-lite": "^0.7.3",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"qs": "^6.16.1",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
@@ -15058,7 +15058,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.13",
"version": "1.0.22",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
+6 -5
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.3",
"version": "26.10.6",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -15,10 +15,10 @@
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
"prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-button-colors.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint:fix": "eslint --fix .",
"type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run",
@@ -31,7 +31,8 @@
"dev:docker": "node scripts/dev.mjs docker",
"dev:frontend": "vite",
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build:backend:core": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"",
"build:backend": "npm run build:backend:core && 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",
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
@@ -54,7 +55,7 @@
"axios": "^1.19.0",
"bcryptjs": "^3.0.3",
"better-sqlite3": "^13.0.3",
"body-parser": "^2.3.0",
"body-parser": "^2.4.0",
"chalk": "^6.0.0",
"compression": "^1.8.2",
"cookie-parser": "^1.4.7",
+78
View File
@@ -1,5 +1,83 @@
# Changelog
## 1.0.22
Plugin API 1.13.0.
### Added
- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
- SSH transport providers get the host's owner, sync id, connection origin and local only flag in `claims` and `open`, and core's SSH relay takes a `syncId` instead of a host id
- `ctx.sync.host.entities.idsOf` maps sync ids to the local ids of the user's own rows of one type
- Rail items take `useStatus` for a small ok, warning or error dot on the icon
- `shell.editHost(hostId)` opens the editor of a saved host
- A sync entity can be read only with `load`, `write` and `erase`, so a plugin can sync rows a user does not own, like a shared library, down to linked desktops
- `getFolderAuthOverride` and `setFolderAuthOverride` in `@termix-ssh/plugin-sdk/ui` read and set a user's own login for the shared hosts in a folder
### Fixed
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
- `execCommand` buffers output and decodes it once as strict UTF-8 with a GBK fallback, exposed as `decodeCommandOutput`, so messages from a Windows host on a legacy codepage are readable instead of � characters
## 1.0.21
Plugin API 1.12.0.
### Added
- `@termix-ssh/plugin-sdk/host-scripts`: Docker, Linux metrics, systemd, journal and OpenRC command builders and parsers, plus `scriptShell()` and `passwordSudoScript()` to send scripts to `bash -s` on stdin. It has no Node or ssh2 imports, so the Termix mobile app runs the same code
## 1.0.20
Plugin API 1.12.0.
### Added
- `columns` and `onColumns` on `ViewToggle` show a cards per row picker: Auto fits as many as the pane has room for, or pick 1 to 6
- `size` and `minCardWidth` on `DataView`, so a card grid follows that pick, and the `GridSize` type
- `usePluginPanelView` also keeps `columns`, the cards per row pick, with `setColumns`
## 1.0.19
Plugin API 1.11.0.
### Added
- `app.registerCompanionLayout` and `useCompanions`, so a plugin can let each host turn the panel bar on or off, move it and choose which panels show and in what order
## 1.0.18
Plugin API 1.10.0.
### Added
- `clientCert` on the sync link, link patch and remote target, and the `client_cert` remote error kind, so a sync plugin can link to a server that needs mutual TLS
## 1.0.17
Plugin API 1.9.0.
### Added
- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running
- `NativeView` in `@termix-ssh/plugin-sdk/ui`, which shows a web page in a native view in the desktop app, for sites that do not work in an iframe
## 1.0.16
Plugin API 1.8.0.
### Added
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
- `ctx.ssh.transports.provide` and the `ssh:transport` capability, so a plugin can carry SSH connections over its own stream
- `ctx.hosts.folders.ensure` and `removeEmpty`
### Removed
- The sync screens and calls in `@termix-ssh/plugin-sdk/ui`: `LinkServerDialog`, `ProxySettingsFields`, `useSyncStatus`, `refreshSyncStatus`, `setSyncStatus`, `getSyncConflicts`, `getSyncErrors`, `retrySyncErrors`, `settleSyncConflict`, `syncNow`, `unlinkServer`, `updateSyncSettings` and their types. They moved into the sync plugin
## 1.0.15
Plugin API 1.7.0.
+160 -74
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.15 | Plugin API 1.7.0
SDK 1.0.22 | Plugin API 1.13.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.
@@ -175,7 +175,49 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
### Mobile app
`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
plugin as having nothing to show (official plugins are mapped by the app itself).
```json
"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
```
| Field | Notes |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
| `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
| `note` | A locale key or plain text the app shows next to the plugin. |
`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
How a `webview` plugin runs in the app:
- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
plugin list.
- Every request and socket goes through the app, which adds the login, proxy headers and client
certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
activity). Other core routes answer 403, so call your own backend instead.
- Images load straight from the server, without proxy headers or a client certificate.
- Nothing in `localStorage` survives closing the view. Keep preferences in settings.
- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
is too old for the range, it asks the user to update the app instead of opening the view. When the range
is for an older API major, it asks to update the plugin. Ask only for the API you use.
- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
`"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
@@ -202,37 +244,40 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
### What ctx has
| Member | Capability | What it does |
| ---------------------- | -------------------------------- | -------------------------------------------------------------- |
| `pluginId`, `manifest` | none | Your id and manifest |
| `log` | none | Log lines tagged with your plugin |
| `db` | `db:own` | Your own tables |
| `kv` | `kv:own` | Small key value state |
| `files.dataDir()` | `files:own` | A folder of your own |
| `settings` | none | Your settings |
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
| `http`, `ws` | `network:serve` | Routes and sockets |
| `rbac` | none | Check the actor's permissions |
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
| `credentials` | varies | The actor's saved keys, and protocol logins |
| `services` | per service | Call other plugins and offer your own |
| `events` | `events:core` for core topics | Emit and listen for events |
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
| `process` | `process:spawn` | Run programs on the server |
| `schedule` | none | Timers that stop on disable and never overlap |
| `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 |
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
| `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 |
| `currentActor()` | none | The user the current call runs for |
| `disposables.add(fn)` | none | Extra cleanup on disable |
| Member | Capability | What it does |
| ---------------------- | -------------------------------- | --------------------------------------------------------------- |
| `pluginId`, `manifest` | none | Your id and manifest |
| `log` | none | Log lines tagged with your plugin |
| `db` | `db:own` | Your own tables |
| `kv` | `kv:own` | Small key value state |
| `files.dataDir()` | `files:own` | A folder of your own |
| `settings` | none | Your settings |
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
| `http`, `ws` | `network:serve` | Routes and sockets |
| `rbac` | none | Check the actor's permissions |
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
| `credentials` | varies | The actor's saved keys, and protocol logins |
| `services` | per service | Call other plugins and offer your own |
| `events` | `events:core` for core topics | Emit and listen for events |
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
| `process` | `process:spawn` | Run programs on the server |
| `schedule` | none | Timers that stop on disable and never overlap |
| `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 |
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
| `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream |
| `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty |
| `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 |
| `currentActor()` | none | The user the current call runs for |
| `disposables.add(fn)` | none | Extra cleanup on disable |
### The actor
@@ -301,6 +346,9 @@ router.post("/notes", async (req, res) => {
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
`hosts.edit` checks a core permission.
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId, host), open(host) })`, where `host` has `id`, `ip`, `port` and on Termix 26.10.6+ also `userId`, `syncId`, `connectionOrigin` and `localOnly`
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
@@ -329,7 +377,9 @@ try {
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal
commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from
`ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
### The desktop app
@@ -445,6 +495,23 @@ Other storage: `ctx.kv` for a few values, `ctx.settings` for settings, `ctx.secr
`ctx.files.dataDir()` for files. To sync a table to a linked desktop, use `ctx.sync.registerEntity` and
`contributes.syncEntities`. The entity name is part of the protocol and never changes.
A registered table syncs the rows each user owns, both ways. For data you keep for the whole server (a shared
library, rows other users share), register a read only entity with `table: null`, `readOnly: true` and `load`,
`write` and `erase` (Termix 26.10.6+). `load(userId)` returns the rows that user should get, each with a
`syncId`. The desktop keeps them with `write` and drops them with `erase`, and never pushes them back:
```ts
ctx.sync.registerEntity({
type: "sharedIcons",
table: null,
readOnly: true,
order: 70,
load: (userId) => iconsVisibleTo(userId),
write: (userId, row) => storeCopy(userId, row),
erase: (userId, syncId) => dropCopy(userId, syncId),
});
```
## Frontend
```tsx
@@ -489,6 +556,8 @@ your plugin's error boundary and Suspense.
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `registerRailItem` | A sidebar rail button |
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
| `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle |
| `registerCompanionLayout` | Per host on/off, side, order and hidden panels for a tab type |
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
| `registerHostAction` | A host button, palette entry or default connect |
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
@@ -624,6 +693,9 @@ Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the t
exports with props is `ui.api.txt` in the Termix repo
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
- Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the
page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders
`fallback` in a browser.
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
@@ -632,6 +704,9 @@ exports with props is `ui.api.txt` in the Termix repo
`PanePrompt` for small prompts.
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
forms end with `FormFooter`, empty lists show `EmptyState`.
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
@@ -707,6 +782,14 @@ lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`,
refuses the login. A user who must have a second factor but has none gets a session that can only set one
up, and `/users/me` reports `restriction: "second-factor-setup"`.
Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every
request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands
over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier
(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions
(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its
own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute
ticket that opens plugin sockets as its user, which is how one server opens sessions through another.
## Testing
```ts
@@ -883,6 +966,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
| `@termix-ssh/plugin-sdk/host-scripts` | Docker, metrics, systemd and journal command builders and parsers, scripts sent to `bash -s` |
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
@@ -893,46 +977,48 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## Capabilities
| Capability | Risk | What it allows |
| ------------------------ | -------- | ------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
| `device:serial` | high | Open serial and USB ports on the server |
| `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots |
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
| `network:probe` | medium | Check that a host answers on a TCP port |
| `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 |
| `ui:theme` | low | Add color themes, CSS only |
| 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 |
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
| `sync:provide` | high | Run sync: answer every /sync request |
| `device:serial` | high | Open serial and USB ports on the server |
| `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins |
| `notify:hub` | high | Receive every alert (the alerts hub only) |
| `ui:layout` | high | Replace the whole app layout |
| `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots |
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
| `network:probe` | medium | Check that a host answers on a TCP port |
| `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 |
| `ui:theme` | low | Add color themes, CSS only |
An update that adds a capability waits for an admin to approve it.
+55 -36
View File
@@ -25,6 +25,43 @@ const FRONTEND_ENTRIES = [
"src/frontend/index.mjs",
"src/frontend/index.js",
];
const MOBILE_ENTRIES = [
"src/mobile/index.tsx",
"src/mobile/index.ts",
"src/mobile/index.mjs",
"src/mobile/index.js",
];
/** Builds a bundle that runs in a browser against core's shared modules. */
async function buildBrowserBundle(entry, outfile, outDir) {
await esbuild.build({
entryPoints: [entry],
outfile,
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
// Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
}
/**
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
@@ -192,15 +229,12 @@ export async function buildTailwind(cwd, outDir) {
// build() adds to what a compiler already emitted, so each half gets its own.
const compiler = () =>
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
const scanner = new Scanner({
sources: [
{
base: path.join(cwd, "src", "frontend"),
pattern: "**/*",
negated: false,
},
],
});
// The mobile bundle uses the same stylesheet.
const sources = ["frontend", "mobile"]
.map((dir) => path.join(cwd, "src", dir))
.filter((dir, index) => index === 0 || fs.existsSync(dir))
.map((base) => ({ base, pattern: "**/*", negated: false }));
const scanner = new Scanner({ sources });
// Flattened and minified the way core's own CSS is, so it does not rely
// on native CSS nesting.
const emit = async (candidates, options) =>
@@ -297,33 +331,18 @@ export async function build({ cwd }) {
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) {
await esbuild.build({
entryPoints: [frontendEntry],
outfile: path.join(outDir, "frontend.js"),
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
// Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
await buildBrowserBundle(
frontendEntry,
path.join(outDir, "frontend.js"),
outDir,
);
}
// A bundle the mobile app loads in its WebView, next to the frontend one.
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
const mobileOut = manifest.contributes?.mobile?.entry;
if (mobileEntry && typeof mobileOut === "string") {
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
}
if (frontendEntry) await buildTailwind(cwd, outDir);
@@ -28,6 +28,10 @@ export async function validate({ cwd }) {
// A theme only plugin has no frontend code.
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
raw.locales ?? "locales",
// Built from src/mobile by termix-plugin build.
...(typeof raw.contributes?.mobile?.entry === "string"
? [raw.contributes.mobile.entry]
: []),
];
for (const rel of referenced) {
if (!fs.existsSync(path.join(cwd, rel))) {
+9 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.15",
"version": "1.0.22",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -81,6 +81,10 @@
"types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js"
},
"./host-scripts": {
"types": "./dist/host-scripts/index.d.ts",
"default": "./dist/host-scripts/index.js"
},
"./ssh-certs": {
"types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js"
@@ -98,6 +102,10 @@
"./testing-host": {
"default": "./dist/host/testing-host.js"
},
"./frame-host": {
"default": "./dist/host/frame-host.js"
},
"./frame-host.css": "./dist/host/frame-host.css",
"./theme.css": "./dist/host/theme.css"
},
"files": [
@@ -152,6 +152,8 @@
"users:write",
"auth:provide",
"auth:policy",
"sync:provide",
"ssh:transport",
"system:tls",
"device:serial",
"hosts:write",
@@ -1237,6 +1239,48 @@
}
}
}
},
"mobile": {
"type": "object",
"description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": {
"type": "string",
"enum": ["webview", "native", "none"],
"description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app."
},
"entry": {
"type": "string",
"pattern": "\\.js$",
"description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame."
},
"sizes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "enum": ["phone", "tablet"] },
"description": "webview only. Screen sizes the UI works at. Defaults to both."
},
"views": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab."
},
"feature": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$",
"description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id."
},
"note": {
"type": "string",
"minLength": 1,
"description": "i18n key in the plugin's namespace, or plain text, shown in the app."
}
}
}
}
},
+507 -1
View File
@@ -177,8 +177,24 @@ export interface SyncWriteEvent {
export interface SyncEntityRegistration {
/** Stable wire name. Never change it: records on both sides match on it. */
type: string;
/** The table, as returned by ctx.db.define. */
/** The table, as returned by ctx.db.define. Null for an entity with `load`. */
table: unknown;
/**
* Server to desktop only: a desktop never pushes these rows. Required with
* `load`.
*/
readOnly?: boolean;
/**
* Builds the rows the user gets instead of reading the rows they own in
* `table`, for data kept for the whole server (a shared library, rows other
* users share with them). Rows are on the wire: plaintext, each with a
* syncId. Needs `readOnly: true`, `write` and `erase`.
*/
load?: (userId: string) => Promise<SyncRow[]>;
/** Keeps a copy of an inbound row on the desktop, for an entity with `load`. */
write?: (userId: string, wire: SyncRow) => Promise<void>;
/** Drops the desktop copy with this syncId, for an entity with `load`. */
erase?: (userId: string, syncId: string) => Promise<void>;
/** Column holding the owning user id. Defaults to "userId". */
userColumn?: string;
/** Fields stored encrypted with the owner's data key. Plaintext on the wire. */
@@ -236,6 +252,454 @@ export interface SyncEntityRegistration {
*/
export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void;
/** Needs sync:provide. Takes over /sync/v2 and /sync/link. Removed on deactivate. */
provide: (provider: PluginSyncProvider) => () => void;
/** Needs sync:provide. The replica store, change feed and link registry. */
readonly host: PluginSyncHost;
}
/**
* The plugin that owns server and desktop sync. Core forwards every request
* under /sync to `handle` (an Express style handler) and answers 404 when no
* plugin provides it. Call `next()` to let core answer instead.
*/
export interface PluginSyncProvider {
handle: (
req: unknown,
res: unknown,
next: (error?: unknown) => void,
) => void | Promise<void>;
}
/** A kind of data that syncs, as the sync provider sees it. */
export interface SyncEntityInfo {
type: string;
owner: string;
order: number;
readOnly: boolean;
/** Whether the row would show in a load even if it exists. */
covers?: (syncId: string) => boolean;
}
/** A row ready for the wire, with its content hash. */
export interface SyncWireRow {
syncId: string;
wire: SyncRow;
hash: string;
}
export interface SyncRecordInfo {
entityType: string;
syncId: string;
revision: number;
hash: string | null;
deleted: boolean;
error: string | null;
errorHash: string | null;
seq: number;
}
export interface SyncConflictInfo {
id: number;
entityType: string;
syncId: string;
localRow: string;
createdAt: string;
}
export interface SyncAccountInfo {
id: string;
username: string;
isAdmin: boolean;
active: boolean;
}
export interface SyncProxyHeader {
name: string;
value: string;
}
export interface SyncBasicAuth {
username: string;
password: string;
}
/** A client certificate for a server that asks for mutual TLS. */
export interface SyncClientCert {
/** Base64 of the PFX/P12 file. */
pfx: string;
passphrase: string;
/** The file name, to show which certificate is saved. */
name: string | null;
}
/** Extra per server connection options. Kept open so new ones need no migration. */
export type SyncLinkOptions = Record<string, unknown>;
/** One server a desktop is linked to, with a replica of its data. */
export interface SyncLinkInfo {
id: number;
/** The local user the replica belongs to. */
userId: string;
serverUrl: string;
serverName: string | null;
serverVersion: string | null;
sessionToken: string | null;
customHeaders: SyncProxyHeader[];
basicAuth: SyncBasicAuth | null;
allowInvalidCertificate: boolean;
clientCert: SyncClientCert | null;
remoteUserId: string | null;
remoteUsername: string | null;
account: {
remoteUserId?: string;
username?: string;
isAdmin?: boolean;
roles?: string[];
permissions?: string[];
} | null;
disabledTypes: string[];
/** Extra connection options for this server, kept open for new ones. */
options: SyncLinkOptions;
knownTypes: string[];
cursor: number;
status: "idle" | "syncing" | "offline" | "error" | "signed_out";
lastError: string | null;
linkedAt: string;
lastSyncAt: string | null;
}
export interface SyncLinkCreate {
userId: string;
serverUrl: string;
serverName?: string | null;
serverVersion?: string | null;
sessionToken: string;
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: SyncClientCert | null;
remoteUserId?: string | null;
remoteUsername?: string | null;
}
export type SyncLinkPatch = Partial<
Pick<
SyncLinkInfo,
| "serverName"
| "serverVersion"
| "sessionToken"
| "customHeaders"
| "basicAuth"
| "allowInvalidCertificate"
| "clientCert"
| "remoteUserId"
| "remoteUsername"
| "account"
| "disabledTypes"
| "options"
| "knownTypes"
| "cursor"
| "status"
| "lastError"
| "lastSyncAt"
>
>;
export type SyncRemoteErrorKind =
| "offline"
| "tls"
| "client_cert"
| "proxy"
| "basic_auth"
| "signed_out"
| "server";
export interface SyncRemoteError extends Error {
readonly kind: SyncRemoteErrorKind;
readonly status?: number;
}
export interface SyncRemoteTarget {
serverUrl: string;
sessionToken?: string | null;
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: SyncClientCert | null;
}
export interface SyncRemoteRequest {
method?: string;
body?: unknown;
timeoutMs?: number;
signal?: AbortSignal;
}
export interface SyncPushOp {
entityType: string;
syncId: string;
baseRevision: number;
op: "upsert" | "delete";
row?: SyncRow;
}
export type SyncPushResult =
| { syncId: string; entityType: string; status: "ok"; revision: number }
| {
syncId: string;
entityType: string;
status: "conflict";
revision: number;
deleted: boolean;
row: SyncRow | null;
}
| { syncId: string; entityType: string; status: "rejected"; reason: string };
export interface SyncRemotePlugin {
id: string;
name: string;
version: string;
source: string;
artifact: boolean;
enabled: boolean;
active: boolean;
desktop: string;
syncEntities: string[];
granted: string[];
}
/**
* What the sync provider gets from core: the replica store, the change feed,
* the linked server registry, sessions and a few helpers. Core keeps all of
* it neutral; the provider decides when and how to use it. Needs sync:provide.
*/
export interface PluginSyncHost {
/** True on the desktop app's own backend. */
readonly embedded: boolean;
info: { appName: () => Promise<string>; version: () => string };
entities: {
list: () => SyncEntityInfo[];
get: (type: string) => SyncEntityInfo | undefined;
/** Registers core's own entities once. */
ensureCore: () => Promise<void>;
onChanged: (listener: () => void) => () => void;
onLocalWrite: (listener: () => void) => () => void;
/** Local ids of the user's own rows of a type, by sync id. Termix 26.10.6+. */
idsOf?: (
type: string,
userId: string,
syncIds: string[],
) => Promise<Record<string, number>>;
};
/** Rows and bookkeeping of one local user's replica. */
replica: {
resolvers: (userId: string, onMissing?: () => void) => unknown;
loadRows: (
type: string,
userId: string,
resolvers: unknown,
) => Promise<{ rows: Map<string, SyncWireRow>; failed: Set<string> }>;
loadRow: (
type: string,
userId: string,
syncId: string,
resolvers: unknown,
) => Promise<SyncWireRow | null>;
writeRow: (
type: string,
userId: string,
wire: SyncRow,
resolvers: unknown,
) => Promise<void>;
deleteRow: (type: string, userId: string, syncId: string) => Promise<void>;
orderSelfReferences: (type: string, rows: SyncRow[]) => SyncRow[];
counts: (userId: string) => Promise<Record<string, number>>;
wipe: (userId: string) => Promise<void>;
regenerateSyncIds: (userId: string) => Promise<void>;
removeSharedCopies: (userId: string) => Promise<void>;
records: {
list: (userId: string) => Promise<Map<string, SyncRecordInfo>>;
put: (
userId: string,
type: string,
syncId: string,
values: { revision: number; hash: string | null; deleted: boolean },
) => Promise<void>;
setError: (
userId: string,
type: string,
syncId: string,
reason: string,
hash: string,
) => Promise<void>;
clearErrors: (userId: string) => Promise<void>;
listErrors: (userId: string) => Promise<SyncRecordInfo[]>;
deleteForUser: (userId: string) => Promise<void>;
key: (type: string, syncId: string) => string;
};
conflicts: {
save: (
userId: string,
type: string,
syncId: string,
row: SyncRow,
revision: number,
) => Promise<void>;
list: (userId: string) => Promise<SyncConflictInfo[]>;
get: (userId: string, id: number) => Promise<SyncConflictInfo | null>;
delete: (userId: string, id: number) => Promise<void>;
};
};
/** The server side: what changed for an account and what a desktop pushed. */
feed: {
withUserLock: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
changes: (
userId: string,
cursor: number,
limit: number,
types?: string[],
) => Promise<{
records: Array<{
seq: number;
entityType: string;
syncId: string;
revision: number;
deleted: boolean;
row: SyncRow | null;
}>;
hasMore: boolean;
nextCursor: number;
entityTypes: Array<{
type: string;
owner: string;
order: number;
readOnly: boolean;
}>;
}>;
parsePushOps: (body: unknown) => Promise<SyncPushOp[] | null>;
applyPush: (userId: string, ops: SyncPushOp[]) => Promise<SyncPushResult[]>;
/** Streams a change notice to the account's open event streams. */
listen: (userId: string, res: unknown) => Promise<() => void>;
};
/**
* The linked server registry, kept in core so it is there before plugins.
* One link per local user. Without a user, the active account is meant.
*/
links: {
list: () => Promise<SyncLinkInfo[]>;
get: (userId?: string) => Promise<SyncLinkInfo | null>;
create: (link: SyncLinkCreate) => Promise<SyncLinkInfo>;
update: (
patch: SyncLinkPatch,
userId?: string,
) => Promise<SyncLinkInfo | null>;
remove: (userId?: string) => Promise<void>;
};
/** Desktop accounts: the local user and the replica user of each server. */
accounts: {
list: () => Promise<SyncAccountInfo[]>;
/** A new empty account, ready to be linked to a server. */
create: () => Promise<string>;
/** Which account the app signs in as from now on. */
setActive: (userId: string) => Promise<void>;
activeId: () => Promise<string | null>;
/** Deletes an account and all its data. The last one cannot go. */
remove: (userId: string) => Promise<void>;
};
http: {
json: <T>(
target: SyncRemoteTarget,
pathname: string,
request?: SyncRemoteRequest,
) => Promise<T>;
fetch: (
target: SyncRemoteTarget,
pathname: string,
request?: SyncRemoteRequest,
) => Promise<{
ok: boolean;
status: number;
body: AsyncIterable<Uint8Array> | null;
}>;
normalizeServerUrl: (input: string) => string | null;
isRemoteError: (error: unknown) => error is SyncRemoteError;
};
sessions: {
/** A long lived session for a linked desktop. Shows in the account's sessions. */
issueDesktop: (
userId: string,
options: { days: number; deviceInfo: string },
) => Promise<string>;
revoke: (sessionId: string) => Promise<void>;
find: (
sessionId: string,
) => Promise<{ expiresAt: string; deviceInfo: string | null } | null>;
findUser: (
userId: string,
) => Promise<{ id: string; username: string } | null>;
/** The user and session behind a token, or null when it is not valid. */
verify: (
token: string,
) => Promise<{ userId: string; sessionId?: string } | null>;
/** Sets the sign in cookie on a response, like a login does. A name sets another cookie. */
setCookie: (
req: unknown,
res: unknown,
value: string,
options?: { name?: string; days?: number },
) => void;
/** Removes the sign in cookie, without ending the session behind it. */
clearCookie: (req: unknown, res: unknown, name?: string) => void;
/** Express middleware that signs the request in and sets userId and sessionId. */
middleware: () => unknown;
/** Express middleware that lets a request through only on a desktop. */
desktopOnly: () => unknown;
};
hosts: {
findForSync: (
userId: string,
syncId: string,
) => Promise<{ id: number; name: string | null } | null>;
statusBySyncId: (userId: string) => Promise<Record<string, unknown>>;
};
plugins: {
listForDesktop: () => Promise<{
plugins: SyncRemotePlugin[];
registryKeys: unknown;
}>;
/** Sends a plugin archive; false when there is no such plugin to share. */
sendPackage: (pluginId: string, res: unknown) => Promise<boolean>;
mirror: (link: SyncLinkInfo) => Promise<void>;
lastRemote: () => SyncRemotePlugin[];
localNames: () => Map<string, string>;
};
desktop: {
/** Tells the app's main process how to reach the linked origin. */
applyProxy: (link: SyncLinkInfo | null) => Promise<void>;
deviceName: () => string;
};
alerts: {
send: (alert: {
title: string;
body: string;
severity: "info" | "warning" | "error";
category: string;
dedupeKey: string;
link?: { tab: string };
}) => Promise<void>;
};
}
/**
@@ -972,6 +1436,18 @@ export interface PluginHosts {
activity: PluginHostActivityApi;
/** Read only hosts this plugin manages. */
locks: PluginHostLocksApi;
/** Folder rows for the acting user. Needs hosts:write. */
folders: PluginHostFoldersApi;
}
export interface PluginHostFoldersApi {
/** Creates the folder row if missing and sets its color and icon. */
ensure: (
name: string,
style?: { color?: string | null; icon?: string | null },
) => Promise<void>;
/** Removes the folder row and subfolders once no host is left in them. */
removeEmpty: (name: string) => Promise<boolean>;
}
export interface PluginHostImportOptions {
@@ -1354,7 +1830,37 @@ export interface PluginNetwork {
) => Promise<boolean>;
}
/**
* A way to reach some hosts other than a plain TCP connection. `claims` is
* asked for every connection, so it must be quick. `open` returns a stream
* the SSH client runs over, like a relayed socket.
*/
/** The host a transport is asked about. Fields past port come from Termix 26.10.6. */
export interface PluginSshTransportHost {
id: number;
ip: string;
port: number;
/** Owner of the host row. */
userId?: string | null;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
localOnly?: boolean;
}
export interface PluginSshTransportProvider {
claims: (hostId: number, host?: PluginSshTransportHost) => boolean;
open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>;
}
export interface PluginSshTransports {
/** Needs ssh:transport. Removed on deactivate. */
provide: (provider: PluginSshTransportProvider) => () => void;
}
export interface PluginSsh {
/** Other ways to reach hosts, used by every connection to them. */
transports: PluginSshTransports;
/**
* Whether a TCP port answers through the host's jump host chain. Chains are
* shared and closed when idle. The acting user must be able to connect to
+2
View File
@@ -48,6 +48,8 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("db:core-refs", "high"),
entry("auth:provide", "high"),
entry("auth:policy", "high"),
entry("sync:provide", "high"),
entry("ssh:transport", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("ui:layout", "high"),
+11
View File
@@ -35,6 +35,17 @@ export interface ElectronAPI {
getAppVersion: () => Promise<string>;
setZoomFactor?: (factor: number) => void;
getPlatform: () => Promise<string>;
/** Whether the app-drawn title bar shows in this window. */
getTitleBarVisible?: () => Promise<boolean>;
/** Calls back when the title bar shows or hides; returns an unsubscribe. */
onTitleBarVisibleChange?: (
callback: (visible: boolean) => void,
) => () => void;
/** Keeps the window controls the system draws in the colors of the theme. */
setTitleBarOverlay?: (colors: {
color?: string;
symbolColor?: string;
}) => Promise<void>;
getSetting?: (key: string) => Promise<string | null | undefined>;
setSetting?: (key: string, value: string) => Promise<void>;
+120 -3
View File
@@ -206,6 +206,8 @@ export interface ShellApi {
* user reviews and saves it. Absent in some shells.
*/
openHostEditor?: (draft?: HostDraft) => void;
/** Opens the editor of a saved host. Termix 26.10.6+, absent in some shells. */
editHost?: (hostId: number) => void;
/** Opens a rail view in the left sidebar. */
openRailView: (id: string) => void;
/** Closes a rail view wherever it is shown. */
@@ -425,6 +427,11 @@ export interface RailItemContribution {
* Zero or nothing shows no badge.
*/
useBadge?: () => number | null | undefined;
/**
* A hook for a small status dot on the icon: "ok" (green), "warning" or
* "error". Nothing shows no dot. Layouts from Termix 26.10.6 draw it.
*/
useStatus?: () => "ok" | "warning" | "error" | null | undefined;
}
export interface PanelProps {
@@ -814,6 +821,66 @@ export interface SettingsComponentProps {
running: boolean;
}
/** What a companion starts with. Read from the plugin's own settings. */
export interface CompanionDefaults {
/** Open by itself the first time a matching tab shows. */
open?: boolean;
side?: "left" | "right" | "bottom";
/** Pixels of width, or height at the bottom. */
size?: number;
/** Show the edge button that opens and closes it. Default true. */
edgeButton?: boolean;
}
/** A panel a tab type can show beside it, for a picker. */
export interface CompanionInfo {
/** The id registerCompanionLayout's hidden and order lists use. */
id: string;
titleKey: string;
icon?: IconComponent;
}
/** How a host wants the panel bar beside a tab. */
export interface CompanionLayout {
/** False removes the bar and its panels. */
enabled?: boolean;
/** Where a panel opens until the user moves it. */
side?: "left" | "right" | "bottom";
/** Companion ids in bar order. */
order?: string[];
/** Companion ids left out. */
hidden?: string[];
}
export interface CompanionProps {
/** The tab it sits beside. */
tab: PluginTabRecord;
host?: PluginHostRecord;
/** The host in the shape the connection APIs take. */
sshHost?: Record<string, unknown>;
/** The tab's surface handle (see TabHandle), read at call time. */
getHandle: () => TabHandle | null;
side: "left" | "right" | "bottom";
/** False while the tab is in the background or the panel is closed. */
isVisible: boolean;
close: () => void;
setSide: (side: "left" | "right" | "bottom") => void;
}
export interface CompanionOptions {
icon?: IconComponent;
titleKey?: string;
order?: number;
/** Tab types it applies to. Any tab with a host when empty. */
tabTypes?: string[];
when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean;
/**
* A hook returning the starting state. It runs in the plugin's scope, so it
* can read the plugin's settings.
*/
useDefaults?: () => CompanionDefaults;
}
export type ActionHandler = (...args: never[]) => unknown;
export interface ActionSlotDefinition {
@@ -1068,6 +1135,26 @@ export interface TermixApp extends TermixAppInfo {
component: ComponentType<TabProps>,
options?: TabOptions,
) => Disposer;
/**
* A panel that sits beside any host tab the options match (a terminal, a
* desktop, ...), bound to that tab's host. The user opens, resizes and moves
* it; the tab keeps running underneath.
*/
registerCompanion: (
id: string,
component: ComponentType<CompanionProps>,
options?: CompanionOptions,
) => Disposer;
/**
* Lets a plugin decide, per host, how the panel bar looks on one tab type.
* Read from the host payload, so it must not call hooks.
*/
registerCompanionLayout: (
tabType: string,
resolve: (host: PluginHostRecord | undefined) => CompanionLayout,
) => Disposer;
/** Opens a companion on a tab, or closes it when it is showing. */
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
registerHostEditorSection: (
section: HostEditorSectionContribution,
) => Disposer;
@@ -1295,6 +1382,7 @@ export interface PluginHostBridge {
hostProtocols: (host: PluginHostRecord) => string[];
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
useHostActions: () => HostActionContribution[];
useCompanions: (tabType: string) => CompanionInfo[];
useActivityTypes: () => string[];
activityTarget: (type: string) => ActivityTargetInfo | undefined;
useExtensions: (pointId: string) => ExtensionContribution[];
@@ -1630,28 +1718,49 @@ export function usePluginUiPreferences<
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
}
type PanelGridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
const PANEL_GRID_SIZES: readonly string[] = [
"auto",
"1",
"2",
"3",
"4",
"5",
"6",
];
/**
* Grid or list and row density for one of this plugin's views, kept in its
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those
* keys in contributes.uiPresets so each preset level can pick a default.
* Grid or list, row density and cards per row for one of this plugin's
* views, kept in its Appearance area as `<prefix>viewMode`, `<prefix>density`
* and `<prefix>columns`. Declare those keys in contributes.uiPresets so each
* preset level can pick a default. Hand columns to ViewToggle and DataView.
*/
export function usePluginPanelView(prefix = ""): {
view: "grid" | "list";
density: "comfortable" | "compact";
compact: boolean;
columns: PanelGridSize;
setView: (view: "grid" | "list") => void;
setDensity: (density: "comfortable" | "compact") => void;
setColumns: (columns: PanelGridSize) => void;
} {
const { values, set } = usePluginUiPreferences();
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
const density =
values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
const saved = values[`${prefix}columns`];
const columns =
typeof saved === "string" && PANEL_GRID_SIZES.includes(saved)
? (saved as PanelGridSize)
: "auto";
return {
view,
density,
compact: density === "compact",
columns,
setView: (next) => set(`${prefix}viewMode`, next),
setDensity: (next) => set(`${prefix}density`, next),
setColumns: (next) => set(`${prefix}columns`, next),
};
}
@@ -1704,6 +1813,14 @@ export function useHostActions(): HostActionContribution[] {
return requireHost().useHostActions();
}
/**
* Every panel that can open beside a tab of this type, for a settings screen
* that lets the user hide or reorder them. Reactive.
*/
export function useCompanions(tabType: string): CompanionInfo[] {
return requireHost().useCompanions(tabType);
}
/**
* Recent-activity types every registered tab claims (registerTab's
* activityTypes), core's and plugins'. For a filter that lists what a user
+38 -13
View File
@@ -11,6 +11,9 @@
export type { Client, ClientChannel } from "ssh2";
import type { Client, ClientChannel } from "ssh2";
// Homebrew and OrbStack paths a non-interactive shell misses.
import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
export { EXTRA_PATH_DIRS };
export interface ExecResult {
stdout: string;
@@ -18,6 +21,30 @@ export interface ExecResult {
code: number | null;
}
/**
* Decodes raw command output. Strict UTF-8 first; when that fails, tries GBK,
* which is what a Chinese-locale Windows console sends (its bytes are almost
* never valid UTF-8) and keeps it only when it decodes without replacement
* characters. Anything else falls back to lossy UTF-8, the old behavior.
*/
export function decodeCommandOutput(buf: Buffer): string {
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
buf,
);
} catch {
// not valid UTF-8, try the legacy codepage
}
try {
// A Node built with small-ICU has no GBK decoder and throws here.
const gbk = new TextDecoder("gbk").decode(buf);
if (!gbk.includes("�")) return gbk;
} catch {
// expected - fall through to lossy UTF-8
}
return buf.toString("utf8");
}
/** Runs one non-interactive command over an existing ssh2 client. */
export function execCommand(
client: Client,
@@ -62,8 +89,10 @@ export function execCommand(
}
stream = _stream;
let stdout = "";
let stderr = "";
// Chunks are buffered and decoded once on close: a multi-byte character
// can be split across chunks, and charset detection needs it whole.
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let exitCode: number | null = null;
stream
@@ -72,11 +101,15 @@ export function execCommand(
settled = true;
exitCode = typeof code === "number" ? code : null;
cleanup();
resolve({ stdout, stderr, code: exitCode });
resolve({
stdout: decodeCommandOutput(Buffer.concat(stdout)),
stderr: decodeCommandOutput(Buffer.concat(stderr)),
code: exitCode,
});
}
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
stdout.push(data);
})
.on("error", (streamErr: Error) => {
if (!settled) {
@@ -89,7 +122,7 @@ export function execCommand(
if (stream.stderr) {
stream.stderr
.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
stderr.push(data);
})
.on("error", (stderrErr: Error) => {
if (!settled) {
@@ -114,14 +147,6 @@ export interface PlatformInfo {
osPrettyName: string | null;
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
* Extend PATH with their common install locations before probing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
const DOCKER_PROBE =
`command -v docker >/dev/null 2>&1` +
` || [ -S /var/run/docker.sock ]` +
@@ -0,0 +1,468 @@
/**
* Docker and Podman command builders and output parsers.
*
* Container ids and names are checked with CONTAINER_ID_RE before they go on
* a command line, so they need no quoting. Anything free form is quoted.
*/
import { HostScriptError } from "./text.js";
export type ContainerRuntime = "docker" | "podman";
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getRuntimeLabel(runtime: ContainerRuntime): string {
return runtime === "podman" ? "Podman" : "Docker";
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
export function containerCommand(
runtime: ContainerRuntime | undefined,
args: string,
isWindows = false,
): string {
const cli = `${normalizeContainerRuntime(runtime)} ${args}`;
// cmd and PowerShell don't understand the POSIX env prefix.
return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`;
}
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
export function isContainerId(value: unknown): value is string {
return (
typeof value === "string" &&
value.length <= 256 &&
CONTAINER_ID_RE.test(value)
);
}
function requireContainer(id: string): string {
if (!isContainerId(id))
throw new HostScriptError("That is not a valid container id");
return id;
}
export const CONTAINER_ACTIONS = [
"start",
"stop",
"restart",
"pause",
"unpause",
] as const;
export type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
export function listFormat(isWindows: boolean): string {
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
}
export function statsFormat(isWindows: boolean): string {
return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
}
/** One row of `ps --format listFormat()`. */
export interface ContainerRow {
id: string;
name: string;
image: string;
status: string;
state: string;
ports: string;
created: string;
}
/** CLI arguments, without the runtime, for each container command. */
export const dockerArgs = {
version: () => "--version",
ping: () => "ps",
list: (all: boolean, isWindows = false) =>
`ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`,
inspect: (id: string) => `inspect ${requireContainer(id)}`,
stats: (id: string, isWindows = false) =>
`stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`,
action: (action: ContainerAction, id: string) => {
if (!CONTAINER_ACTIONS.includes(action))
throw new HostScriptError("Unknown action");
return `${action} ${requireContainer(id)}`;
},
remove: (id: string, force: boolean) =>
`rm ${force ? "-f " : ""}${requireContainer(id)}`,
logs: (
id: string,
options: {
tail?: number;
timestamps?: boolean;
since?: string;
until?: string;
} = {},
) => {
let args = `logs ${requireContainer(id)}`;
if (options.tail && options.tail > 0)
args += ` --tail ${Math.floor(options.tail)}`;
if (options.timestamps) args += " --timestamps";
if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) {
args += ` --since ${options.since}`;
}
if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) {
args += ` --until ${options.until}`;
}
return `${args} 2>&1`;
},
states: () => `ps -a --format ${PS_STATE_FORMAT}`,
composeProjects: () => "compose ls -a --format json",
};
/** The version from `docker --version` or `podman --version`. */
export function parseRuntimeVersion(output: string): string {
return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown";
}
export type RuntimeProblem =
"NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR";
/** Sorts a failed `docker ps` into a reason the UI can explain. */
export function classifyRuntimeError(message: string): RuntimeProblem {
if (
/command not found|not found: (docker|podman)|No such file or directory/i.test(
message,
)
) {
return "NOT_INSTALLED";
}
if (message.includes("Cannot connect to the Docker daemon"))
return "DAEMON_NOT_RUNNING";
if (/permission denied/i.test(message)) return "PERMISSION_DENIED";
return "DOCKER_ERROR";
}
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
export function parseContainerList(output: string): Record<string, unknown>[] {
return output
.split("\n")
.filter((line) => line.trim())
.flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
export interface ContainerStats {
cpu: string;
memoryUsed: string;
memoryLimit: string;
memoryPercent: string;
netInput: string;
netOutput: string;
blockRead: string;
blockWrite: string;
pids: string;
}
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
export function parseContainerStats(output: string): ContainerStats {
const raw = JSON.parse(output.trim()) as Record<string, string>;
const split = (value: string | undefined) => {
const [first, second] = (value ?? "").split(" / ");
return [first?.trim() || "0B", second?.trim() || "0B"];
};
const [memoryUsed, memoryLimit] = split(raw.memory);
const [netInput, netOutput] = split(raw.netIO);
const [blockRead, blockWrite] = split(raw.blockIO);
return {
cpu: raw.cpu,
memoryUsed,
memoryLimit,
memoryPercent: raw.memoryPercent,
netInput,
netOutput,
blockRead,
blockWrite,
pids: raw.pids,
};
}
export interface ContainerState {
/** Docker's own state word: running, exited, restarting, ... */
state: string;
/** Health from the status text, when the image declares a healthcheck. */
unhealthy: boolean;
}
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
export function parseContainerStates(
output: string,
): Map<string, ContainerState> {
const states = new Map<string, ContainerState>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as {
name?: string;
state?: string;
status?: string;
};
if (!parsed.name) continue;
states.set(parsed.name, {
state: (parsed.state ?? "").toLowerCase(),
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
});
} catch {
// A partial line is not worth failing the whole poll over.
}
}
return states;
}
/**
* The events a pair of snapshots implies. A container missing from the
* previous snapshot counts as newly seen rather than started, so the first
* poll after a restart does not replay every running container.
*/
export function diffContainerStates(
previous: Map<string, ContainerState>,
current: Map<string, ContainerState>,
): Array<{ container: string; event: DockerEventName }> {
const events: Array<{ container: string; event: DockerEventName }> = [];
for (const [name, now] of current) {
const before = previous.get(name);
if (!before) continue;
if (before.state !== now.state) {
if (now.state === "exited")
events.push({ container: name, event: "exited" });
else if (now.state === "running")
events.push({ container: name, event: "started" });
else if (now.state === "restarting") {
events.push({ container: name, event: "restarting" });
}
}
// A container can go unhealthy while it stays up.
if (!before.unhealthy && now.unhealthy) {
events.push({ container: name, event: "unhealthy" });
}
}
return events;
}
export interface ContainerCounts {
running: number;
stopped: number;
unhealthy: number;
total: number;
}
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
export function countContainers(output: string): ContainerCounts {
const states = [...parseContainerStates(output).values()];
const running = states.filter((s) => s.state === "running").length;
return {
running,
stopped: states.length - running,
unhealthy: states.filter((s) => s.unhealthy).length,
total: states.length,
};
}
// Compose stacks
export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/;
export const COMPOSE_FILES = [
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
];
export const STACK_ACTIONS = [
"deploy",
"redeploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
"down",
"destroy",
] as const;
export type StackAction = (typeof STACK_ACTIONS)[number];
/** Actions that can target one service of a stack. */
export const STACK_SERVICE_ACTIONS: StackAction[] = [
"deploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
];
export type StackState =
"running" | "partial" | "stopped" | "paused" | "not-deployed";
export interface StackSummary {
name: string;
managed: boolean;
state: StackState;
running: number;
total: number;
composeFile: string | null;
}
export interface StackService {
service: string;
name: string;
id: string;
image: string;
state: string;
status: string;
health: string;
ports: string;
}
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
export function parseJsonLines(output: string): Record<string, unknown>[] {
const text = output.trim();
if (!text) return [];
if (text.startsWith("[")) {
try {
return JSON.parse(text) as Record<string, unknown>[];
} catch {
return [];
}
}
return text.split("\n").flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
const str = (value: unknown): string =>
typeof value === "string" ? value : "";
export function parseServices(output: string): StackService[] {
return parseJsonLines(output).map((row) => {
const publishers = Array.isArray(row.Publishers)
? (row.Publishers as Record<string, unknown>[])
: [];
const ports = publishers
.filter((p) => Number(p.PublishedPort) > 0)
.map((p) => `${p.PublishedPort}:${p.TargetPort}`)
.filter((value, index, all) => all.indexOf(value) === index)
.join(", ");
return {
service: str(row.Service),
name: str(row.Name),
id: str(row.ID),
image: str(row.Image),
state: str(row.State),
status: str(row.Status),
health: str(row.Health),
ports,
};
});
}
function stateOf(running: number, paused: number, total: number): StackState {
if (total === 0) return "not-deployed";
if (paused === total) return "paused";
if (running === total) return "running";
if (running > 0) return "partial";
return "stopped";
}
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
export function parseStackStatus(status: string): {
state: StackState;
running: number;
total: number;
} {
let running = 0;
let paused = 0;
let total = 0;
for (const part of status.split(",")) {
const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i);
if (!match) continue;
const count = Number(match[2]);
total += count;
if (match[1] === "running") running += count;
if (match[1] === "paused") paused += count;
}
return { state: stateOf(running, paused, total), running, total };
}
export function stackStateFromServices(services: StackService[]) {
const total = services.length;
const running = services.filter((s) => s.state === "running").length;
const paused = services.filter((s) => s.state === "paused").length;
return { state: stateOf(running, paused, total), running, total };
}
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
export function parseComposeProjects(output: string): StackSummary[] {
return parseJsonLines(output)
.flatMap((project) => {
const name = str(project.Name);
if (!STACK_NAME_RE.test(name)) return [];
return [
{
name,
managed: false,
...parseStackStatus(str(project.Status)),
composeFile: str(project.ConfigFiles).split(",")[0] || null,
},
];
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/** The compose commands one action runs, in order. */
export function composeArgsFor(
action: StackAction,
service: string | undefined,
options: { removeVolumes?: boolean },
): string[] {
const target = service ? ` ${service}` : "";
switch (action) {
case "deploy":
return [`up -d --remove-orphans${target}`];
case "redeploy":
return [`pull${target}`, `up -d --remove-orphans${target}`];
case "pull":
return [`pull${target}`];
case "start":
case "stop":
case "restart":
case "pause":
case "unpause":
return [`${action}${target}`];
case "down":
return ["down --remove-orphans"];
case "destroy":
return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`];
}
}
@@ -0,0 +1,15 @@
/**
* Host scripts: command builders and output parsers for Docker, Linux
* metrics, systemd, the journal and OpenRC.
*
* Pure TypeScript with no Node or ssh2 imports, so the same code runs in a
* plugin backend and in the mobile app. Run a script by sending it on stdin
* to the command from `scriptShell()`.
*/
export * from "./text.js";
export * from "./shell.js";
export * from "./docker.js";
export * from "./metrics.js";
export * from "./systemd.js";
export * from "./journal.js";
export * from "./openrc.js";
@@ -0,0 +1,242 @@
/** journalctl scripts and parsers. */
import { isUnit } from "./systemd.js";
import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js";
export const MAX_JOURNAL_LINES = 5000;
const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM";
export interface JournalQuery {
units: string[];
priority?: number;
since?: string;
until?: string;
boot?: string;
grep?: string;
lines: number;
kernel: boolean;
}
export interface JournalEntry {
/** Unix ms. */
time: number;
priority: number;
unit: string;
ident: string;
pid: string;
message: string;
}
export interface BootView {
offset: number;
id: string;
first: string;
last: string;
}
const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/;
const WORDS = ["today", "yesterday", "now"];
export function isJournalTime(value: string): boolean {
return (
RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value)
);
}
function str(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** Reads journal filters from a query string. Anything odd throws a HostScriptError. */
export function parseJournalQuery(
input: Record<string, unknown>,
): JournalQuery {
const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit];
const units = rawUnits.map(str).filter(Boolean);
for (const unit of units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
}
if (units.length > 20) throw new HostScriptError("Too many units");
const query: JournalQuery = {
units,
lines: 300,
kernel: input.kernel === "true" || input.kernel === true,
};
const priority = str(input.priority);
if (priority) {
const value = Number(priority);
if (!Number.isInteger(value) || value < 0 || value > 7) {
throw new HostScriptError("priority must be 0 to 7");
}
query.priority = value;
}
for (const key of ["since", "until"] as const) {
const value = str(input[key]);
if (!value) continue;
if (!isJournalTime(value))
throw new HostScriptError(`${key} is not a valid time`);
query[key] = value;
}
const boot = str(input.boot);
if (boot) {
if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot))
throw new HostScriptError("boot is not valid");
query.boot = boot;
}
const grep = typeof input.grep === "string" ? input.grep : "";
if (grep) {
if (grep.length > 200 || /[\r\n\0]/.test(grep)) {
throw new HostScriptError("The search is too long");
}
query.grep = grep;
}
const lines = Number(str(input.lines) || 300);
if (Number.isFinite(lines) && lines > 0) {
query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines));
}
return query;
}
/** journalctl arguments for a query, each one quoted. */
export function journalArgs(
query: Omit<JournalQuery, "lines"> & { lines?: number },
options: { follow?: boolean; useGrep?: boolean } = {},
): string {
const args = ["journalctl", "--no-pager", "-q", "-o", "json"];
args.push(`--output-fields=${FIELDS}`);
if (options.follow) args.push("-f");
if (query.lines) args.push("-n", String(Math.floor(query.lines)));
for (const unit of query.units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
args.push("-u", q(unit));
}
if (query.kernel) args.push("-k");
if (query.priority !== undefined)
args.push("-p", String(Math.floor(query.priority)));
if (query.since) args.push("--since", q(query.since));
if (query.until) args.push("--until", q(query.until));
if (query.boot) args.push("-b", q(query.boot));
if (query.grep && options.useGrep !== false) {
args.push("--case-sensitive=false", "-g", q(query.grep));
}
return args.join(" ");
}
export function journalScript(query: JournalQuery): string {
// Older journalctl builds have no -g. Then the search runs on our side instead.
const noGrep = journalArgs(
{ ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines },
{ useGrep: false },
);
return `
command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; }
${journalArgs(query)} 2>/tmp/termix-journal.$$
RC=$?
if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then
rm -f /tmp/termix-journal.$$
echo "@@TERMIX_NOGREP@@"
${noGrep}
exit 0
fi
cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$
exit $RC
`;
}
function messageOf(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return utf8Decode(value.filter((n): n is number => typeof n === "number"));
}
return value == null ? "" : String(value);
}
/** One `journalctl -o json` line, or null when it is not an entry. */
export function parseEntry(line: string): JournalEntry | null {
const text = line.trim();
if (!text.startsWith("{")) return null;
let raw: Record<string, unknown>;
try {
raw = JSON.parse(text) as Record<string, unknown>;
} catch {
return null;
}
const micros = Number(raw.__REALTIME_TIMESTAMP);
const priority = Number(raw.PRIORITY);
return {
time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0,
priority: Number.isInteger(priority) ? priority : 6,
unit: messageOf(raw._SYSTEMD_UNIT),
ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM),
pid: messageOf(raw._PID),
message: messageOf(raw.MESSAGE),
};
}
export function parseJournal(
output: string,
query: JournalQuery,
): JournalEntry[] {
const fallback = output.includes("@@TERMIX_NOGREP@@");
const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output;
let entries = body
.split("\n")
.map(parseEntry)
.filter((entry): entry is JournalEntry => entry !== null);
if (fallback && query.grep) {
const needle = query.grep.toLowerCase();
entries = entries
.filter((entry) => entry.message.toLowerCase().includes(needle))
.slice(-query.lines);
}
return entries;
}
export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`;
export function parseBoots(output: string): BootView[] {
const boots: BootView[] = [];
for (const line of output.split("\n")) {
const match =
/^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec(
line,
);
if (!match) continue;
boots.push({
offset: Number(match[1]),
id: match[2],
first: match[3],
last: match[4] ?? "",
});
}
return boots.reverse();
}
export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`;
export function parseUsage(output: string): string {
return /take up (\S+)/.exec(output)?.[1] ?? "";
}
const SIZE_RE = /^\d{1,6}(K|M|G)$/;
const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/;
export function vacuumScript(body: Record<string, unknown>): string {
const size = str(body.size);
const time = str(body.time);
if (!size && !time) throw new HostScriptError("Give a size or an age");
if (size && !SIZE_RE.test(size))
throw new HostScriptError("Size looks like 500M or 2G");
if (time && !AGE_RE.test(time))
throw new HostScriptError("Age looks like 7d or 4w");
const flags = [
size ? `--vacuum-size=${size}` : "",
time ? `--vacuum-time=${time}` : "",
]
.filter(Boolean)
.join(" ");
return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`;
}
@@ -0,0 +1,433 @@
/**
* Linux metrics: one awk sample of /proc for CPU, memory, load, network and
* processes, plus df parsing for disks.
*/
// One awk reads every /proc file itself, so a sample is one exec of awk instead
// of a shell plus six tools. Output sections are split by @@; each process line
// is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm.
const AWK = String.raw`BEGIN{f="/proc/stat";while((getline l<f)>0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l<f)>0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l<f;print l;close(f);f="/proc/uptime";getline l<f;print l;close(f);print "@@";f="/proc/net/dev";while((getline l<f)>0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i<ARGC;i++){f=ARGV[i];if((getline s<f)>0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`;
/** The live sample command. The page size never changes on a host, so pass it back once known. */
export const liveCmd = (pageBytes?: number) =>
"awk -v pg=" +
(pageBytes ?? '"$(getconf PAGESIZE)"') +
" '" +
AWK +
"' /proc/[0-9]*/stat";
export interface LiveSample {
/** The admin minimum between samples; the client never polls faster. */
minIntervalS?: number;
cpu: number | null;
cpuMix?: { user: number; system: number; iowait: number; steal: number };
cores: Array<number | null>;
coreIds: string[];
memTotalKb: number;
memCachedKb: number;
memFreeKb: number;
memUsedKb: number;
swapTotalKb: number;
swapUsedKb: number;
load: [number, number, number];
net: Array<{ name: string; rxBps: number | null; txBps: number | null }>;
count: number;
running: number;
procs: Array<{
pid: number;
name: string;
cpu: number;
memKb: number;
state: string;
ppid: number;
}>;
/** Seconds since boot. */
uptime?: number;
/** Total bytes received and sent on every interface but lo, since boot. */
netTotals?: { rx: number; tx: number };
}
/** What the next call needs from this one. */
export interface LivePrev {
at: number;
uptime: number;
pageBytes: number;
cpu: Map<string, { total: number; idle: number }>;
/** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */
cpuRaw?: number[];
/** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */
ticks: Map<string, number>;
net: Map<string, { rx: number; tx: number }>;
}
/** Total and idle ticks from a /proc/stat `cpu` line. */
export function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
const TOP_N = 60;
/** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
export function parseLive(
output: string,
prev: LivePrev | undefined,
at = Date.now(),
): { sample: LiveSample; next: LivePrev } {
const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] =
output.split("@@\n");
// Only the first 8 fields: guest and guest_nice are already inside user and nice.
const cpu = new Map<string, { total: number; idle: number }>();
let cpuRaw: number[] | undefined;
for (const l of cpuS.split("\n")) {
const name = l.split(/\s+/)[0];
if (!name.startsWith("cpu")) continue;
if (name === "cpu") cpuRaw = l.split(/\s+/).slice(1, 9).map(Number);
const c = parseCpuLine(
l
.replace(/^cpu\d*/, "cpu")
.split(/\s+/)
.slice(0, 9)
.join(" "),
);
if (c) cpu.set(name, c);
}
// Matched by name, so an offline core does not shift the ones after it.
const pct = (name: string) => {
const p = prev?.cpu.get(name);
const c = cpu.get(name);
if (!p || !c || c.total <= p.total) return null;
const used = c.total - p.total - (c.idle - p.idle);
return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100));
};
// Where the busy time went since the last sample, as percent of all ticks.
let cpuMix: LiveSample["cpuMix"];
if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) {
const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0));
const total = d.reduce((a, b) => a + b, 0);
if (total > 0 && d.every((v) => v >= 0)) {
const p = (n: number) => (n / total) * 100;
cpuMix = {
user: p(d[0] + d[1]),
system: p(d[2] + d[5] + d[6]),
iowait: p(d[4]),
steal: p(d[7]),
};
}
}
const mem: Record<string, number> = {};
for (const l of memS.split("\n")) {
const m = l.match(/^(\w+):\s+(\d+)/);
if (m) mem[m[1]] = Number(m[2]);
}
const [la = "", up = ""] = loadS.trim().split("\n");
const laParts = la.split(/\s+/).map(Number);
const uptime = Number(up.split(/\s+/)[0]) || 0;
const nets = new Map<string, { rx: number; tx: number }>();
for (const l of netS.split("\n")) {
const [name, rest] = l.split(":");
if (!rest || name.trim() === "lo") continue;
const f = rest.trim().split(/\s+/);
nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 });
}
// A reboot makes uptime go backwards, so rates start again.
const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0;
const rate = (now: number, before: number | undefined) =>
dt > 0 && before !== undefined && now >= before
? (now - before) / dt
: null;
const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096;
const pageKb = pageBytes / 1024;
const aggDelta =
prev && cpu.has("cpu") && prev.cpu.has("cpu")
? cpu.get("cpu")!.total - prev.cpu.get("cpu")!.total
: 0;
const cores = [...cpu.keys()].filter((k) => k !== "cpu");
const ncpu = Math.max(1, cores.length);
const ticks = new Map<string, number>();
const procs: LiveSample["procs"] = [];
for (const l of procS.split("\n")) {
const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t");
const name = rest.join("\t");
if (!name) continue;
const key = pid + ":" + start;
const tk = Number(t) || 0;
ticks.set(key, tk);
const before = prev?.ticks.get(key);
procs.push({
pid: Number(pid),
name,
cpu:
before !== undefined && aggDelta > 0 && tk >= before
? ((tk - before) / aggDelta) * ncpu * 100
: 0,
memKb: (Number(rss) || 0) * pageKb,
state: state || "?",
ppid: Number(ppid) || 0,
});
}
procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb);
// Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample.
if (!cpu.has("cpu") || !mem.MemTotal || !uptime) {
throw new Error("live sample unavailable: /proc data missing");
}
const memTotalKb = mem.MemTotal;
const swapTotalKb = mem.SwapTotal ?? 0;
let rxTotal = 0;
let txTotal = 0;
for (const c of nets.values()) {
rxTotal += c.rx;
txTotal += c.tx;
}
return {
sample: {
cpu: pct("cpu"),
cores: cores.map(pct),
coreIds: cores.map((k) => k.slice(3)),
cpuMix,
memTotalKb,
memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0),
memFreeKb: mem.MemFree ?? 0,
memUsedKb: memTotalKb - (mem.MemAvailable ?? 0),
swapTotalKb,
swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0),
load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0],
net: [...nets].map(([name, c]) => ({
name,
rxBps: rate(c.rx, prev?.net.get(name)?.rx),
txBps: rate(c.tx, prev?.net.get(name)?.tx),
})),
count: procs.length,
running: procs.filter((p) => p.state === "R").length,
procs: procs.slice(0, TOP_N),
uptime,
netTotals: { rx: rxTotal, tx: txTotal },
},
next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets },
};
}
// Disks
const DARWIN_DATA_MOUNT = "/System/Volumes/Data";
const PSEUDO_FS_RE =
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
/** GNU df sizes in KiB, without the header. */
export const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
/** GNU df sizes as short human text, without the header. */
export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
export interface DfRow {
filesystem: string;
type: string;
mount: string;
parts: string[];
}
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
export interface MonitoredMount {
path: string;
label?: string;
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
/**
* Parses `df -T -P` style output: Filesystem, Type, then the size columns,
* with Mounted-on last. Pseudo filesystems are left out.
*/
export function parseDfLines(output: string): DfRow[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
return {
filesystem: parts[0] || "",
type: parts[1] || "",
mount: parts[6] || "",
parts,
};
})
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
}
/** The most used real filesystem in a `df -TkP` row set. */
export function findWorstMountIndex(bytesRows: DfRow[]): {
index: number;
usedBytes: number;
totalBytes: number;
} {
let worstIndex = -1;
let worstUsedBytes = -1;
let worstTotalBytes = 0;
bytesRows.forEach((row, index) => {
const totalBytes = Number(row.parts[2]);
const usedBytes = Number(row.parts[3]);
if (
!Number.isFinite(totalBytes) ||
!Number.isFinite(usedBytes) ||
totalBytes <= 0
) {
return;
}
const usedRatio = usedBytes / totalBytes;
const worstRatio =
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
if (usedRatio > worstRatio) {
worstIndex = index;
worstUsedBytes = usedBytes;
worstTotalBytes = totalBytes;
}
});
return {
index: worstIndex,
usedBytes: worstUsedBytes,
totalBytes: worstTotalBytes,
};
}
/**
* Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
* `df -hT -P` human rows into one list. Human rows only supply the display
* text, matched by mount point so a mismatched row count can't shift them.
*/
export function buildFilesystemList(
bytesRows: DfRow[],
humanRows: DfRow[],
blockSizeBytes = 1,
): DiskFilesystem[] {
const aligned = humanRows.length === bytesRows.length;
return bytesRows
.map((row, index) => {
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
const humanRow = aligned
? humanRows[index]
: humanRows.find((h) => h.mount === row.mount);
const percent = Number.isFinite(usedBytes)
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
: null;
return {
filesystem: row.filesystem,
type: row.type,
mount: row.mount,
percent: toFixedNum(percent, 0),
usedHuman: humanRow?.parts[3] || null,
totalHuman: humanRow?.parts[2] || null,
availableHuman: humanRow?.parts[4] || null,
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
totalBytes,
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
};
})
.filter((fs): fs is DiskFilesystem => fs !== null);
}
/**
* The headline disk is the root filesystem. On macOS "/" is the small sealed
* system volume, so the data volume wins there. With no root mount the most
* used one is shown.
*/
export function selectPrimaryFilesystem(
filesystems: DiskFilesystem[],
): DiskFilesystem | null {
if (filesystems.length === 0) return null;
const root =
filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ??
filesystems.find((fs) => fs.mount === "/");
if (root) return root;
let best = filesystems[0];
for (const fs of filesystems) {
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
if (ratio > bestRatio) best = fs;
}
return best;
}
/**
* Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
* ("nfs" matches nfs and nfs4), so network shares can be hidden.
*/
export function filterExcludedFilesystems(
filesystems: DiskFilesystem[],
excludedMounts?: string[] | null,
): DiskFilesystem[] {
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
const normalized = excludedMounts
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
if (normalized.length === 0) return filesystems;
return filesystems.filter((fs) => {
const mount = fs.mount.toLowerCase();
const type = fs.type.toLowerCase();
return !normalized.some(
(entry) => mount === entry || (type && type.includes(entry)),
);
});
}
export function mergeMonitoredFilesystems(
filesystems: DiskFilesystem[],
monitored: MonitoredMount[],
customFilesystems: Array<DiskFilesystem | null>,
): DiskFilesystem[] {
const result = [...filesystems];
monitored.forEach((entry, index) => {
const path = entry.path.trim();
const custom = customFilesystems[index];
if (!path || !custom) return;
const existing = result.find((fs) => fs.mount === path);
if (existing) {
existing.label = entry.label?.trim() || undefined;
return;
}
result.push({
...custom,
mount: path,
label: entry.label?.trim() || undefined,
});
});
return result;
}
/** Host name, OS, kernel and arch in one exec, as key=value lines. */
export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`;
@@ -0,0 +1,94 @@
/** OpenRC scripts, with states mapped onto the words systemd uses. */
import { isOpenRcName, type UnitAction, type UnitSummary } from "./systemd.js";
import { HostScriptError, shellQuote as q } from "./text.js";
export const OPENRC_LIST_SCRIPT = `
rc-status --servicelist 2>/dev/null
echo "@@TERMIX_RUNLEVELS@@"
rc-update show -v 2>/dev/null
exit 0
`;
function activeOf(state: string): { active: string; sub: string } {
switch (state) {
case "started":
return { active: "active", sub: "running" };
case "crashed":
return { active: "failed", sub: "crashed" };
case "starting":
return { active: "activating", sub: "starting" };
case "stopping":
return { active: "deactivating", sub: "stopping" };
case "inactive":
case "hotplugged":
return { active: "inactive", sub: state };
default:
return { active: "inactive", sub: state || "stopped" };
}
}
export function parseOpenRcList(output: string): UnitSummary[] {
const [list = "", levels = ""] = output.split("@@TERMIX_RUNLEVELS@@");
const units = new Map<string, UnitSummary>();
for (const line of list.split("\n")) {
const match = /^\s*(\S+)\s+\[\s*([a-z ]+?)\s*\]/.exec(line);
if (!match) continue;
const { active, sub } = activeOf(match[2]);
units.set(match[1], {
name: match[1],
type: "service",
load: "loaded",
active,
sub,
description: "",
fileState: "disabled",
preset: "",
});
}
for (const line of levels.split("\n")) {
const match = /^\s*(\S+)\s*\|\s*(.*)$/.exec(line);
if (!match) continue;
const runlevels = match[2].trim();
const known = units.get(match[1]) ?? {
name: match[1],
type: "service",
load: "loaded",
active: "inactive",
sub: "stopped",
description: "",
fileState: "disabled",
preset: "",
};
known.fileState = runlevels ? "enabled" : "disabled";
known.description = runlevels ? `Runlevels: ${runlevels}` : "";
units.set(match[1], known);
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function requireName(name: string): string {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
export function openRcActionScript(name: string, action: UnitAction): string {
const s = q(requireName(name));
switch (action) {
case "start":
case "stop":
case "restart":
return `rc-service ${s} ${action} 2>&1 || { echo "SCRIPT_ERR:rc-service ${action} failed"; exit 1; }\n`;
case "enable":
return `rc-update add ${s} default 2>&1 || { echo "SCRIPT_ERR:rc-update add failed"; exit 1; }\n`;
case "disable":
return `rc-update del ${s} 2>&1 || { echo "SCRIPT_ERR:rc-update del failed"; exit 1; }\n`;
default:
throw new HostScriptError("OpenRC can't do that", 400);
}
}
export function openRcStatusScript(name: string): string {
const s = q(requireName(name));
return `rc-service ${s} status 2>&1; rc-update show -v 2>/dev/null | grep -E '^ *'${s}' *\\|'; exit 0\n`;
}
@@ -0,0 +1,117 @@
/**
* How a script reaches the host: the command line runs a shell that reads
* the script from stdin (`bash -s`), so names and values in a script are
* never parsed out of argv.
*/
import { shellQuote, toBase64 } from "./text.js";
// systemctl and friends often live in sbin, which a normal login skips.
// TZ=UTC so tools print every time in UTC, which parses the same anywhere.
const ENV = "SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC";
/** Puts sbin on PATH and sets a plain, UTC environment. */
export const SCRIPT_PRELUDE = `export PATH="$PATH:/usr/sbin:/sbin:/usr/local/sbin"; export ${ENV}; `;
const SUDO = `sudo -n env PATH="$PATH" ${ENV}`;
/**
* - `plain`: the login user.
* - `auto`: root when it can be had without a password, else the login user.
* - `root`: root or passwordless sudo, fails on anything else.
* - `root-only`: root logins only, for hosts where sudo is turned off.
*/
export type ScriptShellMode = "plain" | "auto" | "root" | "root-only";
const ERR = "SCRIPT_ERR:";
/** The command line that runs a script sent on stdin. */
export function scriptShell(mode: ScriptShellMode): string {
switch (mode) {
case "plain":
return `${SCRIPT_PRELUDE}exec bash -s`;
case "auto":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -s; else exec bash -s; fi`;
case "root":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else exec ${SUDO} bash -s; fi`;
case "root-only":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else echo "${ERR}This needs root, or turn on Use sudo for this host"; exit 1; fi`;
}
}
/**
* Runs a script as root with a sudo password. The command is `plain`; all of
* the returned stdin goes to the host. The password is read by the first
* line into a shell variable and piped to `sudo -S`, so it is never in argv,
* never in the environment and never run as a command. Root logins and
* passwordless sudo skip the password. The script itself travels as base64.
*/
export function passwordSudoScript(
script: string,
password: string,
): { command: string; stdin: string } {
if (/[\r\n\0]/.test(password)) {
throw new Error("A sudo password can't contain a line break");
}
const run = `bash -c "$TERMIX_S"`;
const stdin = `IFS= read -r TERMIX_PW
${password}
TERMIX_S=$(printf '%s' ${shellQuote(toBase64(script))} | base64 -d)
if [ "$(id -u)" = "0" ]; then unset TERMIX_PW; exec ${run}
elif sudo -n true 2>/dev/null; then unset TERMIX_PW; exec ${SUDO} ${run}
else P=$TERMIX_PW; unset TERMIX_PW; printf '%s\\n' "$P" | sudo -k -S -p '' env PATH="$PATH" ${ENV} ${run}; exit $?
fi
`;
return { command: scriptShell("plain"), stdin };
}
/** sudo asked for a password or a terminal it did not get. */
export const SUDO_REFUSED_RE =
/password is required|a terminal is required|no tty present/i;
/** sudo got a password and it was wrong. */
export const SUDO_WRONG_RE =
/incorrect password|sorry, try again|no password was provided/i;
/** The user may not use sudo at all. */
export const NOT_SUDOER_RE =
/is not in the sudoers file|not allowed to run sudo/i;
/**
* Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a
* non zero exit to explain a failure.
*/
export function scriptError(stdout: string): string {
return stdout
.split("\n")
.map((line) => {
if (line.startsWith(ERR)) return line.slice(ERR.length);
if (line.startsWith("SERVICES_ERR:")) return line.slice(13);
return null;
})
.filter((line): line is string => line !== null)
.join("\n")
.trim();
}
/** A line a script prints to explain why it stops. */
export function errLine(message: string): string {
return `echo ${shellQuote(ERR + message)}`;
}
/**
* Runs a long command in the background and stops it once stdin closes,
* which happens when the SSH channel ends. A pty can't be trusted for this:
* sudo keeps its own and the command would outlive the viewer.
*/
export function watchdog(command: string): string {
return `${command} &
P=$!
trap 'kill $P 2>/dev/null' EXIT
cat >/dev/null
`;
}
/** The wrapper a streamed command runs in, as one shell command line. */
export function streamCommand(script: string, sudo: boolean): string {
const quoted = shellQuote(`${SCRIPT_PRELUDE}${script}`);
return sudo
? `if [ "$(id -u)" = "0" ]; then exec bash -c ${quoted}; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -c ${quoted}; else exec bash -c ${quoted}; fi`
: `exec bash -c ${quoted}`;
}
@@ -0,0 +1,389 @@
/**
* systemd unit scripts and parsers, plus init system detection.
* Every script is meant for `scriptShell()`, sent on stdin.
*/
import { HostScriptError, parseKeys, shellQuote as q } from "./text.js";
export const UNIT_TYPES = [
"service",
"socket",
"timer",
"target",
"mount",
"automount",
"path",
"swap",
"slice",
"scope",
"device",
] as const;
export type UnitType = (typeof UNIT_TYPES)[number];
// Backslash is allowed for escaped names like dev-disk-by\x2duuid.
const UNIT_RE = new RegExp(
`^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`,
);
const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/;
export function isUnit(name: unknown): name is string {
return typeof name === "string" && UNIT_RE.test(name);
}
export function unitType(name: string): UnitType {
return name.slice(name.lastIndexOf(".") + 1) as UnitType;
}
/** Units a file may be written for. Templates and device style names are out. */
export function isWritableUnitName(name: string): boolean {
return (
isUnit(name) &&
!name.includes("\\") &&
["service", "socket", "timer", "target", "path", "mount"].includes(
unitType(name),
)
);
}
export function isOpenRcName(name: unknown): name is string {
return typeof name === "string" && OPENRC_RE.test(name);
}
export function requireUnit(name: unknown): string {
if (!isUnit(name)) throw new HostScriptError("That is not a valid unit name");
return name;
}
export type InitSystem = "systemd" | "openrc" | "none";
/** A unit name or, on OpenRC, a service name. */
export function requireService(name: unknown, init: string): string {
if (init === "openrc") {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
return requireUnit(name);
}
export interface UnitSummary {
name: string;
type: string;
load: string;
active: string;
sub: string;
description: string;
/** enabled, disabled, static, masked and so on. Empty when unknown. */
fileState: string;
preset: string;
}
export interface UnitDetail {
name: string;
props: Record<string, string>;
status: string;
}
export interface TimerView {
name: string;
description: string;
active: string;
fileState: string;
/** Unix ms, or null when not set. */
next: number | null;
last: number | null;
activates: string;
schedule: string[];
}
export const UNIT_ACTIONS = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
"enable-now",
"disable-now",
"mask",
"unmask",
"reset-failed",
"revert",
] as const;
export type UnitAction = (typeof UNIT_ACTIONS)[number];
/** Actions OpenRC can do. */
export const OPENRC_ACTIONS: readonly UnitAction[] = [
"start",
"stop",
"restart",
"enable",
"disable",
];
export const DETECT_SCRIPT = `
INIT=none
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd
elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi
echo "init=$INIT"
if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi
if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi
echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"
echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"
echo "uid=$(id -u)"
if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi
if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi
if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi
exit 0
`;
export interface Detected {
init: InitSystem;
version: string;
os: string;
hostname: string;
root: boolean;
journal: boolean;
failed: number;
}
export function parseDetect(output: string): Detected {
const keys = parseKeys(output);
const init =
keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none";
return {
init,
version: keys.version ?? "",
os: keys.os ?? "",
hostname: keys.hostname ?? "",
root: keys.uid === "0",
journal: keys.journal === "1",
failed: Number(keys.failed) || 0,
};
}
/** Types the list can show. */
export const LIST_TYPES = [
"service",
"socket",
"timer",
"target",
"path",
"mount",
] as const;
export type ListType = (typeof LIST_TYPES)[number] | "all";
export function listUnitsScript(type: ListType): string {
const types = type === "all" ? LIST_TYPES.join(",") : type;
if (type !== "all" && !LIST_TYPES.includes(type))
throw new HostScriptError("Unknown unit type");
return `
systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
echo "@@TERMIX_FILES@@"
systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
exit 0
`;
}
/** Merges list-units and list-unit-files. Template units are left out. */
export function parseUnitList(output: string): UnitSummary[] {
const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@");
const units = new Map<string, UnitSummary>();
for (const raw of loaded.split("\n")) {
const line = raw.replace(/^[●*\s]+/, "").trimEnd();
if (!line) continue;
const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
if (!match) continue;
const [, name, load, active, sub, description] = match;
if (!name.includes(".") || name.includes("@.")) continue;
units.set(name, {
name,
type: unitType(name),
load,
active,
sub,
description: description.trim(),
fileState: "",
preset: "",
});
}
for (const raw of files.split("\n")) {
const line = raw.trim();
if (!line) continue;
const [name, state = "", preset = ""] = line.split(/\s+/);
if (!name || !name.includes(".") || name.includes("@.")) continue;
const known = units.get(name);
const cleanPreset = preset === "-" ? "" : preset;
if (known) {
known.fileState = state;
known.preset = cleanPreset;
} else {
units.set(name, {
name,
type: unitType(name),
load: state === "masked" ? "masked" : "unloaded",
active: "inactive",
sub: "dead",
description: "",
fileState: state,
preset: cleanPreset,
});
}
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export const DETAIL_PROPS = [
"Id",
"Description",
"LoadState",
"ActiveState",
"SubState",
"UnitFileState",
"UnitFilePreset",
"Result",
"MainPID",
"ExecMainStartTimestamp",
"ActiveEnterTimestamp",
"InactiveEnterTimestamp",
"MemoryCurrent",
"MemoryPeak",
"CPUUsageNSec",
"TasksCurrent",
"NRestarts",
"FragmentPath",
"DropInPaths",
"Requires",
"Wants",
"After",
"WantedBy",
"TriggeredBy",
"Triggers",
"User",
"Group",
"Restart",
"Type",
"ExecStart",
"CanStart",
"CanStop",
"CanReload",
] as const;
export function detailScript(unit: string): string {
const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" ");
return `
systemctl show --no-pager ${props} -- ${q(requireUnit(unit))}
echo "@@TERMIX_STATUS@@"
systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1
exit 0
`;
}
export function parseDetail(unit: string, output: string): UnitDetail {
const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@");
return { name: unit, props: parseKeys(props), status: status.trim() };
}
const VERBS: Record<UnitAction, string> = {
start: "start",
stop: "stop",
restart: "restart",
reload: "reload",
enable: "enable",
disable: "disable",
"enable-now": "enable --now",
"disable-now": "disable --now",
mask: "mask",
unmask: "unmask",
"reset-failed": "reset-failed",
revert: "revert",
};
/** The systemctl call for an action. Prints `state=` after it. */
export function actionScript(unit: string, action: UnitAction): string {
const verb = VERBS[action];
if (!verb) throw new HostScriptError("Unknown action");
const u = q(requireUnit(unit));
return `
systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; }
echo "state=$(systemctl is-active -- ${u} 2>/dev/null)"
`;
}
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
export function parseSystemdTime(value: string | undefined): number | null {
if (!value) return null;
const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value);
if (!match) return null;
const [, y, mo, d, h, mi, s] = match.map(Number);
return Date.UTC(y, mo - 1, d, h, mi, s);
}
export const TIMERS_SCRIPT = `
UNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u)
[ -n "$UNITS" ] || exit 0
systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS
exit 0
`;
/** Reads systemctl show output for many units, split by blank lines. */
export function parseShowBlocks(output: string): Array<Record<string, string>> {
const blocks: Array<Record<string, string>> = [];
for (const block of output.split(/\n\s*\n/)) {
const lines = block.split("\n").filter((line) => line.includes("="));
if (!lines.length) continue;
// A unit can have several TimersCalendar lines; keep all of them.
const keys: Record<string, string> = {};
for (const line of lines) {
const eq = line.indexOf("=");
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
keys[key] = key in keys ? `${keys[key]}\n${value}` : value;
}
blocks.push(keys);
}
return blocks;
}
function schedules(value: string | undefined): string[] {
if (!value) return [];
const out: string[] = [];
for (const line of value.split("\n")) {
const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line);
if (match) out.push(`${match[1]}=${match[2]}`);
}
return out;
}
export function parseTimers(output: string): TimerView[] {
return parseShowBlocks(output)
.filter((keys) => keys.Id?.endsWith(".timer"))
.map((keys) => ({
name: keys.Id,
description: keys.Description ?? "",
active: keys.ActiveState ?? "",
fileState: keys.UnitFileState ?? "",
next: parseSystemdTime(keys.NextElapseUSecRealtime),
last: parseSystemdTime(keys.LastTriggerUSec),
activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "",
schedule: [
...schedules(keys.TimersCalendar),
...schedules(keys.TimersMonotonic),
],
}))
.sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity));
}
/** One line per unit from systemctl is-active, in the order asked. */
export function isActiveScript(units: string[]): string {
return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`;
}
export function parseIsActive(
units: string[],
output: string,
): Map<string, string> {
const lines = output.split("\n").map((line) => line.trim());
const out = new Map<string, string>();
units.forEach((unit, i) => out.set(unit, lines[i] || "unknown"));
return out;
}
@@ -0,0 +1,118 @@
/**
* Quoting, encoding and the error type the host scripts share.
*
* No Node APIs here: this entry also runs in the mobile app.
*/
/** A bad input to a host script builder. `status` is the HTTP status to answer with. */
export class HostScriptError extends Error {
constructor(
message: string,
readonly status = 400,
readonly extra: Record<string, unknown> = {},
) {
super(message);
this.name = "HostScriptError";
}
}
/** Wraps a value in single quotes for a POSIX shell. */
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
// Written by hand because some JS engines (Hermes) have no TextDecoder.
/** UTF-8 bytes of a string. */
export function utf8Encode(text: string): Uint8Array {
const out: number[] = [];
for (const char of text) {
const cp = char.codePointAt(0) ?? 0xfffd;
if (cp < 0x80) out.push(cp);
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 63));
else if (cp < 0x10000) {
out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63));
} else {
out.push(
0xf0 | (cp >> 18),
0x80 | ((cp >> 12) & 63),
0x80 | ((cp >> 6) & 63),
0x80 | (cp & 63),
);
}
}
return Uint8Array.from(out);
}
/** A string from UTF-8 bytes. Bad sequences become U+FFFD. */
export function utf8Decode(bytes: ArrayLike<number>): string {
let out = "";
let i = 0;
while (i < bytes.length) {
const b = bytes[i];
let need = 0;
let cp = 0;
if (b < 0x80) cp = b;
else if (b >= 0xc2 && b < 0xe0) [need, cp] = [1, b & 31];
else if (b >= 0xe0 && b < 0xf0) [need, cp] = [2, b & 15];
else if (b >= 0xf0 && b < 0xf5) [need, cp] = [3, b & 7];
else {
out += "\ufffd";
i++;
continue;
}
let j = 1;
for (; j <= need; j++) {
const next = bytes[i + j];
if (next === undefined || (next & 0xc0) !== 0x80) break;
cp = (cp << 6) | (next & 63);
}
const tooSmall = [0, 0x80, 0x800, 0x10000][need];
if (
j <= need ||
cp < tooSmall ||
cp > 0x10ffff ||
(cp >= 0xd800 && cp < 0xe000)
) {
out += "\ufffd";
i += Math.max(1, j);
continue;
}
out += String.fromCodePoint(cp);
i += need + 1;
}
return out;
}
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/** Base64 of the UTF-8 bytes of a string, for `base64 -d` on the host. */
export function toBase64(text: string): string {
const bytes = utf8Encode(text);
let out = "";
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i];
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
const n = (a << 16) | (b << 8) | c;
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : "=";
out += i + 2 < bytes.length ? B64[n & 63] : "=";
}
return out;
}
/** Reads key=value lines. Only the first = splits a line. */
export function parseKeys(output: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of output.split("\n")) {
const eq = line.indexOf("=");
if (eq > 0) out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return out;
}
/** Trims output and keeps it short enough to show. */
export function scrub(text: string, max = 600): string {
return text.trim().slice(0, max);
}
+157 -1
View File
@@ -37,7 +37,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.7.0";
export const PLUGIN_API_VERSION = "1.13.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -526,6 +526,36 @@ export interface PluginContributions {
layouts?: PluginLayoutContribution[];
/** Themes. Needs the ui:theme capability. */
themes?: PluginThemeContribution[];
/** How the plugin shows in the Termix mobile app. Plugin API 1.13. */
mobile?: PluginMobileContribution;
}
/**
* - "webview": the app shows the plugin's UI from the server in a WebView.
* - "native": the app has its own screens for this plugin.
* - "none": nothing to show in the app.
*/
export const MOBILE_MODES = ["webview", "native", "none"] as const;
export type PluginMobileMode = (typeof MOBILE_MODES)[number];
export const MOBILE_SIZES = ["phone", "tablet"] as const;
export type PluginMobileSize = (typeof MOBILE_SIZES)[number];
export interface PluginMobileContribution {
mode: PluginMobileMode;
/**
* webview: a bundle built for the app, in the same folder as the frontend
* bundle. Unset means the normal frontend runs in a phone sized frame.
*/
entry?: string;
/** webview: screen sizes the UI works at. Defaults to both. */
sizes?: PluginMobileSize[];
/** webview: tab, panel or card ids the app offers. Defaults to every tab. */
views?: string[];
/** native: the app feature that stands in for it. Defaults to the plugin id. */
feature?: string;
/** i18n key in the plugin's namespace, or plain text, shown in the app. */
note?: string;
}
/** A field a protocol login carries besides username and password. */
@@ -765,6 +795,7 @@ const ALLOWED_CONTRIBUTES = new Set([
"protocols",
"layouts",
"themes",
"mobile",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -946,10 +977,135 @@ export function validateManifest(manifest: unknown): string[] {
typeof m.id === "string" ? m.id : undefined,
errors,
);
if (isPlainObject(m.contributes)) {
validateMobile(
m.contributes,
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
errors,
);
}
return errors;
}
function folderOf(file: string): string {
const parts = file.split("/").filter((part) => part !== "" && part !== ".");
return parts.slice(0, -1).join("/");
}
function validateMobile(
contributes: Record<string, unknown>,
frontend: string,
errors: string[],
): void {
const mobile = contributes.mobile;
if (mobile === undefined) return;
const where = "contributes.mobile";
if (!isPlainObject(mobile)) {
errors.push(`Field "${where}" must be an object`);
return;
}
rejectUnknown(
mobile,
["mode", "entry", "sizes", "views", "feature", "note"],
`"${where}"`,
errors,
);
const mode = mobile.mode;
if (!(MOBILE_MODES as readonly unknown[]).includes(mode)) {
errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`);
return;
}
for (const key of ["entry", "sizes", "views"]) {
if (mobile[key] !== undefined && mode !== "webview") {
errors.push(`${where}.${key} is only for mode "webview"`);
}
}
if (mobile.feature !== undefined && mode !== "native") {
errors.push(`${where}.feature is only for mode "native"`);
}
if (
mobile.entry !== undefined &&
requireString(mobile.entry, `${where}.entry`, errors)
) {
const before = errors.length;
requireRelativePath(mobile.entry, `${where}.entry`, errors);
if (!mobile.entry.endsWith(".js")) {
errors.push(`${where}.entry must be a .js file`);
} else if (
errors.length === before &&
folderOf(mobile.entry) !== folderOf(frontend)
) {
// Core only serves the folder the frontend bundle sits in.
errors.push(
`${where}.entry must sit in the same folder as the frontend bundle (${frontend})`,
);
}
}
if (mobile.sizes !== undefined) {
const sizes = mobile.sizes;
if (!Array.isArray(sizes) || sizes.length === 0) {
errors.push(`${where}.sizes must be a non-empty array`);
} else {
const seen = new Set<unknown>();
for (const size of sizes) {
if (!(MOBILE_SIZES as readonly unknown[]).includes(size)) {
errors.push(
`${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`,
);
} else if (seen.has(size)) {
errors.push(`${where}.sizes duplicates "${String(size)}"`);
}
seen.add(size);
}
}
}
if (mobile.views !== undefined) {
const views = mobile.views;
if (!Array.isArray(views) || views.length === 0) {
errors.push(`${where}.views must be a non-empty array`);
} else {
const declared = new Set<string>();
for (const field of ["tabs", "panels", "dashboardCards"]) {
const list = contributes[field];
if (!Array.isArray(list)) continue;
for (const view of list) {
if (isPlainObject(view) && typeof view.id === "string") {
declared.add(view.id);
}
}
}
const seen = new Set<unknown>();
for (const view of views) {
if (typeof view !== "string" || !declared.has(view)) {
errors.push(
`${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`,
);
} else if (seen.has(view)) {
errors.push(`${where}.views duplicates "${view}"`);
}
seen.add(view);
}
}
}
if (
mobile.feature !== undefined &&
(typeof mobile.feature !== "string" ||
!LOOK_ID_PATTERN.test(mobile.feature))
) {
errors.push(
`${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
);
}
if (mobile.note !== undefined) {
requireString(mobile.note, `${where}.note`, errors);
}
}
function validateFeatures(features: unknown, errors: string[]): void {
if (features === undefined) return;
if (!Array.isArray(features)) {
+33 -1
View File
@@ -68,7 +68,7 @@ import type {
PluginSshKeyCredential,
PluginSshKeyCredentialInput,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginExternalClientRequest,
@@ -104,6 +104,8 @@ export interface FakeScheduledJob {
export interface FakeContextOptions {
pluginId?: string;
/** What ctx.sync.host answers, for a sync provider's tests. */
syncHost?: Partial<PluginSyncHost>;
manifest?: Partial<PluginManifest>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
@@ -756,6 +758,12 @@ export function createFakeContext(
registerEntity: (entity) => {
syncEntities.push(entity);
},
provide: () => () => {},
get host() {
if (!options.syncHost)
throw new Error("Pass syncHost to use ctx.sync.host");
return options.syncHost as PluginSyncHost;
},
},
registry: {
@@ -1062,6 +1070,10 @@ export function createFakeContext(
};
},
},
folders: {
ensure: async () => {},
removeEmpty: async () => true,
},
locks: {
provide: (provider) => {
hostLockProviders.push(provider);
@@ -1140,6 +1152,7 @@ export function createFakeContext(
},
ssh: {
transports: { provide: () => () => {} },
probe: async (hostId, probeOptions) =>
options.reachable?.(hostId, probeOptions?.port) ?? false,
connect: async (host, connectOptions) => {
@@ -1582,6 +1595,8 @@ export function createFakeContext(
export interface MockContextOptions {
pluginId?: string;
/** What ctx.sync.host answers, for a sync provider's tests. */
syncHost?: Partial<PluginSyncHost>;
manifest?: Partial<PluginManifest>;
/** Capabilities the plugin is granted. Anything else throws. */
capabilities?: string[];
@@ -1744,6 +1759,7 @@ export function createMockCtx(
const base = createFakeContext({
pluginId: options.pluginId,
syncHost: options.syncHost,
actor: options.actor,
settings: options.settings,
coreSettings: options.coreSettings,
@@ -1996,6 +2012,16 @@ export function createMockCtx(
return ctx.hosts.activity.provide(provider);
},
},
folders: {
ensure: async (name, style) => {
require("hosts:write");
return ctx.hosts.folders.ensure(name, style);
},
removeEmpty: async (name) => {
require("hosts:write");
return ctx.hosts.folders.removeEmpty(name);
},
},
locks: {
provide: (provider) => {
require("hosts:lock");
@@ -2029,6 +2055,12 @@ export function createMockCtx(
ssh: {
...ctx.ssh,
transports: {
provide: (provider) => {
require("ssh:transport");
return ctx.ssh.transports.provide(provider);
},
},
probe: async (hostId, probeOptions) => {
require("ssh:connect");
return ctx.ssh.probe(hostId, probeOptions);
+1
View File
@@ -139,6 +139,7 @@ const offline = () =>
}),
hostProtocols: () => ["ssh"],
useHostActions: () => [],
useCompanions: () => [],
useActivityTypes: () => [],
activityTarget: () => undefined,
useExtensions: () => [],
+9 -21
View File
@@ -80,7 +80,7 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
DEFAULT_FOLDER_ICON (value): "folder"
DashboardCardId (type): DashboardCardId
DataColumn (type): DataColumn<T>
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
DefaultOverrides (type): DefaultOverrides
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
DesktopUpdateMode (type): DesktopUpdateMode
@@ -131,6 +131,7 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan
GridColumns (type): GridColumns
GridLayout (type): GridLayout
GridSize (type): GridSize
GridSlot (type): GridSlot
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
@@ -176,7 +177,6 @@ LANGUAGES (value): readonly { code: string; label: string; }[]
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
LineChartSeries (type): LineChartSeries
LinkServerDialog (value): ({ open, onOpenChange, onLinked, relogin, }: LinkServerDialogProps) => Element
LinkedAccountInfo (type): LinkedAccountInfo
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
@@ -197,6 +197,8 @@ ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
NativeViewProps (type): NativeViewProps
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
OpenTabRecord (type): OpenTabRecord
@@ -227,7 +229,6 @@ PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDiv
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
ProxyNode (type): ProxyNode
ProxySettingsFields (value): ({ customHeaders, basicAuth, onChange, }: ProxySettingsFieldsProps) => Element
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
RadialGaugeProps (type): RadialGaugeProps
RailGroup (type): RailGroup
@@ -288,12 +289,6 @@ SurfaceKind (type): SurfaceKind
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
SyncBasicAuth (type): SyncBasicAuth
SyncConflictItem (type): SyncConflictItem
SyncEntityInfo (type): SyncEntityInfo
SyncErrorItem (type): SyncErrorItem
SyncProxyHeader (type): SyncProxyHeader
SyncStatus (type): SyncStatus
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
Tab (type): Tab
TabShellCallbacks (type): TabShellCallbacks
@@ -327,7 +322,7 @@ UserRole (type): UserRole
UserSecondFactorSummary (type): UserSecondFactorSummary
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
VersionInfo (type): VersionInfo
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
ViewToggle (value): ({ view, onView, density, onDensity, columns, onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; columns?: GridSize; onColumns?: (next: GridSize) => void; }) => Element
WebSocketConnectionTarget (type): WebSocketConnectionTarget
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
actionOrder (value): (action: HostActionDef) => number
@@ -408,6 +403,7 @@ getDeviceId (value): () => string
getErrorMessage (value): (error: unknown, fallback?: string) => string
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
getFolderAuthOverride (value): (folder: string, protocol: string, sharedCopySyncId?: string) => Promise<{ folder: string; protocol: string; credentialId: number; }>
getFolderDefaultsId (value): (name: string) => Promise<number>
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
@@ -443,8 +439,6 @@ getSessionTimeoutMinutes (value): () => Promise<number>
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
getSyncConflicts (value): () => Promise<SyncConflictItem[]>
getSyncErrors (value): () => Promise<SyncErrorItem[]>
getTabType (value): (id: string) => TabTypeDef
getTlsStatus (value): () => Promise<TlsStatus>
getUpdateChannel (value): () => Promise<UpdateChannelState>
@@ -509,7 +503,6 @@ quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef
railItemLabel (value): (id: string, t: (key: string) => string) => string
readFromClipboard (value): () => Promise<string>
recordHostActionPreference (value): (hostId: string, action: TabType) => void
refreshSyncStatus (value): () => void
registerLayout (value): (item: RegisteredLayout) => () => void
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
releaseUrlFrom (value): (info: VersionInfo) => string
@@ -532,10 +525,9 @@ resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaul
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
resolveRemoteHostId (value): (syncId: string) => Promise<number>
retrySyncErrors (value): () => Promise<SyncStatus>
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
revokeFolderAccess (value): (ruleId: number) => Promise<void>
revokeFolderAccess (value): (ruleId: number, options?: { fromHosts?: boolean; }) => Promise<void>
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
rightDockableIds (value): () => string[]
@@ -553,24 +545,21 @@ saveHostTags (value): (tags: string[]) => Promise<string[]>
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
setDeveloperMode (value): (enabled: boolean) => Promise<void>
setFolderAuthOverride (value): (folder: string, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; folder: string; protocol: string; credentialId: number; }>
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
setSyncStatus (value): (next: SyncStatus) => void
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise<void>
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
shell (value): TabShellCallbacks
splitDefaultKey (value): (fullKey: string) => [string, string]
sshHostToHost (value): (h: SSHHostWithStatus) => Host
syncNow (value): () => Promise<SyncStatus>
tabIcon (value): (type: TabType) => Element
tabJumpHotkeyKeys (value): () => string[]
takePendingManageRequest (value): () => ManageRequest
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
unlinkServer (value): (keepData: boolean) => Promise<SyncStatus>
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
@@ -588,7 +577,6 @@ updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise<SyncStatus>
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
usageBarColor (value): (percent: number) => string
usageColor (value): (percent: number) => string
@@ -601,6 +589,7 @@ useChooseTheme (value): () => (themeId: string) => void
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
useConnectionGate (value): () => ConnectionGateController
useConnectionLog (value): () => ConnectionLogContextType
useDesktopLinked (value): () => boolean
useDesktopUpdate (value): () => DesktopUpdateState
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
useHostActions (value): () => HostActionDef[]
@@ -635,7 +624,6 @@ useSshAuthEditors (value): () => SshAuthEditorDef[]
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
useStatusColorScheme (value): () => StatusColorScheme
useSurfaceKind (value): () => SurfaceKind
useSyncStatus (value): () => any
useTabHandlesVersion (value): () => number
useTabs (value): () => TabContextType
useTabsSafe (value): () => TabContextType
+7 -2
View File
@@ -3,6 +3,8 @@
*
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
* --profile minimal|standard|full (or TERMIX_PLUGIN_PROFILE, default standard)
* picks which tiers of the file are staged.
* Each .tmxplug is downloaded (or read from a path), checked against its
* pinned sha256 and unpacked.
*
@@ -25,13 +27,16 @@ const {
localSdkBuildCommand,
copyLocalBuild,
buildBundledIndex,
resolveProfile,
pluginsForProfile,
} = require("./lib/bundled-plugins.cjs");
const root = path.resolve(__dirname, "..");
const destination = path.join(root, "dist", "plugins");
async function main() {
const plugins = loadBundledPlugins(root);
const profile = resolveProfile();
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
const flag = process.argv.indexOf("--local");
const localArg =
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
@@ -74,7 +79,7 @@ async function main() {
);
console.log(
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
`Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`,
);
}
+14 -6
View File
@@ -1,9 +1,11 @@
/**
* Builds the two pieces of the plugin SDK that come from core:
* Builds the pieces of the plugin SDK that come from core:
*
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
* dist/host/testing-host.js what renderWithApp() renders plugins into
* dist/host/core-classes.json the classes core's CSS has rules for
* dist/host/frame-host.js runs one plugin outside the shell (mobile app)
* dist/host/frame-host.css core's compiled CSS for that frame
*
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
@@ -46,6 +48,7 @@ await esbuild.build({
entryPoints: {
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
"frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"),
},
outdir: outDir,
bundle: true,
@@ -72,6 +75,9 @@ await esbuild.build({
// so the translated locale files it would list are simply none.
banner: { js: "const __termixNoGlob = () => ({});" },
define: {
// esbuild picks "development" for an unminified build, which sends core's
// requests to the Vite dev proxy on any server without a port.
"process.env.NODE_ENV": '"production"',
"import.meta.glob": "__termixNoGlob",
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
@@ -157,14 +163,16 @@ const rootSource =
const scanner = new Scanner({
sources: [...rootSource, ...compiler.sources],
});
const coreClasses = [
...classNamesInCss(
optimize(compiler.build(scanner.scan()), { minify: true }).code,
),
].sort();
const coreCss = optimize(compiler.build(scanner.scan()), {
minify: true,
}).code;
const coreClasses = [...classNamesInCss(coreCss)].sort();
fs.writeFileSync(
path.join(outDir, "core-classes.json"),
`${JSON.stringify(coreClasses)}\n`,
);
// The frame host has no Vite build to load core's CSS, so it gets it here.
fs.writeFileSync(path.join(outDir, "frame-host.css"), coreCss);
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
+102
View File
@@ -16,6 +16,8 @@ import {
localSdkBuildCommand,
rebuildLocalPlugins,
copyLocalBuild,
resolveProfile,
pluginsForProfile,
} from "./lib/bundled-plugins.cjs";
const cleanups: Array<() => void> = [];
@@ -263,10 +265,52 @@ describe("pinsFromIndex", () => {
source: "tmxplug",
url: "https://example.test/beta-1.1.0.tmxplug",
sha256: "b".repeat(64),
tier: "full",
});
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
});
it("keeps the tier of an existing pin", () => {
const pins = pinsFromIndex(index, null, [
{ id: "alpha", tier: "standard" },
]);
expect(pins[0].tier).toBe("standard");
expect(pins[1].tier).toBe("full");
});
it("skips releases whose engine.termix excludes the core version", () => {
const withEngine = {
plugins: [
{
id: "gamma",
versions: [
{
version: "2.0.0",
url: "https://example.test/gamma-2.0.0.tmxplug",
sha256: "d".repeat(64),
engine: { api: "^1", termix: ">=26.10.6" },
},
{
version: "1.0.0",
url: "https://example.test/gamma-1.0.0.tmxplug",
sha256: "e".repeat(64),
engine: { api: "^1", termix: ">=26.10.0" },
},
],
},
],
};
expect(pinsFromIndex(withEngine, null, [], "26.10.5")[0].url).toBe(
"https://example.test/gamma-1.0.0.tmxplug",
);
expect(pinsFromIndex(withEngine, null, [], "26.10.6")[0].url).toBe(
"https://example.test/gamma-2.0.0.tmxplug",
);
expect(pinsFromIndex(withEngine, null)[0].url).toBe(
"https://example.test/gamma-2.0.0.tmxplug",
);
});
it("keeps the onboarding defaults of an existing pin", () => {
const current = [
{ id: "beta", onboarding: { recommended: true, consent: false } },
@@ -511,3 +555,61 @@ describe("default look", () => {
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
});
});
describe("plugin profiles", () => {
const entry = (id: string, tier?: string) => ({
id,
source: "tmxplug",
url: `https://x/${id}.tmxplug`,
sha256: SHA,
...(tier ? { tier } : {}),
});
const { plugins } = parseBundledPlugins({
plugins: [
entry("look", "minimal"),
entry("term", "standard"),
entry("extra", "full"),
entry("untiered"),
],
});
const ids = (profile: string) =>
pluginsForProfile(plugins, profile).map((p: { id: string }) => p.id);
it("ships each tier and every smaller one", () => {
expect(ids("minimal")).toEqual(["look"]);
expect(ids("standard")).toEqual(["look", "term"]);
expect(ids("full")).toEqual(["look", "term", "extra", "untiered"]);
});
it("rejects an unknown tier", () => {
const { problems } = parseBundledPlugins({ plugins: [entry("x", "huge")] });
expect(problems[0]).toMatch(/tier must be one of/);
});
it("reads the profile from the flag, then the environment", () => {
expect(resolveProfile(["node", "x"], {})).toBe("standard");
expect(
resolveProfile(["node", "x"], { TERMIX_PLUGIN_PROFILE: "full" }),
).toBe("full");
expect(
resolveProfile(["node", "x", "--profile", "minimal"], {
TERMIX_PLUGIN_PROFILE: "full",
}),
).toBe("minimal");
expect(() =>
resolveProfile(["node", "x", "--profile", "tiny"], {}),
).toThrow(/unknown plugin profile/);
});
it("keeps the real file valid with a default look in the minimal tier", () => {
const real = loadBundledPlugins(path.resolve(__dirname, ".."));
const minimal = pluginsForProfile(real, "minimal");
expect(minimal.length).toBeGreaterThan(0);
expect(minimal.every((p: { defaultLook: boolean }) => p.defaultLook)).toBe(
true,
);
expect(pluginsForProfile(real, "standard").length).toBeLessThan(
real.length,
);
});
});
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* Buttons are never white.
*
* Every Button variant is the brand orange with a different background
* opacity (or red for destructive, or transparent for ghost). Fails when a
* variant in src/ui/components/button.tsx uses a neutral fill, or when a
* <Button> anywhere in core or the local plugin checkouts sets a neutral fill
* in its className.
*/
const fs = require("node:fs");
const path = require("node:path");
const NEUTRAL =
/(?:^|[\s"'`])(?:dark:)?(?:bg-(?:primary|white|foreground|background|secondary|card|popover|muted|accent|input|neutral-\d+|zinc-\d+|gray-\d+|slate-\d+|stone-\d+)(?![\w-])|text-primary-foreground)/;
/** The opening tag text of each <Button ...> in a source file. */
function buttonTags(source) {
const tags = [];
const re = /<Button(?=[\s/>])/g;
let match;
while ((match = re.exec(source))) {
let depth = 0;
let quote = "";
let i = match.index + match[0].length;
for (; i < source.length; i++) {
const c = source[i];
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = "";
} else if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) break;
}
tags.push(source.slice(match.index, i + 1));
}
return tags;
}
/** Variant strings from the cva block of button.tsx. */
function variantLines(source) {
const start = source.indexOf("variant: {");
const end = source.indexOf("size: {");
if (start < 0 || end < 0) return [];
return source
.slice(start, end)
.split("\n")
.filter((line) => /["`]/.test(line) && !line.includes("variant: {"));
}
function walk(dir, out) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (["node_modules", "dist", "tests", "__fixtures__"].includes(entry.name))
continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.name.endsWith(".tsx")) out.push(full);
}
}
/** Problems found under the given roots. */
function scan(roots, buttonFile) {
const problems = [];
if (buttonFile && fs.existsSync(buttonFile)) {
for (const line of variantLines(fs.readFileSync(buttonFile, "utf8"))) {
if (NEUTRAL.test(line)) {
problems.push(
`${buttonFile}: neutral fill in a variant: ${line.trim()}`,
);
}
}
}
const files = [];
for (const root of roots) walk(root, files);
for (const file of files) {
for (const tag of buttonTags(fs.readFileSync(file, "utf8"))) {
if (NEUTRAL.test(tag)) {
problems.push(
`${file}: <Button> with a neutral fill: ${tag.slice(0, 120)}`,
);
}
}
}
return problems;
}
module.exports = { scan, buttonTags, variantLines };
if (require.main === module) {
const core = path.resolve(__dirname, "..");
const plugins = path.resolve(core, "..", "Plugins");
const roots = [path.join(core, "src", "ui")];
if (fs.existsSync(plugins)) {
for (const entry of fs.readdirSync(plugins)) {
roots.push(path.join(plugins, entry, "src"));
}
}
const problems = scan(
roots,
path.join(core, "src", "ui", "components", "button.tsx"),
);
if (problems.length) {
console.error("Buttons must be the brand orange, never white or neutral:");
for (const p of problems) console.error(" " + p);
process.exit(1);
}
console.log("ok button colors");
}
+80
View File
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { scan, buttonTags } = require("./check-button-colors.cjs") as {
scan: (roots: string[], buttonFile?: string) => string[];
buttonTags: (source: string) => string[];
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "button-colors-"));
for (const [file, content] of Object.entries(files)) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
return root;
}
describe("buttonTags", () => {
it("reads a tag with arrow functions inside", () => {
const tags = buttonTags(
`<Button onClick={() => go(a > b)} className="x">Go</Button>`,
);
expect(tags).toHaveLength(1);
expect(tags[0]).toContain('className="x"');
});
});
describe("check-button-colors", () => {
it("accepts orange buttons and hover fills", () => {
const dir = fixture({
"a.tsx": `<Button className="bg-accent-brand/15 text-accent-brand hover:bg-muted" />`,
});
expect(scan([dir])).toEqual([]);
});
it("rejects a white or neutral fill on a Button", () => {
for (const cls of [
"bg-primary",
"bg-white",
"bg-foreground text-background",
"dark:bg-secondary",
"bg-muted",
"text-primary-foreground",
]) {
const dir = fixture({ "a.tsx": `<Button className="${cls}">x</Button>` });
expect(scan([dir]), cls).toHaveLength(1);
}
});
it("rejects a neutral fill in a variant", () => {
const dir = fixture({
"button.tsx": `variant: {\n default: "bg-primary text-primary-foreground",\n ghost: "hover:bg-muted",\n},\nsize: {`,
});
expect(scan([], path.join(dir, "button.tsx"))).toHaveLength(1);
});
it("passes on the real button", () => {
const button = path.resolve(
__dirname,
"..",
"src",
"ui",
"components",
"button.tsx",
);
expect(scan([], button)).toEqual([]);
});
});
+2
View File
@@ -16,6 +16,8 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
const INTERNAL_EXPORTS = new Set([
"./testing/setup",
"./testing-host",
"./frame-host",
"./frame-host.css",
"./tsconfig.plugin.json",
"./schema/manifest.schema.json",
"./theme.css",
+6 -18
View File
@@ -151,6 +151,7 @@ const ALLOWED = [
"GridCardCatalogEntry",
"GridColSpan",
"GridColumns",
"GridSize",
"GridLayout",
"GridSlot",
"GroupHeading",
@@ -197,7 +198,6 @@ const ALLOWED = [
"Label",
"LineChart",
"LineChartSeries",
"LinkServerDialog",
"LinkedAccountInfo",
"ListBadge",
"ListRow",
@@ -218,6 +218,8 @@ const ALLOWED = [
"Meter",
"MetricCard",
"MiniStat",
"NativeView",
"NativeViewProps",
"NumberField",
"OPEN_PLUGINS_EVENT",
"OpenTabRecord",
@@ -248,7 +250,6 @@ const ALLOWED = [
"PopoverContent",
"PopoverTrigger",
"ProxyNode",
"ProxySettingsFields",
"RadialGauge",
"RadialGaugeProps",
"RailGroup",
@@ -309,12 +310,6 @@ const ALLOWED = [
"SurfaceScope",
"Switch",
"SwitchRow",
"SyncBasicAuth",
"SyncConflictItem",
"SyncEntityInfo",
"SyncErrorItem",
"SyncProxyHeader",
"SyncStatus",
"TOTPDialog",
"Tab",
"TabShellCallbacks",
@@ -431,6 +426,7 @@ const ALLOWED = [
"getFolderAccess",
"getFolderDefaultsId",
"getHostAccess",
"getFolderAuthOverride",
"getHostAuthOverride",
"getHostDefaultsLevel",
"getHostEditorSection",
@@ -464,8 +460,6 @@ const ALLOWED = [
"getSessions",
"getStatusCheckSettings",
"getStatusClasses",
"getSyncConflicts",
"getSyncErrors",
"getTabType",
"getTlsStatus",
"getUpdateChannel",
@@ -530,7 +524,6 @@ const ALLOWED = [
"railItemLabel",
"readFromClipboard",
"recordHostActionPreference",
"refreshSyncStatus",
"registerLayout",
"registerTabCloseGuard",
"releaseUrlFrom",
@@ -553,7 +546,6 @@ const ALLOWED = [
"resolveHostDefaults",
"resolveHostTabType",
"resolveRemoteHostId",
"retrySyncErrors",
"revokeAllUserSessions",
"revokeCredentialAccess",
"revokeFolderAccess",
@@ -574,24 +566,21 @@ const ALLOWED = [
"saveUserPreferences",
"setAllPluginChannels",
"setDeveloperMode",
"setFolderAuthOverride",
"setHostAuthOverride",
"setNotificationPrivateEndpoints",
"setPluginOptions",
"setSyncStatus",
"setUpdateChannel",
"settleSyncConflict",
"shareCredential",
"shareFolder",
"shareHost",
"shell",
"splitDefaultKey",
"sshHostToHost",
"syncNow",
"tabIcon",
"tabJumpHotkeyKeys",
"takePendingManageRequest",
"unlinkExternalFromPasswordAccount",
"unlinkServer",
"updateCredential",
"updateExternalAutoProvision",
"updateFolderMetadata",
@@ -609,7 +598,6 @@ const ALLOWED = [
"updateSessionTimeout",
"updateHttpsSettings",
"updateStatusCheckSettings",
"updateSyncSettings",
"uploadTlsCertificate",
"usageBarColor",
"usageColor",
@@ -622,6 +610,7 @@ const ALLOWED = [
"useConfirm",
"useConnectionGate",
"useConnectionLog",
"useDesktopLinked",
"useDesktopUpdate",
"useEditorDefaultKeys",
"useHostActions",
@@ -656,7 +645,6 @@ const ALLOWED = [
"useSshAuthProviders",
"useStatusColorScheme",
"useSurfaceKind",
"useSyncStatus",
"useTabHandlesVersion",
"useTabs",
"useTabsSafe",
+31 -5
View File
@@ -404,6 +404,7 @@ function watchBackend() {
{ cwd: root, stdio: ["ignore", "pipe", "inherit"] },
);
let first = true;
let restartQueued = false;
let buffer = "";
tsc.stdout.on("data", (chunk) => {
buffer += chunk.toString();
@@ -423,14 +424,21 @@ function watchBackend() {
? "starting the backend"
: "backend changed, restarting it";
first = false;
void queue(() => restartBackend(reason));
if (restartQueued) continue;
restartQueued = true;
void queue(() => {
restartQueued = false;
return restartBackend(reason);
});
}
});
}
function watchSdk() {
let buildStarted = Date.now();
const changed = createDebouncer(300, () =>
queue(async () => {
buildStarted = Date.now();
try {
await run("npm", ["run", "build:sdk"]);
} catch (error) {
@@ -440,7 +448,16 @@ function watchSdk() {
await restartBackend("SDK changed, restarting the backend");
}),
);
watch(SDK_SRC, true, () => changed("sdk"));
// Windows also reports reads (access time), so only a file saved after the last build counts
watch(SDK_SRC, true, (_event, filename) => {
try {
const stat = fs.statSync(path.join(SDK_SRC, String(filename ?? "")));
if (stat.isDirectory() || stat.mtimeMs < buildStarted) return;
} catch {
// deleted or renamed, still a change
}
changed("sdk");
});
}
function watchElectron() {
@@ -580,9 +597,18 @@ function watchPlugins(dir) {
queue(() => rebuildPlugin(byId.get(id))),
);
for (const plugin of plugins) {
watch(plugin.repo, true, (_event, filename) => {
if (isPluginEdit(plugin.repo, filename)) changed(plugin.id);
});
// Only source folders, so node_modules and .git are never watched
const onEvent = (prefix) => (_event, filename) => {
const name = prefix
? path.join(prefix, String(filename ?? ""))
: filename;
if (isPluginEdit(plugin.repo, name)) changed(plugin.id);
};
watch(plugin.repo, false, onEvent(""));
for (const sub of ["src", "locales", "migrations"]) {
const target = path.join(plugin.repo, sub);
if (fs.existsSync(target)) watch(target, true, onEvent(sub));
}
}
log(`watching ${plugins.length} plugin(s) in ${dir}`);
}
+28
View File
@@ -0,0 +1,28 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
function findPackage(start) {
let directory = path.dirname(start);
while (directory !== path.dirname(directory)) {
const manifest = path.join(directory, "package.json");
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest));
directory = path.dirname(directory);
}
throw new Error("Could not locate the installed sharp package manifest");
}
// The build runs on x64 but ships arm64 and ia32 too, so npm ci alone
// only installs the x64 binary.
const sharpPackage = findPackage(require.resolve("sharp"));
const packages = [
"@img/sharp-win32-arm64",
"@img/sharp-win32-ia32",
"@img/sharp-win32-x64",
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
// A .cmd file only starts through a shell on current Node.
execFileSync("npm.cmd", ["install", "--force", "--no-save", ...packages], {
stdio: "inherit",
shell: true,
});
+49 -2
View File
@@ -16,6 +16,11 @@
* environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked.
*
* "tier" says which image profile ships the plugin: "minimal" (only what the
* app cannot start without, the default look), "standard" (the default image)
* or "full" (everything else). A profile includes its own tier and every
* smaller one. A plugin with no tier counts as "full".
*
* "defaultLook": true marks the plugin whose layout (and that layout's
* theme) a fresh install shows. Core finds the default look this way, so it
* never names a plugin. Such a plugin always starts enabled and cannot be
@@ -31,6 +36,8 @@ const ID = /^[a-z0-9][a-z0-9-]*$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
const SHA256 = /^[0-9a-f]{64}$/;
const PROFILES = ["minimal", "standard", "full"];
const DEFAULT_PROFILE = "standard";
function parseOnboarding(value, id, problems) {
if (value === undefined) return null;
@@ -122,6 +129,9 @@ function parseBundledPlugins(raw) {
) {
problems.push(`${entry.id} defaultLook must be a boolean`);
}
if (entry.tier !== undefined && !PROFILES.includes(entry.tier)) {
problems.push(`${entry.id} tier must be one of ${PROFILES.join(", ")}`);
}
if (problems.length > before) continue;
plugins.push({
id: entry.id,
@@ -131,6 +141,7 @@ function parseBundledPlugins(raw) {
sha256: entry.sha256,
onboarding,
defaultLook: entry.defaultLook === true,
tier: entry.tier ?? "full",
});
} else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
@@ -241,12 +252,35 @@ function buildBundledIndex(plugins, stagedIds) {
return { version: 1, plugins: out };
}
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
function resolveProfile(argv = process.argv, env = process.env) {
const flag = argv.indexOf("--profile");
const value =
(flag !== -1 ? argv[flag + 1] : env.TERMIX_PLUGIN_PROFILE) ||
DEFAULT_PROFILE;
if (!PROFILES.includes(value)) {
throw new Error(
`unknown plugin profile "${value}", use ${PROFILES.join(", ")}`,
);
}
return value;
}
/** The plugins a profile ships: its own tier and every smaller one. */
function pluginsForProfile(plugins, profile) {
const rank = PROFILES.indexOf(profile);
return plugins.filter((plugin) => PROFILES.indexOf(plugin.tier) <= rank);
}
/**
* Pins the newest registry version of each wanted plugin. `ids` is the list
* to pin, or null for every plugin in the index. `current` is the existing
* pin list, so fields the registry does not know (onboarding) carry over.
* pin list, so fields the registry does not know (onboarding, tier) carry over;
* a plugin new to the list starts in the full tier. coreVersion, when given,
* skips releases whose engine.termix range does not include it, so the image
* never ships a plugin its own core cannot run.
*/
function pinsFromIndex(index, ids, current = []) {
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry]));
@@ -257,15 +291,25 @@ function pinsFromIndex(index, ids, current = []) {
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
.filter((entry) => semver.valid(entry.version))
.filter((entry) => !semver.prerelease(entry.version))
.filter(
(entry) =>
!coreVersion ||
!entry.engine?.termix ||
semver.satisfies(coreVersion, entry.engine.termix, {
includePrerelease: true,
}),
)
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
if (!latest) throw new Error(`${id} is not in the registry index`);
const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true;
const tier = currentById.get(id)?.tier ?? "full";
return {
id,
source: "tmxplug",
url: latest.url,
sha256: latest.sha256,
tier,
...(onboarding ? { onboarding } : {}),
...(defaultLook ? { defaultLook } : {}),
};
@@ -409,6 +453,9 @@ function copyLocalBuild(repo, destination) {
}
module.exports = {
PROFILES,
resolveProfile,
pluginsForProfile,
findLocalPluginBuilds,
isLocalBuildStale,
localSdkBuildCommand,
+149
View File
@@ -0,0 +1,149 @@
const fs = require("node:fs");
const SK_ED = "sk-ssh-ed25519@openssh.com";
const SK_EC = "sk-ecdsa-sha2-nistp256@openssh.com";
function replaceOnce(source, from, to, label) {
const first = source.indexOf(from);
if (first === -1 || source.indexOf(from, first + 1) !== -1) {
throw new Error(`ssh2 changed (${label}); review the security key patch`);
}
return source.replace(from, () => to);
}
function patchKeyParserSource(source) {
if (source.includes("// termix-sk")) return source;
let out = replaceOnce(
source,
` case 'ecdsa-sha2-nistp521':
return true;
case 'ssh-ed25519':
if (eddsaSupported)
return true;`,
` case 'ecdsa-sha2-nistp521':
return true;
case '${SK_EC}': // termix-sk
return true;
case '${SK_ED}':
case 'ssh-ed25519':
if (eddsaSupported)
return true;`,
"key types",
);
out = replaceOnce(
out,
` default:
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
}
return new OpenSSH_Public(`,
` case '${SK_ED}':
case '${SK_EC}': {
// Public blob only; the security key signs, so there is no private half.
const rest = data.subarray(data._pos || 0);
const typeLen = Buffer.byteLength(baseType);
pubSSH = Buffer.allocUnsafe(4 + typeLen + rest.length);
writeUInt32BE(pubSSH, typeLen, 0);
pubSSH.utf8Write(baseType, 4, typeLen);
pubSSH.set(rest, 4 + typeLen);
algo = baseType === '${SK_ED}' ? null : 'sha256';
break;
}
default:
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
}
return new OpenSSH_Public(`,
"parseDER",
);
return out;
}
function patchAgentSource(source) {
if (source.includes("skTrailer")) return source;
return replaceOnce(
source,
` binaryParser.init(signature, 0);
binaryParser.readString(true);
signature = binaryParser.readString();
binaryParser.clear();`,
` binaryParser.init(signature, 0);
binaryParser.readString(true);
signature = binaryParser.readString();
// Security key signatures end with flags and a counter.
const skTrailer = signature === undefined
? undefined
: binaryParser.readRaw();
binaryParser.clear();
if (skTrailer && skTrailer.length)
signature.skTrailer = Buffer.from(skTrailer);`,
"agent sign response",
);
}
function patchProtocolSource(source) {
if (source.includes("skTrailer")) return source;
const start = source.indexOf(" authPK(username, pubKey, keyAlgo, cbSign) {");
const end = source.indexOf(" authHostbased(", start);
if (start === -1 || end === -1) {
throw new Error("ssh2 changed (authPK); review the security key patch");
}
let region = source.slice(start, end);
region = replaceOnce(
region,
` signature = convertSignature(signature, keyType);`,
` const skTrailer = signature && signature.skTrailer;
signature = convertSignature(signature, keyType);`,
"authPK sign",
);
region = replaceOnce(
region,
` + 4 + algoLen + 4 + sigLen
);`,
` + 4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0)
);`,
"authPK alloc",
);
region = replaceOnce(
region,
` writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);`,
` writeUInt32BE(
packet,
4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0),
p += pubKeyLen
);`,
"authPK length",
);
region = replaceOnce(
region,
` packet.set(signature, p += 4);`,
` packet.set(signature, p += 4);
if (skTrailer)
packet.set(skTrailer, p + sigLen);`,
"authPK copy",
);
return source.slice(0, start) + region + source.slice(end);
}
if (require.main === module) {
const targets = [
["ssh2/lib/protocol/keyParser.js", patchKeyParserSource],
["ssh2/lib/agent.js", patchAgentSource],
["ssh2/lib/protocol/Protocol.js", patchProtocolSource],
];
for (const [id, patch] of targets) {
const target = require.resolve(id);
const source = fs.readFileSync(target, "utf8");
const result = patch(source);
if (result !== source) fs.writeFileSync(target, result);
}
console.log(
"[patch-ssh2-sk] Security key public keys and signatures enabled",
);
}
module.exports = {
patchKeyParserSource,
patchAgentSource,
patchProtocolSource,
};
+7 -2
View File
@@ -4,7 +4,10 @@
* re-released.
*
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
* node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full)
* --core-version X skip releases whose engine.termix excludes X
*
* The release workflow runs it with --all and the version being released.
*/
const fs = require("node:fs");
@@ -22,6 +25,8 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
async function main() {
const all = process.argv.includes("--all");
const flag = process.argv.indexOf("--core-version");
const coreVersion = flag !== -1 ? process.argv[flag + 1] : null;
const response = await fetch(INDEX_URL);
if (!response.ok) {
throw new Error(`registry index download failed with ${response.status}`);
@@ -30,7 +35,7 @@ async function main() {
const current = loadBundledPlugins(root);
const ids = all ? null : current.map((plugin) => plugin.id);
const plugins = pinsFromIndex(index, ids, current);
const plugins = pinsFromIndex(index, ids, current, coreVersion);
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
console.log(
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
+29
View File
@@ -144,3 +144,32 @@ describe("termix-plugin validate layouts and capabilities", () => {
expect(output).not.toContain("is declared but");
});
});
describe("termix-plugin validate mobile entry", () => {
function mobile(built: boolean) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
id: "fixture",
version: "1.0.0",
contributes: { mobile: { mode: "webview", entry: "dist/mobile.js" } },
}),
);
if (built) {
fs.mkdirSync(path.join(root, "dist"), { recursive: true });
fs.writeFileSync(path.join(root, "dist", "mobile.js"), "");
}
return root;
}
it("reports a mobile entry that was not built", async () => {
expect(await problems(mobile(false))).toContain(
"manifest references dist/mobile.js, which does not exist",
);
});
it("finds a built mobile entry", async () => {
expect(await problems(mobile(true))).not.toContain("dist/mobile.js");
});
});
+12 -2
View File
@@ -137,9 +137,19 @@ function verifyArtifact(artifact) {
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
} finally {
if (mountedAt) {
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" });
for (let attempt = 0; attempt < 5; attempt++) {
const args =
attempt < 3 ? ["detach", mountedAt] : ["detach", "-force", mountedAt];
if (spawnSync("hdiutil", args, { stdio: "ignore" }).status === 0) break;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
}
}
fs.rmSync(temporaryRoot, { recursive: true, force: true });
fs.rmSync(temporaryRoot, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 1000,
});
}
}
+4 -4
View File
@@ -27,8 +27,7 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
import uiPreferencesRoutes from "./routes/ui-preferences.js";
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import syncRoutes from "../sync/server/routes.js";
import syncLinkRoutes from "../sync/client/routes.js";
import { syncForwarder } from "../sync/provider.js";
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
import dashboardRoutes from "./routes/dashboard-routes.js";
import {
@@ -36,6 +35,7 @@ import {
mountPluginLegacyPaths,
} from "./routes/plugin-api-routes.js";
import { attachPluginWebSockets } from "../plugins/ws.js";
import { registerSshRelay } from "../hosts/ssh-relay.js";
import pluginRoutes from "./routes/plugins.js";
import { createPluginAssetsRouter } from "../plugins/assets.js";
import { getPluginRuntime } from "../plugins/index.js";
@@ -1625,8 +1625,7 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
app.use("/ui-preferences", uiPreferencesRoutes);
registerAuditLogRoutes(app, authenticateJWT);
app.use("/sync", syncRoutes);
app.use("/sync", syncLinkRoutes);
app.use("/sync", syncForwarder);
app.use("/dashboard", dashboardRoutes);
app.use("/plugins", pluginRoutes);
app.use(
@@ -1900,6 +1899,7 @@ const httpServer = http.createServer(app);
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
// left alone, so core's own upgrade handling is unaffected.
attachPluginWebSockets(httpServer);
registerSshRelay();
httpServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
+15
View File
@@ -484,6 +484,19 @@ async function initializeCompleteDatabase(): Promise<void> {
);
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
CREATE TABLE IF NOT EXISTS shared_folder_auth_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
folder TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'ssh',
credential_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS shared_folder_auth_overrides_user_folder_protocol_unique ON shared_folder_auth_overrides (user_id, folder, protocol);
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -1316,6 +1329,8 @@ const migrateSchema = () => {
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
];
for (const migration of sshDataMigrations) {
+35
View File
@@ -193,6 +193,9 @@ export const hosts = mysqlTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: int("ssh_port").default(22),
@@ -487,6 +490,38 @@ export const sharedHostAuthOverrides = mysqlTable(
],
);
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = mysqlTable(
"shared_folder_auth_overrides",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: varchar("folder", { length: 255 }).notNull(),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = mysqlTable(
"shared_host_secrets",
{
+35
View File
@@ -193,6 +193,9 @@ export const hosts = pgTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: integer("ssh_port").default(22),
@@ -487,6 +490,38 @@ export const sharedHostAuthOverrides = pgTable(
],
);
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = pgTable(
"shared_folder_auth_overrides",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: varchar("folder", { length: 255 }).notNull(),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = pgTable(
"shared_host_secrets",
{
+35
View File
@@ -183,6 +183,9 @@ export const hosts = sqliteTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
sshPort: integer("ssh_port").default(22),
@@ -477,6 +480,38 @@ export const sharedHostAuthOverrides = sqliteTable(
],
);
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = sqliteTable(
"shared_folder_auth_overrides",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: text("folder").notNull(),
protocol: text("protocol").notNull().default("ssh"),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = sqliteTable(
"shared_host_secrets",
{
@@ -31,6 +31,7 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedFolderAuthOverrideRepository } from "./shared-folder-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
@@ -316,6 +317,15 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
);
}
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
return new SharedFolderAuthOverrideRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"shared_folder_auth_override_repository_write",
),
);
}
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
return new SshCredentialUsageRepository(
createCurrentRepositoryContext(),
@@ -236,6 +236,29 @@ export class HostFolderRepository {
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
/** Drops the folder rows (and subfolders) only when no host is left in them. */
async deleteEmptyFolderRecords(
userId: string,
folderName: string,
): Promise<boolean> {
if ((await this.listHostsInFolder(userId, folderName)).length > 0) {
return false;
}
await deleteReturning(
this.context,
sshFolders,
and(
eq(sshFolders.userId, userId),
or(
eq(sshFolders.name, folderName),
like(sshFolders.name, `${folderName} / %`),
),
),
);
await this.afterWrite();
return true;
}
async deleteHostsAndFolderRecords(
userId: string,
folderName: string,
@@ -0,0 +1,115 @@
import { and, eq, inArray } from "drizzle-orm";
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { sharedFolderAuthOverrides } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { deleteReturning, upsert } from "./returning.js";
/** "A / B / C" and the folders above it, closest first. */
export function folderAndParents(folder: string | null | undefined): string[] {
const parts = (folder ?? "")
.split(" / ")
.map((part) => part.trim())
.filter(Boolean);
const out: string[] = [];
for (let i = parts.length; i > 0; i--) {
out.push(parts.slice(0, i).join(" / "));
}
return out;
}
export class SharedFolderAuthOverrideRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findCredentialId(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
): Promise<number | null> {
const rows = await this.context.drizzle
.select({ credentialId: sharedFolderAuthOverrides.credentialId })
.from(sharedFolderAuthOverrides)
.where(
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.folder, folder),
eq(sharedFolderAuthOverrides.protocol, protocol),
),
)
.limit(1);
return rows[0]?.credentialId ?? null;
}
/**
* The login set on the host's folder or the closest folder above it, for
* one protocol. Null when none is set.
*/
async findClosest(
userId: string,
hostFolder: string | null | undefined,
protocol: AuthOverrideProtocol,
): Promise<{ folder: string; credentialId: number } | null> {
const folders = folderAndParents(hostFolder);
if (folders.length === 0) return null;
const rows = await this.context.drizzle
.select({
folder: sharedFolderAuthOverrides.folder,
credentialId: sharedFolderAuthOverrides.credentialId,
})
.from(sharedFolderAuthOverrides)
.where(
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.protocol, protocol),
inArray(sharedFolderAuthOverrides.folder, folders),
),
);
for (const folder of folders) {
const row = rows.find((candidate) => candidate.folder === folder);
if (row) return row;
}
return null;
}
async setCredential(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
credentialId: number,
): Promise<void> {
await upsert(
this.context,
sharedFolderAuthOverrides,
{ userId, folder, protocol, credentialId },
{
target: [
sharedFolderAuthOverrides.userId,
sharedFolderAuthOverrides.folder,
sharedFolderAuthOverrides.protocol,
],
set: { credentialId, updatedAt: new Date().toISOString() },
},
);
await this.onWrite?.();
}
async clearCredential(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
): Promise<boolean> {
const rows = await deleteReturning(
this.context,
sharedFolderAuthOverrides,
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.folder, folder),
eq(sharedFolderAuthOverrides.protocol, protocol),
),
);
if (rows.length > 0) await this.onWrite?.();
return rows.length > 0;
}
}
@@ -61,15 +61,23 @@ export function isNativeTokenExportRequest(req: Request): boolean {
* during testing, or a household sharing one install) -- a user in that
* state deserves to get into the app they installed, not a confusing,
* unexplained dead end. So this always returns a single, deterministic
* user: the admin account if one exists, else the earliest-registered
* account. It never returns null.
* user: the account the user switched to if it still exists, else the admin
* account if one exists, else the earliest-registered account. It never
* returns null when there are users.
*/
export function resolveDesktopAutoSessionUser(
allUsers: UserRecord[],
preferredId?: string | null,
): UserRecord | null {
if (allUsers.length === 0) return null;
if (allUsers.length === 1) return allUsers[0];
// An account the user switched to on purpose, while it still exists.
const preferred = preferredId
? allUsers.find((user) => user.id === preferredId)
: undefined;
if (preferred) return preferred;
const admin = allUsers.find((user) => user.isAdmin);
if (admin) return admin;
@@ -30,6 +30,33 @@ export function applyHostKeyTypeUpdate(
}
}
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
/**
* Color and icon from a host write. A field left out stays as stored, so
* editors that don't know about them never clear them. Bad values store null.
*/
export function normalizeHostLookFields(values: Record<string, unknown>): {
color?: string | null;
icon?: string | null;
} {
const result: { color?: string | null; icon?: string | null } = {};
if (values.color !== undefined) {
result.color =
typeof values.color === "string" && HOST_COLOR.test(values.color)
? values.color.toLowerCase()
: null;
}
if (values.icon !== undefined) {
result.icon =
typeof values.icon === "string" && HOST_ICON.test(values.icon)
? values.icon
: null;
}
return result;
}
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
export function normalizeProtocolEnableFields(
+3
View File
@@ -33,6 +33,7 @@ import {
isOptionalAuthType,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeProtocolEnableFields,
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS,
@@ -335,6 +336,7 @@ router.post(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
@@ -912,6 +914,7 @@ router.put(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
+5 -1
View File
@@ -18,6 +18,7 @@ import {
safeLayoutForced,
unavailableLooks,
} from "../../plugins/looks.js";
import { safeModeOn } from "../../plugins/safe-mode.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -36,7 +37,7 @@ async function adminDefaults() {
* /user-preferences/look:
* get:
* summary: Get the layout and theme to show the current user
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, whether TERMIX_SAFE_MODE runs only layout and theme plugins, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* tags:
* - User Preferences
* responses:
@@ -75,6 +76,8 @@ async function adminDefaults() {
* nullable: true
* safeLayout:
* type: boolean
* safeMode:
* type: boolean
* unavailable:
* type: array
* items:
@@ -112,6 +115,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
defaults: { ...defaults, layout: known(defaults.layout) },
defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(),
safeMode: safeModeOn(),
unavailable: unavailableLooks(loader),
});
} catch (error) {
+43 -4
View File
@@ -16,6 +16,7 @@ import { getPluginRuntime } from "../../plugins/index.js";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { mayRunInSafeMode } from "../../plugins/safe-mode.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
@@ -243,12 +244,17 @@ export async function listPreLoginPlugins(): Promise<
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
* it, and `locales` lists the languages it ships ("en" and xx_YY).
* `dependencies` and `optionalDependencies` let the loader activate
* frontends in the same order the server does.
* frontends in the same order the server does. `engine` holds the
* manifest's `api` and `termix` ranges, so other clients (the mobile
* app) can tell whether they understand the plugin.
*
* On a desktop linked to a server, `runsOnServer` marks plugins that
* only run on that server and stay off here on purpose, and, for
* admins, `managedByServer` marks plugins whose on/off state the server
* decides.
*
* While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps
* off reports `state: "safe-mode"`.
* tags:
* - Plugins
* responses:
@@ -299,6 +305,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let features: string[] = [];
let docs: string | undefined;
let env: PluginEnvVar[] = [];
const engine: { api?: string; termix?: string } = {};
let desktopMode: unknown = loaded
? resolvePluginDesktopMode(loaded.manifest)
: undefined;
@@ -318,7 +325,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
optionalDependencies?: Record<string, string>;
desktop?: unknown;
runtime?: unknown;
engine?: { api?: unknown; termix?: unknown };
};
if (typeof manifest?.engine?.api === "string") {
engine.api = manifest.engine.api;
}
if (typeof manifest?.engine?.termix === "string") {
engine.termix = manifest.engine.termix;
}
desktopMode = loaded
? resolvePluginDesktopMode(loaded.manifest)
: (manifest?.desktop ??
@@ -354,8 +368,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state: loaded?.state ?? record.state,
state:
record.state === "enabled" &&
loaded?.state !== "active" &&
!mayRunInSafeMode(record.id, loader.list())
? "safe-mode"
: (loaded?.state ?? record.state),
runsOnServer: linked && desktopMode === "server",
engine,
contributes,
icon,
dependencies,
@@ -936,7 +956,7 @@ router.delete(
* 404:
* description: No such plugin.
* 409:
* description: This desktop is linked to a server, which decides whether the plugin runs.
* description: This desktop is linked to a server, which decides whether the plugin runs, or safe mode is on and the plugin is not a layout or theme.
*/
router.patch(
"/:id/state",
@@ -1050,6 +1070,8 @@ router.patch(
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin, or it is not on disk.
* 409:
* description: Safe mode is on and the plugin is not a layout or theme.
* 500:
* description: The plugin failed to start again.
*/
@@ -1073,6 +1095,13 @@ router.post(
res.status(404).json({ error: "Plugin is not present on disk" });
return;
}
if (!mayRunInSafeMode(pluginId, loader.list())) {
res.status(409).json({
error: "Safe mode is on, so only layout and theme plugins can start",
code: "SAFE_MODE",
});
return;
}
await repository.update(pluginId, {
state: "enabled",
@@ -2677,6 +2706,10 @@ router.put(
* summary: Upload a .tmxplug to install from a file
* description: >
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
The release's .sig may come base64 encoded in the
X-Termix-Plugin-Signature header; an official one makes the file
install as an official plugin, and it may then fill in a plugin
whose download failed.
* The file is checked (archive, manifest, plugin API) and held for 30
* minutes, but nothing is loaded. The answer lists what it asks for so
* the admin can agree, then POST /plugins/upload/{token}/install
@@ -2722,7 +2755,13 @@ router.post(
}
try {
const { stageUpload } = await import("../../plugins/manage.js");
res.json(await stageUpload(req.body));
// The release's .sig, base64 encoded, so an official file installs as
// official (and may write the tables its 2.8 version left).
const header = req.get("x-termix-plugin-signature");
const signature = header
? Buffer.from(header, "base64").toString("utf8")
: null;
res.json(await stageUpload(req.body, signature));
} catch (error) {
sendManageError(
res,
+209 -3
View File
@@ -11,6 +11,7 @@ import {
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import {
assertHostsUnlocked,
hostLock,
hasHostLocks,
requireUnlockedHost,
sendHostLocked,
@@ -36,8 +37,10 @@ import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSharedFolderAuthOverrideRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
const router = express.Router();
@@ -722,14 +725,40 @@ router.get(
},
);
/** The host grants that went to the same user or role as a folder rule. */
export function grantsForFolderRule<
T extends { userId?: string | null; roleId?: number | null },
>(entries: T[], rule: { userId?: string | null; roleId?: number | null }): T[] {
return entries.filter((entry) =>
rule.userId
? entry.userId === rule.userId
: rule.roleId != null && entry.roleId === rule.roleId,
);
}
/**
* @openapi
* /rbac/folder/access/{id}:
* delete:
* summary: Stop a folder share from applying to hosts added later
* description: Grants already fanned out to hosts stay; revoke those per host.
* description: Grants already fanned out to hosts stay, unless fromHosts=true, which also removes the same user's or role's access from every host in the folder and its subfolders that the caller may manage sharing on.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: fromHosts
* schema:
* type: boolean
* responses:
* 200:
* description: Removed. removedFromHosts counts the host grants removed.
* 404:
* description: No such rule.
*/
router.delete(
"/folder/access/:id",
@@ -741,11 +770,35 @@ router.delete(
}
try {
const repository = createCurrentFolderAccessRepository();
if (!(await repository.findById(ruleId, req.userId!))) {
const rule = await repository.findById(ruleId, req.userId!);
if (!rule) {
return res.status(404).json({ error: "Rule not found" });
}
await repository.deleteById(ruleId, req.userId!);
res.json({ success: true });
let removedFromHosts = 0;
if (req.query.fromHosts === "true") {
const hosts =
await createCurrentHostFolderRepository().listHostsInFolder(
req.userId!,
rule.folder,
);
const access = createCurrentRbacAccessRepository();
for (const host of hosts) {
if (hostLock(host.id)) continue;
if (!(await canManageHostSharing(req.userId!, host.id)).allowed)
continue;
let changed = false;
const entries = await access.listHostAccess(host.id);
for (const entry of grantsForFolderRule(entries, rule)) {
await access.revokeHostAccess(entry.id, host.id);
removedFromHosts++;
changed = true;
}
if (changed) await pruneHostGrants(host.id);
}
}
res.json({ success: true, removedFromHosts });
} catch (error) {
databaseLogger.error("Failed to delete folder access rule", error, {
operation: "delete_folder_access",
@@ -2174,4 +2227,157 @@ router.get(
},
);
/** A folder path as hosts store it ("Parent / Child"), or null. */
function folderParam(value: unknown): string | null {
if (typeof value !== "string") return null;
const folder = value
.split(" / ")
.map((part) => part.trim())
.filter(Boolean)
.join(" / ");
return folder && folder.length <= 255 ? folder : null;
}
/**
* @openapi
* /rbac/folder-auth/{protocol}:
* get:
* summary: Get the current user's personal login for shared hosts in a folder
* tags: [RBAC]
* parameters:
* - in: path
* name: protocol
* required: true
* schema: { type: string }
* - in: query
* name: folder
* required: true
* schema: { type: string }
* responses:
* 200:
* description: The folder, protocol and credential id, or null when none is set.
* 400:
* description: Invalid folder or protocol.
*/
router.get(
"/folder-auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const protocol = req.params.protocol;
const folder = folderParam(req.query.folder);
if (!folder) return res.status(400).json({ error: "Invalid folder" });
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
const credentialId =
await createCurrentSharedFolderAuthOverrideRepository().findCredentialId(
userId,
folder,
protocol,
);
res.json({ folder, protocol, credentialId });
} catch (error) {
databaseLogger.error("Failed to get folder login", error);
res.status(500).json({ error: "Failed to fetch credential" });
}
},
);
/**
* @openapi
* /rbac/folder-auth/{protocol}:
* put:
* summary: Set the current user's personal login for shared hosts in a folder
* description: >
* Used for every host shared with the user in this folder and its
* subfolders that has no personal login of its own. credentialId null
* clears it.
* tags: [RBAC]
* parameters:
* - in: path
* name: protocol
* required: true
* schema: { type: string }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [folder, credentialId]
* properties:
* folder: { type: string }
* credentialId: { type: integer, nullable: true }
* responses:
* 200:
* description: Saved.
* 400:
* description: Invalid folder, protocol or credential id.
* 404:
* description: The credential is not one the user can use.
*/
router.put(
"/folder-auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const protocol = req.params.protocol;
const folder = folderParam(req.body?.folder);
const credentialId = req.body?.credentialId ?? null;
if (!folder) return res.status(400).json({ error: "Invalid folder" });
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
if (
credentialId !== null &&
(!Number.isInteger(credentialId) || credentialId <= 0)
) {
return res.status(400).json({
error: "credentialId must be a positive integer or null",
});
}
const repository = createCurrentSharedFolderAuthOverrideRepository();
if (credentialId === null) {
await repository.clearCredential(userId, folder, protocol);
} else {
if (!(await findUsableCredential(credentialId, userId))) {
return res.status(404).json({ error: "Credential not found" });
}
await repository.setCredential(userId, folder, protocol, credentialId);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action:
credentialId === null
? "clear_shared_folder_auth_override"
: "set_shared_folder_auth_override",
resourceType: "folder",
resourceName: folder,
details: JSON.stringify({ protocol, credentialId }),
ipAddress,
userAgent,
success: true,
}).catch(() => undefined);
res.json({ success: true, folder, protocol, credentialId });
} catch (error) {
databaseLogger.error("Failed to set folder login", error);
res.status(500).json({ error: "Failed to update credential" });
}
},
);
export default router;
@@ -0,0 +1,95 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import bcrypt from "bcryptjs";
import { authLogger } from "../../utils/logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserDeleteAccountRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account and all of their data.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body ?? {};
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
// Same cleanup as an admin delete, so plugins hear user.deleted too.
await deleteUserAndRelatedData(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
}
@@ -61,4 +61,38 @@ export function registerUserSocketTicketRoutes(
res.status(500).json({ error: "Failed to issue a socket ticket" });
}
});
/**
* @openapi
* /users/ws-ticket/api-key:
* post:
* summary: Get a WebSocket ticket for an API key
* description: For another Termix server that manages this one with an API key. Returns a token that is valid for one minute and only opens plugin WebSockets, as the key's user. It cannot call any other route.
* tags:
* - Users
* responses:
* 200:
* description: The ticket.
* 401:
* description: The request did not use an API key.
* 500:
* description: Failed to issue the ticket.
*/
router.post("/ws-ticket/api-key", authenticateJWT, async (req, res) => {
const authReq = req as AuthenticatedRequest;
if (!authReq.apiKeyId) {
return res.status(401).json({ error: "An API key is required" });
}
try {
const ticket = await authManager.issueApiKeySocketTicket(
authReq.userId,
authReq.apiKeyId,
);
res.setHeader("Cache-Control", "no-store");
res.json({ ticket });
} catch (err) {
authLogger.error("Failed to issue an API key socket ticket", err);
res.status(500).json({ error: "Failed to issue a socket ticket" });
}
});
}
+19 -113
View File
@@ -27,9 +27,11 @@ import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerTlsRoutes } from "./tls-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserSocketTicketRoutes } from "./user-socket-ticket-routes.js";
import { shownIdentity } from "../../sync/shown-identity.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
@@ -317,7 +319,7 @@ router.post("/proxy-login", async (req, res) => {
try {
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
authLogger.warn(
"Rejected proxy authentication from an untrusted source",
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
{
operation: "trusted_proxy_auth_rejected",
sourceAddress,
@@ -568,30 +570,6 @@ router.post("/logout", authenticateJWT, async (req, res) => {
}
});
/**
* On a desktop linked to a server, the account it is signed in to there.
* That account is who the user is; the local profile only mirrors it.
*/
async function describeDesktopLink(userId: string) {
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
try {
const { getLink } = await import("../../sync/client/link-store.js");
const link = await getLink();
if (!link || link.userId !== userId) return null;
return {
serverUrl: link.serverUrl,
serverName: link.serverName,
username: link.account?.username ?? link.remoteUsername,
isAdmin: !!link.account?.isAdmin,
roles: link.account?.roles ?? [],
permissions: link.account?.permissions ?? [],
status: link.status,
};
} catch {
return null;
}
}
/**
* @openapi
* /users/me:
@@ -624,18 +602,19 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
const isDualAuth = hasPassword && isExternalAccount(user);
const shown = await shownIdentity(user);
res.json({
userId: user.id,
username: user.username,
is_admin: !!user.isAdmin,
username: shown.username,
is_admin: shown.isAdmin,
is_external: isExternalAccount(user),
is_dual_auth: isDualAuth,
// Any second factor; the name is what 2.8 clients read.
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id,
),
linked: await describeDesktopLink(user.id),
linked: shown.linked,
// "second-factor-setup" while the login policy wants a factor set up.
restriction: (req as AuthenticatedRequest).sessionRestriction ?? null,
});
@@ -743,7 +722,11 @@ router.post("/internal/auto-session", async (req, res) => {
const userRepository = createCurrentUserRepository();
const allUsers = await userRepository.listAll();
const userRecord = resolveDesktopAutoSessionUser(allUsers);
const { getSavedActiveUserId } = await import("../../sync/accounts.js");
const userRecord = resolveDesktopAutoSessionUser(
allUsers,
await getSavedActiveUserId(),
);
if (!userRecord) {
return res.status(403).json({
error: "No local users exist",
@@ -754,10 +737,11 @@ router.post("/internal/auto-session", async (req, res) => {
if (existingToken) {
const existingPayload = await authManager.verifyJWTToken(existingToken);
if (existingPayload?.userId === userRecord.id) {
const shownNow = await shownIdentity(userRecord);
return res.json({
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
is_admin: shownNow.isAdmin,
username: shownNow.username,
token: existingToken,
});
}
@@ -769,10 +753,11 @@ router.post("/internal/auto-session", async (req, res) => {
rememberMe: true,
});
const shown = await shownIdentity(userRecord);
const response = {
success: true,
is_admin: !!userRecord.isAdmin,
username: userRecord.username,
is_admin: shown.isAdmin,
username: shown.username,
token,
};
@@ -1245,86 +1230,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
}
});
/**
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body;
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRecord = await findCurrentUser(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin) {
const adminCount = await createCurrentUserRepository().countAdmins();
if (adminCount <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
}
await createCurrentUserRepository().delete(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
registerUserDeleteAccountRoutes(router, authenticateJWT);
registerUserPasswordResetRoutes(router, { authManager });
@@ -0,0 +1,52 @@
/**
* Other ways to reach a host, provided by plugins through
* ctx.ssh.transports.provide. A provider claims some hosts by id and hands the
* pipeline an open stream to them instead of a TCP connection, for example a
* relay through another Termix server. The first provider that claims a host
* wins, and nothing else (jump hosts, proxies) is tried for it.
*/
import type { Duplex } from "node:stream";
import type {
PluginSshTransportHost,
PluginSshTransportProvider,
} from "@termix-ssh/plugin-sdk/backend";
interface Entry {
pluginId: string;
provider: PluginSshTransportProvider;
}
const providers = new Map<symbol, Entry>();
export function provideSshTransport(
pluginId: string,
provider: PluginSshTransportProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearSshTransports(): void {
providers.clear();
}
/** Opens a stream to the host if a provider claims it, else null. */
export async function openProvidedTransport(
host: PluginSshTransportHost,
): Promise<Duplex | null> {
for (const { provider } of providers.values()) {
let claimed = false;
try {
claimed = provider.claims(host.id, host);
} catch {
claimed = false;
}
if (claimed) return provider.open(host);
}
return null;
}
+18 -3
View File
@@ -1,6 +1,6 @@
/**
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5
* proxy, in that order. Sets config.sock and
* How the bytes get to a host: a transport a plugin provides, else port
* knocking, a jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and
* returns the jump client so the caller can close it with the connection.
*/
@@ -13,6 +13,7 @@ import {
} from "../../utils/socks5-helper.js";
import type { ProxyNode } from "../../../types/index.js";
import { createJumpHostChain } from "../jump-host-chain.js";
import { openProvidedTransport } from "./transport-providers.js";
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
import { performPortKnocking } from "../terminal-auth-helpers.js";
import type {
@@ -59,7 +60,7 @@ export interface OpenTransportOptions {
export interface OpenedTransport {
jumpClient: Client | null;
via: "direct" | "proxy" | "jump";
via: "direct" | "proxy" | "jump" | "provided";
}
function forwardThrough(
@@ -94,6 +95,20 @@ export async function openSshTransport(
config: MutableConnectConfig,
options: OpenTransportOptions = {},
): Promise<OpenedTransport> {
const provided = await openProvidedTransport({
id: host.id,
ip: host.ip,
port: host.port || 22,
userId: host.userId ?? null,
syncId: host.syncId ?? null,
connectionOrigin: host.connectionOrigin ?? null,
localOnly: Boolean(host.localOnly),
});
if (provided) {
config.sock = provided;
return { jumpClient: null, via: "provided" };
}
if (
options.portKnock !== false &&
Array.isArray(host.portKnockSequence) &&
+3
View File
@@ -24,6 +24,9 @@ export interface SshConnectHost {
port: number;
username: string;
userId?: string | null;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
localOnly?: boolean | null;
authType?: string | null;
password?: string | null;
key?: string | Buffer | null;
+2
View File
@@ -73,6 +73,8 @@ async function exportRow(
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
color: resolvedHost.color || null,
icon: resolvedHost.icon || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: pluginSettings.get(host.id as number) ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
+2
View File
@@ -28,6 +28,7 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
import {
isNonEmptyString,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
} from "../../database/routes/host-normalizers.js";
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
@@ -571,6 +572,7 @@ export async function runBulkImport(
? "true"
: "false",
notes: hostData.notes || null,
...normalizeHostLookFields(hostData as Record<string, unknown>),
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
+12 -5
View File
@@ -87,11 +87,18 @@ export async function resolveHostCredentials(
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
ssh: resolution.folder
? {
required: false,
ownerAuthShared: !!host.shareSshAuth,
folder: resolution.folder,
folderCredentialId: resolution.credentialId,
}
: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
authType:
credential.key || credential.privateKey
+114
View File
@@ -0,0 +1,114 @@
/**
* A relay to a host's SSH port, for another Termix server that manages this
* one. It opens a WebSocket at /plugin-ws/core/ssh-relay with an API key
* ticket, names a host, and the bytes of an SSH connection run through it.
* This server reaches the host the way it always does, jump hosts, proxy and
* port knocking included, so the other server never needs a route to it. The
* SSH session itself stays between that server and the host.
*
* The caller needs connect access to the host, the same as for a session.
*/
import net from "node:net";
import type { Duplex } from "node:stream";
import { createWebSocketStream, type WebSocket } from "ws";
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
import { resolveHostById, resolveHostBySyncId } from "./host-resolver.js";
import { openSshTransport } from "./connect/transport.js";
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
import { sshLogger } from "../utils/logger.js";
import { registerCoreWsRoute } from "../plugins/ws.js";
const MAX_RELAYS_PER_USER = 100;
const open = new Map<string, number>();
function close(ws: WebSocket, code: number, reason: string): void {
try {
ws.close(code, reason);
} catch {
// Already closed.
}
}
async function handleRelay(
connection: PluginWebSocketConnection,
): Promise<void> {
const ws = connection.socket as WebSocket;
const url = String((connection.request as { url?: string }).url ?? "");
const params = new URL(url, "http://relay").searchParams;
const hostId = Number(params.get("hostId"));
// A linked desktop knows its hosts by sync id, not by this server's id.
const syncId = params.get("syncId");
const userId = connection.userId;
if (!userId || (!syncId && (!Number.isSafeInteger(hostId) || hostId <= 0))) {
close(ws, 1008, "A host is required");
return;
}
if ((open.get(userId) ?? 0) >= MAX_RELAYS_PER_USER) {
close(ws, 1013, "Too many connections");
return;
}
const host = syncId
? await resolveHostBySyncId(syncId, userId)
: await resolveHostById(hostId, userId);
if (!host) {
close(ws, 1008, "Host not found or access denied");
return;
}
open.set(userId, (open.get(userId) ?? 0) + 1);
let stream: Duplex | null = null;
let jumpEnd: (() => void) | null = null;
let done = false;
const finish = () => {
if (done) return;
done = true;
open.set(userId, Math.max(0, (open.get(userId) ?? 1) - 1));
stream?.destroy();
jumpEnd?.();
close(ws, 1000, "Closed");
};
ws.once("close", finish);
try {
const sshHost = host as unknown as SshConnectHost;
const config = {
host: sshHost.ip,
port: sshHost.port || 22,
} as MutableConnectConfig;
const { jumpClient } = await openSshTransport(sshHost, config);
jumpEnd = () => jumpClient?.end();
stream = (config.sock as Duplex | undefined) ?? null;
if (!stream) {
stream = net.connect({
host: String(config.host),
port: Number(config.port),
});
}
const pipe = createWebSocketStream(ws);
stream.once("error", finish);
stream.once("close", finish);
pipe.once("error", finish);
stream.pipe(pipe);
pipe.pipe(stream);
} catch (error) {
sshLogger.warn("A relay could not reach its host", {
operation: "ssh_relay_open",
hostId: syncId ? undefined : hostId,
syncId,
error: error instanceof Error ? error.message : String(error),
});
finish();
}
}
let registered = false;
/** Mounts the relay once, for servers that other Termix servers manage. */
export function registerSshRelay(): void {
if (registered) return;
registered = true;
registerCoreWsRoute("/ssh-relay", handleRelay);
}
+21
View File
@@ -14,3 +14,24 @@ export function startablePluginIds(
.map((record) => record.id),
);
}
/**
* Plugins that shipped in an earlier image and were left on, but whose code is
* not in this one (a smaller image tag). They get fetched from the registry.
*/
export function missingShippedPluginIds(
records: { id: string; tier: string; state: string }[],
presentIds: ReadonlySet<string>,
ignored: ReadonlySet<string>,
): string[] {
return records
.filter(
(record) =>
record.tier === "bundled" &&
record.state !== "disabled" &&
!presentIds.has(record.id) &&
!ignored.has(record.id),
)
.map((record) => record.id)
.sort();
}
+1
View File
@@ -157,6 +157,7 @@ export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
customHeaders: link.customHeaders,
basicAuth: link.basicAuth,
allowInvalidCertificate: link.allowInvalidCertificate,
clientCert: link.clientCert,
},
pathname,
);
+25
View File
@@ -1041,6 +1041,31 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
},
},
folders: {
ensure: async (name, style) => {
await requireWrite();
const userId = actingUser();
const { createCurrentHostFolderRepository } =
await import("../database/repositories/factory.js");
await createCurrentHostFolderRepository().upsertMetadata(
userId,
name,
style?.color ?? null,
style?.icon ?? null,
);
},
removeEmpty: async (name) => {
await requireWrite();
const userId = actingUser();
const { createCurrentHostFolderRepository } =
await import("../database/repositories/factory.js");
return createCurrentHostFolderRepository().deleteEmptyFolderRecords(
userId,
name,
);
},
},
activity: {
provide: (provider) => {
if (!declared.includes("hosts:activity-provide")) {
+21
View File
@@ -43,6 +43,7 @@ import {
registerSecondFactor,
} from "../auth/registry.js";
import { provideAuthPolicy } from "../auth/policy.js";
import { provideSshTransport } from "../hosts/connect/transport-providers.js";
import type { VerifiedIdentity } from "../auth/types.js";
import type {
MutableConnectConfig,
@@ -296,6 +297,26 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
}`;
return {
transports: {
provide: (provider) => {
if (!declared.includes("ssh:transport")) {
throw capabilityRefused(pluginId, "ssh:transport");
}
const remove = provideSshTransport(pluginId, provider);
void audit(
"ssh_transport_provide",
`${pluginId} carries host connections`,
{
success: true,
},
);
const drop = bag.add(remove, "ssh transport");
return () => {
drop();
remove();
};
},
},
probe: async (hostId, probeOptions) => {
await assertCapability(pluginId, "ssh:connect", declared);
if (!Number.isInteger(hostId) || hostId <= 0) return false;
+35 -6
View File
@@ -54,6 +54,12 @@ import {
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
import * as syncRegistry from "./sync-registry.js";
import { provideSync } from "../sync/provider.js";
import { createSyncHost } from "../sync/host.js";
import type {
PluginSync,
PluginSyncHost,
} from "@termix-ssh/plugin-sdk/backend";
import {
needsExplicitPersist,
resolveDatabaseDialect,
@@ -189,6 +195,34 @@ export function createPluginContext(
handle: PluginHandle,
): PluginContext {
const pluginId = manifest.id;
let syncHost: PluginSyncHost | undefined;
const syncApi: PluginSync = {
registerEntity: (entity) => {
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
provide: (provider) => {
if (!manifest.capabilities.includes("sync:provide")) {
throw capabilityRefused(pluginId, "sync:provide");
}
const remove = provideSync(pluginId, provider);
const drop = handle.bag.add(remove, "sync provider");
return () => {
drop();
remove();
};
},
} as PluginSync;
// Not enumerable, so tools that walk the context do not trip the check.
Object.defineProperty(syncApi, "host", {
enumerable: false,
get() {
if (!manifest.capabilities.includes("sync:provide")) {
throw capabilityRefused(pluginId, "sync:provide");
}
return (syncHost ??= createSyncHost());
},
});
const auditCall = (
action: string,
details: string,
@@ -520,12 +554,7 @@ export function createPluginContext(
},
},
sync: {
registerEntity: (entity) => {
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
},
},
sync: syncApi,
kv: {
get: (key) => kvGet(key),
+20 -3
View File
@@ -38,6 +38,20 @@ export function isUserPluginFile(
);
}
/** Whether a failed import from a plugin should try core's copy. */
export function shouldUseHostModule(
error: unknown,
specifier: string,
parentURL: string | undefined,
pluginsDir: string,
): boolean {
const code = (error as { code?: string })?.code;
// import() says ERR_MODULE_NOT_FOUND, a CommonJS require MODULE_NOT_FOUND.
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
return false;
return isBareSpecifier(specifier) && isUserPluginFile(parentURL, pluginsDir);
}
export function installHostModuleFallback(): void {
if (installed) return;
installed = true;
@@ -49,9 +63,12 @@ export function installHostModuleFallback(): void {
return nextResolve(specifier, context);
} catch (error) {
if (
(error as { code?: string })?.code !== "ERR_MODULE_NOT_FOUND" ||
!isBareSpecifier(specifier) ||
!isUserPluginFile(context.parentURL, getPluginsDir())
!shouldUseHostModule(
error,
specifier,
context.parentURL,
getPluginsDir(),
)
) {
throw error;
}
+20
View File
@@ -23,6 +23,11 @@ import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js";
import { startablePluginIds } from "./boot-state.js";
import {
mayRunInSafeMode,
safeModeOn,
safeModePluginIds,
} from "./safe-mode.js";
import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
@@ -237,6 +242,10 @@ export async function initializePlugins(
if (loaded.length === 0) return [];
const fresh = await seedPlugins(loaded, options.freshInstall === true);
if (!fresh) {
const { restoreMissingPlugins } = await import("./manage.js");
loaded.push(...(await restoreMissingPlugins()));
}
await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) {
const { offerPluginSetup } = await import("./onboarding.js");
@@ -248,8 +257,17 @@ export async function initializePlugins(
const records = await createCurrentPluginRepository().listAll();
const enabled = startablePluginIds(records);
const safe = safeModeOn() ? safeModePluginIds(loaded) : null;
if (safe) {
pluginLogger.warn(
"Safe mode is on (TERMIX_SAFE_MODE). Only layout and theme plugins start",
{ operation: "plugin_init" },
);
}
const candidates = loaded
.filter((plugin) => enabled.has(plugin.id))
.filter((plugin) => !safe || safe.has(plugin.id))
.map((plugin) => plugin.id);
for (const pluginId of candidates) {
@@ -539,6 +557,8 @@ export async function activatePlugin(pluginId: string): Promise<void> {
// Before activate: a plugin's own activate() may already want to check one
// of its permissions.
const plugin = pluginLoader.get(pluginId);
// Stays enabled and starts on the next boot without safe mode.
if (!mayRunInSafeMode(pluginId, pluginLoader.list())) return;
if (plugin) await registerPluginPermissions(plugin);
await pluginLoader.activate(pluginId);
+134 -9
View File
@@ -18,6 +18,7 @@ import path from "node:path";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js";
import { mayRunInSafeMode } from "./safe-mode.js";
import {
activatePlugin,
deactivatePlugin,
@@ -25,6 +26,7 @@ import {
registerLoadedPlugins,
unloadPlugin,
} from "./index.js";
import { missingShippedPluginIds } from "./boot-state.js";
import type { LoadedPlugin, PluginLoader } from "./loader.js";
import {
getBundledPluginsDir,
@@ -71,7 +73,7 @@ import {
pluginFeatures,
type PluginManifest,
} from "./manifest.js";
import { requireSignedPlugins } from "./trust.js";
import { requireSignedPlugins, verifyAnyTrusted } from "./trust.js";
import {
parseChangelog,
parseReleaseNotes,
@@ -763,6 +765,13 @@ export async function setPluginStateUnlocked(
if (!loader.get(pluginId)) {
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
if (enabled && !mayRunInSafeMode(pluginId, loader.list())) {
throw new PluginManageError(
"Safe mode is on, so only layout and theme plugins can start",
409,
"SAFE_MODE",
);
}
const plan = await planStateChange(pluginId, enabled);
if (enabled) {
@@ -959,7 +968,11 @@ export function installPlugin(
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
if (await repository.findById(pluginId)) {
// A record with no code is a plugin an older image shipped: install fills it in.
if (
(await repository.findById(pluginId)) &&
getPluginRuntime().loader.get(pluginId)
) {
throw new PluginManageError(
"Plugin is already installed",
409,
@@ -1018,6 +1031,74 @@ export function installPlugin(
});
}
/**
* Called at boot before plugins start. A plugin the database has on but the
* image no longer ships (an update to a smaller image tag) is fetched from the
* official registry, so nothing an install used goes missing. One that cannot
* be fetched stays as it was and is tried again on the next start.
*/
export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const { loader } = getPluginRuntime();
const ids = missingShippedPluginIds(
await repository.listAll(),
new Set(loader.list().map((plugin) => plugin.id)),
await readUninstalled(),
);
const restored: LoadedPlugin[] = [];
// Shown on the Plugins tab, so a plugin that could not be fetched is not
// listed as working. It stays on and is tried again on the next start.
const markMissing = (id: string, message: string) =>
recordState(
id,
"failed",
`Not in this image and could not be downloaded: ${message}`,
).catch(() => {});
for (const [index, id] of ids.entries()) {
try {
const record = await repository.findById(id);
const { source, release } = await requireRelease(
id,
record?.pinnedVersion ?? undefined,
channelOf(record),
OFFICIAL_REGISTRY_ID,
);
const plugin = await swapIn(id, { release, source });
await repository.update(id, {
registryId: source.id,
tier: tierFor(plugin, source),
});
await grantDeclared(plugin, null);
restored.push(plugin);
pluginLogger.info(
`Restored ${id}@${release.version} from the registry`,
{
operation: "plugin_restore_missing",
},
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
operation: "plugin_restore_missing",
});
await markMissing(id, message);
// The registry is down or offline, so the rest would fail the same way.
if (
error instanceof PluginManageError &&
error.code === "REGISTRY_UNREACHABLE"
) {
for (const rest of ids.slice(index + 1))
await markMissing(rest, message);
break;
}
}
}
return restored;
});
}
export function updatePlugin(
pluginId: string,
options: InstallOptions,
@@ -1444,6 +1525,8 @@ interface StagedUpload {
file: string;
manifest: PluginManifest;
expiresAt: number;
/** Set when the upload came with a .sig an official key made. */
official: boolean;
}
const uploads = new Map<string, StagedUpload>();
@@ -1467,6 +1550,29 @@ export interface UploadPreview {
capabilities: string[];
/** The version of an earlier upload this one replaces. */
replaces: string | null;
/** The .sig sent with it was made by the official Termix key. */
official: boolean;
}
/**
* Checks a .sig sent with an upload. An official signature makes the file
* the same as a registry download, so it may stand in for one that could not
* be fetched (an offline install). Anything else is refused.
*/
function checkUploadSignature(
buffer: Buffer,
signature: string,
): { official: boolean } {
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const result = verifyAnyTrusted(buffer, sha256, signature);
if (result.ok === false) {
throw new PluginManageError(
`The signature does not match this file: ${result.reason}`,
400,
"INVALID_SIGNATURE",
);
}
return { official: result.tier === "official" };
}
async function assertUploadsAllowed(): Promise<void> {
@@ -1523,9 +1629,17 @@ function validateUpload(buffer: Buffer): PluginManifest {
return manifest;
}
/** Only another upload of the same id can be replaced from a file. */
async function assertReplaceable(pluginId: string): Promise<string | null> {
/**
* Only another upload of the same id can be replaced from a file. An
* officially signed file may also fill in a plugin whose code is missing, like
* one that was on but could not be downloaded.
*/
async function assertReplaceable(
pluginId: string,
official = false,
): Promise<string | null> {
const { loader } = getPluginRuntime();
if (official && !loader.get(pluginId)) return null;
if (loader.bundledIds().has(pluginId)) {
throw new PluginManageError(
`${pluginId} ships with Termix and can only be replaced by a signed release`,
@@ -1550,21 +1664,30 @@ async function assertReplaceable(pluginId: string): Promise<string | null> {
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
* it asks for. Nothing is loaded yet.
*/
export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
export async function stageUpload(
buffer: Buffer,
signature: string | null = null,
): Promise<UploadPreview> {
await assertUploadsAllowed();
await clearUploads(Date.now());
const manifest = validateUpload(buffer);
const replaces = await assertReplaceable(manifest.id);
const { official } = signature
? checkUploadSignature(buffer, signature)
: { official: false };
const replaces = await assertReplaceable(manifest.id, official);
const token = crypto.randomBytes(16).toString("hex");
const dir = getDownloadsDir();
await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, `upload-${token}.tmxplug`);
await fs.promises.writeFile(file, buffer);
// Next to the file, where the loader looks for it.
if (signature) await fs.promises.writeFile(`${file}.sig`, signature);
uploads.set(token, {
file,
manifest,
expiresAt: Date.now() + UPLOAD_TTL_MS,
official,
});
return {
@@ -1577,6 +1700,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
features: pluginFeatures(manifest.features),
capabilities: [...manifest.capabilities],
replaces,
official,
};
}
@@ -1606,7 +1730,7 @@ export function installUpload(
);
}
assertConsent(options.capabilities, manifest.capabilities);
await assertReplaceable(pluginId);
await assertReplaceable(pluginId, upload.official);
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
@@ -1619,6 +1743,7 @@ export function installUpload(
plugin = await swapIn(pluginId, { upload: upload.file });
} catch (error) {
await fs.promises.rm(upload.file, { force: true });
await fs.promises.rm(`${upload.file}.sig`, { force: true });
const { loader } = getPluginRuntime();
if (before && wasEnabled && loader.get(pluginId)) {
await startAndRecord(pluginId);
@@ -1631,7 +1756,7 @@ export function installUpload(
}
await repository.update(pluginId, {
tier: UNVERIFIED_TIER,
tier: upload.official ? "official" : UNVERIFIED_TIER,
registryId: null,
pinnedVersion: null,
autoUpdate: false,
@@ -1655,7 +1780,7 @@ export function installUpload(
}
const state = await startAndRecord(pluginId);
pluginLogger.warn(
`Installed unverified plugin ${pluginId}@${manifest.version} from a file`,
`Installed ${upload.official ? "officially signed" : "unverified"} plugin ${pluginId}@${manifest.version} from a file`,
{ operation: "plugin_install" },
);
return { id: pluginId, version: manifest.version, state };
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff. Show more