* 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>
This commit is contained in:
authored and GitHub committed 2026-10-29 17:59:55 -05:00
1 parent 73c15f7c58
commit f90621a54e
168 files changed
+35063 -339

No files matched your search

+69
View File
@@ -250,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'
@@ -488,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'
@@ -763,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: |
+33
View File
@@ -1,5 +1,38 @@
# 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
+34 -34
View File
@@ -17,8 +17,8 @@
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
"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,
@@ -42,8 +42,8 @@
{
"id": "classic-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
"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
},
@@ -72,15 +72,15 @@
{
"id": "databases",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug",
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8",
"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.2.1/docker-1.2.1.tmxplug",
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
"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,
@@ -90,8 +90,8 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
"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,
@@ -101,8 +101,8 @@
{
"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"
},
{
@@ -115,8 +115,8 @@
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
"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,
@@ -140,8 +140,8 @@
{
"id": "import-export",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.1/import-export-1.0.1.tmxplug",
"sha256": "d8f2f865e1e6b226f4fe38501bf69b752f47a643bed1d9d127181083e27da2f0",
"tier": "standard"
},
{
@@ -182,15 +182,15 @@
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
"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.2.1/remote-desktop-1.2.1.tmxplug",
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
"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,
@@ -214,8 +214,8 @@
{
"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"
},
{
@@ -232,8 +232,8 @@
{
"id": "service-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug",
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf",
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.1/service-manager-1.1.1.tmxplug",
"sha256": "5731a04336d341a330416cba7b7999f0d8e6215752eabbf978ed58c4543c5036",
"tier": "full"
},
{
@@ -260,8 +260,8 @@
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug",
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
"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,
@@ -271,8 +271,8 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
"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,
@@ -301,8 +301,8 @@
{
"id": "sync",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.1.0/sync-1.1.0.tmxplug",
"sha256": "9023d547f0e0bcef582c6ba7b7a8ab2ef68d8d0b973dde17d9e8c84d92b6429e",
"tier": "standard"
},
{
@@ -351,8 +351,8 @@
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
"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,
@@ -390,8 +390,8 @@
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
"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,
+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
}
]
}
+2 -1
View File
@@ -1246,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 {
+24
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",
+10 -10
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.5",
"version": "26.10.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.5",
"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",
+2 -2
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.5",
"version": "26.10.6",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -55,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",
+29
View File
@@ -1,5 +1,34 @@
# 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.
+66 -4
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.20 | Plugin API 1.12.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.
@@ -304,7 +346,7 @@ 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), open({ id, ip, port }) })`
- 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
@@ -335,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
@@ -451,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
@@ -905,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 |
+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.20",
"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": [
@@ -1239,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."
}
}
}
}
},
+37 -7
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. */
@@ -470,6 +486,12 @@ export interface PluginSyncHost {
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. */
@@ -1813,13 +1835,21 @@ export interface PluginNetwork {
* 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) => boolean;
open: (host: {
id: number;
ip: string;
port: number;
}) => Promise<import("node:stream").Duplex>;
claims: (hostId: number, host?: PluginSshTransportHost) => boolean;
open: (host: PluginSshTransportHost) => Promise<import("node:stream").Duplex>;
}
export interface PluginSshTransports {
+7
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 {
+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.12.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)) {
+3 -1
View File
@@ -403,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; }>
@@ -526,7 +527,7 @@ resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
resolveRemoteHostId (value): (syncId: string) => Promise<number>
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[]
@@ -544,6 +545,7 @@ 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; }>
+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)}`);
+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",
+2
View File
@@ -426,6 +426,7 @@ const ALLOWED = [
"getFolderAccess",
"getFolderDefaultsId",
"getHostAccess",
"getFolderAuthOverride",
"getHostAuthOverride",
"getHostDefaultsLevel",
"getHostEditorSection",
@@ -565,6 +566,7 @@ const ALLOWED = [
"saveUserPreferences",
"setAllPluginChannels",
"setDeveloperMode",
"setFolderAuthOverride",
"setHostAuthOverride",
"setNotificationPrivateEndpoints",
"setPluginOptions",
+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}`);
}
+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");
});
});
+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(),
@@ -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;
}
}
@@ -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,
};
+23 -2
View File
@@ -244,7 +244,9 @@ 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
@@ -303,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;
@@ -322,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 ??
@@ -365,6 +375,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
? "safe-mode"
: (loaded?.state ?? record.state),
runsOnServer: linked && desktopMode === "server",
engine,
contributes,
icon,
dependencies,
@@ -2695,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
@@ -2740,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" });
}
});
}
+3 -81
View File
@@ -31,6 +31,7 @@ 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";
@@ -318,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,
@@ -1229,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 });
@@ -7,7 +7,10 @@
*/
import type { Duplex } from "node:stream";
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
import type {
PluginSshTransportHost,
PluginSshTransportProvider,
} from "@termix-ssh/plugin-sdk/backend";
interface Entry {
pluginId: string;
@@ -33,15 +36,13 @@ export function clearSshTransports(): void {
}
/** Opens a stream to the host if a provider claims it, else null. */
export async function openProvidedTransport(host: {
id: number;
ip: string;
port: number;
}): Promise<Duplex | 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);
claimed = provider.claims(host.id, host);
} catch {
claimed = false;
}
+4
View File
@@ -99,6 +99,10 @@ export async function openSshTransport(
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;
+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
+11 -7
View File
@@ -13,7 +13,7 @@ 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 } from "./host-resolver.js";
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";
@@ -35,12 +35,13 @@ async function handleRelay(
): Promise<void> {
const ws = connection.socket as WebSocket;
const url = String((connection.request as { url?: string }).url ?? "");
const hostId = Number(
new URL(url, "http://relay").searchParams.get("hostId"),
);
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 || !Number.isSafeInteger(hostId) || hostId <= 0) {
if (!userId || (!syncId && (!Number.isSafeInteger(hostId) || hostId <= 0))) {
close(ws, 1008, "A host is required");
return;
}
@@ -49,7 +50,9 @@ async function handleRelay(
return;
}
const host = await resolveHostById(hostId, userId);
const host = syncId
? await resolveHostBySyncId(syncId, userId)
: await resolveHostById(hostId, userId);
if (!host) {
close(ws, 1008, "Host not found or access denied");
return;
@@ -93,7 +96,8 @@ async function handleRelay(
} catch (error) {
sshLogger.warn("A relay could not reach its host", {
operation: "ssh_relay_open",
hostId,
hostId: syncId ? undefined : hostId,
syncId,
error: error instanceof Error ? error.message : String(error),
});
finish();
+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;
}
+64 -9
View File
@@ -73,7 +73,7 @@ import {
pluginFeatures,
type PluginManifest,
} from "./manifest.js";
import { requireSignedPlugins } from "./trust.js";
import { requireSignedPlugins, verifyAnyTrusted } from "./trust.js";
import {
parseChangelog,
parseReleaseNotes,
@@ -1048,7 +1048,15 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
await readUninstalled(),
);
const restored: LoadedPlugin[] = [];
for (const id of ids) {
// 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(
@@ -1075,11 +1083,14 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
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;
}
}
@@ -1514,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>();
@@ -1537,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> {
@@ -1593,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`,
@@ -1620,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 {
@@ -1647,6 +1700,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
features: pluginFeatures(manifest.features),
capabilities: [...manifest.capabilities],
replaces,
official,
};
}
@@ -1676,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();
@@ -1689,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);
@@ -1701,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,
@@ -1725,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 };
+50 -2
View File
@@ -16,7 +16,7 @@ import type {
SyncRow,
} from "@termix-ssh/plugin-sdk/backend";
/** Core-only extras a plugin registration cannot set. */
/** Extras core sets for its own entities. A plugin may set the first four. */
export interface CoreSyncEntityOptions {
/** Server-to-desktop only. A desktop never pushes it. */
readOnly?: boolean;
@@ -58,6 +58,54 @@ export interface RegisteredSyncEntity
encryptedFields: readonly string[];
}
/** What a plugin registration may carry. Anything else is dropped. */
const PLUGIN_KEYS = [
"type",
"table",
"userColumn",
"encryptedFields",
"references",
"order",
"readOnlyFields",
"singleton",
"answersTo",
"shouldSync",
"permissions",
"serialize",
"deserialize",
"afterWrite",
"remove",
"readOnly",
"load",
"write",
"erase",
] as const;
function pluginEntity(entity: SyncEntityRegistration): SyncEntityRegistration {
const picked: Record<string, unknown> = {};
for (const key of PLUGIN_KEYS) {
if (entity[key] !== undefined) picked[key] = entity[key];
}
const out = picked as unknown as SyncEntityRegistration;
if (out.load || out.write || out.erase) {
if (!out.load || !out.write || !out.erase) {
throw new Error(
`Sync entity "${entity.type}" needs load, write and erase together`,
);
}
// A plugin only ever syncs its own rows both ways; rows it builds for
// a user come down as read only copies.
if (out.readOnly !== true) {
throw new Error(
`Sync entity "${entity.type}" with load must be readOnly`,
);
}
} else if (!out.table) {
throw new Error(`Sync entity "${entity.type}" needs a table or load`);
}
return out;
}
const entities = new Map<string, RegisteredSyncEntity>();
const listeners = new Set<() => void>();
@@ -81,7 +129,7 @@ export function registerEntity(
}
const registered: RegisteredSyncEntity = {
...entity,
...(owner === CORE_OWNER ? entity : pluginEntity(entity)),
...(owner === CORE_OWNER ? core : {}),
owner,
order: entity.order ?? 100,
+6
View File
@@ -38,6 +38,7 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
import {
createResolvers,
deleteStoredRow,
findStoredIds,
listStoredRows,
loadWireRow,
loadWireRows,
@@ -271,6 +272,11 @@ export function createSyncHost(): PluginSyncHost {
ensureCore: () => ensureCoreEntities(),
onChanged: (listener) => onEntitiesChanged(listener),
onLocalWrite: (listener) => onLocalWrite(listener),
idsOf: async (type, userId, syncIds) => {
const entity = getEntity(type);
if (!entity || entity.load) return {};
return findStoredIds(entity, userId, syncIds);
},
},
replica: {
+1 -1
View File
@@ -161,7 +161,7 @@ async function reconcileListeners(): Promise<void> {
}
}
function notifyListeners(userId: string): void {
export function notifyListeners(userId: string): void {
const set = listeners.get(userId);
if (!set) return;
for (const res of set) {
+7 -1
View File
@@ -25,7 +25,7 @@ import {
} from "../store.js";
import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js";
import { orderSelfReferences } from "../wire.js";
import { reconcileUser } from "./feed.js";
import { markChanged, notifyListeners, reconcileUser } from "./feed.js";
export interface PushOp {
entityType: string;
@@ -278,6 +278,12 @@ export async function applyPush(
// Refresh the snapshot so the next pull serves what was just written.
await reconcileUser(userId, { force: true });
// The reconcile finds nothing new, since the push wrote the records itself,
// so tell this user's other clients, and other users who may share a row.
if ([...results.values()].some((result) => result.status === "ok")) {
notifyListeners(userId);
markChanged();
}
return ops.map(
(op) =>
results.get(recordKey(op.entityType, op.syncId)) ?? {
+15
View File
@@ -112,6 +112,21 @@ export async function findStoredRow(
return row ?? null;
}
/** The local ids of the user's own rows, by sync id. Unknown ones are left out. */
export async function findStoredIds(
entity: RegisteredSyncEntity,
userId: string,
syncIds: string[],
): Promise<Record<string, number>> {
const ids: Record<string, number> = {};
if (entity.singleton || !entity.table) return ids;
for (const syncId of new Set(syncIds)) {
const row = await findStoredRow(entity, userId, syncId);
if (row && typeof row.id === "number") ids[syncId] = row.id;
}
return ids;
}
/** Gives an old row with no syncId one, so it can take part. */
async function ensureSyncId(
entity: RegisteredSyncEntity,
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import {
folderAndParents,
SharedFolderAuthOverrideRepository,
} from "../../../database/repositories/shared-folder-auth-override-repository.js";
import { TestSqliteDatabase } from "./test-support.js";
describe("folderAndParents", () => {
it("lists the folder, then each folder above it", () => {
expect(folderAndParents("Prod / Web / EU")).toEqual([
"Prod / Web / EU",
"Prod / Web",
"Prod",
]);
expect(folderAndParents(" Prod ")).toEqual(["Prod"]);
expect(folderAndParents("")).toEqual([]);
expect(folderAndParents(null)).toEqual([]);
});
});
describe("SharedFolderAuthOverrideRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
await adapter?.close();
adapter = null;
});
async function createRepository(onWrite?: () => void) {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('recipient', 'recipient', 'hash'), ('other', 'other', 'hash');
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
VALUES (7, 'recipient', 'cred-seven', 'password'),
(8, 'recipient', 'cred-eight', 'password');
`);
return new SharedFolderAuthOverrideRepository(context, onWrite);
}
it("sets, updates and clears a login per user, folder and protocol", async () => {
let writes = 0;
const repository = await createRepository(() => {
writes += 1;
});
await repository.setCredential("recipient", "Prod", "ssh", 7);
await repository.setCredential("recipient", "Prod", "ssh", 8);
await repository.setCredential("recipient", "Prod", "rdp", 7);
await expect(
repository.findCredentialId("recipient", "Prod", "ssh"),
).resolves.toBe(8);
await expect(
repository.findCredentialId("other", "Prod", "ssh"),
).resolves.toBeNull();
await expect(
repository.clearCredential("recipient", "Prod", "ssh"),
).resolves.toBe(true);
await expect(
repository.clearCredential("recipient", "Prod", "ssh"),
).resolves.toBe(false);
expect(writes).toBe(4);
});
it("finds the login of the closest folder above a host", async () => {
const repository = await createRepository();
await repository.setCredential("recipient", "Prod", "ssh", 7);
await repository.setCredential("recipient", "Prod / Web", "ssh", 8);
await expect(
repository.findClosest("recipient", "Prod / Web / EU", "ssh"),
).resolves.toEqual({ folder: "Prod / Web", credentialId: 8 });
await expect(
repository.findClosest("recipient", "Prod / DB", "ssh"),
).resolves.toEqual({ folder: "Prod", credentialId: 7 });
await expect(
repository.findClosest("recipient", "Staging", "ssh"),
).resolves.toBeNull();
await expect(
repository.findClosest("recipient", "Prod", "rdp"),
).resolves.toBeNull();
await expect(
repository.findClosest("recipient", null, "ssh"),
).resolves.toBeNull();
});
it("drops the login when its credential is deleted", async () => {
const repository = await createRepository();
await repository.setCredential("recipient", "Prod", "ssh", 7);
await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`);
await expect(
repository.findCredentialId("recipient", "Prod", "ssh"),
).resolves.toBeNull();
});
});
@@ -214,3 +214,43 @@ describe("host.created", () => {
}
});
});
describe("host color and icon", () => {
async function stored(id: number) {
const [row] = await ctx.drizzle
.select({ color: hosts.color, icon: hosts.icon })
.from(hosts)
.where(eq(hosts.id, id));
return row;
}
it("saves them on create and keeps them when an update leaves them out", async () => {
const created = await request(app)
.post("/host/db/host")
.send({
...base,
authType: "password",
password: "pw",
color: "#22C55E",
icon: "database",
});
expect(created.status).toBe(200);
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, color: null, icon: null });
expect(await stored(created.body.id)).toEqual({ color: null, icon: null });
});
});
@@ -8,6 +8,7 @@ import {
keepsStoredSshAuth,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
normalizeProtocolEnableFields,
renameFolderPath,
@@ -529,3 +530,25 @@ describe("impliedSshAuthType", () => {
expect(impliedSshAuthType({})).toBeUndefined();
});
});
describe("normalizeHostLookFields", () => {
it("leaves both fields out when the write does not send them", () => {
expect(normalizeHostLookFields({ name: "web" })).toEqual({});
});
it("keeps a hex color and a Lucide name", () => {
expect(
normalizeHostLookFields({ color: "#3B82F6", icon: "server-cog" }),
).toEqual({ color: "#3b82f6", icon: "server-cog" });
});
it("stores null for cleared or bad values", () => {
expect(normalizeHostLookFields({ color: "red", icon: "<script>" })).toEqual(
{ color: null, icon: null },
);
expect(normalizeHostLookFields({ color: null, icon: "" })).toEqual({
color: null,
icon: null,
});
});
});
@@ -488,6 +488,35 @@ describe("plugins route", () => {
}
});
it("lists a plugin's engine ranges to any user", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({
manifestJson: JSON.stringify({
engine: { api: "^1.13", termix: ">=26.10.6", extra: 1 },
}),
}),
);
state.plugins.set(
"bad-engine",
makePlugin({
id: "bad-engine",
manifestJson: JSON.stringify({ engine: { api: 2 } }),
}),
);
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
const body = (await res.json()) as Array<{ id: string; engine: unknown }>;
expect(body.find((p) => p.id === "sample-plugin")?.engine).toEqual({
api: "^1.13",
termix: ">=26.10.6",
});
expect(body.find((p) => p.id === "bad-engine")?.engine).toEqual({});
});
it("reports an enabled plugin that safe mode keeps off", async () => {
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
@@ -643,6 +672,7 @@ describe("plugins route", () => {
enabled: true,
state: "enabled",
runsOnServer: false,
engine: {},
contributes: null,
dependencies: {},
optionalDependencies: {},
@@ -8,9 +8,14 @@ const state = vi.hoisted(() => ({
isShared: true,
isAdminBypass: false,
},
credentialOwned: true,
credentialUsable: true,
credentialId: 7 as number | null,
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
folderWrites: [] as Array<{
folder: string;
protocol: string;
credentialId: number | null;
}>,
auditCalls: [] as Array<Record<string, unknown>>,
}));
@@ -70,11 +75,12 @@ vi.mock("../../../utils/permission-manager.js", () => ({
},
}));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
state.credentialUsable ? { id: credentialId } : null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCredentialRepository: () => ({
findByIdForUser: async () =>
state.credentialOwned ? { id: state.credentialId } : null,
}),
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.credentialId,
setCredential: async (
@@ -96,6 +102,25 @@ vi.mock("../../../database/repositories/factory.js", () => ({
return true;
},
}),
createCurrentSharedFolderAuthOverrideRepository: () => ({
findCredentialId: async () => 7,
setCredential: async (
_userId: string,
folder: string,
protocol: string,
credentialId: number,
) => {
state.folderWrites.push({ folder, protocol, credentialId });
},
clearCredential: async (
_userId: string,
folder: string,
protocol: string,
) => {
state.folderWrites.push({ folder, protocol, credentialId: null });
return true;
},
}),
createCurrentUserRepository: () => ({
findById: async () => ({ id: "recipient", username: "recipient" }),
}),
@@ -108,6 +133,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
vi.mock("../../../utils/audit-logger.js", () => ({
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
getAuditUsername: async (userId: string) => userId,
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
state.auditCalls.push(entry);
}),
@@ -133,6 +159,8 @@ describe("shared host authentication override routes", () => {
method: "get" | "put",
body: Record<string, unknown> = {},
protocol = "ssh",
path = "/host-access/:hostId/auth/:protocol",
query: Record<string, unknown> = {},
): Promise<{ status: number; body: unknown }> {
const routeLayer = (
router as unknown as {
@@ -151,15 +179,14 @@ describe("shared host authentication override routes", () => {
}>;
}
).stack.find(
(layer) =>
layer.route?.path === "/host-access/:hostId/auth/:protocol" &&
layer.route.methods[method],
(layer) => layer.route?.path === path && layer.route.methods[method],
);
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
const req = {
params: { hostId: "42", protocol },
query,
body,
headers: {},
ip: "127.0.0.1",
@@ -206,12 +233,106 @@ describe("shared host authentication override routes", () => {
isShared: true,
isAdminBypass: false,
};
state.credentialOwned = true;
state.credentialUsable = true;
state.credentialId = 7;
state.writes = [];
state.folderWrites = [];
state.auditCalls = [];
});
describe("folder logins", () => {
const FOLDER = "/folder-auth/:protocol";
it("reads the login for a folder", async () => {
const response = await invoke("get", {}, "ssh", FOLDER, {
folder: " Prod / Web ",
});
expect(response).toEqual({
status: 200,
body: { folder: "Prod / Web", protocol: "ssh", credentialId: 7 },
});
});
it("sets and clears the login and audits both", async () => {
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 8 },
"ssh",
FOLDER,
)
).status,
).toBe(200);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: null },
"ssh",
FOLDER,
)
).status,
).toBe(200);
expect(state.folderWrites).toEqual([
{ folder: "Prod", protocol: "ssh", credentialId: 8 },
{ folder: "Prod", protocol: "ssh", credentialId: null },
]);
expect(state.auditCalls.map((call) => call.action)).toEqual([
"set_shared_folder_auth_override",
"clear_shared_folder_auth_override",
]);
});
it("rejects a bad folder, protocol or credential", async () => {
expect(
(await invoke("get", {}, "ssh", FOLDER, { folder: "" })).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "x".repeat(300), credentialId: 8 },
"ssh",
FOLDER,
)
).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 8 },
"smtp",
FOLDER,
)
).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: -1 },
"ssh",
FOLDER,
)
).status,
).toBe(400);
state.credentialUsable = false;
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 99 },
"ssh",
FOLDER,
)
).status,
).toBe(404);
expect(state.folderWrites).toEqual([]);
});
});
it("returns the current override for a role-derived shared recipient", async () => {
const response = await invoke("get");
expect(response).toEqual({
@@ -254,7 +375,7 @@ describe("shared host authentication override routes", () => {
const invalidResponse = await invoke("put", { credentialId: 0 });
expect(invalidResponse.status).toBe(400);
state.credentialOwned = false;
state.credentialUsable = false;
const foreignResponse = await invoke("put", { credentialId: 99 });
expect(foreignResponse.status).toBe(404);
@@ -69,7 +69,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
const { pruneHostGrants, pruneCredentialGrants } =
const { pruneHostGrants, pruneCredentialGrants, grantsForFolderRule } =
await import("../../../database/routes/rbac.js");
const chain = (): Grant[] => [
@@ -105,3 +105,27 @@ describe("share pruning", () => {
expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]);
});
});
describe("grantsForFolderRule", () => {
const entries = [
{ id: 1, userId: "bob", roleId: null },
{ id: 2, userId: null, roleId: 7 },
{ id: 3, userId: "carol", roleId: null },
];
it("picks the grants of the rule's user or role", () => {
expect(
grantsForFolderRule(entries, { userId: "bob", roleId: null }).map(
(e) => e.id,
),
).toEqual([1]);
expect(
grantsForFolderRule(entries, { userId: null, roleId: 7 }).map(
(e) => e.id,
),
).toEqual([2]);
expect(
grantsForFolderRule(entries, { userId: null, roleId: null }),
).toEqual([]);
});
});
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, RequestHandler, Response } from "express";
const state = vi.hoisted(() => ({
users: new Map<
string,
{
id: string;
username: string;
isAdmin: boolean;
isExternal: boolean;
passwordHash: string;
}
>(),
adminCount: 1,
deleted: [] as string[],
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("bcryptjs", () => ({
default: {
compare: async (password: string, hash: string) =>
hash === `hash:${password}`,
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
countAdmins: async () => state.adminCount,
}),
}));
vi.mock("../../../database/routes/delete-user-data.js", () => ({
deleteUserAndRelatedData: async (userId: string) => {
state.deleted.push(userId);
},
}));
const { registerUserDeleteAccountRoutes } =
await import("../../../database/routes/user-delete-account-routes.js");
let handler: RequestHandler | null = null;
registerUserDeleteAccountRoutes(
{
delete: (_path: string, ...handlers: RequestHandler[]) => {
handler = handlers[handlers.length - 1];
},
} as unknown as import("express").Router,
(_req, _res, next) => next(),
);
async function deleteAccount(userId: string, body: Record<string, unknown>) {
const req = { userId, body } as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as Record<string, unknown> | null,
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: Record<string, unknown>) {
this.jsonBody = payload;
return this;
},
};
await handler!(req, res as unknown as Response, () => {});
return res;
}
beforeEach(() => {
state.adminCount = 1;
state.deleted = [];
state.users = new Map([
[
"u1",
{
id: "u1",
username: "user",
isAdmin: false,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"admin1",
{
id: "admin1",
username: "admin",
isAdmin: true,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"sso1",
{
id: "sso1",
username: "sso",
isAdmin: false,
isExternal: true,
passwordHash: "hash:secret",
},
],
]);
});
describe("DELETE /delete-account", () => {
it("runs the full user cleanup", async () => {
const res = await deleteAccount("u1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["u1"]);
});
it("needs a password", async () => {
const res = await deleteAccount("u1", {});
expect(res.statusCode).toBe(400);
expect(state.deleted).toEqual([]);
});
it("refuses a wrong password", async () => {
const res = await deleteAccount("u1", { password: "nope" });
expect(res.statusCode).toBe(401);
expect(state.deleted).toEqual([]);
});
it("refuses external accounts", async () => {
const res = await deleteAccount("sso1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("refuses the last admin", async () => {
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("lets an admin go when another admin is left", async () => {
state.adminCount = 2;
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["admin1"]);
});
it("404s for an unknown user", async () => {
const res = await deleteAccount("ghost", { password: "secret" });
expect(res.statusCode).toBe(404);
});
});
@@ -37,6 +37,29 @@ afterEach(() => {
});
describe("ssh transport providers", () => {
it("tells providers the owner, sync id and origin of the host", async () => {
const claims = vi.fn(() => false);
provideSshTransport("a", { claims, open: vi.fn() });
await openSshTransport(
{
...host,
userId: "u1",
syncId: "s1",
connectionOrigin: "remote",
} as never,
{} as never,
).catch(() => {});
expect(claims).toHaveBeenCalledWith(7, {
id: 7,
ip: "10.0.0.7",
port: 22,
userId: "u1",
syncId: "s1",
connectionOrigin: "remote",
localOnly: false,
});
});
it("opens nothing when no provider claims the host", async () => {
provideSshTransport("a", { claims: () => false, open: vi.fn() });
expect(await openProvidedTransport(host)).toBeNull();
@@ -6,6 +6,7 @@ const state = vi.hoisted(() => ({
isAdminBypass: false,
overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(),
sharedCredentials: new Map<string, Record<string, unknown>>(),
auditCalls: [] as Record<string, unknown>[],
sharedSecret: null as Record<string, unknown> | null,
activeHostAccessId: null as number | null,
@@ -37,6 +38,13 @@ vi.mock("../../database/repositories/factory.js", () => ({
}),
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number, userId: string) =>
state.credentials.get(`${credentialId}:${userId}`) ??
state.sharedCredentials.get(`${credentialId}:${userId}`) ??
null,
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (params: Record<string, unknown>) => {
state.auditCalls.push(params);
@@ -125,6 +133,7 @@ beforeEach(() => {
state.isAdminBypass = false;
state.overrideCredentialId = null;
state.credentials.clear();
state.sharedCredentials.clear();
state.auditCalls = [];
state.sharedSecret = null;
state.activeHostAccessId = null;
@@ -376,6 +385,61 @@ describe("resolveHostById", () => {
expect(host.username).toBe("shared-login");
});
it("uses a credential shared with the recipient as their personal override", async () => {
state.overrideCredentialId = 7;
state.sharedCredentials.set("7:recipient", {
id: 7,
username: "shared-cred-user",
password: "shared-cred-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
unknown
>;
expect(host.username).toBe("shared-cred-user");
expect(host.password).toBe("shared-cred-pass");
});
it("uses the host's credential when it is also shared with the recipient", async () => {
state.host = baseHost({
authType: "credential",
password: null,
credentialId: 1,
});
state.sharedCredentials.set("1:recipient", {
id: 1,
username: "deploy",
password: null,
privateKey: "OWNER-KEY-COPY",
key: null,
keyPassword: null,
keyType: "ssh-ed25519",
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
unknown
>;
expect(host.authType).toBe("key");
expect(host.key).toBe("OWNER-KEY-COPY");
expect(host.username).toBe("deploy");
});
it("denies a recipient when the host's credential is not shared with them", async () => {
state.host = baseHost({
authType: "credential",
password: null,
credentialId: 1,
});
expect(await resolveHostById(42, "recipient")).toBeNull();
});
it("denies a non-owner when a secret-bearing host has no personal credential", async () => {
expect(await resolveHostById(42, "recipient")).toBeNull();
});
+19
View File
@@ -7,6 +7,7 @@ import { WebSocket, WebSocketServer } from "ws";
const mocks = vi.hoisted(() => ({
resolveHostById: vi.fn(),
resolveHostBySyncId: vi.fn(),
openSshTransport: vi.fn(),
handler: null as null | ((connection: unknown) => Promise<void>),
}));
@@ -17,6 +18,7 @@ vi.mock("../../utils/logger.js", () => {
});
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: mocks.resolveHostById,
resolveHostBySyncId: mocks.resolveHostBySyncId,
}));
vi.mock("../../hosts/connect/transport.js", () => ({
openSshTransport: mocks.openSshTransport,
@@ -114,6 +116,23 @@ describe("ssh relay", () => {
await vi.waitFor(() => expect(end).toHaveBeenCalled());
});
it("finds the host by sync id for a linked desktop", async () => {
mocks.resolveHostBySyncId.mockResolvedValue({
id: 9,
ip: "127.0.0.1",
port: echoPort,
});
const { socket } = await open("?syncId=abc");
const received = new Promise<string>((resolve) =>
socket.once("message", (data) => resolve(data.toString())),
);
socket.send("ping");
expect(await received).toBe("ping");
expect(mocks.resolveHostBySyncId).toHaveBeenCalledWith("abc", "user-1");
expect(mocks.resolveHostById).not.toHaveBeenCalled();
socket.close();
});
it("refuses a host the user can't connect to", async () => {
mocks.resolveHostById.mockResolvedValue(null);
const { closed } = await open("?hostId=5");
@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events";
import { describe, it, expect } from "vitest";
import {
parsePlatformProbe,
@@ -7,6 +8,7 @@ import {
buildSudoCommand,
parseUpgradable,
execCommand,
decodeCommandOutput,
} from "@termix-ssh/plugin-sdk/host-commands";
describe("parsePlatformProbe", () => {
@@ -116,6 +118,32 @@ describe("parseUpgradable", () => {
});
});
class FakeStream extends EventEmitter {
stderr = new EventEmitter();
destroy() {}
}
function fakeClient(script: (stream: FakeStream) => void) {
return {
exec: (
_command: string,
_options: unknown,
callback: (err: Error | undefined, stream: FakeStream) => void,
) => {
const stream = new FakeStream();
callback(undefined, stream);
script(stream);
},
} as never;
}
// "The system cannot find the path specified." as the GBK bytes a
// Chinese-locale Windows console actually sends.
const GBK_PATH_ERROR = Buffer.from(
"cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4c2b7beb6a1a3",
"hex",
);
describe("execCommand", () => {
it("leaves the command out of the timeout error", async () => {
const client = { exec: () => {} } as never;
@@ -124,4 +152,64 @@ describe("execCommand", () => {
/^Command timeout after 5ms$/,
);
});
it("decodes GBK stderr from a localized Windows host", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(0, 7));
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(7));
stream.emit("close", 1);
}),
"dir",
);
expect(result.stderr).toBe("系统找不到指定的路径。");
expect(result.code).toBe(1);
});
it("reassembles a UTF-8 character split across chunks", async () => {
const utf8 = Buffer.from("以太网 2", "utf8");
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", utf8.subarray(0, 4)); // cuts 太 in half
stream.emit("data", utf8.subarray(4));
stream.emit("close", 0);
}),
"ipconfig",
);
expect(result.stdout).toBe("以太网 2");
});
it("passes plain ASCII output through unchanged", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", Buffer.from("ok\n"));
stream.stderr.emit("data", Buffer.from("warn\n"));
stream.emit("close", 0);
}),
"echo ok",
);
expect(result).toEqual({ stdout: "ok\n", stderr: "warn\n", code: 0 });
});
});
describe("decodeCommandOutput", () => {
it("keeps valid UTF-8, including the empty buffer", () => {
expect(decodeCommandOutput(Buffer.from("以太网 2", "utf8"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(Buffer.alloc(0))).toBe("");
});
it("falls back to GBK when the bytes are not valid UTF-8", () => {
// "以太网 2" in GBK, the interface name of the metrics widget issue.
expect(decodeCommandOutput(Buffer.from("d2d4ccabcdf82032", "hex"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(GBK_PATH_ERROR)).toBe("系统找不到指定的路径。");
});
it("keeps lossy UTF-8 when no decoder matches", () => {
// 0xff is a valid byte in neither UTF-8 nor GBK.
expect(decodeCommandOutput(Buffer.from([0x68, 0x69, 0xff]))).toBe("hi�");
});
});
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
isBareSpecifier,
isUserPluginFile,
shouldUseHostModule,
} from "../../plugins/host-modules.js";
describe("isBareSpecifier", () => {
@@ -46,6 +47,36 @@ describe("isUserPluginFile", () => {
});
});
describe("shouldUseHostModule", () => {
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
const parent = pathToFileURL(
path.join(root, ".unpacked", "demo", "dist", "backend.js"),
).href;
it("falls back for import and require misses from a plugin", () => {
for (const code of ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]) {
expect(shouldUseHostModule({ code }, "axios", parent, root)).toBe(true);
}
});
it("leaves other errors and files alone", () => {
expect(
shouldUseHostModule({ code: "ERR_REQUIRE_ESM" }, "axios", parent, root),
).toBe(false);
expect(
shouldUseHostModule({ code: "MODULE_NOT_FOUND" }, "./x.js", parent, root),
).toBe(false);
expect(
shouldUseHostModule(
{ code: "MODULE_NOT_FOUND" },
"axios",
undefined,
root,
),
).toBe(false);
});
});
describe("installHostModuleFallback", () => {
let dataDir: string | null = null;
@@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import {
classifyRuntimeError,
composeArgsFor,
containerCommand,
countContainers,
diffContainerStates,
dockerArgs,
parseComposeProjects,
parseContainerList,
parseContainerStates,
parseContainerStats,
parseRuntimeVersion,
parseServices,
parseStackStatus,
stackStateFromServices,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("containerCommand", () => {
it("extends PATH on POSIX hosts", () => {
const command = containerCommand("podman", "ps");
expect(command.startsWith('PATH="')).toBe(true);
expect(command.endsWith(" podman ps")).toBe(true);
});
it("runs the plain CLI on Windows hosts", () => {
expect(containerCommand("docker", "ps -a", true)).toBe("docker ps -a");
});
it("falls back to docker for an unknown runtime", () => {
expect(containerCommand(undefined, "ps", true)).toBe("docker ps");
});
});
describe("dockerArgs", () => {
it("builds the container commands", () => {
expect(dockerArgs.inspect("web")).toBe("inspect web");
expect(dockerArgs.action("restart", "web")).toBe("restart web");
expect(dockerArgs.remove("web", true)).toBe("rm -f web");
expect(dockerArgs.list(true)).toContain("ps -a --format '{");
expect(dockerArgs.stats("web")).toContain("stats web --no-stream");
});
it("refuses container ids that could break the command line", () => {
expect(() => dockerArgs.inspect("web; rm -rf /")).toThrow();
expect(() => dockerArgs.logs("$(id)")).toThrow();
expect(() => dockerArgs.action("restart", "-rf")).toThrow();
expect(() => dockerArgs.action("kill" as never, "web")).toThrow();
});
it("only passes sane log filters", () => {
expect(
dockerArgs.logs("web", {
tail: 50.7,
timestamps: true,
since: "2026-10-01T00:00:00Z",
}),
).toBe("logs web --tail 50 --timestamps --since 2026-10-01T00:00:00Z 2>&1");
expect(dockerArgs.logs("web", { since: "1h; id" })).toBe("logs web 2>&1");
});
});
describe("docker parsing", () => {
it("reads the version and sorts errors", () => {
expect(parseRuntimeVersion("Docker version 27.3.1, build ce12230")).toBe(
"27.3.1",
);
expect(parseRuntimeVersion("podman version 5.2.0")).toBe("5.2.0");
expect(parseRuntimeVersion("nope")).toBe("unknown");
expect(classifyRuntimeError("bash: docker: command not found")).toBe(
"NOT_INSTALLED",
);
expect(
classifyRuntimeError(
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
),
).toBe("DAEMON_NOT_RUNNING");
expect(
classifyRuntimeError("permission denied while trying to connect"),
).toBe("PERMISSION_DENIED");
expect(classifyRuntimeError("something else")).toBe("DOCKER_ERROR");
});
it("skips lines that are not JSON in the list", () => {
const rows = parseContainerList(
'{"id":"a","name":"web"}\nWARNING: x\n{"id":"b","name":"db"}\n',
);
expect(rows.map((r) => r.name)).toEqual(["web", "db"]);
});
it("splits the stats columns", () => {
const stats = parseContainerStats(
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
);
expect(stats).toMatchObject({
cpu: "1.5%",
memoryUsed: "10MiB",
memoryLimit: "1GiB",
netInput: "1kB",
netOutput: "2kB",
pids: "3",
});
});
const ps = [
`{"name":"web","state":"running","status":"Up 2 hours"}`,
`{"name":"db","state":"running","status":"Up 2 hours (unhealthy)"}`,
`{"name":"old","state":"exited","status":"Exited (0)"}`,
].join("\n");
it("counts running, stopped and unhealthy containers", () => {
expect(countContainers(ps)).toEqual({
running: 2,
stopped: 1,
unhealthy: 1,
total: 3,
});
});
it("turns two snapshots into events", () => {
const before = parseContainerStates(ps);
const after = parseContainerStates(
[
`{"name":"web","state":"exited","status":"Exited (1)"}`,
`{"name":"db","state":"running","status":"Up (unhealthy)"}`,
`{"name":"old","state":"running","status":"Up 1 second"}`,
`{"name":"new","state":"running","status":"Up"}`,
].join("\n"),
);
expect(diffContainerStates(before, after)).toEqual([
{ container: "web", event: "exited" },
{ container: "old", event: "started" },
]);
});
});
describe("compose", () => {
it("reads stack status text", () => {
expect(parseStackStatus("running(2), exited(1)")).toEqual({
state: "partial",
running: 2,
total: 3,
});
expect(parseStackStatus("paused(2)")).toMatchObject({ state: "paused" });
expect(parseStackStatus("")).toMatchObject({ state: "not-deployed" });
});
it("reads projects from compose ls", () => {
const stacks = parseComposeProjects(
JSON.stringify([
{
Name: "web",
Status: "running(2)",
ConfigFiles: "/opt/stacks/web/compose.yaml",
},
{ Name: "Bad Name", Status: "running(1)", ConfigFiles: "" },
]),
);
expect(stacks).toEqual([
{
name: "web",
managed: false,
state: "running",
running: 2,
total: 2,
composeFile: "/opt/stacks/web/compose.yaml",
},
]);
});
it("reads services from compose ps, one line or one array", () => {
const row = {
Service: "app",
Name: "web-app-1",
ID: "abc",
Image: "nginx",
State: "running",
Status: "Up",
Health: "",
Publishers: [
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 0, TargetPort: 443 },
],
};
const lines = parseServices(JSON.stringify(row));
expect(lines[0]).toMatchObject({ service: "app", ports: "8080:80" });
expect(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
).toHaveLength(2);
expect(
stackStateFromServices(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
),
).toEqual({
state: "partial",
running: 1,
total: 2,
});
});
it("maps actions to compose commands", () => {
expect(composeArgsFor("redeploy", "app", {})).toEqual([
"pull app",
"up -d --remove-orphans app",
]);
expect(
composeArgsFor("destroy", undefined, { removeVolumes: true }),
).toEqual(["down --remove-orphans -v"]);
});
});
@@ -0,0 +1,400 @@
import { describe, it, expect } from "vitest";
import {
buildFilesystemList,
filterExcludedFilesystems,
findWorstMountIndex,
liveCmd,
mergeMonitoredFilesystems,
parseCpuLine,
parseDfLines,
parseLive,
selectPrimaryFilesystem,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
const output =
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[0].type).toBe("ext4");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay overlay 100 50 50 50% /\n" +
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
it("captures the filesystem type for network shares", () => {
const output =
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
const rows = parseDfLines(output);
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
it("keeps full image and firmware mounts out of disk usage", () => {
const rows = parseDfLines(
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
"/dev/sda1 ext4 1000 400 600 40% /\n",
);
expect(rows.map((row) => row.mount)).toEqual(["/"]);
expect(findWorstMountIndex(rows)).toEqual({
index: 0,
usedBytes: 400,
totalBytes: 1000,
});
});
it("retains real storage without requiring a /dev source", () => {
const rows = parseDfLines(
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
"tank/data zfs 2000 1000 1000 50% /data\n" +
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
);
expect(rows.map((row) => row.type)).toEqual([
"btrfs",
"zfs",
"nfs4",
"cifs",
]);
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
"/dev/sda2 ext4 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
const BYTES_OUTPUT =
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
const HUMAN_OUTPUT =
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
describe("buildFilesystemList", () => {
it("returns every real filesystem with byte maths and human strings", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
mount: "/",
type: "ext4",
percent: 40,
usedHuman: "400",
totalHuman: "1.0K",
availableHuman: "600",
usedBytes: 400,
totalBytes: 1000,
});
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
});
it("matches human rows by mount when the row counts differ", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
);
expect(list[0].totalHuman).toBeNull();
expect(list[1].totalHuman).toBe("2.0K");
});
it("drops filesystems with a zero or invalid total", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
),
[],
);
expect(list).toHaveLength(1);
expect(list[0].mount).toBe("/ok");
});
});
describe("selectPrimaryFilesystem", () => {
it("prefers root over a fuller secondary mount", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
});
it("falls back to the most-utilized mount when there is no root", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
),
[],
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
});
it("returns null for an empty list", () => {
expect(selectPrimaryFilesystem([])).toBeNull();
});
});
describe("filterExcludedFilesystems", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 400 600 40% /\n" +
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
),
[],
);
it("returns the same list when no mounts are excluded", () => {
expect(filterExcludedFilesystems(list)).toHaveLength(3);
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
});
it("excludes an exact mount path match", () => {
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("excludes by filesystem type substring, case-insensitively", () => {
const filtered = filterExcludedFilesystems(list, ["NFS"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("supports excluding multiple network filesystem types at once", () => {
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
});
it("ignores blank/whitespace-only entries", () => {
const filtered = filterExcludedFilesystems(list, [" ", ""]);
expect(filtered).toHaveLength(3);
});
});
describe("mergeMonitoredFilesystems", () => {
it("adds an arbitrary path with a user label", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const custom = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/config", label: "Home Assistant" }],
custom,
);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
mount: "/config",
label: "Home Assistant",
totalBytes: 1000,
});
});
it("labels a path that is already a detected mount", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/data", label: "Media" }],
detected,
);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("Media");
});
});
const out = (
agg: [number, number],
core: [number, number],
rx: number,
up: number,
procs: string,
page = "4096",
) =>
[
`cpu ${agg[0]} 0 0 ${agg[1]} 0 0 0 0 0 0\ncpu0 ${core[0]} 0 0 ${core[1]} 0 0 0 0 0 0`,
"MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB",
`0.1 0.2 0.3 1/2 3\n${up} 5`,
`Inter-|\n eth0: ${rx} 0 0 0 0 0 0 0 ${rx * 2} 0\n lo: 9 0 0 0 0 0 0 0 9 0`,
page,
procs,
].join("\n@@\n");
const proc = (pid: number, ticks: number, start: number, name = "java") =>
`${pid}\t${ticks}\t10\t${start}\tR\t1\t${name}\n`;
describe("parseLive extras", () => {
it("reports state, parent, CPU mix, cache and the running count", () => {
const mk = (user: number, sys: number, wait: number, idle: number) =>
out([user, idle], [user, idle], 1, 10, procs).replace(
/cpu {2}\d+ 0 0 \d+ 0 0 0 0/,
`cpu ${user} 0 ${sys} ${idle} ${wait} 0 0 0`,
);
const procs = "5\t1\t10\t7\tR\t2\tjava\n6\t1\t10\t8\tS\t5\tsh\n";
const a = parseLive(mk(0, 0, 0, 0), undefined, 0);
const b = parseLive(mk(30, 10, 10, 50), a.next, 1000);
expect(b.sample.cpuMix).toMatchObject({ user: 30, system: 10, iowait: 10 });
expect(b.sample.running).toBe(1);
expect(b.sample.procs.find((p) => p.pid === 6)).toMatchObject({
state: "S",
ppid: 5,
});
expect(b.sample.memCachedKb).toBe(0);
});
});
describe("liveCmd", () => {
it("asks for the page size once, then passes the cached number", () => {
expect(liveCmd()).toContain('pg="$(getconf PAGESIZE)"');
expect(liveCmd(16384)).toContain("pg=16384 ");
expect(liveCmd(16384)).not.toContain("getconf");
});
});
describe("parseLive", () => {
it("has no rates on the first sample", () => {
const { sample } = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
expect(sample.memUsedKb).toBe(600);
});
it("derives cpu, per-core, net and process rates from the delta", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
// aggregate: +50 busy, +50 idle; core 0: +25 busy, +75 idle
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 50, 1)),
a.next,
);
expect(sample.cpu).toBeCloseTo(50, 0);
expect(sample.cores[0]).toBeCloseTo(25, 0);
expect(sample.net).toEqual([{ name: "eth0", rxBps: 2000, txBps: 4000 }]);
expect(sample.procs[0]).toMatchObject({ pid: 7, name: "java", memKb: 40 });
expect(sample.procs[0].cpu).toBeCloseTo(50, 0);
});
it("does not charge a reused pid with the old process's ticks", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 5, 1)),
undefined,
);
// same pid, new start time, lower ticks must not look like a huge delta
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 9000, 2)),
a.next,
);
expect(sample.procs[0].cpu).toBe(0);
});
it("keeps the cached page size when a run omits it, and keeps names with tabs", () => {
const a = parseLive(
out([1, 1], [1, 1], 1, 10, proc(1, 0, 1), "16384"),
undefined,
);
expect(a.next.pageBytes).toBe(16384);
const b = parseLive(
out([2, 2], [2, 2], 2, 11, proc(1, 0, 1, "a b"), ""),
a.next,
);
expect(b.next.pageBytes).toBe(16384);
expect(b.sample.procs[0]).toMatchObject({ name: "a\tb", memKb: 160 });
});
it("starts rates again after a reboot", () => {
const a = parseLive(
out([100, 100], [100, 100], 5000, 900, proc(7, 0, 1)),
undefined,
);
const { sample } = parseLive(
out([10, 10], [10, 10], 20, 5, proc(7, 0, 1)),
a.next,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
});
});
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import {
isUnit,
journalArgs,
parseBoots,
parseDetect,
parseEntry,
parseIsActive,
parseJournal,
parseJournalQuery,
parseOpenRcList,
parseSystemdTime,
parseTimers,
parseUnitList,
parseUsage,
shellQuote as q,
vacuumScript,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("names", () => {
it("quotes for bash", () => {
expect(q("a'b")).toBe(`'a'\\''b'`);
});
it("takes unit names and refuses anything else", () => {
expect(isUnit("nginx.service")).toBe(true);
expect(isUnit("getty@tty1.service")).toBe(true);
expect(isUnit("dev-disk-by\\x2duuid-1.device")).toBe(true);
expect(isUnit("-rf.service")).toBe(false);
expect(isUnit("a;b.service")).toBe(false);
expect(isUnit("a b.service")).toBe(false);
expect(isUnit("nginx")).toBe(false);
expect(isUnit("$(id).service")).toBe(false);
});
});
describe("systemd parsing", () => {
const list = [
"cron.service loaded active running Regular background program",
"● bad.service loaded failed failed Broken thing",
"getty@tty1.service loaded active running Getty on tty1",
"@@TERMIX_FILES@@",
"cron.service enabled enabled",
"bad.service disabled enabled",
"getty@.service enabled enabled",
"old.service masked -",
"",
].join("\n");
it("merges loaded units with unit files and skips templates", () => {
const units = parseUnitList(list);
expect(units.map((u) => u.name)).toEqual([
"bad.service",
"cron.service",
"getty@tty1.service",
"old.service",
]);
const cron = units.find((u) => u.name === "cron.service")!;
expect(cron).toMatchObject({
active: "active",
sub: "running",
description: "Regular background program",
fileState: "enabled",
preset: "enabled",
});
expect(units.find((u) => u.name === "bad.service")!.active).toBe("failed");
expect(units.find((u) => u.name === "old.service")).toMatchObject({
load: "masked",
active: "inactive",
preset: "",
});
});
it("reads UTC times", () => {
expect(parseSystemdTime("Thu 2026-10-22 02:03:51 UTC")).toBe(
Date.UTC(2026, 9, 22, 2, 3, 51),
);
expect(parseSystemdTime("")).toBeNull();
expect(parseSystemdTime("n/a")).toBeNull();
});
it("reads timers from systemctl show blocks", () => {
const out = [
"TimersCalendar={ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=@1792605600 }",
"NextElapseUSecRealtime=Thu 2026-10-22 02:03:51 UTC",
"LastTriggerUSec=Wed 2026-10-21 17:02:58 UTC",
"Id=apt-daily.timer",
"Triggers=apt-daily.service",
"ActiveState=active",
"",
"TimersMonotonic={ OnUnitActiveUSec=1d ; next_elapse=1d }",
"TimersMonotonic={ OnBootUSec=15min ; next_elapse=15min }",
"NextElapseUSecRealtime=",
"LastTriggerUSec=",
"Id=clean.timer",
"Triggers=clean.service",
].join("\n");
const timers = parseTimers(out);
expect(timers[0]).toMatchObject({
name: "apt-daily.timer",
activates: "apt-daily.service",
next: Date.UTC(2026, 9, 22, 2, 3, 51),
schedule: ["OnCalendar=*-*-* 06,18:00:00"],
});
expect(timers[1]).toMatchObject({
name: "clean.timer",
next: null,
last: null,
schedule: ["OnUnitActiveUSec=1d", "OnBootUSec=15min"],
});
});
it("maps is-active lines to units", () => {
const states = parseIsActive(
["a.service", "b.service"],
"active\nfailed\n",
);
expect(states.get("a.service")).toBe("active");
expect(states.get("b.service")).toBe("failed");
});
it("reads what detect prints", () => {
expect(
parseDetect(
"init=systemd\nversion=257\nos=Debian GNU/Linux 13 (trixie)\nuid=0\njournal=1\nfailed=2\n",
),
).toMatchObject({
init: "systemd",
version: "257",
root: true,
journal: true,
failed: 2,
});
expect(parseDetect("init=weird\nuid=1000\n")).toMatchObject({
init: "none",
root: false,
});
});
});
describe("journal", () => {
it("checks filters", () => {
const query = parseJournalQuery({
unit: ["nginx.service", "ssh.service"],
priority: "3",
since: "-1h",
boot: "-1",
grep: "fail'ed",
lines: "99999",
});
expect(query.lines).toBe(5000);
const args = journalArgs(query);
expect(args).toContain("-u 'nginx.service' -u 'ssh.service'");
expect(args).toContain("-p 3");
expect(args).toContain("--since '-1h'");
expect(args).toContain("-b '-1'");
expect(args).toContain(`-g 'fail'\\''ed'`);
expect(() => parseJournalQuery({ unit: "x;rm" })).toThrow();
expect(() => parseJournalQuery({ priority: "9" })).toThrow();
expect(() => parseJournalQuery({ since: "$(id)" })).toThrow();
expect(() => parseJournalQuery({ boot: "abc" })).toThrow();
});
it("parses entries, binary messages included", () => {
const entry = parseEntry(
JSON.stringify({
__REALTIME_TIMESTAMP: "1792612429684000",
PRIORITY: "3",
_SYSTEMD_UNIT: "x.service",
SYSLOG_IDENTIFIER: "x",
_PID: "12",
MESSAGE: [104, 105],
}),
);
expect(entry).toEqual({
time: 1792612429684,
priority: 3,
unit: "x.service",
ident: "x",
pid: "12",
message: "hi",
});
expect(parseEntry("not json")).toBeNull();
});
it("filters here when journalctl has no -g", () => {
const line = (message: string) =>
JSON.stringify({ __REALTIME_TIMESTAMP: "1000", MESSAGE: message });
const out = `@@TERMIX_NOGREP@@\n${line("Disk Error")}\n${line("ok")}\n`;
const query = parseJournalQuery({ grep: "error", lines: "10" });
expect(parseJournal(out, query).map((e) => e.message)).toEqual([
"Disk Error",
]);
});
it("reads boots and disk use", () => {
const boots = parseBoots(
[
"IDX BOOT ID FIRST ENTRY LAST ENTRY",
"-1 d2785a657f4c4561a1f436db27555b50 Tue 2026-10-20 13:56:45 UTC Wed 2026-10-21 10:37:00 UTC",
" 0 61b15588a75b4c0cb8dea321fcc438ab Wed 2026-10-21 10:38:10 UTC Wed 2026-10-21 20:00:00 UTC",
].join("\n"),
);
expect(boots.map((b) => b.offset)).toEqual([0, -1]);
expect(boots[0].first).toBe("Wed 2026-10-21 10:38:10 UTC");
expect(
parseUsage(
"Archived and active journals take up 378.6M in the file system.",
),
).toBe("378.6M");
});
it("only takes sane vacuum values", () => {
expect(vacuumScript({ size: "500M" })).toContain("--vacuum-size=500M");
expect(vacuumScript({ time: "4w" })).toContain("--vacuum-time=4w");
expect(() => vacuumScript({ size: "1; rm -rf /" })).toThrow();
expect(() => vacuumScript({})).toThrow();
});
});
describe("openrc", () => {
it("maps services and runlevels", () => {
const units = parseOpenRcList(
[
" sshd [ started ]",
" crond [ stopped ]",
" nginx [ crashed ]",
"@@TERMIX_RUNLEVELS@@",
" sshd | default",
" nginx | ",
"",
].join("\n"),
);
expect(units.find((u) => u.name === "sshd")).toMatchObject({
active: "active",
fileState: "enabled",
});
expect(units.find((u) => u.name === "nginx")).toMatchObject({
active: "failed",
fileState: "disabled",
});
expect(units.find((u) => u.name === "crond")!.active).toBe("inactive");
});
});
@@ -0,0 +1,130 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
actionScript,
errLine,
isActiveScript,
listUnitsScript,
openRcActionScript,
parseKeys,
passwordSudoScript,
scriptError,
scriptShell,
SUDO_REFUSED_RE,
SUDO_WRONG_RE,
toBase64,
utf8Decode,
utf8Encode,
watchdog,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("text helpers", () => {
it("encodes base64 like Buffer does", () => {
for (const text of [
"",
"a",
"ab",
"abc",
"héllo wörld",
"emoji 🙂 here",
"line\nbreak",
]) {
expect(toBase64(text)).toBe(Buffer.from(text, "utf8").toString("base64"));
}
});
it("round trips UTF-8 and replaces bad bytes", () => {
const text = "plain, ünïcode, 日本, 🙂";
expect(utf8Decode(utf8Encode(text))).toBe(text);
expect(Array.from(utf8Encode(text))).toEqual(
Array.from(Buffer.from(text, "utf8")),
);
expect(utf8Decode([0x61, 0xff, 0x62])).toBe("a\ufffdb");
expect(utf8Decode([0xe6, 0x97])).toBe("\ufffd");
});
it("reads key=value lines", () => {
expect(parseKeys("a=1\nb = two=2\njunk\n")).toEqual({ a: "1", b: "two=2" });
});
});
describe("script shells", () => {
it("always reads the script from stdin", () => {
for (const mode of ["plain", "auto", "root"] as const) {
expect(scriptShell(mode)).toContain("bash -s");
}
expect(scriptShell("auto")).toContain("sudo -n true");
expect(scriptShell("root-only")).toContain("SCRIPT_ERR:");
});
it("collects error lines, old and new markers", () => {
expect(scriptError("x\nSCRIPT_ERR:one\nSERVICES_ERR:two\n")).toBe(
"one\ntwo",
);
expect(errLine("it's broken")).toBe(`echo 'SCRIPT_ERR:it'\\''s broken'`);
});
it("matches sudo refusals", () => {
expect(SUDO_REFUSED_RE.test("sudo: a password is required")).toBe(true);
expect(SUDO_WRONG_RE.test("sudo: 1 incorrect password attempt")).toBe(true);
});
it("keeps the sudo password out of the command line", () => {
const { command, stdin } = passwordSudoScript(
"systemctl restart 'a.service'\n",
"s3cr'et $(id)",
);
expect(command).not.toContain("s3cr");
expect(stdin.split("\n")[0]).toBe("IFS= read -r TERMIX_PW");
expect(stdin.split("\n")[1]).toBe("s3cr'et $(id)");
expect(stdin).toContain("sudo -k -S -p ''");
expect(stdin).not.toContain("systemctl restart");
expect(() => passwordSudoScript("x", "a\nb")).toThrow();
});
it.runIf(process.platform !== "win32")(
"runs the script and never runs the password",
() => {
const { stdin } = passwordSudoScript(
'echo "ran:$1${TERMIX_PW:-none}"\n',
"touch /tmp/should-not-exist",
);
// No sudo here: swap it for a fake that drops its own stdin line.
const fake = stdin.replace(
/printf '%s\\n' "\$P" \| sudo -k -S -p '' env PATH="\$PATH" [^\n]* bash -c "\$TERMIX_S"/,
'printf "%s\\n" "$P" | { IFS= read -r _; bash -c "$TERMIX_S"; }',
);
expect(fake).not.toBe(stdin);
const out = execFileSync(
"bash",
["-c", "sudo() { return 1; }; export -f sudo; exec bash -s"],
{
input: fake,
encoding: "utf8",
},
);
expect(out.trim()).toBe("ran:none");
},
);
});
describe("unit scripts", () => {
it("quotes units and refuses bad names", () => {
expect(actionScript("a.service", "enable-now")).toContain(
"systemctl enable --now -- 'a.service'",
);
expect(() => actionScript("a;b.service", "start")).toThrow();
expect(isActiveScript(["a.service", "b.timer"])).toContain(
"-- 'a.service' 'b.timer'",
);
expect(() => listUnitsScript("bogus" as never)).toThrow();
expect(openRcActionScript("sshd", "enable")).toContain(
"rc-update add 'sshd' default",
);
expect(() => openRcActionScript("sshd", "mask")).toThrow();
});
it("stops a watched command once stdin closes", () => {
expect(watchdog("journalctl -f")).toContain("cat >/dev/null");
});
});
+51
View File
@@ -902,6 +902,57 @@ describe("installing from a file", () => {
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
});
it("refuses a signature that does not match the file", async () => {
await boot();
await manage.setDeveloperMode(true);
await expect(
manage.stageUpload(await artifactBuffer("notes"), "bm90IGEgc2ln"),
).rejects.toMatchObject({ code: "INVALID_SIGNATURE" });
});
it("keeps a signature from a non official key unverified", async () => {
const { setCustomPluginKeys } = await import("../../plugins/trust.js");
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
try {
await boot();
await manage.setDeveloperMode(true);
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
const buffer = await artifactBuffer("notes");
const digest = crypto.createHash("sha256").update(buffer).digest();
const signature = crypto
.sign(null, digest, privateKey)
.toString("base64");
const preview = await manage.stageUpload(buffer, signature);
expect(preview.official).toBe(false);
await manage.installUpload(preview.token, {
userId: "admin",
capabilities: preview.capabilities,
});
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
} finally {
setCustomPluginKeys([]);
}
});
it("refuses a file that is not a plugin archive", async () => {
await boot();
await manage.setDeveloperMode(true);
+104
View File
@@ -650,6 +650,110 @@ describe("panels, dashboard cards and guest pages", () => {
});
});
describe("contributes.mobile", () => {
const tabs = [
{ id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] },
];
it("accepts each mode with its own fields", () => {
for (const mobile of [
{
mode: "webview",
entry: "dist/mobile.js",
sizes: ["phone", "tablet"],
views: ["sample"],
note: "mobile.note",
},
{ mode: "webview" },
{ mode: "native", feature: "file-manager" },
{ mode: "none", note: "Not on phones" },
]) {
expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual(
[],
);
}
});
it("needs a known mode and rejects unknown fields", () => {
const errors = validateManifest(
base({ contributes: { mobile: { mode: "app", width: 3 } } }),
).join();
expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/);
expect(errors).toMatch(/contributes\.mobile\.mode must be one of/);
expect(
validateManifest(base({ contributes: { mobile: "webview" } })).join(),
).toMatch(/"contributes\.mobile" must be an object/);
});
it("keeps webview and native fields to their own mode", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
mobile: {
mode: "native",
entry: "dist/mobile.js",
views: ["sample"],
},
},
}),
).join();
expect(errors).toMatch(/mobile\.entry is only for mode "webview"/);
expect(errors).toMatch(/mobile\.views is only for mode "webview"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }),
).join(),
).toMatch(/mobile\.feature is only for mode "native"/);
});
it("puts the entry beside the frontend bundle", () => {
const check = (entry: string, frontend?: string) =>
validateManifest(
base({
...(frontend ? { frontend } : {}),
contributes: { mobile: { mode: "webview", entry } },
}),
).join();
expect(check("dist/mobile.js")).toBe("");
expect(check("./dist/mobile.js")).toBe("");
expect(check("build/mobile.js", "build/frontend.js")).toBe("");
expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/);
expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/);
expect(check("../dist/mobile.js")).toMatch(/relative path inside/);
});
it("checks sizes, views and the feature id", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
panels: [{ id: "side", titleKey: "nav.side" }],
mobile: {
mode: "webview",
sizes: ["phone", "phone", "watch"],
views: ["sample", "side", "missing"],
},
},
}),
).join();
expect(errors).toMatch(/sizes duplicates "phone"/);
expect(errors).toMatch(/sizes entries must be one of: phone, tablet/);
expect(errors).toMatch(/views entries must be ids .*"missing"/);
expect(errors).not.toMatch(/"side"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", sizes: [] } } }),
).join(),
).toMatch(/sizes must be a non-empty array/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "native", feature: "Files" } } }),
).join(),
).toMatch(/mobile\.feature must match/);
});
});
describe("named service providers", () => {
const provides = (names: unknown) => [
{
@@ -117,6 +117,56 @@ describe("entity aliases", () => {
expect(getEntity("thing")?.readOnly).toBeUndefined();
expect(getEntity("thing")?.load).toBeUndefined();
});
it("lets a plugin sync rows it builds as read only copies", () => {
const load = async () => [];
registerEntity("demo", {
type: "sharedIcons",
table: null,
readOnly: true,
load,
write: async () => undefined,
erase: async () => undefined,
});
expect(getEntity("sharedIcons")).toMatchObject({ readOnly: true, load });
});
it("refuses a plugin entity that loads rows but can be pushed", () => {
expect(() =>
registerEntity("demo", {
type: "pushable",
table: null,
load: async () => [],
write: async () => undefined,
erase: async () => undefined,
}),
).toThrow(/readOnly/);
expect(() =>
registerEntity("demo", {
type: "halfDone",
table: null,
readOnly: true,
load: async () => [],
}),
).toThrow(/load, write and erase/);
expect(() =>
registerEntity("demo", { type: "nothing", table: null }),
).toThrow(/table or load/);
});
it("drops core only keys a plugin puts on its registration", () => {
registerEntity("demo", {
type: "sneaky",
table: {},
covers: () => true,
canReference: async () => true,
hashIgnore: ["x"],
} as never);
const entity = getEntity("sneaky")!;
expect(entity.covers).toBeUndefined();
expect(entity.canReference).toBeUndefined();
expect(entity.hashIgnore).toBeUndefined();
});
});
describe("core sync entities", () => {
+25 -2
View File
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
getLink: vi.fn(),
generateJWTToken: vi.fn(),
revokeSession: vi.fn(),
getEntity: vi.fn(),
findStoredIds: vi.fn(),
}));
vi.mock("../../utils/auth-manager.js", () => ({
@@ -28,7 +30,7 @@ vi.mock("../../utils/app-version.js", () => ({
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
vi.mock("../../plugins/sync-registry.js", () => ({
getEntity: vi.fn(),
getEntity: mocks.getEntity,
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
onEntitiesChanged: vi.fn(),
}));
@@ -55,7 +57,9 @@ vi.mock("../../utils/permission-manager.js", () => ({
vi.mock("../../sync/entities.js", () => ({
registerCoreSyncEntities: vi.fn(),
}));
vi.mock("../../sync/store.js", () => ({}));
vi.mock("../../sync/store.js", () => ({
findStoredIds: mocks.findStoredIds,
}));
vi.mock("../../sync/wire.js", () => ({}));
vi.mock("../../sync/records.js", () => ({
listChanges: mocks.listChanges,
@@ -215,3 +219,22 @@ describe("host.links", () => {
});
});
});
describe("host.entities.idsOf", () => {
it("maps sync ids to the user's own row ids", async () => {
const entity = { type: "snippets", table: {} };
mocks.getEntity.mockReturnValue(entity);
mocks.findStoredIds.mockResolvedValue({ a: 3 });
expect(
await createSyncHost().entities.idsOf!("snippets", "u", ["a", "b"]),
).toEqual({ a: 3 });
expect(mocks.findStoredIds).toHaveBeenCalledWith(entity, "u", ["a", "b"]);
});
it("answers nothing for an unknown or built type", async () => {
mocks.getEntity.mockReturnValue(undefined);
expect(await createSyncHost().entities.idsOf!("x", "u", ["a"])).toEqual({});
mocks.getEntity.mockReturnValue({ type: "y", load: vi.fn() });
expect(await createSyncHost().entities.idsOf!("y", "u", ["a"])).toEqual({});
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
authLogger: { info: vi.fn(), error: vi.fn() },
}));
const { renderNginxTemplate } = await import("../../tls/nginx-reload.js");
const TEMPLATE = [
"listen ${PORT};",
"listen ${SSL_PORT} ssl;",
"ssl_certificate ${SSL_CERT_PATH};",
" root /app/html;",
"pid /tmp/nginx/nginx.pid;",
].join("\n");
const values = {
port: "8080",
sslPort: "443",
certPath: "/data/ssl/termix.crt",
keyPath: "/data/ssl/termix.key",
};
describe("renderNginxTemplate", () => {
it("fills the ports and certificate", () => {
const out = renderNginxTemplate(TEMPLATE, values);
expect(out).toContain("listen 8080;");
expect(out).toContain("listen 443 ssl;");
expect(out).toContain("ssl_certificate /data/ssl/termix.crt;");
expect(out).toContain("root /app/html;");
});
it("uses the install folder in place of /app", () => {
const out = renderNginxTemplate(TEMPLATE, {
...values,
appDir: "/opt/termix/",
});
expect(out).toContain("root /opt/termix/html;");
expect(out).toContain("pid /tmp/nginx/nginx.pid;");
});
});
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
hostOverride: null as number | null,
folderOverride: null as { folder: string; credentialId: number } | null,
ownerSecret: null as Record<string, unknown> | null,
usable: new Set<number>(),
folderLookups: [] as Array<{ folder: unknown; protocol: string }>,
}));
vi.mock("../../hosts/protocol-auth/registry.js", () => ({
isAuthOverrideProtocol: (protocol: string) => protocol === "ssh",
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number) =>
state.usable.has(id) ? { id, username: `user-${id}` } : null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async () => "owner",
}),
createCurrentRbacAccessRepository: () => ({
findActiveHostAccess: async () => null,
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.hostOverride,
}),
createCurrentSharedFolderAuthOverrideRepository: () => ({
findClosest: async (_userId: string, folder: unknown, protocol: string) => {
state.folderLookups.push({ folder, protocol });
return state.folderOverride;
},
}),
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
getSecretForUser: async () => state.ownerSecret,
snapshotForUser: async () => undefined,
}),
},
}));
const { resolveRecipientSharedHostAuthentication } =
await import("../../utils/shared-host-auth-resolver.js");
const host = (extra: Record<string, unknown> = {}) =>
({
id: 42,
userId: "owner",
folder: "Prod / Web",
authType: "credential",
credentialId: 99,
shareSshAuth: false,
...extra,
}) as never;
beforeEach(() => {
state.hostOverride = null;
state.folderOverride = null;
state.ownerSecret = null;
state.usable = new Set();
state.folderLookups = [];
});
describe("folder logins for shared hosts", () => {
it("uses the folder's login when the host has none of its own", async () => {
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.usable.add(7);
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({
source: "personal-override",
credentialId: 7,
folder: "Prod",
});
expect(state.folderLookups).toEqual([
{ folder: "Prod / Web", protocol: "ssh" },
]);
});
it("lets the host's own login win over the folder's", async () => {
state.hostOverride = 5;
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.usable.add(5).add(7);
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({ credentialId: 5 });
expect(result).not.toHaveProperty("folder");
});
it("skips a folder login the user can no longer use", async () => {
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.ownerSecret = { authType: "password", password: "x" };
const result = await resolveRecipientSharedHostAuthentication(
host({ shareSshAuth: true }),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({ source: "owner-shared" });
});
it("asks for a login when nothing is set", async () => {
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toEqual({ source: "required" });
});
});
@@ -17,8 +17,14 @@ const state = vi.hoisted(() => ({
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
state.credentials.get(credentialId) ?? null,
findUsableCredential: async (credentialId: number, userId: string) => {
const credential = state.credentials.get(credentialId);
if (!credential) return null;
const sharedWith = (credential.sharedWith as string[] | undefined) ?? [];
return credential.userId === userId || sharedWith.includes(userId)
? credential
: null;
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -29,8 +35,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
findHostOwnerId: async (hostId: number) =>
(state.hosts.get(hostId)?.userId as string) ?? null,
findCredentialByIdForUser: async (credentialId: number) =>
state.credentials.get(credentialId) ?? null,
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
state.credentials.get(credentialId)?.userId === userId
? state.credentials.get(credentialId)
: null,
}),
createCurrentSharedHostSecretsRepository: () => ({
upsert: async (row: Record<string, unknown>) => {
@@ -260,6 +268,39 @@ describe("SharedHostSecretsManager", () => {
});
});
it("snapshots SSH auth from a credential shared with the host owner", async () => {
state.credentials.set(123, {
id: 123,
userId: "cred-owner",
sharedWith: ["owner"],
username: "cred-user",
authType: "password",
password: "cred-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
state.hosts.set(
42,
baseHost({
authType: "credential",
credentialId: 123,
shareSshAuth: true,
password: null,
}),
);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
username: "cred-user",
authType: "password",
password: "cred-pass",
});
});
it("snapshots a credential-backed protocol login from the credential", async () => {
state.credentials.set(55, {
id: 55,
@@ -11,6 +11,7 @@ import {
getFriendlyKeyTypeName,
validateKeyPair,
isSecurityKeyFile,
clearUnlockedKeyCache,
} from "../../utils/ssh-key-utils.js";
const { parsePrivateKey } = sshpk;
@@ -226,6 +227,32 @@ describe("PKCS#8 runtime key normalization", () => {
});
});
describe("encrypted OpenSSH keys", () => {
const key = sshpk.generatePrivateKey("ed25519");
const encrypted = key.toString("openssh", { passphrase: "pw" }).trim();
it("unlocks once and hands ssh2 a key that needs no passphrase", () => {
clearUnlockedKeyCache();
const first = preparePrivateKeyForSSH2(encrypted, "pw");
const parsed = ssh2.utils.parseKey(first);
expect(parsed).not.toBeInstanceOf(Error);
expect(parsePrivateKey(first.toString()).fingerprint().toString()).toBe(
key.fingerprint().toString(),
);
expect(preparePrivateKeyForSSH2(encrypted, "pw").toString()).toBe(
first.toString(),
);
});
it("leaves the key locked for a wrong or missing passphrase", () => {
clearUnlockedKeyCache();
expect(preparePrivateKeyForSSH2(encrypted, "wrong").toString()).toBe(
encrypted,
);
expect(preparePrivateKeyForSSH2(encrypted).toString()).toBe(encrypted);
});
});
describe("security key files", () => {
const str = (v: string | Buffer) => {
const b = Buffer.from(v);
+45 -15
View File
@@ -3,9 +3,40 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import { authLogger } from "../utils/logger.js";
const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template";
const NGINX_CONF = "/tmp/nginx/nginx.conf";
const NGINX_PID = "/tmp/nginx/nginx.pid";
// The Docker image's paths. An install outside Docker (a Proxmox LXC script,
// a source checkout) points these at its own nginx instead.
const nginxTemplate = () =>
process.env.TERMIX_NGINX_TEMPLATE || "/app/nginx/nginx-https.conf.template";
const nginxConf = () =>
process.env.TERMIX_NGINX_CONF || "/tmp/nginx/nginx.conf";
const nginxPid = () => process.env.TERMIX_NGINX_PID || "/tmp/nginx/nginx.pid";
/** The template with Termix's own folder in place of the image's /app. */
export function renderNginxTemplate(
template: string,
values: {
port: string;
sslPort: string;
certPath: string;
keyPath: string;
appDir?: string;
},
): string {
let rendered = template
.split("${PORT}")
.join(values.port)
.split("${SSL_PORT}")
.join(values.sslPort)
.split("${SSL_CERT_PATH}")
.join(values.certPath)
.split("${SSL_KEY_PATH}")
.join(values.keyPath);
const appDir = values.appDir?.replace(/\/+$/, "");
if (appDir && appDir !== "/app") {
rendered = rendered.replace(/(^|[\s;])\/app\//gm, `$1${appDir}/`);
}
return rendered;
}
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
@@ -51,11 +82,13 @@ export function persistSSLEnv(
* restart. No-ops outside the Docker image (template/binary won't exist).
*/
export function reloadNginxWithSSL(): { applied: boolean; message: string } {
if (!existsSync(NGINX_TEMPLATE) || !existsSync(NGINX_PID)) {
const NGINX_TEMPLATE = nginxTemplate();
const NGINX_CONF = nginxConf();
if (!existsSync(NGINX_TEMPLATE) || !existsSync(nginxPid())) {
return {
applied: false,
message:
"nginx is not managed by this environment; restart Termix with ENABLE_SSL=true to apply the certificate.",
"Termix could not find the nginx it runs with, so HTTPS is not on yet. In Docker, restart Termix with ENABLE_SSL=true. On another install, set TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF and TERMIX_NGINX_PID (and TERMIX_APP_DIR if it is not /app), or add a listen 443 ssl block with this certificate to your nginx config.",
};
}
@@ -66,16 +99,13 @@ export function reloadNginxWithSSL(): { applied: boolean; message: string } {
const keyPath = process.env.SSL_KEY_PATH || path.join(SSL_DIR, "termix.key");
try {
const template = readFileSync(NGINX_TEMPLATE, "utf8");
const rendered = template
.split("${PORT}")
.join(port)
.split("${SSL_PORT}")
.join(sslPort)
.split("${SSL_CERT_PATH}")
.join(certPath)
.split("${SSL_KEY_PATH}")
.join(keyPath);
const rendered = renderNginxTemplate(readFileSync(NGINX_TEMPLATE, "utf8"), {
port,
sslPort,
certPath,
keyPath,
appDir: process.env.TERMIX_APP_DIR,
});
writeFileSync(NGINX_CONF, rendered);
@@ -1,10 +1,10 @@
import type { AuthOverrideProtocol } from "../../types/auth-protocols.js";
import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js";
import {
createCurrentCredentialRepository,
createCurrentSharedHostAuthOverrideRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { findUsableCredential } from "../hosts/usable-credential.js";
import { logAudit } from "./audit-logger.js";
import { PermissionManager } from "./permission-manager.js";
@@ -60,11 +60,7 @@ export class SharedHostAuthOverrideService {
await this.requireSharedHostAccess(hostId, userId);
if (credentialId !== null) {
const credential =
await createCurrentCredentialRepository().findByIdForUser(
userId,
credentialId,
);
const credential = await findUsableCredential(credentialId, userId);
if (!credential) {
throw new SharedHostAuthOverrideServiceError(
"Credential not found",
+43 -6
View File
@@ -4,6 +4,7 @@ import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSharedFolderAuthOverrideRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../database/repositories/factory.js";
import type {
@@ -12,6 +13,7 @@ import type {
} from "../database/repositories/host-resolution-repository.js";
import { getSshAuthProvider } from "../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { findUsableCredential } from "../hosts/usable-credential.js";
import {
SharedHostSecretsManager,
type SharedSecretData,
@@ -22,6 +24,8 @@ export type RecipientSharedHostAuthResolution =
source: "personal-override";
credentialId: number;
credential: HostResolutionCredentialRecord;
/** Set when the login comes from the folder, not the host itself. */
folder?: string;
}
| {
source: "owner-shared";
@@ -53,8 +57,8 @@ function isOwnerAuthShared(
/**
* Applies the shared-host authentication precedence independently from any
* transport: recipient override, shared owner auth, secretless auth, then
* "required".
* transport: recipient override on the host, then on its folder, shared
* owner auth, secretless auth, then "required".
*/
export async function resolveRecipientSharedHostAuthentication(
host: HostResolutionHostRecord,
@@ -80,10 +84,7 @@ export async function resolveRecipientSharedHostAuthentication(
}
if (overrideCredentialId) {
const credential = await repository.findCredentialByIdForUser(
overrideCredentialId,
userId,
);
const credential = await findUsableCredential(overrideCredentialId, userId);
if (credential) {
return {
source: "personal-override",
@@ -93,6 +94,31 @@ export async function resolveRecipientSharedHostAuthentication(
}
}
try {
const folderOverride =
await createCurrentSharedFolderAuthOverrideRepository().findClosest(
userId,
host.folder,
protocol,
);
if (folderOverride) {
const credential = await findUsableCredential(
folderOverride.credentialId,
userId,
);
if (credential) {
return {
source: "personal-override",
credentialId: folderOverride.credentialId,
credential,
folder: folderOverride.folder,
};
}
}
} catch {
// Same as a host override: an unreadable one counts as none.
}
if (isOwnerAuthShared(host, protocol)) {
if (protocol === "ssh" && host.authType === "agent") {
return {
@@ -145,6 +171,17 @@ export async function resolveRecipientSharedHostAuthentication(
}
}
if (protocol === "ssh" && host.credentialId) {
const credential = await findUsableCredential(host.credentialId, userId);
if (credential) {
return {
source: "personal-override",
credentialId: host.credentialId,
credential,
};
}
}
return requiresPersonalHostAuthentication(host, protocol)
? { source: "required" }
: { source: "secretless" };
@@ -381,12 +381,11 @@ class SharedHostSecretsManager {
host: HostResolutionHostRecord,
ownerId: string,
): Promise<ProtocolSnapshot[]> {
const repository = createCurrentHostResolutionRepository();
const snapshots: ProtocolSnapshot[] = [];
if (sshEnabled(host) && host.shareSshAuth) {
if (host.credentialId) {
const credential = await repository.findCredentialByIdForUser(
const credential = await findUsableCredential(
host.credentialId,
ownerId,
);
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more