Compare commits

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

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

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

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

* fix: build the SDK host modules in production mode

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

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

* chore: rerun CI against dev-26.10.6

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

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

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

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

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

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

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

* fix: striped rows text

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

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

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

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

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

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

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

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

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

* chore: changelog for merged pull requests

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

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

* fix: test

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

* chore: update translations and plugin pins

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

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
2026-10-29 17:59:55 -05:00
151 changed files with 31941 additions and 307 deletions

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: |
+27
View File
@@ -5,6 +5,33 @@
### 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
+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",
+8 -8
View File
@@ -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",
+1 -1
View File
@@ -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",
+21
View File
@@ -1,5 +1,26 @@
# 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.
+62 -3
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.21 | 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
@@ -453,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
+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))) {
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.21",
"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": {
@@ -102,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 {
+35 -5
View File
@@ -21,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,
@@ -65,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
@@ -75,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) {
@@ -92,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) {
+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;
+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,
);
+49 -2
View File
@@ -1,4 +1,4 @@
import { createPrivateKey } from "node:crypto";
import { createHash, createPrivateKey } from "node:crypto";
import sshpk from "sshpk";
import { getErrorMessage } from "./error-message.js";
import ssh2Pkg from "ssh2";
@@ -264,12 +264,59 @@ export function isSecurityKeyFile(privateKeyData: string): boolean {
}
}
// Unlocking an OpenSSH key runs bcrypt on the main thread (about a second per
// try), and every connect used to do it 3 or more times. Keep the unlocked copy
// in memory for a short while so a burst of connects pays for it once.
const UNLOCKED_KEY_TTL_MS = 15 * 60 * 1000;
const UNLOCKED_KEY_MAX = 64;
const unlockedKeys = new Map<string, { text: string; expires: number }>();
function unlockOpenSSHKey(cleanKey: string, passphrase?: string): string {
if (
!passphrase ||
!cleanKey.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")
)
return cleanKey;
const id = createHash("sha256")
.update(cleanKey)
.update("\n")
.update(passphrase)
.digest("hex");
const now = Date.now();
const hit = unlockedKeys.get(id);
if (hit && hit.expires > now) return hit.text;
unlockedKeys.delete(id);
let text: string;
try {
text = parsePrivateKey(cleanKey, "openssh", { passphrase }).toString(
"openssh",
);
} catch {
// Wrong passphrase or a cipher sshpk lacks; the normal path reports it.
return cleanKey;
}
if (ssh2Utils.parseKey(text) instanceof Error) return cleanKey;
if (unlockedKeys.size >= UNLOCKED_KEY_MAX) {
const oldest = unlockedKeys.keys().next().value;
if (oldest !== undefined) unlockedKeys.delete(oldest);
}
unlockedKeys.set(id, { text, expires: now + UNLOCKED_KEY_TTL_MS });
return text;
}
export function clearUnlockedKeyCache(): void {
unlockedKeys.clear();
}
export function preparePrivateKeyForSSH2(
privateKeyData: string,
passphrase?: string,
): Buffer {
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
let cleanKey = normalizePrivateKeyText(privateKeyData);
let cleanKey = unlockOpenSSHKey(
normalizePrivateKeyText(privateKeyData),
passphrase,
);
if (
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error
+7
View File
@@ -87,6 +87,8 @@ export interface HostSidebarDisplayPreferences extends HostRowFields {
openOnDoubleClick: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths: boolean;
/** When false, rows keep one background instead of alternating. */
stripedRows: boolean;
hostClickBehavior: HostClickBehavior;
/** When true, the sidebar closes after a host is opened from it. */
closeOnOpen: boolean;
@@ -164,6 +166,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
statusColorScheme: "accent",
openOnDoubleClick: false,
showFolderPaths: true,
stripedRows: true,
hostClickBehavior: "newTab",
closeOnOpen: false,
barActions: {},
@@ -272,6 +275,10 @@ export function sanitizeHostSidebarPreferences(
typeof displayObj.showFolderPaths === "boolean"
? displayObj.showFolderPaths
: defaults.display.showFolderPaths,
stripedRows:
typeof displayObj.stripedRows === "boolean"
? displayObj.stripedRows
: defaults.display.stripedRows,
hostClickBehavior: HOST_CLICK_BEHAVIORS.includes(
displayObj.hostClickBehavior as HostClickBehavior,
)
+8
View File
@@ -72,6 +72,10 @@ export type Host = {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;
@@ -182,6 +186,10 @@ export interface HostData {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;
+15 -2
View File
@@ -643,11 +643,24 @@ export interface PluginUploadPreview {
features?: string[];
capabilities: string[];
replaces: string | null;
official?: boolean;
}
export async function uploadPlugin(file: Blob): Promise<PluginUploadPreview> {
export async function uploadPlugin(
file: Blob,
signature?: string,
): Promise<PluginUploadPreview> {
const response = await rbacApi.post("/plugins/upload", file, {
headers: { "Content-Type": "application/octet-stream" },
headers: {
"Content-Type": "application/octet-stream",
...(signature
? {
"X-Termix-Plugin-Signature": btoa(
String.fromCharCode(...new TextEncoder().encode(signature)),
),
}
: {}),
},
});
return response.data;
}
+55 -2
View File
@@ -238,9 +238,14 @@ export async function getFolderAccess(
}
}
export async function revokeFolderAccess(ruleId: number): Promise<void> {
export async function revokeFolderAccess(
ruleId: number,
options: { fromHosts?: boolean } = {},
): Promise<void> {
try {
await rbacApi.delete(`/rbac/folder/access/${ruleId}`);
await rbacApi.delete(`/rbac/folder/access/${ruleId}`, {
params: options.fromHosts ? { fromHosts: "true" } : undefined,
});
} catch (error) {
throw handleApiError(error, "revoke folder access");
}
@@ -383,6 +388,54 @@ export async function getHostAuthOverride(
}
}
/**
* The user's own login for hosts shared with them in a folder. On a desktop
* linked to a server, pass a shared host from the folder so the call goes to
* that server.
*/
export async function getFolderAuthOverride(
folder: string,
protocol: AuthOverrideProtocol,
sharedCopySyncId?: string | null,
): Promise<{
folder: string;
protocol: AuthOverrideProtocol;
credentialId: number | null;
}> {
try {
const target = await getSharedCopyTarget(0, sharedCopySyncId);
const response = await target.api.get(`/rbac/folder-auth/${protocol}`, {
params: { folder },
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch folder authentication override");
}
}
export async function setFolderAuthOverride(
folder: string,
protocol: AuthOverrideProtocol,
credentialId: number | null,
sharedCopySyncId?: string | null,
): Promise<{
success: boolean;
folder: string;
protocol: AuthOverrideProtocol;
credentialId: number | null;
}> {
try {
const target = await getSharedCopyTarget(0, sharedCopySyncId);
const response = await target.api.put(`/rbac/folder-auth/${protocol}`, {
folder,
credentialId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update folder authentication override");
}
}
export async function setHostAuthOverride(
hostId: number,
protocol: AuthOverrideProtocol,
+52 -12
View File
@@ -14,11 +14,32 @@ export interface DataColumn<T> {
/** Any grid track: "1fr", "120px", "minmax(0,2fr)". */
width: string;
cell: (item: T) => React.ReactNode;
/** Dropped below md, for columns that are nice rather than necessary. */
/** Hidden when the table itself is narrower than md (48rem) / lg (64rem). */
hideBelow?: "md" | "lg";
align?: "start" | "end";
}
/** Tracks for the columns visible at each width, so hidden ones take no space. */
function listColumnTemplates<T>(columns: DataColumn<T>[]): {
base: string;
md: string;
lg: string;
} {
return {
base:
columns
.filter((column) => column.hideBelow === undefined)
.map((column) => column.width)
.join(" ") || "none",
md:
columns
.filter((column) => column.hideBelow !== "lg")
.map((column) => column.width)
.join(" ") || "none",
lg: columns.map((column) => column.width).join(" ") || "none",
};
}
export interface GridColumns {
base?: number;
md?: number;
@@ -52,6 +73,9 @@ const XL_COLS: Record<number, string> = {
4: "xl:grid-cols-4",
};
const LIST_GRID_CLASS =
"grid grid-cols-[var(--data-view-cols)] @min-[48rem]:grid-cols-[var(--data-view-cols-md)] @min-[64rem]:grid-cols-[var(--data-view-cols-lg)] gap-x-2 px-3";
function gridClass(columns: GridColumns): string {
return cn(
COLS[columns.base ?? 1],
@@ -61,6 +85,12 @@ function gridClass(columns: GridColumns): string {
);
}
function columnVisibility(hideBelow?: "md" | "lg"): string {
if (hideBelow === "md") return "hidden @min-[48rem]:block";
if (hideBelow === "lg") return "hidden @min-[64rem]:block";
return "";
}
/** One collection as a card grid or a dense row table. */
export function DataView<T>({
items,
@@ -120,14 +150,26 @@ export function DataView<T>({
);
}
const template = listColumns.map((c) => c.width).join(" ");
const rowHeight = density === "compact" ? "h-7" : "h-9";
const templates = listColumnTemplates(listColumns);
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
return (
<div className={cn("border border-border bg-card", className)} role="table">
<div
className={cn("@container border border-border bg-card", className)}
style={
{
"--data-view-cols": templates.base,
"--data-view-cols-md": templates.md,
"--data-view-cols-lg": templates.lg,
} as React.CSSProperties
}
role="table"
>
<div
className="sticky top-0 z-10 grid items-center border-b border-border bg-card px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: template }}
className={cn(
LIST_GRID_CLASS,
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
)}
>
{listColumns.map((column) => (
<span
@@ -135,8 +177,7 @@ export function DataView<T>({
className={cn(
"truncate py-2",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
columnVisibility(column.hideBelow),
)}
>
{column.header}
@@ -152,12 +193,12 @@ export function DataView<T>({
key={getKey(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn(
"grid items-center gap-x-2 border-b border-border/50 px-3 text-xs transition-colors last:border-0",
LIST_GRID_CLASS,
"items-center border-b border-border/50 text-xs transition-colors last:border-0",
rowHeight,
onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
)}
style={{ gridTemplateColumns: template }}
>
{listColumns.map((column) => (
<div
@@ -165,8 +206,7 @@ export function DataView<T>({
className={cn(
"min-w-0 truncate",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
columnVisibility(column.hideBelow),
)}
>
{column.cell(item)}
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Close the sidebar after you open a host from it",
"showFolderPaths": "Show folder paths",
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
"stripedRows": "Alternating row colors",
"stripedRowsDesc": "Shade every other row in the host list",
"rowDetailsTitle": "Row details",
"rowDetailsDesc": "Choose what shows on each host.",
"showAddress": "Address",
@@ -775,6 +777,8 @@
"sharedBadge": "Shared",
"sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)",
"viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.",
"folderRuleRemoveFromHosts": "Also remove this access from every host in the folder and its subfolders",
"sharedCopyBanner": "This host is shared with you by {{owner}}. The desktop keeps a read-only copy, so change it on the server and it syncs here.",
"sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.",
"ownerOnlyControl": "Only the host owner can change this",
"ownerAuthPrivate": "The host owner's SSH authentication is private. Use “Set personal SSH authentication” from the host menu to choose your own credential.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Maak die sybalk toe nadat jy 'n gasheer daaruit oopgemaak het",
"showFolderPaths": "Wys gidsroetes",
"showFolderPathsDesc": "Wys die ouergidsroete bo ingeslote subgidsname. Subgidse bly ingespring in beide gevalle.",
"stripedRows": "Wisselende rykleure",
"stripedRowsDesc": "Skadu elke tweede ry in die gasheerlys",
"rowDetailsTitle": "Rybesonderhede",
"rowDetailsDesc": "Kies wat op elke gasheerbediener verskyn.",
"showAddress": "Adres",
@@ -775,6 +777,8 @@
"sharedBadge": "Gedeel",
"sharedBadgeTooltip": "Gedeel deur {{owner}} ({{level}}-toegang)",
"viewOnlyBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met lees-toegang. Die konfigurasie is lees-slegs.",
"folderRuleRemoveFromHosts": "Verwyder ook hierdie toegang van elke gasheer in die gids en sy subgidse",
"sharedCopyBanner": "Hierdie gasheer word met jou gedeel deur {{owner}}. Die werkskoppie hou 'n lees-slegs kopie, so verander dit op die bediener en dit sinkroniseer hier.",
"sharedEditBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met wysig-toegang. Veranderinge word op die werklike gasheer toegepas; autentikasie-verwysings kan slegs deur die eienaar verander word.",
"ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander",
"ownerAuthPrivate": "Die gasheer-eienaar se SSH-authentikasie is privaat. Gebruik “Stel persoonlike SSH-authentikasie” in die gasheer-menu om jou eie geloofsbewys te kies.",
+4
View File
@@ -614,6 +614,8 @@
"closeOnOpenDesc": "إغلاق الشريط الجانبي بعد فتح مضيف منه",
"showFolderPaths": "إظهار مسارات المجلدات",
"showFolderPathsDesc": "إظهار مسار المجلد الأب فوق أسماء المجلدات الفرعية المتداخلة. تظل المجلدات الفرعية مضمنة في كلتا الحالتين.",
"stripedRows": "ألوان صفوف متناوبة",
"stripedRowsDesc": "تظليل كل صف ثانٍ في قائمة المضيفين",
"rowDetailsTitle": "تفاصيل الصف",
"rowDetailsDesc": "اختر ما يظهر على كل جهاز.",
"showAddress": "العنوان",
@@ -823,6 +825,8 @@
"sharedBadge": "مشترك",
"sharedBadgeTooltip": "مشارك من قبل {{owner}} (وصول {{level}})",
"viewOnlyBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية العرض فقط. التكوين للقراءة فقط.",
"folderRuleRemoveFromHosts": "إزالة هذا الوصول أيضاً من كل مضيف في المجلد والمجلدات الفرعية الخاصة به",
"sharedCopyBanner": "تم مشاركة هذا المضيف معك بواسطة {{owner}}. يحتفظ سطح المكتب بنسخة للقراءة فقط، لذا قم بتعديله على الخادم وسيتم مزامنته هنا.",
"sharedEditBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية التعديل. التغييرات تنطبق على المضيف الفعلي؛ لا يمكن تغيير مراجع المصادقة إلا من قبل المالك.",
"ownerOnlyControl": "يمكن للمالك فقط تغيير هذا",
"ownerAuthPrivate": "مصادقة SSH الخاصة بمالك المضيف خاصة. استخدم “تعيين مصادقة SSH شخصية” من قائمة المضيف لاختيار بيانات الاعتماد الخاصة بك.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Затвори страничната лента след като отвориш хост от нея",
"showFolderPaths": "Показване на пътищата до папките",
"showFolderPathsDesc": "Показване на пътя до родителската папка над имената на вложените подпапки. Подпапките оставят отстъп в двата случая.",
"stripedRows": "Чередуване на цветовете на редовете",
"stripedRowsDesc": "Оцветяване на всеки втори ред в списъка с хостове",
"rowDetailsTitle": "Детайли на реда",
"rowDetailsDesc": "Изберете какво се показва за всеки хост.",
"showAddress": "Адрес",
@@ -775,6 +777,8 @@
"sharedBadge": "Споделен",
"sharedBadgeTooltip": "Споделен от {{owner}} (достъп: {{level}})",
"viewOnlyBanner": "Този хост е споделен с вас от {{owner}} с достъп само за преглед. Конфигурацията е само за четене.",
"folderRuleRemoveFromHosts": "Премахване на достъпа и от всички хостове в папката и подпапките ѝ",
"sharedCopyBanner": "Този хост е споделен с вас от {{owner}}. Работният плот запазва само за четене копия, така че променете го на сървъра и той ще се синхронизира тук.",
"sharedEditBanner": "Този хост е споделен с вас от {{owner}} с достъп за редактиране. Промените се прилагат към реалния хост; референциите за автентикация могат да бъдат променени само от собственика.",
"ownerOnlyControl": "Само собственикът на хоста може да промени това",
"ownerAuthPrivate": "SSH автентикацията на собственика на хоста е частна. Използвайте „Задай лична SSH автентикация“ от менюто на хоста, за да изберете свои собствени данни за вход.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "সাইডবার থেকে কোনো হোস্ট খোলার পর সাইডবার বন্ধ করুন",
"showFolderPaths": "ফোল্ডার পথ দেখান",
"showFolderPathsDesc": "নেস্টেড সাবফোল্ডার নামের উপরে প্যারেন্ট ফোল্ডার পথ দেখান। সাবফোল্ডার যেকোনো ক্ষেত্রেই ইনডেন্ট থাকবে।",
"stripedRows": "বিকল্প সারির রঙ",
"stripedRowsDesc": "হোস্ট তালিকায় প্রতিটি বিকল্প সারি রঙিন করুন",
"rowDetailsTitle": "সারির বিবরণ",
"rowDetailsDesc": "প্রতিটি হোস্টে কী দেখাবে তা নির্বাচন করুন।",
"showAddress": "ঠিকানা",
@@ -775,6 +777,8 @@
"sharedBadge": "শেয়ার করা",
"sharedBadgeTooltip": "{{owner}} দ্বারা শেয়ার করা ({{level}} অ্যাক্সেস)",
"viewOnlyBanner": "{{owner}} এই হোস্টটি আপনার সাথে ভিউ অ্যাক্সেস সহ শেয়ার করেছেন। কনফিগারেশনটি রিড-অনলি।",
"folderRuleRemoveFromHosts": "এই ফোল্ডার এবং এর সাবফোল্ডারে থাকা প্রতিটি হোস্ট থেকেও এই অ্যাক্সেস সরিয়ে দিন",
"sharedCopyBanner": "{{owner}} আপনাকে এই হোস্টটি শেয়ার করেছেন। ডেস্কটপটি একটি রিড-অনলি কপি রাখে, তাই সার্ভারে এটি পরিবর্তন করলে এটি এখানে সিঙ্ক হবে।",
"sharedEditBanner": "{{owner}} এই হোস্টটি আপনার সাথে এডিট অ্যাক্সেস সহ শেয়ার করেছেন। পরিবর্তনগুলো আসল হোস্টে প্রয়োগ হবে; অথেন্টিকেশন রেফারেন্স শুধুমাত্র মালিক পরিবর্তন করতে পারবেন।",
"ownerOnlyControl": "শুধুমাত্র হোস্ট মালিক এটি পরিবর্তন করতে পারেন",
"ownerAuthPrivate": "হোস্ট মালিকের SSH অথেন্টিকেশন প্রাইভেট। আপনার নিজস্ব ক্রেডেনশিয়াল নির্বাচন করতে হোস্ট মেনু থেকে “সেট পার্সোনাল SSH অথেন্টিকেশন” ব্যবহার করুন।",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Tanca la barra lateral després d'obrir un amfitrió des d'ella",
"showFolderPaths": "Mostra les rutes de les carpetes",
"showFolderPathsDesc": "Mostra la ruta de la carpeta principal sobre els noms de les subcarpetes anidades. Les subcarpetes es mantenen amb sagnat en qualsevol cas.",
"stripedRows": "Colors alternats de les files",
"stripedRowsDesc": "Aplicar un color alternat a cada file de la llista d'amfitrions",
"rowDetailsTitle": "Detalls de la fila",
"rowDetailsDesc": "Tria què es mostra en cada amfitrió.",
"showAddress": "Adreça",
@@ -787,6 +789,8 @@
"sharedBadge": "Compartit",
"sharedBadgeTooltip": "Compartit per {{owner}} (accés {{level}})",
"viewOnlyBanner": "Aquest host us ha estat compartit per {{owner}} amb accés de visualització. La configuració és de només lectura.",
"folderRuleRemoveFromHosts": "També elimina aquest accés de tots els amfitrions de la carpeta i les seves subcarpetes",
"sharedCopyBanner": "Aquest amfitrió ha estat compartit amb tu per {{owner}}. L'escriptori manté una còpia de només lectura, de manera que si el modifiques al servidor, els canvis es sincronitzaran aquí.",
"sharedEditBanner": "Aquest host us ha estat compartit per {{owner}} amb accés d'edició. Els canvis s'apliquen a l'host real; les referències d'autenticació només les pot modificar el propietari.",
"ownerOnlyControl": "Només el propietari de l'host pot canviar això",
"ownerAuthPrivate": "L'autenticació SSH del propietari de l'host és privada. Utilitzeu «Establir autenticació SSH personal» al menú de l'host per triar la vostra pròpia credencial.",
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more