Compare commits

...
73 changed files with 16679 additions and 150 deletions

No files matched your search

+5
View File
@@ -14,6 +14,11 @@
- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results - Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results
- Browse shows plugins a page at a time - Browse shows plugins a page at a time
- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them - Plugins that need a newer Termix are hidden in Browse, with a toggle to show them
- Hosts can be managed from a config file through a plugin. Managed hosts are read only for everyone, but pins and order still work
- A plugin can block adding hosts by hand, so every host comes from one place
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
- A user who must have a second factor but has none can sign in only to set one up
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
### Changed ### Changed
+44 -42
View File
@@ -57,48 +57,50 @@ What every install has, with or without plugins:
Plugins add everything else. The official ones ship with Termix, and admins can install, update or remove plugins from the Plugins tab. Each plugin has its own repo. Plugins add everything else. The official ones ship with Termix, and admins can install, update or remove plugins from the Plugins tab. Each plugin has its own repo.
| Plugin | What it does | | Plugin | What it does |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. | | [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. | | [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. | | [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. | | [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. | | [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. | | [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. | | [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. | | [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. | | [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. | | [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. | | [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. | | [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. | | [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. | | [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. | | [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. | | [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. | | [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. | | [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. | | [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. | | [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. | | [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. | | [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. | | [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. | | [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. | | [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. | | [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. | | [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. | | [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. | | [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. | | [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. | | [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. | | [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. | | [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. | | [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. | | [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. | | [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. | | [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. | | [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. | | [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. | | [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry). Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `sessions` ADD `restriction` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `idle_timeout` int;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -526,6 +526,13 @@
"when": 1792374489419, "when": 1792374489419,
"tag": "0074_mature_puff_adder", "tag": "0074_mature_puff_adder",
"breakpoints": true "breakpoints": true
},
{
"idx": 75,
"version": "5",
"when": 1792430772041,
"tag": "0075_session_policy",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "sessions" ADD COLUMN "restriction" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idle_timeout" integer;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -526,6 +526,13 @@
"when": 1792374488837, "when": 1792374488837,
"tag": "0074_bouncy_sersi", "tag": "0074_bouncy_sersi",
"breakpoints": true "breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1792430770990,
"tag": "0075_session_policy",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `sessions` ADD `restriction` text(32);--> statement-breakpoint
ALTER TABLE `sessions` ADD `idle_timeout` integer;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -491,6 +491,13 @@
"when": 1792374488196, "when": 1792374488196,
"tag": "0069_normal_ozymandias", "tag": "0069_normal_ozymandias",
"breakpoints": true "breakpoints": true
},
{
"idx": 70,
"version": "6",
"when": 1792430769899,
"tag": "0070_session_policy",
"breakpoints": true
} }
] ]
} }
+26
View File
@@ -1,5 +1,31 @@
# Changelog # Changelog
## 1.0.15
Plugin API 1.7.0.
### Added
- `ctx.auth.policy.provide` and the `auth:policy` capability, so a plugin can set an idle timeout, cap session length, turn off remember me and require a second factor
- `ctx.auth.listSecondFactors` lists the second factors from every running plugin
- `AuthEnrollmentSections` takes `factorsOnly`
- `getHttpsSettings` and `updateHttpsSettings` in `@termix-ssh/plugin-sdk/ui`
### Fixed
- Plugin frontend tests against a locally linked SDK failed with a second copy of React
## 1.0.14
Plugin API 1.6.0.
### Added
- `ctx.hosts.locks.provide` and the `hosts:lock` capability, so a plugin can manage hosts that stay read only for everyone else and block adding hosts by hand
- `ctx.hosts.setShares` to replace every grant on a host in one call
- `ctx.credentials.list` returns the acting user's credentials by name, without secrets
- `ctx.hosts.create` and `update` take `password`, `key`, `keyPassword`, `keyType`, `sudoPassword`, `protocolAuth` and `pluginEnable`
## 1.0.13 ## 1.0.13
Plugin API 1.5.0. Plugin API 1.5.0.
+16 -1
View File
@@ -1,6 +1,6 @@
# Termix plugin guide # Termix plugin guide
SDK 1.0.13 | Plugin API 1.5.0 SDK 1.0.15 | Plugin API 1.7.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -226,6 +226,7 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
| `sync` | none | Sync your tables to a linked desktop | | `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types | | `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user | | `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
| `users.getLocale` | `users:read` | A user's UI language | | `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate | | `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed | | `plugins.list()` | `plugins:read` | Which plugins are installed |
@@ -300,6 +301,11 @@ 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 - `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. `hosts.edit` checks a core permission.
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`. - Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
`ctx.credentials.list()` finds the acting user's credentials by name.
### Errors ### Errors
@@ -694,6 +700,13 @@ closely. Declare ids in `contributes.auth` and register them with `ctx.auth.regi
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols` `registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one. and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
Login rules need `auth:policy`. `ctx.auth.policy.provide({ evaluate, allowRememberDevice })` is asked at
every login with the user, the method, the device and every second factor (`ctx.auth.listSecondFactors()`
lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`, `idleTimeoutSeconds` and
`secondFactor` (`"always"` or `"new-device"`). The strictest answer from all plugins wins, and a throw
refuses the login. A user who must have a second factor but has none gets a session that can only set one
up, and `/users/me` reports `restriction: "second-factor-setup"`.
## Testing ## Testing
```ts ```ts
@@ -890,6 +903,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `users:impersonate` | high | Act as another user, or get a session for them, audited | | `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) | | `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types | | `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
| `device:serial` | high | Open serial and USB ports on the server | | `device:serial` | high | Open serial and USB ports on the server |
| `desktop:local` | high | Use the user's own computer, desktop app only | | `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins | | `hosts:export` | high | Export hosts with their saved logins |
@@ -898,6 +912,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `hosts:write` | medium | Add and edit hosts | | `hosts:write` | medium | Add and edit hosts |
| `hosts:status-provide` | medium | Be the source of host status dots | | `hosts:status-provide` | medium | Be the source of host status dots |
| `hosts:activity-provide` | medium | Be the source of the recent activity list | | `hosts:activity-provide` | medium | Be the source of the recent activity list |
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
| `network:probe` | medium | Check that a host answers on a TCP port | | `network:probe` | medium | Check that a host answers on a TCP port |
| `credentials:use` | medium | Connect with saved credentials without seeing them | | `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials | | `credentials:write` | medium | Save new credentials |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@termix-ssh/plugin-sdk", "name": "@termix-ssh/plugin-sdk",
"version": "1.0.13", "version": "1.0.15",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
@@ -151,6 +151,7 @@
"process:spawn", "process:spawn",
"users:write", "users:write",
"auth:provide", "auth:provide",
"auth:policy",
"system:tls", "system:tls",
"device:serial", "device:serial",
"hosts:write", "hosts:write",
@@ -167,6 +168,7 @@
"network:probe", "network:probe",
"hosts:status-provide", "hosts:status-provide",
"hosts:activity-provide", "hosts:activity-provide",
"hosts:lock",
"desktop:window", "desktop:window",
"desktop:local", "desktop:local",
"users:impersonate", "users:impersonate",
+143
View File
@@ -698,6 +698,36 @@ export interface PluginHostCreateInput {
sshOptions?: HostSshOptions | null; sshOptions?: HostSshOptions | null;
/** Host settings by plugin id, the shape a Termix export carries. */ /** Host settings by plugin id, the shape a Termix export carries. */
pluginSettings?: Record<string, Record<string, unknown>>; pluginSettings?: Record<string, Record<string, unknown>>;
/**
* Each plugin's own host switch (its manifest enableKey), by plugin id.
* A plugin that is not running is skipped and logged; one with no switch
* is refused.
*/
pluginEnable?: Record<string, boolean>;
/**
* Secrets, stored encrypted with the owner's data key and never returned
* in a PluginHostRecord. An empty string or null clears the stored value.
*/
password?: string | null;
key?: string | null;
keyPassword?: string | null;
keyType?: string | null;
sudoPassword?: string | null;
/**
* Logins for plugin protocols (contributes.protocols), keyed by protocol
* id. null removes that protocol's login. Unknown protocol ids are skipped.
*/
protocolAuth?: Record<string, PluginHostProtocolAuthInput | null>;
}
/** One protocol login on a host write. Absent keys keep what is stored. */
export interface PluginHostProtocolAuthInput {
authType?: "direct" | "credential" | "none";
credentialId?: number | null;
username?: string | null;
password?: string | null;
/** The protocol's declared credentialFields, secret ones included. */
fields?: Record<string, string>;
} }
/** Fields a plugin may change on a host the acting user owns. */ /** Fields a plugin may change on a host the acting user owns. */
@@ -725,6 +755,13 @@ export const PLUGIN_HOST_INPUT_KEYS = [
"forceKeyboardInteractive", "forceKeyboardInteractive",
"sshOptions", "sshOptions",
"pluginSettings", "pluginSettings",
"pluginEnable",
"password",
"key",
"keyPassword",
"keyType",
"sudoPassword",
"protocolAuth",
] as const satisfies readonly (keyof PluginHostCreateInput)[]; ] as const satisfies readonly (keyof PluginHostCreateInput)[];
/** The same shape canAccessHost returns internally, without secrets. */ /** The same shape canAccessHost returns internally, without secrets. */
@@ -761,6 +798,34 @@ export interface PluginShareableRole {
displayName: string | null; displayName: string | null;
} }
/** One grant in ctx.hosts.setShares. */
export interface PluginHostShareGrant {
target: PluginShareTarget;
permissionLevel: PluginHostShareLevel;
/** Omit for a grant that never expires. */
durationHours?: number;
}
/**
* Hosts a plugin manages itself, for example from a config file. Core refuses
* edits, deletes and share changes on a locked host from the UI, the API and
* other plugins, and marks it in host reads so a layout can show it read only.
* Pins and order stay with the user. Only the plugin that locks a host may
* still write it.
*/
export interface PluginHostLockProvider {
isLocked: (hostId: number) => boolean;
/** A short line shown under the managed note, or null. */
note: (hostId: number) => string | null;
/** False blocks adding hosts by hand (editor, import, other plugins). */
allowManualCreate: () => boolean;
}
export interface PluginHostLocksApi {
/** Registers this plugin's locks. Needs hosts:lock. Removed on deactivate. */
provide: (provider: PluginHostLockProvider) => () => void;
}
/** /**
* Hosts the acting user can see, and the RBAC + sharing operations a plugin * Hosts the acting user can see, and the RBAC + sharing operations a plugin
* needs to build a feature (like fleets) on top of hosts it does not own. * needs to build a feature (like fleets) on top of hosts it does not own.
@@ -823,6 +888,15 @@ export interface PluginHosts {
permissionLevel: PluginHostShareLevel, permissionLevel: PluginHostShareLevel,
durationHours?: number, durationHours?: number,
) => Promise<PluginHostShareResult>; ) => Promise<PluginHostShareResult>;
/**
* Replaces every grant on a host the acting user owns with this list.
* Grants that stay keep their row and counters, missing ones are revoked.
* Needs hosts:write.
*/
setShares: (
hostId: number,
grants: PluginHostShareGrant[],
) => Promise<PluginHostShareResult>;
/** Users the caller may pick as a share target, for building a picker UI. */ /** Users the caller may pick as a share target, for building a picker UI. */
listUsers: () => Promise<PluginShareableUser[]>; listUsers: () => Promise<PluginShareableUser[]>;
/** Non-system roles the caller may pick as a share target. */ /** Non-system roles the caller may pick as a share target. */
@@ -896,6 +970,8 @@ export interface PluginHosts {
) => () => void; ) => () => void;
/** The recent activity list on the dashboard. */ /** The recent activity list on the dashboard. */
activity: PluginHostActivityApi; activity: PluginHostActivityApi;
/** Read only hosts this plugin manages. */
locks: PluginHostLocksApi;
} }
export interface PluginHostImportOptions { export interface PluginHostImportOptions {
@@ -1685,6 +1761,54 @@ export interface PluginSecondFactor {
reset?: (userId: string) => Promise<void>; reset?: (userId: string) => Promise<void>;
} }
/** A login core is about to turn into a session. */
export interface PluginAuthPolicyInput {
userId: string;
/** Login method id, `"proxy"` for a trusted proxy login. */
methodId: string;
/** The method signs in through an outside identity provider (SSO, LDAP). */
external: boolean;
deviceType: "web" | "desktop" | "mobile";
/** The device passed a second factor before and was remembered. */
trustedDevice: boolean;
/** The method itself proved a second factor, like a verified passkey. */
mfaSatisfied: boolean;
/** Every second factor core has, and whether this user set it up. */
secondFactors: { id: string; pluginId: string; enrolled: boolean }[];
}
export interface PluginAuthPolicyDecision {
/** False turns remember me off and ignores remembered devices. */
allowRememberDevice?: boolean;
/** Caps how long the session lasts. */
maxSessionSeconds?: number;
/** Ends the session after this many seconds without a request. */
idleTimeoutSeconds?: number;
/**
* Requires a second factor even for users who have none: they get a
* session that can only set one up. `"new-device"` skips it on a
* remembered device.
*/
secondFactor?: "always" | "new-device";
}
/**
* Login rules from a plugin. Core asks every provider and the strictest
* answer wins. A provider that throws refuses the login.
*/
export interface PluginAuthPolicyProvider {
evaluate: (
input: PluginAuthPolicyInput,
) => PluginAuthPolicyDecision | Promise<PluginAuthPolicyDecision>;
/** Whether the login page should offer remember me. */
allowRememberDevice?: () => boolean;
}
export interface PluginAuthPolicyApi {
/** Needs auth:policy. Removed on deactivate. */
provide: (provider: PluginAuthPolicyProvider) => () => void;
}
export interface PluginUserTokenOptions { export interface PluginUserTokenOptions {
/** Why the token exists, kept in the audit log and on the session. */ /** Why the token exists, kept in the audit log and on the session. */
purpose: string; purpose: string;
@@ -1774,6 +1898,10 @@ export interface PluginAuth {
userId: string, userId: string,
options: PluginUserTokenOptions, options: PluginUserTokenOptions,
) => Promise<PluginUserToken>; ) => Promise<PluginUserToken>;
/** Login rules: idle timeout, session length, remember me, second factors. */
policy: PluginAuthPolicyApi;
/** Second factors from every running plugin. Needs no capability. */
listSecondFactors: () => { id: string; pluginId: string }[];
} }
/** What a plugin may learn about a user. Needs users:read. */ /** What a plugin may learn about a user. Needs users:read. */
@@ -2011,6 +2139,15 @@ export interface PluginSshKeyCredential {
publicKey: string | null; publicKey: string | null;
} }
/** A saved credential without its secrets. */
export interface PluginCredentialSummary {
id: number;
name: string;
username: string | null;
authType: string;
folder: string | null;
}
/** A key pair to save as a new credential for the acting user. */ /** A key pair to save as a new credential for the acting user. */
export interface PluginSshKeyCredentialInput { export interface PluginSshKeyCredentialInput {
name: string; name: string;
@@ -2032,6 +2169,12 @@ export interface PluginCredentials {
* Needs credentials:use and the core credentials.view permission. Audited. * Needs credentials:use and the core credentials.view permission. Audited.
*/ */
listSshKeys: () => Promise<PluginSshKeyCredential[]>; listSshKeys: () => Promise<PluginSshKeyCredential[]>;
/**
* The acting user's own credentials by name, with no secret material, so a
* plugin can point a host at one. Needs credentials:use and the core
* credentials.view permission. Audited.
*/
list: () => Promise<PluginCredentialSummary[]>;
/** /**
* Saves a key pair as a new encrypted credential owned by the acting user * Saves a key pair as a new encrypted credential owned by the acting user
* and returns its id. Needs credentials:write, the core credentials.create * and returns its id. Needs credentials:write, the core credentials.create
+2
View File
@@ -47,6 +47,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:impersonate", "high"), entry("users:impersonate", "high"),
entry("db:core-refs", "high"), entry("db:core-refs", "high"),
entry("auth:provide", "high"), entry("auth:provide", "high"),
entry("auth:policy", "high"),
entry("device:serial", "high"), entry("device:serial", "high"),
entry("notify:hub", "high"), entry("notify:hub", "high"),
entry("ui:layout", "high"), entry("ui:layout", "high"),
@@ -61,6 +62,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("network:probe", "medium"), entry("network:probe", "medium"),
entry("hosts:status-provide", "medium"), entry("hosts:status-provide", "medium"),
entry("hosts:activity-provide", "medium"), entry("hosts:activity-provide", "medium"),
entry("hosts:lock", "medium"),
entry("users:read", "medium"), entry("users:read", "medium"),
entry("events:core", "medium"), entry("events:core", "medium"),
entry("notify:send", "medium"), entry("notify:send", "medium"),
+1 -1
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core * "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server. * version by the server.
*/ */
export const PLUGIN_API_VERSION = "1.5.0"; export const PLUGIN_API_VERSION = "1.7.0";
/** The API major, the default engine.api for a new plugin. */ /** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String( export const SUPPORTED_PLUGIN_API_VERSION = String(
+83
View File
@@ -76,6 +76,10 @@ import type {
PluginHostProbeTarget, PluginHostProbeTarget,
PluginHostStatusEntry, PluginHostStatusEntry,
PluginActivityProvider, PluginActivityProvider,
PluginHostLockProvider,
PluginAuthPolicyProvider,
PluginCredentialSummary,
PluginHostShareGrant,
PluginHostStatusProvider, PluginHostStatusProvider,
PluginNotification, PluginNotification,
PluginNotificationChannel, PluginNotificationChannel,
@@ -140,6 +144,8 @@ export interface FakeContextOptions {
protocolTargets?: Record<string, PluginProtocolTarget>; protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys answers. */ /** What ctx.credentials.listSshKeys answers. */
sshKeyCredentials?: PluginSshKeyCredential[]; sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.credentials.list answers. */
credentialSummaries?: PluginCredentialSummary[];
/** What ctx.desktop.available() answers. Defaults to false. */ /** What ctx.desktop.available() answers. Defaults to false. */
desktopAvailable?: boolean; desktopAvailable?: boolean;
/** What ctx.desktop.local is. Calls throw when this is not given. */ /** What ctx.desktop.local is. Calls throw when this is not given. */
@@ -190,6 +196,8 @@ export interface FakeContextOptions {
baseUrl?: string; baseUrl?: string;
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */ /** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
linkedUsers?: Record<string, number>; linkedUsers?: Record<string, number>;
/** Second factors from other plugins that ctx.auth.listSecondFactors adds. */
secondFactors?: { id: string; pluginId: string }[];
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */ /** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number; loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */ /** What ctx.users.getLocale answers, by user id. A missing user answers null. */
@@ -269,6 +277,8 @@ export interface FakeAuthRegistrations {
loginFailures: Map<string, number>; loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */ /** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>; userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
/** Providers passed to ctx.auth.policy.provide that are still registered. */
policies: PluginAuthPolicyProvider[];
} }
export interface FakePluginContext { export interface FakePluginContext {
@@ -285,6 +295,8 @@ export interface FakePluginContext {
hostStatusProviders: PluginHostStatusProvider[]; hostStatusProviders: PluginHostStatusProvider[];
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */ /** Every provider passed to ctx.hosts.activity.provide that is still registered. */
activityProviders: PluginActivityProvider[]; activityProviders: PluginActivityProvider[];
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
hostLockProviders: PluginHostLockProvider[];
/** Topics emitted through ctx.events.emit, in order. */ /** Topics emitted through ctx.events.emit, in order. */
emitted: Array<{ topic: string; payload: unknown }>; emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */ /** Backing store behind ctx.kv. */
@@ -317,6 +329,8 @@ export interface FakePluginContext {
permissionLevel: string; permissionLevel: string;
durationHours?: number; durationHours?: number;
}>; }>;
/** Every ctx.hosts.setShares call, in order. */
hostShareSets: Array<{ hostId: number; grants: PluginHostShareGrant[] }>;
/** Everything registered through ctx.auth. */ /** Everything registered through ctx.auth. */
auth: FakeAuthRegistrations; auth: FakeAuthRegistrations;
/** Every ctx.desktop.openIsolatedWindow call, in order. */ /** Every ctx.desktop.openIsolatedWindow call, in order. */
@@ -488,7 +502,14 @@ function applyHostInput(
const { const {
tags, tags,
pluginSettings: _settings, pluginSettings: _settings,
pluginEnable: _pluginEnable,
forceKeyboardInteractive: _fki, forceKeyboardInteractive: _fki,
password: _password,
key: _key,
keyPassword: _keyPassword,
keyType: _keyType,
sudoPassword: _sudoPassword,
protocolAuth: _protocolAuth,
...rest ...rest
} = input; } = input;
const next: PluginHostRecord = { ...record }; const next: PluginHostRecord = { ...record };
@@ -517,6 +538,7 @@ export function createFakeContext(
const emitted: Array<{ topic: string; payload: unknown }> = []; const emitted: Array<{ topic: string; payload: unknown }> = [];
const hostStatusProviders: PluginHostStatusProvider[] = []; const hostStatusProviders: PluginHostStatusProvider[] = [];
const activityProviders: PluginActivityProvider[] = []; const activityProviders: PluginActivityProvider[] = [];
const hostLockProviders: PluginHostLockProvider[] = [];
const kv = new Map<string, unknown>(); const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = []; const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = []; const syncEntities: SyncEntityRegistration[] = [];
@@ -534,6 +556,7 @@ export function createFakeContext(
}>(); }>();
const sshConnections: FakePluginContext["sshConnections"] = []; const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = []; const hostShares: FakePluginContext["hostShares"] = [];
const hostShareSets: FakePluginContext["hostShareSets"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = []; const desktopWindows: FakePluginContext["desktopWindows"] = [];
const externalClientLaunches: FakePluginContext["externalClientLaunches"] = const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
[]; [];
@@ -606,6 +629,7 @@ export function createFakeContext(
revokedSessions: [], revokedSessions: [],
loginFailures: new Map(), loginFailures: new Map(),
userTokens: [], userTokens: [],
policies: [],
}; };
const sshClient = options.sshClient ?? {}; const sshClient = options.sshClient ?? {};
let actor = options.actor; let actor = options.actor;
@@ -1004,6 +1028,10 @@ export function createFakeContext(
hostShares.push({ hostId, targets, permissionLevel, durationHours }); hostShares.push({ hostId, targets, permissionLevel, durationHours });
return { hostId, shared: true }; return { hostId, shared: true };
}, },
setShares: async (hostId, grants) => {
hostShareSets.push({ hostId, grants: [...grants] });
return { hostId, shared: true };
},
listUsers: async () => options.shareableUsers ?? [], listUsers: async () => options.shareableUsers ?? [],
listRoles: async () => options.shareableRoles ?? [], listRoles: async () => options.shareableRoles ?? [],
trackSession: (hostId) => { trackSession: (hostId) => {
@@ -1034,6 +1062,15 @@ export function createFakeContext(
}; };
}, },
}, },
locks: {
provide: (provider) => {
hostLockProviders.push(provider);
return () => {
const at = hostLockProviders.indexOf(provider);
if (at >= 0) hostLockProviders.splice(at, 1);
};
},
},
status: { status: {
provide: (provider) => { provide: (provider) => {
hostStatusProviders.push(provider); hostStatusProviders.push(provider);
@@ -1266,6 +1303,22 @@ export function createFakeContext(
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
}; };
}, },
policy: {
provide: (provider) => {
auth.policies.push(provider);
return () => {
const at = auth.policies.indexOf(provider);
if (at >= 0) auth.policies.splice(at, 1);
};
},
},
listSecondFactors: () => [
...auth.secondFactors.map((factor) => ({
id: factor.id,
pluginId,
})),
...(options.secondFactors ?? []),
],
}, },
users: { users: {
@@ -1286,6 +1339,7 @@ export function createFakeContext(
}, },
credentials: { credentials: {
list: async () => [...(options.credentialSummaries ?? [])],
listSshKeys: async () => [ listSshKeys: async () => [
...(options.sshKeyCredentials ?? []), ...(options.sshKeyCredentials ?? []),
...createdSshKeys.map((key) => ({ ...createdSshKeys.map((key) => ({
@@ -1468,6 +1522,7 @@ export function createFakeContext(
disposals, disposals,
hostStatusProviders, hostStatusProviders,
activityProviders, activityProviders,
hostLockProviders,
emitted, emitted,
kv, kv,
tables, tables,
@@ -1478,6 +1533,7 @@ export function createFakeContext(
coreSettings, coreSettings,
sshConnections, sshConnections,
hostShares, hostShares,
hostShareSets,
auth, auth,
desktopWindows, desktopWindows,
externalClientLaunches, externalClientLaunches,
@@ -1560,6 +1616,8 @@ export interface MockContextOptions {
protocolTargets?: Record<string, PluginProtocolTarget>; protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys serves. */ /** What ctx.credentials.listSshKeys serves. */
sshKeyCredentials?: PluginSshKeyCredential[]; sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.credentials.list serves. */
credentialSummaries?: PluginCredentialSummary[];
/** What ctx.desktop.available() answers. */ /** What ctx.desktop.available() answers. */
desktopAvailable?: boolean; desktopAvailable?: boolean;
/** What ctx.desktop.local is. See FakeContextOptions. */ /** What ctx.desktop.local is. See FakeContextOptions. */
@@ -1598,6 +1656,8 @@ export interface MockContextOptions {
/** See FakeContextOptions. */ /** See FakeContextOptions. */
loginAttemptLimit?: number; loginAttemptLimit?: number;
/** See FakeContextOptions. */ /** See FakeContextOptions. */
secondFactors?: FakeContextOptions["secondFactors"];
/** See FakeContextOptions. */
locales?: Record<string, string>; locales?: Record<string, string>;
} }
@@ -1699,6 +1759,7 @@ export function createMockCtx(
services: options.services, services: options.services,
protocolTargets: options.protocolTargets, protocolTargets: options.protocolTargets,
sshKeyCredentials: options.sshKeyCredentials, sshKeyCredentials: options.sshKeyCredentials,
credentialSummaries: options.credentialSummaries,
desktopAvailable: options.desktopAvailable, desktopAvailable: options.desktopAvailable,
desktopLocal: options.desktopLocal, desktopLocal: options.desktopLocal,
auditEntries: options.auditEntries, auditEntries: options.auditEntries,
@@ -1716,6 +1777,7 @@ export function createMockCtx(
baseUrl: options.baseUrl, baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers, linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit, loginAttemptLimit: options.loginAttemptLimit,
secondFactors: options.secondFactors,
locales: options.locales, locales: options.locales,
manifest: { manifest: {
capabilities: options.capabilities ?? [], capabilities: options.capabilities ?? [],
@@ -1900,6 +1962,10 @@ export function createMockCtx(
require("hosts:write"); require("hosts:write");
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours); return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
}, },
setShares: async (hostId, grants) => {
require("hosts:write");
return ctx.hosts.setShares(hostId, grants);
},
listUsers: async () => { listUsers: async () => {
require("hosts:write"); require("hosts:write");
return ctx.hosts.listUsers(); return ctx.hosts.listUsers();
@@ -1930,6 +1996,12 @@ export function createMockCtx(
return ctx.hosts.activity.provide(provider); return ctx.hosts.activity.provide(provider);
}, },
}, },
locks: {
provide: (provider) => {
require("hosts:lock");
return ctx.hosts.locks.provide(provider);
},
},
status: { status: {
provide: (provider) => { provide: (provider) => {
require("hosts:read"); require("hosts:read");
@@ -2057,6 +2129,13 @@ export function createMockCtx(
require("users:impersonate"); require("users:impersonate");
return ctx.auth.issueUserToken(userId, tokenOptions); return ctx.auth.issueUserToken(userId, tokenOptions);
}, },
policy: {
provide: (provider) => {
require("auth:policy");
return ctx.auth.policy.provide(provider);
},
},
listSecondFactors: () => ctx.auth.listSecondFactors(),
}, },
users: { users: {
@@ -2098,6 +2177,10 @@ export function createMockCtx(
}, },
credentials: { credentials: {
list: async () => {
require("credentials:use");
return ctx.credentials.list();
},
listSshKeys: async () => { listSshKeys: async () => {
require("credentials:use"); require("credentials:use");
return ctx.credentials.listSshKeys(); return ctx.credentials.listSshKeys();
+23
View File
@@ -9,6 +9,7 @@
* Two projects: tests/backend runs in node, tests/frontend in jsdom. * Two projects: tests/backend runs in node, tests/frontend in jsdom.
*/ */
import { createRequire } from "node:module";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -121,6 +122,28 @@ export function pluginVitestConfig(
// Vite must be allowed to read it, and React must come from the plugin. // Vite must be allowed to read it, and React must come from the plugin.
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url))); const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
const server = { fs: { allow: [pluginDir, sdkDir] } }; const server = { fs: { allow: [pluginDir, sdkDir] } };
// dedupe alone is not enough: react-dom or the testing library loaded from
// the linked SDK's own node_modules pull in a second React, and hooks then
// see no renderer.
if (!sdkDir.startsWith(pluginDir)) {
const pluginRequire = createRequire(path.join(pluginDir, "package.json"));
for (const name of [
"react",
"react-dom",
"@testing-library/react",
"react-i18next",
"i18next",
]) {
if (alias[name]) continue;
try {
alias[name] = path.dirname(
pluginRequire.resolve(`${name}/package.json`),
);
} catch {
// Not installed in the plugin: leave it to normal resolution.
}
}
}
const resolve = { const resolve = {
alias, alias,
dedupe: ["react", "react-dom", "react-i18next", "i18next"], dedupe: ["react", "react-dom", "react-i18next", "i18next"],
+7 -1
View File
@@ -22,7 +22,7 @@ AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<H
ApiKey (type): ApiKey ApiKey (type): ApiKey
AuditLog (type): AuditLog AuditLog (type): AuditLog
AuditLogFilters (type): AuditLogFilters AuditLogFilters (type): AuditLogFilters
AuthEnrollmentSections (value): () => Element AuthEnrollmentSections (value): ({ factorsOnly, }?: { factorsOnly?: boolean; }) => Element
AuthOverrideProtocol (type): string AuthOverrideProtocol (type): string
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
@@ -151,7 +151,9 @@ HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostFolder (type): HostFolder HostFolder (type): HostFolder
HostGroupKey (type): HostGroupKey HostGroupKey (type): HostGroupKey
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
HostLock (type): HostLock
HostOnly (value): ({ children }: { children: ReactNode; }) => Element HostOnly (value): ({ children }: { children: ReactNode; }) => Element
HostPolicy (type): HostPolicy
HostProtocolAuthInput (type): HostProtocolAuthInput HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols HostProtocols (type): HostProtocols
@@ -159,6 +161,7 @@ HostRowFields (type): HostRowFields
HostSidebarFilterState (type): HostSidebarFilterState HostSidebarFilterState (type): HostSidebarFilterState
HostSidebarPreferences (type): HostSidebarPreferences HostSidebarPreferences (type): HostSidebarPreferences
HostTrayTrigger (type): HostTrayTrigger HostTrayTrigger (type): HostTrayTrigger
HttpsSettings (type): HttpsSettings
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
@@ -411,8 +414,10 @@ getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData> getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
getHostEditorSection (value): (id: string) => HostEditorSectionDef getHostEditorSection (value): (id: string) => HostEditorSectionDef
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string> getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
getHostPolicy (value): () => Promise<HostPolicy>
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences> getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
getHostTags (value): () => Promise<string[]> getHostTags (value): () => Promise<string[]>
getHttpsSettings (value): () => Promise<HttpsSettings>
getLiveHostStatus (value): (hostId: number) => StatusValue getLiveHostStatus (value): (hostId: number) => StatusValue
getLogLevel (value): () => Promise<{ level: string; }> getLogLevel (value): () => Promise<{ level: string; }>
getNotificationPrivateEndpoints (value): () => Promise<string[]> getNotificationPrivateEndpoints (value): () => Promise<string[]>
@@ -570,6 +575,7 @@ updateCredential (value): (credentialId: number, credentialData: Record<string,
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>> updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void> updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }> updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
updateHttpsSettings (value): (forceHttps: boolean) => Promise<HttpsSettings>
updateLogLevel (value): (level: string) => Promise<void> updateLogLevel (value): (level: string) => Promise<void>
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }> updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }> updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
+6
View File
@@ -168,6 +168,9 @@ const ALLOWED = [
"HostDefaultsTarget", "HostDefaultsTarget",
"HostDensity", "HostDensity",
"HostFeatureFields", "HostFeatureFields",
"HostLock",
"HostPolicy",
"HttpsSettings",
"HostFeatureFieldsProps", "HostFeatureFieldsProps",
"HostFolder", "HostFolder",
"HostGroupKey", "HostGroupKey",
@@ -432,9 +435,11 @@ const ALLOWED = [
"getHostDefaultsLevel", "getHostDefaultsLevel",
"getHostEditorSection", "getHostEditorSection",
"getHostPassword", "getHostPassword",
"getHostPolicy",
"getHostSidebarPreferences", "getHostSidebarPreferences",
"getHostTags", "getHostTags",
"getLiveHostStatus", "getLiveHostStatus",
"getHttpsSettings",
"getLogLevel", "getLogLevel",
"getNotificationPrivateEndpoints", "getNotificationPrivateEndpoints",
"getPanel", "getPanel",
@@ -602,6 +607,7 @@ const ALLOWED = [
"updateSSHHost", "updateSSHHost",
"updateSecondFactorAfterExternalLogin", "updateSecondFactorAfterExternalLogin",
"updateSessionTimeout", "updateSessionTimeout",
"updateHttpsSettings",
"updateStatusCheckSettings", "updateStatusCheckSettings",
"updateSyncSettings", "updateSyncSettings",
"uploadTlsCertificate", "uploadTlsCertificate",
+74 -17
View File
@@ -26,6 +26,12 @@ import {
isSecondFactorAfterExternalLoginEnabled, isSecondFactorAfterExternalLoginEnabled,
} from "./core-auth.js"; } from "./core-auth.js";
import { findOrProvisionExternalUser } from "./provisioning.js"; import { findOrProvisionExternalUser } from "./provisioning.js";
import {
SECOND_FACTOR_SETUP,
evaluateAuthPolicy,
hasAuthPolicies,
rememberDeviceAllowed,
} from "./policy.js";
import { import {
getLoginMethod, getLoginMethod,
getSecondFactor, getSecondFactor,
@@ -122,12 +128,15 @@ export async function evaluateSecondFactors(
* governs external methods (SSO, LDAP) alone. * governs external methods (SSO, LDAP) alone.
*/ */
function shouldRunSecondFactors(methodId: string): boolean { function shouldRunSecondFactors(methodId: string): boolean {
ensureCoreLoginProviders(); if (!isExternalMethod(methodId)) return true;
const method = getLoginMethod(methodId);
if (!method?.external) return true;
return isSecondFactorAfterExternalLoginEnabled(); return isSecondFactorAfterExternalLoginEnabled();
} }
function isExternalMethod(methodId: string): boolean {
ensureCoreLoginProviders();
return !!getLoginMethod(methodId)?.external;
}
export const TRUST_DEVICE_COOKIE = "termix_trust_device"; export const TRUST_DEVICE_COOKIE = "termix_trust_device";
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/; const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -294,9 +303,40 @@ export async function runLogin(
`${context.methodId}_role_shared_credentials`, `${context.methodId}_role_shared_credentials`,
); );
const factors = shouldRunSecondFactors(context.methodId) // A login policy may want a factor where core would skip it, so it always
? await evaluateSecondFactors(user.id) // sees the full picture.
: { required: [], blocked: [] }; const runFactors = shouldRunSecondFactors(context.methodId);
const factors =
runFactors || hasAuthPolicies()
? await evaluateSecondFactors(user.id)
: { required: [], blocked: [] };
const trusted =
factors.required.length > 0 || hasAuthPolicies()
? await isTrustedDevice(req, user.id)
: false;
const policy = await evaluateAuthPolicy({
userId: user.id,
methodId: context.methodId,
external: isExternalMethod(context.methodId),
deviceType,
trustedDevice: trusted,
mfaSatisfied: !!identity.mfaSatisfied,
secondFactors: listSecondFactors().map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
enrolled: factors.required.some(
(required) =>
required.id === factor.id && required.pluginId === factor.pluginId,
),
})),
});
if (!runFactors && !policy.secondFactor) {
factors.required = [];
factors.blocked = [];
}
const rememberMe = context.rememberMe && policy.allowRememberDevice;
const skipByDevice =
trusted && policy.allowRememberDevice && policy.secondFactor !== "always";
if (factors.blocked.length > 0) { if (factors.blocked.length > 0) {
authLogger.warn("Login refused: an enrolled second factor is unavailable", { authLogger.warn("Login refused: an enrolled second factor is unavailable", {
operation: "second_factor_unavailable", operation: "second_factor_unavailable",
@@ -324,11 +364,7 @@ export async function runLogin(
const ssoClaims = { externalSession: identity.externalSession ?? null }; const ssoClaims = { externalSession: identity.externalSession ?? null };
if ( if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) {
factors.required.length > 0 &&
!identity.mfaSatisfied &&
!(await isTrustedDevice(req, user.id))
) {
const tempToken = await AuthManager.getInstance().generateJWTToken( const tempToken = await AuthManager.getInstance().generateJWTToken(
user.id, user.id,
{ pendingTOTP: true, expiresIn: "10m" }, { pendingTOTP: true, expiresIn: "10m" },
@@ -337,14 +373,17 @@ export async function runLogin(
pendingLogins.set(pendingKey(tempToken), { pendingLogins.set(pendingKey(tempToken), {
userId: user.id, userId: user.id,
methodId: context.methodId, methodId: context.methodId,
rememberMe: context.rememberMe, rememberMe,
allowRememberDevice: policy.allowRememberDevice,
maxSessionSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
...ssoClaims, ...ssoClaims,
createdAt: Date.now(), createdAt: Date.now(),
}); });
return { return {
kind: "second-factor", kind: "second-factor",
tempToken, tempToken,
rememberMe: context.rememberMe, rememberMe,
factors: factors.required.map((factor) => ({ factors: factors.required.map((factor) => ({
id: factor.id, id: factor.id,
pluginId: factor.pluginId, pluginId: factor.pluginId,
@@ -353,11 +392,22 @@ export async function runLogin(
}; };
} }
// Required but nothing set up yet: a session that can only set one up.
const needsSetup =
!!policy.secondFactor &&
factors.required.length === 0 &&
!identity.mfaSatisfied &&
!skipByDevice;
const session = await issueSession(req, user, { const session = await issueSession(req, user, {
methodId: context.methodId, methodId: context.methodId,
rememberMe: context.rememberMe, rememberMe,
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername, rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
longLivedForApps: identity.kind === "external", longLivedForApps:
identity.kind === "external" && policy.allowRememberDevice,
maxAgeSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
restriction: needsSetup ? SECOND_FACTOR_SETUP : null,
...ssoClaims, ...ssoClaims,
}); });
return { kind: "session", ...session }; return { kind: "session", ...session };
@@ -510,10 +560,15 @@ export async function verifySecondFactorAndRespond(
} }
loginRateLimiter.resetTOTPAttempts(user.id); loginRateLimiter.resetTOTPAttempts(user.id);
// A pending entry lost to a restart falls back to the current policy.
const allowRemember = lookup.pending
? lookup.pending.allowRememberDevice !== false
: rememberDeviceAllowed();
const rememberMe = const rememberMe =
typeof req.body?.rememberMe === "boolean" allowRemember &&
(typeof req.body?.rememberMe === "boolean"
? req.body.rememberMe ? req.body.rememberMe
: (lookup.pending?.rememberMe ?? false); : (lookup.pending?.rememberMe ?? false));
let trustToken: string | null = null; let trustToken: string | null = null;
if (rememberMe) { if (rememberMe) {
@@ -545,6 +600,8 @@ export async function verifySecondFactorAndRespond(
methodId: pending?.methodId ?? "password", methodId: pending?.methodId ?? "password",
rememberMe, rememberMe,
externalSession: pending?.externalSession ?? null, externalSession: pending?.externalSession ?? null,
maxAgeSeconds: pending?.maxSessionSeconds ?? null,
idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null,
}); });
if (trustToken && isNativeAppRequest(req)) { if (trustToken && isNativeAppRequest(req)) {
+125
View File
@@ -0,0 +1,125 @@
/**
* Login policy from plugins through ctx.auth.policy.provide: idle timeout,
* session length, remember me and a required second factor. Core asks every
* provider and the strictest answer wins. A provider that throws at login
* refuses the login, since skipping a security rule is worse than a failed
* sign in. The idle timeout is stored on the session at login. Desktop
* loopback, sync link and plugin minted sessions are device credentials, not
* logins, so the policy never reaches them.
*/
import type {
PluginAuthPolicyDecision,
PluginAuthPolicyInput,
PluginAuthPolicyProvider,
} from "@termix-ssh/plugin-sdk/backend";
import { authLogger } from "../utils/logger.js";
import { LoginMethodError } from "./types.js";
/** sessions.restriction of a session that may only set up a second factor. */
export const SECOND_FACTOR_SETUP = "second-factor-setup";
interface Entry {
pluginId: string;
provider: PluginAuthPolicyProvider;
}
const providers = new Map<symbol, Entry>();
export function provideAuthPolicy(
pluginId: string,
provider: PluginAuthPolicyProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearAuthPolicies(): void {
providers.clear();
}
export function hasAuthPolicies(): boolean {
return providers.size > 0;
}
export interface AuthPolicy {
allowRememberDevice: boolean;
maxSessionSeconds: number | null;
idleTimeoutSeconds: number | null;
secondFactor: "always" | "new-device" | null;
}
const NO_POLICY: AuthPolicy = {
allowRememberDevice: true,
maxSessionSeconds: null,
idleTimeoutSeconds: null,
secondFactor: null,
};
function shortest(current: number | null, next: unknown): number | null {
if (typeof next !== "number" || !(next > 0)) return current;
const value = Math.floor(next);
return current === null ? value : Math.min(current, value);
}
function merge(policy: AuthPolicy, next: PluginAuthPolicyDecision): AuthPolicy {
let secondFactor = policy.secondFactor;
if (next.secondFactor === "always") secondFactor = "always";
else if (next.secondFactor === "new-device" && secondFactor !== "always") {
secondFactor = "new-device";
}
return {
allowRememberDevice:
policy.allowRememberDevice && next.allowRememberDevice !== false,
maxSessionSeconds: shortest(
policy.maxSessionSeconds,
next.maxSessionSeconds,
),
idleTimeoutSeconds: shortest(
policy.idleTimeoutSeconds,
next.idleTimeoutSeconds,
),
secondFactor,
};
}
export async function evaluateAuthPolicy(
input: PluginAuthPolicyInput,
): Promise<AuthPolicy> {
let policy = NO_POLICY;
for (const entry of providers.values()) {
let decision: PluginAuthPolicyDecision;
try {
decision = (await entry.provider.evaluate(input)) ?? {};
} catch (error) {
authLogger.warn("Login policy failed, refusing the login", {
operation: "auth_policy",
pluginId: entry.pluginId,
error: error instanceof Error ? error.message : String(error),
});
throw new LoginMethodError(
"Sign in is not available right now. Contact an admin.",
503,
"auth_policy_unavailable",
);
}
policy = merge(policy, decision);
}
return policy;
}
/** Whether the login page should offer remember me. */
export function rememberDeviceAllowed(): boolean {
for (const entry of providers.values()) {
try {
if (entry.provider.allowRememberDevice?.() === false) return false;
} catch {
return false;
}
}
return true;
}
+13 -1
View File
@@ -68,6 +68,11 @@ export interface IssueSessionOptions {
externalSession?: ExternalSessionRef | null; externalSession?: ExternalSessionRef | null;
/** SSO logins keep desktop and mobile apps signed in for 30 days. */ /** SSO logins keep desktop and mobile apps signed in for 30 days. */
longLivedForApps?: boolean; longLivedForApps?: boolean;
/** Login policy cap on the session length. */
maxAgeSeconds?: number | null;
/** See SECOND_FACTOR_SETUP. */
restriction?: string | null;
idleTimeoutSeconds?: number | null;
} }
export interface IssuedSession { export interface IssuedSession {
@@ -93,6 +98,9 @@ export async function issueSession(
deviceType: deviceInfo.type, deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo, deviceInfo: deviceInfo.deviceInfo,
externalSession: options.externalSession ?? null, externalSession: options.externalSession ?? null,
maxAgeSeconds: options.maxAgeSeconds ?? null,
restriction: options.restriction ?? null,
idleTimeoutSeconds: options.idleTimeoutSeconds ?? null,
}); });
if (options.rateLimitUsername) { if (options.rateLimitUsername) {
@@ -123,11 +131,14 @@ export async function issueSession(
method: options.methodId, method: options.methodId,
}); });
const maxAge = let maxAge =
options.longLivedForApps && options.longLivedForApps &&
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile") (deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
? THIRTY_DAYS_MS ? THIRTY_DAYS_MS
: await sessionCookieMaxAge(options.rememberMe); : await sessionCookieMaxAge(options.rememberMe);
if (options.maxAgeSeconds) {
maxAge = Math.min(maxAge, options.maxAgeSeconds * 1000);
}
return { return {
token, token,
@@ -142,6 +153,7 @@ export async function issueSession(
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor( totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
user.id, user.id,
), ),
...(options.restriction ? { restriction: options.restriction } : {}),
...(isNativeAppRequest(req) ? { token } : {}), ...(isNativeAppRequest(req) ? { token } : {}),
}, },
}; };
+4
View File
@@ -35,5 +35,9 @@ export interface PendingLogin {
methodId: string; methodId: string;
rememberMe: boolean; rememberMe: boolean;
externalSession?: ExternalSessionRef | null; externalSession?: ExternalSessionRef | null;
/** Login policy that applied when the login paused. */
allowRememberDevice?: boolean;
maxSessionSeconds?: number | null;
idleTimeoutSeconds?: number | null;
createdAt: number; createdAt: number;
} }
+2
View File
@@ -73,6 +73,7 @@ import {
} from "../plugins/user-data.js"; } from "../plugins/user-data.js";
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js"; import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
import { acmeChallengeHandler } from "../tls/acme-challenges.js"; import { acmeChallengeHandler } from "../tls/acme-challenges.js";
import { httpsPolicyMiddleware } from "../tls/https-policy.js";
import { import {
createCurrentCredentialRepository, createCurrentCredentialRepository,
createCurrentHostRepository, createCurrentHostRepository,
@@ -105,6 +106,7 @@ const __dirname = path.dirname(__filename);
const app = express(); const app = express();
app.set("trust proxy", "loopback"); app.set("trust proxy", "loopback");
app.use(httpsPolicyMiddleware);
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware(); const authenticateJWT = authManager.createAuthMiddleware();
+2
View File
@@ -1021,6 +1021,8 @@ const migrateSchema = () => {
} }
enforceUniqueUsernames(); enforceUniqueUsernames();
addColumnIfNotExists("sessions", "external_session_ref", "TEXT"); addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
addColumnIfNotExists("sessions", "restriction", "TEXT");
addColumnIfNotExists("sessions", "idle_timeout", "INTEGER");
addColumnIfNotExists("ssh_data", "name", "TEXT"); addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists("ssh_data", "folder", "TEXT"); addColumnIfNotExists("ssh_data", "folder", "TEXT");
+4
View File
@@ -59,6 +59,10 @@ export const sessions = mysqlTable(
lastActiveAt: text("last_active_at") lastActiveAt: text("last_active_at")
.notNull() .notNull()
.default(sql`(CURRENT_TIMESTAMP)`), .default(sql`(CURRENT_TIMESTAMP)`),
// Set while the session may only set up a second factor.
restriction: text("restriction"),
// Seconds without a request before the session ends, from the login policy.
idleTimeout: int("idle_timeout"),
}, },
// Listing a user's devices, and the startup sweep of expired rows. // Listing a user's devices, and the startup sweep of expired rows.
(table) => [ (table) => [
+4
View File
@@ -59,6 +59,10 @@ export const sessions = pgTable(
lastActiveAt: text("last_active_at") lastActiveAt: text("last_active_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
// Set while the session may only set up a second factor.
restriction: text("restriction"),
// Seconds without a request before the session ends, from the login policy.
idleTimeout: integer("idle_timeout"),
}, },
// Listing a user's devices, and the startup sweep of expired rows. // Listing a user's devices, and the startup sweep of expired rows.
(table) => [ (table) => [
+4
View File
@@ -47,6 +47,10 @@ export const sessions = sqliteTable(
lastActiveAt: text("last_active_at") lastActiveAt: text("last_active_at")
.notNull() .notNull()
.default(sql`CURRENT_TIMESTAMP`), .default(sql`CURRENT_TIMESTAMP`),
// Set while the session may only set up a second factor.
restriction: text("restriction", { length: 32 }),
// Seconds without a request before the session ends, from the login policy.
idleTimeout: integer("idle_timeout"),
}, },
// Listing a user's devices, and the startup sweep of expired rows. // Listing a user's devices, and the startup sweep of expired rows.
(table) => [ (table) => [
@@ -69,6 +69,14 @@ export class SessionRepository {
return true; return true;
} }
async clearRestrictionForUser(userId: string): Promise<void> {
await this.context.drizzle
.update(sessions)
.set({ restriction: null })
.where(eq(sessions.userId, userId));
await this.afterWrite();
}
async updateToken( async updateToken(
id: string, id: string,
jwtToken: string, jwtToken: string,
+6 -2
View File
@@ -25,6 +25,7 @@ import {
} from "../../auth/registry.js"; } from "../../auth/registry.js";
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js"; import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js"; import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
import { rememberDeviceAllowed } from "../../auth/policy.js";
export interface PublicLoginMethod { export interface PublicLoginMethod {
id: string; id: string;
@@ -243,7 +244,7 @@ export function registerAuthRoutes(router: Router): void {
* /users/auth/methods: * /users/auth/methods:
* get: * get:
* summary: List login methods * summary: List login methods
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only. * description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only, plus rememberDevice, false when the login policy turned remember me off.
* tags: * tags:
* - Auth * - Auth
* responses: * responses:
@@ -252,7 +253,10 @@ export function registerAuthRoutes(router: Router): void {
*/ */
router.get("/auth/methods", async (_req, res) => { router.get("/auth/methods", async (_req, res) => {
try { try {
res.json({ methods: await listPublicLoginMethods() }); res.json({
methods: await listPublicLoginMethods(),
rememberDevice: rememberDeviceAllowed(),
});
} catch (error) { } catch (error) {
authLogger.error("Failed to list login methods", error); authLogger.error("Failed to list login methods", error);
res.status(500).json({ error: "Failed to list login methods" }); res.status(500).json({ error: "Failed to list login methods" });
@@ -11,6 +11,7 @@ import { sshLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js"; import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { createCurrentHostRepository } from "../repositories/factory.js"; import { createCurrentHostRepository } from "../repositories/factory.js";
import { validateParentHostId } from "./host-parent-validation.js"; import { validateParentHostId } from "./host-parent-validation.js";
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
import { setHostPluginEnabled } from "./host-plugin-settings.js"; import { setHostPluginEnabled } from "./host-plugin-settings.js";
@@ -141,6 +142,15 @@ export function registerHostBulkRoutes(
); );
const ownedIds = ownedHosts.map((h) => h.id); const ownedIds = ownedHosts.map((h) => h.id);
// Pins stay the user's own on a managed host.
if (Object.keys(updates).some((key) => key !== "pin")) {
try {
assertHostsUnlocked(ownedIds);
} catch (error) {
if (sendHostLocked(res, error)) return;
throw error;
}
}
const unauthorizedIds = hostIds.filter( const unauthorizedIds = hostIds.filter(
(id: number) => !ownedIds.includes(id), (id: number) => !ownedIds.includes(id),
); );
@@ -32,6 +32,7 @@ import {
} from "../../hosts/defaults/service.js"; } from "../../hosts/defaults/service.js";
import { changeHostOverrides } from "../../hosts/defaults/overrides.js"; import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
import { readDefaultsReset } from "./host-bulk-routes.js"; import { readDefaultsReset } from "./host-bulk-routes.js";
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
type HostDefaultsRoutesDeps = { type HostDefaultsRoutesDeps = {
authenticateJWT: RequestHandler; authenticateJWT: RequestHandler;
@@ -678,6 +679,7 @@ export function registerHostDefaultsRoutes(
const reset = readDefaultsReset(req.body); const reset = readDefaultsReset(req.body);
if (!reset) return res.status(400).json({ error: "Nothing to reset" }); if (!reset) return res.status(400).json({ error: "Nothing to reset" });
try { try {
assertHostsUnlocked([hostId]);
const access = await PermissionManager.getInstance().canAccessHost( const access = await PermissionManager.getInstance().canAccessHost(
userId, userId,
hostId, hostId,
@@ -700,6 +702,7 @@ export function registerHostDefaultsRoutes(
await changeHostOverrides([hostId], reset); await changeHostOverrides([hostId], reset);
res.json({ message: "Host reset to its defaults" }); res.json({ message: "Host reset to its defaults" });
} catch (error) { } catch (error) {
if (sendHostLocked(res, error)) return;
databaseLogger.error("Failed to reset host defaults", error); databaseLogger.error("Failed to reset host defaults", error);
res.status(500).json({ error: "Failed to reset host defaults" }); res.status(500).json({ error: "Failed to reset host defaults" });
} }
@@ -16,6 +16,31 @@ import {
folderCredentialIds, folderCredentialIds,
setFolderCredentialDefault, setFolderCredentialDefault,
} from "../../hosts/defaults/service.js"; } from "../../hosts/defaults/service.js";
import {
assertHostsUnlocked,
hasHostLocks,
sendHostLocked,
} from "../../hosts/locks.js";
/** Refuses a folder change that would touch a locked host. */
async function folderHostsUnlocked(
userId: string,
folderName: string,
res: Response,
): Promise<boolean> {
if (!hasHostLocks()) return true;
const inFolder = await createCurrentHostFolderRepository().listHostsInFolder(
userId,
folderName,
);
try {
assertHostsUnlocked(inFolder.map((host) => host.id));
return true;
} catch (error) {
if (sendHostLocked(res, error)) return false;
throw error;
}
}
type HostFolderRoutesDeps = { type HostFolderRoutesDeps = {
authenticateJWT: RequestHandler; authenticateJWT: RequestHandler;
@@ -86,6 +111,7 @@ export function registerHostFolderRoutes(
} }
try { try {
if (!(await folderHostsUnlocked(userId, oldName, res))) return;
const { updatedHosts, updatedCredentials } = const { updatedHosts, updatedCredentials } =
await createCurrentHostFolderRepository().renameFolder( await createCurrentHostFolderRepository().renameFolder(
userId, userId,
@@ -417,6 +443,7 @@ export function registerHostFolderRoutes(
}); });
try { try {
if (!(await folderHostsUnlocked(userId, folderName, res))) return;
const hostFolderRepository = createCurrentHostFolderRepository(); const hostFolderRepository = createCurrentHostFolderRepository();
const hostsToDelete = await hostFolderRepository.listHostsInFolder( const hostsToDelete = await hostFolderRepository.listHostsInFolder(
userId, userId,
@@ -5,6 +5,7 @@ import {
} from "../../hosts/ssh-options.js"; } from "../../hosts/ssh-options.js";
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js"; import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js"; import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
import { hostLock } from "../../hosts/locks.js";
export function isNonEmptyString(value: unknown): value is string { export function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0; return typeof value === "string" && value.trim().length > 0;
@@ -337,6 +338,7 @@ const CONNECT_LEVEL_FIELDS = new Set([
"sshPort", "sshPort",
"protocolAuth", "protocolAuth",
"jumpHosts", "jumpHosts",
"lock",
"createdAt", "createdAt",
"updatedAt", "updatedAt",
]); ]);
@@ -439,8 +441,10 @@ export function transformHostResponse(
host: Record<string, unknown>, host: Record<string, unknown>,
): Record<string, unknown> { ): Record<string, unknown> {
const shared = parseSharedSource(host.sharedSource); const shared = parseSharedSource(host.sharedSource);
const lock = typeof host.id === "number" ? hostLock(host.id) : null;
return { return {
...host, ...host,
...(lock ? { lock } : {}),
sharedSource: undefined, sharedSource: undefined,
localOnly: !!host.localOnly, localOnly: !!host.localOnly,
sharedCopy: !!shared, sharedCopy: !!shared,
+29
View File
@@ -85,6 +85,11 @@ import {
type PlannedProtocolAuth, type PlannedProtocolAuth,
} from "../../hosts/protocol-auth/protocol-auth.js"; } from "../../hosts/protocol-auth/protocol-auth.js";
import { findHostProtocol } from "../../hosts/protocol-auth/registry.js"; import { findHostProtocol } from "../../hosts/protocol-auth/registry.js";
import {
manualCreateBlockedBy,
requireManualCreate,
requireUnlockedHost,
} from "../../hosts/locks.js";
import { import {
mergeStoredSshOptions, mergeStoredSshOptions,
parseTerminalConfig, parseTerminalConfig,
@@ -157,6 +162,7 @@ router.post(
authenticateJWT, authenticateJWT,
permissionManager.requirePermission("hosts.create"), permissionManager.requirePermission("hosts.create"),
requireDataAccess, requireDataAccess,
requireManualCreate,
requireHostEnrollmentAccessForPath, requireHostEnrollmentAccessForPath,
upload.single("key"), upload.single("key"),
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
@@ -736,6 +742,7 @@ router.put(
authenticateJWT, authenticateJWT,
permissionManager.requirePermission("hosts.edit"), permissionManager.requirePermission("hosts.edit"),
requireDataAccess, requireDataAccess,
requireUnlockedHost,
upload.single("key"), upload.single("key"),
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const hostId = Array.isArray(req.params.id) const hostId = Array.isArray(req.params.id)
@@ -1205,6 +1212,27 @@ router.put(
}, },
); );
/**
* @openapi
* /host/policy:
* get:
* summary: Host creation policy
* description: Whether hosts can be added by hand, or a plugin manages them all.
* tags:
* - SSH
* responses:
* 200:
* description: "{ manualCreate, blockedBy? } where blockedBy is the managing plugin's name."
*/
router.get("/policy", authenticateJWT, (_req: Request, res: Response) => {
const blocked = manualCreateBlockedBy();
res.json(
blocked
? { manualCreate: false, blockedBy: blocked.pluginName }
: { manualCreate: true },
);
});
/** /**
* @openapi * @openapi
* /host/db/host: * /host/db/host:
@@ -1702,6 +1730,7 @@ router.delete(
authenticateJWT, authenticateJWT,
permissionManager.requirePermission("hosts.delete"), permissionManager.requirePermission("hosts.delete"),
requireDataAccess, requireDataAccess,
requireUnlockedHost,
async (req: Request, res: Response) => { async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId; const userId = (req as AuthenticatedRequest).userId;
const hostId = Array.isArray(req.params.id) const hostId = Array.isArray(req.params.id)
+13
View File
@@ -44,6 +44,7 @@ import {
validateSettingsSave, validateSettingsSave,
type PluginSettingsScope, type PluginSettingsScope,
} from "../../plugins/settings.js"; } from "../../plugins/settings.js";
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
import { import {
getAuditUsername, getAuditUsername,
getRequestMeta, getRequestMeta,
@@ -1878,6 +1879,18 @@ router.put(
const inherit = Array.isArray(inheritRaw) const inherit = Array.isArray(inheritRaw)
? inheritRaw.filter((key): key is string => typeof key === "string") ? inheritRaw.filter((key): key is string => typeof key === "string")
: []; : [];
// A managed host keeps its settings; only personal ones stay per user.
const changesShared = [...Object.keys(written), ...inherit].some(
(key) => !findField(manifest, "host", key)?.personal,
);
if (changesShared) {
try {
assertHostsUnlocked([hostId]);
} catch (error) {
if (sendHostLocked(res, error)) return;
throw error;
}
}
const errors = await applySettings( const errors = await applySettings(
userId, userId,
manifest, manifest,
+18
View File
@@ -9,6 +9,12 @@ import {
logAudit, logAudit,
} from "../../utils/audit-logger.js"; } from "../../utils/audit-logger.js";
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js"; import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import {
assertHostsUnlocked,
hasHostLocks,
requireUnlockedHost,
sendHostLocked,
} from "../../hosts/locks.js";
import { import {
SharedHostAuthOverrideService, SharedHostAuthOverrideService,
SharedHostAuthOverrideServiceError, SharedHostAuthOverrideServiceError,
@@ -229,6 +235,7 @@ router.post(
"/host/:id/share", "/host/:id/share",
authenticateJWT, authenticateJWT,
permissionManager.requirePermission("hosts.share"), permissionManager.requirePermission("hosts.share"),
requireUnlockedHost,
async (req: AuthenticatedRequest, res: Response) => { async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const hostId = parseInt(id, 10); const hostId = parseInt(id, 10);
@@ -451,6 +458,14 @@ router.post(
} }
try { try {
if (hasHostLocks()) {
const inFolder =
await createCurrentHostFolderRepository().listHostsInFolder(
userId,
folder,
);
assertHostsUnlocked(inFolder.map((host) => host.id));
}
const targets = parseShareTargets(req.body ?? {}); const targets = parseShareTargets(req.body ?? {});
if (!targets) { if (!targets) {
return res.status(400).json({ return res.status(400).json({
@@ -613,6 +628,7 @@ router.post(
hostResults, hostResults,
}); });
} catch (error) { } catch (error) {
if (sendHostLocked(res, error)) return;
databaseLogger.error("Failed to share folder", error, { databaseLogger.error("Failed to share folder", error, {
operation: "share_folder", operation: "share_folder",
folder, folder,
@@ -786,6 +802,7 @@ router.delete(
router.patch( router.patch(
"/host/:id/access/:accessId", "/host/:id/access/:accessId",
authenticateJWT, authenticateJWT,
requireUnlockedHost,
async (req: AuthenticatedRequest, res: Response) => { async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId) const accessIdParam = Array.isArray(req.params.accessId)
@@ -908,6 +925,7 @@ router.patch(
router.delete( router.delete(
"/host/:id/access/:accessId", "/host/:id/access/:accessId",
authenticateJWT, authenticateJWT,
requireUnlockedHost,
async (req: AuthenticatedRequest, res: Response) => { async (req: AuthenticatedRequest, res: Response) => {
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
const accessIdParam = Array.isArray(req.params.accessId) const accessIdParam = Array.isArray(req.params.accessId)
@@ -15,6 +15,8 @@ import {
createCurrentUserRepository, createCurrentUserRepository,
} from "../repositories/factory.js"; } from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js"; import type { UserRecord } from "../repositories/user-repository.js";
import { isForceHttpsEnabled, setForceHttps } from "../../tls/https-policy.js";
import { getTlsConfig } from "../../tls/tls-service.js";
async function getAdminActor( async function getAdminActor(
userId: string | undefined, userId: string | undefined,
@@ -187,6 +189,90 @@ export function registerUserSettingsRoutes(
} }
}); });
/**
* @openapi
* /users/https-settings:
* get:
* summary: Get the HTTPS redirect setting (admin only)
* description: Whether plain HTTP is redirected to HTTPS with HSTS, and whether HTTPS is on at all. The redirect only applies while HTTPS is on.
* tags:
* - Users
* responses:
* 200:
* description: forceHttps and tlsEnabled.
* 403:
* description: Not authorized.
*/
router.get("/https-settings", authenticateJWT, async (req, res) => {
try {
if (!(await getAdminActor((req as AuthenticatedRequest).userId))) {
return res.status(403).json({ error: "Not authorized" });
}
res.json({
forceHttps: await isForceHttpsEnabled(),
tlsEnabled: getTlsConfig().enabled,
});
} catch (err) {
authLogger.error("Failed to get HTTPS settings", err);
res.status(500).json({ error: "Failed to get HTTPS settings" });
}
});
/**
* @openapi
* /users/https-settings:
* patch:
* summary: Update the HTTPS redirect setting (admin only)
* description: Turns the redirect from HTTP to HTTPS and the HSTS header on or off. Off by default so reverse proxies keep working.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* forceHttps:
* type: boolean
* responses:
* 200:
* description: Setting updated.
* 400:
* description: Invalid value.
* 403:
* description: Not authorized.
*/
router.patch("/https-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { forceHttps } = req.body ?? {};
if (typeof forceHttps !== "boolean") {
return res.status(400).json({ error: "forceHttps must be a boolean" });
}
await setForceHttps(forceHttps);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_force_https",
resourceType: "setting",
details: JSON.stringify({ forceHttps }),
ipAddress,
userAgent,
success: true,
});
res.json({ forceHttps, tlsEnabled: getTlsConfig().enabled });
} catch (err) {
authLogger.error("Failed to set HTTPS settings", err);
res.status(500).json({ error: "Failed to set HTTPS settings" });
}
});
/** /**
* GET/PATCH a comma-list of private hosts an outbound feature may reach. * GET/PATCH a comma-list of private hosts an outbound feature may reach.
* One route pair per private endpoint allowlist, each with its own setting key. * One route pair per private endpoint allowlist, each with its own setting key.
+25 -1
View File
@@ -61,6 +61,7 @@ import {
syncSharedCredentialsForUserRoles, syncSharedCredentialsForUserRoles,
} from "../../auth/session-issuer.js"; } from "../../auth/session-issuer.js";
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js"; import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
import { evaluateAuthPolicy } from "../../auth/policy.js";
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
@@ -420,9 +421,21 @@ router.post("/proxy-login", async (req, res) => {
userRecord.id, userRecord.id,
"trusted_proxy_login_role_shared_credentials", "trusted_proxy_login_role_shared_credentials",
); );
// The proxy owns second factors, so only the session length applies.
const policy = await evaluateAuthPolicy({
userId: userRecord.id,
methodId: "proxy",
external: true,
deviceType: deviceInfo.type,
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [],
});
const token = await authManager.generateJWTToken(userRecord.id, { const token = await authManager.generateJWTToken(userRecord.id, {
deviceType: deviceInfo.type, deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo, deviceInfo: deviceInfo.deviceInfo,
maxAgeSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
}); });
const payload = await authManager.verifyJWTToken(token); const payload = await authManager.verifyJWTToken(token);
const { ipAddress, userAgent } = getRequestMeta(req); const { ipAddress, userAgent } = getRequestMeta(req);
@@ -443,7 +456,16 @@ router.post("/proxy-login", async (req, res) => {
}); });
return res return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req)) .cookie(
"jwt",
token,
authManager.getSecureCookieOptions(
req,
policy.maxSessionSeconds
? Math.min(24 * 60 * 60 * 1000, policy.maxSessionSeconds * 1000)
: undefined,
),
)
.json({ .json({
enabled: true, enabled: true,
success: true, success: true,
@@ -614,6 +636,8 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
user.id, user.id,
), ),
linked: await describeDesktopLink(user.id), linked: await describeDesktopLink(user.id),
// "second-factor-setup" while the login policy wants a factor set up.
restriction: (req as AuthenticatedRequest).sessionRestriction ?? null,
}); });
} catch (err) { } catch (err) {
authLogger.error("Failed to get username", err); authLogger.error("Failed to get username", err);
+26
View File
@@ -30,6 +30,7 @@ import {
isValidPort, isValidPort,
normalizeImportedHost, normalizeImportedHost,
} from "../../database/routes/host-normalizers.js"; } from "../../database/routes/host-normalizers.js";
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
/** A request the caller got wrong. Routes answer it with a 400. */ /** A request the caller got wrong. Routes answer it with a 400. */
export class ImportError extends Error { export class ImportError extends Error {
@@ -263,6 +264,19 @@ export function importResponse(results: ImportResults) {
export const MAX_IMPORT_HOSTS = 100; export const MAX_IMPORT_HOSTS = 100;
/** Why an import row can't be written while a plugin manages hosts, if it can't. */
function importLockError(existingId: number | undefined): string | null {
if (!hasHostLocks()) return null;
if (existingId === undefined) {
const blocked = manualCreateBlockedBy();
return blocked
? `Hosts are managed by ${blocked.pluginName}, so new hosts can't be added`
: null;
}
const lock = hostLock(existingId);
return lock ? `This host is managed by ${lock.pluginName}` : null;
}
/** Deletes what an atomic import created, newest first. */ /** Deletes what an atomic import created, newest first. */
async function rollBackCreated( async function rollBackCreated(
userId: string, userId: string,
@@ -446,6 +460,12 @@ export async function runBulkImport(
results.skipped++; results.skipped++;
continue; continue;
} }
const locked = importLockError(existing?.id);
if (locked) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${locked}`);
continue;
}
if ( if (
effectiveConnectionType === "ssh" && effectiveConnectionType === "ssh" &&
@@ -745,6 +765,12 @@ export async function runSshConfigImport(
results.skipped++; results.skipped++;
continue; continue;
} }
const locked = importLockError(existing?.id);
if (locked) {
results.failed++;
results.errors.push(`Host "${parsed[i].name}": ${locked}`);
continue;
}
const sshDataObj: Record<string, unknown> = { const sshDataObj: Record<string, unknown> = {
userId, userId,
+173
View File
@@ -0,0 +1,173 @@
/**
* Host locks: hosts a plugin manages itself (for example from a config file)
* through ctx.hosts.locks.provide. Core refuses edits, deletes and share
* changes on a locked host everywhere, except from the plugin that locks it,
* and a provider can block adding hosts by hand. Pins and order stay with the
* user.
*/
import type { PluginHostLockProvider } from "@termix-ssh/plugin-sdk/backend";
import { pluginLogger } from "../utils/logger.js";
export interface HostLockInfo {
pluginId: string;
pluginName: string;
note: string | null;
}
interface Entry {
pluginId: string;
pluginName: string;
provider: PluginHostLockProvider;
}
const providers = new Map<symbol, Entry>();
export class HostLockedError extends Error {
readonly status = 403;
readonly code = "HOST_LOCKED";
constructor(
message: string,
readonly lock: { pluginId: string; pluginName: string },
) {
super(message);
this.name = "HostLockedError";
}
}
export function provideHostLocks(
pluginId: string,
pluginName: string,
provider: PluginHostLockProvider,
): () => void {
const key = Symbol(pluginId);
providers.set(key, { pluginId, pluginName, provider });
return () => {
providers.delete(key);
};
}
/** Test seam. */
export function clearHostLocks(): void {
providers.clear();
}
// A provider that throws never locks: a broken plugin must not freeze hosts.
function safely<T>(entry: Entry, fallback: T, read: () => T): T {
try {
return read();
} catch (error) {
pluginLogger.warn("Host lock provider failed", {
operation: "host_lock",
pluginId: entry.pluginId,
error: error instanceof Error ? error.message : String(error),
});
return fallback;
}
}
export function hostLock(
hostId: number,
exemptPluginId?: string,
): HostLockInfo | null {
if (!Number.isInteger(hostId)) return null;
for (const entry of providers.values()) {
if (entry.pluginId === exemptPluginId) continue;
if (!safely(entry, false, () => entry.provider.isLocked(hostId))) continue;
return {
pluginId: entry.pluginId,
pluginName: entry.pluginName,
note: safely(entry, null, () => entry.provider.note(hostId) ?? null),
};
}
return null;
}
export function hasHostLocks(): boolean {
return providers.size > 0;
}
/** Throws HostLockedError for the first locked host. */
export function assertHostsUnlocked(
hostIds: Iterable<number>,
exemptPluginId?: string,
): void {
if (providers.size === 0) return;
for (const hostId of hostIds) {
const lock = hostLock(Number(hostId), exemptPluginId);
if (lock) {
throw new HostLockedError(
`This host is managed by ${lock.pluginName} and can't be changed here`,
lock,
);
}
}
}
/** The plugin that blocks adding hosts by hand, if any. */
export function manualCreateBlockedBy(
exemptPluginId?: string,
): { pluginId: string; pluginName: string } | null {
for (const entry of providers.values()) {
if (entry.pluginId === exemptPluginId) continue;
if (!safely(entry, true, () => entry.provider.allowManualCreate())) {
return { pluginId: entry.pluginId, pluginName: entry.pluginName };
}
}
return null;
}
export function assertManualCreateAllowed(exemptPluginId?: string): void {
const blocked = manualCreateBlockedBy(exemptPluginId);
if (blocked) {
throw new HostLockedError(
`Hosts are managed by ${blocked.pluginName}, so new hosts can't be added here`,
blocked,
);
}
}
type Res = { status: (code: number) => { json: (body: unknown) => unknown } };
type Next = () => void;
/** Route guard for creating hosts by hand. */
export function requireManualCreate(_req: unknown, res: Res, next: Next) {
try {
assertManualCreateAllowed();
} catch (error) {
if (sendHostLocked(res, error)) return;
throw error;
}
next();
}
/** Route guard for routes that change the host named by :id. */
export function requireUnlockedHost(
req: { params: Record<string, string | string[]> },
res: Res,
next: Next,
) {
const raw = req.params.id;
const hostId = Number(Array.isArray(raw) ? raw[0] : raw);
try {
assertHostsUnlocked([hostId]);
} catch (error) {
if (sendHostLocked(res, error)) return;
throw error;
}
next();
}
/** Sends a HostLockedError as a 403. False when the error is something else. */
export function sendHostLocked(
res: { status: (code: number) => { json: (body: unknown) => unknown } },
error: unknown,
): boolean {
if (!(error instanceof HostLockedError)) return false;
res.status(403).json({
error: error.message,
code: error.code,
lockedBy: error.lock.pluginId,
});
return true;
}
+31
View File
@@ -150,6 +150,37 @@ export function createPluginCredentials({
} }
}, },
list: async () => {
try {
await assertCapability(pluginId, "credentials:use", declared);
const userId = actingUser();
await requireCorePermission(userId, "credentials.view");
const { createCurrentCredentialRepository } =
await import("../database/repositories/factory.js");
const rows =
await createCurrentCredentialRepository().listDecryptedByUserId(
userId,
);
const list = rows.map((row) => ({
id: row.id,
name: row.name,
username: row.username ?? null,
authType: String(row.authType ?? ""),
folder: row.folder ?? null,
}));
await audit("credentials_list", `${list.length} credentials`, {
success: true,
});
return list;
} catch (error) {
await audit("credentials_list", "saved credentials", {
success: false,
errorMessage: errorText(error),
});
throw error;
}
},
createSshKey: async (input) => { createSshKey: async (input) => {
const details = `SSH key "${input?.name ?? ""}"`; const details = `SSH key "${input?.name ?? ""}"`;
try { try {
+280 -49
View File
@@ -20,6 +20,7 @@ import type {
PluginShareableUser, PluginShareableUser,
PluginShareableRole, PluginShareableRole,
PluginHostJumpHost, PluginHostJumpHost,
PluginHostShareGrant,
} from "@termix-ssh/plugin-sdk/backend"; } from "@termix-ssh/plugin-sdk/backend";
import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend"; import { PLUGIN_HOST_INPUT_KEYS } from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest"; import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
@@ -35,6 +36,11 @@ import { hostTerminalExport } from "../database/routes/host-normalizers.js";
import type { DisposableBag } from "./disposables.js"; import type { DisposableBag } from "./disposables.js";
import { pluginEvents, TOPICS } from "./events.js"; import { pluginEvents, TOPICS } from "./events.js";
import { pluginLogger } from "../utils/logger.js"; import { pluginLogger } from "../utils/logger.js";
import {
assertHostsUnlocked,
assertManualCreateAllowed,
provideHostLocks,
} from "../hosts/locks.js";
type AuditFn = ( type AuditFn = (
action: string, action: string,
@@ -207,6 +213,8 @@ void inputKeysComplete;
export function toHostWrite(input: PluginHostUpdateInput): { export function toHostWrite(input: PluginHostUpdateInput): {
row: Record<string, unknown>; row: Record<string, unknown>;
settings: Record<string, Record<string, unknown>>; settings: Record<string, Record<string, unknown>>;
protocolAuth: Record<string, unknown> | null;
pluginEnable: Record<string, boolean>;
} { } {
const unknown = Object.keys(input ?? {}).filter( const unknown = Object.keys(input ?? {}).filter(
(key) => !INPUT_KEYS.has(key), (key) => !INPUT_KEYS.has(key),
@@ -268,6 +276,32 @@ export function toHostWrite(input: PluginHostUpdateInput): {
? null ? null
: JSON.stringify(parseSshOptions(input.sshOptions)), : JSON.stringify(parseSshOptions(input.sshOptions)),
); );
for (const key of [
"password",
"key",
"keyPassword",
"keyType",
"sudoPassword",
] as const) {
const value = input[key];
if (value !== undefined && value !== null && typeof value !== "string") {
throw new PluginHostInputError(`${key} must be a string`);
}
set(key, value === undefined ? undefined : value || null);
}
let protocolAuth: Record<string, unknown> | null = null;
if (input.protocolAuth !== undefined && input.protocolAuth !== null) {
if (
typeof input.protocolAuth !== "object" ||
Array.isArray(input.protocolAuth)
) {
throw new PluginHostInputError(
"protocolAuth must map protocol ids to their logins",
);
}
protocolAuth = input.protocolAuth as Record<string, unknown>;
}
const settings: Record<string, Record<string, unknown>> = {}; const settings: Record<string, Record<string, unknown>> = {};
if (input.pluginSettings !== undefined && input.pluginSettings !== null) { if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
@@ -288,7 +322,136 @@ export function toHostWrite(input: PluginHostUpdateInput): {
settings[pluginId] = values; settings[pluginId] = values;
} }
} }
return { row, settings }; const pluginEnable: Record<string, boolean> = {};
if (input.pluginEnable !== undefined && input.pluginEnable !== null) {
if (
typeof input.pluginEnable !== "object" ||
Array.isArray(input.pluginEnable)
) {
throw new PluginHostInputError(
"pluginEnable must map plugin ids to true or false",
);
}
for (const [pluginId, enabled] of Object.entries(input.pluginEnable)) {
if (typeof enabled !== "boolean") {
throw new PluginHostInputError(
`pluginEnable.${pluginId} must be true or false`,
);
}
pluginEnable[pluginId] = enabled;
}
}
return { row, settings, protocolAuth, pluginEnable };
}
/** Folds pluginEnable into each plugin's settings under its enableKey. */
async function foldPluginEnable(
settings: Record<string, Record<string, unknown>>,
pluginEnable: Record<string, boolean>,
): Promise<Record<string, Record<string, unknown>>> {
const ids = Object.keys(pluginEnable);
if (ids.length === 0) return settings;
const { hostSettingsPlugins } =
await import("../database/routes/host-plugin-settings.js");
const running = hostSettingsPlugins();
const out = { ...settings };
for (const pluginId of ids) {
const manifest = running.find((m) => m.id === pluginId);
if (!manifest) {
// checkHostPluginSettingsInput skips and logs a plugin that is not running.
out[pluginId] = { ...out[pluginId] };
continue;
}
const enableKey = manifest.contributes?.settings?.host?.enableKey;
if (!enableKey) {
throw new PluginHostInputError(`${pluginId} has no host switch`);
}
out[pluginId] = { ...out[pluginId], [enableKey]: pluginEnable[pluginId] };
}
return out;
}
/** Writes a host's protocol logins; a credential the owner can't use is refused. */
async function writeHostProtocolAuth(
ownerId: string,
hostId: number,
protocolAuth: Record<string, unknown> | null,
): Promise<void> {
if (!protocolAuth) return;
const { readProtocolAuthPayload, writeProtocolAuth } =
await import("../hosts/protocol-auth/protocol-auth.js");
const patch = readProtocolAuthPayload({ protocolAuth });
if (!patch) return;
const { findUsableCredential } =
await import("../hosts/usable-credential.js");
for (const [protocol, login] of patch) {
const credentialId = Number(login?.credentialId);
if (
Number.isInteger(credentialId) &&
credentialId > 0 &&
!(await findUsableCredential(credentialId, ownerId))
) {
throw new PluginHostInputError(
`protocolAuth.${protocol}: credential ${credentialId} not found`,
);
}
}
await writeProtocolAuth(ownerId, hostId, patch, { isOwner: true });
}
const SHARE_LEVELS: readonly string[] = ["connect", "view", "edit", "manage"];
/** Upserts one grant and snapshots the host's secrets for it. */
async function grantHostShare(
hostId: number,
ownerId: string,
grantedBy: string,
target: PluginShareTarget,
permissionLevel: PluginHostShareLevel,
expiresAt: string | null,
): Promise<number> {
const { createCurrentRbacAccessRepository } =
await import("../database/repositories/factory.js");
const grant = await createCurrentRbacAccessRepository().upsertHostAccess({
hostId,
grantedBy,
permissionLevel,
expiresAt,
...(target.type === "user"
? { targetType: "user" as const, targetUserId: target.id as string }
: { targetType: "role" as const, targetRoleId: target.id as number }),
});
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
grant.id,
hostId,
target.id as string,
ownerId,
);
} else {
await secretsManager.snapshotForRole(
grant.id,
hostId,
target.id as number,
ownerId,
);
}
} catch {
// A snapshot failure never blocks the grant: the recipient can
// still be prompted, or the owner can retry sharing later.
}
return grant.id;
}
function hoursFromNow(durationHours: number | undefined): string | null {
return durationHours && durationHours > 0
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
: null;
} }
function actingUser(): string { function actingUser(): string {
@@ -476,6 +639,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => { create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => {
try { try {
await requireWrite(); await requireWrite();
assertManualCreateAllowed(pluginId);
} catch (error) { } catch (error) {
await audit("hosts_create", host?.name ?? host?.ip ?? "host", { await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
success: false, success: false,
@@ -484,8 +648,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
throw error; throw error;
} }
const userId = actingUser(); const userId = actingUser();
const { row, settings } = toHostWrite(host); const { row, settings, protocolAuth, pluginEnable } = toHostWrite(host);
const writes = await checkSettings(settings); const writes = await checkSettings(
await foldPluginEnable(settings, pluginEnable),
);
const { createCurrentHostRepository } = const { createCurrentHostRepository } =
await import("../database/repositories/factory.js"); await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } = const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
@@ -504,6 +670,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
userId, userId,
columns, columns,
); );
await writeHostProtocolAuth(userId, created.id, protocolAuth);
await applyDefaultsAfterHostWrite(created.id); await applyDefaultsAfterHostWrite(created.id);
await writeSettings(created.id, writes); await writeSettings(created.id, writes);
await markSettingsOwn(created.id, writes); await markSettingsOwn(created.id, writes);
@@ -520,6 +687,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
): Promise<PluginHostRecord | null> => { ): Promise<PluginHostRecord | null> => {
try { try {
await requireWrite(); await requireWrite();
assertHostsUnlocked([hostId], pluginId);
} catch (error) { } catch (error) {
await audit("hosts_update", `host ${hostId}`, { await audit("hosts_update", `host ${hostId}`, {
success: false, success: false,
@@ -528,8 +696,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
throw error; throw error;
} }
const userId = actingUser(); const userId = actingUser();
const { row, settings } = toHostWrite(patch); const { row, settings, protocolAuth, pluginEnable } = toHostWrite(patch);
const writes = await checkSettings(settings); const writes = await checkSettings(
await foldPluginEnable(settings, pluginEnable),
);
const { createCurrentHostRepository } = const { createCurrentHostRepository } =
await import("../database/repositories/factory.js"); await import("../database/repositories/factory.js");
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } = const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
@@ -560,6 +730,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
columns, columns,
); );
if (updated) { if (updated) {
await writeHostProtocolAuth(userId, hostId, protocolAuth);
await writeSettings(hostId, writes); await writeSettings(hostId, writes);
await markSettingsOwn(hostId, writes); await markSettingsOwn(hostId, writes);
await applyDefaultsAfterHostWrite(hostId, { await applyDefaultsAfterHostWrite(hostId, {
@@ -584,6 +755,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
delete: async (hostId: number): Promise<boolean> => { delete: async (hostId: number): Promise<boolean> => {
try { try {
await requireWrite(); await requireWrite();
assertHostsUnlocked([hostId], pluginId);
const userId = actingUser(); const userId = actingUser();
const { PermissionManager } = const { PermissionManager } =
await import("../utils/permission-manager.js"); await import("../utils/permission-manager.js");
@@ -631,6 +803,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
): Promise<PluginHostShareResult> => { ): Promise<PluginHostShareResult> => {
try { try {
await requireWrite(); await requireWrite();
assertHostsUnlocked([hostId], pluginId);
} catch (error) { } catch (error) {
await audit("hosts_share", `host ${hostId}`, { await audit("hosts_share", `host ${hostId}`, {
success: false, success: false,
@@ -660,10 +833,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
return result; return result;
} }
const { const { createCurrentHostResolutionRepository } =
createCurrentHostResolutionRepository, await import("../database/repositories/factory.js");
createCurrentRbacAccessRepository,
} = await import("../database/repositories/factory.js");
const ownerId = const ownerId =
(await createCurrentHostResolutionRepository().findHostOwnerId( (await createCurrentHostResolutionRepository().findHostOwnerId(
hostId, hostId,
@@ -677,56 +848,95 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
return { hostId, shared: false, reason: "owner" }; return { hostId, shared: false, reason: "owner" };
} }
const expiresAt = const expiresAt = hoursFromNow(durationHours);
durationHours && durationHours > 0
? new Date(Date.now() + durationHours * 60 * 60 * 1000).toISOString()
: null;
const rbacAccessRepository = createCurrentRbacAccessRepository();
const { SharedHostSecretsManager } =
await import("../utils/shared-host-secrets-manager.js");
const secretsManager = SharedHostSecretsManager.getInstance();
for (const target of targets) { for (const target of targets) {
const grant = await rbacAccessRepository.upsertHostAccess({ await grantHostShare(
hostId, hostId,
grantedBy: userId, ownerId,
userId,
target,
permissionLevel, permissionLevel,
expiresAt, expiresAt,
...(target.type === "user" );
? { targetType: "user" as const, targetUserId: target.id as string }
: {
targetType: "role" as const,
targetRoleId: target.id as number,
}),
});
try {
if (target.type === "user") {
await secretsManager.snapshotForUser(
grant.id,
hostId,
target.id as string,
ownerId,
);
} else {
await secretsManager.snapshotForRole(
grant.id,
hostId,
target.id as number,
ownerId,
);
}
} catch {
// A snapshot failure never blocks the grant: the recipient can
// still be prompted, or the owner can retry sharing later.
}
} }
await audit("hosts_share", `host ${hostId}`, { success: true }); await audit("hosts_share", `host ${hostId}`, { success: true });
return { hostId, shared: true }; return { hostId, shared: true };
}, },
setShares: async (
hostId: number,
grants: PluginHostShareGrant[],
): Promise<PluginHostShareResult> => {
const fail = async (reason: string): Promise<PluginHostShareResult> => {
await audit("hosts_set_shares", `host ${hostId}`, {
success: false,
errorMessage: reason,
});
return { hostId, shared: false, reason };
};
try {
await requireWrite();
assertHostsUnlocked([hostId], pluginId);
} catch (error) {
await fail(error instanceof Error ? error.message : String(error));
throw error;
}
if (!Array.isArray(grants)) {
throw new PluginHostInputError("grants must be a list");
}
for (const grant of grants) {
const type = grant?.target?.type;
if (
(type !== "user" && type !== "role") ||
!SHARE_LEVELS.includes(grant.permissionLevel)
) {
throw new PluginHostInputError(
"Each grant needs a user or role target and a share level",
);
}
}
const userId = actingUser();
const {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
} = await import("../database/repositories/factory.js");
const ownerId =
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
if (ownerId !== userId) return fail("forbidden");
if (
grants.some((g) => g.target.type === "user" && g.target.id === userId)
) {
return fail("owner");
}
const kept = new Set<number>();
for (const grant of grants) {
kept.add(
await grantHostShare(
hostId,
ownerId,
userId,
grant.target,
grant.permissionLevel,
hoursFromNow(grant.durationHours),
),
);
}
const repository = createCurrentRbacAccessRepository();
for (const existing of await repository.listHostAccess(hostId)) {
if (!kept.has(existing.id)) {
await repository.revokeHostAccess(existing.id, hostId);
}
}
const { pruneHostGrants } = await import("../database/routes/rbac.js");
await pruneHostGrants(hostId);
await audit("hosts_set_shares", `host ${hostId}`, { success: true });
return { hostId, shared: true };
},
listUsers: async (): Promise<PluginShareableUser[]> => { listUsers: async (): Promise<PluginShareableUser[]> => {
try { try {
await requireWrite(); await requireWrite();
@@ -810,6 +1020,27 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
return hostSessionStatus.hasActiveSession(hostId); return hostSessionStatus.hasActiveSession(hostId);
}, },
locks: {
provide: (provider) => {
if (!declared.includes("hosts:lock")) {
throw capabilityRefused(pluginId, "hosts:lock");
}
const remove = provideHostLocks(
pluginId,
manifest.name || pluginId,
provider,
);
void audit("host_locks_provide", `${pluginId} manages hosts`, {
success: true,
});
const drop = bag?.add(remove, "host lock provider");
return () => {
drop?.();
remove();
};
},
},
activity: { activity: {
provide: (provider) => { provide: (provider) => {
if (!declared.includes("hosts:activity-provide")) { if (!declared.includes("hosts:activity-provide")) {
+27
View File
@@ -38,9 +38,11 @@ import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interacti
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js"; import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { import {
getLoginMethod, getLoginMethod,
listSecondFactors,
registerLoginMethod, registerLoginMethod,
registerSecondFactor, registerSecondFactor,
} from "../auth/registry.js"; } from "../auth/registry.js";
import { provideAuthPolicy } from "../auth/policy.js";
import type { VerifiedIdentity } from "../auth/types.js"; import type { VerifiedIdentity } from "../auth/types.js";
import type { import type {
MutableConnectConfig, MutableConnectConfig,
@@ -856,6 +858,8 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
getActor() === userId ? getActorSessionId() : undefined, getActor() === userId ? getActorSessionId() : undefined,
); );
await createCurrentTrustedDeviceRepository().deleteByUserId(userId); await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
// The session that set it up may have been a setup only one.
await createCurrentSessionRepository().clearRestrictionForUser(userId);
await audit("auth_factor_enrolled", `${factorId} for ${userId}`, { await audit("auth_factor_enrolled", `${factorId} for ${userId}`, {
success: true, success: true,
}); });
@@ -934,5 +938,28 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
}, },
issueUserToken: (userId, options) => issueUserToken: (userId, options) =>
issueUserToken(manifest, userId, options), issueUserToken(manifest, userId, options),
policy: {
provide: (provider) => {
if (!declared.includes("auth:policy")) {
throw capabilityRefused(pluginId, "auth:policy");
}
const remove = provideAuthPolicy(pluginId, provider);
void audit("auth_policy_provide", `${pluginId} sets login rules`, {
success: true,
});
const drop = bag.add(remove, "login policy");
return () => {
drop();
remove();
};
},
},
listSecondFactors: () =>
listSecondFactors().map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
})),
}; };
} }
+29
View File
@@ -11,6 +11,12 @@ import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
import { getEntity } from "../../plugins/sync-registry.js"; import { getEntity } from "../../plugins/sync-registry.js";
import { PermissionManager } from "../../utils/permission-manager.js"; import { PermissionManager } from "../../utils/permission-manager.js";
import { syncLogger } from "../../utils/logger.js"; import { syncLogger } from "../../utils/logger.js";
import {
hasHostLocks,
hostLock,
manualCreateBlockedBy,
} from "../../hosts/locks.js";
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
import { import {
createResolvers, createResolvers,
deleteStoredRow, deleteStoredRow,
@@ -48,6 +54,22 @@ export type PushResult =
export const MAX_PUSH_OPS = 500; export const MAX_PUSH_OPS = 500;
/** A host a plugin manages can't be changed from a desktop either. */
async function hostPushLocked(
userId: string,
syncId: string,
exists: boolean,
): Promise<boolean> {
if (!hasHostLocks()) return false;
if (!exists) return manualCreateBlockedBy() !== null;
const hostId =
await createCurrentHostResolutionRepository().findHostIdBySyncIdForUser(
syncId,
userId,
);
return hostId !== null && hostId !== undefined && hostLock(hostId) !== null;
}
function isPushOp(value: unknown): value is PushOp { function isPushOp(value: unknown): value is PushOp {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const op = value as Record<string, unknown>; const op = value as Record<string, unknown>;
@@ -166,6 +188,13 @@ export async function applyPush(
results.set(key, { ...base, status: "rejected", reason: "permission" }); results.set(key, { ...base, status: "rejected", reason: "permission" });
continue; continue;
} }
if (
op.entityType === "hosts" &&
(await hostPushLocked(userId, op.syncId, exists))
) {
results.set(key, { ...base, status: "rejected", reason: "locked" });
continue;
}
try { try {
if (op.op === "delete") { if (op.op === "delete") {
@@ -97,6 +97,8 @@ const { getPasswordLoginStatus } = await import("../../auth/core-auth.js");
const { registerLoginMethod, registerSecondFactor } = const { registerLoginMethod, registerSecondFactor } =
await import("../../auth/registry.js"); await import("../../auth/registry.js");
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js"); const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
const { provideAuthPolicy, clearAuthPolicies, SECOND_FACTOR_SETUP } =
await import("../../auth/policy.js");
const PASSWORD = "correct-horse"; const PASSWORD = "correct-horse";
// Stands in for the totp plugin's factor: core only sees the registration. // Stands in for the totp plugin's factor: core only sees the registration.
@@ -137,6 +139,7 @@ beforeEach(() => {
delete process.env.ALLOW_PASSWORD_LOGIN; delete process.env.ALLOW_PASSWORD_LOGIN;
disposeTotp?.(); disposeTotp?.();
registerTotpFixture(); registerTotpFixture();
clearAuthPolicies();
}); });
async function passwordLogin(body: Record<string, unknown>, headers = {}) { async function passwordLogin(body: Record<string, unknown>, headers = {}) {
@@ -891,3 +894,219 @@ describe("lockout guard", () => {
}); });
}); });
}); });
describe("login policy", () => {
function enrolTotp() {
h.state.factors.push({ userId: "u1", pluginId: "totp", factorId: "totp" });
}
function trustedLogin(token: string) {
const req = fakeRequest({
body: { username: "alice", password: PASSWORD },
cookies: { [TRUST_DEVICE_COOKIE]: token },
});
return runLogin(
req as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: true },
);
}
it("hands the provider the user's factors and the device", async () => {
addUser();
enrolTotp();
const evaluate = vi.fn(() => ({}));
provideAuthPolicy("policy", { evaluate });
await passwordLogin({ username: "alice", password: PASSWORD });
expect(evaluate).toHaveBeenCalledWith({
userId: "u1",
methodId: "password",
external: false,
deviceType: "web",
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [{ id: "totp", pluginId: "totp", enrolled: true }],
});
});
it("turns remember me off and caps the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 600 }),
});
const res = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 600 }),
);
expect((res.cookies[0].options as { maxAge: number }).maxAge).toBe(
600 * 1000,
);
});
it("stores the idle timeout on the session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ idleTimeoutSeconds: 900 }),
});
provideAuthPolicy("other", {
evaluate: () => ({ idleTimeoutSeconds: 300 }),
});
await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ idleTimeoutSeconds: 300 }),
);
});
it("ignores a remembered device when remember me is off", async () => {
addUser();
enrolTotp();
const token = "c".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false }),
});
const result = await trustedLogin(token);
expect(result.kind).toBe("second-factor");
});
it("asks for the factor on a remembered device with always, not with new-device", async () => {
addUser();
enrolTotp();
const token = "d".repeat(64);
h.state.trusted.add(`u1:${hashTrustToken(token)}`);
const remove = provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
expect((await trustedLogin(token)).kind).toBe("second-factor");
remove();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "new-device" }),
});
expect((await trustedLogin(token)).kind).toBe("session");
});
it("gives a user without a factor a setup only session", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const res = await passwordLogin({ username: "alice", password: PASSWORD });
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: SECOND_FACTOR_SETUP }),
);
expect(res.body).toMatchObject({ restriction: SECOND_FACTOR_SETUP });
});
it("lets a verified passkey satisfy a required factor", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => ({ secondFactor: "always" }),
});
const result = await runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1", mfaSatisfied: true },
{ methodId: "passkey", rememberMe: false },
);
expect(result.kind).toBe("session");
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ restriction: null }),
);
});
it("runs factors for SSO logins when the policy requires one", async () => {
const dispose = registerLoginMethod({
id: "oidc",
pluginId: "sso",
labelKey: "loginWithSso",
kind: "redirect",
external: true,
});
try {
addUser({ passwordHash: "" });
enrolTotp();
h.state.identities.push({
id: 1,
userId: "u1",
providerId: "3",
subject: "sub-1",
email: null,
});
provideAuthPolicy("policy", {
evaluate: (input) => (input.external ? { secondFactor: "always" } : {}),
});
const result = await runLogin(
fakeRequest() as never,
{
kind: "external",
provider: "3",
subject: "sub-1",
email: null,
name: "Alice",
} as never,
{ methodId: "oidc", rememberMe: false },
);
expect(result.kind).toBe("second-factor");
} finally {
dispose();
}
});
it("keeps the policy for the factor step: no trusted device, same cap", async () => {
addUser();
enrolTotp();
provideAuthPolicy("policy", {
evaluate: () => ({ allowRememberDevice: false, maxSessionSeconds: 1200 }),
});
const first = await passwordLogin({
username: "alice",
password: PASSWORD,
rememberMe: true,
});
const res = fakeResponse();
await verifySecondFactorAndRespond(
fakeRequest({
body: {
temp_token: (first.body as { temp_token: string }).temp_token,
totp_code: TOTP_CODE,
rememberMe: true,
},
}) as never,
res as never,
"totp",
);
expect(res.statusCode).toBe(200);
expect(res.cookies.map((c) => c.name)).toEqual(["jwt"]);
expect(h.state.trustedAdded).toEqual([]);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u1",
expect.objectContaining({ rememberMe: false, maxAgeSeconds: 1200 }),
);
});
it("refuses the login when a provider throws", async () => {
addUser();
provideAuthPolicy("policy", {
evaluate: () => {
throw new Error("boom");
},
});
await expect(
runLogin(
fakeRequest() as never,
{ kind: "user", userId: "u1" },
{ methodId: "password", rememberMe: false },
),
).rejects.toMatchObject({ status: 503, code: "auth_policy_unavailable" });
expect(h.state.audits).toEqual([]);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
authLogger: { warn: vi.fn() },
}));
const {
clearAuthPolicies,
evaluateAuthPolicy,
hasAuthPolicies,
provideAuthPolicy,
rememberDeviceAllowed,
} = await import("../../auth/policy.js");
const input = {
userId: "u1",
methodId: "password",
external: false,
deviceType: "web" as const,
trustedDevice: false,
mfaSatisfied: false,
secondFactors: [],
};
beforeEach(() => clearAuthPolicies());
describe("auth policy", () => {
it("changes nothing without a provider", async () => {
expect(hasAuthPolicies()).toBe(false);
expect(await evaluateAuthPolicy(input)).toEqual({
allowRememberDevice: true,
maxSessionSeconds: null,
idleTimeoutSeconds: null,
secondFactor: null,
});
expect(rememberDeviceAllowed()).toBe(true);
});
it("takes the strictest answer from every provider", async () => {
provideAuthPolicy("a", {
evaluate: () => ({
maxSessionSeconds: 3600,
idleTimeoutSeconds: 900,
secondFactor: "new-device",
}),
});
provideAuthPolicy("b", {
evaluate: async () => ({
allowRememberDevice: false,
maxSessionSeconds: 7200,
idleTimeoutSeconds: 300,
secondFactor: "always",
}),
allowRememberDevice: () => false,
});
expect(await evaluateAuthPolicy(input)).toEqual({
allowRememberDevice: false,
maxSessionSeconds: 3600,
idleTimeoutSeconds: 300,
secondFactor: "always",
});
expect(rememberDeviceAllowed()).toBe(false);
});
it("ignores zero and negative limits", async () => {
provideAuthPolicy("a", {
evaluate: () => ({ maxSessionSeconds: 0, idleTimeoutSeconds: -5 }),
});
expect(await evaluateAuthPolicy(input)).toMatchObject({
maxSessionSeconds: null,
idleTimeoutSeconds: null,
});
});
it("refuses the login when a provider throws, until it is removed", async () => {
const remove = provideAuthPolicy("a", {
evaluate: () => {
throw new Error("boom");
},
});
await expect(evaluateAuthPolicy(input)).rejects.toMatchObject({
status: 503,
code: "auth_policy_unavailable",
});
remove();
await expect(evaluateAuthPolicy(input)).resolves.toMatchObject({
secondFactor: null,
});
});
it("hides remember me when the hint throws", () => {
provideAuthPolicy("a", {
evaluate: () => ({}),
allowRememberDevice: () => {
throw new Error("boom");
},
});
expect(rememberDeviceAllowed()).toBe(false);
});
});
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import express, { type RequestHandler } from "express";
import request from "supertest";
const state = vi.hoisted(() => ({
updates: [] as Array<{ ids: number[]; update: Record<string, unknown> }>,
renamed: 0,
folderHosts: [] as Array<{ id: number }>,
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
sshLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
pluginLogger: { warn: vi.fn() },
}));
vi.mock("../../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: { batched: (fn: RequestHandler) => fn },
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
}));
vi.mock("../../../hosts/defaults/recompute.js", () => ({
recompute: async () => ({ changedHostIds: [] }),
}));
vi.mock("../../../hosts/defaults/service.js", () => ({
folderCredentialIds: async () => [],
setFolderCredentialDefault: async () => {},
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
setHostPluginEnabled: async () => true,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostRepository: () => ({
listBulkUpdateState: async (_userId: string, ids: number[]) =>
ids.map((id) => ({ id })),
updateManyForUser: async (
_userId: string,
ids: number[],
update: Record<string, unknown>,
) => {
state.updates.push({ ids, update });
},
}),
createCurrentHostFolderRepository: () => ({
listHostsInFolder: async () => state.folderHosts,
renameFolder: async () => {
state.renamed++;
return { updatedHosts: 1, updatedCredentials: 0 };
},
}),
createCurrentFolderAccessRepository: () => ({
renameFolder: async () => {},
}),
}));
const { registerHostBulkRoutes } =
await import("../../../database/routes/host-bulk-routes.js");
const { registerHostFolderRoutes } =
await import("../../../database/routes/host-folder-routes.js");
const { clearHostLocks, provideHostLocks } =
await import("../../../hosts/locks.js");
const pass: RequestHandler = (req, _res, next) => {
(req as unknown as { userId: string }).userId = "owner";
next();
};
function app() {
const router = express.Router();
registerHostBulkRoutes(router, pass, pass, pass);
registerHostFolderRoutes(router, {
authenticateJWT: pass,
requireViewPermission: pass,
requireEditPermission: pass,
requireDeletePermission: pass,
requireCredentialEditPermission: pass,
requireDataAccess: pass,
});
const server = express();
server.use(express.json());
server.use(router);
return server;
}
beforeEach(() => {
state.updates = [];
state.renamed = 0;
state.folderHosts = [];
provideHostLocks("cfg", "Config", {
isLocked: (hostId) => hostId === 2,
note: () => null,
allowManualCreate: () => true,
});
});
afterEach(() => clearHostLocks());
describe("routes on locked hosts", () => {
it("still lets a user pin a locked host", async () => {
await request(app())
.patch("/bulk-update")
.send({ hostIds: [1, 2], updates: { pin: true } })
.expect(200);
expect(state.updates).toEqual([{ ids: [1, 2], update: { pin: true } }]);
});
it("refuses a bulk change that touches a locked host", async () => {
const res = await request(app())
.patch("/bulk-update")
.send({ hostIds: [1, 2], updates: { folder: "Moved" } })
.expect(403);
expect(res.body).toMatchObject({ code: "HOST_LOCKED" });
expect(state.updates).toEqual([]);
});
it("refuses renaming a folder that holds a locked host", async () => {
state.folderHosts = [{ id: 1 }, { id: 2 }];
await request(app())
.put("/folders/rename")
.send({ oldName: "Prod", newName: "Live" })
.expect(403);
expect(state.renamed).toBe(0);
state.folderHosts = [{ id: 1 }];
await request(app())
.put("/folders/rename")
.send({ oldName: "Prod", newName: "Live" })
.expect(200);
expect(state.renamed).toBe(1);
});
});
@@ -26,6 +26,8 @@ vi.mock("../../../utils/audit-logger.js", () => ({
vi.mock("../../../database/repositories/factory.js", () => ({ vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null, get: async (key: string) => state.settings[key] ?? null,
getBoolean: async (key: string, fallback: boolean) =>
key in state.settings ? state.settings[key] === "true" : fallback,
set: async (key: string, value: string) => { set: async (key: string, value: string) => {
state.settings[key] = value; state.settings[key] = value;
}, },
@@ -39,6 +41,12 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}), }),
})); }));
vi.mock("../../../tls/tls-service.js", () => ({
getTlsConfig: () => ({ enabled: true, port: 8443 }),
}));
const { resetHttpsPolicyForTests } =
await import("../../../tls/https-policy.js");
const { registerUserSettingsRoutes } = const { registerUserSettingsRoutes } =
await import("../../../database/routes/user-settings-routes.js"); await import("../../../database/routes/user-settings-routes.js");
@@ -59,6 +67,7 @@ function app() {
beforeEach(() => { beforeEach(() => {
state.settings = {}; state.settings = {};
state.admin = true; state.admin = true;
resetHttpsPolicyForTests();
}); });
describe("/users/host-defaults", () => { describe("/users/host-defaults", () => {
@@ -77,3 +86,35 @@ describe("/users/host-defaults", () => {
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true); expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
}); });
}); });
describe("/users/https-settings", () => {
it("is off by default and saves a change", async () => {
const read = await request(app()).get("/users/https-settings");
expect(read.body).toEqual({ forceHttps: false, tlsEnabled: true });
const write = await request(app())
.patch("/users/https-settings")
.send({ forceHttps: true });
expect(write.status).toBe(200);
expect(state.settings.force_https).toBe("true");
const after = await request(app()).get("/users/https-settings");
expect(after.body.forceHttps).toBe(true);
});
it("is admin only and checks the value", async () => {
const bad = await request(app())
.patch("/users/https-settings")
.send({ forceHttps: "yes" });
expect(bad.status).toBe(400);
state.admin = false;
expect((await request(app()).get("/users/https-settings")).status).toBe(
403,
);
const write = await request(app())
.patch("/users/https-settings")
.send({ forceHttps: true });
expect(write.status).toBe(403);
expect(state.settings.force_https).toBeUndefined();
});
});
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
pluginLogger: { warn: vi.fn() },
}));
const {
assertHostsUnlocked,
assertManualCreateAllowed,
clearHostLocks,
hasHostLocks,
hostLock,
HostLockedError,
manualCreateBlockedBy,
provideHostLocks,
requireManualCreate,
requireUnlockedHost,
} = await import("../../hosts/locks.js");
const provider = (locked: number[], allowManualCreate = true) => ({
isLocked: (hostId: number) => locked.includes(hostId),
note: (hostId: number) => `id ${hostId}`,
allowManualCreate: () => allowManualCreate,
});
function fakeRes() {
const res = {
code: 0,
body: null as unknown,
status(code: number) {
res.code = code;
return { json: (body: unknown) => (res.body = body) };
},
};
return res;
}
afterEach(() => clearHostLocks());
describe("host locks", () => {
it("reports nothing without providers", () => {
expect(hasHostLocks()).toBe(false);
expect(hostLock(1)).toBeNull();
expect(() => assertHostsUnlocked([1, 2])).not.toThrow();
expect(manualCreateBlockedBy()).toBeNull();
});
it("names the plugin and note for a locked host", () => {
provideHostLocks("cfg", "Config", provider([3]));
expect(hostLock(3)).toEqual({
pluginId: "cfg",
pluginName: "Config",
note: "id 3",
});
expect(hostLock(4)).toBeNull();
expect(() => assertHostsUnlocked([4, 3])).toThrow(HostLockedError);
});
it("lets the locking plugin through", () => {
provideHostLocks("cfg", "Config", provider([3], false));
expect(() => assertHostsUnlocked([3], "cfg")).not.toThrow();
expect(() => assertManualCreateAllowed("cfg")).not.toThrow();
expect(() => assertManualCreateAllowed()).toThrow(/managed by Config/);
});
it("drops a provider when removed", () => {
const remove = provideHostLocks("cfg", "Config", provider([3], false));
remove();
expect(hostLock(3)).toBeNull();
expect(manualCreateBlockedBy()).toBeNull();
});
it("never locks when a provider throws", () => {
provideHostLocks("cfg", "Config", {
isLocked: () => {
throw new Error("broken");
},
note: () => null,
allowManualCreate: () => {
throw new Error("broken");
},
});
expect(hostLock(1)).toBeNull();
expect(manualCreateBlockedBy()).toBeNull();
});
});
describe("lock route guards", () => {
it("answers 403 HOST_LOCKED for a locked :id", () => {
provideHostLocks("cfg", "Config", provider([7]));
const res = fakeRes();
const next = vi.fn();
requireUnlockedHost({ params: { id: "7" } }, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.code).toBe(403);
expect(res.body).toMatchObject({ code: "HOST_LOCKED", lockedBy: "cfg" });
requireUnlockedHost({ params: { id: "8" } }, fakeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
});
it("blocks manual creates only while a provider says so", () => {
const next = vi.fn();
requireManualCreate({}, fakeRes(), next);
expect(next).toHaveBeenCalledTimes(1);
provideHostLocks("cfg", "Config", provider([], false));
const res = fakeRes();
requireManualCreate({}, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res.code).toBe(403);
});
});
@@ -461,6 +461,40 @@ describe("ctx.credentials.registerSecretResolver", () => {
}); });
}); });
describe("ctx.credentials.list", () => {
beforeEach(() => {
state.userCredentials.set("owner", [
{ id: 3, name: "pw", authType: "password", password: "secret" },
{ id: 4, name: "key", authType: "key", privateKey: "PRIVATE KEY" },
]);
});
it("refuses without credentials:use", async () => {
grants.set("demo", []);
const ctx = contextFor([]);
await expect(
runAsActor("owner", "request", () => ctx.credentials.list()),
).rejects.toThrow(/credentials:use/);
});
it("lists the actor's credentials without secrets", async () => {
grants.set("demo", ["credentials:use"]);
const ctx = contextFor(["credentials:use"]);
const list = await runAsActor("owner", "request", () =>
ctx.credentials.list(),
);
expect(list).toEqual([
{ id: 3, name: "pw", username: null, authType: "password", folder: null },
{ id: 4, name: "key", username: null, authType: "key", folder: null },
]);
expect(JSON.stringify(list)).not.toMatch(/secret|PRIVATE/);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_credentials_list",
success: true,
});
});
});
describe("ctx.credentials.listSshKeys", () => { describe("ctx.credentials.listSshKeys", () => {
const pair = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 }); const pair = ssh2.utils.generateKeyPairSync("rsa", { bits: 2048 });
const derived = (() => { const derived = (() => {
+256
View File
@@ -27,6 +27,9 @@ const h = vi.hoisted(() => ({
statusPortsRemoved: [] as string[], statusPortsRemoved: [] as string[],
userPermissions: new Set<string>(), userPermissions: new Set<string>(),
deleted: [] as Array<{ userId: string; hostId: number }>, deleted: [] as Array<{ userId: string; hostId: number }>,
existingAccess: [] as Array<{ id: number }>,
revoked: [] as number[],
protocolWrites: [] as Array<{ hostId: number; protocols: string[] }>,
roles: [] as Array<{ roles: [] as Array<{
id: number; id: number;
name: string; name: string;
@@ -85,6 +88,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
h.upserts.push(input); h.upserts.push(input);
return { id: h.upserts.length, created: true }; return { id: h.upserts.length, created: true };
}, },
listHostAccess: async () => h.existingAccess,
revokeHostAccess: async (accessId: number) => {
h.revoked.push(accessId);
},
}), }),
createCurrentUserRepository: () => ({ createCurrentUserRepository: () => ({
listAll: async () => h.users, listAll: async () => h.users,
@@ -145,6 +152,13 @@ vi.mock("../../plugins/host-status-provider.js", () => ({
}, },
})); }));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({ vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => [
{
id: "metrics",
contributes: { settings: { host: { enableKey: "enableMetrics" } } },
},
{ id: "noswitch", contributes: { settings: { host: { fields: [] } } } },
],
loadHostPluginSettings: async (hostIds: number[]) => loadHostPluginSettings: async (hostIds: number[]) =>
new Map( new Map(
hostIds.map((id) => [ hostIds.map((id) => [
@@ -198,6 +212,25 @@ vi.mock("../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {}, changeHostOverrides: async () => {},
})); }));
vi.mock("../../database/routes/rbac.js", () => ({
pruneHostGrants: async () => 0,
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
credentialId === 5 ? { id: 5 } : null,
}));
vi.mock("../../hosts/protocol-auth/protocol-auth.js", () => ({
readProtocolAuthPayload: (data: { protocolAuth: Record<string, unknown> }) =>
new Map(Object.entries(data.protocolAuth)),
writeProtocolAuth: async (
_ownerId: string,
hostId: number,
patch: Map<string, unknown>,
) => {
h.protocolWrites.push({ hostId, protocols: [...patch.keys()] });
},
}));
vi.mock("../../hosts/delete-host.js", () => ({ vi.mock("../../hosts/delete-host.js", () => ({
deleteOwnedHost: async (userId: string, hostId: number) => { deleteOwnedHost: async (userId: string, hostId: number) => {
h.deleted.push({ userId, hostId }); h.deleted.push({ userId, hostId });
@@ -209,6 +242,8 @@ const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
const { PluginCapabilityError } = const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend"); await import("@termix-ssh/plugin-sdk/backend");
const { pluginEvents, TOPICS } = await import("../../plugins/events.js"); const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
const { clearHostLocks, hostLock, provideHostLocks } =
await import("../../hosts/locks.js");
function manifest(capabilities: string[]) { function manifest(capabilities: string[]) {
return { return {
@@ -236,6 +271,10 @@ beforeEach(() => {
h.roles = []; h.roles = [];
h.userPermissions = new Set(); h.userPermissions = new Set();
h.deleted = []; h.deleted = [];
h.existingAccess = [];
h.revoked = [];
h.protocolWrites = [];
clearHostLocks();
}); });
describe("ctx.hosts", () => { describe("ctx.hosts", () => {
@@ -705,3 +744,220 @@ describe("ctx.hosts.status", () => {
expect(h.statusPortsRemoved).toEqual(["rdp"]); expect(h.statusPortsRemoved).toEqual(["rdp"]);
}); });
}); });
describe("ctx.hosts secrets and protocol logins", () => {
it("stores secrets on create and writes protocol logins", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const created = await hosts.create({
name: "rdp",
ip: "10.0.0.9",
port: 3389,
username: "",
authType: "password",
password: "s3cret",
sudoPassword: "",
protocolAuth: { rdp: { authType: "direct", username: "admin" } },
});
expect(h.created[0]).toMatchObject({
password: "s3cret",
sudoPassword: null,
});
expect(created).not.toHaveProperty("password");
expect(h.protocolWrites).toEqual([{ hostId: 99, protocols: ["rdp"] }]);
});
it("refuses a protocol credential the owner cannot use", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.create({
name: "vnc",
ip: "10.0.0.9",
port: 5900,
username: "",
authType: "password",
protocolAuth: { vnc: { authType: "credential", credentialId: 6 } },
}),
).rejects.toThrow(/credential 6/);
});
it("refuses a secret that is not a string", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(hosts.update(5, { password: 12 as never })).rejects.toThrow(
/password/,
);
});
});
describe("ctx.hosts pluginEnable", () => {
it("writes each plugin's switch under its enableKey", async () => {
h.granted = new Set(["hosts:write"]);
h.importRows = [];
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await hosts.create({
name: "n",
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
pluginEnable: { metrics: false },
});
expect(h.importRows).toEqual([
{ hostId: 99, row: { metrics: { enableMetrics: false } } },
]);
});
it("refuses a plugin with no switch", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.update(5, { pluginEnable: { noswitch: true } }),
).rejects.toThrow(/no host switch/);
expect(h.updates).toHaveLength(0);
});
});
describe("ctx.hosts.setShares", () => {
it("upserts the listed grants and revokes the rest", async () => {
h.granted = new Set(["hosts:write"]);
h.existingAccess = [{ id: 1 }, { id: 7 }];
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
const result = await hosts.setShares(3, [
{ target: { type: "role", id: 2 }, permissionLevel: "connect" },
]);
expect(result).toEqual({ hostId: 3, shared: true });
expect(h.upserts).toEqual([
expect.objectContaining({
hostId: 3,
targetType: "role",
targetRoleId: 2,
permissionLevel: "connect",
expiresAt: null,
}),
]);
expect(h.revoked).toEqual([7]);
});
it("only works for the host owner", async () => {
h.granted = new Set(["hosts:write"]);
h.ownerId = "someone-else";
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
expect(await hosts.setShares(3, [])).toEqual({
hostId: 3,
shared: false,
reason: "forbidden",
});
expect(h.revoked).toEqual([]);
});
it("refuses a bad level", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(
hosts.setShares(3, [
{ target: { type: "user", id: "u2" }, permissionLevel: "own" as never },
]),
).rejects.toThrow(/share level/);
});
});
describe("ctx.hosts.locks", () => {
const provider = (locked: number[], allowManualCreate = true) => ({
isLocked: (hostId: number) => locked.includes(hostId),
note: () => "From hosts.yaml",
allowManualCreate: () => allowManualCreate,
});
it("needs hosts:lock", () => {
const hosts = createPluginHosts({
manifest: manifest([]),
audit: vi.fn(async () => {}),
});
expect(() => hosts.locks.provide(provider([1]))).toThrow(
PluginCapabilityError,
);
});
it("locks hosts until the plugin removes its provider", () => {
const hosts = createPluginHosts({
manifest: manifest(["hosts:lock"]),
audit: vi.fn(async () => {}),
});
const remove = hosts.locks.provide(provider([4]));
expect(hostLock(4)).toEqual({
pluginId: "fixture",
pluginName: "Fixture",
note: "From hosts.yaml",
});
expect(hostLock(5)).toBeNull();
remove();
expect(hostLock(4)).toBeNull();
});
it("blocks other plugins on a locked host but not the plugin that locks it", async () => {
h.granted = new Set(["hosts:write"]);
h.updateResult = { id: 4, name: "x" };
h.userPermissions = new Set(["hosts.delete"]);
provideHostLocks("hosts-file", "Hosts File", provider([4], false));
const other = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(other.update(4, { name: "y" })).rejects.toThrow(
/managed by Hosts File/,
);
await expect(other.delete(4)).rejects.toThrow(/managed by Hosts File/);
await expect(
other.share(4, [{ type: "user", id: "u2" }], "view"),
).rejects.toThrow(/managed by Hosts File/);
await expect(
other.create({
name: "n",
ip: "10.0.0.1",
port: 22,
username: "root",
authType: "password",
}),
).rejects.toThrow(/new hosts can't be added/);
expect(h.updates).toHaveLength(0);
expect(h.deleted).toHaveLength(0);
const owner = createPluginHosts({
manifest: {
id: "hosts-file",
name: "Hosts File",
version: "1.0.0",
capabilities: ["hosts:write"],
} as never,
audit: vi.fn(async () => {}),
});
await owner.update(4, { name: "y" });
expect(h.updates).toHaveLength(1);
});
});
@@ -12,6 +12,7 @@ const h = vi.hoisted(() => ({
sessionId: undefined as string | undefined, sessionId: undefined as string | undefined,
revoked: [] as Array<{ userId: string; except?: string }>, revoked: [] as Array<{ userId: string; except?: string }>,
trustedCleared: [] as string[], trustedCleared: [] as string[],
unrestricted: [] as string[],
policyError: null as Error | null, policyError: null as Error | null,
logins: [] as Array<{ hostId: number; outcome: unknown }>, logins: [] as Array<{ hostId: number; outcome: unknown }>,
connectError: null as Error | null, connectError: null as Error | null,
@@ -126,6 +127,9 @@ vi.mock("../../database/repositories/factory.js", () => ({
h.revoked.push({ userId, except }); h.revoked.push({ userId, except });
return 0; return 0;
}, },
clearRestrictionForUser: async (userId: string) => {
h.unrestricted.push(userId);
},
}), }),
createCurrentTrustedDeviceRepository: () => ({ createCurrentTrustedDeviceRepository: () => ({
deleteByUserId: async (userId: string) => { deleteByUserId: async (userId: string) => {
@@ -145,6 +149,8 @@ const { getLoginMethod, getSecondFactor } =
await import("../../auth/registry.js"); await import("../../auth/registry.js");
const { PluginCapabilityError } = const { PluginCapabilityError } =
await import("@termix-ssh/plugin-sdk/backend"); await import("@termix-ssh/plugin-sdk/backend");
const { hasAuthPolicies, clearAuthPolicies } =
await import("../../auth/policy.js");
function manifest(capabilities: string[], auth: Record<string, string[]> = {}) { function manifest(capabilities: string[], auth: Record<string, string[]> = {}) {
return { return {
@@ -655,6 +661,7 @@ describe("ctx.auth", () => {
h.factors = []; h.factors = [];
h.revoked = []; h.revoked = [];
h.trustedCleared = []; h.trustedCleared = [];
h.unrestricted = [];
h.policyError = null; h.policyError = null;
h.actor = "user-1"; h.actor = "user-1";
h.sessionId = "session-a"; h.sessionId = "session-a";
@@ -666,10 +673,65 @@ describe("ctx.auth", () => {
await auth.recordEnrollment("user-1", "pin"); await auth.recordEnrollment("user-1", "pin");
expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]); expect(h.revoked).toEqual([{ userId: "user-1", except: "session-a" }]);
expect(h.trustedCleared).toEqual(["user-1"]); expect(h.trustedCleared).toEqual(["user-1"]);
expect(h.unrestricted).toEqual(["user-1"]);
h.actor = undefined; h.actor = undefined;
h.sessionId = undefined; h.sessionId = undefined;
}); });
it("needs auth:policy for a login policy and drops it on deactivate", async () => {
clearAuthPolicies();
const plain = createPluginAuth({
manifest: manifest(["auth:provide"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
expect(() => plain.policy.provide({ evaluate: () => ({}) })).toThrow(
PluginCapabilityError,
);
const bag = new DisposableBag("fixture");
const audit = vi.fn(async () => {});
const auth = createPluginAuth({
manifest: manifest(["auth:policy"]),
bag,
audit,
});
const remove = auth.policy.provide({ evaluate: () => ({}) });
expect(hasAuthPolicies()).toBe(true);
expect(audit).toHaveBeenCalledWith(
"auth_policy_provide",
"fixture sets login rules",
{ success: true },
);
remove();
expect(hasAuthPolicies()).toBe(false);
auth.policy.provide({ evaluate: () => ({}) });
await bag.disposeAll();
expect(hasAuthPolicies()).toBe(false);
});
it("lists second factors from every plugin", async () => {
h.granted = new Set(["auth:provide"]);
const bag = new DisposableBag("fixture");
const auth = createPluginAuth({
manifest: manifest(["auth:provide"], { secondFactors: ["pin"] }),
bag,
audit: vi.fn(async () => {}),
});
auth.registerSecondFactor({
id: "pin",
labelKey: "pin",
isEnrolled: async () => false,
verify: async () => true,
});
expect(auth.listSecondFactors()).toContainEqual({
id: "pin",
pluginId: "fixture",
});
await bag.disposeAll();
});
it("refuses enrolment when core policy forbids second factors", async () => { it("refuses enrolment when core policy forbids second factors", async () => {
h.granted = new Set(["auth:provide"]); h.granted = new Set(["auth:provide"]);
h.factors = []; h.factors = [];
@@ -71,6 +71,7 @@ const UNGATED: Record<string, string> = {
"rbac.hasFor": "a yes/no about a role permission, grants nothing", "rbac.hasFor": "a yes/no about a role permission, grants nothing",
"rbac.require": "route middleware for the plugin's own permissions", "rbac.require": "route middleware for the plugin's own permissions",
"capabilities.has": "a yes/no about the plugin's own grants", "capabilities.has": "a yes/no about the plugin's own grants",
"auth.listSecondFactors": "ids of the second factors, no user data",
"disposables.add": "the plugin's own cleanup", "disposables.add": "the plugin's own cleanup",
"schedule.every": "the plugin's own timers", "schedule.every": "the plugin's own timers",
"schedule.after": "the plugin's own timers", "schedule.after": "the plugin's own timers",
+108
View File
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
settings: new Map<string, string>(),
tlsEnabled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
getBoolean: async (key: string, fallback: boolean) =>
h.settings.has(key) ? h.settings.get(key) === "true" : fallback,
set: async (key: string, value: string) => {
h.settings.set(key, value);
},
}),
}));
vi.mock("../../tls/tls-service.js", () => ({
getTlsConfig: () => ({ enabled: h.tlsEnabled, port: 8443 }),
}));
const { httpsPolicyMiddleware, setForceHttps, resetHttpsPolicyForTests } =
await import("../../tls/https-policy.js");
function run(
path: string,
{ secure = false, headers = {} as Record<string, string> } = {},
) {
const all: Record<string, string> = { host: "termix.lan:8080", ...headers };
const req = {
secure,
path,
originalUrl: path,
url: path,
get: (name: string) => all[name.toLowerCase()],
};
const res = {
headers: {} as Record<string, string>,
redirected: null as null | { status: number; url: string },
setHeader(name: string, value: string) {
res.headers[name] = value;
},
redirect(status: number, url: string) {
res.redirected = { status, url };
},
};
const next = vi.fn();
return httpsPolicyMiddleware(req as never, res as never, next).then(() => ({
res,
next,
}));
}
beforeEach(() => {
h.settings.clear();
h.tlsEnabled = true;
resetHttpsPolicyForTests();
});
describe("https policy", () => {
it("is off by default", async () => {
const { res, next } = await run("/users/me");
expect(next).toHaveBeenCalled();
expect(res.redirected).toBeNull();
expect(res.headers).toEqual({});
});
it("does nothing while HTTPS is off, even when turned on", async () => {
await setForceHttps(true);
h.tlsEnabled = false;
const { res, next } = await run("/users/me");
expect(next).toHaveBeenCalled();
expect(res.redirected).toBeNull();
});
it("redirects plain HTTP to the HTTPS port", async () => {
await setForceHttps(true);
const { res, next } = await run("/users/me?x=1");
expect(next).not.toHaveBeenCalled();
expect(res.redirected).toEqual({
status: 308,
url: "https://termix.lan:8443/users/me?x=1",
});
});
it("trusts X-Forwarded-Proto and sends HSTS over HTTPS", async () => {
await setForceHttps(true);
const forwarded = await run("/users/me", {
headers: { "x-forwarded-proto": "https, http" },
});
expect(forwarded.next).toHaveBeenCalled();
expect(forwarded.res.headers["Strict-Transport-Security"]).toBe(
"max-age=31536000",
);
const direct = await run("/users/me", { secure: true });
expect(direct.next).toHaveBeenCalled();
expect(direct.res.headers["Strict-Transport-Security"]).toBeDefined();
});
it("leaves certificate checks and health probes on HTTP", async () => {
await setForceHttps(true);
for (const path of ["/.well-known/acme-challenge/abc", "/health"]) {
const { res, next } = await run(path);
expect(next).toHaveBeenCalled();
expect(res.redirected).toBeNull();
}
});
});
@@ -8,6 +8,8 @@ const encryptionKey = crypto.randomBytes(32);
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
hasUserDEK: vi.fn(() => false), hasUserDEK: vi.fn(() => false),
adoptRecoveredDEK: vi.fn(async () => {}), adoptRecoveredDEK: vi.fn(async () => {}),
revoke: vi.fn(async () => true),
created: [] as Array<Record<string, unknown>>,
})); }));
vi.mock("../../database/db/index.js", () => ({ vi.mock("../../database/db/index.js", () => ({
@@ -19,10 +21,27 @@ vi.mock("../../database/db/index.js", () => ({
vi.mock("../../database/repositories/factory.js", () => ({ vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({ get: async () => null }), createCurrentSettingsRepository: () => ({ get: async () => null }),
createCurrentSessionRepository: () => ({ createCurrentSessionRepository: () => ({
findById: async (id: string) => findById: async (id: string) => {
id === "expired" const minutesAgo = (n: number) =>
? { id, expiresAt: new Date(Date.now() - 1000).toISOString() } new Date(Date.now() - n * 60_000).toISOString();
: { id }, if (id === "expired") {
return { id, expiresAt: new Date(Date.now() - 1000).toISOString() };
}
if (id === "idle") {
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(11) };
}
if (id === "active") {
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(5) };
}
if (id === "setup") return { id, restriction: "second-factor-setup" };
return { id };
},
create: async (row: Record<string, unknown>) => {
mocks.created.push(row);
return row;
},
revoke: mocks.revoke,
touch: async () => true,
}), }),
createCurrentUserRepository: () => ({}), createCurrentUserRepository: () => ({}),
createCurrentApiKeyRepository: () => ({}), createCurrentApiKeyRepository: () => ({}),
@@ -54,7 +73,9 @@ vi.mock("../../utils/system-crypto.js", () => ({
}, },
})); }));
const { AuthManager } = await import("../../utils/auth-manager.js"); const { AuthManager, allowsSetupSession } =
await import("../../utils/auth-manager.js");
const { registerSecondFactor } = await import("../../auth/registry.js");
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
function makeLegacyDataKeyWrap( function makeLegacyDataKeyWrap(
@@ -74,6 +95,8 @@ function makeLegacyDataKeyWrap(
} }
beforeEach(() => { beforeEach(() => {
mocks.created.length = 0;
mocks.revoke.mockClear();
mocks.hasUserDEK.mockReset().mockReturnValue(false); mocks.hasUserDEK.mockReset().mockReturnValue(false);
mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined); mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined);
}); });
@@ -209,3 +232,97 @@ describe("AuthManager token handling", () => {
).toBeNull(); ).toBeNull();
}); });
}); });
describe("login policy on sessions", () => {
const sign = (sessionId: string) =>
jwt.sign({ userId: "user-1", sessionId }, jwtSecret, { expiresIn: "1h" });
it("caps the lifetime and stores the idle timeout and restriction", async () => {
const token = await authManager.generateJWTToken("user-1", {
rememberMe: true,
deviceType: "web",
deviceInfo: "Firefox",
maxAgeSeconds: 600,
idleTimeoutSeconds: 300,
restriction: "second-factor-setup",
});
const payload = jwt.decode(token) as { iat: number; exp: number };
expect(payload.exp - payload.iat).toBe(600);
expect(mocks.created[0]).toMatchObject({
idleTimeout: 300,
restriction: "second-factor-setup",
});
const lifetime =
new Date(mocks.created[0].expiresAt as string).getTime() -
new Date(mocks.created[0].createdAt as string).getTime();
expect(lifetime).toBe(600_000);
});
it("ends a session idle past its timeout and keeps an active one", async () => {
expect(await authManager.verifyJWTToken(sign("idle"))).toBeNull();
expect(mocks.revoke).toHaveBeenCalledWith("idle");
expect(await authManager.verifyJWTToken(sign("active"))).not.toBeNull();
});
it("only lets a setup session reach setup routes", async () => {
const middleware = authManager.createAuthMiddleware();
const call = async (url: string) => {
const res = {
statusCode: 200,
body: undefined as unknown,
status(code: number) {
res.statusCode = code;
return res;
},
json(body: unknown) {
res.body = body;
return res;
},
clearCookie() {
return res;
},
};
const next = vi.fn();
await middleware(
{
cookies: { jwt: sign("setup") },
headers: {},
originalUrl: url,
url,
} as never,
res as never,
next,
);
return { res, next };
};
const blocked = await call("/host/db/host");
expect(blocked.res.statusCode).toBe(403);
expect(blocked.res.body).toMatchObject({
code: "SECOND_FACTOR_SETUP_REQUIRED",
});
expect(blocked.next).not.toHaveBeenCalled();
const me = await call("/users/me");
expect(me.next).toHaveBeenCalled();
});
it("allows second factor plugins' routes and nothing else", () => {
const dispose = registerSecondFactor({
id: "key",
pluginId: "keys",
labelKey: "key",
isEnrolled: async () => false,
verify: async () => true,
});
try {
expect(allowsSetupSession("/plugin-api/keys/register")).toBe(true);
expect(allowsSetupSession("/plugin-api/other/thing")).toBe(false);
expect(allowsSetupSession("/users/logout")).toBe(true);
expect(allowsSetupSession("/users/me/token")).toBe(false);
expect(allowsSetupSession("/credentials")).toBe(false);
} finally {
dispose();
}
});
});
+70
View File
@@ -0,0 +1,70 @@
/**
* Admin option force_https: when HTTPS is on, plain HTTP requests are sent to
* the HTTPS port and HTTPS responses carry HSTS. Off by default, so a reverse
* proxy that terminates TLS itself keeps working. A request counts as HTTPS
* when it came in over TLS or a proxy says so in X-Forwarded-Proto.
*/
import type { NextFunction, Request, Response } from "express";
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
import { getTlsConfig } from "./tls-service.js";
export const FORCE_HTTPS_KEY = "force_https";
const HSTS = "max-age=31536000";
// Certificate checks and health probes must still work over plain HTTP.
const EXEMPT = [/^\/\.well-known\/acme-challenge\//, /^\/health$/];
let forceHttps: boolean | null = null;
export async function isForceHttpsEnabled(): Promise<boolean> {
if (forceHttps === null) {
forceHttps = await createCurrentSettingsRepository()
.getBoolean(FORCE_HTTPS_KEY, false)
.catch(() => false);
}
return forceHttps;
}
export async function setForceHttps(enabled: boolean): Promise<void> {
await createCurrentSettingsRepository().set(
FORCE_HTTPS_KEY,
enabled ? "true" : "false",
);
forceHttps = enabled;
}
/** Test seam. */
export function resetHttpsPolicyForTests(): void {
forceHttps = null;
}
export function isSecureRequest(req: Request): boolean {
if (req.secure) return true;
const forwarded = req.get("x-forwarded-proto");
return forwarded?.split(",")[0].trim().toLowerCase() === "https";
}
function httpsUrl(req: Request, port: number): string {
const host = (req.get("host") || "localhost").replace(/:\d+$/, "");
const suffix = port === 443 ? "" : `:${port}`;
return `https://${host}${suffix}${req.originalUrl || req.url}`;
}
export async function httpsPolicyMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const tls = getTlsConfig();
if (!tls.enabled || !(await isForceHttpsEnabled())) return next();
if (isSecureRequest(req)) {
res.setHeader("Strict-Transport-Security", HSTS);
return next();
}
if (EXEMPT.some((pattern) => pattern.test(req.path))) return next();
res.redirect(308, httpsUrl(req, tls.port));
} catch (error) {
next(error);
}
}
+72
View File
@@ -16,6 +16,7 @@ import { nanoid } from "nanoid";
import { inArray, isNotNull } from "drizzle-orm"; import { inArray, isNotNull } from "drizzle-orm";
import type { ExternalSessionRef } from "../auth/types.js"; import type { ExternalSessionRef } from "../auth/types.js";
import { listSecondFactors } from "../auth/registry.js"; import { listSecondFactors } from "../auth/registry.js";
import { SECOND_FACTOR_SETUP } from "../auth/policy.js";
import type { DeviceType } from "./user-agent-parser.js"; import type { DeviceType } from "./user-agent-parser.js";
import { getDb } from "../database/db/index.js"; import { getDb } from "../database/db/index.js";
import { sessions } from "../database/db/schema.js"; import { sessions } from "../database/db/schema.js";
@@ -50,6 +51,8 @@ interface JWTPayload {
* from the token, so a refreshed token stays marked. * from the token, so a refreshed token stays marked.
*/ */
pluginSession?: boolean; pluginSession?: boolean;
/** sessions.restriction, read from the row on verify. */
restriction?: string | null;
dataKeyWrap?: WrappedDataKey; dataKeyWrap?: WrappedDataKey;
iat?: number; iat?: number;
exp?: number; exp?: number;
@@ -69,6 +72,7 @@ interface AuthenticatedRequest extends Request {
pendingTOTP?: boolean; pendingTOTP?: boolean;
dataKey?: Buffer; dataKey?: Buffer;
actingAdminUserId?: string; actingAdminUserId?: string;
sessionRestriction?: string | null;
} }
interface RequestWithHeaders extends Request { interface RequestWithHeaders extends Request {
@@ -129,6 +133,28 @@ export function allowsPluginSession(path: string): boolean {
return !listSecondFactors().some((factor) => factor.pluginId === plugin); return !listSecondFactors().some((factor) => factor.pluginId === plugin);
} }
// What a session that must set up a second factor first may call: who am I,
// sign out, the login and factor routes, plugin bundles, and the plugins that
// provide a second factor.
const SETUP_SESSION_ALLOWLIST = [
/^\/users\/(me|logout)$/,
/^\/users\/auth\//,
/^\/user-preferences\/look(\/|$)/,
/^\/plugins(\/|$)/,
/^\/plugin-assets\//,
];
/** Whether a session restricted to second factor setup may call this path. */
export function allowsSetupSession(path: string): boolean {
if (SETUP_SESSION_ALLOWLIST.some((pattern) => pattern.test(path))) {
return true;
}
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
return (
!!plugin && listSecondFactors().some((factor) => factor.pluginId === plugin)
);
}
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false; let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
/** Set by the plugin runtime: whether a running plugin opted in. */ /** Set by the plugin runtime: whether a running plugin opted in. */
@@ -336,6 +362,11 @@ class AuthManager {
deviceType?: DeviceType; deviceType?: DeviceType;
deviceInfo?: string; deviceInfo?: string;
externalSession?: ExternalSessionRef | null; externalSession?: ExternalSessionRef | null;
/** Caps the lifetime, from the login policy. */
maxAgeSeconds?: number | null;
/** Stored on the session, see SECOND_FACTOR_SETUP. */
restriction?: string | null;
idleTimeoutSeconds?: number | null;
} = {}, } = {},
): Promise<string> { ): Promise<string> {
const jwtSecret = await this.systemCrypto.getJWTSecret(); const jwtSecret = await this.systemCrypto.getJWTSecret();
@@ -355,6 +386,12 @@ class AuthManager {
} else if (!expiresIn) { } else if (!expiresIn) {
expiresIn = defaultExpiry; expiresIn = defaultExpiry;
} }
if (
options.maxAgeSeconds &&
options.maxAgeSeconds * 1000 < this.parseExpiresIn(expiresIn)
) {
expiresIn = `${Math.floor(options.maxAgeSeconds)}s`;
}
const payload: JWTPayload = { userId }; const payload: JWTPayload = { userId };
if (options.pendingTOTP) { if (options.pendingTOTP) {
@@ -385,6 +422,8 @@ class AuthManager {
createdAt, createdAt,
expiresAt, expiresAt,
lastActiveAt: createdAt, lastActiveAt: createdAt,
restriction: options.restriction ?? null,
idleTimeout: options.idleTimeoutSeconds ?? null,
}); });
} catch (error) { } catch (error) {
databaseLogger.error("Failed to create session", error, { databaseLogger.error("Failed to create session", error, {
@@ -500,6 +539,19 @@ class AuthManager {
} }
payload.pluginSession = payload.pluginSession =
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE; sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
payload.restriction = sessionRecord.restriction ?? null;
if (this.idleExpired(sessionRecord)) {
databaseLogger.info("Session ended after idle timeout", {
operation: "session_idle_timeout",
sessionId: payload.sessionId,
userId: payload.userId,
});
await createCurrentSessionRepository()
.revoke(payload.sessionId)
.catch(() => {});
return null;
}
await this.migrateDataKeyFromPayload(payload); await this.migrateDataKeyFromPayload(payload);
} catch (dbError) { } catch (dbError) {
@@ -531,6 +583,15 @@ class AuthManager {
} }
} }
private idleExpired(session: {
idleTimeout: number | null;
lastActiveAt: string;
}): boolean {
if (!session.idleTimeout) return false;
const idleMs = Date.now() - new Date(session.lastActiveAt).getTime();
return idleMs > session.idleTimeout * 1000;
}
async refreshSessionToken( async refreshSessionToken(
userId: string, userId: string,
sessionId: string, sessionId: string,
@@ -921,6 +982,7 @@ class AuthManager {
authReq.userId = payload.userId; authReq.userId = payload.userId;
authReq.sessionId = payload.sessionId; authReq.sessionId = payload.sessionId;
authReq.pendingTOTP = payload.pendingTOTP; authReq.pendingTOTP = payload.pendingTOTP;
authReq.sessionRestriction = payload.restriction ?? null;
if (authReq.headers[ADMIN_TARGET_USER_HEADER]) { if (authReq.headers[ADMIN_TARGET_USER_HEADER]) {
const handled = await this.applyAdminImpersonation( const handled = await this.applyAdminImpersonation(
@@ -1022,6 +1084,16 @@ class AuthManager {
payload: JWTPayload, payload: JWTPayload,
): boolean { ): boolean {
const path = (req.originalUrl || req.url || "").split("?")[0]; const path = (req.originalUrl || req.url || "").split("?")[0];
if (
payload.restriction === SECOND_FACTOR_SETUP &&
!allowsSetupSession(path)
) {
res.status(403).json({
error: "Set up a second factor before using Termix",
code: "SECOND_FACTOR_SETUP_REQUIRED",
});
return true;
}
if (!payload.pluginSession || allowsPluginSession(path)) return false; if (!payload.pluginSession || allowsPluginSession(path)) return false;
res.status(403).json({ res.status(403).json({
error: "A session a plugin issued cannot use this route", error: "A session a plugin issued cannot use this route",
+11
View File
@@ -37,6 +37,13 @@ export interface JumpHost {
hostId: number; hostId: number;
} }
/** The plugin that manages a host, from ctx.hosts.locks. */
export interface HostLock {
pluginId: string;
pluginName: string;
note: string | null;
}
export type Host = { export type Host = {
id: number; id: number;
name: string; name: string;
@@ -116,6 +123,8 @@ export type Host = {
ownerUsername?: string; ownerUsername?: string;
/** A read-only copy of a host shared with the linked account. */ /** A read-only copy of a host shared with the linked account. */
sharedCopy?: boolean; sharedCopy?: boolean;
/** Set when a plugin manages this host: read only, pins and order still work. */
lock?: HostLock;
/** Desktop only: kept on this device, never synced to the server. */ /** Desktop only: kept on this device, never synced to the server. */
localOnly?: boolean; localOnly?: boolean;
@@ -319,6 +328,8 @@ export interface AuthenticatedRequest extends Request {
sessionId?: string; sessionId?: string;
apiKeyId?: string; apiKeyId?: string;
actingAdminUserId?: string; actingAdminUserId?: string;
/** Set while the session may only set up a second factor. */
sessionRestriction?: string | null;
user?: { user?: {
id: string; id: string;
username: string; username: string;
+3
View File
@@ -1,4 +1,5 @@
import type { import type {
HostLock,
HostProtocolAuthSummary, HostProtocolAuthSummary,
HostSshOptions, HostSshOptions,
HostTerminalConfig, HostTerminalConfig,
@@ -104,6 +105,8 @@ export type Host = {
* linked to. It arrives through sync and is managed on the server. * linked to. It arrives through sync and is managed on the server.
*/ */
sharedCopy?: boolean; sharedCopy?: boolean;
/** Set when a plugin manages this host: read only, pins and order still work. */
lock?: HostLock;
/** Desktop only: kept on this device, never synced to the server. */ /** Desktop only: kept on this device, never synced to the server. */
localOnly?: boolean; localOnly?: boolean;
}; };
+10
View File
@@ -46,6 +46,16 @@ export async function getLoginMethods(): Promise<PublicLoginMethod[]> {
} }
} }
/** False when the login policy turned remember me off. Public. */
export async function getRememberDeviceAllowed(): Promise<boolean> {
try {
const response = await authApi.get("/users/auth/methods");
return response.data?.rememberDevice !== false;
} catch {
return true;
}
}
export async function submitLoginMethod( export async function submitLoginMethod(
methodId: string, methodId: string,
body: Record<string, unknown>, body: Record<string, unknown>,
+27
View File
@@ -43,6 +43,33 @@ export async function updateSessionTimeout(
} }
} }
export interface HttpsSettings {
forceHttps: boolean;
tlsEnabled: boolean;
}
export async function getHttpsSettings(): Promise<HttpsSettings> {
try {
const response = await authApi.get("/users/https-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch HTTPS settings");
}
}
export async function updateHttpsSettings(
forceHttps: boolean,
): Promise<HttpsSettings> {
try {
const response = await authApi.patch("/users/https-settings", {
forceHttps,
});
return response.data;
} catch (error) {
handleApiError(error, "update HTTPS settings");
}
}
// ============================================================================ // ============================================================================
// BRANDING SETTINGS // BRANDING SETTINGS
// ============================================================================ // ============================================================================
+15
View File
@@ -86,6 +86,21 @@ export async function bulkUpdateSSHHosts(
} }
} }
export interface HostPolicy {
manualCreate: boolean;
/** The plugin that manages hosts, when hosts can't be added by hand. */
blockedBy?: string;
}
export async function getHostPolicy(): Promise<HostPolicy> {
try {
const response = await sshHostApi.get("/policy");
return response.data;
} catch (error) {
handleApiError(error, "load host policy");
}
}
export async function reorderSSHHosts( export async function reorderSSHHosts(
positions: { id: number; sortOrder: number }[], positions: { id: number; sortOrder: number }[],
): Promise<{ updated: number }> { ): Promise<{ updated: number }> {
+41 -27
View File
@@ -42,6 +42,7 @@ import {
import { import {
challengeSecondFactor, challengeSecondFactor,
getLoginMethods, getLoginMethods,
getRememberDeviceAllowed,
startLoginRedirect, startLoginRedirect,
submitLoginMethod, submitLoginMethod,
verifySecondFactor, verifySecondFactor,
@@ -232,6 +233,7 @@ export function Auth({ onLogin }: AuthProps) {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [rememberAllowed, setRememberAllowed] = useState(true);
const [rememberMe, setRememberMe] = useState(() => { const [rememberMe, setRememberMe] = useState(() => {
try { try {
return localStorage.getItem("rememberMe") === "true"; return localStorage.getItem("rememberMe") === "true";
@@ -395,6 +397,12 @@ export function Auth({ onLogin }: AuthProps) {
void startPreLoginPlugins() void startPreLoginPlugins()
.catch(() => {}) .catch(() => {})
.finally(() => setAuthPluginsLoading(false)); .finally(() => setAuthPluginsLoading(false));
getRememberDeviceAllowed()
.then((allowed) => {
setRememberAllowed(allowed);
if (!allowed) setRememberMe(false);
})
.catch(() => {});
getLoginMethods() getLoginMethods()
.then((methods) => setAuthMethods(methods)) .then((methods) => setAuthMethods(methods))
.catch(() => setAuthMethods([])) .catch(() => setAuthMethods([]))
@@ -1525,19 +1533,21 @@ export function Auth({ onLogin }: AuthProps) {
{view === "external" && ( {view === "external" && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center gap-2"> {rememberAllowed && (
<Checkbox <div className="flex items-center gap-2">
id="rememberSSO" <Checkbox
checked={rememberMe} id="rememberSSO"
onCheckedChange={(v) => setRememberMe(v === true)} checked={rememberMe}
/> onCheckedChange={(v) => setRememberMe(v === true)}
<label />
htmlFor="rememberSSO" <label
className="text-xs text-muted-foreground cursor-pointer" htmlFor="rememberSSO"
> className="text-xs text-muted-foreground cursor-pointer"
{t("auth.rememberMe")} >
</label> {t("auth.rememberMe")}
</div> </label>
</div>
)}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{externalMethods.map((method) => {externalMethods.map((method) =>
@@ -1573,20 +1583,24 @@ export function Auth({ onLogin }: AuthProps) {
/> />
</Field> </Field>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> {rememberAllowed ? (
<Checkbox <div className="flex items-center gap-2">
id="rememberMe" <Checkbox
checked={rememberMe} id="rememberMe"
onCheckedChange={(v) => setRememberMe(v === true)} checked={rememberMe}
disabled={loading} onCheckedChange={(v) => setRememberMe(v === true)}
/> disabled={loading}
<label />
htmlFor="rememberMe" <label
className="text-xs text-muted-foreground cursor-pointer" htmlFor="rememberMe"
> className="text-xs text-muted-foreground cursor-pointer"
{t("auth.rememberMe")} >
</label> {t("auth.rememberMe")}
</div> </label>
</div>
) : (
<span />
)}
{passwordResetAllowed && ( {passwordResetAllowed && (
<button <button
type="button" type="button"
+11
View File
@@ -548,6 +548,9 @@
"deleteFolderConfirm_one": "Delete \"{{name}}\" and its {{count}} host? This cannot be undone.", "deleteFolderConfirm_one": "Delete \"{{name}}\" and its {{count}} host? This cannot be undone.",
"deleteFolderConfirm_other": "Delete \"{{name}}\" and its {{count}} hosts? This cannot be undone.", "deleteFolderConfirm_other": "Delete \"{{name}}\" and its {{count}} hosts? This cannot be undone.",
"failedToMoveHosts": "Failed to move hosts", "failedToMoveHosts": "Failed to move hosts",
"managedBanner": "This host is managed by {{plugin}}. Change it there. You can still pin and reorder it.",
"managedTooltip": "Managed by {{plugin}}",
"addBlocked": "Hosts are managed by {{plugin}}, so new hosts can't be added here.",
"failedToReorder": "Failed to reorder", "failedToReorder": "Failed to reorder",
"expandAll": "Expand all folders", "expandAll": "Expand all folders",
"collapseAll": "Collapse all folders", "collapseAll": "Collapse all folders",
@@ -1144,6 +1147,10 @@
"title": "Handle sign in", "title": "Handle sign in",
"consequence": "It can add a login method, a second factor or an SSH sign-in type." "consequence": "It can add a login method, a second factor or an SSH sign-in type."
}, },
"auth:policy": {
"title": "Set sign in rules",
"consequence": "It can sign people out when idle, limit session length, turn off remember me and require a second factor."
},
"system:tls": { "system:tls": {
"title": "Manage certificates", "title": "Manage certificates",
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities." "consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."
@@ -1212,6 +1219,10 @@
"title": "Store recent activity", "title": "Store recent activity",
"consequence": "Becomes the store for the recent activity list of every user." "consequence": "Becomes the store for the recent activity list of every user."
}, },
"hosts:lock": {
"title": "Manage hosts as read only",
"consequence": "Hosts it manages can't be edited, deleted or reshared by anyone else."
},
"desktop:window": { "desktop:window": {
"title": "Open desktop windows", "title": "Open desktop windows",
"consequence": "Only in the Termix desktop app." "consequence": "Only in the Termix desktop app."
+5
View File
@@ -879,6 +879,8 @@ export {
updateSSHHost, updateSSHHost,
bulkUpdateSSHHosts, bulkUpdateSSHHosts,
reorderSSHHosts, reorderSSHHosts,
getHostPolicy,
type HostPolicy,
deleteSSHHost, deleteSSHHost,
getSSHHostById, getSSHHostById,
testProxyConnection, testProxyConnection,
@@ -909,6 +911,9 @@ export {
updateLogLevel, updateLogLevel,
getSessionTimeout, getSessionTimeout,
updateSessionTimeout, updateSessionTimeout,
getHttpsSettings,
updateHttpsSettings,
type HttpsSettings,
} from "@/api/settings-api"; } from "@/api/settings-api";
// ============================================================================ // ============================================================================
+6
View File
@@ -402,6 +402,7 @@ export {
} from "@/types/host-sidebar-preferences"; } from "@/types/host-sidebar-preferences";
export type { export type {
HostData, HostData,
HostLock,
HostProtocolAuthInput, HostProtocolAuthInput,
ProxyNode, ProxyNode,
SSHHost, SSHHost,
@@ -656,7 +657,11 @@ export {
getHostAccess, getHostAccess,
getHostAuthOverride, getHostAuthOverride,
getHostPassword, getHostPassword,
getHostPolicy,
type HostPolicy,
getHostSidebarPreferences, getHostSidebarPreferences,
getHttpsSettings,
type HttpsSettings,
getLogLevel, getLogLevel,
getPasswordLoginAllowed, getPasswordLoginAllowed,
getPasswordResetAllowed, getPasswordResetAllowed,
@@ -701,6 +706,7 @@ export {
updateExternalAutoProvision, updateExternalAutoProvision,
updateFolderMetadata, updateFolderMetadata,
updateHostAccess, updateHostAccess,
updateHttpsSettings,
updateLogLevel, updateLogLevel,
updatePasswordLoginAllowed, updatePasswordLoginAllowed,
updatePasswordResetAllowed, updatePasswordResetAllowed,
+9 -2
View File
@@ -5,9 +5,16 @@ import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry";
* Enrolment sections login methods and second factors register, such as * Enrolment sections login methods and second factors register, such as
* adding a passkey or setting up an authenticator app. * adding a passkey or setting up an authenticator app.
*/ */
export function AuthEnrollmentSections() { export function AuthEnrollmentSections({
factorsOnly = false,
}: {
/** Only second factors, for a login policy that requires one. */
factorsOnly?: boolean;
} = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const sections = [...useLoginMethods(), ...useSecondFactors()].filter( const methods = useLoginMethods();
const factors = useSecondFactors();
const sections = [...(factorsOnly ? [] : methods), ...factors].filter(
(entry) => entry.enrollment, (entry) => entry.enrollment,
); );
if (sections.length === 0) return null; if (sections.length === 0) return null;
+1
View File
@@ -74,6 +74,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
connectionOrigin: h.connectionOrigin ?? null, connectionOrigin: h.connectionOrigin ?? null,
localOnly: !!h.localOnly, localOnly: !!h.localOnly,
sharedCopy: !!h.sharedCopy, sharedCopy: !!h.sharedCopy,
...(h.lock ? { lock: h.lock } : {}),
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost, enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
sshPort: sshPort:
h.sshPort ?? h.sshPort ??
@@ -40,6 +40,7 @@ const mainAxios = vi.hoisted(() => ({
const authMethodsApi = vi.hoisted(() => ({ const authMethodsApi = vi.hoisted(() => ({
getLoginMethods: vi.fn(), getLoginMethods: vi.fn(),
getRememberDeviceAllowed: vi.fn(async () => true),
submitLoginMethod: vi.fn(), submitLoginMethod: vi.fn(),
startLoginRedirect: vi.fn(), startLoginRedirect: vi.fn(),
verifySecondFactor: vi.fn(), verifySecondFactor: vi.fn(),
@@ -150,6 +151,18 @@ async function openExternalTab() {
} }
describe("login methods on the login screen", () => { describe("login methods on the login screen", () => {
it("offers remember me unless the login policy turned it off", async () => {
await openExternalTab();
expect(screen.getByText("auth.rememberMe")).toBeTruthy();
cleanup();
authMethodsApi.getRememberDeviceAllowed.mockResolvedValueOnce(false);
await openExternalTab();
await waitFor(() =>
expect(screen.queryByText("auth.rememberMe")).toBeNull(),
);
});
it("lists every enabled method", async () => { it("lists every enabled method", async () => {
await openExternalTab(); await openExternalTab();
// A redirect method whose plugin UI is not loaded gets a plain button. // A redirect method whose plugin UI is not loaded gets a plain button.