Compare commits

...
Author SHA1 Message Date
LukeGus ec44ba2443 fix: stop dev runner restart loops and narrow plugin watchers 2026-10-29 16:42:06 -05:00
LukeGus ef5057c45c fix: test 2026-10-29 16:06:22 -05:00
LukeGus a0e13f9eeb feat: folder logins for shared hosts, read only plugin sync, full desktop builds 2026-10-29 15:54:35 -05:00
LukeGus b32203d19b fix: accept the release .sig when installing a plugin from a file 2026-10-29 14:36:01 -05:00
LukeGus 3def536dcc chore: changelog for merged pull requests 2026-10-29 14:09:20 -05:00
LukeGus a42ea3e5e0 feat: remove a folder share from its hosts too (#1719) 2026-10-29 14:06:36 -05:00
LukeGus 0e9b3db5fc fix: let non docker installs switch nginx to https (#1711) 2026-10-29 14:03:15 -05:00
LukeGus d43f543c59 fix: let commonjs plugins fall back to core packages on the desktop 2026-10-29 14:01:14 -05:00
LukeGus b211311530 fix: retry a plugin bundle that was not ready yet (#1708) 2026-10-29 13:59:26 -05:00
LukeGus 95a00d841c fix: pass the host id as text when opening its editor 2026-10-29 13:59:22 -05:00
LukeGus 44fa791c09 fix: show plugins that could not be downloaded as failed (#1714) 2026-10-29 13:56:08 -05:00
LukeGus 034d68b163 fix: number shortcuts skip the home tab (#1724) 2026-10-29 13:54:52 -05:00
LukeGus 474996b6e0 fix: show the logo in the macos menu bar (#1728) 2026-10-29 13:53:57 -05:00
LukeGus 8da88b906b feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36) 2026-10-29 13:24:01 -05:00
LukeGus cc2223d69e fix: striped rows text 2026-10-29 13:01:15 -05:00
LukeGus d330f4a64c feat: sidebar preference for alternating rows (#1712) 2026-10-29 13:00:15 -05:00
LukeGus 523fdf2e81 fix: add the desktop shared copy banner text (#1709) 2026-10-29 12:58:02 -05:00
LukeGus e292462b38 feat: rail items can show a status dot (Plugin-Sync#5) 2026-10-29 12:55:10 -05:00
LukeGus 8d26c716b4 feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) 2026-10-29 12:49:47 -05:00
LukeGus 691896db2d fix: wake other clients after a sync push (Plugin-Sync#1) 2026-10-29 12:46:42 -05:00
LukeGus 7493c0aa17 feat: give ssh transports the host origin and sync id (#1717) 2026-10-29 12:44:43 -05:00
LukeGus d715d097c5 fix: unlock passphrase keys once per connect burst (#1737) 2026-10-29 12:40:38 -05:00
Rhiz3K 27525d8f72 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
2026-10-29 12:38:05 -05:00
Jonathan Rehuel Lewerissa ffe757f91d 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
2026-10-29 12:37:48 -05:00
Sebastian Cao 3173f45e7e fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) 2026-10-29 12:37:37 -05:00
renovysandrenovys 1f8b006831 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>
2026-10-29 12:37:18 -05:00
LukeGus dda70dcc5d fix: deleting your own account removes all of its data 2026-10-27 21:15:24 -05:00
LukeGus ab3edbdf7b fix: show source address when rejecting proxy auth 2026-10-27 20:18:03 -05:00
LukeGus f9a6b451c2 Merge remote-tracking branch 'origin/dev-26.10.6' into dev-26.10.6 2026-10-27 19:27:24 -05:00
LukeGus 4d41a02efa fix: test 2026-10-27 19:26:10 -05:00
86 changed files with 16760 additions and 233 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: |
+24
View File
@@ -8,6 +8,30 @@
- 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
+1 -3
View File
@@ -288,9 +288,7 @@
"onboarding": {
"recommended": false,
"consent": false,
"enabledByEnv": [
"OIDC_CLIENT_ID"
]
"enabledByEnv": ["OIDC_CLIENT_ID"]
}
},
{
@@ -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
+7
View File
@@ -540,6 +540,13 @@
"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,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
+7
View File
@@ -540,6 +540,13 @@
"when": 1792945360507,
"tag": "0076_host_color_icon",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1793304928399,
"tag": "0077_shared_folder_auth_overrides",
"breakpoints": true
}
]
}
@@ -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
+7
View File
@@ -505,6 +505,13 @@
"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",
+7
View File
@@ -9,10 +9,17 @@ Plugin API 1.13.0.
- `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
+18 -1
View File
@@ -346,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
@@ -495,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
+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) {
+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; }>
+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}`);
}
+13
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,
+32
View File
@@ -490,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",
{
+32
View File
@@ -490,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",
{
+32
View File
@@ -480,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;
}
}
+11 -1
View File
@@ -2706,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
@@ -2751,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;
+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();
});
});
@@ -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);
@@ -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,
)
+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.",
+2
View File
@@ -1317,6 +1317,8 @@ export {
revokeHostAccess,
getHostAuthOverride,
setHostAuthOverride,
getFolderAuthOverride,
setFolderAuthOverride,
getPermissionsCatalog,
getSharedHosts,
} from "@/api/rbac-api";
+6
View File
@@ -20,6 +20,7 @@ import { getTabType, type TabRenderProps } from "@/shell/tab-registry";
import { getPanel } from "@/shell/panel-registry";
import { getSSHHosts } from "@/main-axios";
import { sshHostToHost } from "@/sidebar/HostManagerData";
import { hostToSSHHost } from "@/lib/host-to-ssh-host";
import type { Host, Tab } from "@/types/ui-types";
import { createPluginApp } from "./app";
import { installPluginHostBridge } from "./bridge";
@@ -150,6 +151,11 @@ function FrameBody({
key={`${view.view}:${view.hostId ?? ""}:${version}`}
tab={record}
host={host}
sshHost={
host
? (hostToSSHHost(host) as unknown as Record<string, unknown>)
: undefined
}
label={view.view}
isVisible
isFocusedPane
+37 -3
View File
@@ -85,13 +85,47 @@ function absolute(url: string): string {
return new URL(url, window.location.href).href;
}
const IMPORT_RETRY_DELAYS_MS = [500, 1500, 3000];
/**
* A bundle asked for while its plugin is still starting (a slow desktop boot)
* answers 404, and the browser then keeps that failure for the URL. So a
* failed import is tried again a few times, each under a new URL.
*/
export async function importWithRetry<T>(
url: string,
load: (url: string) => Promise<T>,
sleep: (ms: number) => Promise<void> = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms)),
): Promise<T> {
for (let attempt = 0; ; attempt++) {
const next =
attempt === 0
? url
: `${url}${url.includes("?") ? "&" : "?"}retry=${attempt}`;
try {
return await load(next);
} catch (error) {
// A bundle that loaded but threw is broken, not late.
const fetchFailed =
error instanceof TypeError &&
/dynamically imported module|importing a module script/i.test(
error.message,
);
if (!fetchFailed || attempt >= IMPORT_RETRY_DELAYS_MS.length) throw error;
await sleep(IMPORT_RETRY_DELAYS_MS[attempt]);
}
}
}
const defaultDeps: PluginLoaderDeps = {
fetchPlugins: getPlugins,
async importFrontend(summary) {
return (await import(
/* @vite-ignore */ absolute(assetUrl(summary, "frontend.js"))
)) as FrontendModule;
return importWithRetry(
absolute(assetUrl(summary, "frontend.js")),
(url) => import(/* @vite-ignore */ url) as Promise<FrontendModule>,
);
},
async loadLocale(summary, file) {
+2
View File
@@ -640,6 +640,7 @@ export {
getExternalAutoProvision,
getHostAccess,
getHostAuthOverride,
getFolderAuthOverride,
getHostPassword,
getHostPolicy,
type HostPolicy,
@@ -682,6 +683,7 @@ export {
saveCredentialSidebarPreferences,
saveHostSidebarPreferences,
setHostAuthOverride,
setFolderAuthOverride,
shareFolder,
shareHost,
type SSHHostWithStatus,
+7 -3
View File
@@ -983,12 +983,16 @@ function UploadButton({ manager }: { manager: PluginsManager }) {
<input
ref={input}
type="file"
accept=".tmxplug"
accept=".tmxplug,.sig"
multiple
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
// A release's .sig can be picked with it, so it installs as official.
const files = [...(e.target.files ?? [])];
e.target.value = "";
if (file) void manager.requestUpload(file);
const file = files.find((f) => f.name.endsWith(".tmxplug"));
const signature = files.find((f) => f.name.endsWith(".sig"));
if (file) void manager.requestUpload(file, signature);
}}
/>
<Button
+9 -3
View File
@@ -333,11 +333,17 @@ export function usePluginsManager() {
[run, t, failToast],
);
/** Reads a picked .tmxplug and asks for consent before anything loads. */
/**
* Reads a picked .tmxplug, with its .sig when one was picked too, and asks
* for consent before anything loads.
*/
const requestUpload = useCallback(
async (file: File) => {
async (file: File, signatureFile?: File) => {
try {
const preview = await uploadPlugin(file);
const preview = await uploadPlugin(
file,
signatureFile ? await signatureFile.text() : undefined,
);
setConsent({
plugin: uploadEntry(preview),
mode: "upload",
@@ -173,6 +173,10 @@ export function ShellControllerProvider({
},
openHostEditor: (draft) =>
requestManage({ kind: "host", hostId: null, draft }),
editHost: (hostId) => {
requestManage({ kind: "host", hostId: String(hostId) });
if (isMobile) regions.left.setOpen(false);
},
saveQuickConnect: tabsState.saveQuickConnectHost,
confirm: (options) =>
confirmRef.current ? confirmRef.current(options) : Promise.resolve(false),
@@ -196,6 +200,7 @@ export function ShellControllerProvider({
closeRailView: (...args) => shellImplRef.current.closeRailView(...args),
openHostEditor: (...args) =>
shellImplRef.current.openHostEditor?.(...args),
editHost: (...args) => shellImplRef.current.editHost?.(...args),
saveQuickConnect: (...args) =>
shellImplRef.current.saveQuickConnect!(...args),
confirm: (...args) => shellImplRef.current.confirm!(...args),
@@ -208,11 +208,12 @@ export function useShellKeybindings({
return;
}
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that
// position. The home tab has its own button, so the count starts after it.
const tabDigit = getTabJumpDigit(e);
if (tabDigit !== null) {
const currentTabs = tabsRef.current.filter(
(tab) => !tab.parentSplitTabId,
(tab) => !tab.parentSplitTabId && tab.type !== "dashboard",
);
const index = tabDigit - 1;
if (index < currentTabs.length) {
+2
View File
@@ -33,6 +33,8 @@ export interface TabShellCallbacks {
closeRailView: (id: string) => void;
/** Opens the host editor for a new host with these fields filled in. */
openHostEditor?: (draft?: HostDraft) => void;
/** Opens the editor of a saved host. */
editHost?: (hostId: number) => void;
/** Saves a quick-connect tab's host. Core terminal only. */
saveQuickConnect?: (tab: Tab, host: Host) => Promise<void>;
/** Asks a yes or no question over the main area. */
+131 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { DataView } from "@/components/data-view";
import { DataView, type DataColumn } from "@/components/data-view";
afterEach(cleanup);
@@ -75,3 +75,133 @@ describe("DataView", () => {
expect(screen.getByText("nothing")).toBeTruthy();
});
});
describe("list grid", () => {
const listColumns: DataColumn<(typeof items)[number]>[] = [
{ key: "a", header: "A", width: "1fr", cell: (item) => `a ${item.name}` },
{
key: "b",
header: "B",
width: "90px",
hideBelow: "md",
cell: (item) => `b ${item.name}`,
},
{ key: "c", header: "C", width: "2fr", cell: (item) => `c ${item.name}` },
{
key: "d",
header: "D",
width: "70px",
hideBelow: "lg",
cell: (item) => `d ${item.name}`,
},
{
key: "e",
header: "E",
width: "minmax(0,1.2fr)",
hideBelow: "md",
cell: (item) => `e ${item.name}`,
},
];
function renderList(density?: "compact", columns = listColumns) {
render(
<DataView
items={items}
view="list"
density={density}
getKey={(item) => item.id}
renderCard={() => null}
columns={{}}
listColumns={columns}
empty={null}
/>,
);
const root = screen.getByRole("table");
const header = root.children[0] as HTMLElement;
const rows = Array.from(root.children[1].children) as HTMLElement[];
return { root, header, rows };
}
it("sets container templates using only columns visible at each breakpoint", () => {
const { root } = renderList();
expect(root.style.getPropertyValue("--data-view-cols")).toBe("1fr 2fr");
expect(root.style.getPropertyValue("--data-view-cols-md")).toBe(
"1fr 90px 2fr minmax(0,1.2fr)",
);
expect(root.style.getPropertyValue("--data-view-cols-lg")).toBe(
"1fr 90px 2fr 70px minmax(0,1.2fr)",
);
expect(root.classList.contains("@container")).toBe(true);
});
it("shares grid classes between the header and every body row", () => {
const { header, rows } = renderList();
const expectedGridClasses = [
"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",
].sort();
expect(rows).toHaveLength(items.length);
for (const row of [header, ...rows]) {
const gridClasses = Array.from(row.classList).filter(
(className) =>
className === "grid" ||
className.includes("grid-cols-") ||
className.startsWith("gap-x-"),
);
expect(gridClasses.sort()).toEqual(expectedGridClasses);
expect(row.style.gridTemplateColumns).toBe("");
}
});
it("uses container-query visibility for cells and their headers", () => {
const { header, rows } = renderList();
for (const row of [header, ...rows]) {
for (const index of [1, 4]) {
expect(row.children[index].classList.contains("hidden")).toBe(true);
expect(
row.children[index].classList.contains("@min-[48rem]:block"),
).toBe(true);
}
expect(row.children[3].classList.contains("hidden")).toBe(true);
expect(row.children[3].classList.contains("@min-[64rem]:block")).toBe(
true,
);
for (const index of [0, 2]) {
expect(row.children[index].classList.contains("hidden")).toBe(false);
}
}
});
it("uses a minimum height for compact rows without a fixed height", () => {
const { rows } = renderList("compact");
for (const row of rows) {
expect(row.classList.contains("min-h-7")).toBe(true);
expect(row.classList.contains("h-7")).toBe(false);
}
});
it("uses the default row minimum height when density is omitted", () => {
const { rows } = renderList();
for (const row of rows) {
expect(row.classList.contains("min-h-9")).toBe(true);
}
});
it("uses no base tracks when every column is hidden at the base width", () => {
const { root } = renderList(undefined, [
listColumns[1],
listColumns[3],
listColumns[4],
]);
expect(root.style.getPropertyValue("--data-view-cols")).toBe("none");
});
});
+22 -4
View File
@@ -6,7 +6,14 @@ import type { PluginSummary } from "@/api/plugins-api";
vi.mock("@/main-axios", async (importOriginal) => ({
...(await importOriginal<typeof import("@/main-axios")>()),
getSSHHosts: vi.fn(async () => [
{ id: 7, name: "web-01", ip: "10.0.0.7", port: 22, username: "root" },
{
id: 7,
name: "web-01",
ip: "10.0.0.7",
port: 22,
username: "root",
pluginSettings: { gizmo: { enableGizmo: true } },
},
]),
}));
@@ -59,8 +66,19 @@ function Home() {
);
}
function HostView({ host }: { host?: { name: string } }) {
return <p>host view: {host?.name ?? "none"}</p>;
function HostView({
host,
sshHost,
}: {
host?: { name: string };
sshHost?: { pluginSettings?: { gizmo?: { enableGizmo?: boolean } } };
}) {
const on = sshHost?.pluginSettings?.gizmo?.enableGizmo === true;
return (
<p>
host view: {host?.name ?? "none"} {on ? "on" : "off"}
</p>
);
}
const module = {
@@ -159,7 +177,7 @@ describe("startPluginFrame", () => {
shell: makeShell(),
}),
);
await waitFor(() => screen.getByText("host view: web-01"));
await waitFor(() => screen.getByText("host view: web-01 on"));
act(() => frame!.setView({ view: "gizmo-home" }));
await waitFor(() => screen.getByText("hosts: web-01"));
act(() => frame!.setTheme({ id: "nord", mode: "dark" }));
+27
View File
@@ -22,6 +22,7 @@ import {
startPluginRuntime,
startPreLoginPlugins,
syncPlugins,
importWithRetry,
} from "@/plugin-host/loader";
import {
getPluginRecord,
@@ -522,3 +523,29 @@ describe("layouts and themes", () => {
);
});
});
describe("importWithRetry", () => {
const noWait = async () => {};
it("tries a bundle that failed to load again under a new URL", async () => {
const load = vi
.fn()
.mockRejectedValueOnce(
new TypeError("Failed to fetch dynamically imported module: x"),
)
.mockResolvedValueOnce({ ok: true });
await expect(
importWithRetry("http://h/plugin-assets/a/frontend.js?v=1", load, noWait),
).resolves.toEqual({ ok: true });
expect(load.mock.calls.map((call) => call[0])).toEqual([
"http://h/plugin-assets/a/frontend.js?v=1",
"http://h/plugin-assets/a/frontend.js?v=1&retry=1",
]);
});
it("does not retry a bundle that threw while running", async () => {
const load = vi.fn().mockRejectedValue(new Error("boom"));
await expect(importWithRetry("u", load, noWait)).rejects.toThrow("boom");
expect(load).toHaveBeenCalledTimes(1);
});
});
+2 -1
View File
@@ -46,7 +46,8 @@ describe("TitleBar", () => {
installBridge();
render(<TitleBar />);
expect(await screen.findByText("Termix")).toBeTruthy();
expect(heightVar()).toBe("40px");
// The offset is set in an effect that can run after the bar shows up.
await waitFor(() => expect(heightVar()).toBe("40px"));
});
it("stays hidden in windows without the app-drawn bar", async () => {
@@ -27,6 +27,7 @@ describe("defaultHostSidebarPreferences", () => {
statusColorScheme: "accent",
openOnDoubleClick: false,
showFolderPaths: true,
stripedRows: true,
hostClickBehavior: "newTab",
closeOnOpen: false,
barActions: {},
@@ -74,6 +75,7 @@ describe("sanitizeHostSidebarPreferences", () => {
statusColorScheme: "status",
openOnDoubleClick: true,
showFolderPaths: false,
stripedRows: true,
hostClickBehavior: "focusExistingDoubleClickNew",
closeOnOpen: true,
barActions: { maintenance: true },