mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
Compare commits
9
Commits
main
..
sdk-v1.0.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cdbfc674c | ||
|
|
9d93fb0893 | ||
|
|
556f0406aa | ||
|
|
0589465e4e | ||
|
|
ec3cc9cf18 | ||
|
|
4c235bb23f | ||
|
|
b414b85746 | ||
|
|
d049588f7f | ||
|
|
86161d66e2 |
No files matched your search
@@ -41,15 +41,9 @@ on:
|
||||
default: ""
|
||||
|
||||
jobs:
|
||||
# One image per plugin profile (docker/bundled-plugins.json). standard keeps
|
||||
# the plain tags; minimal and full add a suffix.
|
||||
build:
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
profile: [standard, minimal, full]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
@@ -86,17 +80,12 @@ jobs:
|
||||
TAGS=()
|
||||
ALL_TAGS=()
|
||||
|
||||
SUFFIX=""
|
||||
if [ "${{ matrix.profile }}" != "standard" ]; then
|
||||
SUFFIX="-${{ matrix.profile }}"
|
||||
fi
|
||||
|
||||
if [ "$BUILD_TYPE" = "Production" ]; then
|
||||
TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX")
|
||||
TAGS+=("$VERSION" "latest")
|
||||
elif [ "$BUILD_TYPE" = "Beta" ]; then
|
||||
TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX")
|
||||
TAGS+=("beta" "$VERSION")
|
||||
else
|
||||
TAGS+=("dev-$VERSION$SUFFIX")
|
||||
TAGS+=("dev-$VERSION")
|
||||
fi
|
||||
|
||||
for tag in "${TAGS[@]}"; do
|
||||
@@ -135,7 +124,6 @@ jobs:
|
||||
tags: ${{ env.ALL_TAGS }}
|
||||
build-args: |
|
||||
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
|
||||
PLUGIN_PROFILE=${{ matrix.profile }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
|
||||
|
||||
@@ -115,9 +115,7 @@ jobs:
|
||||
Write-Host "Spectre libs present: $($found.FullName)"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
node scripts/install-windows-sharp.cjs
|
||||
run: npm ci
|
||||
|
||||
# Bundled plugins are counted as downloads in the plugin store, so a
|
||||
# build only fetches them when the pins change.
|
||||
|
||||
@@ -161,10 +161,9 @@ jobs:
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Translates new or changed strings with the Termix LLM server and pins the
|
||||
# bundled plugins to their newest registry releases, then commits both to the
|
||||
# dev branch so the beta or stable build includes them. If the server is down
|
||||
# it warns and the release goes on with what is there.
|
||||
# Translates new or changed strings with the Termix LLM server and commits
|
||||
# them to the dev branch, so the beta or stable build includes them. If the
|
||||
# server is down it warns and the release goes on with what is there.
|
||||
translate:
|
||||
needs: [prep]
|
||||
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
|
||||
@@ -192,26 +191,19 @@ jobs:
|
||||
languages: src/ui/locales/languages.json
|
||||
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
# Newest stable plugin release whose engine.termix allows this version.
|
||||
- name: Pin bundled plugins
|
||||
run: node scripts/pin-bundled-plugins.cjs --all --core-version "${{ needs.prep.outputs.version }}"
|
||||
|
||||
- name: Commit translations and plugin pins
|
||||
- name: Commit translations
|
||||
id: commit
|
||||
run: |
|
||||
git add src/ui/locales docker/bundled-plugins.json
|
||||
git add src/ui/locales
|
||||
if git diff --cached --quiet; then
|
||||
echo "No translation changes."
|
||||
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
|
||||
echo "DRY RUN: would commit these translation and pin changes:"
|
||||
echo "DRY RUN: would commit these translation changes:"
|
||||
git --no-pager diff --cached --stat
|
||||
else
|
||||
git config user.name "LukeGus"
|
||||
git config user.email "bugattiguy527@gmail.com"
|
||||
git commit -m "chore: update translations and plugin pins"
|
||||
git commit -m "chore: update translations"
|
||||
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 26.10.5
|
||||
|
||||
### Added
|
||||
|
||||
- Safe mode: start the server with TERMIX_SAFE_MODE=1 to run only layout and theme plugins, so a broken plugin can be turned off
|
||||
- TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS lets plugin registries live on a private address, like a registry in your LAN
|
||||
- Plugin registry browsing and downloads use HTTP_PROXY and HTTPS_PROXY when they are set
|
||||
- Docker image sizes: the default image now ships the most used plugins, the minimal tag ships only the look plugins and the full tag ships every official plugin
|
||||
- Plugins an older image shipped and you had on are downloaded from the registry on the first start of a smaller image, so nothing goes missing
|
||||
|
||||
### Changed
|
||||
|
||||
- Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row
|
||||
|
||||
### Fixed
|
||||
|
||||
- The macOS app asks for Local Network access, so it can reach servers on your LAN
|
||||
- The desktop app can change the connection origin of a host shared with you, instead of failing to save
|
||||
|
||||
## 26.10.4
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pin classic layout plugin (fixes proxmox install)
|
||||
|
||||
## 26.10.3
|
||||
|
||||
### Added
|
||||
@@ -39,27 +14,9 @@
|
||||
- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results
|
||||
- Browse shows plugins a page at a time
|
||||
- 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
|
||||
- A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app
|
||||
- The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch
|
||||
- The linked server list holds one link per account, with room for more connection options per server
|
||||
- A plugin can carry SSH connections over its own stream, and core relays a host's connection over a socket for another Termix
|
||||
- Plugins can create a folder with a color and icon and remove it when it is empty
|
||||
- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user
|
||||
- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine
|
||||
- Pasting a security key file now explains how to use it instead of failing to decode it
|
||||
- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it
|
||||
- The desktop app can link to a server that requires a client certificate (mutual TLS). It uses a PFX or P12 file for sync and the certificate in your system store for the app window
|
||||
- Plugins can show a web page in a native view in the desktop app, for sites that do not load in an iframe
|
||||
- The desktop app draws its own title bar in the colors of your theme instead of the native white one
|
||||
|
||||
### Changed
|
||||
|
||||
- Server and desktop sync moved out of the core app into a plugin, including the Sync screen, the link wizard and the first run step. Existing links and offline copies keep working
|
||||
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
|
||||
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
|
||||
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
|
||||
@@ -75,12 +32,6 @@
|
||||
- Importing hosts from a JSON or SSH config file and exporting hosts now come from the Import and Export plugin. Exports are now written to the audit log. Without the plugin there is no import or export
|
||||
- Generating and checking SSH keys in the credential editor and deploying a key to a host now come from the Credential Tools plugin. Deploys are now written to the audit log. Without the plugin those buttons are hidden
|
||||
|
||||
### Fixed
|
||||
|
||||
- Linking the desktop app to a server no longer stalls on the server dashboard after sign in
|
||||
- Exporting your data no longer fails when a plugin table has a text key, like saved passkeys
|
||||
- The Windows on ARM desktop app starts again instead of failing with a local server error
|
||||
|
||||
### Removed
|
||||
|
||||
- The desktop app no longer has its own client tunnel and native Remote Desktop code
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "termix" do
|
||||
version "26.10.5"
|
||||
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
|
||||
version "26.10.2"
|
||||
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223"
|
||||
|
||||
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
|
||||
name "Termix"
|
||||
|
||||
@@ -57,58 +57,48 @@ 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.
|
||||
|
||||
| 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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. |
|
||||
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
|
||||
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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 Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
|
||||
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| 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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
|
||||
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
|
||||
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
|
||||
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
|
||||
| [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. |
|
||||
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [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. |
|
||||
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
|
||||
| [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. |
|
||||
| [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).
|
||||
|
||||
|
||||
+4
-15
@@ -34,20 +34,9 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build:backend:core
|
||||
|
||||
# Stage 3b: Stage the bundled plugins for one profile (minimal, standard or
|
||||
# full, see docker/bundled-plugins.json). Only this stage and the final one
|
||||
# differ between profiles, so the other stages are built once.
|
||||
FROM deps AS plugins
|
||||
WORKDIR /app
|
||||
ARG PLUGIN_PROFILE=standard
|
||||
COPY scripts/build-plugins.cjs ./scripts/build-plugins.cjs
|
||||
COPY scripts/lib/bundled-plugins.cjs ./scripts/lib/bundled-plugins.cjs
|
||||
COPY docker/bundled-plugins.json ./docker/bundled-plugins.json
|
||||
# Bundled plugin downloads show in the plugin store, so keep them between builds.
|
||||
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
|
||||
node scripts/build-plugins.cjs --profile "$PLUGIN_PROFILE"
|
||||
npm run build:backend
|
||||
|
||||
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
|
||||
FROM node:24-slim AS opkssh-downloader
|
||||
@@ -128,9 +117,9 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html
|
||||
|
||||
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
|
||||
# Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved
|
||||
# as dist/plugins by getBundledPluginsDir().
|
||||
COPY --chown=node:node --from=plugins /app/dist/plugins ./dist/plugins
|
||||
# Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by
|
||||
# getBundledPluginsDir().
|
||||
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
|
||||
# Target of node_modules/@termix-ssh/plugin-sdk, which core and the plugins import.
|
||||
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
|
||||
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
|
||||
|
||||
+56
-190
@@ -3,85 +3,50 @@
|
||||
{
|
||||
"id": "acme-ssl",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug",
|
||||
"sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
|
||||
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.3.0/ai-1.3.0.tmxplug",
|
||||
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
|
||||
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
|
||||
},
|
||||
{
|
||||
"id": "alerts",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
|
||||
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
|
||||
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "audit-tools",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug",
|
||||
"sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "automations",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug",
|
||||
"sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
||||
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
||||
},
|
||||
{
|
||||
"id": "classic-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
|
||||
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
|
||||
"tier": "minimal",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
|
||||
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "classic-themes",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
|
||||
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
|
||||
"tier": "minimal",
|
||||
"sha256": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "cloud-providers",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Cloud-Providers/releases/download/v1.1.0/cloud-providers-1.1.0.tmxplug",
|
||||
"sha256": "7f61eafc3b99bdf1328fffa95791db1d27f8e1b683e5a5925603b5840057a9c7",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "credential-tools",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
|
||||
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce",
|
||||
"tier": "standard"
|
||||
},
|
||||
{
|
||||
"id": "databases",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug",
|
||||
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug",
|
||||
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
|
||||
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -90,9 +55,8 @@
|
||||
{
|
||||
"id": "file-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
|
||||
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
|
||||
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -102,167 +66,91 @@
|
||||
"id": "fleets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59",
|
||||
"tier": "full"
|
||||
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
|
||||
},
|
||||
{
|
||||
"id": "homepage",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug",
|
||||
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
|
||||
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
|
||||
},
|
||||
{
|
||||
"id": "host-metrics",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
|
||||
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
|
||||
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "host-status",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
|
||||
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170",
|
||||
"tier": "standard"
|
||||
},
|
||||
{
|
||||
"id": "hosts-config",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug",
|
||||
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "import-export",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
|
||||
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58",
|
||||
"tier": "standard"
|
||||
},
|
||||
{
|
||||
"id": "kubernetes",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Kubernetes/releases/download/v1.0.1/kubernetes-1.0.1.tmxplug",
|
||||
"sha256": "31d6b200579d326d07244712713ae291ff473270d44b478a884d07c863a394f0",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "ldap",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "login-security",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug",
|
||||
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa",
|
||||
"tier": "full"
|
||||
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
|
||||
},
|
||||
{
|
||||
"id": "network-topology",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
|
||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56",
|
||||
"tier": "full"
|
||||
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
|
||||
},
|
||||
{
|
||||
"id": "opkssh",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
|
||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219",
|
||||
"tier": "full"
|
||||
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
|
||||
},
|
||||
{
|
||||
"id": "proxmox",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
|
||||
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
|
||||
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
|
||||
},
|
||||
{
|
||||
"id": "remote-desktop",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.1/remote-desktop-1.2.1.tmxplug",
|
||||
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
|
||||
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "reverse-proxy",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug",
|
||||
"sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "samba",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Samba/releases/download/v1.0.0/samba-1.0.0.tmxplug",
|
||||
"sha256": "6e131ccdca998a9d0802becdd439a88305b64fcd7f07335bec9c7ed6791c0f53",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "secret-sources",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134",
|
||||
"tier": "full"
|
||||
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
|
||||
},
|
||||
{
|
||||
"id": "serial",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
|
||||
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": "desktop",
|
||||
"consent": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "service-manager",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug",
|
||||
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "session-recording",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug",
|
||||
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
|
||||
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
|
||||
},
|
||||
{
|
||||
"id": "session-sharing",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.1.0/session-sharing-1.1.0.tmxplug",
|
||||
"sha256": "2988b39221d93b3dbc5e2a81e104e5dc01ce264afb1e66df3407492658ab9fdc",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "simple-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Simple-Layout/releases/download/v1.0.1/simple-layout-1.0.1.tmxplug",
|
||||
"sha256": "838f08050335b44b62295b87006b50d1db1029699bfe6a35fa75808d4aea2cde",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
|
||||
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
|
||||
},
|
||||
{
|
||||
"id": "snippets",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.1.0/snippets-1.1.0.tmxplug",
|
||||
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
|
||||
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -271,9 +159,8 @@
|
||||
{
|
||||
"id": "ssh-terminal",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
|
||||
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
|
||||
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -282,9 +169,8 @@
|
||||
{
|
||||
"id": "sso",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug",
|
||||
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
|
||||
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
|
||||
"onboarding": {
|
||||
"recommended": false,
|
||||
"consent": false,
|
||||
@@ -295,29 +181,19 @@
|
||||
"id": "step-ca",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a",
|
||||
"tier": "full"
|
||||
},
|
||||
{
|
||||
"id": "sync",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
|
||||
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
|
||||
"tier": "standard"
|
||||
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
|
||||
},
|
||||
{
|
||||
"id": "tailscale",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug",
|
||||
"sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
|
||||
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
|
||||
},
|
||||
{
|
||||
"id": "telemetry",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
|
||||
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": true
|
||||
@@ -327,22 +203,19 @@
|
||||
"id": "termix-identity",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
|
||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927",
|
||||
"tier": "full"
|
||||
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
|
||||
},
|
||||
{
|
||||
"id": "tmux-monitor",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug",
|
||||
"sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
|
||||
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
|
||||
},
|
||||
{
|
||||
"id": "totp",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
|
||||
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
|
||||
"tier": "standard",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -351,9 +224,8 @@
|
||||
{
|
||||
"id": "tunnels",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
|
||||
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
|
||||
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -363,36 +235,31 @@
|
||||
"id": "vault",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
|
||||
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28",
|
||||
"tier": "full"
|
||||
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
|
||||
},
|
||||
{
|
||||
"id": "wake-on-lan",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug",
|
||||
"sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
|
||||
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
|
||||
},
|
||||
{
|
||||
"id": "warpgate",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
|
||||
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47",
|
||||
"tier": "full"
|
||||
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
|
||||
},
|
||||
{
|
||||
"id": "web-endpoint",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug",
|
||||
"sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c",
|
||||
"tier": "full"
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
|
||||
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
|
||||
},
|
||||
{
|
||||
"id": "webauthn",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
|
||||
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
|
||||
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
@@ -401,9 +268,8 @@
|
||||
{
|
||||
"id": "workspaces",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug",
|
||||
"sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf",
|
||||
"tier": "standard",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
|
||||
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
|
||||
"onboarding": {
|
||||
"recommended": true,
|
||||
"consent": false
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
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
@@ -526,13 +526,6 @@
|
||||
"when": 1792374489419,
|
||||
"tag": "0074_mature_puff_adder",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "5",
|
||||
"when": 1792430772041,
|
||||
"tag": "0075_session_policy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
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
@@ -526,13 +526,6 @@
|
||||
"when": 1792374488837,
|
||||
"tag": "0074_bouncy_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "7",
|
||||
"when": 1792430770990,
|
||||
"tag": "0075_session_policy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
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
@@ -491,13 +491,6 @@
|
||||
"when": 1792374488196,
|
||||
"tag": "0069_normal_ozymandias",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "6",
|
||||
"when": 1792430769899,
|
||||
"tag": "0070_session_policy",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -128,10 +128,6 @@
|
||||
"category": "public.app-category.developer-tools",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"extendInfo": {
|
||||
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
|
||||
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"]
|
||||
},
|
||||
"entitlements": "packaging/build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
|
||||
"type": "distribution",
|
||||
@@ -157,8 +153,6 @@
|
||||
"artifactName": "termix_macos_${arch}_mas.${ext}",
|
||||
"extendInfo": {
|
||||
"ITSAppUsesNonExemptEncryption": false,
|
||||
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
|
||||
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"],
|
||||
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
const { isolatedUrl } = require("./isolated-window.cjs");
|
||||
|
||||
function cleanBounds(bounds) {
|
||||
const value = (n) => Math.max(0, Math.round(Number(n) || 0));
|
||||
return {
|
||||
x: value(bounds?.x),
|
||||
y: value(bounds?.y),
|
||||
width: value(bounds?.width),
|
||||
height: value(bounds?.height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Native web views drawn inside the main window, for pages that do not work
|
||||
* in a sandboxed iframe. Each one is a WebContentsView in its own
|
||||
* non-persistent session, with the same limits as an isolated window: no
|
||||
* permissions, no downloads, http(s) and ws(s) only. The renderer owns the
|
||||
* placement and sends the bounds of the element the view should cover.
|
||||
*/
|
||||
function createEmbeddedViews({ WebContentsView, session, getMainWindow }) {
|
||||
const views = new Map();
|
||||
|
||||
function destroy(id) {
|
||||
const entry = views.get(id);
|
||||
if (!entry) return;
|
||||
views.delete(id);
|
||||
const main = getMainWindow();
|
||||
if (main && !main.isDestroyed()) {
|
||||
try {
|
||||
main.contentView.removeChildView(entry.view);
|
||||
} catch {
|
||||
/* already detached */
|
||||
}
|
||||
}
|
||||
if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
|
||||
void Promise.allSettled([
|
||||
entry.session.clearStorageData(),
|
||||
entry.session.clearCache(),
|
||||
entry.session.closeAllConnections(),
|
||||
]);
|
||||
}
|
||||
|
||||
function destroyAll() {
|
||||
for (const id of [...views.keys()]) destroy(id);
|
||||
}
|
||||
|
||||
function open(options = {}) {
|
||||
const main = getMainWindow();
|
||||
if (!main) throw new Error("No window to attach a view to");
|
||||
const id = String(options.id || "");
|
||||
if (!id) throw new Error("A view needs an id");
|
||||
const url = isolatedUrl(options.url);
|
||||
const existing = views.get(id);
|
||||
if (existing) {
|
||||
if (
|
||||
existing.url === url.href &&
|
||||
existing.ignoreCert === !!options.ignoreCert
|
||||
) {
|
||||
setBounds(id, options.bounds);
|
||||
setVisible(id, options.visible !== false);
|
||||
return { success: true };
|
||||
}
|
||||
destroy(id);
|
||||
}
|
||||
|
||||
const isolated = session.fromPartition(
|
||||
`embedded-view-${id}-${Date.now()}`,
|
||||
{
|
||||
cache: false,
|
||||
},
|
||||
);
|
||||
isolated.setPermissionRequestHandler((_c, _p, callback) => callback(false));
|
||||
isolated.setPermissionCheckHandler(() => false);
|
||||
isolated.on("will-download", (event) => event.preventDefault());
|
||||
isolated.webRequest.onBeforeRequest((details, callback) => {
|
||||
const protocol = new URL(details.url).protocol;
|
||||
callback({
|
||||
cancel: !["http:", "https:", "ws:", "wss:"].includes(protocol),
|
||||
});
|
||||
});
|
||||
|
||||
const view = new WebContentsView({
|
||||
webPreferences: {
|
||||
session: isolated,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
webSecurity: true,
|
||||
},
|
||||
});
|
||||
const canNavigate = (target) => {
|
||||
try {
|
||||
return target === "about:blank" || !!isolatedUrl(target);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
for (const name of ["will-navigate", "will-redirect"]) {
|
||||
view.webContents.on(name, (event, target) => {
|
||||
if (!canNavigate(target)) event.preventDefault();
|
||||
});
|
||||
}
|
||||
view.webContents.setWindowOpenHandler(({ url: target }) =>
|
||||
canNavigate(target)
|
||||
? {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
webPreferences: { session: isolated, sandbox: true },
|
||||
},
|
||||
}
|
||||
: { action: "deny" },
|
||||
);
|
||||
|
||||
views.set(id, {
|
||||
view,
|
||||
session: isolated,
|
||||
url: url.href,
|
||||
origin: url.origin,
|
||||
ignoreCert: !!options.ignoreCert,
|
||||
});
|
||||
main.contentView.addChildView(view);
|
||||
setBounds(id, options.bounds);
|
||||
setVisible(id, options.visible !== false);
|
||||
view.webContents.loadURL(url.href).catch(() => {});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function setBounds(id, bounds) {
|
||||
const entry = views.get(id);
|
||||
if (entry) entry.view.setBounds(cleanBounds(bounds));
|
||||
}
|
||||
|
||||
function setVisible(id, visible) {
|
||||
const entry = views.get(id);
|
||||
if (entry) entry.view.setVisible(!!visible);
|
||||
}
|
||||
|
||||
function handleCertificateError(event, contents, url, callback) {
|
||||
for (const entry of views.values()) {
|
||||
if (entry.view.webContents !== contents) continue;
|
||||
event.preventDefault();
|
||||
let allowed = false;
|
||||
try {
|
||||
allowed = entry.ignoreCert && new URL(url).origin === entry.origin;
|
||||
} catch {
|
||||
/* malformed URL is refused */
|
||||
}
|
||||
callback(allowed);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
open,
|
||||
setBounds,
|
||||
setVisible,
|
||||
destroy,
|
||||
destroyAll,
|
||||
handleCertificateError,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createEmbeddedViews, cleanBounds };
|
||||
@@ -34,8 +34,6 @@ function setLinkedServer(config) {
|
||||
basicAuth:
|
||||
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
|
||||
allowInvalidCertificate: !!config.allowInvalidCertificate,
|
||||
clientCert:
|
||||
config.clientCert && config.clientCert.pfx ? config.clientCert : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,16 +45,6 @@ function isLinkedOrigin(url) {
|
||||
return !!linked && getOrigin(url) === linked.origin;
|
||||
}
|
||||
|
||||
// The PFX of the link's client certificate, for TLS this process makes itself
|
||||
// to the linked origin. Null for any other origin.
|
||||
function clientCertOptionsFor(url) {
|
||||
if (!isLinkedOrigin(url) || !linked.clientCert) return {};
|
||||
return {
|
||||
pfx: Buffer.from(linked.clientCert.pfx, "base64"),
|
||||
passphrase: linked.clientCert.passphrase || "",
|
||||
};
|
||||
}
|
||||
|
||||
function basicAuthValue(basicAuth) {
|
||||
return `Basic ${Buffer.from(
|
||||
`${basicAuth.username}:${basicAuth.password || ""}`,
|
||||
@@ -111,7 +99,6 @@ module.exports = {
|
||||
setLinkedServer,
|
||||
getLinkedServer,
|
||||
isLinkedOrigin,
|
||||
clientCertOptionsFor,
|
||||
applyLinkHeaders,
|
||||
linkedRequestHeaders,
|
||||
answerLogin,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const {
|
||||
app,
|
||||
BrowserWindow,
|
||||
WebContentsView,
|
||||
shell,
|
||||
ipcMain,
|
||||
dialog,
|
||||
@@ -31,10 +30,6 @@ const { quitApp } = require("./app-quit.cjs");
|
||||
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
||||
// Saved by the file manager after the user picks an editor in a file dialog.
|
||||
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
|
||||
|
||||
// The height of the title bar the app draws itself. The renderer uses the
|
||||
// same number, so the bar and the window controls always line up.
|
||||
const TITLE_BAR_HEIGHT = 40;
|
||||
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
|
||||
|
||||
// The main process's Node.js networking (the global `fetch`) only
|
||||
@@ -464,7 +459,6 @@ function isInvalidCertificateAllowedForUrl(url) {
|
||||
function getTlsVerificationOptions(url) {
|
||||
return {
|
||||
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
|
||||
...linkedServer.clientCertOptionsFor(url),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,38 +496,12 @@ app.commandLine.appendSwitch("--enable-features=NetworkService");
|
||||
|
||||
let mainWindow = null;
|
||||
const { createIsolatedWindows } = require("./isolated-window.cjs");
|
||||
const { createEmbeddedViews } = require("./embedded-view.cjs");
|
||||
const isolatedWindows = createIsolatedWindows({
|
||||
BrowserWindow,
|
||||
session,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
const embeddedViews = createEmbeddedViews({
|
||||
WebContentsView,
|
||||
session,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
// Only the app's own page may place native views, never a frame inside it.
|
||||
function fromMainFrame(event) {
|
||||
return !!mainWindow && event.senderFrame === mainWindow.webContents.mainFrame;
|
||||
}
|
||||
|
||||
ipcMain.handle("embedded-view-open", (event, options) => {
|
||||
if (!fromMainFrame(event)) throw new Error("Not allowed");
|
||||
return embeddedViews.open(options);
|
||||
});
|
||||
ipcMain.handle("embedded-view-bounds", (event, id, bounds) => {
|
||||
if (fromMainFrame(event)) embeddedViews.setBounds(String(id), bounds);
|
||||
});
|
||||
ipcMain.handle("embedded-view-visible", (event, id, visible) => {
|
||||
if (fromMainFrame(event)) embeddedViews.setVisible(String(id), visible);
|
||||
});
|
||||
ipcMain.handle("embedded-view-close", (event, id) => {
|
||||
if (fromMainFrame(event)) embeddedViews.destroy(String(id));
|
||||
});
|
||||
|
||||
// Requests from the embedded backend over the fork IPC channel (the other
|
||||
// direction from the "shutdown" message main already sends it). A plugin's
|
||||
// ctx.desktop.openIsolatedWindow is the one caller today.
|
||||
@@ -722,39 +690,6 @@ ipcMain.handle("allow-invalid-certificate-for-origin", (_event, origin) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Mutual TLS for the linked server: Chromium asks which certificate from the
|
||||
// OS store to present. Other origins keep Chromium's default (none).
|
||||
const chosenClientCerts = new Map();
|
||||
|
||||
app.on(
|
||||
"select-client-certificate",
|
||||
async (event, _webContents, url, list, callback) => {
|
||||
if (!linkedServer.isLinkedOrigin(url) || !list.length) return;
|
||||
event.preventDefault();
|
||||
const origin = new URL(url).origin;
|
||||
const remembered = list.find(
|
||||
(cert) => cert.fingerprint === chosenClientCerts.get(origin),
|
||||
);
|
||||
let chosen = remembered || (list.length === 1 ? list[0] : null);
|
||||
if (!chosen) {
|
||||
const { response } = await dialog.showMessageBox(mainWindow, {
|
||||
type: "question",
|
||||
title: "Client certificate",
|
||||
message: "Choose the certificate for this server",
|
||||
detail: origin,
|
||||
buttons: [
|
||||
...list.slice(0, 8).map((cert) => cert.subjectName),
|
||||
"Cancel",
|
||||
],
|
||||
cancelId: Math.min(list.length, 8),
|
||||
});
|
||||
chosen = list[response] || null;
|
||||
}
|
||||
if (chosen) chosenClientCerts.set(origin, chosen.fingerprint);
|
||||
callback(chosen || undefined);
|
||||
},
|
||||
);
|
||||
|
||||
app.on(
|
||||
"certificate-error",
|
||||
(event, _webContents, url, error, certificate, callback) => {
|
||||
@@ -762,10 +697,6 @@ app.on(
|
||||
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
|
||||
)
|
||||
return;
|
||||
if (
|
||||
embeddedViews.handleCertificateError(event, _webContents, url, callback)
|
||||
)
|
||||
return;
|
||||
if (isWebEndpointCertificateAllowed(url)) {
|
||||
event.preventDefault();
|
||||
logToFile("Allowed invalid certificate for configured web endpoint", {
|
||||
@@ -1331,27 +1262,12 @@ function createWindow() {
|
||||
? "macOS"
|
||||
: "Linux";
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
title: "Termix",
|
||||
// The app draws its own title bar so it follows the active theme. macOS
|
||||
// keeps the traffic lights on top of it, Windows and Linux get the
|
||||
// native controls as an overlay whose colors the renderer keeps in sync.
|
||||
titleBarStyle: isMac ? "hiddenInset" : "hidden",
|
||||
...(isMac
|
||||
? { trafficLightPosition: { x: 14, y: 16 } }
|
||||
: {
|
||||
titleBarOverlay: {
|
||||
color: "#0f0f14",
|
||||
symbolColor: "#e6e6ea",
|
||||
height: TITLE_BAR_HEIGHT,
|
||||
},
|
||||
}),
|
||||
icon: path.join(
|
||||
appRoot,
|
||||
"public",
|
||||
@@ -1558,24 +1474,7 @@ function createWindow() {
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.on("did-start-navigation", (details) => {
|
||||
if (details.isMainFrame && !details.isSameDocument) {
|
||||
embeddedViews.destroyAll();
|
||||
}
|
||||
});
|
||||
|
||||
const sendTitleBarVisible = () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send(
|
||||
"title-bar-visible-changed",
|
||||
titleBarVisible(mainWindow),
|
||||
);
|
||||
};
|
||||
mainWindow.on("enter-full-screen", sendTitleBarVisible);
|
||||
mainWindow.on("leave-full-screen", sendTitleBarVisible);
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
embeddedViews.destroyAll();
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
@@ -1603,50 +1502,10 @@ ipcMain.handle("get-app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
/** The window an IPC call came from, so the main window and the extra
|
||||
* windows (isolated views, sign-in) can each answer for themselves. */
|
||||
function windowFor(event) {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win && !win.isDestroyed()) return win;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
|
||||
return null;
|
||||
}
|
||||
|
||||
ipcMain.handle("get-platform", () => {
|
||||
return process.platform;
|
||||
});
|
||||
|
||||
// Whether the app-drawn title bar shows in the window that asks. Only the
|
||||
// main window hides the native bar, and in full screen macOS hides the
|
||||
// traffic lights, so the bar goes away there too.
|
||||
function titleBarVisible(win) {
|
||||
return !!win && win === mainWindow && !win.isFullScreen();
|
||||
}
|
||||
|
||||
ipcMain.handle("get-title-bar-visible", (event) => {
|
||||
return titleBarVisible(windowFor(event));
|
||||
});
|
||||
|
||||
// The renderer sends the colors of the active theme so the window controls
|
||||
// the system draws (Windows and Linux) match the app.
|
||||
ipcMain.handle("set-title-bar-overlay", (event, colors) => {
|
||||
const win = windowFor(event);
|
||||
if (!win || process.platform === "darwin") return;
|
||||
const options = {};
|
||||
if (typeof colors?.color === "string" && colors.color) {
|
||||
options.color = colors.color;
|
||||
}
|
||||
if (typeof colors?.symbolColor === "string" && colors.symbolColor) {
|
||||
options.symbolColor = colors.symbolColor;
|
||||
}
|
||||
if (Object.keys(options).length === 0) return;
|
||||
try {
|
||||
win.setTitleBarOverlay(options);
|
||||
} catch (error) {
|
||||
logToFile("setTitleBarOverlay failed:", error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-embedded-server-status", () => {
|
||||
return {
|
||||
running:
|
||||
|
||||
@@ -32,19 +32,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value)));
|
||||
},
|
||||
getPlatform: () => ipcRenderer.invoke("get-platform"),
|
||||
|
||||
// Whether the app-drawn title bar shows in this window, and when it changes
|
||||
// (full screen hides it).
|
||||
getTitleBarVisible: () => ipcRenderer.invoke("get-title-bar-visible"),
|
||||
onTitleBarVisibleChange: (callback) => {
|
||||
const listener = (_event, visible) => callback(visible);
|
||||
ipcRenderer.on("title-bar-visible-changed", listener);
|
||||
return () =>
|
||||
ipcRenderer.removeListener("title-bar-visible-changed", listener);
|
||||
},
|
||||
// Keeps the window controls the system draws in the colors of the theme.
|
||||
setTitleBarOverlay: (colors) =>
|
||||
ipcRenderer.invoke("set-title-bar-overlay", colors),
|
||||
getEmbeddedServerStatus: () =>
|
||||
ipcRenderer.invoke("get-embedded-server-status"),
|
||||
|
||||
@@ -93,16 +80,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
||||
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
||||
|
||||
// Native web views placed over an element of the page (WebContentsView).
|
||||
embeddedView: {
|
||||
open: (options) => ipcRenderer.invoke("embedded-view-open", options),
|
||||
setBounds: (id, bounds) =>
|
||||
ipcRenderer.invoke("embedded-view-bounds", id, bounds),
|
||||
setVisible: (id, visible) =>
|
||||
ipcRenderer.invoke("embedded-view-visible", id, visible),
|
||||
close: (id) => ipcRenderer.invoke("embedded-view-close", id),
|
||||
},
|
||||
|
||||
invoke: invokeAllowed,
|
||||
});
|
||||
|
||||
|
||||
+1
-12
@@ -298,7 +298,7 @@
|
||||
{
|
||||
"name": "HTTP_PROXY",
|
||||
"group": "proxy",
|
||||
"description": "Proxy for outbound HTTP requests, like plugin registry downloads and webhooks."
|
||||
"description": "Proxy for outbound HTTP requests, like registry downloads and webhooks."
|
||||
},
|
||||
{
|
||||
"name": "HTTPS_PROXY",
|
||||
@@ -353,11 +353,6 @@
|
||||
"group": "plugins",
|
||||
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS",
|
||||
"group": "plugins",
|
||||
"description": "Comma-separated host names or IP addresses that plugin registry downloads may reach on a private address, like a registry in your LAN. List bare hosts: entries with a scheme, port or path are ignored. Matched by name only, on any port, so only list names your own DNS answers for. With HTTPS_PROXY set, add them to NO_PROXY too."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
|
||||
"group": "plugins",
|
||||
@@ -370,12 +365,6 @@
|
||||
"default": "false",
|
||||
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_SAFE_MODE",
|
||||
"group": "plugins",
|
||||
"default": "false",
|
||||
"description": "Start only layout and theme plugins. Every other plugin stays off until a restart without it, so you can turn off a plugin that breaks the server or the app."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||
"group": "plugins",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.5",
|
||||
"version": "26.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.5",
|
||||
"version": "26.10.3",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
|
||||
+4
-5
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.5",
|
||||
"version": "26.10.3",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -15,10 +15,10 @@
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
|
||||
"prepare": "husky || true",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-button-colors.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "npm run build:sdk && tsc -b --force",
|
||||
"test": "vitest run",
|
||||
@@ -31,8 +31,7 @@
|
||||
"dev:docker": "node scripts/dev.mjs docker",
|
||||
"dev:frontend": "vite",
|
||||
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"build:backend:core": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"",
|
||||
"build:backend": "npm run build:backend:core && npm run build:plugins",
|
||||
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
|
||||
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
|
||||
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
|
||||
|
||||
@@ -1,80 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.20
|
||||
|
||||
Plugin API 1.12.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `columns` and `onColumns` on `ViewToggle` show a cards per row picker: Auto fits as many as the pane has room for, or pick 1 to 6
|
||||
- `size` and `minCardWidth` on `DataView`, so a card grid follows that pick, and the `GridSize` type
|
||||
- `usePluginPanelView` also keeps `columns`, the cards per row pick, with `setColumns`
|
||||
|
||||
## 1.0.19
|
||||
|
||||
Plugin API 1.11.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `app.registerCompanionLayout` and `useCompanions`, so a plugin can let each host turn the panel bar on or off, move it and choose which panels show and in what order
|
||||
|
||||
## 1.0.18
|
||||
|
||||
Plugin API 1.10.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `clientCert` on the sync link, link patch and remote target, and the `client_cert` remote error kind, so a sync plugin can link to a server that needs mutual TLS
|
||||
|
||||
## 1.0.17
|
||||
|
||||
Plugin API 1.9.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running
|
||||
- `NativeView` in `@termix-ssh/plugin-sdk/ui`, which shows a web page in a native view in the desktop app, for sites that do not work in an iframe
|
||||
|
||||
## 1.0.16
|
||||
|
||||
Plugin API 1.8.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
|
||||
- `ctx.ssh.transports.provide` and the `ssh:transport` capability, so a plugin can carry SSH connections over its own stream
|
||||
- `ctx.hosts.folders.ensure` and `removeEmpty`
|
||||
|
||||
### Removed
|
||||
|
||||
- The sync screens and calls in `@termix-ssh/plugin-sdk/ui`: `LinkServerDialog`, `ProxySettingsFields`, `useSyncStatus`, `refreshSyncStatus`, `setSyncStatus`, `getSyncConflicts`, `getSyncErrors`, `retrySyncErrors`, `settleSyncConflict`, `syncNow`, `unlinkServer`, `updateSyncSettings` and their types. They moved into the sync plugin
|
||||
|
||||
## 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
|
||||
|
||||
Plugin API 1.5.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.20 | Plugin API 1.12.0
|
||||
SDK 1.0.13 | Plugin API 1.5.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.
|
||||
@@ -202,40 +202,36 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
|
||||
|
||||
### What ctx has
|
||||
|
||||
| Member | Capability | What it does |
|
||||
| ---------------------- | -------------------------------- | --------------------------------------------------------------- |
|
||||
| `pluginId`, `manifest` | none | Your id and manifest |
|
||||
| `log` | none | Log lines tagged with your plugin |
|
||||
| `db` | `db:own` | Your own tables |
|
||||
| `kv` | `kv:own` | Small key value state |
|
||||
| `files.dataDir()` | `files:own` | A folder of your own |
|
||||
| `settings` | none | Your settings |
|
||||
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
|
||||
| `http`, `ws` | `network:serve` | Routes and sockets |
|
||||
| `rbac` | none | Check the actor's permissions |
|
||||
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
|
||||
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
|
||||
| `credentials` | varies | The actor's saved keys, and protocol logins |
|
||||
| `services` | per service | Call other plugins and offer your own |
|
||||
| `events` | `events:core` for core topics | Emit and listen for events |
|
||||
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
|
||||
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
|
||||
| `process` | `process:spawn` | Run programs on the server |
|
||||
| `schedule` | none | Timers that stop on disable and never overlap |
|
||||
| `audit.record` | none | Write your own audit line |
|
||||
| `sync` | none | Sync your tables to a linked desktop |
|
||||
| `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.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
|
||||
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
|
||||
| `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream |
|
||||
| `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty |
|
||||
| `users.getLocale` | `users:read` | A user's UI language |
|
||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||
| `asUser(userId, fn)` | always audited | Run background work as a user |
|
||||
| `currentActor()` | none | The user the current call runs for |
|
||||
| `disposables.add(fn)` | none | Extra cleanup on disable |
|
||||
| Member | Capability | What it does |
|
||||
| ---------------------- | -------------------------------- | -------------------------------------------------------------- |
|
||||
| `pluginId`, `manifest` | none | Your id and manifest |
|
||||
| `log` | none | Log lines tagged with your plugin |
|
||||
| `db` | `db:own` | Your own tables |
|
||||
| `kv` | `kv:own` | Small key value state |
|
||||
| `files.dataDir()` | `files:own` | A folder of your own |
|
||||
| `settings` | none | Your settings |
|
||||
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
|
||||
| `http`, `ws` | `network:serve` | Routes and sockets |
|
||||
| `rbac` | none | Check the actor's permissions |
|
||||
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
|
||||
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
|
||||
| `credentials` | varies | The actor's saved keys, and protocol logins |
|
||||
| `services` | per service | Call other plugins and offer your own |
|
||||
| `events` | `events:core` for core topics | Emit and listen for events |
|
||||
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
|
||||
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
|
||||
| `process` | `process:spawn` | Run programs on the server |
|
||||
| `schedule` | none | Timers that stop on disable and never overlap |
|
||||
| `audit.record` | none | Write your own audit line |
|
||||
| `sync` | none | Sync your tables to a linked desktop |
|
||||
| `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 |
|
||||
| `users.getLocale` | `users:read` | A user's UI language |
|
||||
| `system` | `system:tls` | The server's HTTPS certificate |
|
||||
| `plugins.list()` | `plugins:read` | Which plugins are installed |
|
||||
| `asUser(userId, fn)` | always audited | Run background work as a user |
|
||||
| `currentActor()` | none | The user the current call runs for |
|
||||
| `disposables.add(fn)` | none | Extra cleanup on disable |
|
||||
|
||||
### The actor
|
||||
|
||||
@@ -304,14 +300,6 @@ router.post("/notes", async (req, res) => {
|
||||
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
|
||||
`hosts.edit` checks a core permission.
|
||||
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
|
||||
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
|
||||
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
|
||||
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
|
||||
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
|
||||
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
|
||||
|
||||
@@ -495,8 +483,6 @@ your plugin's error boundary and Suspense.
|
||||
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `registerRailItem` | A sidebar rail button |
|
||||
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
|
||||
| `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle |
|
||||
| `registerCompanionLayout` | Per host on/off, side, order and hidden panels for a tab type |
|
||||
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
|
||||
| `registerHostAction` | A host button, palette entry or default connect |
|
||||
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
||||
@@ -632,9 +618,6 @@ Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the t
|
||||
exports with props is `ui.api.txt` in the Termix repo
|
||||
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
|
||||
|
||||
- Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the
|
||||
page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders
|
||||
`fallback` in a browser.
|
||||
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
|
||||
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
|
||||
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
|
||||
@@ -643,9 +626,6 @@ exports with props is `ui.api.txt` in the Termix repo
|
||||
`PanePrompt` for small prompts.
|
||||
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
|
||||
forms end with `FormFooter`, empty lists show `EmptyState`.
|
||||
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
|
||||
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
|
||||
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
|
||||
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
|
||||
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
|
||||
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
|
||||
@@ -714,21 +694,6 @@ closely. Declare ids in `contributes.auth` and register them with `ctx.auth.regi
|
||||
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
|
||||
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"`.
|
||||
|
||||
Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every
|
||||
request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands
|
||||
over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier
|
||||
(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions
|
||||
(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its
|
||||
own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute
|
||||
ticket that opens plugin sockets as its user, which is how one server opens sessions through another.
|
||||
|
||||
## Testing
|
||||
|
||||
```ts
|
||||
@@ -915,48 +880,44 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Capability | Risk | What it allows |
|
||||
| ------------------------ | -------- | ------------------------------------------------------------- |
|
||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||
| `process:spawn` | high | Run programs on the Termix server |
|
||||
| `users:write` | high | Add users and change their roles |
|
||||
| `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) |
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
|
||||
| `sync:provide` | high | Run sync: answer every /sync request |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||
| `hosts:export` | high | Export hosts with their saved logins |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `ui:layout` | high | Replace the whole app layout |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||
| `network:serve` | medium | HTTP routes and sockets |
|
||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||
| `users:read` | medium | Usernames, roles and UI language |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
| `db:own` | low | Your own tables |
|
||||
| `kv:own` | low | Small key value state |
|
||||
| `files:own` | low | Your own folder on disk |
|
||||
| `secrets:own` | low | Your own encrypted secrets |
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
| `ui:theme` | low | Add color themes, CSS only |
|
||||
| Capability | Risk | What it allows |
|
||||
| ------------------------ | -------- | ------------------------------------------------------- |
|
||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||
| `process:spawn` | high | Run programs on the Termix server |
|
||||
| `users:write` | high | Add users and change their roles |
|
||||
| `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) |
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||
| `hosts:export` | high | Export hosts with their saved logins |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `ui:layout` | high | Replace the whole app layout |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
||||
| `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 |
|
||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||
| `network:serve` | medium | HTTP routes and sockets |
|
||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||
| `users:read` | medium | Usernames, roles and UI language |
|
||||
| `events:core` | medium | Listen to core events |
|
||||
| `notify:send` | medium | Send alerts |
|
||||
| `audit:read` | medium | Read the audit log |
|
||||
| `desktop:window` | medium | Open windows in the desktop app |
|
||||
| `hosts:read` | low | Read hosts the user can see |
|
||||
| `db:own` | low | Your own tables |
|
||||
| `kv:own` | low | Small key value state |
|
||||
| `files:own` | low | Your own folder on disk |
|
||||
| `secrets:own` | low | Your own encrypted secrets |
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
| `ui:theme` | low | Add color themes, CSS only |
|
||||
|
||||
An update that adds a capability waits for an admin to approve it.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.13",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -151,9 +151,6 @@
|
||||
"process:spawn",
|
||||
"users:write",
|
||||
"auth:provide",
|
||||
"auth:policy",
|
||||
"sync:provide",
|
||||
"ssh:transport",
|
||||
"system:tls",
|
||||
"device:serial",
|
||||
"hosts:write",
|
||||
@@ -170,7 +167,6 @@
|
||||
"network:probe",
|
||||
"hosts:status-provide",
|
||||
"hosts:activity-provide",
|
||||
"hosts:lock",
|
||||
"desktop:window",
|
||||
"desktop:local",
|
||||
"users:impersonate",
|
||||
|
||||
@@ -236,448 +236,6 @@ export interface SyncEntityRegistration {
|
||||
*/
|
||||
export interface PluginSync {
|
||||
registerEntity: (entity: SyncEntityRegistration) => void;
|
||||
/** Needs sync:provide. Takes over /sync/v2 and /sync/link. Removed on deactivate. */
|
||||
provide: (provider: PluginSyncProvider) => () => void;
|
||||
/** Needs sync:provide. The replica store, change feed and link registry. */
|
||||
readonly host: PluginSyncHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin that owns server and desktop sync. Core forwards every request
|
||||
* under /sync to `handle` (an Express style handler) and answers 404 when no
|
||||
* plugin provides it. Call `next()` to let core answer instead.
|
||||
*/
|
||||
export interface PluginSyncProvider {
|
||||
handle: (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
next: (error?: unknown) => void,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** A kind of data that syncs, as the sync provider sees it. */
|
||||
export interface SyncEntityInfo {
|
||||
type: string;
|
||||
owner: string;
|
||||
order: number;
|
||||
readOnly: boolean;
|
||||
/** Whether the row would show in a load even if it exists. */
|
||||
covers?: (syncId: string) => boolean;
|
||||
}
|
||||
|
||||
/** A row ready for the wire, with its content hash. */
|
||||
export interface SyncWireRow {
|
||||
syncId: string;
|
||||
wire: SyncRow;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface SyncRecordInfo {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
revision: number;
|
||||
hash: string | null;
|
||||
deleted: boolean;
|
||||
error: string | null;
|
||||
errorHash: string | null;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export interface SyncConflictInfo {
|
||||
id: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
localRow: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SyncAccountInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface SyncProxyHeader {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SyncBasicAuth {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** A client certificate for a server that asks for mutual TLS. */
|
||||
export interface SyncClientCert {
|
||||
/** Base64 of the PFX/P12 file. */
|
||||
pfx: string;
|
||||
passphrase: string;
|
||||
/** The file name, to show which certificate is saved. */
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
/** Extra per server connection options. Kept open so new ones need no migration. */
|
||||
export type SyncLinkOptions = Record<string, unknown>;
|
||||
|
||||
/** One server a desktop is linked to, with a replica of its data. */
|
||||
export interface SyncLinkInfo {
|
||||
id: number;
|
||||
/** The local user the replica belongs to. */
|
||||
userId: string;
|
||||
serverUrl: string;
|
||||
serverName: string | null;
|
||||
serverVersion: string | null;
|
||||
sessionToken: string | null;
|
||||
customHeaders: SyncProxyHeader[];
|
||||
basicAuth: SyncBasicAuth | null;
|
||||
allowInvalidCertificate: boolean;
|
||||
clientCert: SyncClientCert | null;
|
||||
remoteUserId: string | null;
|
||||
remoteUsername: string | null;
|
||||
account: {
|
||||
remoteUserId?: string;
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
roles?: string[];
|
||||
permissions?: string[];
|
||||
} | null;
|
||||
disabledTypes: string[];
|
||||
/** Extra connection options for this server, kept open for new ones. */
|
||||
options: SyncLinkOptions;
|
||||
knownTypes: string[];
|
||||
cursor: number;
|
||||
status: "idle" | "syncing" | "offline" | "error" | "signed_out";
|
||||
lastError: string | null;
|
||||
linkedAt: string;
|
||||
lastSyncAt: string | null;
|
||||
}
|
||||
|
||||
export interface SyncLinkCreate {
|
||||
userId: string;
|
||||
serverUrl: string;
|
||||
serverName?: string | null;
|
||||
serverVersion?: string | null;
|
||||
sessionToken: string;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: SyncClientCert | null;
|
||||
remoteUserId?: string | null;
|
||||
remoteUsername?: string | null;
|
||||
}
|
||||
|
||||
export type SyncLinkPatch = Partial<
|
||||
Pick<
|
||||
SyncLinkInfo,
|
||||
| "serverName"
|
||||
| "serverVersion"
|
||||
| "sessionToken"
|
||||
| "customHeaders"
|
||||
| "basicAuth"
|
||||
| "allowInvalidCertificate"
|
||||
| "clientCert"
|
||||
| "remoteUserId"
|
||||
| "remoteUsername"
|
||||
| "account"
|
||||
| "disabledTypes"
|
||||
| "options"
|
||||
| "knownTypes"
|
||||
| "cursor"
|
||||
| "status"
|
||||
| "lastError"
|
||||
| "lastSyncAt"
|
||||
>
|
||||
>;
|
||||
|
||||
export type SyncRemoteErrorKind =
|
||||
| "offline"
|
||||
| "tls"
|
||||
| "client_cert"
|
||||
| "proxy"
|
||||
| "basic_auth"
|
||||
| "signed_out"
|
||||
| "server";
|
||||
|
||||
export interface SyncRemoteError extends Error {
|
||||
readonly kind: SyncRemoteErrorKind;
|
||||
readonly status?: number;
|
||||
}
|
||||
|
||||
export interface SyncRemoteTarget {
|
||||
serverUrl: string;
|
||||
sessionToken?: string | null;
|
||||
customHeaders?: SyncProxyHeader[];
|
||||
basicAuth?: SyncBasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: SyncClientCert | null;
|
||||
}
|
||||
|
||||
export interface SyncRemoteRequest {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SyncPushOp {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
baseRevision: number;
|
||||
op: "upsert" | "delete";
|
||||
row?: SyncRow;
|
||||
}
|
||||
|
||||
export type SyncPushResult =
|
||||
| { syncId: string; entityType: string; status: "ok"; revision: number }
|
||||
| {
|
||||
syncId: string;
|
||||
entityType: string;
|
||||
status: "conflict";
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}
|
||||
| { syncId: string; entityType: string; status: "rejected"; reason: string };
|
||||
|
||||
export interface SyncRemotePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
source: string;
|
||||
artifact: boolean;
|
||||
enabled: boolean;
|
||||
active: boolean;
|
||||
desktop: string;
|
||||
syncEntities: string[];
|
||||
granted: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sync provider gets from core: the replica store, the change feed,
|
||||
* the linked server registry, sessions and a few helpers. Core keeps all of
|
||||
* it neutral; the provider decides when and how to use it. Needs sync:provide.
|
||||
*/
|
||||
export interface PluginSyncHost {
|
||||
/** True on the desktop app's own backend. */
|
||||
readonly embedded: boolean;
|
||||
info: { appName: () => Promise<string>; version: () => string };
|
||||
|
||||
entities: {
|
||||
list: () => SyncEntityInfo[];
|
||||
get: (type: string) => SyncEntityInfo | undefined;
|
||||
/** Registers core's own entities once. */
|
||||
ensureCore: () => Promise<void>;
|
||||
onChanged: (listener: () => void) => () => void;
|
||||
onLocalWrite: (listener: () => void) => () => void;
|
||||
};
|
||||
|
||||
/** Rows and bookkeeping of one local user's replica. */
|
||||
replica: {
|
||||
resolvers: (userId: string, onMissing?: () => void) => unknown;
|
||||
loadRows: (
|
||||
type: string,
|
||||
userId: string,
|
||||
resolvers: unknown,
|
||||
) => Promise<{ rows: Map<string, SyncWireRow>; failed: Set<string> }>;
|
||||
loadRow: (
|
||||
type: string,
|
||||
userId: string,
|
||||
syncId: string,
|
||||
resolvers: unknown,
|
||||
) => Promise<SyncWireRow | null>;
|
||||
writeRow: (
|
||||
type: string,
|
||||
userId: string,
|
||||
wire: SyncRow,
|
||||
resolvers: unknown,
|
||||
) => Promise<void>;
|
||||
deleteRow: (type: string, userId: string, syncId: string) => Promise<void>;
|
||||
orderSelfReferences: (type: string, rows: SyncRow[]) => SyncRow[];
|
||||
counts: (userId: string) => Promise<Record<string, number>>;
|
||||
wipe: (userId: string) => Promise<void>;
|
||||
regenerateSyncIds: (userId: string) => Promise<void>;
|
||||
removeSharedCopies: (userId: string) => Promise<void>;
|
||||
records: {
|
||||
list: (userId: string) => Promise<Map<string, SyncRecordInfo>>;
|
||||
put: (
|
||||
userId: string,
|
||||
type: string,
|
||||
syncId: string,
|
||||
values: { revision: number; hash: string | null; deleted: boolean },
|
||||
) => Promise<void>;
|
||||
setError: (
|
||||
userId: string,
|
||||
type: string,
|
||||
syncId: string,
|
||||
reason: string,
|
||||
hash: string,
|
||||
) => Promise<void>;
|
||||
clearErrors: (userId: string) => Promise<void>;
|
||||
listErrors: (userId: string) => Promise<SyncRecordInfo[]>;
|
||||
deleteForUser: (userId: string) => Promise<void>;
|
||||
key: (type: string, syncId: string) => string;
|
||||
};
|
||||
conflicts: {
|
||||
save: (
|
||||
userId: string,
|
||||
type: string,
|
||||
syncId: string,
|
||||
row: SyncRow,
|
||||
revision: number,
|
||||
) => Promise<void>;
|
||||
list: (userId: string) => Promise<SyncConflictInfo[]>;
|
||||
get: (userId: string, id: number) => Promise<SyncConflictInfo | null>;
|
||||
delete: (userId: string, id: number) => Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
/** The server side: what changed for an account and what a desktop pushed. */
|
||||
feed: {
|
||||
withUserLock: <T>(userId: string, run: () => Promise<T>) => Promise<T>;
|
||||
changes: (
|
||||
userId: string,
|
||||
cursor: number,
|
||||
limit: number,
|
||||
types?: string[],
|
||||
) => Promise<{
|
||||
records: Array<{
|
||||
seq: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}>;
|
||||
hasMore: boolean;
|
||||
nextCursor: number;
|
||||
entityTypes: Array<{
|
||||
type: string;
|
||||
owner: string;
|
||||
order: number;
|
||||
readOnly: boolean;
|
||||
}>;
|
||||
}>;
|
||||
parsePushOps: (body: unknown) => Promise<SyncPushOp[] | null>;
|
||||
applyPush: (userId: string, ops: SyncPushOp[]) => Promise<SyncPushResult[]>;
|
||||
/** Streams a change notice to the account's open event streams. */
|
||||
listen: (userId: string, res: unknown) => Promise<() => void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The linked server registry, kept in core so it is there before plugins.
|
||||
* One link per local user. Without a user, the active account is meant.
|
||||
*/
|
||||
links: {
|
||||
list: () => Promise<SyncLinkInfo[]>;
|
||||
get: (userId?: string) => Promise<SyncLinkInfo | null>;
|
||||
create: (link: SyncLinkCreate) => Promise<SyncLinkInfo>;
|
||||
update: (
|
||||
patch: SyncLinkPatch,
|
||||
userId?: string,
|
||||
) => Promise<SyncLinkInfo | null>;
|
||||
remove: (userId?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
/** Desktop accounts: the local user and the replica user of each server. */
|
||||
accounts: {
|
||||
list: () => Promise<SyncAccountInfo[]>;
|
||||
/** A new empty account, ready to be linked to a server. */
|
||||
create: () => Promise<string>;
|
||||
/** Which account the app signs in as from now on. */
|
||||
setActive: (userId: string) => Promise<void>;
|
||||
activeId: () => Promise<string | null>;
|
||||
/** Deletes an account and all its data. The last one cannot go. */
|
||||
remove: (userId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
http: {
|
||||
json: <T>(
|
||||
target: SyncRemoteTarget,
|
||||
pathname: string,
|
||||
request?: SyncRemoteRequest,
|
||||
) => Promise<T>;
|
||||
fetch: (
|
||||
target: SyncRemoteTarget,
|
||||
pathname: string,
|
||||
request?: SyncRemoteRequest,
|
||||
) => Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: AsyncIterable<Uint8Array> | null;
|
||||
}>;
|
||||
normalizeServerUrl: (input: string) => string | null;
|
||||
isRemoteError: (error: unknown) => error is SyncRemoteError;
|
||||
};
|
||||
|
||||
sessions: {
|
||||
/** A long lived session for a linked desktop. Shows in the account's sessions. */
|
||||
issueDesktop: (
|
||||
userId: string,
|
||||
options: { days: number; deviceInfo: string },
|
||||
) => Promise<string>;
|
||||
revoke: (sessionId: string) => Promise<void>;
|
||||
find: (
|
||||
sessionId: string,
|
||||
) => Promise<{ expiresAt: string; deviceInfo: string | null } | null>;
|
||||
findUser: (
|
||||
userId: string,
|
||||
) => Promise<{ id: string; username: string } | null>;
|
||||
/** The user and session behind a token, or null when it is not valid. */
|
||||
verify: (
|
||||
token: string,
|
||||
) => Promise<{ userId: string; sessionId?: string } | null>;
|
||||
/** Sets the sign in cookie on a response, like a login does. A name sets another cookie. */
|
||||
setCookie: (
|
||||
req: unknown,
|
||||
res: unknown,
|
||||
value: string,
|
||||
options?: { name?: string; days?: number },
|
||||
) => void;
|
||||
/** Removes the sign in cookie, without ending the session behind it. */
|
||||
clearCookie: (req: unknown, res: unknown, name?: string) => void;
|
||||
/** Express middleware that signs the request in and sets userId and sessionId. */
|
||||
middleware: () => unknown;
|
||||
/** Express middleware that lets a request through only on a desktop. */
|
||||
desktopOnly: () => unknown;
|
||||
};
|
||||
|
||||
hosts: {
|
||||
findForSync: (
|
||||
userId: string,
|
||||
syncId: string,
|
||||
) => Promise<{ id: number; name: string | null } | null>;
|
||||
statusBySyncId: (userId: string) => Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
plugins: {
|
||||
listForDesktop: () => Promise<{
|
||||
plugins: SyncRemotePlugin[];
|
||||
registryKeys: unknown;
|
||||
}>;
|
||||
/** Sends a plugin archive; false when there is no such plugin to share. */
|
||||
sendPackage: (pluginId: string, res: unknown) => Promise<boolean>;
|
||||
mirror: (link: SyncLinkInfo) => Promise<void>;
|
||||
lastRemote: () => SyncRemotePlugin[];
|
||||
localNames: () => Map<string, string>;
|
||||
};
|
||||
|
||||
desktop: {
|
||||
/** Tells the app's main process how to reach the linked origin. */
|
||||
applyProxy: (link: SyncLinkInfo | null) => Promise<void>;
|
||||
deviceName: () => string;
|
||||
};
|
||||
|
||||
alerts: {
|
||||
send: (alert: {
|
||||
title: string;
|
||||
body: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
category: string;
|
||||
dedupeKey: string;
|
||||
link?: { tab: string };
|
||||
}) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1140,36 +698,6 @@ export interface PluginHostCreateInput {
|
||||
sshOptions?: HostSshOptions | null;
|
||||
/** Host settings by plugin id, the shape a Termix export carries. */
|
||||
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. */
|
||||
@@ -1197,13 +725,6 @@ export const PLUGIN_HOST_INPUT_KEYS = [
|
||||
"forceKeyboardInteractive",
|
||||
"sshOptions",
|
||||
"pluginSettings",
|
||||
"pluginEnable",
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"keyType",
|
||||
"sudoPassword",
|
||||
"protocolAuth",
|
||||
] as const satisfies readonly (keyof PluginHostCreateInput)[];
|
||||
|
||||
/** The same shape canAccessHost returns internally, without secrets. */
|
||||
@@ -1240,34 +761,6 @@ export interface PluginShareableRole {
|
||||
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
|
||||
* needs to build a feature (like fleets) on top of hosts it does not own.
|
||||
@@ -1330,15 +823,6 @@ export interface PluginHosts {
|
||||
permissionLevel: PluginHostShareLevel,
|
||||
durationHours?: number,
|
||||
) => 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. */
|
||||
listUsers: () => Promise<PluginShareableUser[]>;
|
||||
/** Non-system roles the caller may pick as a share target. */
|
||||
@@ -1412,20 +896,6 @@ export interface PluginHosts {
|
||||
) => () => void;
|
||||
/** The recent activity list on the dashboard. */
|
||||
activity: PluginHostActivityApi;
|
||||
/** Read only hosts this plugin manages. */
|
||||
locks: PluginHostLocksApi;
|
||||
/** Folder rows for the acting user. Needs hosts:write. */
|
||||
folders: PluginHostFoldersApi;
|
||||
}
|
||||
|
||||
export interface PluginHostFoldersApi {
|
||||
/** Creates the folder row if missing and sets its color and icon. */
|
||||
ensure: (
|
||||
name: string,
|
||||
style?: { color?: string | null; icon?: string | null },
|
||||
) => Promise<void>;
|
||||
/** Removes the folder row and subfolders once no host is left in them. */
|
||||
removeEmpty: (name: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PluginHostImportOptions {
|
||||
@@ -1808,29 +1278,7 @@ export interface PluginNetwork {
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A way to reach some hosts other than a plain TCP connection. `claims` is
|
||||
* asked for every connection, so it must be quick. `open` returns a stream
|
||||
* the SSH client runs over, like a relayed socket.
|
||||
*/
|
||||
export interface PluginSshTransportProvider {
|
||||
claims: (hostId: number) => boolean;
|
||||
open: (host: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
}) => Promise<import("node:stream").Duplex>;
|
||||
}
|
||||
|
||||
export interface PluginSshTransports {
|
||||
/** Needs ssh:transport. Removed on deactivate. */
|
||||
provide: (provider: PluginSshTransportProvider) => () => void;
|
||||
}
|
||||
|
||||
export interface PluginSsh {
|
||||
/** Other ways to reach hosts, used by every connection to them. */
|
||||
transports: PluginSshTransports;
|
||||
|
||||
/**
|
||||
* Whether a TCP port answers through the host's jump host chain. Chains are
|
||||
* shared and closed when idle. The acting user must be able to connect to
|
||||
@@ -2237,54 +1685,6 @@ export interface PluginSecondFactor {
|
||||
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 {
|
||||
/** Why the token exists, kept in the audit log and on the session. */
|
||||
purpose: string;
|
||||
@@ -2374,10 +1774,6 @@ export interface PluginAuth {
|
||||
userId: string,
|
||||
options: PluginUserTokenOptions,
|
||||
) => 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. */
|
||||
@@ -2615,15 +2011,6 @@ export interface PluginSshKeyCredential {
|
||||
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. */
|
||||
export interface PluginSshKeyCredentialInput {
|
||||
name: string;
|
||||
@@ -2645,12 +2032,6 @@ export interface PluginCredentials {
|
||||
* Needs credentials:use and the core credentials.view permission. Audited.
|
||||
*/
|
||||
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
|
||||
* and returns its id. Needs credentials:write, the core credentials.create
|
||||
|
||||
@@ -47,9 +47,6 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("users:impersonate", "high"),
|
||||
entry("db:core-refs", "high"),
|
||||
entry("auth:provide", "high"),
|
||||
entry("auth:policy", "high"),
|
||||
entry("sync:provide", "high"),
|
||||
entry("ssh:transport", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
entry("ui:layout", "high"),
|
||||
@@ -64,7 +61,6 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("network:probe", "medium"),
|
||||
entry("hosts:status-provide", "medium"),
|
||||
entry("hosts:activity-provide", "medium"),
|
||||
entry("hosts:lock", "medium"),
|
||||
entry("users:read", "medium"),
|
||||
entry("events:core", "medium"),
|
||||
entry("notify:send", "medium"),
|
||||
|
||||
@@ -35,17 +35,6 @@ export interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
setZoomFactor?: (factor: number) => void;
|
||||
getPlatform: () => Promise<string>;
|
||||
/** Whether the app-drawn title bar shows in this window. */
|
||||
getTitleBarVisible?: () => Promise<boolean>;
|
||||
/** Calls back when the title bar shows or hides; returns an unsubscribe. */
|
||||
onTitleBarVisibleChange?: (
|
||||
callback: (visible: boolean) => void,
|
||||
) => () => void;
|
||||
/** Keeps the window controls the system draws in the colors of the theme. */
|
||||
setTitleBarOverlay?: (colors: {
|
||||
color?: string;
|
||||
symbolColor?: string;
|
||||
}) => Promise<void>;
|
||||
getSetting?: (key: string) => Promise<string | null | undefined>;
|
||||
setSetting?: (key: string, value: string) => Promise<void>;
|
||||
|
||||
|
||||
@@ -814,66 +814,6 @@ export interface SettingsComponentProps {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/** What a companion starts with. Read from the plugin's own settings. */
|
||||
export interface CompanionDefaults {
|
||||
/** Open by itself the first time a matching tab shows. */
|
||||
open?: boolean;
|
||||
side?: "left" | "right" | "bottom";
|
||||
/** Pixels of width, or height at the bottom. */
|
||||
size?: number;
|
||||
/** Show the edge button that opens and closes it. Default true. */
|
||||
edgeButton?: boolean;
|
||||
}
|
||||
|
||||
/** A panel a tab type can show beside it, for a picker. */
|
||||
export interface CompanionInfo {
|
||||
/** The id registerCompanionLayout's hidden and order lists use. */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon?: IconComponent;
|
||||
}
|
||||
|
||||
/** How a host wants the panel bar beside a tab. */
|
||||
export interface CompanionLayout {
|
||||
/** False removes the bar and its panels. */
|
||||
enabled?: boolean;
|
||||
/** Where a panel opens until the user moves it. */
|
||||
side?: "left" | "right" | "bottom";
|
||||
/** Companion ids in bar order. */
|
||||
order?: string[];
|
||||
/** Companion ids left out. */
|
||||
hidden?: string[];
|
||||
}
|
||||
|
||||
export interface CompanionProps {
|
||||
/** The tab it sits beside. */
|
||||
tab: PluginTabRecord;
|
||||
host?: PluginHostRecord;
|
||||
/** The host in the shape the connection APIs take. */
|
||||
sshHost?: Record<string, unknown>;
|
||||
/** The tab's surface handle (see TabHandle), read at call time. */
|
||||
getHandle: () => TabHandle | null;
|
||||
side: "left" | "right" | "bottom";
|
||||
/** False while the tab is in the background or the panel is closed. */
|
||||
isVisible: boolean;
|
||||
close: () => void;
|
||||
setSide: (side: "left" | "right" | "bottom") => void;
|
||||
}
|
||||
|
||||
export interface CompanionOptions {
|
||||
icon?: IconComponent;
|
||||
titleKey?: string;
|
||||
order?: number;
|
||||
/** Tab types it applies to. Any tab with a host when empty. */
|
||||
tabTypes?: string[];
|
||||
when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean;
|
||||
/**
|
||||
* A hook returning the starting state. It runs in the plugin's scope, so it
|
||||
* can read the plugin's settings.
|
||||
*/
|
||||
useDefaults?: () => CompanionDefaults;
|
||||
}
|
||||
|
||||
export type ActionHandler = (...args: never[]) => unknown;
|
||||
|
||||
export interface ActionSlotDefinition {
|
||||
@@ -1128,26 +1068,6 @@ export interface TermixApp extends TermixAppInfo {
|
||||
component: ComponentType<TabProps>,
|
||||
options?: TabOptions,
|
||||
) => Disposer;
|
||||
/**
|
||||
* A panel that sits beside any host tab the options match (a terminal, a
|
||||
* desktop, ...), bound to that tab's host. The user opens, resizes and moves
|
||||
* it; the tab keeps running underneath.
|
||||
*/
|
||||
registerCompanion: (
|
||||
id: string,
|
||||
component: ComponentType<CompanionProps>,
|
||||
options?: CompanionOptions,
|
||||
) => Disposer;
|
||||
/**
|
||||
* Lets a plugin decide, per host, how the panel bar looks on one tab type.
|
||||
* Read from the host payload, so it must not call hooks.
|
||||
*/
|
||||
registerCompanionLayout: (
|
||||
tabType: string,
|
||||
resolve: (host: PluginHostRecord | undefined) => CompanionLayout,
|
||||
) => Disposer;
|
||||
/** Opens a companion on a tab, or closes it when it is showing. */
|
||||
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
|
||||
registerHostEditorSection: (
|
||||
section: HostEditorSectionContribution,
|
||||
) => Disposer;
|
||||
@@ -1375,7 +1295,6 @@ export interface PluginHostBridge {
|
||||
hostProtocols: (host: PluginHostRecord) => string[];
|
||||
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
|
||||
useHostActions: () => HostActionContribution[];
|
||||
useCompanions: (tabType: string) => CompanionInfo[];
|
||||
useActivityTypes: () => string[];
|
||||
activityTarget: (type: string) => ActivityTargetInfo | undefined;
|
||||
useExtensions: (pointId: string) => ExtensionContribution[];
|
||||
@@ -1711,49 +1630,28 @@ export function usePluginUiPreferences<
|
||||
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
|
||||
}
|
||||
|
||||
type PanelGridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
|
||||
const PANEL_GRID_SIZES: readonly string[] = [
|
||||
"auto",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
];
|
||||
|
||||
/**
|
||||
* Grid or list, row density and cards per row for one of this plugin's
|
||||
* views, kept in its Appearance area as `<prefix>viewMode`, `<prefix>density`
|
||||
* and `<prefix>columns`. Declare those keys in contributes.uiPresets so each
|
||||
* preset level can pick a default. Hand columns to ViewToggle and DataView.
|
||||
* Grid or list and row density for one of this plugin's views, kept in its
|
||||
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those
|
||||
* keys in contributes.uiPresets so each preset level can pick a default.
|
||||
*/
|
||||
export function usePluginPanelView(prefix = ""): {
|
||||
view: "grid" | "list";
|
||||
density: "comfortable" | "compact";
|
||||
compact: boolean;
|
||||
columns: PanelGridSize;
|
||||
setView: (view: "grid" | "list") => void;
|
||||
setDensity: (density: "comfortable" | "compact") => void;
|
||||
setColumns: (columns: PanelGridSize) => void;
|
||||
} {
|
||||
const { values, set } = usePluginUiPreferences();
|
||||
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
|
||||
const density =
|
||||
values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
|
||||
const saved = values[`${prefix}columns`];
|
||||
const columns =
|
||||
typeof saved === "string" && PANEL_GRID_SIZES.includes(saved)
|
||||
? (saved as PanelGridSize)
|
||||
: "auto";
|
||||
return {
|
||||
view,
|
||||
density,
|
||||
compact: density === "compact",
|
||||
columns,
|
||||
setView: (next) => set(`${prefix}viewMode`, next),
|
||||
setDensity: (next) => set(`${prefix}density`, next),
|
||||
setColumns: (next) => set(`${prefix}columns`, next),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1806,14 +1704,6 @@ export function useHostActions(): HostActionContribution[] {
|
||||
return requireHost().useHostActions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every panel that can open beside a tab of this type, for a settings screen
|
||||
* that lets the user hide or reorder them. Reactive.
|
||||
*/
|
||||
export function useCompanions(tabType: string): CompanionInfo[] {
|
||||
return requireHost().useCompanions(tabType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent-activity types every registered tab claims (registerTab's
|
||||
* activityTypes), core's and plugins'. For a filter that lists what a user
|
||||
|
||||
@@ -37,7 +37,7 @@ export {
|
||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||
* version by the server.
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = "1.12.0";
|
||||
export const PLUGIN_API_VERSION = "1.5.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
|
||||
@@ -68,7 +68,7 @@ import type {
|
||||
PluginSshKeyCredential,
|
||||
PluginSshKeyCredentialInput,
|
||||
} from "./backend.js";
|
||||
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
|
||||
import type { SyncEntityRegistration } from "./backend.js";
|
||||
import type { PluginDatabase } from "./backend.js";
|
||||
import type {
|
||||
PluginExternalClientRequest,
|
||||
@@ -76,10 +76,6 @@ import type {
|
||||
PluginHostProbeTarget,
|
||||
PluginHostStatusEntry,
|
||||
PluginActivityProvider,
|
||||
PluginHostLockProvider,
|
||||
PluginAuthPolicyProvider,
|
||||
PluginCredentialSummary,
|
||||
PluginHostShareGrant,
|
||||
PluginHostStatusProvider,
|
||||
PluginNotification,
|
||||
PluginNotificationChannel,
|
||||
@@ -104,8 +100,6 @@ export interface FakeScheduledJob {
|
||||
|
||||
export interface FakeContextOptions {
|
||||
pluginId?: string;
|
||||
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||
syncHost?: Partial<PluginSyncHost>;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Acting user returned by currentActor and used by asUser. */
|
||||
actor?: string;
|
||||
@@ -146,8 +140,6 @@ export interface FakeContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.credentials.listSshKeys answers. */
|
||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||
/** What ctx.credentials.list answers. */
|
||||
credentialSummaries?: PluginCredentialSummary[];
|
||||
/** What ctx.desktop.available() answers. Defaults to false. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.desktop.local is. Calls throw when this is not given. */
|
||||
@@ -198,8 +190,6 @@ export interface FakeContextOptions {
|
||||
baseUrl?: string;
|
||||
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
|
||||
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. */
|
||||
loginAttemptLimit?: number;
|
||||
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
|
||||
@@ -279,8 +269,6 @@ export interface FakeAuthRegistrations {
|
||||
loginFailures: Map<string, number>;
|
||||
/** Every ctx.auth.issueUserToken call, in order. */
|
||||
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
|
||||
/** Providers passed to ctx.auth.policy.provide that are still registered. */
|
||||
policies: PluginAuthPolicyProvider[];
|
||||
}
|
||||
|
||||
export interface FakePluginContext {
|
||||
@@ -297,8 +285,6 @@ export interface FakePluginContext {
|
||||
hostStatusProviders: PluginHostStatusProvider[];
|
||||
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */
|
||||
activityProviders: PluginActivityProvider[];
|
||||
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
|
||||
hostLockProviders: PluginHostLockProvider[];
|
||||
/** Topics emitted through ctx.events.emit, in order. */
|
||||
emitted: Array<{ topic: string; payload: unknown }>;
|
||||
/** Backing store behind ctx.kv. */
|
||||
@@ -331,8 +317,6 @@ export interface FakePluginContext {
|
||||
permissionLevel: string;
|
||||
durationHours?: number;
|
||||
}>;
|
||||
/** Every ctx.hosts.setShares call, in order. */
|
||||
hostShareSets: Array<{ hostId: number; grants: PluginHostShareGrant[] }>;
|
||||
/** Everything registered through ctx.auth. */
|
||||
auth: FakeAuthRegistrations;
|
||||
/** Every ctx.desktop.openIsolatedWindow call, in order. */
|
||||
@@ -504,14 +488,7 @@ function applyHostInput(
|
||||
const {
|
||||
tags,
|
||||
pluginSettings: _settings,
|
||||
pluginEnable: _pluginEnable,
|
||||
forceKeyboardInteractive: _fki,
|
||||
password: _password,
|
||||
key: _key,
|
||||
keyPassword: _keyPassword,
|
||||
keyType: _keyType,
|
||||
sudoPassword: _sudoPassword,
|
||||
protocolAuth: _protocolAuth,
|
||||
...rest
|
||||
} = input;
|
||||
const next: PluginHostRecord = { ...record };
|
||||
@@ -540,7 +517,6 @@ export function createFakeContext(
|
||||
const emitted: Array<{ topic: string; payload: unknown }> = [];
|
||||
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
||||
const activityProviders: PluginActivityProvider[] = [];
|
||||
const hostLockProviders: PluginHostLockProvider[] = [];
|
||||
const kv = new Map<string, unknown>();
|
||||
const tables: PluginTableDefinition[] = [];
|
||||
const syncEntities: SyncEntityRegistration[] = [];
|
||||
@@ -558,7 +534,6 @@ export function createFakeContext(
|
||||
}>();
|
||||
const sshConnections: FakePluginContext["sshConnections"] = [];
|
||||
const hostShares: FakePluginContext["hostShares"] = [];
|
||||
const hostShareSets: FakePluginContext["hostShareSets"] = [];
|
||||
const desktopWindows: FakePluginContext["desktopWindows"] = [];
|
||||
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
|
||||
[];
|
||||
@@ -631,7 +606,6 @@ export function createFakeContext(
|
||||
revokedSessions: [],
|
||||
loginFailures: new Map(),
|
||||
userTokens: [],
|
||||
policies: [],
|
||||
};
|
||||
const sshClient = options.sshClient ?? {};
|
||||
let actor = options.actor;
|
||||
@@ -758,12 +732,6 @@ export function createFakeContext(
|
||||
registerEntity: (entity) => {
|
||||
syncEntities.push(entity);
|
||||
},
|
||||
provide: () => () => {},
|
||||
get host() {
|
||||
if (!options.syncHost)
|
||||
throw new Error("Pass syncHost to use ctx.sync.host");
|
||||
return options.syncHost as PluginSyncHost;
|
||||
},
|
||||
},
|
||||
|
||||
registry: {
|
||||
@@ -1036,10 +1004,6 @@ export function createFakeContext(
|
||||
hostShares.push({ hostId, targets, permissionLevel, durationHours });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
setShares: async (hostId, grants) => {
|
||||
hostShareSets.push({ hostId, grants: [...grants] });
|
||||
return { hostId, shared: true };
|
||||
},
|
||||
listUsers: async () => options.shareableUsers ?? [],
|
||||
listRoles: async () => options.shareableRoles ?? [],
|
||||
trackSession: (hostId) => {
|
||||
@@ -1070,19 +1034,6 @@ export function createFakeContext(
|
||||
};
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
ensure: async () => {},
|
||||
removeEmpty: async () => true,
|
||||
},
|
||||
locks: {
|
||||
provide: (provider) => {
|
||||
hostLockProviders.push(provider);
|
||||
return () => {
|
||||
const at = hostLockProviders.indexOf(provider);
|
||||
if (at >= 0) hostLockProviders.splice(at, 1);
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
provide: (provider) => {
|
||||
hostStatusProviders.push(provider);
|
||||
@@ -1152,7 +1103,6 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
ssh: {
|
||||
transports: { provide: () => () => {} },
|
||||
probe: async (hostId, probeOptions) =>
|
||||
options.reachable?.(hostId, probeOptions?.port) ?? false,
|
||||
connect: async (host, connectOptions) => {
|
||||
@@ -1316,22 +1266,6 @@ export function createFakeContext(
|
||||
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: {
|
||||
@@ -1352,7 +1286,6 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
credentials: {
|
||||
list: async () => [...(options.credentialSummaries ?? [])],
|
||||
listSshKeys: async () => [
|
||||
...(options.sshKeyCredentials ?? []),
|
||||
...createdSshKeys.map((key) => ({
|
||||
@@ -1535,7 +1468,6 @@ export function createFakeContext(
|
||||
disposals,
|
||||
hostStatusProviders,
|
||||
activityProviders,
|
||||
hostLockProviders,
|
||||
emitted,
|
||||
kv,
|
||||
tables,
|
||||
@@ -1546,7 +1478,6 @@ export function createFakeContext(
|
||||
coreSettings,
|
||||
sshConnections,
|
||||
hostShares,
|
||||
hostShareSets,
|
||||
auth,
|
||||
desktopWindows,
|
||||
externalClientLaunches,
|
||||
@@ -1595,8 +1526,6 @@ export function createFakeContext(
|
||||
|
||||
export interface MockContextOptions {
|
||||
pluginId?: string;
|
||||
/** What ctx.sync.host answers, for a sync provider's tests. */
|
||||
syncHost?: Partial<PluginSyncHost>;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
/** Capabilities the plugin is granted. Anything else throws. */
|
||||
capabilities?: string[];
|
||||
@@ -1631,8 +1560,6 @@ export interface MockContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.credentials.listSshKeys serves. */
|
||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||
/** What ctx.credentials.list serves. */
|
||||
credentialSummaries?: PluginCredentialSummary[];
|
||||
/** What ctx.desktop.available() answers. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.desktop.local is. See FakeContextOptions. */
|
||||
@@ -1671,8 +1598,6 @@ export interface MockContextOptions {
|
||||
/** See FakeContextOptions. */
|
||||
loginAttemptLimit?: number;
|
||||
/** See FakeContextOptions. */
|
||||
secondFactors?: FakeContextOptions["secondFactors"];
|
||||
/** See FakeContextOptions. */
|
||||
locales?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -1759,7 +1684,6 @@ export function createMockCtx(
|
||||
|
||||
const base = createFakeContext({
|
||||
pluginId: options.pluginId,
|
||||
syncHost: options.syncHost,
|
||||
actor: options.actor,
|
||||
settings: options.settings,
|
||||
coreSettings: options.coreSettings,
|
||||
@@ -1775,7 +1699,6 @@ export function createMockCtx(
|
||||
services: options.services,
|
||||
protocolTargets: options.protocolTargets,
|
||||
sshKeyCredentials: options.sshKeyCredentials,
|
||||
credentialSummaries: options.credentialSummaries,
|
||||
desktopAvailable: options.desktopAvailable,
|
||||
desktopLocal: options.desktopLocal,
|
||||
auditEntries: options.auditEntries,
|
||||
@@ -1793,7 +1716,6 @@ export function createMockCtx(
|
||||
baseUrl: options.baseUrl,
|
||||
linkedUsers: options.linkedUsers,
|
||||
loginAttemptLimit: options.loginAttemptLimit,
|
||||
secondFactors: options.secondFactors,
|
||||
locales: options.locales,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
@@ -1978,10 +1900,6 @@ export function createMockCtx(
|
||||
require("hosts:write");
|
||||
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
|
||||
},
|
||||
setShares: async (hostId, grants) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.setShares(hostId, grants);
|
||||
},
|
||||
listUsers: async () => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.listUsers();
|
||||
@@ -2012,22 +1930,6 @@ export function createMockCtx(
|
||||
return ctx.hosts.activity.provide(provider);
|
||||
},
|
||||
},
|
||||
folders: {
|
||||
ensure: async (name, style) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.folders.ensure(name, style);
|
||||
},
|
||||
removeEmpty: async (name) => {
|
||||
require("hosts:write");
|
||||
return ctx.hosts.folders.removeEmpty(name);
|
||||
},
|
||||
},
|
||||
locks: {
|
||||
provide: (provider) => {
|
||||
require("hosts:lock");
|
||||
return ctx.hosts.locks.provide(provider);
|
||||
},
|
||||
},
|
||||
status: {
|
||||
provide: (provider) => {
|
||||
require("hosts:read");
|
||||
@@ -2055,12 +1957,6 @@ export function createMockCtx(
|
||||
|
||||
ssh: {
|
||||
...ctx.ssh,
|
||||
transports: {
|
||||
provide: (provider) => {
|
||||
require("ssh:transport");
|
||||
return ctx.ssh.transports.provide(provider);
|
||||
},
|
||||
},
|
||||
probe: async (hostId, probeOptions) => {
|
||||
require("ssh:connect");
|
||||
return ctx.ssh.probe(hostId, probeOptions);
|
||||
@@ -2161,13 +2057,6 @@ export function createMockCtx(
|
||||
require("users:impersonate");
|
||||
return ctx.auth.issueUserToken(userId, tokenOptions);
|
||||
},
|
||||
policy: {
|
||||
provide: (provider) => {
|
||||
require("auth:policy");
|
||||
return ctx.auth.policy.provide(provider);
|
||||
},
|
||||
},
|
||||
listSecondFactors: () => ctx.auth.listSecondFactors(),
|
||||
},
|
||||
|
||||
users: {
|
||||
@@ -2209,10 +2098,6 @@ export function createMockCtx(
|
||||
},
|
||||
|
||||
credentials: {
|
||||
list: async () => {
|
||||
require("credentials:use");
|
||||
return ctx.credentials.list();
|
||||
},
|
||||
listSshKeys: async () => {
|
||||
require("credentials:use");
|
||||
return ctx.credentials.listSshKeys();
|
||||
|
||||
@@ -139,7 +139,6 @@ const offline = () =>
|
||||
}),
|
||||
hostProtocols: () => ["ssh"],
|
||||
useHostActions: () => [],
|
||||
useCompanions: () => [],
|
||||
useActivityTypes: () => [],
|
||||
activityTarget: () => undefined,
|
||||
useExtensions: () => [],
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
|
||||
*/
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -122,28 +121,6 @@ export function pluginVitestConfig(
|
||||
// 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 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 = {
|
||||
alias,
|
||||
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
|
||||
|
||||
@@ -22,7 +22,7 @@ AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<H
|
||||
ApiKey (type): ApiKey
|
||||
AuditLog (type): AuditLog
|
||||
AuditLogFilters (type): AuditLogFilters
|
||||
AuthEnrollmentSections (value): ({ factorsOnly, }?: { factorsOnly?: boolean; }) => Element
|
||||
AuthEnrollmentSections (value): () => Element
|
||||
AuthOverrideProtocol (type): string
|
||||
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
|
||||
@@ -80,7 +80,7 @@ DEFAULT_FOLDER_COLOR (value): "#f59145"
|
||||
DEFAULT_FOLDER_ICON (value): "folder"
|
||||
DashboardCardId (type): DashboardCardId
|
||||
DataColumn (type): DataColumn<T>
|
||||
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
|
||||
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
|
||||
DefaultOverrides (type): DefaultOverrides
|
||||
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
DesktopUpdateMode (type): DesktopUpdateMode
|
||||
@@ -131,7 +131,6 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
|
||||
GridColSpan (type): GridColSpan
|
||||
GridColumns (type): GridColumns
|
||||
GridLayout (type): GridLayout
|
||||
GridSize (type): GridSize
|
||||
GridSlot (type): GridSlot
|
||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
|
||||
@@ -152,9 +151,7 @@ HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
HostFolder (type): HostFolder
|
||||
HostGroupKey (type): HostGroupKey
|
||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||
HostLock (type): HostLock
|
||||
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
|
||||
HostPolicy (type): HostPolicy
|
||||
HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||
HostProtocolDef (type): HostProtocolDef
|
||||
HostProtocols (type): HostProtocols
|
||||
@@ -162,7 +159,6 @@ HostRowFields (type): HostRowFields
|
||||
HostSidebarFilterState (type): HostSidebarFilterState
|
||||
HostSidebarPreferences (type): HostSidebarPreferences
|
||||
HostTrayTrigger (type): HostTrayTrigger
|
||||
HttpsSettings (type): HttpsSettings
|
||||
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
|
||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||
@@ -177,6 +173,7 @@ LANGUAGES (value): readonly { code: string; label: string; }[]
|
||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
LinkServerDialog (value): ({ open, onOpenChange, onLinked, relogin, }: LinkServerDialogProps) => Element
|
||||
LinkedAccountInfo (type): LinkedAccountInfo
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
@@ -197,8 +194,6 @@ ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value
|
||||
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
|
||||
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
|
||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
|
||||
NativeViewProps (type): NativeViewProps
|
||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
|
||||
OpenTabRecord (type): OpenTabRecord
|
||||
@@ -229,6 +224,7 @@ PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDiv
|
||||
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ProxyNode (type): ProxyNode
|
||||
ProxySettingsFields (value): ({ customHeaders, basicAuth, onChange, }: ProxySettingsFieldsProps) => Element
|
||||
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||
RadialGaugeProps (type): RadialGaugeProps
|
||||
RailGroup (type): RailGroup
|
||||
@@ -289,6 +285,12 @@ SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
SyncBasicAuth (type): SyncBasicAuth
|
||||
SyncConflictItem (type): SyncConflictItem
|
||||
SyncEntityInfo (type): SyncEntityInfo
|
||||
SyncErrorItem (type): SyncErrorItem
|
||||
SyncProxyHeader (type): SyncProxyHeader
|
||||
SyncStatus (type): SyncStatus
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
@@ -322,7 +324,7 @@ UserRole (type): UserRole
|
||||
UserSecondFactorSummary (type): UserSecondFactorSummary
|
||||
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
|
||||
VersionInfo (type): VersionInfo
|
||||
ViewToggle (value): ({ view, onView, density, onDensity, columns, onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; columns?: GridSize; onColumns?: (next: GridSize) => void; }) => Element
|
||||
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
|
||||
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
||||
actionOrder (value): (action: HostActionDef) => number
|
||||
@@ -409,10 +411,8 @@ getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId
|
||||
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
|
||||
getHostEditorSection (value): (id: string) => HostEditorSectionDef
|
||||
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||
getHostPolicy (value): () => Promise<HostPolicy>
|
||||
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
|
||||
getHostTags (value): () => Promise<string[]>
|
||||
getHttpsSettings (value): () => Promise<HttpsSettings>
|
||||
getLiveHostStatus (value): (hostId: number) => StatusValue
|
||||
getLogLevel (value): () => Promise<{ level: string; }>
|
||||
getNotificationPrivateEndpoints (value): () => Promise<string[]>
|
||||
@@ -438,6 +438,8 @@ getSessionTimeoutMinutes (value): () => Promise<number>
|
||||
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
|
||||
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
|
||||
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
|
||||
getSyncConflicts (value): () => Promise<SyncConflictItem[]>
|
||||
getSyncErrors (value): () => Promise<SyncErrorItem[]>
|
||||
getTabType (value): (id: string) => TabTypeDef
|
||||
getTlsStatus (value): () => Promise<TlsStatus>
|
||||
getUpdateChannel (value): () => Promise<UpdateChannelState>
|
||||
@@ -502,6 +504,7 @@ quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef
|
||||
railItemLabel (value): (id: string, t: (key: string) => string) => string
|
||||
readFromClipboard (value): () => Promise<string>
|
||||
recordHostActionPreference (value): (hostId: string, action: TabType) => void
|
||||
refreshSyncStatus (value): () => void
|
||||
registerLayout (value): (item: RegisteredLayout) => () => void
|
||||
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
|
||||
releaseUrlFrom (value): (info: VersionInfo) => string
|
||||
@@ -524,6 +527,7 @@ resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaul
|
||||
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
|
||||
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
retrySyncErrors (value): () => Promise<SyncStatus>
|
||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
||||
@@ -547,22 +551,25 @@ setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||
setSyncStatus (value): (next: SyncStatus) => void
|
||||
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
|
||||
settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise<void>
|
||||
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
|
||||
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
|
||||
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
|
||||
shell (value): TabShellCallbacks
|
||||
splitDefaultKey (value): (fullKey: string) => [string, string]
|
||||
sshHostToHost (value): (h: SSHHostWithStatus) => Host
|
||||
syncNow (value): () => Promise<SyncStatus>
|
||||
tabIcon (value): (type: TabType) => Element
|
||||
tabJumpHotkeyKeys (value): () => string[]
|
||||
takePendingManageRequest (value): () => ManageRequest
|
||||
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
unlinkServer (value): (keepData: boolean) => Promise<SyncStatus>
|
||||
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => 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>
|
||||
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>
|
||||
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||
@@ -575,6 +582,7 @@ updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
|
||||
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
|
||||
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
|
||||
updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise<SyncStatus>
|
||||
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
|
||||
usageBarColor (value): (percent: number) => string
|
||||
usageColor (value): (percent: number) => string
|
||||
@@ -587,7 +595,6 @@ useChooseTheme (value): () => (themeId: string) => void
|
||||
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
||||
useConnectionGate (value): () => ConnectionGateController
|
||||
useConnectionLog (value): () => ConnectionLogContextType
|
||||
useDesktopLinked (value): () => boolean
|
||||
useDesktopUpdate (value): () => DesktopUpdateState
|
||||
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
|
||||
useHostActions (value): () => HostActionDef[]
|
||||
@@ -622,6 +629,7 @@ useSshAuthEditors (value): () => SshAuthEditorDef[]
|
||||
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
|
||||
useStatusColorScheme (value): () => StatusColorScheme
|
||||
useSurfaceKind (value): () => SurfaceKind
|
||||
useSyncStatus (value): () => any
|
||||
useTabHandlesVersion (value): () => number
|
||||
useTabs (value): () => TabContextType
|
||||
useTabsSafe (value): () => TabContextType
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
*
|
||||
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||
* --profile minimal|standard|full (or TERMIX_PLUGIN_PROFILE, default standard)
|
||||
* picks which tiers of the file are staged.
|
||||
* Each .tmxplug is downloaded (or read from a path), checked against its
|
||||
* pinned sha256 and unpacked.
|
||||
*
|
||||
@@ -27,16 +25,13 @@ const {
|
||||
localSdkBuildCommand,
|
||||
copyLocalBuild,
|
||||
buildBundledIndex,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const destination = path.join(root, "dist", "plugins");
|
||||
|
||||
async function main() {
|
||||
const profile = resolveProfile();
|
||||
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
|
||||
const plugins = loadBundledPlugins(root);
|
||||
const flag = process.argv.indexOf("--local");
|
||||
const localArg =
|
||||
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
||||
@@ -79,7 +74,7 @@ async function main() {
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`,
|
||||
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ import {
|
||||
localSdkBuildCommand,
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
} from "./lib/bundled-plugins.cjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
@@ -265,52 +263,10 @@ describe("pinsFromIndex", () => {
|
||||
source: "tmxplug",
|
||||
url: "https://example.test/beta-1.1.0.tmxplug",
|
||||
sha256: "b".repeat(64),
|
||||
tier: "full",
|
||||
});
|
||||
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the tier of an existing pin", () => {
|
||||
const pins = pinsFromIndex(index, null, [
|
||||
{ id: "alpha", tier: "standard" },
|
||||
]);
|
||||
expect(pins[0].tier).toBe("standard");
|
||||
expect(pins[1].tier).toBe("full");
|
||||
});
|
||||
|
||||
it("skips releases whose engine.termix excludes the core version", () => {
|
||||
const withEngine = {
|
||||
plugins: [
|
||||
{
|
||||
id: "gamma",
|
||||
versions: [
|
||||
{
|
||||
version: "2.0.0",
|
||||
url: "https://example.test/gamma-2.0.0.tmxplug",
|
||||
sha256: "d".repeat(64),
|
||||
engine: { api: "^1", termix: ">=26.10.6" },
|
||||
},
|
||||
{
|
||||
version: "1.0.0",
|
||||
url: "https://example.test/gamma-1.0.0.tmxplug",
|
||||
sha256: "e".repeat(64),
|
||||
engine: { api: "^1", termix: ">=26.10.0" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(pinsFromIndex(withEngine, null, [], "26.10.5")[0].url).toBe(
|
||||
"https://example.test/gamma-1.0.0.tmxplug",
|
||||
);
|
||||
expect(pinsFromIndex(withEngine, null, [], "26.10.6")[0].url).toBe(
|
||||
"https://example.test/gamma-2.0.0.tmxplug",
|
||||
);
|
||||
expect(pinsFromIndex(withEngine, null)[0].url).toBe(
|
||||
"https://example.test/gamma-2.0.0.tmxplug",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the onboarding defaults of an existing pin", () => {
|
||||
const current = [
|
||||
{ id: "beta", onboarding: { recommended: true, consent: false } },
|
||||
@@ -555,61 +511,3 @@ describe("default look", () => {
|
||||
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin profiles", () => {
|
||||
const entry = (id: string, tier?: string) => ({
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: `https://x/${id}.tmxplug`,
|
||||
sha256: SHA,
|
||||
...(tier ? { tier } : {}),
|
||||
});
|
||||
const { plugins } = parseBundledPlugins({
|
||||
plugins: [
|
||||
entry("look", "minimal"),
|
||||
entry("term", "standard"),
|
||||
entry("extra", "full"),
|
||||
entry("untiered"),
|
||||
],
|
||||
});
|
||||
const ids = (profile: string) =>
|
||||
pluginsForProfile(plugins, profile).map((p: { id: string }) => p.id);
|
||||
|
||||
it("ships each tier and every smaller one", () => {
|
||||
expect(ids("minimal")).toEqual(["look"]);
|
||||
expect(ids("standard")).toEqual(["look", "term"]);
|
||||
expect(ids("full")).toEqual(["look", "term", "extra", "untiered"]);
|
||||
});
|
||||
|
||||
it("rejects an unknown tier", () => {
|
||||
const { problems } = parseBundledPlugins({ plugins: [entry("x", "huge")] });
|
||||
expect(problems[0]).toMatch(/tier must be one of/);
|
||||
});
|
||||
|
||||
it("reads the profile from the flag, then the environment", () => {
|
||||
expect(resolveProfile(["node", "x"], {})).toBe("standard");
|
||||
expect(
|
||||
resolveProfile(["node", "x"], { TERMIX_PLUGIN_PROFILE: "full" }),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveProfile(["node", "x", "--profile", "minimal"], {
|
||||
TERMIX_PLUGIN_PROFILE: "full",
|
||||
}),
|
||||
).toBe("minimal");
|
||||
expect(() =>
|
||||
resolveProfile(["node", "x", "--profile", "tiny"], {}),
|
||||
).toThrow(/unknown plugin profile/);
|
||||
});
|
||||
|
||||
it("keeps the real file valid with a default look in the minimal tier", () => {
|
||||
const real = loadBundledPlugins(path.resolve(__dirname, ".."));
|
||||
const minimal = pluginsForProfile(real, "minimal");
|
||||
expect(minimal.length).toBeGreaterThan(0);
|
||||
expect(minimal.every((p: { defaultLook: boolean }) => p.defaultLook)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(pluginsForProfile(real, "standard").length).toBeLessThan(
|
||||
real.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Buttons are never white.
|
||||
*
|
||||
* Every Button variant is the brand orange with a different background
|
||||
* opacity (or red for destructive, or transparent for ghost). Fails when a
|
||||
* variant in src/ui/components/button.tsx uses a neutral fill, or when a
|
||||
* <Button> anywhere in core or the local plugin checkouts sets a neutral fill
|
||||
* in its className.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const NEUTRAL =
|
||||
/(?:^|[\s"'`])(?:dark:)?(?:bg-(?:primary|white|foreground|background|secondary|card|popover|muted|accent|input|neutral-\d+|zinc-\d+|gray-\d+|slate-\d+|stone-\d+)(?![\w-])|text-primary-foreground)/;
|
||||
|
||||
/** The opening tag text of each <Button ...> in a source file. */
|
||||
function buttonTags(source) {
|
||||
const tags = [];
|
||||
const re = /<Button(?=[\s/>])/g;
|
||||
let match;
|
||||
while ((match = re.exec(source))) {
|
||||
let depth = 0;
|
||||
let quote = "";
|
||||
let i = match.index + match[0].length;
|
||||
for (; i < source.length; i++) {
|
||||
const c = source[i];
|
||||
if (quote) {
|
||||
if (c === "\\") i++;
|
||||
else if (c === quote) quote = "";
|
||||
} else if (c === '"' || c === "'" || c === "`") quote = c;
|
||||
else if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
else if (c === ">" && depth === 0) break;
|
||||
}
|
||||
tags.push(source.slice(match.index, i + 1));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Variant strings from the cva block of button.tsx. */
|
||||
function variantLines(source) {
|
||||
const start = source.indexOf("variant: {");
|
||||
const end = source.indexOf("size: {");
|
||||
if (start < 0 || end < 0) return [];
|
||||
return source
|
||||
.slice(start, end)
|
||||
.split("\n")
|
||||
.filter((line) => /["`]/.test(line) && !line.includes("variant: {"));
|
||||
}
|
||||
|
||||
function walk(dir, out) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (["node_modules", "dist", "tests", "__fixtures__"].includes(entry.name))
|
||||
continue;
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, out);
|
||||
else if (entry.name.endsWith(".tsx")) out.push(full);
|
||||
}
|
||||
}
|
||||
|
||||
/** Problems found under the given roots. */
|
||||
function scan(roots, buttonFile) {
|
||||
const problems = [];
|
||||
if (buttonFile && fs.existsSync(buttonFile)) {
|
||||
for (const line of variantLines(fs.readFileSync(buttonFile, "utf8"))) {
|
||||
if (NEUTRAL.test(line)) {
|
||||
problems.push(
|
||||
`${buttonFile}: neutral fill in a variant: ${line.trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const files = [];
|
||||
for (const root of roots) walk(root, files);
|
||||
for (const file of files) {
|
||||
for (const tag of buttonTags(fs.readFileSync(file, "utf8"))) {
|
||||
if (NEUTRAL.test(tag)) {
|
||||
problems.push(
|
||||
`${file}: <Button> with a neutral fill: ${tag.slice(0, 120)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
module.exports = { scan, buttonTags, variantLines };
|
||||
|
||||
if (require.main === module) {
|
||||
const core = path.resolve(__dirname, "..");
|
||||
const plugins = path.resolve(core, "..", "Plugins");
|
||||
const roots = [path.join(core, "src", "ui")];
|
||||
if (fs.existsSync(plugins)) {
|
||||
for (const entry of fs.readdirSync(plugins)) {
|
||||
roots.push(path.join(plugins, entry, "src"));
|
||||
}
|
||||
}
|
||||
const problems = scan(
|
||||
roots,
|
||||
path.join(core, "src", "ui", "components", "button.tsx"),
|
||||
);
|
||||
if (problems.length) {
|
||||
console.error("Buttons must be the brand orange, never white or neutral:");
|
||||
for (const p of problems) console.error(" " + p);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("ok button colors");
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { scan, buttonTags } = require("./check-button-colors.cjs") as {
|
||||
scan: (roots: string[], buttonFile?: string) => string[];
|
||||
buttonTags: (source: string) => string[];
|
||||
};
|
||||
|
||||
let root: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
root = null;
|
||||
});
|
||||
|
||||
function fixture(files: Record<string, string>): string {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "button-colors-"));
|
||||
for (const [file, content] of Object.entries(files)) {
|
||||
const full = path.join(root, file);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("buttonTags", () => {
|
||||
it("reads a tag with arrow functions inside", () => {
|
||||
const tags = buttonTags(
|
||||
`<Button onClick={() => go(a > b)} className="x">Go</Button>`,
|
||||
);
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0]).toContain('className="x"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("check-button-colors", () => {
|
||||
it("accepts orange buttons and hover fills", () => {
|
||||
const dir = fixture({
|
||||
"a.tsx": `<Button className="bg-accent-brand/15 text-accent-brand hover:bg-muted" />`,
|
||||
});
|
||||
expect(scan([dir])).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a white or neutral fill on a Button", () => {
|
||||
for (const cls of [
|
||||
"bg-primary",
|
||||
"bg-white",
|
||||
"bg-foreground text-background",
|
||||
"dark:bg-secondary",
|
||||
"bg-muted",
|
||||
"text-primary-foreground",
|
||||
]) {
|
||||
const dir = fixture({ "a.tsx": `<Button className="${cls}">x</Button>` });
|
||||
expect(scan([dir]), cls).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a neutral fill in a variant", () => {
|
||||
const dir = fixture({
|
||||
"button.tsx": `variant: {\n default: "bg-primary text-primary-foreground",\n ghost: "hover:bg-muted",\n},\nsize: {`,
|
||||
});
|
||||
expect(scan([], path.join(dir, "button.tsx"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("passes on the real button", () => {
|
||||
const button = path.resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"ui",
|
||||
"components",
|
||||
"button.tsx",
|
||||
);
|
||||
expect(scan([], button)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -151,7 +151,6 @@ const ALLOWED = [
|
||||
"GridCardCatalogEntry",
|
||||
"GridColSpan",
|
||||
"GridColumns",
|
||||
"GridSize",
|
||||
"GridLayout",
|
||||
"GridSlot",
|
||||
"GroupHeading",
|
||||
@@ -169,9 +168,6 @@ const ALLOWED = [
|
||||
"HostDefaultsTarget",
|
||||
"HostDensity",
|
||||
"HostFeatureFields",
|
||||
"HostLock",
|
||||
"HostPolicy",
|
||||
"HttpsSettings",
|
||||
"HostFeatureFieldsProps",
|
||||
"HostFolder",
|
||||
"HostGroupKey",
|
||||
@@ -198,6 +194,7 @@ const ALLOWED = [
|
||||
"Label",
|
||||
"LineChart",
|
||||
"LineChartSeries",
|
||||
"LinkServerDialog",
|
||||
"LinkedAccountInfo",
|
||||
"ListBadge",
|
||||
"ListRow",
|
||||
@@ -218,8 +215,6 @@ const ALLOWED = [
|
||||
"Meter",
|
||||
"MetricCard",
|
||||
"MiniStat",
|
||||
"NativeView",
|
||||
"NativeViewProps",
|
||||
"NumberField",
|
||||
"OPEN_PLUGINS_EVENT",
|
||||
"OpenTabRecord",
|
||||
@@ -250,6 +245,7 @@ const ALLOWED = [
|
||||
"PopoverContent",
|
||||
"PopoverTrigger",
|
||||
"ProxyNode",
|
||||
"ProxySettingsFields",
|
||||
"RadialGauge",
|
||||
"RadialGaugeProps",
|
||||
"RailGroup",
|
||||
@@ -310,6 +306,12 @@ const ALLOWED = [
|
||||
"SurfaceScope",
|
||||
"Switch",
|
||||
"SwitchRow",
|
||||
"SyncBasicAuth",
|
||||
"SyncConflictItem",
|
||||
"SyncEntityInfo",
|
||||
"SyncErrorItem",
|
||||
"SyncProxyHeader",
|
||||
"SyncStatus",
|
||||
"TOTPDialog",
|
||||
"Tab",
|
||||
"TabShellCallbacks",
|
||||
@@ -430,11 +432,9 @@ const ALLOWED = [
|
||||
"getHostDefaultsLevel",
|
||||
"getHostEditorSection",
|
||||
"getHostPassword",
|
||||
"getHostPolicy",
|
||||
"getHostSidebarPreferences",
|
||||
"getHostTags",
|
||||
"getLiveHostStatus",
|
||||
"getHttpsSettings",
|
||||
"getLogLevel",
|
||||
"getNotificationPrivateEndpoints",
|
||||
"getPanel",
|
||||
@@ -459,6 +459,8 @@ const ALLOWED = [
|
||||
"getSessions",
|
||||
"getStatusCheckSettings",
|
||||
"getStatusClasses",
|
||||
"getSyncConflicts",
|
||||
"getSyncErrors",
|
||||
"getTabType",
|
||||
"getTlsStatus",
|
||||
"getUpdateChannel",
|
||||
@@ -523,6 +525,7 @@ const ALLOWED = [
|
||||
"railItemLabel",
|
||||
"readFromClipboard",
|
||||
"recordHostActionPreference",
|
||||
"refreshSyncStatus",
|
||||
"registerLayout",
|
||||
"registerTabCloseGuard",
|
||||
"releaseUrlFrom",
|
||||
@@ -545,6 +548,7 @@ const ALLOWED = [
|
||||
"resolveHostDefaults",
|
||||
"resolveHostTabType",
|
||||
"resolveRemoteHostId",
|
||||
"retrySyncErrors",
|
||||
"revokeAllUserSessions",
|
||||
"revokeCredentialAccess",
|
||||
"revokeFolderAccess",
|
||||
@@ -568,17 +572,21 @@ const ALLOWED = [
|
||||
"setHostAuthOverride",
|
||||
"setNotificationPrivateEndpoints",
|
||||
"setPluginOptions",
|
||||
"setSyncStatus",
|
||||
"setUpdateChannel",
|
||||
"settleSyncConflict",
|
||||
"shareCredential",
|
||||
"shareFolder",
|
||||
"shareHost",
|
||||
"shell",
|
||||
"splitDefaultKey",
|
||||
"sshHostToHost",
|
||||
"syncNow",
|
||||
"tabIcon",
|
||||
"tabJumpHotkeyKeys",
|
||||
"takePendingManageRequest",
|
||||
"unlinkExternalFromPasswordAccount",
|
||||
"unlinkServer",
|
||||
"updateCredential",
|
||||
"updateExternalAutoProvision",
|
||||
"updateFolderMetadata",
|
||||
@@ -594,8 +602,8 @@ const ALLOWED = [
|
||||
"updateSSHHost",
|
||||
"updateSecondFactorAfterExternalLogin",
|
||||
"updateSessionTimeout",
|
||||
"updateHttpsSettings",
|
||||
"updateStatusCheckSettings",
|
||||
"updateSyncSettings",
|
||||
"uploadTlsCertificate",
|
||||
"usageBarColor",
|
||||
"usageColor",
|
||||
@@ -608,7 +616,6 @@ const ALLOWED = [
|
||||
"useConfirm",
|
||||
"useConnectionGate",
|
||||
"useConnectionLog",
|
||||
"useDesktopLinked",
|
||||
"useDesktopUpdate",
|
||||
"useEditorDefaultKeys",
|
||||
"useHostActions",
|
||||
@@ -643,6 +650,7 @@ const ALLOWED = [
|
||||
"useSshAuthProviders",
|
||||
"useStatusColorScheme",
|
||||
"useSurfaceKind",
|
||||
"useSyncStatus",
|
||||
"useTabHandlesVersion",
|
||||
"useTabs",
|
||||
"useTabsSafe",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
function findPackage(start) {
|
||||
let directory = path.dirname(start);
|
||||
while (directory !== path.dirname(directory)) {
|
||||
const manifest = path.join(directory, "package.json");
|
||||
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest));
|
||||
directory = path.dirname(directory);
|
||||
}
|
||||
throw new Error("Could not locate the installed sharp package manifest");
|
||||
}
|
||||
|
||||
// The build runs on x64 but ships arm64 and ia32 too, so npm ci alone
|
||||
// only installs the x64 binary.
|
||||
const sharpPackage = findPackage(require.resolve("sharp"));
|
||||
const packages = [
|
||||
"@img/sharp-win32-arm64",
|
||||
"@img/sharp-win32-ia32",
|
||||
"@img/sharp-win32-x64",
|
||||
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
|
||||
|
||||
// A .cmd file only starts through a shell on current Node.
|
||||
execFileSync("npm.cmd", ["install", "--force", "--no-save", ...packages], {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
});
|
||||
@@ -16,11 +16,6 @@
|
||||
* environment variables is set, for a server configured before first boot.
|
||||
* Plugins without it start unchecked.
|
||||
*
|
||||
* "tier" says which image profile ships the plugin: "minimal" (only what the
|
||||
* app cannot start without, the default look), "standard" (the default image)
|
||||
* or "full" (everything else). A profile includes its own tier and every
|
||||
* smaller one. A plugin with no tier counts as "full".
|
||||
*
|
||||
* "defaultLook": true marks the plugin whose layout (and that layout's
|
||||
* theme) a fresh install shows. Core finds the default look this way, so it
|
||||
* never names a plugin. Such a plugin always starts enabled and cannot be
|
||||
@@ -36,8 +31,6 @@ const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
||||
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const PROFILES = ["minimal", "standard", "full"];
|
||||
const DEFAULT_PROFILE = "standard";
|
||||
|
||||
function parseOnboarding(value, id, problems) {
|
||||
if (value === undefined) return null;
|
||||
@@ -129,9 +122,6 @@ function parseBundledPlugins(raw) {
|
||||
) {
|
||||
problems.push(`${entry.id} defaultLook must be a boolean`);
|
||||
}
|
||||
if (entry.tier !== undefined && !PROFILES.includes(entry.tier)) {
|
||||
problems.push(`${entry.id} tier must be one of ${PROFILES.join(", ")}`);
|
||||
}
|
||||
if (problems.length > before) continue;
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
@@ -141,7 +131,6 @@ function parseBundledPlugins(raw) {
|
||||
sha256: entry.sha256,
|
||||
onboarding,
|
||||
defaultLook: entry.defaultLook === true,
|
||||
tier: entry.tier ?? "full",
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
@@ -252,35 +241,12 @@ function buildBundledIndex(plugins, stagedIds) {
|
||||
return { version: 1, plugins: out };
|
||||
}
|
||||
|
||||
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
|
||||
function resolveProfile(argv = process.argv, env = process.env) {
|
||||
const flag = argv.indexOf("--profile");
|
||||
const value =
|
||||
(flag !== -1 ? argv[flag + 1] : env.TERMIX_PLUGIN_PROFILE) ||
|
||||
DEFAULT_PROFILE;
|
||||
if (!PROFILES.includes(value)) {
|
||||
throw new Error(
|
||||
`unknown plugin profile "${value}", use ${PROFILES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The plugins a profile ships: its own tier and every smaller one. */
|
||||
function pluginsForProfile(plugins, profile) {
|
||||
const rank = PROFILES.indexOf(profile);
|
||||
return plugins.filter((plugin) => PROFILES.indexOf(plugin.tier) <= rank);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the newest registry version of each wanted plugin. `ids` is the list
|
||||
* to pin, or null for every plugin in the index. `current` is the existing
|
||||
* pin list, so fields the registry does not know (onboarding, tier) carry over;
|
||||
* a plugin new to the list starts in the full tier. coreVersion, when given,
|
||||
* skips releases whose engine.termix range does not include it, so the image
|
||||
* never ships a plugin its own core cannot run.
|
||||
* pin list, so fields the registry does not know (onboarding) carry over.
|
||||
*/
|
||||
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||
function pinsFromIndex(index, ids, current = []) {
|
||||
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
|
||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||
@@ -291,25 +257,15 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
|
||||
.filter((entry) => semver.valid(entry.version))
|
||||
.filter((entry) => !semver.prerelease(entry.version))
|
||||
.filter(
|
||||
(entry) =>
|
||||
!coreVersion ||
|
||||
!entry.engine?.termix ||
|
||||
semver.satisfies(coreVersion, entry.engine.termix, {
|
||||
includePrerelease: true,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
||||
const onboarding = currentById.get(id)?.onboarding;
|
||||
const defaultLook = currentById.get(id)?.defaultLook === true;
|
||||
const tier = currentById.get(id)?.tier ?? "full";
|
||||
return {
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: latest.url,
|
||||
sha256: latest.sha256,
|
||||
tier,
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
...(defaultLook ? { defaultLook } : {}),
|
||||
};
|
||||
@@ -453,9 +409,6 @@ function copyLocalBuild(repo, destination) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROFILES,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
findLocalPluginBuilds,
|
||||
isLocalBuildStale,
|
||||
localSdkBuildCommand,
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
const fs = require("node:fs");
|
||||
|
||||
const SK_ED = "sk-ssh-ed25519@openssh.com";
|
||||
const SK_EC = "sk-ecdsa-sha2-nistp256@openssh.com";
|
||||
|
||||
function replaceOnce(source, from, to, label) {
|
||||
const first = source.indexOf(from);
|
||||
if (first === -1 || source.indexOf(from, first + 1) !== -1) {
|
||||
throw new Error(`ssh2 changed (${label}); review the security key patch`);
|
||||
}
|
||||
return source.replace(from, () => to);
|
||||
}
|
||||
|
||||
function patchKeyParserSource(source) {
|
||||
if (source.includes("// termix-sk")) return source;
|
||||
let out = replaceOnce(
|
||||
source,
|
||||
` case 'ecdsa-sha2-nistp521':
|
||||
return true;
|
||||
case 'ssh-ed25519':
|
||||
if (eddsaSupported)
|
||||
return true;`,
|
||||
` case 'ecdsa-sha2-nistp521':
|
||||
return true;
|
||||
case '${SK_EC}': // termix-sk
|
||||
return true;
|
||||
case '${SK_ED}':
|
||||
case 'ssh-ed25519':
|
||||
if (eddsaSupported)
|
||||
return true;`,
|
||||
"key types",
|
||||
);
|
||||
out = replaceOnce(
|
||||
out,
|
||||
` default:
|
||||
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
|
||||
}
|
||||
|
||||
return new OpenSSH_Public(`,
|
||||
` case '${SK_ED}':
|
||||
case '${SK_EC}': {
|
||||
// Public blob only; the security key signs, so there is no private half.
|
||||
const rest = data.subarray(data._pos || 0);
|
||||
const typeLen = Buffer.byteLength(baseType);
|
||||
pubSSH = Buffer.allocUnsafe(4 + typeLen + rest.length);
|
||||
writeUInt32BE(pubSSH, typeLen, 0);
|
||||
pubSSH.utf8Write(baseType, 4, typeLen);
|
||||
pubSSH.set(rest, 4 + typeLen);
|
||||
algo = baseType === '${SK_ED}' ? null : 'sha256';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
|
||||
}
|
||||
|
||||
return new OpenSSH_Public(`,
|
||||
"parseDER",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function patchAgentSource(source) {
|
||||
if (source.includes("skTrailer")) return source;
|
||||
return replaceOnce(
|
||||
source,
|
||||
` binaryParser.init(signature, 0);
|
||||
binaryParser.readString(true);
|
||||
signature = binaryParser.readString();
|
||||
binaryParser.clear();`,
|
||||
` binaryParser.init(signature, 0);
|
||||
binaryParser.readString(true);
|
||||
signature = binaryParser.readString();
|
||||
// Security key signatures end with flags and a counter.
|
||||
const skTrailer = signature === undefined
|
||||
? undefined
|
||||
: binaryParser.readRaw();
|
||||
binaryParser.clear();
|
||||
if (skTrailer && skTrailer.length)
|
||||
signature.skTrailer = Buffer.from(skTrailer);`,
|
||||
"agent sign response",
|
||||
);
|
||||
}
|
||||
|
||||
function patchProtocolSource(source) {
|
||||
if (source.includes("skTrailer")) return source;
|
||||
const start = source.indexOf(" authPK(username, pubKey, keyAlgo, cbSign) {");
|
||||
const end = source.indexOf(" authHostbased(", start);
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error("ssh2 changed (authPK); review the security key patch");
|
||||
}
|
||||
let region = source.slice(start, end);
|
||||
region = replaceOnce(
|
||||
region,
|
||||
` signature = convertSignature(signature, keyType);`,
|
||||
` const skTrailer = signature && signature.skTrailer;
|
||||
signature = convertSignature(signature, keyType);`,
|
||||
"authPK sign",
|
||||
);
|
||||
region = replaceOnce(
|
||||
region,
|
||||
` + 4 + algoLen + 4 + sigLen
|
||||
);`,
|
||||
` + 4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0)
|
||||
);`,
|
||||
"authPK alloc",
|
||||
);
|
||||
region = replaceOnce(
|
||||
region,
|
||||
` writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);`,
|
||||
` writeUInt32BE(
|
||||
packet,
|
||||
4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0),
|
||||
p += pubKeyLen
|
||||
);`,
|
||||
"authPK length",
|
||||
);
|
||||
region = replaceOnce(
|
||||
region,
|
||||
` packet.set(signature, p += 4);`,
|
||||
` packet.set(signature, p += 4);
|
||||
if (skTrailer)
|
||||
packet.set(skTrailer, p + sigLen);`,
|
||||
"authPK copy",
|
||||
);
|
||||
return source.slice(0, start) + region + source.slice(end);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const targets = [
|
||||
["ssh2/lib/protocol/keyParser.js", patchKeyParserSource],
|
||||
["ssh2/lib/agent.js", patchAgentSource],
|
||||
["ssh2/lib/protocol/Protocol.js", patchProtocolSource],
|
||||
];
|
||||
for (const [id, patch] of targets) {
|
||||
const target = require.resolve(id);
|
||||
const source = fs.readFileSync(target, "utf8");
|
||||
const result = patch(source);
|
||||
if (result !== source) fs.writeFileSync(target, result);
|
||||
}
|
||||
console.log(
|
||||
"[patch-ssh2-sk] Security key public keys and signatures enabled",
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
patchKeyParserSource,
|
||||
patchAgentSource,
|
||||
patchProtocolSource,
|
||||
};
|
||||
@@ -4,10 +4,7 @@
|
||||
* re-released.
|
||||
*
|
||||
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
|
||||
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full)
|
||||
* --core-version X skip releases whose engine.termix excludes X
|
||||
*
|
||||
* The release workflow runs it with --all and the version being released.
|
||||
* node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
@@ -25,8 +22,6 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
|
||||
|
||||
async function main() {
|
||||
const all = process.argv.includes("--all");
|
||||
const flag = process.argv.indexOf("--core-version");
|
||||
const coreVersion = flag !== -1 ? process.argv[flag + 1] : null;
|
||||
const response = await fetch(INDEX_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`registry index download failed with ${response.status}`);
|
||||
@@ -35,7 +30,7 @@ async function main() {
|
||||
|
||||
const current = loadBundledPlugins(root);
|
||||
const ids = all ? null : current.map((plugin) => plugin.id);
|
||||
const plugins = pinsFromIndex(index, ids, current, coreVersion);
|
||||
const plugins = pinsFromIndex(index, ids, current);
|
||||
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
|
||||
console.log(
|
||||
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
|
||||
|
||||
@@ -137,19 +137,9 @@ function verifyArtifact(artifact) {
|
||||
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
|
||||
} finally {
|
||||
if (mountedAt) {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const args =
|
||||
attempt < 3 ? ["detach", mountedAt] : ["detach", "-force", mountedAt];
|
||||
if (spawnSync("hdiutil", args, { stdio: "ignore" }).status === 0) break;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
||||
}
|
||||
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" });
|
||||
}
|
||||
fs.rmSync(temporaryRoot, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 5,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,12 +26,6 @@ import {
|
||||
isSecondFactorAfterExternalLoginEnabled,
|
||||
} from "./core-auth.js";
|
||||
import { findOrProvisionExternalUser } from "./provisioning.js";
|
||||
import {
|
||||
SECOND_FACTOR_SETUP,
|
||||
evaluateAuthPolicy,
|
||||
hasAuthPolicies,
|
||||
rememberDeviceAllowed,
|
||||
} from "./policy.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
getSecondFactor,
|
||||
@@ -128,13 +122,10 @@ export async function evaluateSecondFactors(
|
||||
* governs external methods (SSO, LDAP) alone.
|
||||
*/
|
||||
function shouldRunSecondFactors(methodId: string): boolean {
|
||||
if (!isExternalMethod(methodId)) return true;
|
||||
return isSecondFactorAfterExternalLoginEnabled();
|
||||
}
|
||||
|
||||
function isExternalMethod(methodId: string): boolean {
|
||||
ensureCoreLoginProviders();
|
||||
return !!getLoginMethod(methodId)?.external;
|
||||
const method = getLoginMethod(methodId);
|
||||
if (!method?.external) return true;
|
||||
return isSecondFactorAfterExternalLoginEnabled();
|
||||
}
|
||||
|
||||
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
|
||||
@@ -303,40 +294,9 @@ export async function runLogin(
|
||||
`${context.methodId}_role_shared_credentials`,
|
||||
);
|
||||
|
||||
// A login policy may want a factor where core would skip it, so it always
|
||||
// sees the full picture.
|
||||
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";
|
||||
const factors = shouldRunSecondFactors(context.methodId)
|
||||
? await evaluateSecondFactors(user.id)
|
||||
: { required: [], blocked: [] };
|
||||
if (factors.blocked.length > 0) {
|
||||
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
|
||||
operation: "second_factor_unavailable",
|
||||
@@ -364,7 +324,11 @@ export async function runLogin(
|
||||
|
||||
const ssoClaims = { externalSession: identity.externalSession ?? null };
|
||||
|
||||
if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) {
|
||||
if (
|
||||
factors.required.length > 0 &&
|
||||
!identity.mfaSatisfied &&
|
||||
!(await isTrustedDevice(req, user.id))
|
||||
) {
|
||||
const tempToken = await AuthManager.getInstance().generateJWTToken(
|
||||
user.id,
|
||||
{ pendingTOTP: true, expiresIn: "10m" },
|
||||
@@ -373,17 +337,14 @@ export async function runLogin(
|
||||
pendingLogins.set(pendingKey(tempToken), {
|
||||
userId: user.id,
|
||||
methodId: context.methodId,
|
||||
rememberMe,
|
||||
allowRememberDevice: policy.allowRememberDevice,
|
||||
maxSessionSeconds: policy.maxSessionSeconds,
|
||||
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
||||
rememberMe: context.rememberMe,
|
||||
...ssoClaims,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return {
|
||||
kind: "second-factor",
|
||||
tempToken,
|
||||
rememberMe,
|
||||
rememberMe: context.rememberMe,
|
||||
factors: factors.required.map((factor) => ({
|
||||
id: factor.id,
|
||||
pluginId: factor.pluginId,
|
||||
@@ -392,22 +353,11 @@ 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, {
|
||||
methodId: context.methodId,
|
||||
rememberMe,
|
||||
rememberMe: context.rememberMe,
|
||||
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
|
||||
longLivedForApps:
|
||||
identity.kind === "external" && policy.allowRememberDevice,
|
||||
maxAgeSeconds: policy.maxSessionSeconds,
|
||||
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
||||
restriction: needsSetup ? SECOND_FACTOR_SETUP : null,
|
||||
longLivedForApps: identity.kind === "external",
|
||||
...ssoClaims,
|
||||
});
|
||||
return { kind: "session", ...session };
|
||||
@@ -560,15 +510,10 @@ export async function verifySecondFactorAndRespond(
|
||||
}
|
||||
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 =
|
||||
allowRemember &&
|
||||
(typeof req.body?.rememberMe === "boolean"
|
||||
typeof req.body?.rememberMe === "boolean"
|
||||
? req.body.rememberMe
|
||||
: (lookup.pending?.rememberMe ?? false));
|
||||
: (lookup.pending?.rememberMe ?? false);
|
||||
|
||||
let trustToken: string | null = null;
|
||||
if (rememberMe) {
|
||||
@@ -600,8 +545,6 @@ export async function verifySecondFactorAndRespond(
|
||||
methodId: pending?.methodId ?? "password",
|
||||
rememberMe,
|
||||
externalSession: pending?.externalSession ?? null,
|
||||
maxAgeSeconds: pending?.maxSessionSeconds ?? null,
|
||||
idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null,
|
||||
});
|
||||
|
||||
if (trustToken && isNativeAppRequest(req)) {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -68,11 +68,6 @@ export interface IssueSessionOptions {
|
||||
externalSession?: ExternalSessionRef | null;
|
||||
/** SSO logins keep desktop and mobile apps signed in for 30 days. */
|
||||
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 {
|
||||
@@ -98,9 +93,6 @@ export async function issueSession(
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
externalSession: options.externalSession ?? null,
|
||||
maxAgeSeconds: options.maxAgeSeconds ?? null,
|
||||
restriction: options.restriction ?? null,
|
||||
idleTimeoutSeconds: options.idleTimeoutSeconds ?? null,
|
||||
});
|
||||
|
||||
if (options.rateLimitUsername) {
|
||||
@@ -131,14 +123,11 @@ export async function issueSession(
|
||||
method: options.methodId,
|
||||
});
|
||||
|
||||
let maxAge =
|
||||
const maxAge =
|
||||
options.longLivedForApps &&
|
||||
(deviceInfo.type === "desktop" || deviceInfo.type === "mobile")
|
||||
? THIRTY_DAYS_MS
|
||||
: await sessionCookieMaxAge(options.rememberMe);
|
||||
if (options.maxAgeSeconds) {
|
||||
maxAge = Math.min(maxAge, options.maxAgeSeconds * 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
@@ -153,7 +142,6 @@ export async function issueSession(
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
user.id,
|
||||
),
|
||||
...(options.restriction ? { restriction: options.restriction } : {}),
|
||||
...(isNativeAppRequest(req) ? { token } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,9 +35,5 @@ export interface PendingLogin {
|
||||
methodId: string;
|
||||
rememberMe: boolean;
|
||||
externalSession?: ExternalSessionRef | null;
|
||||
/** Login policy that applied when the login paused. */
|
||||
allowRememberDevice?: boolean;
|
||||
maxSessionSeconds?: number | null;
|
||||
idleTimeoutSeconds?: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -27,7 +27,8 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import { syncForwarder } from "../sync/provider.js";
|
||||
import syncRoutes from "../sync/server/routes.js";
|
||||
import syncLinkRoutes from "../sync/client/routes.js";
|
||||
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
import {
|
||||
@@ -35,7 +36,6 @@ import {
|
||||
mountPluginLegacyPaths,
|
||||
} from "./routes/plugin-api-routes.js";
|
||||
import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import { registerSshRelay } from "../hosts/ssh-relay.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
@@ -73,7 +73,6 @@ import {
|
||||
} from "../plugins/user-data.js";
|
||||
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
|
||||
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
|
||||
import { httpsPolicyMiddleware } from "../tls/https-policy.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostRepository,
|
||||
@@ -106,7 +105,6 @@ const __dirname = path.dirname(__filename);
|
||||
const app = express();
|
||||
|
||||
app.set("trust proxy", "loopback");
|
||||
app.use(httpsPolicyMiddleware);
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
@@ -1625,7 +1623,8 @@ app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/sync", syncForwarder);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/sync", syncLinkRoutes);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use(
|
||||
@@ -1899,7 +1898,6 @@ const httpServer = http.createServer(app);
|
||||
// Plugin sockets ride this server at /plugin-ws/<id>/<path>. Anything else is
|
||||
// left alone, so core's own upgrade handling is unaffected.
|
||||
attachPluginWebSockets(httpServer);
|
||||
registerSshRelay();
|
||||
|
||||
httpServer.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
|
||||
@@ -1021,8 +1021,6 @@ const migrateSchema = () => {
|
||||
}
|
||||
enforceUniqueUsernames();
|
||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||
addColumnIfNotExists("sessions", "restriction", "TEXT");
|
||||
addColumnIfNotExists("sessions", "idle_timeout", "INTEGER");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "folder", "TEXT");
|
||||
|
||||
@@ -59,10 +59,6 @@ export const sessions = mysqlTable(
|
||||
lastActiveAt: text("last_active_at")
|
||||
.notNull()
|
||||
.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.
|
||||
(table) => [
|
||||
|
||||
@@ -59,10 +59,6 @@ export const sessions = pgTable(
|
||||
lastActiveAt: text("last_active_at")
|
||||
.notNull()
|
||||
.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.
|
||||
(table) => [
|
||||
|
||||
@@ -47,10 +47,6 @@ export const sessions = sqliteTable(
|
||||
lastActiveAt: text("last_active_at")
|
||||
.notNull()
|
||||
.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.
|
||||
(table) => [
|
||||
|
||||
@@ -236,29 +236,6 @@ export class HostFolderRepository {
|
||||
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
|
||||
}
|
||||
|
||||
/** Drops the folder rows (and subfolders) only when no host is left in them. */
|
||||
async deleteEmptyFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
): Promise<boolean> {
|
||||
if ((await this.listHostsInFolder(userId, folderName)).length > 0) {
|
||||
return false;
|
||||
}
|
||||
await deleteReturning(
|
||||
this.context,
|
||||
sshFolders,
|
||||
and(
|
||||
eq(sshFolders.userId, userId),
|
||||
or(
|
||||
eq(sshFolders.name, folderName),
|
||||
like(sshFolders.name, `${folderName} / %`),
|
||||
),
|
||||
),
|
||||
);
|
||||
await this.afterWrite();
|
||||
return true;
|
||||
}
|
||||
|
||||
async deleteHostsAndFolderRecords(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
|
||||
@@ -69,14 +69,6 @@ export class SessionRepository {
|
||||
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(
|
||||
id: string,
|
||||
jwtToken: string,
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from "../../auth/registry.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
||||
import { rememberDeviceAllowed } from "../../auth/policy.js";
|
||||
|
||||
export interface PublicLoginMethod {
|
||||
id: string;
|
||||
@@ -244,7 +243,7 @@ export function registerAuthRoutes(router: Router): void {
|
||||
* /users/auth/methods:
|
||||
* get:
|
||||
* summary: List login methods
|
||||
* 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.
|
||||
* description: Public. The login methods with at least one enabled instance, for the login screen. Carries ids and labels only.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
@@ -253,10 +252,7 @@ export function registerAuthRoutes(router: Router): void {
|
||||
*/
|
||||
router.get("/auth/methods", async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
methods: await listPublicLoginMethods(),
|
||||
rememberDevice: rememberDeviceAllowed(),
|
||||
});
|
||||
res.json({ methods: await listPublicLoginMethods() });
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to list login methods", error);
|
||||
res.status(500).json({ error: "Failed to list login methods" });
|
||||
|
||||
@@ -61,23 +61,15 @@ export function isNativeTokenExportRequest(req: Request): boolean {
|
||||
* during testing, or a household sharing one install) -- a user in that
|
||||
* state deserves to get into the app they installed, not a confusing,
|
||||
* unexplained dead end. So this always returns a single, deterministic
|
||||
* user: the account the user switched to if it still exists, else the admin
|
||||
* account if one exists, else the earliest-registered account. It never
|
||||
* returns null when there are users.
|
||||
* user: the admin account if one exists, else the earliest-registered
|
||||
* account. It never returns null.
|
||||
*/
|
||||
export function resolveDesktopAutoSessionUser(
|
||||
allUsers: UserRecord[],
|
||||
preferredId?: string | null,
|
||||
): UserRecord | null {
|
||||
if (allUsers.length === 0) return null;
|
||||
if (allUsers.length === 1) return allUsers[0];
|
||||
|
||||
// An account the user switched to on purpose, while it still exists.
|
||||
const preferred = preferredId
|
||||
? allUsers.find((user) => user.id === preferredId)
|
||||
: undefined;
|
||||
if (preferred) return preferred;
|
||||
|
||||
const admin = allUsers.find((user) => user.isAdmin);
|
||||
if (admin) return admin;
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { sshLogger } from "../../utils/logger.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { createCurrentHostRepository } from "../repositories/factory.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
|
||||
|
||||
import { setHostPluginEnabled } from "./host-plugin-settings.js";
|
||||
|
||||
@@ -142,15 +141,6 @@ export function registerHostBulkRoutes(
|
||||
);
|
||||
|
||||
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(
|
||||
(id: number) => !ownedIds.includes(id),
|
||||
);
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
} from "../../hosts/defaults/service.js";
|
||||
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
|
||||
import { readDefaultsReset } from "./host-bulk-routes.js";
|
||||
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
|
||||
|
||||
type HostDefaultsRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -679,7 +678,6 @@ export function registerHostDefaultsRoutes(
|
||||
const reset = readDefaultsReset(req.body);
|
||||
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
|
||||
try {
|
||||
assertHostsUnlocked([hostId]);
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
@@ -702,7 +700,6 @@ export function registerHostDefaultsRoutes(
|
||||
await changeHostOverrides([hostId], reset);
|
||||
res.json({ message: "Host reset to its defaults" });
|
||||
} catch (error) {
|
||||
if (sendHostLocked(res, error)) return;
|
||||
databaseLogger.error("Failed to reset host defaults", error);
|
||||
res.status(500).json({ error: "Failed to reset host defaults" });
|
||||
}
|
||||
|
||||
@@ -16,31 +16,6 @@ import {
|
||||
folderCredentialIds,
|
||||
setFolderCredentialDefault,
|
||||
} 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 = {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -111,7 +86,6 @@ export function registerHostFolderRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await folderHostsUnlocked(userId, oldName, res))) return;
|
||||
const { updatedHosts, updatedCredentials } =
|
||||
await createCurrentHostFolderRepository().renameFolder(
|
||||
userId,
|
||||
@@ -443,7 +417,6 @@ export function registerHostFolderRoutes(
|
||||
});
|
||||
|
||||
try {
|
||||
if (!(await folderHostsUnlocked(userId, folderName, res))) return;
|
||||
const hostFolderRepository = createCurrentHostFolderRepository();
|
||||
const hostsToDelete = await hostFolderRepository.listHostsInFolder(
|
||||
userId,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "../../hosts/ssh-options.js";
|
||||
import { listHostProtocols } from "../../hosts/protocol-auth/registry.js";
|
||||
import { sanitizeProtocolAuthForRecipient } from "../../hosts/protocol-auth/summary.js";
|
||||
import { hostLock } from "../../hosts/locks.js";
|
||||
|
||||
export function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
@@ -338,7 +337,6 @@ const CONNECT_LEVEL_FIELDS = new Set([
|
||||
"sshPort",
|
||||
"protocolAuth",
|
||||
"jumpHosts",
|
||||
"lock",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
]);
|
||||
@@ -441,10 +439,8 @@ export function transformHostResponse(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const shared = parseSharedSource(host.sharedSource);
|
||||
const lock = typeof host.id === "number" ? hostLock(host.id) : null;
|
||||
return {
|
||||
...host,
|
||||
...(lock ? { lock } : {}),
|
||||
sharedSource: undefined,
|
||||
localOnly: !!host.localOnly,
|
||||
sharedCopy: !!shared,
|
||||
|
||||
@@ -85,11 +85,6 @@ import {
|
||||
type PlannedProtocolAuth,
|
||||
} from "../../hosts/protocol-auth/protocol-auth.js";
|
||||
import { findHostProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
manualCreateBlockedBy,
|
||||
requireManualCreate,
|
||||
requireUnlockedHost,
|
||||
} from "../../hosts/locks.js";
|
||||
import {
|
||||
mergeStoredSshOptions,
|
||||
parseTerminalConfig,
|
||||
@@ -162,7 +157,6 @@ router.post(
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.create"),
|
||||
requireDataAccess,
|
||||
requireManualCreate,
|
||||
requireHostEnrollmentAccessForPath,
|
||||
upload.single("key"),
|
||||
async (req: Request, res: Response) => {
|
||||
@@ -742,7 +736,6 @@ router.put(
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.edit"),
|
||||
requireDataAccess,
|
||||
requireUnlockedHost,
|
||||
upload.single("key"),
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = Array.isArray(req.params.id)
|
||||
@@ -1212,27 +1205,6 @@ 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
|
||||
* /host/db/host:
|
||||
@@ -1730,7 +1702,6 @@ router.delete(
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.delete"),
|
||||
requireDataAccess,
|
||||
requireUnlockedHost,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = Array.isArray(req.params.id)
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
safeLayoutForced,
|
||||
unavailableLooks,
|
||||
} from "../../plugins/looks.js";
|
||||
import { safeModeOn } from "../../plugins/safe-mode.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
@@ -37,7 +36,7 @@ async function adminDefaults() {
|
||||
* /user-preferences/look:
|
||||
* get:
|
||||
* summary: Get the layout and theme to show the current user
|
||||
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, whether TERMIX_SAFE_MODE runs only layout and theme plugins, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* responses:
|
||||
@@ -76,8 +75,6 @@ async function adminDefaults() {
|
||||
* nullable: true
|
||||
* safeLayout:
|
||||
* type: boolean
|
||||
* safeMode:
|
||||
* type: boolean
|
||||
* unavailable:
|
||||
* type: array
|
||||
* items:
|
||||
@@ -115,7 +112,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
defaults: { ...defaults, layout: known(defaults.layout) },
|
||||
defaultLook: resolveDefaultLook(loader),
|
||||
safeLayout: safeLayoutForced(),
|
||||
safeMode: safeModeOn(),
|
||||
unavailable: unavailableLooks(loader),
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
|
||||
import { mayRunInSafeMode } from "../../plugins/safe-mode.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
validateSettingsSave,
|
||||
type PluginSettingsScope,
|
||||
} from "../../plugins/settings.js";
|
||||
import { assertHostsUnlocked, sendHostLocked } from "../../hosts/locks.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
@@ -250,9 +248,6 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* only run on that server and stay off here on purpose, and, for
|
||||
* admins, `managedByServer` marks plugins whose on/off state the server
|
||||
* decides.
|
||||
*
|
||||
* While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps
|
||||
* off reports `state: "safe-mode"`.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -358,12 +353,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
name: record.name,
|
||||
version: record.version,
|
||||
enabled: record.state === "enabled",
|
||||
state:
|
||||
record.state === "enabled" &&
|
||||
loaded?.state !== "active" &&
|
||||
!mayRunInSafeMode(record.id, loader.list())
|
||||
? "safe-mode"
|
||||
: (loaded?.state ?? record.state),
|
||||
state: loaded?.state ?? record.state,
|
||||
runsOnServer: linked && desktopMode === "server",
|
||||
contributes,
|
||||
icon,
|
||||
@@ -945,7 +935,7 @@ router.delete(
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
* 409:
|
||||
* description: This desktop is linked to a server, which decides whether the plugin runs, or safe mode is on and the plugin is not a layout or theme.
|
||||
* description: This desktop is linked to a server, which decides whether the plugin runs.
|
||||
*/
|
||||
router.patch(
|
||||
"/:id/state",
|
||||
@@ -1059,8 +1049,6 @@ router.patch(
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin, or it is not on disk.
|
||||
* 409:
|
||||
* description: Safe mode is on and the plugin is not a layout or theme.
|
||||
* 500:
|
||||
* description: The plugin failed to start again.
|
||||
*/
|
||||
@@ -1084,13 +1072,6 @@ router.post(
|
||||
res.status(404).json({ error: "Plugin is not present on disk" });
|
||||
return;
|
||||
}
|
||||
if (!mayRunInSafeMode(pluginId, loader.list())) {
|
||||
res.status(409).json({
|
||||
error: "Safe mode is on, so only layout and theme plugins can start",
|
||||
code: "SAFE_MODE",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await repository.update(pluginId, {
|
||||
state: "enabled",
|
||||
@@ -1897,18 +1878,6 @@ router.put(
|
||||
const inherit = Array.isArray(inheritRaw)
|
||||
? 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(
|
||||
userId,
|
||||
manifest,
|
||||
|
||||
@@ -9,12 +9,6 @@ import {
|
||||
logAudit,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||
import {
|
||||
assertHostsUnlocked,
|
||||
hasHostLocks,
|
||||
requireUnlockedHost,
|
||||
sendHostLocked,
|
||||
} from "../../hosts/locks.js";
|
||||
import {
|
||||
SharedHostAuthOverrideService,
|
||||
SharedHostAuthOverrideServiceError,
|
||||
@@ -235,7 +229,6 @@ router.post(
|
||||
"/host/:id/share",
|
||||
authenticateJWT,
|
||||
permissionManager.requirePermission("hosts.share"),
|
||||
requireUnlockedHost,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const hostId = parseInt(id, 10);
|
||||
@@ -458,14 +451,6 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
if (hasHostLocks()) {
|
||||
const inFolder =
|
||||
await createCurrentHostFolderRepository().listHostsInFolder(
|
||||
userId,
|
||||
folder,
|
||||
);
|
||||
assertHostsUnlocked(inFolder.map((host) => host.id));
|
||||
}
|
||||
const targets = parseShareTargets(req.body ?? {});
|
||||
if (!targets) {
|
||||
return res.status(400).json({
|
||||
@@ -628,7 +613,6 @@ router.post(
|
||||
hostResults,
|
||||
});
|
||||
} catch (error) {
|
||||
if (sendHostLocked(res, error)) return;
|
||||
databaseLogger.error("Failed to share folder", error, {
|
||||
operation: "share_folder",
|
||||
folder,
|
||||
@@ -802,7 +786,6 @@ router.delete(
|
||||
router.patch(
|
||||
"/host/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
requireUnlockedHost,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const accessIdParam = Array.isArray(req.params.accessId)
|
||||
@@ -925,7 +908,6 @@ router.patch(
|
||||
router.delete(
|
||||
"/host/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
requireUnlockedHost,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||
const accessIdParam = Array.isArray(req.params.accessId)
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.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(
|
||||
userId: string | undefined,
|
||||
@@ -189,90 +187,6 @@ 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.
|
||||
* One route pair per private endpoint allowlist, each with its own setting key.
|
||||
|
||||
@@ -61,38 +61,4 @@ export function registerUserSocketTicketRoutes(
|
||||
res.status(500).json({ error: "Failed to issue a socket ticket" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ws-ticket/api-key:
|
||||
* post:
|
||||
* summary: Get a WebSocket ticket for an API key
|
||||
* description: For another Termix server that manages this one with an API key. Returns a token that is valid for one minute and only opens plugin WebSockets, as the key's user. It cannot call any other route.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The ticket.
|
||||
* 401:
|
||||
* description: The request did not use an API key.
|
||||
* 500:
|
||||
* description: Failed to issue the ticket.
|
||||
*/
|
||||
router.post("/ws-ticket/api-key", authenticateJWT, async (req, res) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
if (!authReq.apiKeyId) {
|
||||
return res.status(401).json({ error: "An API key is required" });
|
||||
}
|
||||
try {
|
||||
const ticket = await authManager.issueApiKeySocketTicket(
|
||||
authReq.userId,
|
||||
authReq.apiKeyId,
|
||||
);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.json({ ticket });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to issue an API key socket ticket", err);
|
||||
res.status(500).json({ error: "Failed to issue a socket ticket" });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
import { registerTlsRoutes } from "./tls-routes.js";
|
||||
import { registerUserSessionRoutes } from "./user-session-routes.js";
|
||||
import { registerUserSocketTicketRoutes } from "./user-socket-ticket-routes.js";
|
||||
import { shownIdentity } from "../../sync/shown-identity.js";
|
||||
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
@@ -62,7 +61,6 @@ import {
|
||||
syncSharedCredentialsForUserRoles,
|
||||
} from "../../auth/session-issuer.js";
|
||||
import { EXTERNAL_AUTO_PROVISION_KEY } from "../../auth/provisioning.js";
|
||||
import { evaluateAuthPolicy } from "../../auth/policy.js";
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
@@ -422,21 +420,9 @@ router.post("/proxy-login", async (req, res) => {
|
||||
userRecord.id,
|
||||
"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, {
|
||||
deviceType: deviceInfo.type,
|
||||
deviceInfo: deviceInfo.deviceInfo,
|
||||
maxAgeSeconds: policy.maxSessionSeconds,
|
||||
idleTimeoutSeconds: policy.idleTimeoutSeconds,
|
||||
});
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
@@ -457,16 +443,7 @@ router.post("/proxy-login", async (req, res) => {
|
||||
});
|
||||
|
||||
return res
|
||||
.cookie(
|
||||
"jwt",
|
||||
token,
|
||||
authManager.getSecureCookieOptions(
|
||||
req,
|
||||
policy.maxSessionSeconds
|
||||
? Math.min(24 * 60 * 60 * 1000, policy.maxSessionSeconds * 1000)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req))
|
||||
.json({
|
||||
enabled: true,
|
||||
success: true,
|
||||
@@ -569,6 +546,30 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* On a desktop linked to a server, the account it is signed in to there.
|
||||
* That account is who the user is; the local profile only mirrors it.
|
||||
*/
|
||||
async function describeDesktopLink(userId: string) {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
|
||||
try {
|
||||
const { getLink } = await import("../../sync/client/link-store.js");
|
||||
const link = await getLink();
|
||||
if (!link || link.userId !== userId) return null;
|
||||
return {
|
||||
serverUrl: link.serverUrl,
|
||||
serverName: link.serverName,
|
||||
username: link.account?.username ?? link.remoteUsername,
|
||||
isAdmin: !!link.account?.isAdmin,
|
||||
roles: link.account?.roles ?? [],
|
||||
permissions: link.account?.permissions ?? [],
|
||||
status: link.status,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/me:
|
||||
@@ -601,21 +602,18 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
|
||||
const hasPassword = user.passwordHash && user.passwordHash.trim() !== "";
|
||||
const isDualAuth = hasPassword && isExternalAccount(user);
|
||||
const shown = await shownIdentity(user);
|
||||
|
||||
res.json({
|
||||
userId: user.id,
|
||||
username: shown.username,
|
||||
is_admin: shown.isAdmin,
|
||||
username: user.username,
|
||||
is_admin: !!user.isAdmin,
|
||||
is_external: isExternalAccount(user),
|
||||
is_dual_auth: isDualAuth,
|
||||
// Any second factor; the name is what 2.8 clients read.
|
||||
totp_enabled: await createCurrentUserAuthRepository().hasSecondFactor(
|
||||
user.id,
|
||||
),
|
||||
linked: shown.linked,
|
||||
// "second-factor-setup" while the login policy wants a factor set up.
|
||||
restriction: (req as AuthenticatedRequest).sessionRestriction ?? null,
|
||||
linked: await describeDesktopLink(user.id),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get username", err);
|
||||
@@ -721,11 +719,7 @@ router.post("/internal/auto-session", async (req, res) => {
|
||||
|
||||
const userRepository = createCurrentUserRepository();
|
||||
const allUsers = await userRepository.listAll();
|
||||
const { getSavedActiveUserId } = await import("../../sync/accounts.js");
|
||||
const userRecord = resolveDesktopAutoSessionUser(
|
||||
allUsers,
|
||||
await getSavedActiveUserId(),
|
||||
);
|
||||
const userRecord = resolveDesktopAutoSessionUser(allUsers);
|
||||
if (!userRecord) {
|
||||
return res.status(403).json({
|
||||
error: "No local users exist",
|
||||
@@ -736,11 +730,10 @@ router.post("/internal/auto-session", async (req, res) => {
|
||||
if (existingToken) {
|
||||
const existingPayload = await authManager.verifyJWTToken(existingToken);
|
||||
if (existingPayload?.userId === userRecord.id) {
|
||||
const shownNow = await shownIdentity(userRecord);
|
||||
return res.json({
|
||||
success: true,
|
||||
is_admin: shownNow.isAdmin,
|
||||
username: shownNow.username,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
token: existingToken,
|
||||
});
|
||||
}
|
||||
@@ -752,11 +745,10 @@ router.post("/internal/auto-session", async (req, res) => {
|
||||
rememberMe: true,
|
||||
});
|
||||
|
||||
const shown = await shownIdentity(userRecord);
|
||||
const response = {
|
||||
success: true,
|
||||
is_admin: shown.isAdmin,
|
||||
username: shown.username,
|
||||
is_admin: !!userRecord.isAdmin,
|
||||
username: userRecord.username,
|
||||
token,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Other ways to reach a host, provided by plugins through
|
||||
* ctx.ssh.transports.provide. A provider claims some hosts by id and hands the
|
||||
* pipeline an open stream to them instead of a TCP connection, for example a
|
||||
* relay through another Termix server. The first provider that claims a host
|
||||
* wins, and nothing else (jump hosts, proxies) is tried for it.
|
||||
*/
|
||||
|
||||
import type { Duplex } from "node:stream";
|
||||
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
provider: PluginSshTransportProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export function provideSshTransport(
|
||||
pluginId: string,
|
||||
provider: PluginSshTransportProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearSshTransports(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
/** Opens a stream to the host if a provider claims it, else null. */
|
||||
export async function openProvidedTransport(host: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
}): Promise<Duplex | null> {
|
||||
for (const { provider } of providers.values()) {
|
||||
let claimed = false;
|
||||
try {
|
||||
claimed = provider.claims(host.id);
|
||||
} catch {
|
||||
claimed = false;
|
||||
}
|
||||
if (claimed) return provider.open(host);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* How the bytes get to a host: a transport a plugin provides, else port
|
||||
* knocking, a jump host chain or a SOCKS5 proxy, in that order. Sets config.sock and
|
||||
* How the bytes get to a host: port knocking, a jump host chain or a SOCKS5
|
||||
* proxy, in that order. Sets config.sock and
|
||||
* returns the jump client so the caller can close it with the connection.
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "../../utils/socks5-helper.js";
|
||||
import type { ProxyNode } from "../../../types/index.js";
|
||||
import { createJumpHostChain } from "../jump-host-chain.js";
|
||||
import { openProvidedTransport } from "./transport-providers.js";
|
||||
import { resolveSshConnectConfigHost } from "../ssh-dns.js";
|
||||
import { performPortKnocking } from "../terminal-auth-helpers.js";
|
||||
import type {
|
||||
@@ -60,7 +59,7 @@ export interface OpenTransportOptions {
|
||||
|
||||
export interface OpenedTransport {
|
||||
jumpClient: Client | null;
|
||||
via: "direct" | "proxy" | "jump" | "provided";
|
||||
via: "direct" | "proxy" | "jump";
|
||||
}
|
||||
|
||||
function forwardThrough(
|
||||
@@ -95,16 +94,6 @@ export async function openSshTransport(
|
||||
config: MutableConnectConfig,
|
||||
options: OpenTransportOptions = {},
|
||||
): Promise<OpenedTransport> {
|
||||
const provided = await openProvidedTransport({
|
||||
id: host.id,
|
||||
ip: host.ip,
|
||||
port: host.port || 22,
|
||||
});
|
||||
if (provided) {
|
||||
config.sock = provided;
|
||||
return { jumpClient: null, via: "provided" };
|
||||
}
|
||||
|
||||
if (
|
||||
options.portKnock !== false &&
|
||||
Array.isArray(host.portKnockSequence) &&
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
isValidPort,
|
||||
normalizeImportedHost,
|
||||
} 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. */
|
||||
export class ImportError extends Error {
|
||||
@@ -264,19 +263,6 @@ export function importResponse(results: ImportResults) {
|
||||
|
||||
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. */
|
||||
async function rollBackCreated(
|
||||
userId: string,
|
||||
@@ -460,12 +446,6 @@ export async function runBulkImport(
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
const locked = importLockError(existing?.id);
|
||||
if (locked) {
|
||||
results.failed++;
|
||||
results.errors.push(`Host ${i + 1}: ${locked}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
@@ -765,12 +745,6 @@ export async function runSshConfigImport(
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
const locked = importLockError(existing?.id);
|
||||
if (locked) {
|
||||
results.failed++;
|
||||
results.errors.push(`Host "${parsed[i].name}": ${locked}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId,
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* A relay to a host's SSH port, for another Termix server that manages this
|
||||
* one. It opens a WebSocket at /plugin-ws/core/ssh-relay with an API key
|
||||
* ticket, names a host, and the bytes of an SSH connection run through it.
|
||||
* This server reaches the host the way it always does, jump hosts, proxy and
|
||||
* port knocking included, so the other server never needs a route to it. The
|
||||
* SSH session itself stays between that server and the host.
|
||||
*
|
||||
* The caller needs connect access to the host, the same as for a session.
|
||||
*/
|
||||
|
||||
import net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { createWebSocketStream, type WebSocket } from "ws";
|
||||
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { resolveHostById } from "./host-resolver.js";
|
||||
import { openSshTransport } from "./connect/transport.js";
|
||||
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { registerCoreWsRoute } from "../plugins/ws.js";
|
||||
|
||||
const MAX_RELAYS_PER_USER = 100;
|
||||
const open = new Map<string, number>();
|
||||
|
||||
function close(ws: WebSocket, code: number, reason: string): void {
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
// Already closed.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRelay(
|
||||
connection: PluginWebSocketConnection,
|
||||
): Promise<void> {
|
||||
const ws = connection.socket as WebSocket;
|
||||
const url = String((connection.request as { url?: string }).url ?? "");
|
||||
const hostId = Number(
|
||||
new URL(url, "http://relay").searchParams.get("hostId"),
|
||||
);
|
||||
const userId = connection.userId;
|
||||
|
||||
if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) {
|
||||
close(ws, 1008, "A host is required");
|
||||
return;
|
||||
}
|
||||
if ((open.get(userId) ?? 0) >= MAX_RELAYS_PER_USER) {
|
||||
close(ws, 1013, "Too many connections");
|
||||
return;
|
||||
}
|
||||
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) {
|
||||
close(ws, 1008, "Host not found or access denied");
|
||||
return;
|
||||
}
|
||||
|
||||
open.set(userId, (open.get(userId) ?? 0) + 1);
|
||||
let stream: Duplex | null = null;
|
||||
let jumpEnd: (() => void) | null = null;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
open.set(userId, Math.max(0, (open.get(userId) ?? 1) - 1));
|
||||
stream?.destroy();
|
||||
jumpEnd?.();
|
||||
close(ws, 1000, "Closed");
|
||||
};
|
||||
ws.once("close", finish);
|
||||
|
||||
try {
|
||||
const sshHost = host as unknown as SshConnectHost;
|
||||
const config = {
|
||||
host: sshHost.ip,
|
||||
port: sshHost.port || 22,
|
||||
} as MutableConnectConfig;
|
||||
const { jumpClient } = await openSshTransport(sshHost, config);
|
||||
jumpEnd = () => jumpClient?.end();
|
||||
stream = (config.sock as Duplex | undefined) ?? null;
|
||||
if (!stream) {
|
||||
stream = net.connect({
|
||||
host: String(config.host),
|
||||
port: Number(config.port),
|
||||
});
|
||||
}
|
||||
const pipe = createWebSocketStream(ws);
|
||||
stream.once("error", finish);
|
||||
stream.once("close", finish);
|
||||
pipe.once("error", finish);
|
||||
stream.pipe(pipe);
|
||||
pipe.pipe(stream);
|
||||
} catch (error) {
|
||||
sshLogger.warn("A relay could not reach its host", {
|
||||
operation: "ssh_relay_open",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Mounts the relay once, for servers that other Termix servers manage. */
|
||||
export function registerSshRelay(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerCoreWsRoute("/ssh-relay", handleRelay);
|
||||
}
|
||||
@@ -14,24 +14,3 @@ export function startablePluginIds(
|
||||
.map((record) => record.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugins that shipped in an earlier image and were left on, but whose code is
|
||||
* not in this one (a smaller image tag). They get fetched from the registry.
|
||||
*/
|
||||
export function missingShippedPluginIds(
|
||||
records: { id: string; tier: string; state: string }[],
|
||||
presentIds: ReadonlySet<string>,
|
||||
ignored: ReadonlySet<string>,
|
||||
): string[] {
|
||||
return records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.tier === "bundled" &&
|
||||
record.state !== "disabled" &&
|
||||
!presentIds.has(record.id) &&
|
||||
!ignored.has(record.id),
|
||||
)
|
||||
.map((record) => record.id)
|
||||
.sort();
|
||||
}
|
||||
@@ -150,37 +150,6 @@ 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) => {
|
||||
const details = `SSH key "${input?.name ?? ""}"`;
|
||||
try {
|
||||
|
||||
@@ -157,7 +157,6 @@ export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
|
||||
customHeaders: link.customHeaders,
|
||||
basicAuth: link.basicAuth,
|
||||
allowInvalidCertificate: link.allowInvalidCertificate,
|
||||
clientCert: link.clientCert,
|
||||
},
|
||||
pathname,
|
||||
);
|
||||
|
||||
@@ -20,7 +20,6 @@ import type {
|
||||
PluginShareableUser,
|
||||
PluginShareableRole,
|
||||
PluginHostJumpHost,
|
||||
PluginHostShareGrant,
|
||||
} 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";
|
||||
@@ -36,11 +35,6 @@ import { hostTerminalExport } from "../database/routes/host-normalizers.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { pluginEvents, TOPICS } from "./events.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
assertHostsUnlocked,
|
||||
assertManualCreateAllowed,
|
||||
provideHostLocks,
|
||||
} from "../hosts/locks.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -213,8 +207,6 @@ void inputKeysComplete;
|
||||
export function toHostWrite(input: PluginHostUpdateInput): {
|
||||
row: Record<string, unknown>;
|
||||
settings: Record<string, Record<string, unknown>>;
|
||||
protocolAuth: Record<string, unknown> | null;
|
||||
pluginEnable: Record<string, boolean>;
|
||||
} {
|
||||
const unknown = Object.keys(input ?? {}).filter(
|
||||
(key) => !INPUT_KEYS.has(key),
|
||||
@@ -276,32 +268,6 @@ export function toHostWrite(input: PluginHostUpdateInput): {
|
||||
? null
|
||||
: 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>> = {};
|
||||
if (input.pluginSettings !== undefined && input.pluginSettings !== null) {
|
||||
@@ -322,136 +288,7 @@ export function toHostWrite(input: PluginHostUpdateInput): {
|
||||
settings[pluginId] = values;
|
||||
}
|
||||
}
|
||||
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;
|
||||
return { row, settings };
|
||||
}
|
||||
|
||||
function actingUser(): string {
|
||||
@@ -639,7 +476,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
create: async (host: PluginHostCreateInput): Promise<PluginHostRecord> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
assertManualCreateAllowed(pluginId);
|
||||
} catch (error) {
|
||||
await audit("hosts_create", host?.name ?? host?.ip ?? "host", {
|
||||
success: false,
|
||||
@@ -648,10 +484,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
throw error;
|
||||
}
|
||||
const userId = actingUser();
|
||||
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(host);
|
||||
const writes = await checkSettings(
|
||||
await foldPluginEnable(settings, pluginEnable),
|
||||
);
|
||||
const { row, settings } = toHostWrite(host);
|
||||
const writes = await checkSettings(settings);
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||
@@ -670,7 +504,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
userId,
|
||||
columns,
|
||||
);
|
||||
await writeHostProtocolAuth(userId, created.id, protocolAuth);
|
||||
await applyDefaultsAfterHostWrite(created.id);
|
||||
await writeSettings(created.id, writes);
|
||||
await markSettingsOwn(created.id, writes);
|
||||
@@ -687,7 +520,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
): Promise<PluginHostRecord | null> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
assertHostsUnlocked([hostId], pluginId);
|
||||
} catch (error) {
|
||||
await audit("hosts_update", `host ${hostId}`, {
|
||||
success: false,
|
||||
@@ -696,10 +528,8 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
throw error;
|
||||
}
|
||||
const userId = actingUser();
|
||||
const { row, settings, protocolAuth, pluginEnable } = toHostWrite(patch);
|
||||
const writes = await checkSettings(
|
||||
await foldPluginEnable(settings, pluginEnable),
|
||||
);
|
||||
const { row, settings } = toHostWrite(patch);
|
||||
const writes = await checkSettings(settings);
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||
@@ -730,7 +560,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
columns,
|
||||
);
|
||||
if (updated) {
|
||||
await writeHostProtocolAuth(userId, hostId, protocolAuth);
|
||||
await writeSettings(hostId, writes);
|
||||
await markSettingsOwn(hostId, writes);
|
||||
await applyDefaultsAfterHostWrite(hostId, {
|
||||
@@ -755,7 +584,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
delete: async (hostId: number): Promise<boolean> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
assertHostsUnlocked([hostId], pluginId);
|
||||
const userId = actingUser();
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
@@ -803,7 +631,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
): Promise<PluginHostShareResult> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
assertHostsUnlocked([hostId], pluginId);
|
||||
} catch (error) {
|
||||
await audit("hosts_share", `host ${hostId}`, {
|
||||
success: false,
|
||||
@@ -833,8 +660,10 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const {
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
} = await import("../database/repositories/factory.js");
|
||||
const ownerId =
|
||||
(await createCurrentHostResolutionRepository().findHostOwnerId(
|
||||
hostId,
|
||||
@@ -848,95 +677,56 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
return { hostId, shared: false, reason: "owner" };
|
||||
}
|
||||
|
||||
const expiresAt = hoursFromNow(durationHours);
|
||||
const expiresAt =
|
||||
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) {
|
||||
await grantHostShare(
|
||||
const grant = await rbacAccessRepository.upsertHostAccess({
|
||||
hostId,
|
||||
ownerId,
|
||||
userId,
|
||||
target,
|
||||
grantedBy: userId,
|
||||
permissionLevel,
|
||||
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 });
|
||||
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[]> => {
|
||||
try {
|
||||
await requireWrite();
|
||||
@@ -1020,52 +810,6 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
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();
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
folders: {
|
||||
ensure: async (name, style) => {
|
||||
await requireWrite();
|
||||
const userId = actingUser();
|
||||
const { createCurrentHostFolderRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
await createCurrentHostFolderRepository().upsertMetadata(
|
||||
userId,
|
||||
name,
|
||||
style?.color ?? null,
|
||||
style?.icon ?? null,
|
||||
);
|
||||
},
|
||||
removeEmpty: async (name) => {
|
||||
await requireWrite();
|
||||
const userId = actingUser();
|
||||
const { createCurrentHostFolderRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentHostFolderRepository().deleteEmptyFolderRecords(
|
||||
userId,
|
||||
name,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
activity: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("hosts:activity-provide")) {
|
||||
|
||||
@@ -38,12 +38,9 @@ import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interacti
|
||||
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
listSecondFactors,
|
||||
registerLoginMethod,
|
||||
registerSecondFactor,
|
||||
} from "../auth/registry.js";
|
||||
import { provideAuthPolicy } from "../auth/policy.js";
|
||||
import { provideSshTransport } from "../hosts/connect/transport-providers.js";
|
||||
import type { VerifiedIdentity } from "../auth/types.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
@@ -297,26 +294,6 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
}`;
|
||||
|
||||
return {
|
||||
transports: {
|
||||
provide: (provider) => {
|
||||
if (!declared.includes("ssh:transport")) {
|
||||
throw capabilityRefused(pluginId, "ssh:transport");
|
||||
}
|
||||
const remove = provideSshTransport(pluginId, provider);
|
||||
void audit(
|
||||
"ssh_transport_provide",
|
||||
`${pluginId} carries host connections`,
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
);
|
||||
const drop = bag.add(remove, "ssh transport");
|
||||
return () => {
|
||||
drop();
|
||||
remove();
|
||||
};
|
||||
},
|
||||
},
|
||||
probe: async (hostId, probeOptions) => {
|
||||
await assertCapability(pluginId, "ssh:connect", declared);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) return false;
|
||||
@@ -879,8 +856,6 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
getActor() === userId ? getActorSessionId() : undefined,
|
||||
);
|
||||
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}`, {
|
||||
success: true,
|
||||
});
|
||||
@@ -959,28 +934,5 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
},
|
||||
issueUserToken: (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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -54,12 +54,6 @@ import {
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
|
||||
import * as syncRegistry from "./sync-registry.js";
|
||||
import { provideSync } from "../sync/provider.js";
|
||||
import { createSyncHost } from "../sync/host.js";
|
||||
import type {
|
||||
PluginSync,
|
||||
PluginSyncHost,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import {
|
||||
needsExplicitPersist,
|
||||
resolveDatabaseDialect,
|
||||
@@ -195,34 +189,6 @@ export function createPluginContext(
|
||||
handle: PluginHandle,
|
||||
): PluginContext {
|
||||
const pluginId = manifest.id;
|
||||
let syncHost: PluginSyncHost | undefined;
|
||||
const syncApi: PluginSync = {
|
||||
registerEntity: (entity) => {
|
||||
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
||||
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
||||
},
|
||||
provide: (provider) => {
|
||||
if (!manifest.capabilities.includes("sync:provide")) {
|
||||
throw capabilityRefused(pluginId, "sync:provide");
|
||||
}
|
||||
const remove = provideSync(pluginId, provider);
|
||||
const drop = handle.bag.add(remove, "sync provider");
|
||||
return () => {
|
||||
drop();
|
||||
remove();
|
||||
};
|
||||
},
|
||||
} as PluginSync;
|
||||
// Not enumerable, so tools that walk the context do not trip the check.
|
||||
Object.defineProperty(syncApi, "host", {
|
||||
enumerable: false,
|
||||
get() {
|
||||
if (!manifest.capabilities.includes("sync:provide")) {
|
||||
throw capabilityRefused(pluginId, "sync:provide");
|
||||
}
|
||||
return (syncHost ??= createSyncHost());
|
||||
},
|
||||
});
|
||||
const auditCall = (
|
||||
action: string,
|
||||
details: string,
|
||||
@@ -554,7 +520,12 @@ export function createPluginContext(
|
||||
},
|
||||
},
|
||||
|
||||
sync: syncApi,
|
||||
sync: {
|
||||
registerEntity: (entity) => {
|
||||
const dispose = syncRegistry.registerEntity(pluginId, entity);
|
||||
handle.bag.add(dispose, `sync entity ${entity.type}`);
|
||||
},
|
||||
},
|
||||
|
||||
kv: {
|
||||
get: (key) => kvGet(key),
|
||||
|
||||
@@ -23,11 +23,6 @@ import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
|
||||
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
|
||||
import { recordConflict } from "./conflicts.js";
|
||||
import { startablePluginIds } from "./boot-state.js";
|
||||
import {
|
||||
mayRunInSafeMode,
|
||||
safeModeOn,
|
||||
safeModePluginIds,
|
||||
} from "./safe-mode.js";
|
||||
import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
|
||||
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
|
||||
|
||||
@@ -242,10 +237,6 @@ export async function initializePlugins(
|
||||
if (loaded.length === 0) return [];
|
||||
|
||||
const fresh = await seedPlugins(loaded, options.freshInstall === true);
|
||||
if (!fresh) {
|
||||
const { restoreMissingPlugins } = await import("./manage.js");
|
||||
loaded.push(...(await restoreMissingPlugins()));
|
||||
}
|
||||
await syncCapabilityGrants(loaded);
|
||||
if (loaded.some((plugin) => plugin.source === "bundled")) {
|
||||
const { offerPluginSetup } = await import("./onboarding.js");
|
||||
@@ -257,17 +248,8 @@ export async function initializePlugins(
|
||||
const records = await createCurrentPluginRepository().listAll();
|
||||
const enabled = startablePluginIds(records);
|
||||
|
||||
const safe = safeModeOn() ? safeModePluginIds(loaded) : null;
|
||||
if (safe) {
|
||||
pluginLogger.warn(
|
||||
"Safe mode is on (TERMIX_SAFE_MODE). Only layout and theme plugins start",
|
||||
{ operation: "plugin_init" },
|
||||
);
|
||||
}
|
||||
|
||||
const candidates = loaded
|
||||
.filter((plugin) => enabled.has(plugin.id))
|
||||
.filter((plugin) => !safe || safe.has(plugin.id))
|
||||
.map((plugin) => plugin.id);
|
||||
|
||||
for (const pluginId of candidates) {
|
||||
@@ -557,8 +539,6 @@ export async function activatePlugin(pluginId: string): Promise<void> {
|
||||
// Before activate: a plugin's own activate() may already want to check one
|
||||
// of its permissions.
|
||||
const plugin = pluginLoader.get(pluginId);
|
||||
// Stays enabled and starts on the next boot without safe mode.
|
||||
if (!mayRunInSafeMode(pluginId, pluginLoader.list())) return;
|
||||
if (plugin) await registerPluginPermissions(plugin);
|
||||
|
||||
await pluginLoader.activate(pluginId);
|
||||
|
||||
@@ -18,7 +18,6 @@ import path from "node:path";
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { isDefaultLookPlugin } from "./bundled-index.js";
|
||||
import { mayRunInSafeMode } from "./safe-mode.js";
|
||||
import {
|
||||
activatePlugin,
|
||||
deactivatePlugin,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
registerLoadedPlugins,
|
||||
unloadPlugin,
|
||||
} from "./index.js";
|
||||
import { missingShippedPluginIds } from "./boot-state.js";
|
||||
import type { LoadedPlugin, PluginLoader } from "./loader.js";
|
||||
import {
|
||||
getBundledPluginsDir,
|
||||
@@ -765,13 +763,6 @@ export async function setPluginStateUnlocked(
|
||||
if (!loader.get(pluginId)) {
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
if (enabled && !mayRunInSafeMode(pluginId, loader.list())) {
|
||||
throw new PluginManageError(
|
||||
"Safe mode is on, so only layout and theme plugins can start",
|
||||
409,
|
||||
"SAFE_MODE",
|
||||
);
|
||||
}
|
||||
const plan = await planStateChange(pluginId, enabled);
|
||||
|
||||
if (enabled) {
|
||||
@@ -968,11 +959,7 @@ export function installPlugin(
|
||||
return serialized(async () => {
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
// A record with no code is a plugin an older image shipped: install fills it in.
|
||||
if (
|
||||
(await repository.findById(pluginId)) &&
|
||||
getPluginRuntime().loader.get(pluginId)
|
||||
) {
|
||||
if (await repository.findById(pluginId)) {
|
||||
throw new PluginManageError(
|
||||
"Plugin is already installed",
|
||||
409,
|
||||
@@ -1031,63 +1018,6 @@ export function installPlugin(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at boot before plugins start. A plugin the database has on but the
|
||||
* image no longer ships (an update to a smaller image tag) is fetched from the
|
||||
* official registry, so nothing an install used goes missing. One that cannot
|
||||
* be fetched stays as it was and is tried again on the next start.
|
||||
*/
|
||||
export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
|
||||
return serialized(async () => {
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
const { loader } = getPluginRuntime();
|
||||
const ids = missingShippedPluginIds(
|
||||
await repository.listAll(),
|
||||
new Set(loader.list().map((plugin) => plugin.id)),
|
||||
await readUninstalled(),
|
||||
);
|
||||
const restored: LoadedPlugin[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const record = await repository.findById(id);
|
||||
const { source, release } = await requireRelease(
|
||||
id,
|
||||
record?.pinnedVersion ?? undefined,
|
||||
channelOf(record),
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
);
|
||||
const plugin = await swapIn(id, { release, source });
|
||||
await repository.update(id, {
|
||||
registryId: source.id,
|
||||
tier: tierFor(plugin, source),
|
||||
});
|
||||
await grantDeclared(plugin, null);
|
||||
restored.push(plugin);
|
||||
pluginLogger.info(
|
||||
`Restored ${id}@${release.version} from the registry`,
|
||||
{
|
||||
operation: "plugin_restore_missing",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
|
||||
operation: "plugin_restore_missing",
|
||||
});
|
||||
// The registry is down or offline, so the rest would fail the same way.
|
||||
if (
|
||||
error instanceof PluginManageError &&
|
||||
error.code === "REGISTRY_UNREACHABLE"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return restored;
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlugin(
|
||||
pluginId: string,
|
||||
options: InstallOptions,
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import {
|
||||
@@ -24,11 +23,7 @@ import {
|
||||
youtubeVideoId,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import {
|
||||
isBlockedAddress,
|
||||
safeOutboundFetch,
|
||||
} from "../utils/safe-outbound-fetch.js";
|
||||
import { fetchWithProxy, getProxyAgent } from "../utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
COMMUNITY_PLUGIN_KEYS,
|
||||
@@ -283,72 +278,15 @@ export function canRunRelease(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the admin lets registry fetches reach on a private address, like a
|
||||
* registry in the LAN. Matched by host name on any port, also when another
|
||||
* index or a redirect points there; every other host stays blocked.
|
||||
*/
|
||||
export function getRegistryPrivateHosts(): string[] {
|
||||
return (process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map(normalizePrivateHost)
|
||||
.filter((host): host is string => host !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* The host in the form safeOutboundFetch compares against, or undefined when
|
||||
* the entry carries more than a host, like a scheme, port, path or user.
|
||||
*/
|
||||
function normalizePrivateHost(entry: string): string | undefined {
|
||||
const candidate = isIP(entry) === 6 ? `[${entry}]` : entry;
|
||||
if (/[/?#@\\]|:\d*$/.test(candidate)) return undefined;
|
||||
try {
|
||||
return new URL(`http://${candidate}`).hostname.replace(/^\[|\]$/g, "");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* With HTTP_PROXY or HTTPS_PROXY set, registry traffic goes through the proxy,
|
||||
* which does its own name lookup. Only the URL itself is checked here: web
|
||||
* pages only, no logins in it, and no private address typed in as a literal.
|
||||
*/
|
||||
function fetchThroughProxy(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
privateHosts: readonly string[],
|
||||
): Promise<Response> {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, "");
|
||||
if (
|
||||
!["http:", "https:"].includes(parsed.protocol) ||
|
||||
parsed.username ||
|
||||
parsed.password
|
||||
) {
|
||||
throw new Error("Invalid outbound URL");
|
||||
}
|
||||
if (isIP(host) && isBlockedAddress(host) && !privateHosts.includes(host)) {
|
||||
throw new Error("Private destinations are not allowed");
|
||||
}
|
||||
return fetchWithProxy(url, options);
|
||||
}
|
||||
|
||||
/** Follows redirects by hand so each hop still gets the outbound checks. */
|
||||
async function fetchFollowing(url: string): Promise<Response> {
|
||||
const privateHosts = getRegistryPrivateHosts();
|
||||
let current = url;
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const options: RequestInit = {
|
||||
const response = await safeOutboundFetch(current, {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
headers: { "User-Agent": "Termix" },
|
||||
};
|
||||
const response = getProxyAgent(current)
|
||||
? await fetchThroughProxy(current, options, privateHosts)
|
||||
: await safeOutboundFetch(current, options, privateHosts);
|
||||
});
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get("location");
|
||||
if (!location) throw new Error(`redirect without a location: ${current}`);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
|
||||
/** TERMIX_SAFE_MODE=1 starts only the plugins that draw the app. */
|
||||
export function safeModeOn(): boolean {
|
||||
const value = process.env.TERMIX_SAFE_MODE?.trim().toLowerCase();
|
||||
return value === "1" || value === "true";
|
||||
}
|
||||
|
||||
function isLookPlugin(manifest: PluginManifest): boolean {
|
||||
return (
|
||||
(manifest.contributes?.layouts?.length ?? 0) > 0 ||
|
||||
(manifest.contributes?.themes?.length ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Layout and theme plugins, plus the plugins they need to start. */
|
||||
export function safeModePluginIds(
|
||||
plugins: { id: string; manifest: PluginManifest }[],
|
||||
): Set<string> {
|
||||
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
|
||||
const allowed = new Set<string>();
|
||||
const add = (id: string) => {
|
||||
const plugin = byId.get(id);
|
||||
if (!plugin || allowed.has(id)) return;
|
||||
allowed.add(id);
|
||||
for (const dependency of Object.keys(plugin.manifest.dependencies ?? {})) {
|
||||
add(dependency);
|
||||
}
|
||||
};
|
||||
for (const plugin of plugins) {
|
||||
if (isLookPlugin(plugin.manifest)) add(plugin.id);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
|
||||
/** Whether a plugin may run now. Always true outside safe mode. */
|
||||
export function mayRunInSafeMode(
|
||||
pluginId: string,
|
||||
plugins: { id: string; manifest: PluginManifest }[],
|
||||
): boolean {
|
||||
return !safeModeOn() || safeModePluginIds(plugins).has(pluginId);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export interface UserOwnedTable {
|
||||
otherUserColumns: string[];
|
||||
/** refHost columns. */
|
||||
hostColumns: string[];
|
||||
/** Columns that travel, as SQL names. Auto ids and secrets do not. */
|
||||
/** Columns that travel, as SQL names. The primary key and secrets do not. */
|
||||
columns: string[];
|
||||
/** The owner column is the primary key: one row per user. */
|
||||
userIsKey: boolean;
|
||||
@@ -57,7 +57,9 @@ export function listUserOwnedTables(): UserOwnedTable[] {
|
||||
.filter(
|
||||
([, column]) =>
|
||||
column === user[1] ||
|
||||
(column.type !== "id" && column.type !== "encryptedText"),
|
||||
(column.type !== "id" &&
|
||||
!column.primaryKey &&
|
||||
column.type !== "encryptedText"),
|
||||
)
|
||||
.map(([property, column]) => columnName(property, column)),
|
||||
userIsKey: !!user[1].primaryKey,
|
||||
|
||||
@@ -54,8 +54,6 @@ interface Route {
|
||||
rawHandler?: RawUpgradeHandler;
|
||||
/** Sockets handed to rawHandler, destroyed when the route goes away. */
|
||||
rawSockets?: Set<Duplex>;
|
||||
/** Core's own socket: served under /plugin-ws/core/, no plugin grant. */
|
||||
core?: boolean;
|
||||
}
|
||||
|
||||
/** Largest frame a plugin socket accepts. ws defaults to 100MB. */
|
||||
@@ -165,10 +163,7 @@ async function handlePluginUpgrade(
|
||||
// checked per upgrade rather than at registration, and a revoke takes effect
|
||||
// on the next connection without a restart.
|
||||
const { hasCapability } = await import("./permissions.js");
|
||||
if (
|
||||
!route.core &&
|
||||
!(await hasCapability(route.pluginId, "network:serve", route.declared))
|
||||
) {
|
||||
if (!(await hasCapability(route.pluginId, "network:serve", route.declared))) {
|
||||
reject(socket, 403, "Forbidden");
|
||||
return true;
|
||||
}
|
||||
@@ -308,32 +303,6 @@ export function registerPluginWsRoute(
|
||||
return () => disposeRoute(routeKey, pluginId, normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* A socket core itself serves, at /plugin-ws/core/<path>, so it rides the same
|
||||
* proxy rules and token checks as plugin sockets. Authenticated like them.
|
||||
*/
|
||||
export function registerCoreWsRoute(
|
||||
path: string,
|
||||
handler: PluginWebSocketHandler,
|
||||
): () => void {
|
||||
const routeKey = key("core", path);
|
||||
const normalized = normalizePath(path);
|
||||
if (routes.has(routeKey)) return () => {};
|
||||
routes.set(routeKey, {
|
||||
pluginId: "core",
|
||||
path: normalized,
|
||||
options: {},
|
||||
declared: [],
|
||||
handler,
|
||||
core: true,
|
||||
});
|
||||
servers.set(
|
||||
routeKey,
|
||||
new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES }),
|
||||
);
|
||||
return () => disposeRoute(routeKey, "core", normalized);
|
||||
}
|
||||
|
||||
export function registerPluginWsUpgrade(
|
||||
pluginId: string,
|
||||
path: string,
|
||||
|
||||
@@ -265,15 +265,17 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Sync itself starts with the plugin that provides it.
|
||||
// After plugins, so their sync entities are registered before a pass.
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
try {
|
||||
const { startDesktopSync } = await import("./sync/client/engine.js");
|
||||
startDesktopSync();
|
||||
const { startDesktopUpdateCheck } =
|
||||
await import("./updates/desktop-update-check.js");
|
||||
startDesktopUpdateCheck();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Desktop update check failed to start", {
|
||||
operation: "update_check_start",
|
||||
systemLogger.warn("Desktop sync failed to start", {
|
||||
operation: "sync_start",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Accounts on a desktop. Each local user is one account: the plain local one,
|
||||
* or the replica of a linked server. Exactly one is active, and the app signs
|
||||
* in as it. Switching only changes which user the desktop auto session picks,
|
||||
* so no data is shared between accounts.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { resolveDesktopAutoSessionUser } from "../database/routes/desktop-auto-session.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
|
||||
const ACTIVE_KEY = "desktop_active_user";
|
||||
|
||||
/** The user the desktop signs in as. Falls back to the usual single-user pick. */
|
||||
export async function getActiveUserId(): Promise<string | null> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
if (users.length === 0) return null;
|
||||
const saved = await createCurrentSettingsRepository().get(ACTIVE_KEY);
|
||||
return resolveDesktopAutoSessionUser(users, saved)?.id ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveUserId(userId: string): Promise<void> {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user) throw new Error("No such account");
|
||||
await createCurrentSettingsRepository().set(ACTIVE_KEY, userId);
|
||||
}
|
||||
|
||||
/** Saved choice only, for the auto session route. */
|
||||
export async function getSavedActiveUserId(): Promise<string | null> {
|
||||
return createCurrentSettingsRepository().get(ACTIVE_KEY);
|
||||
}
|
||||
|
||||
export interface DesktopAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export async function listAccounts(): Promise<DesktopAccount[]> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
const active = await getActiveUserId();
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
isAdmin: !!user.isAdmin,
|
||||
active: user.id === active,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A new empty account for another server to be linked to. */
|
||||
export async function createAccount(): Promise<string> {
|
||||
const users = createCurrentUserRepository();
|
||||
const id = crypto.randomUUID();
|
||||
await users.create({
|
||||
id,
|
||||
username: `server-${id.slice(0, 8)}`,
|
||||
passwordHash: "",
|
||||
isAdmin: true,
|
||||
});
|
||||
try {
|
||||
await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId: id,
|
||||
roleName: "admin",
|
||||
grantedBy: id,
|
||||
});
|
||||
} catch {
|
||||
// The account still works; admin is also set on the user row.
|
||||
}
|
||||
await AuthManager.getInstance().registerUser(
|
||||
id,
|
||||
crypto.randomBytes(32).toString("hex"),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Deletes an account and everything it owns. The last account stays. */
|
||||
export async function removeAccount(userId: string): Promise<void> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
if (users.length <= 1) throw new Error("The last account cannot be removed");
|
||||
const active = await getActiveUserId();
|
||||
const { deleteUserAndRelatedData } =
|
||||
await import("../database/routes/delete-user-data.js");
|
||||
await deleteUserAndRelatedData(userId);
|
||||
if (active === userId) {
|
||||
const next = users.find((user) => user.id !== userId);
|
||||
if (next) await setActiveUserId(next.id);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import os from "os";
|
||||
|
||||
export function defaultDeviceName(): string {
|
||||
return os.hostname() || "Termix Desktop";
|
||||
}
|
||||
@@ -14,11 +14,7 @@ export async function applyElectronProxyConfig(
|
||||
link:
|
||||
| (Pick<
|
||||
SyncLink,
|
||||
| "serverUrl"
|
||||
| "customHeaders"
|
||||
| "basicAuth"
|
||||
| "allowInvalidCertificate"
|
||||
| "clientCert"
|
||||
"serverUrl" | "customHeaders" | "basicAuth" | "allowInvalidCertificate"
|
||||
> & { sessionToken?: string | null; status?: string })
|
||||
| null,
|
||||
): Promise<void> {
|
||||
@@ -34,7 +30,6 @@ export async function applyElectronProxyConfig(
|
||||
headers: link.customHeaders ?? [],
|
||||
basicAuth: link.basicAuth ?? null,
|
||||
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
||||
clientCert: link.clientCert ?? null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
/**
|
||||
* The linked desktop's side of sync.
|
||||
*
|
||||
* A pass pushes local edits, then pulls what changed on the server after the
|
||||
* saved cursor, then backfills any entity type this device has never pulled
|
||||
* (first link, a feature switched on, a category turned back on). Local
|
||||
* edits are found the same way the server finds changes: by comparing each
|
||||
* row's hash with the one recorded at the last sync.
|
||||
*/
|
||||
|
||||
import os from "os";
|
||||
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import {
|
||||
getEntity,
|
||||
listEntities,
|
||||
onEntitiesChanged,
|
||||
type RegisteredSyncEntity,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { sendCoreAlert } from "../../notify/core-notify.js";
|
||||
import { registerCoreSyncEntities } from "../entities.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
loadWireRow,
|
||||
loadWireRows,
|
||||
writeWireRow,
|
||||
} from "../store.js";
|
||||
import {
|
||||
listRecords,
|
||||
putRecord,
|
||||
recordKey,
|
||||
saveConflict,
|
||||
setRecordError,
|
||||
type SyncRecord,
|
||||
} from "../records.js";
|
||||
import { orderSelfReferences } from "../wire.js";
|
||||
import { onLocalWrite } from "../server/change-watcher.js";
|
||||
import { getLink, updateLink, type SyncLink } from "./link-store.js";
|
||||
import { RemoteError, remoteFetch, remoteJson } from "./http.js";
|
||||
import { mirrorPlugins } from "./plugins.js";
|
||||
import { applyElectronProxyConfig } from "./electron-proxy.js";
|
||||
|
||||
export interface RemoteChange {
|
||||
seq: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}
|
||||
|
||||
interface ChangesPage {
|
||||
changes: RemoteChange[];
|
||||
nextCursor: number;
|
||||
hasMore: boolean;
|
||||
entityTypes: Array<{
|
||||
type: string;
|
||||
owner: string;
|
||||
order: number;
|
||||
readOnly: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PushOp {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
baseRevision: number;
|
||||
op: "upsert" | "delete";
|
||||
row?: SyncRow;
|
||||
}
|
||||
|
||||
type PushResult =
|
||||
| { syncId: string; entityType: string; status: "ok"; revision: number }
|
||||
| {
|
||||
syncId: string;
|
||||
entityType: string;
|
||||
status: "conflict";
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}
|
||||
| { syncId: string; entityType: string; status: "rejected"; reason: string };
|
||||
|
||||
const MAX_BATCH_OPS = 200;
|
||||
const MAX_BATCH_BYTES = 1_000_000;
|
||||
const MAX_PAGES = 500;
|
||||
const SAFETY_INTERVAL_MS = 5 * 60_000;
|
||||
const REFRESH_CHECK_MS = 24 * 60 * 60_000;
|
||||
const DELETED_HASH = "deleted";
|
||||
|
||||
export interface EngineState {
|
||||
running: boolean;
|
||||
/** Local changes found in the last pass that the server has not taken. */
|
||||
pending: number;
|
||||
/** Entity types the server runs, from the last pull. */
|
||||
serverTypes: Array<{ type: string; owner: string; readOnly: boolean }>;
|
||||
lastPassAt: string | null;
|
||||
}
|
||||
|
||||
/** An entity this device could sync: registered here, and on the server. */
|
||||
function syncable(
|
||||
entity: RegisteredSyncEntity,
|
||||
link: SyncLink,
|
||||
serverTypes: Set<string> | null,
|
||||
): boolean {
|
||||
if (link.disabledTypes.includes(entity.type)) return false;
|
||||
if (serverTypes && !serverTypes.has(entity.type)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortChanges(changes: RemoteChange[]): RemoteChange[] {
|
||||
const byType = new Map<string, RemoteChange[]>();
|
||||
for (const change of changes) {
|
||||
const list = byType.get(change.entityType) ?? [];
|
||||
list.push(change);
|
||||
byType.set(change.entityType, list);
|
||||
}
|
||||
const types = [...byType.keys()].sort(
|
||||
(a, b) => (getEntity(a)?.order ?? 1e6) - (getEntity(b)?.order ?? 1e6),
|
||||
);
|
||||
const out: RemoteChange[] = [];
|
||||
for (const type of types) {
|
||||
const list = byType.get(type)!;
|
||||
const entity = getEntity(type);
|
||||
// Only the latest record of each row matters.
|
||||
const latest = new Map<string, RemoteChange>();
|
||||
for (const change of list) latest.set(change.syncId, change);
|
||||
const live = [...latest.values()].filter((change) => !change.deleted);
|
||||
const gone = [...latest.values()].filter((change) => change.deleted);
|
||||
if (entity) {
|
||||
const bySyncId = new Map(live.map((change) => [change.syncId, change]));
|
||||
const ordered = orderSelfReferences(
|
||||
entity,
|
||||
live.map((change) => ({
|
||||
...(change.row ?? {}),
|
||||
syncId: change.syncId,
|
||||
})),
|
||||
);
|
||||
out.push(...ordered.map((row) => bySyncId.get(row.syncId as string)!));
|
||||
} else {
|
||||
out.push(...live);
|
||||
}
|
||||
out.push(...gone);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class SyncEngine {
|
||||
private running = false;
|
||||
private again = false;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private safety: NodeJS.Timeout | null = null;
|
||||
private backoffMs = 0;
|
||||
private events: AbortController | null = null;
|
||||
private eventsFor: string | null = null;
|
||||
private lastRefreshCheck = 0;
|
||||
private serverTypes: Set<string> | null = null;
|
||||
private stopFns: Array<() => void> = [];
|
||||
private alerted = false;
|
||||
readonly state: EngineState = {
|
||||
running: false,
|
||||
pending: 0,
|
||||
serverTypes: [],
|
||||
lastPassAt: null,
|
||||
};
|
||||
|
||||
start(): void {
|
||||
if (this.safety) return;
|
||||
registerCoreSyncEntities();
|
||||
this.safety = setInterval(() => this.request(0), SAFETY_INTERVAL_MS);
|
||||
this.safety.unref?.();
|
||||
this.stopFns.push(onLocalWrite(() => this.request(2000)));
|
||||
// A plugin that came up may bring entity types to backfill.
|
||||
this.stopFns.push(onEntitiesChanged(() => this.request(3000)));
|
||||
this.request(3000);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.safety) clearInterval(this.safety);
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.safety = this.timer = null;
|
||||
this.closeEvents();
|
||||
for (const stop of this.stopFns.splice(0)) stop();
|
||||
}
|
||||
|
||||
/** Runs a pass after `delayMs`, folding requests that arrive meanwhile. */
|
||||
request(delayMs = 0): void {
|
||||
if (this.timer) {
|
||||
if (delayMs > 0) return;
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.run();
|
||||
}, delayMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
/** Runs a pass now and waits for it. */
|
||||
async syncNow(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.again = true;
|
||||
while (this.running) await new Promise((r) => setTimeout(r, 100));
|
||||
return;
|
||||
}
|
||||
await this.run();
|
||||
}
|
||||
|
||||
/** Forgets what the server runs, after a relink or unlink. */
|
||||
reset(): void {
|
||||
this.serverTypes = null;
|
||||
this.state.serverTypes = [];
|
||||
this.state.pending = 0;
|
||||
this.alerted = false;
|
||||
this.backoffMs = 0;
|
||||
this.closeEvents();
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.again = true;
|
||||
return;
|
||||
}
|
||||
this.running = this.state.running = true;
|
||||
try {
|
||||
await this.pass();
|
||||
} finally {
|
||||
this.running = this.state.running = false;
|
||||
this.state.lastPassAt = new Date().toISOString();
|
||||
if (this.again) {
|
||||
this.again = false;
|
||||
this.request(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pass(): Promise<void> {
|
||||
const link = await getLink();
|
||||
if (!link?.sessionToken) {
|
||||
this.closeEvents();
|
||||
return;
|
||||
}
|
||||
await updateLink({ status: "syncing" });
|
||||
|
||||
try {
|
||||
await this.refreshSession(link);
|
||||
const fresh = (await getLink())!;
|
||||
await mirrorPlugins(fresh);
|
||||
|
||||
// A first pass pulls before it pushes, so rows both sides already
|
||||
// have are matched up instead of sent back as edits.
|
||||
const firstPass = fresh.knownTypes.length === 0;
|
||||
if (!firstPass) {
|
||||
await this.push(fresh);
|
||||
await this.pull(fresh);
|
||||
}
|
||||
await this.backfill((await getLink())!);
|
||||
if (firstPass) await this.push((await getLink())!);
|
||||
|
||||
this.backoffMs = 0;
|
||||
this.alerted = false;
|
||||
await updateLink({
|
||||
status: "idle",
|
||||
lastError: null,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
});
|
||||
const done = (await getLink())!;
|
||||
await applyElectronProxyConfig(done).catch(() => {});
|
||||
this.openEvents(done);
|
||||
} catch (error) {
|
||||
await this.fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async fail(error: unknown): Promise<void> {
|
||||
const remote = error instanceof RemoteError ? error : null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
syncLogger.warn("Sync pass failed", {
|
||||
operation: "sync_pass",
|
||||
kind: remote?.kind ?? "internal",
|
||||
error: message,
|
||||
});
|
||||
|
||||
if (remote?.kind === "signed_out") {
|
||||
const link = await updateLink({
|
||||
status: "signed_out",
|
||||
lastError: message,
|
||||
});
|
||||
await applyElectronProxyConfig(link).catch(() => {});
|
||||
this.closeEvents();
|
||||
if (!this.alerted) {
|
||||
this.alerted = true;
|
||||
await sendCoreAlert({
|
||||
title: "Sync is paused",
|
||||
body: "This device was signed out of the server it syncs with. Sign in again from Sync to keep going.",
|
||||
severity: "warning",
|
||||
category: "termix.sync.signed_out",
|
||||
dedupeKey: "sync:signed_out",
|
||||
audience: "admins",
|
||||
link: { tab: "sync" },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const offline = remote?.kind === "offline";
|
||||
await updateLink({
|
||||
status: offline ? "offline" : "error",
|
||||
lastError: message,
|
||||
});
|
||||
this.backoffMs = Math.min(
|
||||
this.backoffMs ? this.backoffMs * 2 : 15_000,
|
||||
10 * 60_000,
|
||||
);
|
||||
this.request(this.backoffMs);
|
||||
}
|
||||
|
||||
private async refreshSession(link: SyncLink): Promise<void> {
|
||||
if (Date.now() - this.lastRefreshCheck < REFRESH_CHECK_MS) return;
|
||||
const result = await remoteJson<{ token: string | null }>(
|
||||
link,
|
||||
"/sync/v2/session/refresh",
|
||||
{ method: "POST", body: {} },
|
||||
);
|
||||
this.lastRefreshCheck = Date.now();
|
||||
if (result.token) await updateLink({ sessionToken: result.token });
|
||||
}
|
||||
|
||||
/** Local rows the server does not have in this form yet. */
|
||||
async outbox(link: SyncLink): Promise<{
|
||||
ops: PushOp[];
|
||||
hashes: Map<string, string>;
|
||||
}> {
|
||||
const records = await listRecords(link.userId);
|
||||
const resolvers = createResolvers(link.userId);
|
||||
const ops: PushOp[] = [];
|
||||
const hashes = new Map<string, string>();
|
||||
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
if (!link.knownTypes.includes(entity.type)) continue;
|
||||
if (!syncable(entity, link, this.serverTypes)) continue;
|
||||
|
||||
const { rows, failed } = await loadWireRows(
|
||||
entity,
|
||||
link.userId,
|
||||
resolvers,
|
||||
);
|
||||
for (const row of rows.values()) {
|
||||
const key = recordKey(entity.type, row.syncId);
|
||||
const record = records.get(key);
|
||||
if (record && !record.deleted && record.hash === row.hash) continue;
|
||||
// Unavailable server rows can recover without another local edit.
|
||||
if (record?.errorHash === row.hash && record.error !== "unavailable") {
|
||||
continue;
|
||||
}
|
||||
ops.push({
|
||||
entityType: entity.type,
|
||||
syncId: row.syncId,
|
||||
baseRevision: record?.revision ?? 0,
|
||||
op: "upsert",
|
||||
row: row.wire,
|
||||
});
|
||||
hashes.set(key, row.hash);
|
||||
}
|
||||
|
||||
for (const record of records.values()) {
|
||||
if (record.entityType !== entity.type || record.deleted) continue;
|
||||
if (record.revision === 0) continue;
|
||||
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
|
||||
if (entity.covers && !entity.covers(record.syncId)) continue;
|
||||
if (
|
||||
record.errorHash === DELETED_HASH &&
|
||||
record.error !== "unavailable"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ops.push({
|
||||
entityType: entity.type,
|
||||
syncId: record.syncId,
|
||||
baseRevision: record.revision,
|
||||
op: "delete",
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ops, hashes };
|
||||
}
|
||||
|
||||
private async push(link: SyncLink): Promise<void> {
|
||||
const { ops, hashes } = await this.outbox(link);
|
||||
this.state.pending = ops.length;
|
||||
if (ops.length === 0) return;
|
||||
|
||||
for (const batch of batches(ops)) {
|
||||
const { results } = await remoteJson<{ results: PushResult[] }>(
|
||||
link,
|
||||
"/sync/v2/push",
|
||||
{ method: "POST", body: { ops: batch }, timeoutMs: 120_000 },
|
||||
);
|
||||
const byKey = new Map(
|
||||
batch.map((op) => [recordKey(op.entityType, op.syncId), op]),
|
||||
);
|
||||
for (const result of results) {
|
||||
const key = recordKey(result.entityType, result.syncId);
|
||||
const op = byKey.get(key);
|
||||
if (!op) continue;
|
||||
await this.settle(link, op, result, hashes.get(key) ?? null);
|
||||
}
|
||||
this.state.pending = Math.max(0, this.state.pending - batch.length);
|
||||
}
|
||||
}
|
||||
|
||||
private async settle(
|
||||
link: SyncLink,
|
||||
op: PushOp,
|
||||
result: PushResult,
|
||||
hash: string | null,
|
||||
): Promise<void> {
|
||||
const userId = link.userId;
|
||||
const entity = getEntity(op.entityType);
|
||||
if (!entity) return;
|
||||
|
||||
if (result.status === "ok") {
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
hash: op.op === "delete" ? null : hash,
|
||||
deleted: op.op === "delete",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === "rejected") {
|
||||
await setRecordError(
|
||||
userId,
|
||||
op.entityType,
|
||||
op.syncId,
|
||||
result.reason,
|
||||
hash ?? DELETED_HASH,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// A live row the server could not send says nothing; keep ours to retry.
|
||||
if (!result.deleted && !result.row) return;
|
||||
|
||||
// Conflict: the server moved on since this device last saw the row. Its
|
||||
// version wins here, and a local edit it replaced is kept for the user.
|
||||
if (op.op === "upsert" && op.row) {
|
||||
await saveConflict(
|
||||
userId,
|
||||
op.entityType,
|
||||
op.syncId,
|
||||
op.row,
|
||||
result.revision,
|
||||
);
|
||||
}
|
||||
const resolvers = createResolvers(userId);
|
||||
if (result.deleted) {
|
||||
await deleteStoredRow(entity, userId, op.syncId);
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
hash: null,
|
||||
deleted: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...result.row, syncId: op.syncId },
|
||||
resolvers,
|
||||
);
|
||||
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
hash: written?.hash ?? null,
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchChanges(
|
||||
link: SyncLink,
|
||||
cursor: number,
|
||||
types?: string[],
|
||||
): Promise<{ changes: RemoteChange[]; cursor: number }> {
|
||||
const all: RemoteChange[] = [];
|
||||
let next = cursor;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const query = new URLSearchParams({
|
||||
cursor: String(next),
|
||||
limit: "1000",
|
||||
});
|
||||
if (types?.length) query.set("types", types.join(","));
|
||||
const result = await remoteJson<ChangesPage>(
|
||||
link,
|
||||
`/sync/v2/changes?${query.toString()}`,
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
all.push(...result.changes);
|
||||
next = result.nextCursor;
|
||||
this.learnTypes(result);
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
return { changes: all, cursor: next };
|
||||
}
|
||||
|
||||
private learnTypes(page: ChangesPage): void {
|
||||
this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type));
|
||||
this.state.serverTypes = page.entityTypes.map((entry) => ({
|
||||
type: entry.type,
|
||||
owner: entry.owner,
|
||||
readOnly: entry.readOnly,
|
||||
}));
|
||||
}
|
||||
|
||||
private async pull(link: SyncLink): Promise<void> {
|
||||
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
|
||||
const known = new Set(link.knownTypes);
|
||||
await this.apply(
|
||||
link,
|
||||
changes.filter(
|
||||
(change) =>
|
||||
known.has(change.entityType) &&
|
||||
!link.disabledTypes.includes(change.entityType),
|
||||
),
|
||||
false,
|
||||
);
|
||||
await updateLink({ cursor });
|
||||
}
|
||||
|
||||
/** Pulls every row of types this device has not pulled before. */
|
||||
private async backfill(link: SyncLink): Promise<void> {
|
||||
if (!this.serverTypes) {
|
||||
// Only the entity types are wanted, so a one-row page is enough.
|
||||
this.learnTypes(
|
||||
await remoteJson<ChangesPage>(
|
||||
link,
|
||||
`/sync/v2/changes?cursor=${link.cursor}&limit=1`,
|
||||
{ timeoutMs: 120_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
const fresh = listEntities()
|
||||
.map((entity) => entity.type)
|
||||
.filter(
|
||||
(type) =>
|
||||
!link.knownTypes.includes(type) &&
|
||||
!link.disabledTypes.includes(type) &&
|
||||
this.serverTypes?.has(type),
|
||||
);
|
||||
if (fresh.length === 0) return;
|
||||
|
||||
const { changes, cursor } = await this.fetchChanges(link, 0, fresh);
|
||||
await this.apply(link, changes, true);
|
||||
const current = (await getLink())!;
|
||||
await updateLink({
|
||||
knownTypes: [...new Set([...current.knownTypes, ...fresh])],
|
||||
// On a first pass the backfill is the whole pull, so it sets the cursor.
|
||||
...(link.knownTypes.length === 0 ? { cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes pulled changes. `adopt` is for a first pull of a type: a local row
|
||||
* with no record yet takes the server's version without a conflict.
|
||||
*/
|
||||
private async apply(
|
||||
link: SyncLink,
|
||||
changes: RemoteChange[],
|
||||
adopt: boolean,
|
||||
): Promise<void> {
|
||||
if (changes.length === 0) return;
|
||||
const userId = link.userId;
|
||||
const records = await listRecords(userId);
|
||||
let missed = false;
|
||||
const resolvers = createResolvers(userId, () => {
|
||||
missed = true;
|
||||
});
|
||||
const retry: RemoteChange[] = [];
|
||||
|
||||
for (const change of sortChanges(changes)) {
|
||||
const entity = getEntity(change.entityType);
|
||||
if (!entity) continue;
|
||||
const key = recordKey(change.entityType, change.syncId);
|
||||
const record = records.get(key);
|
||||
if (record && record.revision >= change.revision) continue;
|
||||
|
||||
try {
|
||||
if (!entity.readOnly) {
|
||||
await this.keepLocalEdit(
|
||||
entity,
|
||||
userId,
|
||||
change,
|
||||
record,
|
||||
adopt,
|
||||
resolvers,
|
||||
);
|
||||
}
|
||||
if (change.deleted) {
|
||||
await deleteStoredRow(entity, userId, change.syncId);
|
||||
await putRecord(userId, change.entityType, change.syncId, {
|
||||
revision: change.revision,
|
||||
hash: null,
|
||||
deleted: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
missed = false;
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...(change.row ?? {}), syncId: change.syncId },
|
||||
resolvers,
|
||||
);
|
||||
if (missed) retry.push(change);
|
||||
await this.recordApplied(entity, userId, change, resolvers);
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not apply a synced change", {
|
||||
operation: "sync_apply",
|
||||
entityType: change.entityType,
|
||||
syncId: change.syncId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rows that pointed at something written later in the same pull.
|
||||
for (const change of retry) {
|
||||
const entity = getEntity(change.entityType)!;
|
||||
try {
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...(change.row ?? {}), syncId: change.syncId },
|
||||
resolvers,
|
||||
);
|
||||
await this.recordApplied(entity, userId, change, resolvers);
|
||||
} catch {
|
||||
// The first write stands.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async recordApplied(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
change: RemoteChange,
|
||||
resolvers: ReturnType<typeof createResolvers>,
|
||||
): Promise<void> {
|
||||
const written = entity.readOnly
|
||||
? null
|
||||
: await loadWireRow(entity, userId, change.syncId, resolvers);
|
||||
await putRecord(userId, change.entityType, change.syncId, {
|
||||
revision: change.revision,
|
||||
hash: written?.hash ?? null,
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Saves a local edit the incoming change is about to replace. */
|
||||
private async keepLocalEdit(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
change: RemoteChange,
|
||||
record: SyncRecord | undefined,
|
||||
adopt: boolean,
|
||||
resolvers: ReturnType<typeof createResolvers>,
|
||||
): Promise<void> {
|
||||
if (!record && adopt) return;
|
||||
const local = await loadWireRow(
|
||||
entity,
|
||||
userId,
|
||||
change.syncId,
|
||||
resolvers,
|
||||
).catch(() => null);
|
||||
if (!local) return;
|
||||
if (record && !record.deleted && record.hash === local.hash) return;
|
||||
await saveConflict(
|
||||
userId,
|
||||
change.entityType,
|
||||
change.syncId,
|
||||
local.wire,
|
||||
change.revision,
|
||||
);
|
||||
}
|
||||
|
||||
private openEvents(link: SyncLink): void {
|
||||
const key = `${link.serverUrl}|${link.sessionToken}`;
|
||||
if (this.events && this.eventsFor === key) return;
|
||||
this.closeEvents();
|
||||
const controller = new AbortController();
|
||||
this.events = controller;
|
||||
this.eventsFor = key;
|
||||
void this.listen(link, controller);
|
||||
}
|
||||
|
||||
private closeEvents(): void {
|
||||
this.events?.abort();
|
||||
this.events = null;
|
||||
this.eventsFor = null;
|
||||
}
|
||||
|
||||
private async listen(
|
||||
link: SyncLink,
|
||||
controller: AbortController,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await remoteFetch(link, "/sync/v2/events", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.decode(chunk as Uint8Array, { stream: true });
|
||||
let index;
|
||||
while ((index = buffer.indexOf("\n\n")) >= 0) {
|
||||
const block = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 2);
|
||||
if (/^event: change$/m.test(block)) this.request(500);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Dropped or refused: the safety interval still syncs, and the next
|
||||
// successful pass reopens the stream.
|
||||
}
|
||||
if (this.events === controller) {
|
||||
this.events = null;
|
||||
this.eventsFor = null;
|
||||
if (!controller.signal.aborted) {
|
||||
setTimeout(() => this.request(0), 30_000).unref?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the link's proxy settings have to reach outside the backend. */
|
||||
async applyProxyConfig(): Promise<void> {
|
||||
await applyElectronProxyConfig(await getLink());
|
||||
}
|
||||
}
|
||||
|
||||
function* batches(ops: PushOp[]): Generator<PushOp[]> {
|
||||
let batch: PushOp[] = [];
|
||||
let bytes = 0;
|
||||
for (const op of ops) {
|
||||
const size = JSON.stringify(op).length;
|
||||
if (
|
||||
batch.length > 0 &&
|
||||
(batch.length >= MAX_BATCH_OPS || bytes + size > MAX_BATCH_BYTES)
|
||||
) {
|
||||
yield batch;
|
||||
batch = [];
|
||||
bytes = 0;
|
||||
}
|
||||
batch.push(op);
|
||||
bytes += size;
|
||||
}
|
||||
if (batch.length > 0) yield batch;
|
||||
}
|
||||
|
||||
let engine: SyncEngine | null = null;
|
||||
|
||||
export function getSyncEngine(): SyncEngine {
|
||||
if (!engine) engine = new SyncEngine();
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** Starts syncing on a desktop. A server install never links. */
|
||||
export function startDesktopSync(): void {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return;
|
||||
const instance = getSyncEngine();
|
||||
instance.start();
|
||||
void instance.applyProxyConfig().catch(() => {});
|
||||
}
|
||||
|
||||
export function defaultDeviceName(): string {
|
||||
return os.hostname() || "Termix Desktop";
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import type { Dispatcher } from "undici";
|
||||
import type { BasicAuth, ClientCert, ProxyHeader } from "./link-store.js";
|
||||
import type { BasicAuth, ProxyHeader } from "./link-store.js";
|
||||
|
||||
export interface RemoteTarget {
|
||||
serverUrl: string;
|
||||
@@ -17,7 +17,6 @@ export interface RemoteTarget {
|
||||
customHeaders?: ProxyHeader[];
|
||||
basicAuth?: BasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: ClientCert | null;
|
||||
}
|
||||
|
||||
export type RemoteErrorKind =
|
||||
@@ -25,8 +24,6 @@ export type RemoteErrorKind =
|
||||
| "offline"
|
||||
/** TLS failed and the certificate is not allowed. */
|
||||
| "tls"
|
||||
/** The server wants a client certificate and none was accepted. */
|
||||
| "client_cert"
|
||||
/** A proxy answered instead of Termix (its login page, a 404 page). */
|
||||
| "proxy"
|
||||
/** The proxy wants basic auth. */
|
||||
@@ -56,13 +53,6 @@ const TLS_CODES = new Set([
|
||||
"CERT_UNTRUSTED",
|
||||
]);
|
||||
|
||||
const CLIENT_CERT_CODES = new Set([
|
||||
"ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED",
|
||||
"ERR_SSL_SSLV3_ALERT_BAD_CERTIFICATE",
|
||||
"ERR_SSL_TLSV1_ALERT_UNKNOWN_CA",
|
||||
"ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE",
|
||||
]);
|
||||
|
||||
function envProxy(url: string): string | null {
|
||||
const proxy =
|
||||
process.env.https_proxy ||
|
||||
@@ -94,16 +84,10 @@ const dispatchers = new Map<string, Dispatcher>();
|
||||
function dispatcherFor(target: RemoteTarget): Dispatcher {
|
||||
const insecure = !!target.allowInvalidCertificate;
|
||||
const proxy = envProxy(target.serverUrl);
|
||||
const cert = target.clientCert;
|
||||
const key = `${insecure}|${proxy ?? ""}|${cert ? `${cert.passphrase}|${cert.pfx}` : ""}`;
|
||||
const key = `${insecure}|${proxy ?? ""}`;
|
||||
let dispatcher = dispatchers.get(key);
|
||||
if (!dispatcher) {
|
||||
const tls = {
|
||||
...(insecure ? { rejectUnauthorized: false } : {}),
|
||||
...(cert
|
||||
? { pfx: Buffer.from(cert.pfx, "base64"), passphrase: cert.passphrase }
|
||||
: {}),
|
||||
};
|
||||
const tls = insecure ? { rejectUnauthorized: false } : {};
|
||||
dispatcher = proxy
|
||||
? new ProxyAgent({ uri: proxy, requestTls: tls })
|
||||
: new Agent({ connect: tls });
|
||||
@@ -157,22 +141,10 @@ export function requestHeaders(target: RemoteTarget): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
// A server that requires a client certificate often just drops the
|
||||
// connection when it gets none, or one it does not accept.
|
||||
const DROPPED_CODES = new Set(["UND_ERR_SOCKET", "ECONNRESET"]);
|
||||
|
||||
function describeNetworkError(
|
||||
error: unknown,
|
||||
target?: RemoteTarget,
|
||||
): RemoteError {
|
||||
function describeNetworkError(error: unknown): RemoteError {
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })
|
||||
?.cause;
|
||||
const code = cause?.code ?? (error as { code?: string })?.code ?? "";
|
||||
const droppedOverTls =
|
||||
DROPPED_CODES.has(code) && target?.serverUrl.startsWith("https:");
|
||||
if (CLIENT_CERT_CODES.has(code) || droppedOverTls) {
|
||||
return new RemoteError("client_cert", cause?.message || code);
|
||||
}
|
||||
if (TLS_CODES.has(code) || /certificate/i.test(cause?.message ?? "")) {
|
||||
return new RemoteError("tls", cause?.message || code);
|
||||
}
|
||||
@@ -221,7 +193,7 @@ export async function remoteFetch(
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
throw describeNetworkError(error, target);
|
||||
throw describeNetworkError(error);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
@@ -256,7 +228,7 @@ export async function remoteStreamFetch(
|
||||
signal: request.signal,
|
||||
} as never);
|
||||
} catch (error) {
|
||||
throw describeNetworkError(error, target);
|
||||
throw describeNetworkError(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
/**
|
||||
* The desktop's links to servers, one row each in sync_link and at most one
|
||||
* per local user, since each link has its own replica user. Secrets (the
|
||||
* session token, proxy headers, basic auth) are stored encrypted with the
|
||||
* system key. Without a user, a call means the active account's link.
|
||||
* The desktop's link to a server, one row in sync_link. Secrets (the session
|
||||
* token, proxy headers, basic auth) are stored encrypted with the system key.
|
||||
*/
|
||||
|
||||
import { createSecureContext } from "node:tls";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActiveUserId } from "../accounts.js";
|
||||
import { syncLink } from "../../database/db/schema.js";
|
||||
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
@@ -37,14 +33,6 @@ export interface BasicAuth {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** A PFX/P12 client certificate for servers that require mutual TLS. */
|
||||
export interface ClientCert {
|
||||
/** Base64 of the PFX/P12 file. */
|
||||
pfx: string;
|
||||
passphrase: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface SyncLink {
|
||||
id: number;
|
||||
userId: string;
|
||||
@@ -55,14 +43,11 @@ export interface SyncLink {
|
||||
customHeaders: ProxyHeader[];
|
||||
basicAuth: BasicAuth | null;
|
||||
allowInvalidCertificate: boolean;
|
||||
clientCert: ClientCert | null;
|
||||
remoteUserId: string | null;
|
||||
remoteUsername: string | null;
|
||||
account: LinkAccount | null;
|
||||
/** Entity types switched off on this device. */
|
||||
disabledTypes: string[];
|
||||
/** Extra connection options for this server, kept open for new ones. */
|
||||
options: Record<string, unknown>;
|
||||
/** Entity types this device has fully pulled at least once. */
|
||||
knownTypes: string[];
|
||||
cursor: number;
|
||||
@@ -105,11 +90,7 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
sessionToken = null;
|
||||
}
|
||||
}
|
||||
const scope = parseJson<{
|
||||
disabled?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
clientCert?: string;
|
||||
}>(row.scope, {});
|
||||
const scope = parseJson<{ disabled?: string[] }>(row.scope, {});
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
@@ -120,16 +101,10 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
customHeaders: await decryptJson<ProxyHeader[]>(row.customHeaders, []),
|
||||
basicAuth: await decryptJson<BasicAuth | null>(row.basicAuth, null),
|
||||
allowInvalidCertificate: !!row.allowInvalidCertificate,
|
||||
clientCert: await decryptJson<ClientCert | null>(
|
||||
scope.clientCert ?? null,
|
||||
null,
|
||||
),
|
||||
remoteUserId: row.remoteUserId,
|
||||
remoteUsername: row.remoteUsername,
|
||||
account: parseJson<LinkAccount | null>(row.account, null),
|
||||
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
|
||||
options:
|
||||
scope.options && typeof scope.options === "object" ? scope.options : {},
|
||||
knownTypes: parseJson<string[]>(row.knownTypes, []),
|
||||
cursor: Number(row.cursor) || 0,
|
||||
status: (row.status as LinkStatus) || "idle",
|
||||
@@ -139,20 +114,8 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLinks(): Promise<SyncLink[]> {
|
||||
const rows = await db().select().from(syncLink);
|
||||
return Promise.all(rows.map(fromRow));
|
||||
}
|
||||
|
||||
/** The link of one local user, or of the active account when none is given. */
|
||||
export async function getLink(userId?: string): Promise<SyncLink | null> {
|
||||
const owner = userId ?? (await getActiveUserId());
|
||||
if (!owner) return null;
|
||||
const [row] = await db()
|
||||
.select()
|
||||
.from(syncLink)
|
||||
.where(eq(syncLink.userId, owner))
|
||||
.limit(1);
|
||||
export async function getLink(): Promise<SyncLink | null> {
|
||||
const [row] = await db().select().from(syncLink).limit(1);
|
||||
return row ? fromRow(row) : null;
|
||||
}
|
||||
|
||||
@@ -165,40 +128,12 @@ export interface NewLink {
|
||||
customHeaders?: ProxyHeader[];
|
||||
basicAuth?: BasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: ClientCert | null;
|
||||
remoteUserId?: string | null;
|
||||
remoteUsername?: string | null;
|
||||
}
|
||||
|
||||
/** Throws when the PFX is unreadable or the passphrase is wrong. */
|
||||
export function assertClientCert(cert: ClientCert): void {
|
||||
try {
|
||||
createSecureContext({
|
||||
pfx: Buffer.from(cert.pfx, "base64"),
|
||||
passphrase: cert.passphrase,
|
||||
});
|
||||
} catch {
|
||||
throw new Error("The client certificate or its passphrase is not valid");
|
||||
}
|
||||
}
|
||||
|
||||
async function sealScope(
|
||||
disabled: string[],
|
||||
options: Record<string, unknown>,
|
||||
clientCert: ClientCert | null,
|
||||
): Promise<string> {
|
||||
return JSON.stringify({
|
||||
disabled,
|
||||
options,
|
||||
...(clientCert
|
||||
? { clientCert: await encryptSystemSecret(JSON.stringify(clientCert)) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
if (link.clientCert) assertClientCert(link.clientCert);
|
||||
await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
|
||||
await db().delete(syncLink);
|
||||
await db()
|
||||
.insert(syncLink)
|
||||
.values({
|
||||
@@ -214,7 +149,6 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
? await encryptSystemSecret(JSON.stringify(link.basicAuth))
|
||||
: null,
|
||||
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
||||
scope: link.clientCert ? await sealScope([], {}, link.clientCert) : null,
|
||||
remoteUserId: link.remoteUserId ?? null,
|
||||
remoteUsername: link.remoteUsername ?? null,
|
||||
cursor: 0,
|
||||
@@ -223,7 +157,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
linkedAt: new Date().toISOString(),
|
||||
});
|
||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
||||
return (await getLink(link.userId))!;
|
||||
return (await getLink())!;
|
||||
}
|
||||
|
||||
export interface LinkPatch {
|
||||
@@ -233,12 +167,10 @@ export interface LinkPatch {
|
||||
customHeaders?: ProxyHeader[];
|
||||
basicAuth?: BasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: ClientCert | null;
|
||||
remoteUserId?: string | null;
|
||||
remoteUsername?: string | null;
|
||||
account?: LinkAccount | null;
|
||||
disabledTypes?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
knownTypes?: string[];
|
||||
cursor?: number;
|
||||
status?: LinkStatus;
|
||||
@@ -246,13 +178,9 @@ export interface LinkPatch {
|
||||
lastSyncAt?: string | null;
|
||||
}
|
||||
|
||||
export async function updateLink(
|
||||
patch: LinkPatch,
|
||||
userId?: string,
|
||||
): Promise<SyncLink | null> {
|
||||
const current = await getLink(userId);
|
||||
export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
|
||||
const current = await getLink();
|
||||
if (!current) return null;
|
||||
if (patch.clientCert) assertClientCert(patch.clientCert);
|
||||
const set: Partial<Row> = {};
|
||||
if (patch.serverName !== undefined) set.serverName = patch.serverName;
|
||||
if (patch.serverVersion !== undefined)
|
||||
@@ -282,16 +210,8 @@ export async function updateLink(
|
||||
if (patch.account !== undefined) {
|
||||
set.account = patch.account ? JSON.stringify(patch.account) : null;
|
||||
}
|
||||
if (
|
||||
patch.disabledTypes !== undefined ||
|
||||
patch.options !== undefined ||
|
||||
patch.clientCert !== undefined
|
||||
) {
|
||||
set.scope = await sealScope(
|
||||
patch.disabledTypes ?? current.disabledTypes,
|
||||
patch.options ?? current.options,
|
||||
patch.clientCert === undefined ? current.clientCert : patch.clientCert,
|
||||
);
|
||||
if (patch.disabledTypes !== undefined) {
|
||||
set.scope = JSON.stringify({ disabled: patch.disabledTypes });
|
||||
}
|
||||
if (patch.knownTypes !== undefined) {
|
||||
set.knownTypes = JSON.stringify(patch.knownTypes);
|
||||
@@ -304,12 +224,10 @@ export async function updateLink(
|
||||
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
|
||||
DatabaseSaveTrigger.triggerSave("sync_link");
|
||||
}
|
||||
return getLink(current.userId);
|
||||
return getLink();
|
||||
}
|
||||
|
||||
export async function deleteLink(userId?: string): Promise<void> {
|
||||
const owner = userId ?? (await getActiveUserId());
|
||||
if (!owner) return;
|
||||
await db().delete(syncLink).where(eq(syncLink.userId, owner));
|
||||
export async function deleteLink(): Promise<void> {
|
||||
await db().delete(syncLink);
|
||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
/**
|
||||
* The desktop's own sync routes, under /sync/link. They only exist in the
|
||||
* embedded desktop backend; on a server they answer 404.
|
||||
*/
|
||||
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from "express";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { hosts, sshCredentials } from "../../database/db/schema.js";
|
||||
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
|
||||
import { registerCoreSyncEntities } from "../entities.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
listStoredRows,
|
||||
regenerateSyncIds,
|
||||
writeWireRow,
|
||||
} from "../store.js";
|
||||
import {
|
||||
clearRecordErrors,
|
||||
deleteConflict,
|
||||
deleteRecordsForUser,
|
||||
getConflict,
|
||||
listConflicts,
|
||||
listRecordErrors,
|
||||
} from "../records.js";
|
||||
import { SYNC_PROTOCOL_VERSION } from "../protocol.js";
|
||||
import {
|
||||
createLink,
|
||||
deleteLink,
|
||||
getLink,
|
||||
updateLink,
|
||||
type BasicAuth,
|
||||
type ProxyHeader,
|
||||
type SyncLink,
|
||||
} from "./link-store.js";
|
||||
import {
|
||||
normalizeServerUrl,
|
||||
RemoteError,
|
||||
remoteJson,
|
||||
type RemoteTarget,
|
||||
} from "./http.js";
|
||||
import { defaultDeviceName, getSyncEngine } from "./engine.js";
|
||||
import { applyElectronProxyConfig } from "./electron-proxy.js";
|
||||
import { lastRemotePlugins } from "./plugins.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
function desktopOnly(_req: Request, res: Response, next: NextFunction) {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
const guard = [desktopOnly, authenticateJWT];
|
||||
|
||||
function userOf(req: Request): string {
|
||||
return (req as AuthenticatedRequest).userId;
|
||||
}
|
||||
|
||||
function readHeaders(value: unknown): ProxyHeader[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(
|
||||
(header): header is ProxyHeader =>
|
||||
!!header &&
|
||||
typeof header.name === "string" &&
|
||||
typeof header.value === "string" &&
|
||||
/^[A-Za-z0-9-]{1,100}$/.test(header.name.trim()),
|
||||
)
|
||||
.map((header) => ({ name: header.name.trim(), value: header.value }))
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
function readBasicAuth(value: unknown): BasicAuth | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const { username, password } = value as Record<string, unknown>;
|
||||
if (typeof username !== "string" || !username) return null;
|
||||
return {
|
||||
username,
|
||||
password: typeof password === "string" ? password : "",
|
||||
};
|
||||
}
|
||||
|
||||
function readTarget(body: Record<string, unknown>): RemoteTarget | null {
|
||||
const serverUrl =
|
||||
typeof body.serverUrl === "string"
|
||||
? normalizeServerUrl(body.serverUrl)
|
||||
: null;
|
||||
if (!serverUrl) return null;
|
||||
return {
|
||||
serverUrl,
|
||||
customHeaders: readHeaders(body.customHeaders),
|
||||
basicAuth: readBasicAuth(body.basicAuth),
|
||||
allowInvalidCertificate: body.allowInvalidCertificate === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the sync panel can show about each entity type. */
|
||||
function describeEntities(link: SyncLink | null) {
|
||||
registerCoreSyncEntities();
|
||||
const names = new Map<string, string>();
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
names.set(plugin.id, plugin.manifest.name);
|
||||
}
|
||||
for (const plugin of lastRemotePlugins()) {
|
||||
if (!names.has(plugin.id)) names.set(plugin.id, plugin.name);
|
||||
}
|
||||
const serverTypes = getSyncEngine().state.serverTypes;
|
||||
const seen = new Set<string>();
|
||||
const entries = [
|
||||
...listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
...serverTypes,
|
||||
];
|
||||
return entries
|
||||
.filter((entry) => {
|
||||
if (seen.has(entry.type) || entry.type === "accountProfile") return false;
|
||||
seen.add(entry.type);
|
||||
return true;
|
||||
})
|
||||
.map((entry) => ({
|
||||
type: entry.type,
|
||||
owner: entry.owner,
|
||||
ownerName:
|
||||
entry.owner === "core" ? null : (names.get(entry.owner) ?? entry.owner),
|
||||
readOnly: entry.readOnly,
|
||||
onServer: serverTypes.some((server) => server.type === entry.type),
|
||||
onDevice: !!getEntity(entry.type),
|
||||
enabled: !link?.disabledTypes.includes(entry.type),
|
||||
}));
|
||||
}
|
||||
|
||||
async function statusBody() {
|
||||
const link = await getLink();
|
||||
const engine = getSyncEngine();
|
||||
if (!link) {
|
||||
return { linked: false, entities: describeEntities(null) };
|
||||
}
|
||||
return {
|
||||
linked: true,
|
||||
serverUrl: link.serverUrl,
|
||||
serverName: link.serverName,
|
||||
serverVersion: link.serverVersion,
|
||||
account: link.account ?? {
|
||||
username: link.remoteUsername ?? undefined,
|
||||
},
|
||||
status: engine.state.running ? "syncing" : link.status,
|
||||
lastError: link.lastError,
|
||||
linkedAt: link.linkedAt,
|
||||
lastSyncAt: link.lastSyncAt,
|
||||
pending: engine.state.pending,
|
||||
conflicts: (await listConflicts(link.userId)).length,
|
||||
errors: (await listRecordErrors(link.userId)).length,
|
||||
firstSyncDone: link.knownTypes.length > 0,
|
||||
allowInvalidCertificate: link.allowInvalidCertificate,
|
||||
customHeaders: link.customHeaders.map((header) => ({
|
||||
name: header.name,
|
||||
value: "",
|
||||
set: true,
|
||||
})),
|
||||
basicAuth: link.basicAuth
|
||||
? { username: link.basicAuth.username, password: "", set: true }
|
||||
: null,
|
||||
entities: describeEntities(link),
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes read-only copies of what other users shared with the account. */
|
||||
async function removeSharedCopies(userId: string): Promise<void> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
const sharedHosts = await db
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
|
||||
const { deleteOwnedHost } = await import("../../hosts/delete-host.js");
|
||||
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
|
||||
|
||||
const sharedCredentials = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
isNotNull(sshCredentials.sharedSource),
|
||||
),
|
||||
);
|
||||
const { deleteOwnedCredential } =
|
||||
await import("../../hosts/delete-credential.js");
|
||||
for (const credential of sharedCredentials) {
|
||||
await deleteOwnedCredential(userId, credential.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every row that syncs, keeping local-only ones. */
|
||||
async function wipeSyncedData(userId: string): Promise<void> {
|
||||
const entities = [...listEntities()].reverse();
|
||||
for (const entity of entities) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
for (const row of await entity.load(userId)) {
|
||||
await entity.erase?.(userId, String(row.syncId));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entity.table || entity.singleton) continue;
|
||||
for (const row of await listStoredRows(entity, userId)) {
|
||||
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
|
||||
if (typeof row.syncId !== "string") continue;
|
||||
await deleteStoredRow(entity, userId, row.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not clear synced data", {
|
||||
operation: "sync_wipe",
|
||||
entityType: entity.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function problemFor(error: unknown) {
|
||||
if (!(error instanceof RemoteError)) {
|
||||
return { problem: "unreachable", message: String(error) };
|
||||
}
|
||||
switch (error.kind) {
|
||||
case "tls":
|
||||
return { problem: "tls_untrusted", message: error.message };
|
||||
case "basic_auth":
|
||||
return { problem: "basic_auth_required", message: error.message };
|
||||
case "proxy":
|
||||
return error.status === 404
|
||||
? { problem: "not_termix", message: error.message }
|
||||
: { problem: "proxy_login", message: error.message };
|
||||
case "signed_out":
|
||||
case "server":
|
||||
// A Termix older than 2.9 has no /sync/v2 and answers with an error.
|
||||
return { problem: "version_mismatch", message: error.message };
|
||||
default:
|
||||
return { problem: "unreachable", message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/status:
|
||||
* get:
|
||||
* summary: This desktop's link to a server
|
||||
* description: Desktop only. Whether the app is linked, to which server and account, how syncing is going, and which kinds of data sync.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The link status.
|
||||
*/
|
||||
router.get("/link/status", ...guard, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to read sync status", error, {
|
||||
operation: "sync_status",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read sync status" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/probe:
|
||||
* post:
|
||||
* summary: Check a server address before linking
|
||||
* description: Desktop only. Tries the server with the given proxy settings and says what is in the way, if anything. On success the proxy settings are also applied to the desktop's browser session so the sign-in page loads.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The server's name and version, or the problem found.
|
||||
*/
|
||||
router.post("/link/probe", ...guard, async (req: Request, res: Response) => {
|
||||
const target = readTarget(req.body ?? {});
|
||||
if (!target) return res.json({ ok: false, problem: "invalid_url" });
|
||||
try {
|
||||
const info = await remoteJson<{
|
||||
termix?: boolean;
|
||||
name?: string;
|
||||
version?: string;
|
||||
protocol?: number;
|
||||
}>(target, "/sync/v2/info", { timeoutMs: 15_000 });
|
||||
if (!info?.termix) return res.json({ ok: false, problem: "not_termix" });
|
||||
if (info.protocol !== SYNC_PROTOCOL_VERSION) {
|
||||
return res.json({
|
||||
ok: false,
|
||||
problem: "version_mismatch",
|
||||
version: info.version,
|
||||
});
|
||||
}
|
||||
await applyElectronProxyConfig({
|
||||
serverUrl: target.serverUrl,
|
||||
customHeaders: target.customHeaders ?? [],
|
||||
basicAuth: target.basicAuth ?? null,
|
||||
allowInvalidCertificate: !!target.allowInvalidCertificate,
|
||||
}).catch(() => {});
|
||||
res.json({
|
||||
ok: true,
|
||||
serverUrl: target.serverUrl,
|
||||
name: info.name ?? "Termix",
|
||||
version: info.version ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({ ok: false, ...problemFor(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/preview:
|
||||
* get:
|
||||
* summary: How much this desktop has that would sync
|
||||
* description: Desktop only. Row counts per kind of data, shown before choosing whether to merge this desktop's data into the account or replace it.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Counts per entity type.
|
||||
*/
|
||||
router.get("/link/preview", ...guard, async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
registerCoreSyncEntities();
|
||||
const counts: Record<string, number> = {};
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
counts[entity.type] = (await entity.load(userId)).length;
|
||||
continue;
|
||||
}
|
||||
if (!entity.table) continue;
|
||||
counts[entity.type] = (await listStoredRows(entity, userId)).filter(
|
||||
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
|
||||
).length;
|
||||
} catch {
|
||||
counts[entity.type] = 0;
|
||||
}
|
||||
}
|
||||
res.json({ counts });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/complete:
|
||||
* post:
|
||||
* summary: Link this desktop to a server account
|
||||
* description: Desktop only. Takes the session from signing in to the server, trades it for a long-lived desktop session, and starts syncing. "merge" uploads this desktop's data into the account; "replace" deletes it here first and takes the account's.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Linked; the new status.
|
||||
* 400:
|
||||
* description: Missing server or session.
|
||||
* 502:
|
||||
* description: The server refused the session.
|
||||
*/
|
||||
router.post("/link/complete", ...guard, async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const body = req.body ?? {};
|
||||
const target = readTarget(body);
|
||||
const token = typeof body.token === "string" ? body.token : "";
|
||||
const mode = body.mode === "replace" ? "replace" : "merge";
|
||||
if (!target || !token) {
|
||||
return res.status(400).json({ error: "Missing server or session" });
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceName =
|
||||
typeof body.deviceName === "string" && body.deviceName.trim()
|
||||
? body.deviceName.trim()
|
||||
: defaultDeviceName();
|
||||
const linked = await remoteJson<{
|
||||
token: string;
|
||||
user: { id: string; username: string };
|
||||
server?: { version?: string | null };
|
||||
}>({ ...target, sessionToken: token }, "/sync/v2/link", {
|
||||
method: "POST",
|
||||
body: { deviceName },
|
||||
});
|
||||
|
||||
const previous = await getLink();
|
||||
if (previous) {
|
||||
await removeSharedCopies(previous.userId);
|
||||
await deleteRecordsForUser(previous.userId);
|
||||
}
|
||||
if (mode === "replace") {
|
||||
await wipeSyncedData(userId);
|
||||
}
|
||||
await deleteRecordsForUser(userId);
|
||||
|
||||
const link = await createLink({
|
||||
userId,
|
||||
serverUrl: target.serverUrl,
|
||||
serverName: typeof body.serverName === "string" ? body.serverName : null,
|
||||
serverVersion: linked.server?.version ?? null,
|
||||
sessionToken: linked.token,
|
||||
customHeaders: target.customHeaders,
|
||||
basicAuth: target.basicAuth,
|
||||
allowInvalidCertificate: target.allowInvalidCertificate,
|
||||
remoteUserId: linked.user.id,
|
||||
remoteUsername: linked.user.username,
|
||||
});
|
||||
const engine = getSyncEngine();
|
||||
engine.reset();
|
||||
await applyElectronProxyConfig(link).catch(() => {});
|
||||
engine.request(0);
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
syncLogger.warn("Linking to a server failed", {
|
||||
operation: "sync_link_complete",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(502).json({ ...problemFor(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/relogin:
|
||||
* post:
|
||||
* summary: Sign this desktop back in after its session ended
|
||||
* description: Desktop only. Takes a new sign-in session for the same account and resumes syncing where it stopped.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Signed in again; the new status.
|
||||
* 409:
|
||||
* description: The sign-in was for a different account.
|
||||
*/
|
||||
router.post("/link/relogin", ...guard, async (req: Request, res: Response) => {
|
||||
const token = typeof req.body?.token === "string" ? req.body.token : "";
|
||||
const link = await getLink();
|
||||
if (!link || !token) return res.status(400).json({ error: "Not linked" });
|
||||
try {
|
||||
const linked = await remoteJson<{
|
||||
token: string;
|
||||
user: { id: string; username: string };
|
||||
}>({ ...link, sessionToken: token }, "/sync/v2/link", {
|
||||
method: "POST",
|
||||
body: { deviceName: defaultDeviceName() },
|
||||
});
|
||||
if (link.remoteUserId && linked.user.id !== link.remoteUserId) {
|
||||
return res.status(409).json({ error: "different_account" });
|
||||
}
|
||||
await updateLink({
|
||||
sessionToken: linked.token,
|
||||
status: "idle",
|
||||
lastError: null,
|
||||
});
|
||||
getSyncEngine().request(0);
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
res.status(502).json({ ...problemFor(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/unlink:
|
||||
* post:
|
||||
* summary: Unlink this desktop from its server
|
||||
* description: Desktop only. Ends the desktop's session on the server when it can be reached. With keepData the synced data stays on this device as its own; otherwise it is deleted. Copies of what others shared with the account are always removed.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Unlinked.
|
||||
*/
|
||||
router.post("/link/unlink", ...guard, async (req: Request, res: Response) => {
|
||||
const keepData = req.body?.keepData !== false;
|
||||
const link = await getLink();
|
||||
if (!link) return res.json(await statusBody());
|
||||
const userId = link.userId;
|
||||
try {
|
||||
await remoteJson(link, "/sync/v2/unlink", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
timeoutMs: 10_000,
|
||||
}).catch(() => {});
|
||||
|
||||
const engine = getSyncEngine();
|
||||
engine.reset();
|
||||
await removeSharedCopies(userId);
|
||||
if (keepData) {
|
||||
// New syncIds so this data reads as new if it is ever linked again.
|
||||
for (const entity of listEntities()) {
|
||||
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
|
||||
}
|
||||
} else {
|
||||
await wipeSyncedData(userId);
|
||||
}
|
||||
await deleteRecordsForUser(userId);
|
||||
await deleteLink();
|
||||
await applyElectronProxyConfig(null).catch(() => {});
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
syncLogger.error("Unlinking failed", error, { operation: "sync_unlink" });
|
||||
res.status(500).json({ error: "Failed to unlink" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/sync:
|
||||
* post:
|
||||
* summary: Sync now
|
||||
* description: Desktop only. Runs a sync pass and waits for it.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The status after the pass.
|
||||
*/
|
||||
router.post("/link/sync", ...guard, async (_req: Request, res: Response) => {
|
||||
await getSyncEngine().syncNow();
|
||||
res.json(await statusBody());
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/settings:
|
||||
* put:
|
||||
* summary: Change what syncs and how the server is reached
|
||||
* description: Desktop only. disabledTypes lists the kinds of data that stay on this device. Proxy headers and basic auth left blank keep their saved values.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new status.
|
||||
* 400:
|
||||
* description: Not linked.
|
||||
*/
|
||||
router.put("/link/settings", ...guard, async (req: Request, res: Response) => {
|
||||
const link = await getLink();
|
||||
if (!link) return res.status(400).json({ error: "Not linked" });
|
||||
const body = req.body ?? {};
|
||||
const patch: Parameters<typeof updateLink>[0] = {};
|
||||
|
||||
if (Array.isArray(body.disabledTypes)) {
|
||||
const disabled = body.disabledTypes.filter(
|
||||
(type: unknown): type is string =>
|
||||
typeof type === "string" && type !== "accountProfile",
|
||||
);
|
||||
const changed = new Set([
|
||||
...disabled.filter((type: string) => !link.disabledTypes.includes(type)),
|
||||
...link.disabledTypes.filter((type) => !disabled.includes(type)),
|
||||
]);
|
||||
patch.disabledTypes = disabled;
|
||||
// A type switched back on is pulled again in full.
|
||||
patch.knownTypes = link.knownTypes.filter((type) => !changed.has(type));
|
||||
}
|
||||
if (Array.isArray(body.customHeaders)) {
|
||||
const saved = new Map(link.customHeaders.map((h) => [h.name, h.value]));
|
||||
patch.customHeaders = (body.customHeaders as unknown[])
|
||||
.filter(
|
||||
(header): header is { name: string; value?: string } =>
|
||||
!!header && typeof (header as { name?: unknown }).name === "string",
|
||||
)
|
||||
.map((header) => ({
|
||||
name: header.name,
|
||||
value: header.value || saved.get(header.name) || "",
|
||||
}))
|
||||
.filter((header) => header.value);
|
||||
patch.customHeaders = readHeaders(patch.customHeaders);
|
||||
}
|
||||
if (body.basicAuth === null) {
|
||||
patch.basicAuth = null;
|
||||
} else if (body.basicAuth && typeof body.basicAuth === "object") {
|
||||
const next = readBasicAuth(body.basicAuth);
|
||||
if (next && !next.password && link.basicAuth) {
|
||||
next.password = link.basicAuth.password;
|
||||
}
|
||||
patch.basicAuth = next;
|
||||
}
|
||||
if (typeof body.allowInvalidCertificate === "boolean") {
|
||||
patch.allowInvalidCertificate = body.allowInvalidCertificate;
|
||||
}
|
||||
|
||||
const updated = await updateLink(patch);
|
||||
await applyElectronProxyConfig(updated).catch(() => {});
|
||||
getSyncEngine().request(0);
|
||||
res.json(await statusBody());
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/conflicts:
|
||||
* get:
|
||||
* summary: Local edits that lost to a newer edit on the server
|
||||
* description: Desktop only. The server's version was kept; each entry holds this device's version so it can be restored.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The conflicts, oldest first.
|
||||
*/
|
||||
router.get("/link/conflicts", ...guard, async (req: Request, res: Response) => {
|
||||
const conflicts = await listConflicts(userOf(req));
|
||||
res.json({
|
||||
conflicts: conflicts.map((conflict) => {
|
||||
let row: SyncRow = {};
|
||||
try {
|
||||
row = JSON.parse(conflict.localRow);
|
||||
} catch {
|
||||
row = {};
|
||||
}
|
||||
return {
|
||||
id: conflict.id,
|
||||
entityType: conflict.entityType,
|
||||
syncId: conflict.syncId,
|
||||
name: row.name ?? row.title ?? row.label ?? row.ip ?? conflict.syncId,
|
||||
createdAt: conflict.createdAt,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/conflicts/{id}:
|
||||
* post:
|
||||
* summary: Settle a conflict
|
||||
* description: Desktop only. keep "mine" puts this device's version back, and the next sync sends it to the server. keep "server" drops it.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Settled.
|
||||
* 404:
|
||||
* description: No such conflict.
|
||||
*/
|
||||
router.post(
|
||||
"/link/conflicts/:id",
|
||||
...guard,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const conflict = await getConflict(userId, Number(req.params.id));
|
||||
if (!conflict) return res.status(404).json({ error: "Not found" });
|
||||
try {
|
||||
if (req.body?.keep === "mine") {
|
||||
const entity = getEntity(conflict.entityType);
|
||||
if (entity && !entity.readOnly) {
|
||||
const row = JSON.parse(conflict.localRow) as SyncRow;
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...row, syncId: conflict.syncId },
|
||||
createResolvers(userId),
|
||||
);
|
||||
}
|
||||
}
|
||||
await deleteConflict(userId, conflict.id);
|
||||
getSyncEngine().request(0);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not settle a sync conflict", {
|
||||
operation: "sync_conflict",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to settle conflict" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/errors:
|
||||
* get:
|
||||
* summary: Changes the server refused
|
||||
* description: Desktop only. Each entry names the record and why the server refused it. A refused change is not sent again until it changes or is retried.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The refused changes.
|
||||
*/
|
||||
router.get("/link/errors", ...guard, async (req: Request, res: Response) => {
|
||||
const errors = await listRecordErrors(userOf(req));
|
||||
res.json({
|
||||
errors: errors.map((record) => ({
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
reason: record.error,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/errors/retry:
|
||||
* post:
|
||||
* summary: Send refused changes again
|
||||
* description: Desktop only. Clears every refusal and syncs.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The status after the pass.
|
||||
*/
|
||||
router.post(
|
||||
"/link/errors/retry",
|
||||
...guard,
|
||||
async (req: Request, res: Response) => {
|
||||
await clearRecordErrors(userOf(req));
|
||||
await getSyncEngine().syncNow();
|
||||
res.json(await statusBody());
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/session:
|
||||
* get:
|
||||
* summary: The linked server and session, for connecting to it directly
|
||||
* description: Desktop only. Used when a host is set to connect through the server rather than from this device.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The server address and session token, or nulls when not linked.
|
||||
*/
|
||||
router.get("/link/session", ...guard, async (_req: Request, res: Response) => {
|
||||
const link = await getLink();
|
||||
if (!link?.sessionToken || link.status === "signed_out") {
|
||||
return res.json({ serverUrl: null, token: null });
|
||||
}
|
||||
res.json({ serverUrl: link.serverUrl, token: link.sessionToken });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,544 +0,0 @@
|
||||
/**
|
||||
* What the sync provider plugin gets from core through ctx.sync.host: the
|
||||
* replica store, the change feed, the linked server registry, sessions and
|
||||
* a few helpers. Core stays neutral; the plugin decides what to do with it.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import * as tar from "tar";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { PluginSyncHost, SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
import { sendCoreAlert } from "../notify/core-notify.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import {
|
||||
getEntity,
|
||||
listEntities,
|
||||
onEntitiesChanged,
|
||||
type RegisteredSyncEntity,
|
||||
} from "../plugins/sync-registry.js";
|
||||
import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
createCurrentPluginRepository,
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { resolveBrandingSettings } from "../database/routes/branding-settings.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
listStoredRows,
|
||||
loadWireRow,
|
||||
loadWireRows,
|
||||
regenerateSyncIds,
|
||||
writeWireRow,
|
||||
type Resolvers,
|
||||
} from "./store.js";
|
||||
import {
|
||||
clearRecordErrors,
|
||||
deleteConflict,
|
||||
deleteRecordsForUser,
|
||||
getConflict,
|
||||
listChanges,
|
||||
listConflicts,
|
||||
listRecordErrors,
|
||||
listRecords,
|
||||
putRecord,
|
||||
recordKey,
|
||||
saveConflict,
|
||||
setRecordError,
|
||||
} from "./records.js";
|
||||
import { orderSelfReferences } from "./wire.js";
|
||||
import { onLocalWrite } from "./server/write-listeners.js";
|
||||
import {
|
||||
createLink,
|
||||
deleteLink,
|
||||
getLink,
|
||||
listLinks,
|
||||
updateLink,
|
||||
} from "./client/link-store.js";
|
||||
import {
|
||||
createAccount,
|
||||
getActiveUserId,
|
||||
listAccounts,
|
||||
removeAccount,
|
||||
setActiveUserId,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
normalizeServerUrl,
|
||||
RemoteError,
|
||||
remoteFetch,
|
||||
remoteJson,
|
||||
} from "./client/http.js";
|
||||
import { applyElectronProxyConfig } from "./client/electron-proxy.js";
|
||||
import { lastRemotePlugins, mirrorPlugins } from "./client/plugins.js";
|
||||
import { defaultDeviceName } from "./client/device.js";
|
||||
|
||||
function entityOf(type: string): RegisteredSyncEntity {
|
||||
const entity = getEntity(type);
|
||||
if (!entity) throw new Error(`Unknown sync entity "${type}"`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
function info(entity: RegisteredSyncEntity) {
|
||||
return {
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
covers: entity.covers,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deletes every row that syncs, keeping local-only ones. */
|
||||
async function wipeSyncedData(userId: string): Promise<void> {
|
||||
const entities = [...listEntities()].reverse();
|
||||
for (const entity of entities) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
for (const row of await entity.load(userId)) {
|
||||
await entity.erase?.(userId, String(row.syncId));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entity.table || entity.singleton) continue;
|
||||
for (const row of await listStoredRows(entity, userId)) {
|
||||
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
|
||||
if (typeof row.syncId !== "string") continue;
|
||||
await deleteStoredRow(entity, userId, row.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not clear synced data", {
|
||||
operation: "sync_wipe",
|
||||
entityType: entity.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes read-only copies of what other users shared with the account. */
|
||||
async function removeSharedCopies(userId: string): Promise<void> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
const sharedHosts = await db
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
|
||||
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
||||
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
|
||||
|
||||
const sharedCredentials = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
isNotNull(sshCredentials.sharedSource),
|
||||
),
|
||||
);
|
||||
const { deleteOwnedCredential } =
|
||||
await import("../hosts/delete-credential.js");
|
||||
for (const credential of sharedCredentials) {
|
||||
await deleteOwnedCredential(userId, credential.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCoreEntities(): Promise<void> {
|
||||
const { registerCoreSyncEntities } = await import("./entities.js");
|
||||
registerCoreSyncEntities();
|
||||
}
|
||||
|
||||
async function counts(userId: string): Promise<Record<string, number>> {
|
||||
await ensureCoreEntities();
|
||||
const result: Record<string, number> = {};
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
result[entity.type] = (await entity.load(userId)).length;
|
||||
continue;
|
||||
}
|
||||
if (!entity.table) continue;
|
||||
result[entity.type] = (await listStoredRows(entity, userId)).filter(
|
||||
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
|
||||
).length;
|
||||
} catch {
|
||||
result[entity.type] = 0;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listPluginsForDesktop() {
|
||||
const records = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((record) => [
|
||||
record.id,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const grants = createCurrentPluginPermissionGrantRepository();
|
||||
const plugins = [];
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
plugins.push({
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
// A bundled plugin updated from a signed .tmxplug can be mirrored.
|
||||
artifact: !!plugin.artifact,
|
||||
enabled: records.get(plugin.id)?.state === "enabled",
|
||||
active: plugin.state === "active",
|
||||
desktop: resolvePluginDesktopMode(plugin.manifest),
|
||||
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
|
||||
granted: (await grants.listByPlugin(plugin.id)).map(
|
||||
(grant) => grant.capability,
|
||||
),
|
||||
});
|
||||
}
|
||||
const { customRegistryKeys } = await import("../plugins/registries.js");
|
||||
return { plugins, registryKeys: await customRegistryKeys() };
|
||||
}
|
||||
|
||||
async function sendPackage(
|
||||
pluginId: string,
|
||||
target: unknown,
|
||||
): Promise<boolean> {
|
||||
const res = target as Response;
|
||||
const plugin = getPluginRuntime()
|
||||
.loader.list()
|
||||
.find((candidate) => candidate.id === pluginId);
|
||||
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) return false;
|
||||
const artifact = plugin.artifact ?? "";
|
||||
res.setHeader("Content-Type", "application/gzip");
|
||||
if (fs.existsSync(artifact)) {
|
||||
const sig = `${artifact}.sig`;
|
||||
if (fs.existsSync(sig)) {
|
||||
res.setHeader(
|
||||
"X-Termix-Signature",
|
||||
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString("base64"),
|
||||
);
|
||||
}
|
||||
fs.createReadStream(artifact).pipe(res);
|
||||
return true;
|
||||
}
|
||||
const temp = path.join(
|
||||
os.tmpdir(),
|
||||
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
|
||||
);
|
||||
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
|
||||
const stream = fs.createReadStream(temp);
|
||||
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
|
||||
stream.pipe(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createSyncHost(): PluginSyncHost {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
return {
|
||||
embedded: process.env.ELECTRON_EMBEDDED === "true",
|
||||
info: {
|
||||
appName: async () => {
|
||||
try {
|
||||
return (
|
||||
await resolveBrandingSettings(createCurrentSettingsRepository())
|
||||
).appName;
|
||||
} catch {
|
||||
return "Termix";
|
||||
}
|
||||
},
|
||||
version: () => getLocalVersion(),
|
||||
},
|
||||
|
||||
entities: {
|
||||
list: () => listEntities().map(info),
|
||||
get: (type) => {
|
||||
const entity = getEntity(type);
|
||||
return entity ? info(entity) : undefined;
|
||||
},
|
||||
ensureCore: () => ensureCoreEntities(),
|
||||
onChanged: (listener) => onEntitiesChanged(listener),
|
||||
onLocalWrite: (listener) => onLocalWrite(listener),
|
||||
},
|
||||
|
||||
replica: {
|
||||
resolvers: (userId, onMissing) => createResolvers(userId, onMissing),
|
||||
loadRows: async (type, userId, resolvers) => {
|
||||
const result = await loadWireRows(
|
||||
entityOf(type),
|
||||
userId,
|
||||
resolvers as Resolvers,
|
||||
);
|
||||
return { rows: result.rows, failed: result.failed };
|
||||
},
|
||||
loadRow: (type, userId, syncId, resolvers) =>
|
||||
loadWireRow(entityOf(type), userId, syncId, resolvers as Resolvers),
|
||||
writeRow: async (type, userId, wire, resolvers) => {
|
||||
await writeWireRow(
|
||||
entityOf(type),
|
||||
userId,
|
||||
wire,
|
||||
resolvers as Resolvers,
|
||||
);
|
||||
},
|
||||
deleteRow: async (type, userId, syncId) => {
|
||||
await deleteStoredRow(entityOf(type), userId, syncId);
|
||||
},
|
||||
orderSelfReferences: (type, rows) =>
|
||||
orderSelfReferences(entityOf(type), rows as SyncRow[]),
|
||||
counts: (userId) => counts(userId),
|
||||
wipe: (userId) => wipeSyncedData(userId),
|
||||
regenerateSyncIds: async (userId) => {
|
||||
for (const entity of listEntities()) {
|
||||
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
|
||||
}
|
||||
},
|
||||
removeSharedCopies: (userId) => removeSharedCopies(userId),
|
||||
records: {
|
||||
list: (userId) => listRecords(userId) as never,
|
||||
put: (userId, type, syncId, values) =>
|
||||
putRecord(userId, type, syncId, values),
|
||||
setError: (userId, type, syncId, reason, hash) =>
|
||||
setRecordError(userId, type, syncId, reason, hash),
|
||||
clearErrors: (userId) => clearRecordErrors(userId),
|
||||
listErrors: (userId) => listRecordErrors(userId) as never,
|
||||
deleteForUser: (userId) => deleteRecordsForUser(userId),
|
||||
key: (type, syncId) => recordKey(type, syncId),
|
||||
},
|
||||
conflicts: {
|
||||
save: (userId, type, syncId, row, revision) =>
|
||||
saveConflict(userId, type, syncId, row, revision),
|
||||
list: (userId) => listConflicts(userId) as never,
|
||||
get: (userId, id) => getConflict(userId, id) as never,
|
||||
delete: (userId, id) => deleteConflict(userId, id),
|
||||
},
|
||||
},
|
||||
|
||||
feed: {
|
||||
withUserLock: async (userId, run) => {
|
||||
const { withUserLock } = await import("./server/feed.js");
|
||||
return withUserLock(userId, run);
|
||||
},
|
||||
changes: async (userId, cursor, limit, types) => {
|
||||
const { withUserLock, reconcileUser } =
|
||||
await import("./server/feed.js");
|
||||
const page = await withUserLock(userId, async () => {
|
||||
const { wire } = await reconcileUser(userId);
|
||||
const records = await listChanges(userId, cursor, limit + 1, types);
|
||||
return { wire, records };
|
||||
});
|
||||
const hasMore = page.records.length > limit;
|
||||
const records = [];
|
||||
for (const record of page.records.slice(0, limit)) {
|
||||
if (!getEntity(record.entityType)) continue;
|
||||
const row = record.deleted
|
||||
? null
|
||||
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
|
||||
null);
|
||||
// A live record with no row failed to serialize; it comes again once
|
||||
// it changes, rather than arriving as a delete.
|
||||
if (!record.deleted && !row) continue;
|
||||
records.push({
|
||||
seq: record.seq,
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
revision: record.revision,
|
||||
deleted: !!record.deleted,
|
||||
row,
|
||||
});
|
||||
}
|
||||
const slice = page.records.slice(0, limit);
|
||||
return {
|
||||
records,
|
||||
hasMore,
|
||||
nextCursor: slice.length ? slice[slice.length - 1].seq : cursor,
|
||||
entityTypes: listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
} as never;
|
||||
},
|
||||
parsePushOps: async (body) => {
|
||||
const { parsePushOps } = await import("./server/push.js");
|
||||
return parsePushOps(body) as never;
|
||||
},
|
||||
applyPush: async (userId, ops) => {
|
||||
const { withUserLock } = await import("./server/feed.js");
|
||||
const { applyPush } = await import("./server/push.js");
|
||||
return withUserLock(userId, () =>
|
||||
applyPush(userId, ops as never),
|
||||
) as never;
|
||||
},
|
||||
listen: async (userId, res) => {
|
||||
const { addListener } = await import("./server/feed.js");
|
||||
return addListener(userId, res as Response);
|
||||
},
|
||||
},
|
||||
|
||||
links: {
|
||||
list: () => listLinks() as never,
|
||||
get: (userId) => getLink(userId) as never,
|
||||
create: (link) => createLink(link) as never,
|
||||
update: (patch, userId) => updateLink(patch, userId) as never,
|
||||
remove: (userId) => deleteLink(userId),
|
||||
},
|
||||
|
||||
accounts: {
|
||||
list: () => listAccounts(),
|
||||
create: () => createAccount(),
|
||||
setActive: (userId) => setActiveUserId(userId),
|
||||
activeId: () => getActiveUserId(),
|
||||
remove: (userId) => removeAccount(userId),
|
||||
},
|
||||
|
||||
http: {
|
||||
json: (target, pathname, request) =>
|
||||
remoteJson(target, pathname, request),
|
||||
fetch: ((target, pathname, request) =>
|
||||
remoteFetch(target, pathname, request)) as never,
|
||||
normalizeServerUrl,
|
||||
isRemoteError: ((error: unknown) =>
|
||||
error instanceof RemoteError) as never,
|
||||
},
|
||||
|
||||
sessions: {
|
||||
issueDesktop: (userId, options) =>
|
||||
authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${options.days}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: options.deviceInfo,
|
||||
}),
|
||||
revoke: async (sessionId) => {
|
||||
await authManager.revokeSession(sessionId);
|
||||
},
|
||||
find: async (sessionId) => {
|
||||
const session =
|
||||
await createCurrentSessionRepository().findById(sessionId);
|
||||
return session
|
||||
? { expiresAt: session.expiresAt, deviceInfo: session.deviceInfo }
|
||||
: null;
|
||||
},
|
||||
findUser: async (userId) => {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return user ? { id: user.id, username: user.username } : null;
|
||||
},
|
||||
verify: async (token) => {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
return payload
|
||||
? { userId: payload.userId, sessionId: payload.sessionId }
|
||||
: null;
|
||||
},
|
||||
setCookie: (req, res, value, options) => {
|
||||
(res as Response).cookie(
|
||||
options?.name ?? "jwt",
|
||||
value,
|
||||
authManager.getSecureCookieOptions(
|
||||
req as Request,
|
||||
(options?.days ?? 30) * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
);
|
||||
},
|
||||
clearCookie: (req, res, name) => {
|
||||
(res as Response).clearCookie(
|
||||
name ?? "jwt",
|
||||
authManager.getClearCookieOptions(req as Request),
|
||||
);
|
||||
},
|
||||
middleware: () => authManager.createAuthMiddleware(),
|
||||
desktopOnly: () => (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
},
|
||||
|
||||
hosts: {
|
||||
findForSync: async (userId, syncId) => {
|
||||
const hostId =
|
||||
await createCurrentHostResolutionRepository().findHostIdBySyncId(
|
||||
syncId,
|
||||
);
|
||||
if (hostId === null) return null;
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) return null;
|
||||
const row = await createCurrentHostRepository().findById(hostId);
|
||||
return { id: hostId, name: row?.name ?? row?.ip ?? null };
|
||||
},
|
||||
statusBySyncId: async (userId) => {
|
||||
const { hostStatus } =
|
||||
await import("../plugins/host-status-provider.js");
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const statuses = await hostStatus.statusesFor(userId, null);
|
||||
const entries = [...statuses.entries()];
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
entries.map(([id]) => id),
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [id, entry] of entries) {
|
||||
if (!allowed.has(id)) continue;
|
||||
const row = await hostRepository.findById(id);
|
||||
if (row?.syncId) result[row.syncId] = entry;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
listForDesktop: listPluginsForDesktop as never,
|
||||
sendPackage,
|
||||
mirror: (link) => mirrorPlugins(link as never),
|
||||
lastRemote: () => lastRemotePlugins() as never,
|
||||
localNames: () => {
|
||||
const names = new Map<string, string>();
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
names.set(plugin.id, plugin.manifest.name);
|
||||
}
|
||||
return names;
|
||||
},
|
||||
},
|
||||
|
||||
desktop: {
|
||||
applyProxy: (link) => applyElectronProxyConfig(link as never),
|
||||
deviceName: defaultDeviceName,
|
||||
},
|
||||
|
||||
alerts: {
|
||||
send: (alert) =>
|
||||
sendCoreAlert({
|
||||
title: alert.title,
|
||||
body: alert.body,
|
||||
severity: alert.severity,
|
||||
category: alert.category,
|
||||
dedupeKey: alert.dedupeKey,
|
||||
audience: "admins",
|
||||
link: alert.link,
|
||||
} as never),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Bumped when a desktop and server can no longer understand each other. */
|
||||
export const SYNC_PROTOCOL_VERSION = 1;
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* The sync provider: a plugin that answers /sync/v2 and /sync/link through
|
||||
* ctx.sync.provide. Core only forwards. With no provider the paths answer 404,
|
||||
* so a server without it looks like one that never had sync.
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||
import type { PluginSyncProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
provider: PluginSyncProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export function provideSync(
|
||||
pluginId: string,
|
||||
provider: PluginSyncProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearSyncProviders(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
export function hasSyncProvider(): boolean {
|
||||
return providers.size > 0;
|
||||
}
|
||||
|
||||
/** Sends the request to the first provider that does not pass. */
|
||||
export const syncForwarder: RequestHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const entries = [...providers.values()];
|
||||
const run = (index: number): void => {
|
||||
const entry = entries[index];
|
||||
if (!entry) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const pass = (error?: unknown): void => {
|
||||
if (error) next(error);
|
||||
else run(index + 1);
|
||||
};
|
||||
try {
|
||||
const result = entry.provider.handle(req, res, pass);
|
||||
if (result && typeof result.catch === "function") {
|
||||
result.catch((error: unknown) => fail(entry, error, res, next));
|
||||
}
|
||||
} catch (error) {
|
||||
fail(entry, error, res, next);
|
||||
}
|
||||
};
|
||||
run(0);
|
||||
};
|
||||
|
||||
function fail(
|
||||
entry: Entry,
|
||||
error: unknown,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
syncLogger.warn("Sync provider failed", {
|
||||
operation: "sync_provider",
|
||||
pluginId: entry.pluginId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (res.headersSent) next(error);
|
||||
else res.status(500).json({ error: "Sync is not available right now" });
|
||||
}
|
||||
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user