mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-07 02:55:24 +00:00
Compare commits
20
Commits
sdk-v1.1.0
...
sdk-v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
789d27014f | ||
|
|
67b70f8a97 | ||
|
|
1f5a19f54d | ||
|
|
fd3eeea3b9 | ||
|
|
87d57770ca | ||
|
|
d451628122 | ||
|
|
b2d15281dd | ||
|
|
081c8b6021 | ||
|
|
c28cda7822 | ||
|
|
528092846c | ||
|
|
13ca4369c5 | ||
|
|
741c211f7b | ||
|
|
7e308fe57c | ||
|
|
358664648b | ||
|
|
3824203639 | ||
|
|
49b6dd6aa2 | ||
|
|
db5d4f4157 | ||
|
|
d0ef505951 | ||
|
|
c09d09f2ce | ||
|
|
041e10eb5c |
No files matched your search
@@ -9,6 +9,7 @@
|
||||
- A click on a host can open its host page, when a plugin offers one
|
||||
- Plugins can build pages of tiles you drag and resize, and add many copies of one dashboard card, each with its own settings
|
||||
- A plugin a newer release ships is downloaded on its own when your image does not have it, and tried again on the next start when offline
|
||||
- Plugins can decide who may connect to a host, so a host can need approved access before an SSH or remote desktop session opens
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [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. |
|
||||
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
||||
| [Ansible](https://github.com/Termix-SSH/Plugin-Ansible) | Run Ansible playbooks and ad-hoc commands from a control host, with an inventory made from your Termix hosts. |
|
||||
| [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. |
|
||||
| [Backups](https://github.com/Termix-SSH/Plugin-Backups) | Back up folders, databases and Docker data on your hosts with restic, then browse, download and restore. |
|
||||
@@ -72,28 +73,39 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
|
||||
| [Cron](https://github.com/Termix-SSH/Plugin-Cron) | See, add and run cron jobs on your hosts, with a schedule preview and the system cron files. |
|
||||
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
|
||||
| [DNS](https://github.com/Termix-SSH/Plugin-DNS) | Manage DNS records and domains across Cloudflare, Route 53, Namecheap and other providers. |
|
||||
| [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. |
|
||||
| [Firewall](https://github.com/Termix-SSH/Plugin-Firewall) | Manage UFW, firewalld, nftables and iptables rules on your hosts over SSH, with lockout protection. |
|
||||
| [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. |
|
||||
| [Git](https://github.com/Termix-SSH/Plugin-Git) | See, pull, commit and push git repos on your hosts, and keep them up to date with auto pull. |
|
||||
| [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 Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
|
||||
| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
|
||||
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
|
||||
| [IP-KVM](https://github.com/Termix-SSH/Plugin-IP-KVM) | See and control machines through PiKVM, NanoKVM and JetKVM devices. |
|
||||
| [Kubernetes](https://github.com/Termix-SSH/Plugin-Kubernetes) | Manage Kubernetes clusters from your hosts over SSH, with logs, metrics, YAML and a shell. |
|
||||
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
||||
| [Log Search](https://github.com/Termix-SSH/Plugin-Log-Search) | Search logs from the journal, log files and Docker containers across many hosts at once. |
|
||||
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
|
||||
| [Network Devices](https://github.com/Termix-SSH/Plugin-Network-Devices) | See and manage routers, switches and access points over SNMP and router APIs. |
|
||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||
| [Notes](https://github.com/Termix-SSH/Plugin-Notes) | Write markdown notes for each host, folder or yourself, with commands you can copy or send to the terminal. |
|
||||
| [Object Storage](https://github.com/Termix-SSH/Plugin-Object-Storage) | Browse and manage S3-compatible object storage buckets and files. |
|
||||
| [Operations Center](https://github.com/Termix-SSH/Plugin-Operations-Center) | Run, roll out, approve and track every plugin action across your hosts, with a page per host and tables across all of them. |
|
||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||
| [Out-of-band](https://github.com/Termix-SSH/Plugin-Out-Of-Band) | Power, sensors, event logs and consoles for server BMCs over Redfish, IPMI and Intel AMT. |
|
||||
| [Packages](https://github.com/Termix-SSH/Plugin-Package-Manager) | See and install updates on your hosts, search, install and remove packages, and read the package history. |
|
||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||
| [Privileged Access](https://github.com/Termix-SSH/Plugin-Privileged-Access) | Ask for approval before anyone connects to a privileged host, with access that ends on its own and a full audit trail. |
|
||||
| [Process Manager](https://github.com/Termix-SSH/Plugin-Process-Manager) | See and control running processes, pm2 and supervisor apps on your hosts, read their logs and get alerts when they fail. |
|
||||
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
|
||||
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
|
||||
| [Reverse Proxy](https://github.com/Termix-SSH/Plugin-Reverse-Proxy) | Reverse proxy for TCP, UDP, HTTP and HTTPS with free certificates and Termix login, managed per host. |
|
||||
| [Samba Shares](https://github.com/Termix-SSH/Plugin-Samba) | Create and manage Samba (SMB) file shares, users and permissions on your hosts from one tab. |
|
||||
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
|
||||
| [Security Center](https://github.com/Termix-SSH/Plugin-Security-Center) | Audit SSH keys, rotate them and scan hosts for known vulnerabilities over SSH. |
|
||||
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
|
||||
| [Services](https://github.com/Termix-SSH/Plugin-Service-Manager) | Manage services, timers and logs on your hosts over SSH. |
|
||||
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
|
||||
@@ -104,17 +116,22 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
||||
| [SSH Certificate Signer](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault or OpenBao. |
|
||||
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
|
||||
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
|
||||
| [Storage Health](https://github.com/Termix-SSH/Plugin-Storage-Health) | Watch drive and array health over SSH: SMART, NVMe, ZFS, mdadm, btrfs and LVM, with history, alerts and self tests. |
|
||||
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
|
||||
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
|
||||
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
|
||||
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
|
||||
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
|
||||
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
|
||||
| [UPS Manager](https://github.com/Termix-SSH/Plugin-UPS-Manager) | Watch your UPS devices over NUT, apcupsd or SNMP, with history, alerts and self tests. |
|
||||
| [Uptime](https://github.com/Termix-SSH/Plugin-Uptime) | Watch websites, ports, hosts and services, get alerts when they go down, and publish status pages. |
|
||||
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
||||
| [Users](https://github.com/Termix-SSH/Plugin-User-Manager) | Manage the user accounts, groups, passwords and SSH keys on your hosts. |
|
||||
| [Virtual Machines](https://github.com/Termix-SSH/Plugin-Virtual-Machines) | Create and manage KVM virtual machines on your Linux hosts over SSH with libvirt. |
|
||||
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake, shut down and track your machines from one power dashboard. |
|
||||
| [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. |
|
||||
| [Web Servers](https://github.com/Termix-SSH/Plugin-Web-Servers) | Manage nginx, Apache and Caddy on your hosts: sites, config, logs and HTTPS, with every change tested first. |
|
||||
| [WireGuard](https://github.com/Termix-SSH/Plugin-WireGuard) | Install WireGuard on your hosts and manage interfaces, clients and configs from one tab. |
|
||||
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.1
|
||||
|
||||
Plugin API 1.15.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.hosts.connectPolicy.provide` and the `hosts:connect-policy` capability, so a plugin can allow, deny or ask for an access request before an SSH or remote desktop session opens
|
||||
- SSH prepare outcomes can carry the error codes `access-denied` and `access-required` with a `requestTab`
|
||||
|
||||
## 1.1.0
|
||||
|
||||
Plugin API 1.15.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.1.0 | Plugin API 1.15.0
|
||||
SDK 1.1.1 | Plugin API 1.15.0
|
||||
|
||||
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||
@@ -354,6 +354,13 @@ router.post("/notes", async (req, res) => {
|
||||
`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.
|
||||
- Connect policy: with `hosts:connect-policy`, `ctx.hosts.connectPolicy.provide({ check(input) })` is asked
|
||||
before every SSH config is built and every remote desktop login is handed out (`input` has `userId`,
|
||||
`hostId`, `protocol`, `purpose`, `profile`, `interactive`, `origin`). Return `{ decision: "allow" }`,
|
||||
`{ decision: "deny", reason }` or `{ decision: "request", reason, requestTab }`. The strictest answer of
|
||||
all providers wins and a provider that throws denies. SSH prepare then gives an error outcome with code
|
||||
`access-denied` or `access-required`, and `ctx.credentials.resolveHostProtocol` throws an error with that
|
||||
`code` and a `toJSON()` body for a 403. Keep `check` fast, it also runs for background polling.
|
||||
|
||||
### Errors
|
||||
|
||||
@@ -1085,6 +1092,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
||||
| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
|
||||
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
|
||||
| `hosts:connect-policy` | high | Allow, deny or ask for approval before sessions open |
|
||||
| `network:probe` | medium | Check that a host answers on a TCP port |
|
||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -171,6 +171,7 @@
|
||||
"hosts:status-provide",
|
||||
"hosts:activity-provide",
|
||||
"hosts:lock",
|
||||
"hosts:connect-policy",
|
||||
"desktop:window",
|
||||
"desktop:local",
|
||||
"users:impersonate",
|
||||
|
||||
@@ -1295,6 +1295,54 @@ export interface PluginHostLocksApi {
|
||||
provide: (provider: PluginHostLockProvider) => () => void;
|
||||
}
|
||||
|
||||
/** What core asks a connect policy before a session to a host opens. */
|
||||
export interface PluginConnectPolicyInput {
|
||||
/** The person connecting. */
|
||||
userId: string;
|
||||
hostId: number;
|
||||
/** "ssh", or a remote desktop protocol id such as "rdp", "vnc" or "telnet". */
|
||||
protocol: string;
|
||||
/** Free form purpose the caller gave, such as "terminal" or "sftp". */
|
||||
purpose: string;
|
||||
/**
|
||||
* SSH profile: terminal, session, stream, forward, background or jump.
|
||||
* "desktop" for remote desktop logins.
|
||||
*/
|
||||
profile: string;
|
||||
/** True when a person waits on this connect (a prompt channel is open). */
|
||||
interactive: boolean;
|
||||
/** Plugin id that asked for the connection, or "core". */
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export type PluginConnectPolicyDecision =
|
||||
| { decision: "allow" }
|
||||
| { decision: "deny"; reason?: string }
|
||||
| {
|
||||
decision: "request";
|
||||
reason?: string;
|
||||
/** A tab id of the providing plugin where the user can ask for access. */
|
||||
requestTab?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decides whether a user may open a session to a host. Core asks every
|
||||
* provider before it builds an SSH config (terminal, file manager, tunnels,
|
||||
* background exec) and before it hands out remote desktop logins. The
|
||||
* strictest answer wins and a provider that throws denies. Keep check fast:
|
||||
* it runs on every connect, including background polling.
|
||||
*/
|
||||
export interface PluginConnectPolicyProvider {
|
||||
check: (
|
||||
input: PluginConnectPolicyInput,
|
||||
) => PluginConnectPolicyDecision | Promise<PluginConnectPolicyDecision>;
|
||||
}
|
||||
|
||||
export interface PluginConnectPolicyApi {
|
||||
/** Registers this plugin's policy. Needs hosts:connect-policy. Removed on deactivate. */
|
||||
provide: (provider: PluginConnectPolicyProvider) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -1441,6 +1489,8 @@ export interface PluginHosts {
|
||||
activity: PluginHostActivityApi;
|
||||
/** Read only hosts this plugin manages. */
|
||||
locks: PluginHostLocksApi;
|
||||
/** Who may open sessions to which hosts. */
|
||||
connectPolicy: PluginConnectPolicyApi;
|
||||
/** Folder rows for the acting user. Needs hosts:write. */
|
||||
folders: PluginHostFoldersApi;
|
||||
}
|
||||
@@ -1668,8 +1718,12 @@ export type PluginSshAuthOutcome =
|
||||
| "invalid-key"
|
||||
| "passphrase-required"
|
||||
| "provider-missing"
|
||||
| "failed";
|
||||
| "failed"
|
||||
| "access-denied"
|
||||
| "access-required";
|
||||
message: string;
|
||||
/** For access-required: a tab of the policy plugin where access is asked for. */
|
||||
requestTab?: string;
|
||||
}
|
||||
| {
|
||||
/** Try once more with these config changes. */
|
||||
@@ -2695,7 +2749,9 @@ export interface PluginCredentials {
|
||||
/**
|
||||
* The host's login for one of this plugin's own contributes.protocols.
|
||||
* Null when the host does not exist or the acting user cannot connect to
|
||||
* it. Needs credentials:read. Audited.
|
||||
* it. Throws an error with `code` "access-denied" or "access-required"
|
||||
* (and `requestTab`, `toJSON()` for a 403 body) when a connect policy
|
||||
* refuses the session. Needs credentials:read. Audited.
|
||||
*/
|
||||
resolveHostProtocol: (
|
||||
hostId: number,
|
||||
|
||||
@@ -67,6 +67,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("hosts:status-provide", "medium"),
|
||||
entry("hosts:activity-provide", "medium"),
|
||||
entry("hosts:lock", "medium"),
|
||||
entry("hosts:connect-policy", "high"),
|
||||
entry("users:read", "medium"),
|
||||
entry("events:core", "medium"),
|
||||
entry("notify:send", "medium"),
|
||||
|
||||
@@ -81,6 +81,7 @@ import type {
|
||||
PluginHostStatusEntry,
|
||||
PluginActivityProvider,
|
||||
PluginHostLockProvider,
|
||||
PluginConnectPolicyProvider,
|
||||
PluginAuthPolicyProvider,
|
||||
PluginCredentialSummary,
|
||||
PluginHostShareGrant,
|
||||
@@ -303,6 +304,8 @@ export interface FakePluginContext {
|
||||
activityProviders: PluginActivityProvider[];
|
||||
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
|
||||
hostLockProviders: PluginHostLockProvider[];
|
||||
/** Every provider passed to ctx.hosts.connectPolicy.provide that is still registered. */
|
||||
connectPolicyProviders: PluginConnectPolicyProvider[];
|
||||
/** Topics emitted through ctx.events.emit, in order. */
|
||||
emitted: Array<{ topic: string; payload: unknown }>;
|
||||
/** Backing store behind ctx.kv. */
|
||||
@@ -549,6 +552,7 @@ export function createFakeContext(
|
||||
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
||||
const activityProviders: PluginActivityProvider[] = [];
|
||||
const hostLockProviders: PluginHostLockProvider[] = [];
|
||||
const connectPolicyProviders: PluginConnectPolicyProvider[] = [];
|
||||
const kv = new Map<string, unknown>();
|
||||
const tables: PluginTableDefinition[] = [];
|
||||
const syncEntities: SyncEntityRegistration[] = [];
|
||||
@@ -1093,6 +1097,15 @@ export function createFakeContext(
|
||||
};
|
||||
},
|
||||
},
|
||||
connectPolicy: {
|
||||
provide: (provider) => {
|
||||
connectPolicyProviders.push(provider);
|
||||
return () => {
|
||||
const at = connectPolicyProviders.indexOf(provider);
|
||||
if (at >= 0) connectPolicyProviders.splice(at, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
provide: (provider) => {
|
||||
hostStatusProviders.push(provider);
|
||||
@@ -1586,6 +1599,7 @@ export function createFakeContext(
|
||||
hostStatusProviders,
|
||||
activityProviders,
|
||||
hostLockProviders,
|
||||
connectPolicyProviders,
|
||||
emitted,
|
||||
kv,
|
||||
tables,
|
||||
@@ -2080,6 +2094,12 @@ export function createMockCtx(
|
||||
return ctx.hosts.locks.provide(provider);
|
||||
},
|
||||
},
|
||||
connectPolicy: {
|
||||
provide: (provider) => {
|
||||
require("hosts:connect-policy");
|
||||
return ctx.hosts.connectPolicy.provide(provider);
|
||||
},
|
||||
},
|
||||
status: {
|
||||
provide: (provider) => {
|
||||
require("hosts:read");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -471,6 +471,27 @@ describe("rebuilding local plugins", () => {
|
||||
expect(isLocalBuildStale(root)).toBe(false);
|
||||
});
|
||||
|
||||
it("survives a build replacing dist during the scan", () => {
|
||||
const root = repo(tempDir(), "Plugin-A", "a");
|
||||
const gone = path.join(root, "dist", "migrations", "postgres");
|
||||
fs.mkdirSync(gone, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "manifest.json"), "{}");
|
||||
const realStat = fs.statSync;
|
||||
const spy = vi.spyOn(fs, "statSync").mockImplementation(((
|
||||
target: string,
|
||||
) => {
|
||||
if (target === gone) {
|
||||
throw Object.assign(new Error("gone"), { code: "ENOENT" });
|
||||
}
|
||||
return realStat(target);
|
||||
}) as typeof fs.statSync);
|
||||
try {
|
||||
expect(() => isLocalBuildStale(root)).not.toThrow();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds with this checkout's SDK CLI, not the plugin's own", () => {
|
||||
const [command, args] = localSdkBuildCommand();
|
||||
expect(command).toBe(process.execPath);
|
||||
|
||||
@@ -370,8 +370,9 @@ const BUILD_INPUTS = [
|
||||
"package.json",
|
||||
];
|
||||
|
||||
// A build can replace dist while this walks it, so a path can vanish mid scan.
|
||||
function newestMtime(target) {
|
||||
if (!fs.existsSync(target)) return 0;
|
||||
try {
|
||||
const stat = fs.statSync(target);
|
||||
if (!stat.isDirectory()) return stat.mtimeMs;
|
||||
let newest = 0;
|
||||
@@ -379,6 +380,10 @@ function newestMtime(target) {
|
||||
newest = Math.max(newest, newestMtime(path.join(target, name)));
|
||||
}
|
||||
return newest;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return 0;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when a plugin repo has never been built or its sources changed since. */
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Connect policy from plugins through ctx.hosts.connectPolicy.provide. Core
|
||||
* asks every provider before it builds an SSH config or hands out a remote
|
||||
* desktop login, and the strictest answer wins: deny, then request, then
|
||||
* allow. A provider that throws denies, since letting a session through when
|
||||
* the access rule is broken is worse than a failed connect. No provider means
|
||||
* allow.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginConnectPolicyDecision,
|
||||
PluginConnectPolicyInput,
|
||||
PluginConnectPolicyProvider,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
provider: PluginConnectPolicyProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export type ConnectPolicyResult =
|
||||
| { decision: "allow" }
|
||||
| {
|
||||
decision: "deny" | "request";
|
||||
reason: string;
|
||||
pluginId: string;
|
||||
requestTab: string | null;
|
||||
};
|
||||
|
||||
export class ConnectPolicyError extends Error {
|
||||
readonly status = 403;
|
||||
readonly code: "access-denied" | "access-required";
|
||||
constructor(
|
||||
readonly result: Exclude<ConnectPolicyResult, { decision: "allow" }>,
|
||||
) {
|
||||
super(result.reason);
|
||||
this.name = "ConnectPolicyError";
|
||||
this.code =
|
||||
result.decision === "request" ? "access-required" : "access-denied";
|
||||
}
|
||||
|
||||
/** The body routes send back, so a client can offer the request tab. */
|
||||
toJSON(): {
|
||||
error: string;
|
||||
code: string;
|
||||
requestTab: string | null;
|
||||
pluginId: string;
|
||||
} {
|
||||
return {
|
||||
error: this.message,
|
||||
code: this.code,
|
||||
requestTab: this.result.requestTab,
|
||||
pluginId: this.result.pluginId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function provideConnectPolicy(
|
||||
pluginId: string,
|
||||
provider: PluginConnectPolicyProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearConnectPolicies(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
export function hasConnectPolicies(): boolean {
|
||||
return providers.size > 0;
|
||||
}
|
||||
|
||||
const RANK = { allow: 0, request: 1, deny: 2 } as const;
|
||||
|
||||
function plainTab(value: unknown): string | null {
|
||||
return typeof value === "string" &&
|
||||
/^[a-z0-9][a-z0-9:_.-]{0,80}$/i.test(value)
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export async function evaluateConnectPolicy(
|
||||
input: PluginConnectPolicyInput,
|
||||
): Promise<ConnectPolicyResult> {
|
||||
let result: ConnectPolicyResult = { decision: "allow" };
|
||||
for (const entry of providers.values()) {
|
||||
let decision: PluginConnectPolicyDecision;
|
||||
try {
|
||||
decision = (await entry.provider.check(input)) ?? { decision: "allow" };
|
||||
} catch (error) {
|
||||
pluginLogger.warn("Connect policy failed, refusing the connection", {
|
||||
operation: "connect_policy",
|
||||
pluginId: entry.pluginId,
|
||||
hostId: input.hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
decision = {
|
||||
decision: "deny",
|
||||
reason: "Access to this host can't be checked right now",
|
||||
};
|
||||
}
|
||||
const kind =
|
||||
decision.decision === "deny" || decision.decision === "request"
|
||||
? decision.decision
|
||||
: "allow";
|
||||
if (RANK[kind] <= RANK[result.decision]) continue;
|
||||
if (kind === "allow") continue;
|
||||
const reason =
|
||||
"reason" in decision && typeof decision.reason === "string"
|
||||
? decision.reason.slice(0, 300)
|
||||
: "";
|
||||
result = {
|
||||
decision: kind,
|
||||
reason:
|
||||
reason ||
|
||||
(kind === "request"
|
||||
? "This host needs approved access"
|
||||
: "Access to this host is not allowed"),
|
||||
pluginId: entry.pluginId,
|
||||
requestTab:
|
||||
kind === "request" && "requestTab" in decision
|
||||
? plainTab(decision.requestTab)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function auditRefusal(
|
||||
input: PluginConnectPolicyInput,
|
||||
result: Exclude<ConnectPolicyResult, { decision: "allow" }>,
|
||||
): Promise<void> {
|
||||
const audit = await import("../utils/audit-logger.js");
|
||||
const username = await audit
|
||||
.getAuditUsername(input.userId)
|
||||
.catch(() => input.userId);
|
||||
void audit.logAudit({
|
||||
userId: input.userId,
|
||||
username,
|
||||
action: "connect_policy_denied",
|
||||
resourceType: "host",
|
||||
resourceId: String(input.hostId),
|
||||
details: `${input.protocol} ${result.decision} by ${result.pluginId}: ${result.reason}`,
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Throws ConnectPolicyError when a provider denies or wants a request. */
|
||||
export async function assertConnectAllowed(
|
||||
input: PluginConnectPolicyInput,
|
||||
): Promise<void> {
|
||||
if (providers.size === 0) return;
|
||||
const result = await evaluateConnectPolicy(input);
|
||||
if (result.decision === "allow") return;
|
||||
await auditRefusal(input, result).catch(() => {});
|
||||
throw new ConnectPolicyError(result);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
requireSshAuthProvider,
|
||||
} from "./auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "./core-providers.js";
|
||||
import { ConnectPolicyError, assertConnectAllowed } from "../connect-policy.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthEnv,
|
||||
@@ -113,6 +114,8 @@ export interface BuildConnectConfigOptions {
|
||||
/** Terminal socket for the changed-host-key prompt. */
|
||||
hostKeySocket?: WebSocket | null;
|
||||
interactive?: boolean;
|
||||
/** Plugin id that asked for the connection, or "core". For connect policies. */
|
||||
origin?: string;
|
||||
log?: SshAuthLog;
|
||||
/** Applied after the purpose defaults and before auth is prepared. */
|
||||
overrides?: Partial<MutableConnectConfig>;
|
||||
@@ -197,6 +200,35 @@ export async function buildConnectConfig(
|
||||
log: options.log ?? noopLog,
|
||||
};
|
||||
|
||||
try {
|
||||
await assertConnectAllowed({
|
||||
userId: options.userId,
|
||||
hostId: serverHostId,
|
||||
protocol: "ssh",
|
||||
purpose: String(options.purpose),
|
||||
profile,
|
||||
interactive: options.interactive ?? false,
|
||||
origin: options.origin ?? "core",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ConnectPolicyError) {
|
||||
return {
|
||||
config,
|
||||
provider: null,
|
||||
env,
|
||||
outcome: {
|
||||
status: "error",
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.result.requestTab
|
||||
? { requestTab: error.result.requestTab }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let provider: SshAuthProvider;
|
||||
try {
|
||||
provider = requireSshAuthProvider(authType);
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface ConnectHostOptions {
|
||||
*/
|
||||
sock?: MutableConnectConfig["sock"];
|
||||
log?: SshAuthLog;
|
||||
/** Plugin id that asked for the connection. For connect policies. */
|
||||
origin?: string;
|
||||
}
|
||||
|
||||
export interface SshConnection {
|
||||
@@ -107,6 +109,7 @@ export async function connectHost(
|
||||
client,
|
||||
hostKeySocket: options.prompt?.hostKeySocket ?? null,
|
||||
interactive: !!options.prompt,
|
||||
origin: options.origin,
|
||||
log: options.log,
|
||||
overrides: options.overrides,
|
||||
});
|
||||
|
||||
@@ -98,8 +98,12 @@ export type SshAuthOutcome =
|
||||
| "invalid-key"
|
||||
| "passphrase-required"
|
||||
| "provider-missing"
|
||||
| "failed";
|
||||
| "failed"
|
||||
| "access-denied"
|
||||
| "access-required";
|
||||
message: string;
|
||||
/** For access-required: a tab of the policy plugin where access is asked for. */
|
||||
requestTab?: string;
|
||||
}
|
||||
| {
|
||||
/** Try once more with these config changes. Used by Tailscale. */
|
||||
|
||||
@@ -354,6 +354,27 @@ export function createPluginCredentials({
|
||||
);
|
||||
}
|
||||
|
||||
// Same connect policy as SSH: a remote desktop login is a session.
|
||||
const { assertConnectAllowed } =
|
||||
await import("../hosts/connect-policy.js");
|
||||
try {
|
||||
await assertConnectAllowed({
|
||||
userId,
|
||||
hostId,
|
||||
protocol: String(protocol),
|
||||
purpose: "desktop-login",
|
||||
profile: "desktop",
|
||||
interactive: true,
|
||||
origin: pluginId,
|
||||
});
|
||||
} catch (error) {
|
||||
await audit("credentials_read", details, {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const repository = createCurrentHostResolutionRepository();
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
assertManualCreateAllowed,
|
||||
provideHostLocks,
|
||||
} from "../hosts/locks.js";
|
||||
import { provideConnectPolicy } from "../hosts/connect-policy.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -1041,6 +1042,25 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
},
|
||||
},
|
||||
|
||||
connectPolicy: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("hosts:connect-policy")) {
|
||||
throw capabilityRefused(pluginId, "hosts:connect-policy");
|
||||
}
|
||||
const remove = provideConnectPolicy(pluginId, provider);
|
||||
void audit(
|
||||
"host_connect_policy_provide",
|
||||
`${pluginId} decides who can connect`,
|
||||
{ success: true },
|
||||
);
|
||||
const drop = bag?.add(remove, "connect policy provider");
|
||||
return () => {
|
||||
drop?.();
|
||||
remove();
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
folders: {
|
||||
ensure: async (name, style) => {
|
||||
await requireWrite();
|
||||
|
||||
@@ -263,6 +263,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
userId,
|
||||
purpose: purposeOf(options),
|
||||
profile: profileOf(options),
|
||||
origin: pluginId,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
prompt: options?.prompt,
|
||||
overrides: options?.overrides as Partial<MutableConnectConfig>,
|
||||
@@ -478,6 +479,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
serverHostId: options.serverHostId,
|
||||
log: options.log,
|
||||
interactive: options.interactive,
|
||||
origin: pluginId,
|
||||
hostKeySocket: (options.hostKeySocket ?? null) as never,
|
||||
});
|
||||
await audit("ssh_prepare", describeHost(host), {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const logAudit = vi.fn();
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: { warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: (params: unknown) => logAudit(params),
|
||||
getAuditUsername: async (userId: string) => `name-${userId}`,
|
||||
}));
|
||||
|
||||
const {
|
||||
assertConnectAllowed,
|
||||
clearConnectPolicies,
|
||||
ConnectPolicyError,
|
||||
evaluateConnectPolicy,
|
||||
hasConnectPolicies,
|
||||
provideConnectPolicy,
|
||||
} = await import("../../hosts/connect-policy.js");
|
||||
|
||||
const input = {
|
||||
userId: "u1",
|
||||
hostId: 4,
|
||||
protocol: "ssh",
|
||||
purpose: "terminal",
|
||||
profile: "terminal",
|
||||
interactive: true,
|
||||
origin: "ssh-terminal",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
clearConnectPolicies();
|
||||
logAudit.mockReset();
|
||||
});
|
||||
|
||||
describe("connect policy", () => {
|
||||
it("allows with no provider", async () => {
|
||||
expect(hasConnectPolicies()).toBe(false);
|
||||
await expect(assertConnectAllowed(input)).resolves.toBeUndefined();
|
||||
expect(await evaluateConnectPolicy(input)).toEqual({ decision: "allow" });
|
||||
});
|
||||
|
||||
it("passes the input to the provider", async () => {
|
||||
const check = vi.fn(() => ({ decision: "allow" as const }));
|
||||
provideConnectPolicy("p", { check });
|
||||
await assertConnectAllowed(input);
|
||||
expect(check).toHaveBeenCalledWith(input);
|
||||
});
|
||||
|
||||
it("picks the strictest answer", async () => {
|
||||
provideConnectPolicy("a", {
|
||||
check: () => ({ decision: "request", reason: "ask", requestTab: "pa" }),
|
||||
});
|
||||
provideConnectPolicy("b", { check: () => ({ decision: "allow" }) });
|
||||
expect(await evaluateConnectPolicy(input)).toEqual({
|
||||
decision: "request",
|
||||
reason: "ask",
|
||||
pluginId: "a",
|
||||
requestTab: "pa",
|
||||
});
|
||||
provideConnectPolicy("c", {
|
||||
check: async () => ({ decision: "deny", reason: "no" }),
|
||||
});
|
||||
const result = await evaluateConnectPolicy(input);
|
||||
expect(result).toMatchObject({ decision: "deny", pluginId: "c" });
|
||||
});
|
||||
|
||||
it("denies when a provider throws", async () => {
|
||||
provideConnectPolicy("broken", {
|
||||
check: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
expect(await evaluateConnectPolicy(input)).toMatchObject({
|
||||
decision: "deny",
|
||||
pluginId: "broken",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws a typed error and audits the refusal", async () => {
|
||||
provideConnectPolicy("pa", {
|
||||
check: () => ({ decision: "request", requestTab: "access" }),
|
||||
});
|
||||
const error = await assertConnectAllowed(input).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(ConnectPolicyError);
|
||||
expect(error.code).toBe("access-required");
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.toJSON()).toEqual({
|
||||
error: "This host needs approved access",
|
||||
code: "access-required",
|
||||
requestTab: "access",
|
||||
pluginId: "pa",
|
||||
});
|
||||
expect(logAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "connect_policy_denied",
|
||||
userId: "u1",
|
||||
username: "name-u1",
|
||||
resourceId: "4",
|
||||
success: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a request tab that is not a plain id", async () => {
|
||||
provideConnectPolicy("pa", {
|
||||
check: () => ({ decision: "request", requestTab: "../x y" }),
|
||||
});
|
||||
expect(await evaluateConnectPolicy(input)).toMatchObject({
|
||||
requestTab: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("stops asking once removed", async () => {
|
||||
const remove = provideConnectPolicy("pa", {
|
||||
check: () => ({ decision: "deny" }),
|
||||
});
|
||||
remove();
|
||||
await expect(assertConnectAllowed(input)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ssh2 from "ssh2";
|
||||
import { FakeSshClient } from "./pipeline-test-helpers.js";
|
||||
|
||||
@@ -83,6 +83,10 @@ import {
|
||||
} from "../../../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../../../hosts/connect/core-providers.js";
|
||||
import type { SshConnectHost } from "../../../hosts/connect/types.js";
|
||||
import {
|
||||
clearConnectPolicies,
|
||||
provideConnectPolicy,
|
||||
} from "../../../hosts/connect-policy.js";
|
||||
|
||||
const plainKey = ssh2.utils.generateKeyPairSync("ed25519").private;
|
||||
const encryptedKey = ssh2.utils.generateKeyPairSync("ed25519", {
|
||||
@@ -405,6 +409,46 @@ describe("profile defaults", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("connect policy", () => {
|
||||
afterEach(() => clearConnectPolicies());
|
||||
|
||||
it("refuses before auth is prepared", async () => {
|
||||
const check = vi.fn(() => ({
|
||||
decision: "request" as const,
|
||||
reason: "Ask first",
|
||||
requestTab: "access",
|
||||
}));
|
||||
provideConnectPolicy("pa", { check });
|
||||
const built = await build(host());
|
||||
expect(built.provider).toBeNull();
|
||||
expect(built.outcome).toEqual({
|
||||
status: "error",
|
||||
code: "access-required",
|
||||
message: "Ask first",
|
||||
requestTab: "access",
|
||||
});
|
||||
expect(check).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
hostId: 7,
|
||||
protocol: "ssh",
|
||||
profile: "terminal",
|
||||
origin: "core",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("connectHost throws with the policy code", async () => {
|
||||
provideConnectPolicy("pa", { check: () => ({ decision: "deny" }) });
|
||||
const error = await connectHost(host(), {
|
||||
userId: "user-1",
|
||||
purpose: "test",
|
||||
}).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(SshConnectError);
|
||||
expect(error.code).toBe("access-denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider registry", () => {
|
||||
it("registers and removes a plugin provider", async () => {
|
||||
ensureCoreSshAuthProviders();
|
||||
|
||||
@@ -232,6 +232,36 @@ describe("ctx.credentials.resolveHostProtocol", () => {
|
||||
expect(state.ownerDecrypts).toBe(0);
|
||||
});
|
||||
|
||||
it("asks the connect policy and refuses before decrypting", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
const { provideConnectPolicy, clearConnectPolicies } =
|
||||
await import("../../hosts/connect-policy.js");
|
||||
const check = vi.fn(() => ({
|
||||
decision: "request" as const,
|
||||
requestTab: "access",
|
||||
}));
|
||||
provideConnectPolicy("pa", { check });
|
||||
try {
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
const error = await runAsActor("owner", "request", () =>
|
||||
ctx.credentials.resolveHostProtocol(7, "spice"),
|
||||
).catch((e) => e);
|
||||
expect(error.code).toBe("access-required");
|
||||
expect(check).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "owner",
|
||||
hostId: 7,
|
||||
protocol: "spice",
|
||||
profile: "desktop",
|
||||
origin: "demo",
|
||||
}),
|
||||
);
|
||||
expect(state.ownerDecrypts).toBe(0);
|
||||
} finally {
|
||||
clearConnectPolicies();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the owner's stored login and audits it", async () => {
|
||||
grants.set("demo", ["credentials:read"]);
|
||||
const ctx = spiceContext(["credentials:read"]);
|
||||
|
||||
@@ -42,17 +42,20 @@ function useReachesBottom(ref: React.RefObject<HTMLElement | null>) {
|
||||
if (!element || !scroller) return;
|
||||
// Measured without the line itself, so showing it never flips the answer.
|
||||
const update = () => {
|
||||
const line = reachesRef.current ? 0 : 1;
|
||||
const end =
|
||||
element.getBoundingClientRect().bottom -
|
||||
scroller.getBoundingClientRect().top +
|
||||
scroller.scrollTop -
|
||||
(reachesRef.current ? 0 : 1);
|
||||
setReaches(end >= scroller.clientHeight - 0.5);
|
||||
line;
|
||||
// Compared with the whole scrollable height, so content below keeps the line.
|
||||
setReaches(end >= scroller.scrollHeight - line - 0.5);
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(scroller);
|
||||
observer.observe(element);
|
||||
for (const child of Array.from(scroller.children)) observer.observe(child);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return reaches;
|
||||
|
||||
@@ -1251,6 +1251,10 @@
|
||||
"title": "Manage hosts as read only",
|
||||
"consequence": "Hosts it manages can't be edited, deleted or reshared by anyone else."
|
||||
},
|
||||
"hosts:connect-policy": {
|
||||
"title": "Decide who can connect",
|
||||
"consequence": "Can block or allow every SSH and remote desktop connection to any host."
|
||||
},
|
||||
"desktop:window": {
|
||||
"title": "Open desktop windows",
|
||||
"consequence": "Only in the Termix desktop app."
|
||||
|
||||
Reference in new issue
Block a user