mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
9
Commits
v26.10.3
...
sdk-v1.0.13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cdbfc674c | ||
|
|
9d93fb0893 | ||
|
|
556f0406aa | ||
|
|
0589465e4e | ||
|
|
ec3cc9cf18 | ||
|
|
4c235bb23f | ||
|
|
b414b85746 | ||
|
|
d049588f7f | ||
|
|
86161d66e2 |
No files matched your search
@@ -11,4 +11,3 @@ db
|
|||||||
*.min.css
|
*.min.css
|
||||||
openapi.json
|
openapi.json
|
||||||
drizzle/
|
drizzle/
|
||||||
src/ui/settings/settings-search-index.ts
|
|
||||||
@@ -1,5 +1,41 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 26.10.3
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone
|
||||||
|
- Plugins can add layouts and themes
|
||||||
|
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
|
||||||
|
- Hovering a theme in Settings previews it
|
||||||
|
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
|
||||||
|
- Switching layouts closes the tabs only the old layout could show, like its Settings tab
|
||||||
|
- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- A theme that no longer exists shows as System instead of an unstyled page
|
||||||
|
- Installs and updates from a registry check the Termix version a plugin needs, not only its plugin API
|
||||||
|
- Host online and offline dots now come from the Host Status plugin. The check interval setting moved to its settings and keeps its value. Without the plugin hosts show no dot
|
||||||
|
- Audit log forwarding and the CSV and NDJSON export now come from the Audit Tools plugin. The collector URL and token move to its settings on upgrade. The audit log viewer, retention and the audit log itself stay in Termix
|
||||||
|
- Client tunnels on your own computer and opening a host in Windows Remote Desktop moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build
|
||||||
|
- The local terminal, the local side of the file manager with its transfers, and opening a file in an external editor moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build
|
||||||
|
- The recent activity list and the first run steps for the preset and for theme, accent, font and language now come from bundled plugins. Existing entries and choices are kept, and people who went through those steps are not asked again
|
||||||
|
- Branding (app name, tagline and logo) is now saved by the bundled layout plugin. An existing setup keeps its branding, and the sign in screen still reads it from Termix
|
||||||
|
- Without the plugin that stores recent activity, the list stays empty and nothing is recorded
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- The desktop app no longer has its own client tunnel and native Remote Desktop code
|
||||||
|
|
||||||
## 26.10.2
|
## 26.10.2
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -62,13 +62,19 @@ Plugins add everything else. The official ones ship with Termix, and admins can
|
|||||||
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
|
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
|
||||||
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
|
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
|
||||||
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
|
||||||
|
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
|
||||||
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
|
||||||
|
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
|
||||||
|
| [Classic 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. |
|
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
|
||||||
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
|
||||||
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
|
||||||
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
|
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
|
||||||
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
|
||||||
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
|
||||||
|
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
|
||||||
|
| [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. |
|
| [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. |
|
| [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. |
|
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
|
||||||
|
|||||||
@@ -28,6 +28,20 @@
|
|||||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
||||||
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "classic-layout",
|
||||||
|
"source": "tmxplug",
|
||||||
|
"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": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
|
||||||
|
"defaultLook": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "docker",
|
"id": "docker",
|
||||||
"source": "tmxplug",
|
"source": "tmxplug",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- recent_activity moved to the homepage plugin, whose migration renames it to
|
||||||
|
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
|
||||||
|
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -512,6 +512,20 @@
|
|||||||
"when": 1791935283211,
|
"when": 1791935283211,
|
||||||
"tag": "0072_unique_username",
|
"tag": "0072_unique_username",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 73,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1792357129912,
|
||||||
|
"tag": "0073_user_look_preferences",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 74,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1792374489419,
|
||||||
|
"tag": "0074_mature_puff_adder",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- recent_activity moved to the homepage plugin, whose migration renames it to
|
||||||
|
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
|
||||||
|
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -512,6 +512,20 @@
|
|||||||
"when": 1791935282340,
|
"when": 1791935282340,
|
||||||
"tag": "0072_unique_username",
|
"tag": "0072_unique_username",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 73,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792357128746,
|
||||||
|
"tag": "0073_user_look_preferences",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 74,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1792374488837,
|
||||||
|
"tag": "0074_bouncy_sersi",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- recent_activity moved to the homepage plugin, whose migration renames it to
|
||||||
|
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
|
||||||
|
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -477,6 +477,20 @@
|
|||||||
"when": 1791935281343,
|
"when": 1791935281343,
|
||||||
"tag": "0067_unique_username",
|
"tag": "0067_unique_username",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 68,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792357127581,
|
||||||
|
"tag": "0068_user_look_preferences",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 69,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792374488196,
|
||||||
|
"tag": "0069_normal_ozymandias",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
const { randomUUID } = require("node:crypto");
|
|
||||||
|
|
||||||
function createC2SAuthBridge(ipcMain, getWindow) {
|
|
||||||
const pending = new Map();
|
|
||||||
ipcMain.handle("answer-c2s-auth", (event, id, answer) => {
|
|
||||||
const item = pending.get(id);
|
|
||||||
if (!item || item.sender !== event.sender) return false;
|
|
||||||
if (answer !== null && (typeof answer !== "string" || answer.length > 8192))
|
|
||||||
return false;
|
|
||||||
item.finish(answer);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return function attach(ws, tunnelName) {
|
|
||||||
const active = new Set();
|
|
||||||
const close = () => {
|
|
||||||
for (const id of active) pending.get(id)?.finish(null);
|
|
||||||
ws.off("message", onMessage);
|
|
||||||
ws.off("close", close);
|
|
||||||
ws.off("error", close);
|
|
||||||
};
|
|
||||||
const onMessage = (raw, binary) => {
|
|
||||||
if (binary) return;
|
|
||||||
let message;
|
|
||||||
try {
|
|
||||||
message = JSON.parse(raw.toString());
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type !== "auth-prompt") return;
|
|
||||||
const window = getWindow();
|
|
||||||
if (
|
|
||||||
!window ||
|
|
||||||
window.isDestroyed() ||
|
|
||||||
active.size ||
|
|
||||||
typeof message.requestId !== "string" ||
|
|
||||||
!["totp", "input", "browser"].includes(message.request?.kind) // plugin-id-ok: SSH prompt kind, not the plugin
|
|
||||||
) {
|
|
||||||
ws.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sender = window.webContents;
|
|
||||||
const id = randomUUID();
|
|
||||||
const finish = (answer) => {
|
|
||||||
if (!pending.delete(id)) return;
|
|
||||||
active.delete(id);
|
|
||||||
clearTimeout(timer);
|
|
||||||
sender.off("destroyed", cancelled);
|
|
||||||
sender.off("did-start-loading", cancelled);
|
|
||||||
if (!sender.isDestroyed())
|
|
||||||
sender.send("c2s-auth-prompt", { id, closed: true });
|
|
||||||
if (ws.readyState === 1)
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "auth-response",
|
|
||||||
requestId: message.requestId,
|
|
||||||
answer,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (answer === null) ws.close();
|
|
||||||
};
|
|
||||||
const cancelled = () => finish(null);
|
|
||||||
const timer = setTimeout(cancelled, 60_000);
|
|
||||||
active.add(id);
|
|
||||||
pending.set(id, { sender, finish });
|
|
||||||
sender.once("destroyed", cancelled);
|
|
||||||
sender.once("did-start-loading", cancelled);
|
|
||||||
sender.send("c2s-auth-prompt", {
|
|
||||||
id,
|
|
||||||
tunnelName,
|
|
||||||
request: message.request,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
ws.on("message", onMessage);
|
|
||||||
ws.once("close", close);
|
|
||||||
ws.once("error", close);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
module.exports = { createC2SAuthBridge };
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
const path = require("path");
|
|
||||||
|
|
||||||
// Saved by the file manager after the user picks an editor in a file dialog.
|
|
||||||
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
|
|
||||||
|
|
||||||
// Extensions the OS would run rather than open in an editor. With no editor
|
|
||||||
// configured the temp copy goes to shell.openPath, and its name comes from
|
|
||||||
// the remote server.
|
|
||||||
const LAUNCHABLE_EXTENSIONS = new Set([
|
|
||||||
// Windows
|
|
||||||
"application",
|
|
||||||
"appref-ms",
|
|
||||||
"bat",
|
|
||||||
"chm",
|
|
||||||
"cmd",
|
|
||||||
"com",
|
|
||||||
"cpl",
|
|
||||||
"exe",
|
|
||||||
"gadget",
|
|
||||||
"hta",
|
|
||||||
"inf",
|
|
||||||
"jar",
|
|
||||||
"js",
|
|
||||||
"jse",
|
|
||||||
"library-ms",
|
|
||||||
"lnk",
|
|
||||||
"msc",
|
|
||||||
"msi",
|
|
||||||
"msp",
|
|
||||||
"mst",
|
|
||||||
"pif",
|
|
||||||
"ps1",
|
|
||||||
"psd1",
|
|
||||||
"psm1",
|
|
||||||
"py",
|
|
||||||
"pyw",
|
|
||||||
"reg",
|
|
||||||
"scf",
|
|
||||||
"scr",
|
|
||||||
"search-ms",
|
|
||||||
"settingcontent-ms",
|
|
||||||
"url",
|
|
||||||
"vbe",
|
|
||||||
"vbs",
|
|
||||||
"ws",
|
|
||||||
"wsf",
|
|
||||||
"wsh",
|
|
||||||
"xll",
|
|
||||||
// macOS
|
|
||||||
"app",
|
|
||||||
"command",
|
|
||||||
"pkg",
|
|
||||||
"terminal",
|
|
||||||
"tool",
|
|
||||||
"workflow",
|
|
||||||
// Linux
|
|
||||||
"appimage",
|
|
||||||
"desktop",
|
|
||||||
"run",
|
|
||||||
"sh",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function isLaunchableFileName(fileName) {
|
|
||||||
// Windows drops trailing dots and spaces, so "a.exe. " is saved as a.exe.
|
|
||||||
const name = path.basename(String(fileName || "")).replace(/[. ]+$/, "");
|
|
||||||
const extension = path.extname(name).slice(1).toLowerCase();
|
|
||||||
return LAUNCHABLE_EXTENSIONS.has(extension);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { EXTERNAL_EDITOR_SETTING, isLaunchableFileName };
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,71 +0,0 @@
|
|||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
function isUsableShell(file, exists) {
|
|
||||||
return !!file && (!path.isAbsolute(file) || exists(file));
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveLocalShell(
|
|
||||||
platform,
|
|
||||||
requestedShell,
|
|
||||||
env = process.env,
|
|
||||||
exists = fs.existsSync,
|
|
||||||
) {
|
|
||||||
if (platform === "win32") {
|
|
||||||
if (requestedShell === "wsl") {
|
|
||||||
return { file: "wsl.exe", args: [] };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
|
|
||||||
args: ["-NoLogo"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// A SHELL left pointing at an uninstalled shell makes the spawn fail.
|
|
||||||
const fallbacks =
|
|
||||||
platform === "darwin"
|
|
||||||
? ["/bin/zsh", "/bin/bash", "/bin/sh"]
|
|
||||||
: ["/bin/bash", "/bin/sh"];
|
|
||||||
const file =
|
|
||||||
[env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find((candidate) =>
|
|
||||||
isUsableShell(candidate, exists),
|
|
||||||
) || fallbacks[0];
|
|
||||||
return { file, args: ["-l"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* node-pty starts every shell through its spawn-helper binary, and a packaged
|
|
||||||
* copy that lost its execute bit fails with "posix_spawnp failed". Returns
|
|
||||||
* how many helpers were fixed.
|
|
||||||
*/
|
|
||||||
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
|
|
||||||
// A universal macOS build ships app-arm64.asar and app-x64.asar.
|
|
||||||
const dir = ptyDir.replace(
|
|
||||||
/\b(app(?:-[a-z0-9]+)?)\.asar(?!\.unpacked)/,
|
|
||||||
"$1.asar.unpacked",
|
|
||||||
);
|
|
||||||
const candidates = [
|
|
||||||
path.join(dir, "build", "Release", "spawn-helper"),
|
|
||||||
path.join(
|
|
||||||
dir,
|
|
||||||
"prebuilds",
|
|
||||||
`${process.platform}-${process.arch}`,
|
|
||||||
"spawn-helper",
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let fixed = 0;
|
|
||||||
for (const helper of candidates) {
|
|
||||||
try {
|
|
||||||
const mode = fsImpl.statSync(helper).mode;
|
|
||||||
if ((mode & 0o111) === 0) {
|
|
||||||
fsImpl.chmodSync(helper, mode | 0o755);
|
|
||||||
fixed += 1;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Missing or read-only; nothing to fix here.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { resolveLocalShell, repairSpawnHelpers };
|
|
||||||
+23
-1956
File diff suppressed because it is too large.
Load diff
@@ -1,85 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const os = require("os");
|
|
||||||
const path = require("path");
|
|
||||||
const { spawn } = require("child_process");
|
|
||||||
|
|
||||||
function singleLine(value, maxLength = 255) {
|
|
||||||
return String(value ?? "")
|
|
||||||
.replace(/[\r\n\0]/g, "")
|
|
||||||
.trim()
|
|
||||||
.slice(0, maxLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateNativeRdpOptions(options) {
|
|
||||||
const host = singleLine(options?.host);
|
|
||||||
const port = Number(options?.port ?? 3389);
|
|
||||||
if (!host || host.length > 253 || /[\\/]/.test(host)) {
|
|
||||||
throw new Error("Invalid RDP host");
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
||||||
throw new Error("Invalid RDP port");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
username: singleLine(options?.username),
|
|
||||||
domain: singleLine(options?.domain),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRdpFile(options) {
|
|
||||||
const { host, port, username, domain } = validateNativeRdpOptions(options);
|
|
||||||
const address = host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
|
|
||||||
const qualifiedUsername = username
|
|
||||||
? domain
|
|
||||||
? `${domain}\\${username}`
|
|
||||||
: username
|
|
||||||
: "";
|
|
||||||
return [
|
|
||||||
`full address:s:${address}`,
|
|
||||||
"prompt for credentials:i:1",
|
|
||||||
"administrative session:i:0",
|
|
||||||
...(qualifiedUsername ? [`username:s:${qualifiedUsername}`] : []),
|
|
||||||
"",
|
|
||||||
].join("\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launchNativeRdp(options, platform = process.platform) {
|
|
||||||
if (platform !== "win32") {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: "Windows Remote Desktop is only available on Windows",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const rdpContent = buildRdpFile(options);
|
|
||||||
const tempDir = await fs.promises.mkdtemp(
|
|
||||||
path.join(os.tmpdir(), "termix-rdp-"),
|
|
||||||
);
|
|
||||||
const rdpPath = path.join(tempDir, "connection.rdp");
|
|
||||||
await fs.promises.writeFile(rdpPath, rdpContent, {
|
|
||||||
encoding: "utf8",
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const child = spawn("mstsc.exe", [rdpPath], {
|
|
||||||
detached: true,
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
const cleanup = () =>
|
|
||||||
fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
||||||
child.once("error", (error) => {
|
|
||||||
cleanup();
|
|
||||||
resolve({ success: false, error: error.message });
|
|
||||||
});
|
|
||||||
child.once("spawn", () => {
|
|
||||||
child.unref();
|
|
||||||
setTimeout(cleanup, 30_000).unref();
|
|
||||||
resolve({ success: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { buildRdpFile, launchNativeRdp, validateNativeRdpOptions };
|
|
||||||
@@ -58,36 +58,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
|
|
||||||
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
|
||||||
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
|
||||||
answerC2SAuth: (id, answer) =>
|
|
||||||
ipcRenderer.invoke("answer-c2s-auth", id, answer),
|
|
||||||
onC2SAuthPrompt: (callback) => {
|
|
||||||
const listener = (_event, prompt) => callback(prompt);
|
|
||||||
ipcRenderer.on("c2s-auth-prompt", listener);
|
|
||||||
return () => ipcRenderer.removeListener("c2s-auth-prompt", listener);
|
|
||||||
},
|
|
||||||
getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"),
|
|
||||||
saveC2STunnelConfig: (config) =>
|
|
||||||
ipcRenderer.invoke("save-c2s-tunnel-config", config),
|
|
||||||
checkLocalPortAvailable: (host, port) =>
|
|
||||||
ipcRenderer.invoke("check-local-port-available", host, port),
|
|
||||||
getC2STunnelPresetDefaultName: () =>
|
|
||||||
ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"),
|
|
||||||
startC2STunnel: (tunnel, index, authToken) =>
|
|
||||||
ipcRenderer.invoke("start-c2s-tunnel", tunnel, index, authToken),
|
|
||||||
testC2STunnel: (tunnel, index, authToken) =>
|
|
||||||
ipcRenderer.invoke("test-c2s-tunnel", tunnel, index, authToken),
|
|
||||||
stopC2STunnel: (tunnelName) =>
|
|
||||||
ipcRenderer.invoke("stop-c2s-tunnel", tunnelName),
|
|
||||||
getC2STunnelStatuses: () => ipcRenderer.invoke("get-c2s-tunnel-statuses"),
|
|
||||||
onC2STunnelStatuses: (callback) => {
|
|
||||||
const listener = (_event, statuses) => callback(statuses);
|
|
||||||
ipcRenderer.on("c2s-tunnel-statuses", listener);
|
|
||||||
return () => ipcRenderer.removeListener("c2s-tunnel-statuses", listener);
|
|
||||||
},
|
|
||||||
startC2SAutoStartTunnels: () =>
|
|
||||||
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
|
|
||||||
setC2SRelayPath: (relayPath) =>
|
|
||||||
ipcRenderer.invoke("set-c2s-relay-path", relayPath),
|
|
||||||
|
|
||||||
onCloseActiveTab: (callback) => {
|
onCloseActiveTab: (callback) => {
|
||||||
const listener = () => callback();
|
const listener = () => callback();
|
||||||
@@ -100,32 +70,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
externalBrowserLogin: (authUrl, callbackPort) =>
|
externalBrowserLogin: (authUrl, callbackPort) =>
|
||||||
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
|
||||||
|
|
||||||
openExternalEditor: (fileData) =>
|
|
||||||
ipcRenderer.invoke("open-external-editor", fileData),
|
|
||||||
closeExternalEditor: (editId) =>
|
|
||||||
ipcRenderer.invoke("close-external-editor", editId),
|
|
||||||
onExternalEditorSaved: (callback) => {
|
|
||||||
const listener = (_event, payload) => callback(payload);
|
|
||||||
ipcRenderer.on("external-editor-saved", listener);
|
|
||||||
return () => ipcRenderer.removeListener("external-editor-saved", listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
|
||||||
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
|
||||||
getLocalHomeDirectory: () => ipcRenderer.invoke("get-local-home-directory"),
|
|
||||||
listLocalDirectory: (dirPath) =>
|
|
||||||
ipcRenderer.invoke("list-local-directory", dirPath),
|
|
||||||
statLocalPaths: (paths) => ipcRenderer.invoke("stat-local-paths", paths),
|
|
||||||
collectLocalFiles: (paths) =>
|
|
||||||
ipcRenderer.invoke("collect-local-files", paths),
|
|
||||||
createLocalFolder: (parentPath, folderName) =>
|
|
||||||
ipcRenderer.invoke("create-local-folder", parentPath, folderName),
|
|
||||||
renameLocalPath: (entryPath, newName) =>
|
|
||||||
ipcRenderer.invoke("rename-local-path", entryPath, newName),
|
|
||||||
trashLocalPath: (entryPath) =>
|
|
||||||
ipcRenderer.invoke("trash-local-path", entryPath),
|
|
||||||
chmodLocalPath: (entryPath, permissions) =>
|
|
||||||
ipcRenderer.invoke("chmod-local-path", entryPath, permissions),
|
|
||||||
createTempFile: (fileData) =>
|
createTempFile: (fileData) =>
|
||||||
ipcRenderer.invoke("create-temp-file", fileData),
|
ipcRenderer.invoke("create-temp-file", fileData),
|
||||||
createTempFolder: (folderData) =>
|
createTempFolder: (folderData) =>
|
||||||
@@ -134,63 +80,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
|||||||
ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
ipcRenderer.invoke("start-drag-to-desktop", dragData),
|
||||||
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
|
||||||
|
|
||||||
startLocalTerminal: (dimensions) =>
|
|
||||||
ipcRenderer.invoke("local-terminal-start", dimensions),
|
|
||||||
writeLocalTerminal: (sessionId, data) =>
|
|
||||||
ipcRenderer.invoke("local-terminal-write", sessionId, data),
|
|
||||||
readyLocalTerminal: (sessionId) =>
|
|
||||||
ipcRenderer.invoke("local-terminal-ready", sessionId),
|
|
||||||
resizeLocalTerminal: (sessionId, cols, rows) =>
|
|
||||||
ipcRenderer.invoke("local-terminal-resize", sessionId, cols, rows),
|
|
||||||
closeLocalTerminal: (sessionId) =>
|
|
||||||
ipcRenderer.invoke("local-terminal-close", sessionId),
|
|
||||||
onLocalTerminalData: (sessionId, callback) => {
|
|
||||||
const channel = `local-terminal:data:${sessionId}`;
|
|
||||||
const listener = (_event, data) => callback(data);
|
|
||||||
ipcRenderer.on(channel, listener);
|
|
||||||
return () => ipcRenderer.removeListener(channel, listener);
|
|
||||||
},
|
|
||||||
onLocalTerminalExit: (sessionId, callback) => {
|
|
||||||
const channel = `local-terminal:exit:${sessionId}`;
|
|
||||||
const listener = (_event, exitCode) => callback(exitCode);
|
|
||||||
ipcRenderer.on(channel, listener);
|
|
||||||
return () => ipcRenderer.removeListener(channel, listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Dual-pane file manager: local disk browsing and streamed transfers.
|
|
||||||
localFs: {
|
|
||||||
home: () => ipcRenderer.invoke("local-fs:home"),
|
|
||||||
list: (dirPath) => ipcRenderer.invoke("local-fs:list", dirPath),
|
|
||||||
mkdir: (parentPath, name) =>
|
|
||||||
ipcRenderer.invoke("local-fs:mkdir", parentPath, name),
|
|
||||||
createFile: (parentPath, name) =>
|
|
||||||
ipcRenderer.invoke("local-fs:create-file", parentPath, name),
|
|
||||||
rename: (oldPath, newName) =>
|
|
||||||
ipcRenderer.invoke("local-fs:rename", oldPath, newName),
|
|
||||||
trash: (paths) => ipcRenderer.invoke("local-fs:trash", paths),
|
|
||||||
ensureDir: (dirPath, rootPath) =>
|
|
||||||
ipcRenderer.invoke("local-fs:ensure-dir", dirPath, rootPath),
|
|
||||||
exists: (paths) => ipcRenderer.invoke("local-fs:exists", paths),
|
|
||||||
walk: (paths) => ipcRenderer.invoke("local-fs:walk", paths),
|
|
||||||
reveal: (targetPath) => ipcRenderer.invoke("local-fs:reveal", targetPath),
|
|
||||||
open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath),
|
|
||||||
},
|
|
||||||
localTransfer: {
|
|
||||||
setApiPath: (apiPath) =>
|
|
||||||
ipcRenderer.invoke("local-transfer:set-api", apiPath),
|
|
||||||
upload: (options) => ipcRenderer.invoke("local-transfer:upload", options),
|
|
||||||
download: (options) =>
|
|
||||||
ipcRenderer.invoke("local-transfer:download", options),
|
|
||||||
cancel: (transferId) =>
|
|
||||||
ipcRenderer.invoke("local-transfer:cancel", transferId),
|
|
||||||
onProgress: (callback) => {
|
|
||||||
const listener = (_event, payload) => callback(payload);
|
|
||||||
ipcRenderer.on("local-transfer:progress", listener);
|
|
||||||
return () =>
|
|
||||||
ipcRenderer.removeListener("local-transfer:progress", listener);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
invoke: invokeAllowed,
|
invoke: invokeAllowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
const fs = require("node:fs");
|
|
||||||
|
|
||||||
function renameLocalPath(source, target) {
|
|
||||||
if (source === target) return;
|
|
||||||
try {
|
|
||||||
fs.lstatSync(target);
|
|
||||||
const error = new Error("A file or folder with that name already exists");
|
|
||||||
error.code = "EEXIST";
|
|
||||||
throw error;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== "ENOENT") throw error;
|
|
||||||
}
|
|
||||||
if (fs.lstatSync(source).isDirectory()) {
|
|
||||||
fs.renameSync(source, target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// link is exclusive: a destination created concurrently is never replaced.
|
|
||||||
fs.linkSync(source, target);
|
|
||||||
fs.unlinkSync(source);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { renameLocalPath };
|
|
||||||
+12
-11
@@ -332,17 +332,6 @@
|
|||||||
"default": "10000",
|
"default": "10000",
|
||||||
"description": "Most audit entries kept. The oldest go first."
|
"description": "Most audit entries kept. The oldest go first."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "AUDIT_LOG_FORWARD_URL",
|
|
||||||
"group": "logging",
|
|
||||||
"description": "Also POST every audit entry as JSON to this URL. Overrides the admin setting."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "AUDIT_LOG_FORWARD_TOKEN",
|
|
||||||
"group": "logging",
|
|
||||||
"secret": true,
|
|
||||||
"description": "Bearer token sent with forwarded audit entries."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "SESSION_RECORDING_RETENTION_DAYS",
|
"name": "SESSION_RECORDING_RETENTION_DAYS",
|
||||||
"group": "logging",
|
"group": "logging",
|
||||||
@@ -370,6 +359,12 @@
|
|||||||
"default": "false",
|
"default": "false",
|
||||||
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "TERMIX_SAFE_LAYOUT",
|
||||||
|
"group": "plugins",
|
||||||
|
"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_BUNDLED_PLUGINS_DIR",
|
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||||
"group": "plugins",
|
"group": "plugins",
|
||||||
@@ -427,6 +422,12 @@
|
|||||||
"internal": true,
|
"internal": true,
|
||||||
"description": "Set by the desktop app when it runs its own backend."
|
"description": "Set by the desktop app when it runs its own backend."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ELECTRON_USER_DATA",
|
||||||
|
"group": "desktop",
|
||||||
|
"internal": true,
|
||||||
|
"description": "Set by the desktop app to its user data folder."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "EMBEDDED_BACKEND_PORT",
|
"name": "EMBEDDED_BACKEND_PORT",
|
||||||
"group": "desktop",
|
"group": "desktop",
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.2",
|
"version": "26.10.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "26.10.2",
|
"version": "26.10.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@@ -15058,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.11",
|
"version": "1.0.13",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"version": "26.10.2",
|
"version": "26.10.3",
|
||||||
"description": "Self-hosted, plugin-based server management.",
|
"description": "Self-hosted, plugin-based server management.",
|
||||||
"author": "Karmaa",
|
"author": "Karmaa",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"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",
|
"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",
|
"prepare": "husky || true",
|
||||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
"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-sdk-guide.cjs && node scripts/generate-settings-search-index.cjs --check && 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 .",
|
"lint:fix": "eslint --fix .",
|
||||||
"type-check": "npm run build:sdk && tsc -b --force",
|
"type-check": "npm run build:sdk && tsc -b --force",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.13
|
||||||
|
|
||||||
|
Plugin API 1.5.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `runtime: "desktop"` in the manifest for a plugin that only loads on the desktop app
|
||||||
|
- `desktop:local` and `ctx.desktop.local`: file system, local shell and machine info on the desktop app's own backend
|
||||||
|
- `ctx.audit.subscribe`, `ctx.audit.list` and `ctx.audit.exportBatches` under `audit:read`
|
||||||
|
- `ctx.hosts.importMany`, `importSshConfig`, `exportMany` and `registerImportNormalizer`, with the `hosts:export` capability
|
||||||
|
- `ctx.credentials.publicKey` and `resolveKeyRefs`, `ctx.ssh.exec`, and `@termix-ssh/plugin-sdk/ssh-keys`
|
||||||
|
- `ctx.hosts.status.provide` and `ctx.hosts.activity.provide` so a plugin can own host status and recent activity, with `ctx.network.tcpProbe`, `ctx.ssh.probe`, `ctx.hosts.probeTargets` and `ctx.hosts.sessionActive`
|
||||||
|
- `registerHostStatusIndicator` and `registerHostEditorToolbarItem`, and `order` for host badges
|
||||||
|
- Onboarding steps can sit in a `start` section, take a `position` and `replaces` a core step
|
||||||
|
- A plugin can provide the app name, tagline and logo through the `<id>.brandingProvider` registry key
|
||||||
|
|
||||||
|
## 1.0.12
|
||||||
|
|
||||||
|
Plugin API 1.4.0.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4
|
||||||
|
- `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it
|
||||||
|
- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries. The shell's home tab is left out until some plugin registers a `dashboard` tab type
|
||||||
|
- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
|
||||||
|
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
|
||||||
|
- `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release
|
||||||
|
- `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
|
||||||
|
- A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend
|
||||||
|
- `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
|
||||||
|
- Plugin tests and type checks work with the SDK linked from a local Termix checkout
|
||||||
|
|
||||||
## 1.0.11
|
## 1.0.11
|
||||||
|
|
||||||
Plugin API 1.3.0.
|
Plugin API 1.3.0.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# Termix plugin guide
|
||||||
|
|
||||||
SDK 1.0.11 | Plugin API 1.3.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
|
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
|
||||||
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
|
||||||
@@ -164,7 +164,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
|||||||
| `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
|
| `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
|
||||||
| `features` | Up to 20 short lines shown on the plugin's page. |
|
| `features` | Up to 20 short lines shown on the plugin's page. |
|
||||||
| `docs` | An https link to your docs. |
|
| `docs` | An https link to your docs. |
|
||||||
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. |
|
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. Older Termix builds don't list a release they can't run. |
|
||||||
| `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
|
| `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
|
||||||
| `capabilities` | What your code may do. See the reference at the end. |
|
| `capabilities` | What your code may do. See the reference at the end. |
|
||||||
| `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
|
| `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
|
||||||
@@ -175,7 +175,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
|
|||||||
|
|
||||||
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
`contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`,
|
||||||
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
`settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`,
|
||||||
`uiPresets` and `uses`. Frontend `register*` calls need their ids declared here first.
|
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
||||||
|
|
||||||
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your
|
||||||
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
code may reach. A permission (`hello.use`, with a dot) is what a user may do.
|
||||||
@@ -325,6 +325,24 @@ try {
|
|||||||
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
|
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
|
||||||
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
|
||||||
|
|
||||||
|
### The desktop app
|
||||||
|
|
||||||
|
Set `"runtime": "desktop"` in the manifest for a plugin that only makes sense on a desktop app. It never
|
||||||
|
loads on a server, ignores server sync and may declare `desktop:local`. Any plugin can declare
|
||||||
|
`desktop:local` and check `ctx.desktop.local.available()` at runtime.
|
||||||
|
|
||||||
|
`ctx.desktop.local` works on the desktop app's own backend only:
|
||||||
|
|
||||||
|
- `info()`: home folder, platform, user data folder, backend port and the linked server (url and token) or null
|
||||||
|
- `fs`: `list`, `mkdir`, `createFile`, `rename`, `ensureDir`, `exists`, `walk`, `trash`, `reveal`, `open`,
|
||||||
|
`openRead` and `openDownload`. Downloads stay inside the folder the user picked, links in it are refused and
|
||||||
|
an existing file is never replaced by accident
|
||||||
|
- `pty.spawn()`: a local shell, stopped when the plugin stops
|
||||||
|
- `pluginFetch(origin, subPath, init)`: calls this plugin's own routes on the desktop's backend ("local") or the linked server ("remote") as the acting user. Bodies can be streams, so a plugin can move big files without the renderer
|
||||||
|
|
||||||
|
Use `ctx.audit.subscribe`, `list` and `exportBatches` (`audit:read`) to read the audit trail. Subscribers
|
||||||
|
are called after a line is accepted and never delay the action that wrote it.
|
||||||
|
|
||||||
### Events, timers, alerts, services
|
### Events, timers, alerts, services
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -470,22 +488,35 @@ your plugin's error boundary and Suspense.
|
|||||||
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
|
||||||
| `registerHostProtocol` | A connection protocol next to SSH |
|
| `registerHostProtocol` | A connection protocol next to SSH |
|
||||||
| `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows |
|
| `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows |
|
||||||
|
| `registerHostStatusIndicator` | The status dot or stripe on a host row (a layout draws it) |
|
||||||
|
| `registerHostEditorToolbarItem` | A control in the host editor toolbar (a layout draws it) |
|
||||||
| `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries |
|
| `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries |
|
||||||
| `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions |
|
| `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions |
|
||||||
| `registerSettingsComponent` | A component for a `custom` settings field |
|
| `registerSettingsComponent` | A component for a `custom` settings field |
|
||||||
| `registerAction`, `invokeAction` | Actions across plugins |
|
| `registerAction`, `invokeAction` | Actions across plugins |
|
||||||
| `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot |
|
| `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot |
|
||||||
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
|
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
|
||||||
| `registerOnboardingStep` | A first run setup step |
|
| `registerOnboardingStep` | A first run setup step (`section`, `position`, `replaces`) |
|
||||||
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
||||||
|
| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
|
||||||
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
|
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
|
||||||
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
||||||
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
|
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
|
||||||
| `onSettingsChanged`, `onDispose` | Hooks |
|
| `onSettingsChanged`, `onDispose` | Hooks |
|
||||||
|
|
||||||
|
Badges, status indicators and toolbar items take `{ id, when(host), component, order? }`; lower `order`
|
||||||
|
comes first. An onboarding step sits in a section (`start`, `setup`, `explore`, `security`) or at an exact
|
||||||
|
`position` from 1 to 99. `replaces: ["preset"]` lists older step keys the step took over, so people who saw
|
||||||
|
those are not shown it again.
|
||||||
|
|
||||||
|
Branding: a plugin that owns the app name, tagline and logo provides
|
||||||
|
`ctx.registry.provide("<id>.brandingProvider", async () => ({ appName, tagline, logo }))` in its backend.
|
||||||
|
Core reads it for `GET /users/branding` (the login page, the browser tab and the sync server name) and falls
|
||||||
|
back to "Termix" with no logo when no plugin provides one. `logo` is a PNG data URL or null.
|
||||||
|
|
||||||
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
||||||
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
||||||
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
|
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
|
||||||
|
|
||||||
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
|
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
|
||||||
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
|
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
|
||||||
@@ -496,6 +527,91 @@ Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `termin
|
|||||||
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
|
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
|
||||||
https://docs.termix.site/develop/more-extension-points.
|
https://docs.termix.site/develop/more-extension-points.
|
||||||
|
|
||||||
|
## Layouts and themes
|
||||||
|
|
||||||
|
A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
|
||||||
|
admins set the default for everyone.
|
||||||
|
|
||||||
|
A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
|
||||||
|
needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
|
||||||
|
it in `activate`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
|
||||||
|
import {
|
||||||
|
ShellConfirmCapture,
|
||||||
|
SurfaceScope,
|
||||||
|
TabStage,
|
||||||
|
} from "@termix-ssh/plugin-sdk/ui";
|
||||||
|
|
||||||
|
function MyLayout() {
|
||||||
|
const shell = useShell();
|
||||||
|
if (!shell) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh w-screen flex-col bg-background">
|
||||||
|
<header>
|
||||||
|
{/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
|
||||||
|
</header>
|
||||||
|
<SurfaceScope kind="tab">
|
||||||
|
<ShellConfirmCapture />
|
||||||
|
<TabStage />
|
||||||
|
</SurfaceScope>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activate(app: TermixApp) {
|
||||||
|
app.registerLayout({
|
||||||
|
id: "my-layout",
|
||||||
|
label: "layout.name",
|
||||||
|
component: MyLayout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
|
||||||
|
command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
|
||||||
|
every session.
|
||||||
|
- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
|
||||||
|
keeps its connection. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so
|
||||||
|
close questions and inline views show over the tab area. Overlays and onboarding stay in core.
|
||||||
|
- The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls
|
||||||
|
`palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts).
|
||||||
|
- The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some
|
||||||
|
plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw.
|
||||||
|
- Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render
|
||||||
|
`LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own
|
||||||
|
Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared.
|
||||||
|
- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
|
||||||
|
- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
|
||||||
|
screen where people can switch, reset, update, turn it off or (for a bundled plugin) restore the copy that
|
||||||
|
shipped. `?safe-layout=1` opens it any time.
|
||||||
|
- Termix has no layout of its own. The default is the bundled Classic Layout plugin
|
||||||
|
(https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
|
||||||
|
sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
|
||||||
|
`host-manager` and `settings` tab types, and offers the host list and dashboard as the components
|
||||||
|
`classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
|
||||||
|
It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
|
||||||
|
`useShell()` and may change with Termix. A third party layout should start from `useShell()`.
|
||||||
|
|
||||||
|
A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
|
||||||
|
optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
|
||||||
|
so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
|
||||||
|
inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
|
||||||
|
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
|
||||||
|
`--foreground`, `--card`, `--border`, `--muted` and the rest).
|
||||||
|
|
||||||
|
A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
|
||||||
|
`validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
|
||||||
|
`validateThemeCss()`. Notes:
|
||||||
|
|
||||||
|
- A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
|
||||||
|
- The first theme a bundled default-look plugin lists is the default for new installs.
|
||||||
|
- Themes show in the picker in the order core lists its own, then the plugin's order.
|
||||||
|
- A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
|
||||||
|
- Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
|
||||||
|
- Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example.
|
||||||
|
|
||||||
## Looking native
|
## Looking native
|
||||||
|
|
||||||
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
||||||
@@ -742,58 +858,66 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
|||||||
|
|
||||||
## SDK entry points
|
## SDK entry points
|
||||||
|
|
||||||
| Import | What it is |
|
| Import | What it is |
|
||||||
| -------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||||
| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
|
| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
|
||||||
| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
|
| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
|
||||||
| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
|
| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
|
||||||
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
|
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
|
||||||
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
|
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
|
||||||
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
|
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
|
||||||
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
|
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
|
||||||
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
|
||||||
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
|
||||||
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
|
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
|
||||||
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
|
||||||
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
|
||||||
| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
|
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
|
||||||
| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
|
| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
|
||||||
| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers |
|
| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
|
||||||
| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these |
|
| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers |
|
||||||
|
| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these |
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
| Capability | Risk | What it allows |
|
| Capability | Risk | What it allows |
|
||||||
| -------------------- | -------- | ------------------------------------------------------- |
|
| ------------------------ | -------- | ------------------------------------------------------- |
|
||||||
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
|
||||||
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
|
||||||
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
| `ssh:connect` | high | Run any command on hosts, with the user's access |
|
||||||
| `process:spawn` | high | Run programs on the Termix server |
|
| `process:spawn` | high | Run programs on the Termix server |
|
||||||
| `users:write` | high | Add users and change their roles |
|
| `users:write` | high | Add users and change their roles |
|
||||||
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
|
||||||
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
|
||||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||||
| `device:serial` | high | Open serial and USB ports on the server |
|
| `device:serial` | high | Open serial and USB ports on the server |
|
||||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
| `desktop:local` | high | Use the user's own computer, desktop app only |
|
||||||
| `hosts:write` | medium | Add and edit hosts |
|
| `hosts:export` | high | Export hosts with their saved logins |
|
||||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||||
| `credentials:write` | medium | Save new credentials |
|
| `ui:layout` | high | Replace the whole app layout |
|
||||||
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
| `hosts:write` | medium | Add and edit hosts |
|
||||||
| `network:serve` | medium | HTTP routes and sockets |
|
| `hosts:status-provide` | medium | Be the source of host status dots |
|
||||||
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
|
||||||
| `users:read` | medium | Usernames, roles and UI language |
|
| `network:probe` | medium | Check that a host answers on a TCP port |
|
||||||
| `events:core` | medium | Listen to core events |
|
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||||
| `notify:send` | medium | Send alerts |
|
| `credentials:write` | medium | Save new credentials |
|
||||||
| `audit:read` | medium | Read the audit log |
|
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
|
||||||
| `desktop:window` | medium | Open windows in the desktop app |
|
| `network:serve` | medium | HTTP routes and sockets |
|
||||||
| `hosts:read` | low | Read hosts the user can see |
|
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
|
||||||
| `db:own` | low | Your own tables |
|
| `users:read` | medium | Usernames, roles and UI language |
|
||||||
| `kv:own` | low | Small key value state |
|
| `events:core` | medium | Listen to core events |
|
||||||
| `files:own` | low | Your own folder on disk |
|
| `notify:send` | medium | Send alerts |
|
||||||
| `secrets:own` | low | Your own encrypted secrets |
|
| `audit:read` | medium | Read the audit log |
|
||||||
| `settings:read-core` | low | Read Termix server settings |
|
| `desktop:window` | medium | Open windows in the desktop app |
|
||||||
| `plugins:read` | low | See installed plugins |
|
| `hosts:read` | low | Read hosts the user can see |
|
||||||
| `ui:surface` | low | Add tabs, panels and settings |
|
| `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.
|
An update that adds a capability waits for an admin to approve it.
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { fileURLToPath } from "node:url";
|
|||||||
import * as esbuild from "esbuild";
|
import * as esbuild from "esbuild";
|
||||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
import {
|
||||||
|
readManifest,
|
||||||
|
resolveEntry,
|
||||||
|
copyDir,
|
||||||
|
isThemeOnly,
|
||||||
|
} from "../lib/plugin-dir.mjs";
|
||||||
import { openapi } from "./openapi.mjs";
|
import { openapi } from "./openapi.mjs";
|
||||||
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
||||||
|
|
||||||
@@ -232,32 +237,41 @@ export async function build({ cwd }) {
|
|||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
||||||
if (!backendEntry) {
|
if (!backendEntry && !isThemeOnly(manifest)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await esbuild.build({
|
if (backendEntry) {
|
||||||
entryPoints: [backendEntry],
|
await esbuild.build({
|
||||||
outfile: path.join(outDir, "backend.js"),
|
entryPoints: [backendEntry],
|
||||||
bundle: true,
|
outfile: path.join(outDir, "backend.js"),
|
||||||
format: "esm",
|
bundle: true,
|
||||||
platform: "node",
|
format: "esm",
|
||||||
target: "node22",
|
platform: "node",
|
||||||
sourcemap: true,
|
target: "node22",
|
||||||
logLevel: "warning",
|
sourcemap: true,
|
||||||
// A native dependency's compiled .node binary is resolved by the
|
logLevel: "warning",
|
||||||
// package's own relative paths, which break once esbuild inlines its JS
|
// A native dependency's compiled .node binary is resolved by the
|
||||||
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
// package's own relative paths, which break once esbuild inlines its JS
|
||||||
// node_modules import instead, resolved at runtime like a host-provided
|
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
||||||
// package.
|
// node_modules import instead, resolved at runtime like a host-provided
|
||||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
// package.
|
||||||
loader: BINARY_LOADERS,
|
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||||
// Bundled CJS deps still call require() for Node builtins at runtime.
|
loader: BINARY_LOADERS,
|
||||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
// Bundled CJS deps still call require() for Node builtins at runtime.
|
||||||
banner: { js: REQUIRE_BANNER },
|
// ESM has no ambient require, so give esbuild's require shim a real one.
|
||||||
});
|
banner: { js: REQUIRE_BANNER },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Core loads every plugin through a backend entry, so a theme only
|
||||||
|
// plugin gets an empty one.
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(outDir, "backend.js"),
|
||||||
|
"export function activate() {}\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const worker of readWorkerEntries(cwd, pluginId)) {
|
for (const worker of readWorkerEntries(cwd, pluginId)) {
|
||||||
await esbuild.build({
|
await esbuild.build({
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||||
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
|
import {
|
||||||
|
collectFiles,
|
||||||
|
createTmxplug,
|
||||||
|
themeFiles,
|
||||||
|
TMXPLUG_CONTENTS,
|
||||||
|
} from "../lib/tmxplug.mjs";
|
||||||
import { sha256 } from "../lib/signing.mjs";
|
import { sha256 } from "../lib/signing.mjs";
|
||||||
import { validate } from "./validate.mjs";
|
import { validate } from "./validate.mjs";
|
||||||
|
|
||||||
@@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) {
|
|||||||
fs.mkdirSync(outDir, { recursive: true });
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
|
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
|
||||||
|
|
||||||
const archive = createTmxplug(cwd, collectFiles(cwd));
|
const archive = createTmxplug(
|
||||||
|
cwd,
|
||||||
|
collectFiles(cwd, [
|
||||||
|
...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
fs.writeFileSync(outfile, archive);
|
fs.writeFileSync(outfile, archive);
|
||||||
|
|
||||||
console.log(`packed ${path.basename(outfile)}`);
|
console.log(`packed ${path.basename(outfile)}`);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||||
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
|
|||||||
|
|
||||||
export async function validate({ cwd }) {
|
export async function validate({ cwd }) {
|
||||||
const raw = readManifest(cwd);
|
const raw = readManifest(cwd);
|
||||||
const { parseManifest } = await loadSdkModule("manifest");
|
const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
|
||||||
const { errors } = parseManifest(raw);
|
const { errors } = parseManifest(raw);
|
||||||
|
|
||||||
const problems = [...errors];
|
const problems = [...errors];
|
||||||
@@ -25,7 +25,8 @@ export async function validate({ cwd }) {
|
|||||||
// The manifest names files. They have to be there.
|
// The manifest names files. They have to be there.
|
||||||
const referenced = [
|
const referenced = [
|
||||||
raw.backend ?? "dist/backend.js",
|
raw.backend ?? "dist/backend.js",
|
||||||
raw.frontend ?? "dist/frontend.js",
|
// A theme only plugin has no frontend code.
|
||||||
|
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||||
raw.locales ?? "locales",
|
raw.locales ?? "locales",
|
||||||
];
|
];
|
||||||
for (const rel of referenced) {
|
for (const rel of referenced) {
|
||||||
@@ -34,6 +35,8 @@ export async function validate({ cwd }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
|
||||||
|
problems.push(...validateLookCapabilities(cwd, raw));
|
||||||
problems.push(
|
problems.push(
|
||||||
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
||||||
);
|
);
|
||||||
@@ -53,6 +56,63 @@ export async function validate({ cwd }) {
|
|||||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every declared theme file exists and holds only CSS a theme may hold. */
|
||||||
|
export function validateThemeFiles(cwd, raw, validateThemeCss) {
|
||||||
|
const problems = [];
|
||||||
|
const themes = raw?.contributes?.themes;
|
||||||
|
if (!Array.isArray(themes)) return problems;
|
||||||
|
for (const theme of themes) {
|
||||||
|
if (typeof theme?.css !== "string" || typeof theme?.id !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const file = path.join(cwd, theme.css);
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
problems.push(`theme ${theme.id}: ${theme.css} does not exist`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const error of validateThemeCss(
|
||||||
|
fs.readFileSync(file, "utf8"),
|
||||||
|
theme.id,
|
||||||
|
)) {
|
||||||
|
problems.push(`theme ${theme.id}: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ui:layout and ui:theme must match what the manifest contributes, both
|
||||||
|
* ways. The manifest check already flags a contribution without its
|
||||||
|
* capability; this flags a capability with nothing behind it, and a layout
|
||||||
|
* with no frontend to register it.
|
||||||
|
*/
|
||||||
|
export function validateLookCapabilities(cwd, raw) {
|
||||||
|
const problems = [];
|
||||||
|
const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
|
||||||
|
const layouts = raw?.contributes?.layouts;
|
||||||
|
const themes = raw?.contributes?.themes;
|
||||||
|
const hasLayouts = Array.isArray(layouts) && layouts.length > 0;
|
||||||
|
const hasThemes = Array.isArray(themes) && themes.length > 0;
|
||||||
|
if (capabilities.includes("ui:layout") && !hasLayouts) {
|
||||||
|
problems.push('"ui:layout" is declared but contributes.layouts is empty');
|
||||||
|
}
|
||||||
|
if (capabilities.includes("ui:theme") && !hasThemes) {
|
||||||
|
problems.push('"ui:theme" is declared but contributes.themes is empty');
|
||||||
|
}
|
||||||
|
if (hasLayouts) {
|
||||||
|
const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"];
|
||||||
|
const frontend = entries.some((entry) =>
|
||||||
|
fs.existsSync(path.join(cwd, "src", "frontend", entry)),
|
||||||
|
);
|
||||||
|
if (!frontend && raw.frontend === undefined) {
|
||||||
|
problems.push(
|
||||||
|
"contributes.layouts needs a frontend that calls app.registerLayout",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
/** Env vars core sets for everyone, so a plugin need not list them. */
|
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||||
const CORE_ENV = new Set([
|
const CORE_ENV = new Set([
|
||||||
"DATA_DIR",
|
"DATA_DIR",
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ export function readManifest(cwd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The first of these that exists, or null. */
|
/** The first of these that exists, or null. */
|
||||||
|
/** Only themes: CSS files and no code, so no source entry is needed. */
|
||||||
|
export function isThemeOnly(manifest) {
|
||||||
|
const capabilities = manifest.capabilities ?? [];
|
||||||
|
return (
|
||||||
|
capabilities.length > 0 &&
|
||||||
|
capabilities.every((capability) => capability === "ui:theme") &&
|
||||||
|
Array.isArray(manifest.contributes?.themes) &&
|
||||||
|
manifest.contributes.themes.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveEntry(cwd, candidates) {
|
export function resolveEntry(cwd, candidates) {
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const full = path.join(cwd, candidate);
|
const full = path.join(cwd, candidate);
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import zlib from "node:zlib";
|
import zlib from "node:zlib";
|
||||||
|
|
||||||
|
/** Theme CSS files a manifest declares, which travel with the plugin too. */
|
||||||
|
export function themeFiles(manifest) {
|
||||||
|
const themes = manifest?.contributes?.themes;
|
||||||
|
if (!Array.isArray(themes)) return [];
|
||||||
|
return themes
|
||||||
|
.map((theme) => theme?.css)
|
||||||
|
.filter((css) => typeof css === "string");
|
||||||
|
}
|
||||||
|
|
||||||
/** What a .tmxplug holds, in the order the spec lists it. */
|
/** What a .tmxplug holds, in the order the spec lists it. */
|
||||||
export const TMXPLUG_CONTENTS = [
|
export const TMXPLUG_CONTENTS = [
|
||||||
"manifest.json",
|
"manifest.json",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.11",
|
"version": "1.0.13",
|
||||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -85,6 +85,10 @@
|
|||||||
"types": "./dist/ssh-certs.d.ts",
|
"types": "./dist/ssh-certs.d.ts",
|
||||||
"default": "./dist/ssh-certs.js"
|
"default": "./dist/ssh-certs.js"
|
||||||
},
|
},
|
||||||
|
"./ssh-keys": {
|
||||||
|
"types": "./dist/ssh-keys.d.ts",
|
||||||
|
"default": "./dist/ssh-keys.js"
|
||||||
|
},
|
||||||
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
|
||||||
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
|
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
|
||||||
"./ui": {
|
"./ui": {
|
||||||
|
|||||||
@@ -82,7 +82,11 @@
|
|||||||
"features": {
|
"features": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"maxItems": 20,
|
"maxItems": 20,
|
||||||
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 160
|
||||||
|
},
|
||||||
"description": "Short lines listing what the plugin does, shown on its page."
|
"description": "Short lines listing what the plugin does, shown on its page."
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
@@ -98,11 +102,24 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["name", "description"],
|
"required": ["name", "description"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
"name": {
|
||||||
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
"type": "string",
|
||||||
"default": { "type": "string" },
|
"pattern": "^[A-Z][A-Z0-9_]{0,63}$"
|
||||||
"required": { "type": "boolean" },
|
},
|
||||||
"secret": { "type": "boolean" }
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 300
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"secret": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -137,6 +154,7 @@
|
|||||||
"system:tls",
|
"system:tls",
|
||||||
"device:serial",
|
"device:serial",
|
||||||
"hosts:write",
|
"hosts:write",
|
||||||
|
"hosts:export",
|
||||||
"credentials:use",
|
"credentials:use",
|
||||||
"credentials:write",
|
"credentials:write",
|
||||||
"network:outbound",
|
"network:outbound",
|
||||||
@@ -146,7 +164,11 @@
|
|||||||
"events:core",
|
"events:core",
|
||||||
"notify:send",
|
"notify:send",
|
||||||
"audit:read",
|
"audit:read",
|
||||||
|
"network:probe",
|
||||||
|
"hosts:status-provide",
|
||||||
|
"hosts:activity-provide",
|
||||||
"desktop:window",
|
"desktop:window",
|
||||||
|
"desktop:local",
|
||||||
"users:impersonate",
|
"users:impersonate",
|
||||||
"db:core-refs",
|
"db:core-refs",
|
||||||
"hosts:read",
|
"hosts:read",
|
||||||
@@ -155,7 +177,11 @@
|
|||||||
"files:own",
|
"files:own",
|
||||||
"secrets:own",
|
"secrets:own",
|
||||||
"settings:read-core",
|
"settings:read-core",
|
||||||
"ui:surface"
|
"ui:surface",
|
||||||
|
"notify:hub",
|
||||||
|
"plugins:read",
|
||||||
|
"ui:layout",
|
||||||
|
"ui:theme"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -944,7 +970,9 @@
|
|||||||
"defaultLevels": {
|
"defaultLevels": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
"items": { "enum": ["admin", "user", "folder"] },
|
"items": {
|
||||||
|
"enum": ["admin", "user", "folder"]
|
||||||
|
},
|
||||||
"description": "Levels that may set a default for the field. All three by default."
|
"description": "Levels that may set a default for the field. All three by default."
|
||||||
},
|
},
|
||||||
"personal": {
|
"personal": {
|
||||||
@@ -1127,6 +1155,86 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
|
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"layouts": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Whole-app layouts the frontend registers with app.registerLayout. Needs the ui:layout capability.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "label"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "i18n key in the plugin namespace, or plain text."
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"defaultTheme": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||||
|
"description": "Theme picked with this layout when nobody chose one."
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "The layout a default-look plugin starts with. At most one."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"themes": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Themes: one CSS file each with token rules scoped under .<id>. Needs the ui:theme capability.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "label", "mode", "css"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["light", "dark"]
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": ".css$",
|
||||||
|
"description": "Path of the CSS file inside the plugin."
|
||||||
|
},
|
||||||
|
"swatch": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["background", "panel", "text"],
|
||||||
|
"properties": {
|
||||||
|
"background": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1158,6 +1266,11 @@
|
|||||||
"enum": ["mirror", "local", "server"],
|
"enum": ["mirror", "local", "server"],
|
||||||
"description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror."
|
"description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror."
|
||||||
},
|
},
|
||||||
|
"runtime": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["desktop"],
|
||||||
|
"description": "desktop: the plugin only loads on a desktop app's own embedded backend, never on a server. It implies desktop local and may declare desktop:local."
|
||||||
|
},
|
||||||
"nativeDependencies": {
|
"nativeDependencies": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"minItems": 1,
|
"minItems": 1,
|
||||||
|
|||||||
@@ -848,6 +848,168 @@ export interface PluginHosts {
|
|||||||
* Needs hosts:read.
|
* Needs hosts:read.
|
||||||
*/
|
*/
|
||||||
status: PluginHostStatusApi;
|
status: PluginHostStatusApi;
|
||||||
|
/**
|
||||||
|
* The hosts the acting user may connect to (their own and shared ones) as a
|
||||||
|
* status provider needs them: address, port and jump hosts, no secrets.
|
||||||
|
* Pass hostIds to narrow it. Needs hosts:read.
|
||||||
|
*/
|
||||||
|
probeTargets: (filter?: {
|
||||||
|
hostIds?: number[];
|
||||||
|
}) => Promise<PluginHostProbeTarget[]>;
|
||||||
|
/** Whether a session is open to the host right now. Needs hosts:read. */
|
||||||
|
sessionActive: (hostId: number) => boolean;
|
||||||
|
/**
|
||||||
|
* Imports Termix JSON host rows for the acting user, the same way POST
|
||||||
|
* /host/bulk-import does: up to 100 rows, jump hosts remapped by exportId,
|
||||||
|
* defaults, protocol logins and plugin settings applied, one save at the end.
|
||||||
|
* Needs hosts:write, plus credentials:write when credentials are passed, and
|
||||||
|
* the acting user needs the core hosts.create and hosts.edit permissions.
|
||||||
|
*/
|
||||||
|
importMany: (
|
||||||
|
hosts: Record<string, unknown>[],
|
||||||
|
options?: PluginHostImportOptions,
|
||||||
|
) => Promise<PluginHostImportResult>;
|
||||||
|
/** Imports the Host entries of an OpenSSH config file. Same rules as importMany. */
|
||||||
|
importSshConfig: (
|
||||||
|
content: string,
|
||||||
|
options?: Pick<PluginHostImportOptions, "overwrite" | "atomic">,
|
||||||
|
) => Promise<PluginHostImportResult>;
|
||||||
|
/**
|
||||||
|
* The acting user's own hosts as Termix JSON. Without share the secrets are
|
||||||
|
* included, decrypted. With share every secret is left out and credential
|
||||||
|
* logins point at a scrubbed credentials list by alias. Needs hosts:export
|
||||||
|
* and is always audited.
|
||||||
|
*/
|
||||||
|
exportMany: (options?: {
|
||||||
|
share?: boolean;
|
||||||
|
/** Only these of the user's hosts. */
|
||||||
|
hostIds?: number[];
|
||||||
|
}) => Promise<PluginHostExport>;
|
||||||
|
/**
|
||||||
|
* Validates and writes this plugin's own host settings from an imported
|
||||||
|
* row. Called for every imported row once the host exists. Return the
|
||||||
|
* values to write, or null to write nothing. Removed when the plugin
|
||||||
|
* deactivates. Needs hosts:write.
|
||||||
|
*/
|
||||||
|
registerImportNormalizer: (
|
||||||
|
normalizer: PluginHostImportNormalizer,
|
||||||
|
) => () => void;
|
||||||
|
/** The recent activity list on the dashboard. */
|
||||||
|
activity: PluginHostActivityApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostImportOptions {
|
||||||
|
/** Update a host that matches on ip, port and username instead of skipping it. */
|
||||||
|
overwrite?: boolean;
|
||||||
|
/**
|
||||||
|
* All or nothing: if any row fails, the hosts this call created are deleted
|
||||||
|
* and the result says rolledBack. Cannot be combined with overwrite.
|
||||||
|
*/
|
||||||
|
atomic?: boolean;
|
||||||
|
/** Credential placeholders rows can refer to by credentialAlias. */
|
||||||
|
credentials?: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostImportResult {
|
||||||
|
success: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
createdHostIds: number[];
|
||||||
|
rolledBack?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostExport {
|
||||||
|
hosts: Array<Record<string, unknown>>;
|
||||||
|
/** Share exports only. */
|
||||||
|
version?: string;
|
||||||
|
exportedAt?: string;
|
||||||
|
credentials?: Array<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginHostImportNormalizer = (
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
) => Record<string, unknown> | null;
|
||||||
|
|
||||||
|
export interface PluginHostProbeTarget {
|
||||||
|
id: number;
|
||||||
|
/** The user who owns the host; its jump hosts are theirs. */
|
||||||
|
ownerUserId: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
connectionType: string;
|
||||||
|
jumpHosts: Array<{ hostId: number }>;
|
||||||
|
statusCheckEnabled: boolean;
|
||||||
|
/** Seconds, or null to use the global interval. */
|
||||||
|
statusCheckInterval: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plugin that owns host status. Core keeps the routes, the sync feed and
|
||||||
|
* ctx.hosts.status; this fills them. One provider at a time.
|
||||||
|
*/
|
||||||
|
export interface PluginHostStatusProvider {
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
get: (hostId: number) => PluginHostStatusEntry | null;
|
||||||
|
/**
|
||||||
|
* Every status known, after polling is brought in line with the request:
|
||||||
|
* with hostIds, exactly those of the user's hosts; without, all of them.
|
||||||
|
*/
|
||||||
|
statusesFor: (
|
||||||
|
userId: string,
|
||||||
|
hostIds: number[] | null,
|
||||||
|
) => Promise<Map<number, PluginHostStatusEntry>>;
|
||||||
|
/** Restarts the user's checks. */
|
||||||
|
refresh: (userId: string) => Promise<void>;
|
||||||
|
check: (hostId: number) => Promise<PluginHostStatusEntry | null>;
|
||||||
|
reportLogin: (hostId: number, outcome: { ok: boolean }) => void;
|
||||||
|
registerPort: (
|
||||||
|
connectionType: string,
|
||||||
|
resolve: (
|
||||||
|
hostId: number,
|
||||||
|
) => number | undefined | Promise<number | undefined>,
|
||||||
|
) => () => void;
|
||||||
|
/** The global check interval in seconds. */
|
||||||
|
getIntervalSeconds: () => Promise<number>;
|
||||||
|
setIntervalSeconds: (seconds: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginActivityEntry {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
type: string;
|
||||||
|
hostId: number;
|
||||||
|
hostName: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginRecordActivityResult =
|
||||||
|
| { status: "logged"; id: number }
|
||||||
|
| { status: "rate_limited" }
|
||||||
|
| { status: "invalid_type" }
|
||||||
|
| { status: "denied" }
|
||||||
|
| { status: "unavailable" };
|
||||||
|
|
||||||
|
/** The plugin that stores recent activity. One provider at a time. */
|
||||||
|
export interface PluginActivityProvider {
|
||||||
|
record: (
|
||||||
|
userId: string,
|
||||||
|
entry: { type: string; hostId: number; hostName: string },
|
||||||
|
) => Promise<PluginRecordActivityResult>;
|
||||||
|
list: (userId: string, limit: number) => Promise<PluginActivityEntry[]>;
|
||||||
|
deleteByUserId: (userId: string) => Promise<void>;
|
||||||
|
deleteByHostIds: (hostIds: number[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostActivityApi {
|
||||||
|
/**
|
||||||
|
* Becomes the store behind the dashboard's recent activity and
|
||||||
|
* ctx.hosts.recordActivity. Needs hosts:activity-provide. Removed on
|
||||||
|
* deactivate.
|
||||||
|
*/
|
||||||
|
provide: (provider: PluginActivityProvider) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -896,6 +1058,11 @@ export interface PluginHostStatusApi {
|
|||||||
hostId: number,
|
hostId: number,
|
||||||
) => number | undefined | Promise<number | undefined>,
|
) => number | undefined | Promise<number | undefined>,
|
||||||
) => () => void;
|
) => () => void;
|
||||||
|
/**
|
||||||
|
* Becomes core's host status. Needs hosts:status-provide. Removed on
|
||||||
|
* deactivate, which hands status back to core's built-in checks.
|
||||||
|
*/
|
||||||
|
provide: (provider: PluginHostStatusProvider) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1060,6 +1227,25 @@ export interface PluginSshConnectOptions {
|
|||||||
sock?: unknown;
|
sock?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginSshExecOptions extends PluginSshConnectOptions {
|
||||||
|
/** Time to wait for the command to finish. Default 30s, at most 10 minutes. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
/** Most bytes kept of each of stdout and stderr. Default 256 KB, at most 4 MB. */
|
||||||
|
maxOutputBytes?: number;
|
||||||
|
/** Written to the command's standard input, then closed. */
|
||||||
|
stdin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSshExecResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
/** The exit code. Null when the command was killed or ran out of time. */
|
||||||
|
code: number | null;
|
||||||
|
/** Output went past maxOutputBytes and the rest was dropped. */
|
||||||
|
truncated: boolean;
|
||||||
|
timedOut: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginSshConnection<Client = unknown> {
|
export interface PluginSshConnection<Client = unknown> {
|
||||||
/** An ssh2 Client, already "ready". */
|
/** An ssh2 Client, already "ready". */
|
||||||
client: Client;
|
client: Client;
|
||||||
@@ -1079,12 +1265,47 @@ export interface PluginSshConnection<Client = unknown> {
|
|||||||
* audit line per new connection. Connections still open on deactivate are
|
* audit line per new connection. Connections still open on deactivate are
|
||||||
* closed.
|
* closed.
|
||||||
*/
|
*/
|
||||||
|
export interface PluginNetwork {
|
||||||
|
/**
|
||||||
|
* Whether a TCP port answers. An SSH banner is read and answered politely;
|
||||||
|
* nothing is ever sent that logs in. Loopback, private and link-local
|
||||||
|
* addresses are allowed, since status checks target the user's own hosts.
|
||||||
|
*/
|
||||||
|
tcpProbe: (
|
||||||
|
host: string,
|
||||||
|
port: number,
|
||||||
|
options?: { timeoutMs?: number },
|
||||||
|
) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginSsh {
|
export interface PluginSsh {
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* the host. Needs ssh:connect.
|
||||||
|
*/
|
||||||
|
probe: (
|
||||||
|
hostId: number,
|
||||||
|
options?: { port?: number; timeoutMs?: number },
|
||||||
|
) => Promise<boolean>;
|
||||||
|
|
||||||
connect: <Client = unknown>(
|
connect: <Client = unknown>(
|
||||||
host: number | PluginSshHost,
|
host: number | PluginSshHost,
|
||||||
options?: PluginSshConnectOptions,
|
options?: PluginSshConnectOptions,
|
||||||
) => Promise<PluginSshConnection<Client>>;
|
) => Promise<PluginSshConnection<Client>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one command on a host with its stored login and closes the
|
||||||
|
* connection. The acting user needs connect access to the host, the same as
|
||||||
|
* connect. Output is capped and the command is stopped at the time limit.
|
||||||
|
* Needs ssh:connect and credentials:use.
|
||||||
|
*/
|
||||||
|
exec: (
|
||||||
|
host: number | PluginSshHost,
|
||||||
|
command: string,
|
||||||
|
options?: PluginSshExecOptions,
|
||||||
|
) => Promise<PluginSshExecResult>;
|
||||||
|
|
||||||
/** Borrows a pooled connection. The pool key is `<pool>:<owner>:<address>`. */
|
/** Borrows a pooled connection. The pool key is `<pool>:<owner>:<address>`. */
|
||||||
withConnection: <T, Client = unknown>(
|
withConnection: <T, Client = unknown>(
|
||||||
host: number | PluginSshHost,
|
host: number | PluginSshHost,
|
||||||
@@ -1603,6 +1824,148 @@ export interface PluginDesktop {
|
|||||||
) => Promise<{ success: boolean; error?: string }>;
|
) => Promise<{ success: boolean; error?: string }>;
|
||||||
/** Whether the server runs embedded in the desktop app, so the calls above can work. */
|
/** Whether the server runs embedded in the desktop app, so the calls above can work. */
|
||||||
available: () => boolean;
|
available: () => boolean;
|
||||||
|
/**
|
||||||
|
* The user's own machine. Only works on the desktop app's embedded backend
|
||||||
|
* and needs desktop:local; every call is audited once per plugin start.
|
||||||
|
*/
|
||||||
|
readonly local: PluginDesktopLocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalDirEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "file" | "directory" | "link";
|
||||||
|
size: number;
|
||||||
|
modifiedTimestamp?: number;
|
||||||
|
linkTarget?: string;
|
||||||
|
hidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalWalkResult {
|
||||||
|
files: Array<{ localPath: string; relativePath: string; size: number }>;
|
||||||
|
emptyDirs: string[];
|
||||||
|
totalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalPty {
|
||||||
|
readonly pid: number;
|
||||||
|
/** The shell program that was started. */
|
||||||
|
readonly shell: string;
|
||||||
|
write: (data: string) => void;
|
||||||
|
resize: (cols: number, rows: number) => void;
|
||||||
|
kill: () => void;
|
||||||
|
onData: (handler: (data: string) => void) => () => void;
|
||||||
|
onExit: (handler: (exitCode: number) => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A download being written next to its final name, published when done. */
|
||||||
|
export interface PluginLocalDownload {
|
||||||
|
/** Final path inside the chosen folder, links inside it already refused. */
|
||||||
|
readonly path: string;
|
||||||
|
readonly stream: NodeJS.WritableStream;
|
||||||
|
/** Moves the finished partial onto its final name. */
|
||||||
|
publish: (options?: { overwrite?: boolean }) => Promise<string>;
|
||||||
|
/** Removes the partial file. Safe to call after publish. */
|
||||||
|
abort: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginLocalResponse {
|
||||||
|
readonly status: number;
|
||||||
|
readonly ok: boolean;
|
||||||
|
readonly headers: { get: (name: string) => string | null };
|
||||||
|
/** A web stream of the body, or null when there is none. */
|
||||||
|
readonly body: AsyncIterable<Uint8Array> | null;
|
||||||
|
text: () => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDesktopLocal {
|
||||||
|
/** Whether local access is possible: embedded backend and capability granted. */
|
||||||
|
available: () => boolean;
|
||||||
|
/**
|
||||||
|
* Calls this plugin's own HTTP routes on the embedded backend ("local") or
|
||||||
|
* on the server the desktop is linked to ("remote"), as the acting user.
|
||||||
|
* The path is under /plugin-api/<this plugin id>; nothing else is
|
||||||
|
* reachable. The link's proxy headers and certificate rules apply. A body
|
||||||
|
* may be a string, a buffer or a Node stream.
|
||||||
|
*/
|
||||||
|
pluginFetch: (
|
||||||
|
origin: "local" | "remote",
|
||||||
|
subPath: string,
|
||||||
|
init?: {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string | Buffer | NodeJS.ReadableStream;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
},
|
||||||
|
) => Promise<PluginLocalResponse>;
|
||||||
|
info: () => Promise<{
|
||||||
|
home: string;
|
||||||
|
separator: string;
|
||||||
|
platform: NodeJS.Platform;
|
||||||
|
/** The desktop app's user data folder. */
|
||||||
|
userDataDir: string;
|
||||||
|
/** The port the embedded backend listens on. */
|
||||||
|
backendPort: number;
|
||||||
|
/** The server this desktop is linked to, or null. */
|
||||||
|
link: {
|
||||||
|
serverUrl: string;
|
||||||
|
token: string | null;
|
||||||
|
/**
|
||||||
|
* Headers for a request to the linked server: the session, plus the
|
||||||
|
* proxy headers and basic auth the link was set up with.
|
||||||
|
*/
|
||||||
|
headers: Record<string, string>;
|
||||||
|
/** The link accepts a self-signed certificate. */
|
||||||
|
allowInvalidCertificate: boolean;
|
||||||
|
} | null;
|
||||||
|
}>;
|
||||||
|
/**
|
||||||
|
* GET a JSON route on the linked server with the link's session, proxy
|
||||||
|
* headers and certificate rules. Rejects with an error whose `kind` is
|
||||||
|
* "signed_out" when the server refused the session.
|
||||||
|
*/
|
||||||
|
linkJson: (pathname: string) => Promise<unknown>;
|
||||||
|
readonly fs: {
|
||||||
|
list: (dirPath: string) => Promise<{
|
||||||
|
path: string;
|
||||||
|
parent: string | null;
|
||||||
|
entries: PluginLocalDirEntry[];
|
||||||
|
}>;
|
||||||
|
mkdir: (parentPath: string, name: string) => Promise<string>;
|
||||||
|
createFile: (parentPath: string, name: string) => Promise<string>;
|
||||||
|
/** Renames inside the same folder; never replaces an existing name. */
|
||||||
|
rename: (path: string, newName: string) => Promise<string>;
|
||||||
|
/** Creates a folder; with rootPath it must stay inside that folder. */
|
||||||
|
ensureDir: (dirPath: string, rootPath?: string) => Promise<string>;
|
||||||
|
/** Which of the given paths exist. */
|
||||||
|
exists: (paths: string[]) => Promise<string[]>;
|
||||||
|
walk: (rootPaths: string[]) => Promise<PluginLocalWalkResult>;
|
||||||
|
/** Sends entries to the operating system's trash. */
|
||||||
|
trash: (paths: string[]) => Promise<{
|
||||||
|
trashed: number;
|
||||||
|
failed: Array<{ path: string; error: string }>;
|
||||||
|
}>;
|
||||||
|
reveal: (path: string) => Promise<void>;
|
||||||
|
open: (path: string) => Promise<void>;
|
||||||
|
openRead: (path: string) => Promise<{
|
||||||
|
stream: NodeJS.ReadableStream;
|
||||||
|
size: number;
|
||||||
|
}>;
|
||||||
|
openDownload: (options: {
|
||||||
|
rootPath: string;
|
||||||
|
destPath: string;
|
||||||
|
transferId: string;
|
||||||
|
overwrite?: boolean;
|
||||||
|
}) => Promise<PluginLocalDownload>;
|
||||||
|
};
|
||||||
|
readonly pty: {
|
||||||
|
spawn: (options?: {
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
/** "wsl" on Windows starts WSL instead of PowerShell. */
|
||||||
|
shell?: string;
|
||||||
|
}) => Promise<PluginLocalPty>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1699,6 +2062,23 @@ export interface PluginCredentials {
|
|||||||
scheme: string,
|
scheme: string,
|
||||||
resolve: (userId: string, reference: string) => Promise<string>,
|
resolve: (userId: string, reference: string) => Promise<string>,
|
||||||
) => void;
|
) => void;
|
||||||
|
/**
|
||||||
|
* The public key line of an SSH key credential the acting user can see
|
||||||
|
* (their own, with the core credentials.view permission). Derived from the
|
||||||
|
* private key when none is stored, including a key kept behind a secret
|
||||||
|
* reference. Null when the credential is missing, is not a key, or cannot
|
||||||
|
* be read. Needs credentials:use. Audited.
|
||||||
|
*/
|
||||||
|
publicKey: (credentialId: number) => Promise<string | null>;
|
||||||
|
/**
|
||||||
|
* Replaces secret references ("op://...") in a key and its passphrase with
|
||||||
|
* the real values, for the acting user. Plain values pass through. Hands
|
||||||
|
* private key material to the plugin. Needs credentials:read. Audited.
|
||||||
|
*/
|
||||||
|
resolveKeyRefs: (
|
||||||
|
key: string,
|
||||||
|
passphrase?: string | null,
|
||||||
|
) => Promise<{ key: string; passphrase?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1721,6 +2101,57 @@ export interface PluginAudit {
|
|||||||
*/
|
*/
|
||||||
request?: unknown;
|
request?: unknown;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Called for every audit line core or any plugin writes, after it is
|
||||||
|
* accepted. Fire and forget: a slow or throwing handler never delays or
|
||||||
|
* fails the audited action. Lines written before the plugin started are not
|
||||||
|
* replayed; read them with list or export. Returns an unsubscribe function;
|
||||||
|
* it is also removed when the plugin deactivates. Needs audit:read.
|
||||||
|
*/
|
||||||
|
subscribe: (handler: (entry: PluginAuditEntry) => void) => () => void;
|
||||||
|
/** A page of stored lines, newest first. Needs audit:read. */
|
||||||
|
list: (options?: {
|
||||||
|
filters?: PluginAuditFilters;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}) => Promise<{ entries: PluginAuditEntry[]; total: number }>;
|
||||||
|
/**
|
||||||
|
* Stored lines in time order, in batches, so an export never holds the
|
||||||
|
* whole trail in memory. Needs audit:read.
|
||||||
|
*/
|
||||||
|
exportBatches: (
|
||||||
|
filters?: PluginAuditFilters,
|
||||||
|
batchSize?: number,
|
||||||
|
) => AsyncGenerator<PluginAuditEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginAuditFilters {
|
||||||
|
userId?: string;
|
||||||
|
action?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
success?: boolean;
|
||||||
|
/** ISO date-time. */
|
||||||
|
startDate?: string;
|
||||||
|
/** ISO date-time. */
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginAuditEntry {
|
||||||
|
/** Only on stored lines (list and export). */
|
||||||
|
id?: number;
|
||||||
|
/** ISO date-time. */
|
||||||
|
timestamp: string;
|
||||||
|
userId: string | null;
|
||||||
|
username: string;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string | null;
|
||||||
|
resourceName: string | null;
|
||||||
|
details: string | null;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A notification channel a user set up. Its config never leaves the hub. */
|
/** A notification channel a user set up. Its config never leaves the hub. */
|
||||||
@@ -2066,6 +2497,8 @@ export interface PluginContext {
|
|||||||
readonly notify: PluginNotify;
|
readonly notify: PluginNotify;
|
||||||
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
|
/** Outbound HTTP through core's SSRF guard. Needs network:outbound. */
|
||||||
readonly fetch: PluginFetch;
|
readonly fetch: PluginFetch;
|
||||||
|
/** Plain reachability checks. Needs network:probe. */
|
||||||
|
readonly network: PluginNetwork;
|
||||||
/** Programs on the Termix server. Needs process:spawn. */
|
/** Programs on the Termix server. Needs process:spawn. */
|
||||||
readonly process: PluginProcess;
|
readonly process: PluginProcess;
|
||||||
/** The server certificate. Needs system:tls. */
|
/** The server certificate. Needs system:tls. */
|
||||||
|
|||||||
@@ -49,18 +49,24 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("auth:provide", "high"),
|
entry("auth:provide", "high"),
|
||||||
entry("device:serial", "high"),
|
entry("device:serial", "high"),
|
||||||
entry("notify:hub", "high"),
|
entry("notify:hub", "high"),
|
||||||
|
entry("ui:layout", "high"),
|
||||||
|
|
||||||
entry("hosts:write", "medium"),
|
entry("hosts:write", "medium"),
|
||||||
|
entry("hosts:export", "high"),
|
||||||
entry("credentials:use", "medium"),
|
entry("credentials:use", "medium"),
|
||||||
entry("credentials:write", "medium"),
|
entry("credentials:write", "medium"),
|
||||||
entry("network:outbound", "medium"),
|
entry("network:outbound", "medium"),
|
||||||
entry("network:serve", "medium"),
|
entry("network:serve", "medium"),
|
||||||
entry("network:broadcast", "medium"),
|
entry("network:broadcast", "medium"),
|
||||||
|
entry("network:probe", "medium"),
|
||||||
|
entry("hosts:status-provide", "medium"),
|
||||||
|
entry("hosts:activity-provide", "medium"),
|
||||||
entry("users:read", "medium"),
|
entry("users:read", "medium"),
|
||||||
entry("events:core", "medium"),
|
entry("events:core", "medium"),
|
||||||
entry("notify:send", "medium"),
|
entry("notify:send", "medium"),
|
||||||
entry("audit:read", "medium"),
|
entry("audit:read", "medium"),
|
||||||
entry("desktop:window", "medium"),
|
entry("desktop:window", "medium"),
|
||||||
|
entry("desktop:local", "high"),
|
||||||
|
|
||||||
entry("hosts:read", "low"),
|
entry("hosts:read", "low"),
|
||||||
entry("db:own", "low"),
|
entry("db:own", "low"),
|
||||||
@@ -70,6 +76,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
|||||||
entry("settings:read-core", "low"),
|
entry("settings:read-core", "low"),
|
||||||
entry("plugins:read", "low"),
|
entry("plugins:read", "low"),
|
||||||
entry("ui:surface", "low"),
|
entry("ui:surface", "low"),
|
||||||
|
entry("ui:theme", "low"),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
|
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
|
|||||||
homepage_items: "homepage",
|
homepage_items: "homepage",
|
||||||
homepage_layouts: "homepage",
|
homepage_layouts: "homepage",
|
||||||
dashboard_service_links: "homepage",
|
dashboard_service_links: "homepage",
|
||||||
|
recent_activity: "homepage",
|
||||||
secret_sources: "secret-sources",
|
secret_sources: "secret-sources",
|
||||||
opkssh_tokens: "opkssh",
|
opkssh_tokens: "opkssh",
|
||||||
vault_profiles: "vault",
|
vault_profiles: "vault",
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* only inside the Termix desktop app; check isElectron before calling it.
|
* only inside the Termix desktop app; check isElectron before calling it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PluginSshPromptRequest } from "./backend.js";
|
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
allowInvalidCertificate?: boolean;
|
allowInvalidCertificate?: boolean;
|
||||||
@@ -33,27 +31,6 @@ export interface DialogResult {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalDirectoryResult {
|
|
||||||
success: boolean;
|
|
||||||
path: string;
|
|
||||||
parent?: string;
|
|
||||||
entries: LocalFileEntry[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalCollectedFile {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
relativePath: string;
|
|
||||||
size: number;
|
|
||||||
created?: string;
|
|
||||||
modified: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalPathMutationResult =
|
|
||||||
| ({ success: true } & Partial<LocalFileEntry>)
|
|
||||||
| { success: false; error?: string };
|
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
getAppVersion: () => Promise<string>;
|
getAppVersion: () => Promise<string>;
|
||||||
setZoomFactor?: (factor: number) => void;
|
setZoomFactor?: (factor: number) => void;
|
||||||
@@ -67,60 +44,6 @@ export interface ElectronAPI {
|
|||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
allowInvalidCertificate?: boolean,
|
allowInvalidCertificate?: boolean,
|
||||||
) => Promise<ConnectionTestResult>;
|
) => Promise<ConnectionTestResult>;
|
||||||
answerC2SAuth: (id: string, answer: string | null) => Promise<boolean>;
|
|
||||||
onC2SAuthPrompt: (
|
|
||||||
callback: (prompt: {
|
|
||||||
id: string;
|
|
||||||
closed?: boolean;
|
|
||||||
tunnelName?: string;
|
|
||||||
request?: PluginSshPromptRequest;
|
|
||||||
}) => void,
|
|
||||||
) => () => void;
|
|
||||||
getC2STunnelConfig: () => Promise<unknown[]>;
|
|
||||||
saveC2STunnelConfig: (
|
|
||||||
config: unknown[],
|
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
|
||||||
checkLocalPortAvailable: (
|
|
||||||
host: string,
|
|
||||||
port: number,
|
|
||||||
) => Promise<{ available: boolean; error?: string }>;
|
|
||||||
getC2STunnelPresetDefaultName: () => Promise<string>;
|
|
||||||
startC2STunnel: (
|
|
||||||
tunnel: unknown,
|
|
||||||
index: number,
|
|
||||||
authToken?: string,
|
|
||||||
) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
tunnelName?: string;
|
|
||||||
error?: string;
|
|
||||||
code?: string;
|
|
||||||
}>;
|
|
||||||
testC2STunnel: (
|
|
||||||
tunnel: unknown,
|
|
||||||
index: number,
|
|
||||||
authToken?: string,
|
|
||||||
) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
code?: string;
|
|
||||||
}>;
|
|
||||||
stopC2STunnel: (
|
|
||||||
tunnelName: string,
|
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
|
||||||
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
|
|
||||||
onC2STunnelStatuses?: (
|
|
||||||
callback: (statuses: Record<string, unknown>) => void,
|
|
||||||
) => () => void;
|
|
||||||
startC2SAutoStartTunnels: () => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
started: number;
|
|
||||||
errors: string[];
|
|
||||||
}>;
|
|
||||||
/** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */
|
|
||||||
setC2SRelayPath?: (
|
|
||||||
relayPath: string,
|
|
||||||
) => Promise<{ success: boolean; error?: string }>;
|
|
||||||
onCloseActiveTab?: (callback: () => void) => () => void;
|
onCloseActiveTab?: (callback: () => void) => () => void;
|
||||||
clearSessionCookies: () => Promise<void>;
|
clearSessionCookies: () => Promise<void>;
|
||||||
getSessionCookie: (
|
getSessionCookie: (
|
||||||
@@ -136,62 +59,6 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
|
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||||
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
|
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||||
getLocalHomeDirectory?: () => Promise<string>;
|
|
||||||
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
|
|
||||||
statLocalPaths?: (
|
|
||||||
paths: string[],
|
|
||||||
) => Promise<
|
|
||||||
Array<
|
|
||||||
| (LocalFileEntry & { success: true })
|
|
||||||
| { success: false; path: string; error?: string }
|
|
||||||
>
|
|
||||||
>;
|
|
||||||
collectLocalFiles?: (paths: string[]) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
files: LocalCollectedFile[];
|
|
||||||
truncated?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
createLocalFolder?: (
|
|
||||||
parentPath: string,
|
|
||||||
folderName: string,
|
|
||||||
) => Promise<LocalPathMutationResult>;
|
|
||||||
renameLocalPath?: (
|
|
||||||
path: string,
|
|
||||||
newName: string,
|
|
||||||
) => Promise<LocalPathMutationResult>;
|
|
||||||
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
|
|
||||||
chmodLocalPath?: (
|
|
||||||
path: string,
|
|
||||||
permissions: string,
|
|
||||||
) => Promise<LocalPathMutationResult>;
|
|
||||||
|
|
||||||
openExternalEditor: (fileData: {
|
|
||||||
fileName: string;
|
|
||||||
content: string;
|
|
||||||
encoding?: "utf8" | "base64";
|
|
||||||
editorPath?: string | null;
|
|
||||||
}) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
editId?: string;
|
|
||||||
path?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
closeExternalEditor: (editId: string) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
onExternalEditorSaved?: (
|
|
||||||
callback: (payload: {
|
|
||||||
editId: string;
|
|
||||||
content: string;
|
|
||||||
encoding: "utf8";
|
|
||||||
path: string;
|
|
||||||
}) => void,
|
|
||||||
) => () => void;
|
|
||||||
|
|
||||||
onUpdateAvailable: (callback: () => void) => void;
|
onUpdateAvailable: (callback: () => void) => void;
|
||||||
onUpdateDownloaded: (callback: () => void) => void;
|
onUpdateDownloaded: (callback: () => void) => void;
|
||||||
|
|
||||||
@@ -238,159 +105,6 @@ export interface ElectronAPI {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
startLocalTerminal(dimensions: {
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
shell?: "default" | "wsl";
|
|
||||||
}): Promise<{ sessionId: string; shell: string }>;
|
|
||||||
readyLocalTerminal(sessionId: string): Promise<boolean>;
|
|
||||||
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
|
|
||||||
resizeLocalTerminal(
|
|
||||||
sessionId: string,
|
|
||||||
cols: number,
|
|
||||||
rows: number,
|
|
||||||
): Promise<boolean>;
|
|
||||||
closeLocalTerminal(sessionId: string): Promise<boolean>;
|
|
||||||
onLocalTerminalData(
|
|
||||||
sessionId: string,
|
|
||||||
callback: (data: string) => void,
|
|
||||||
): () => void;
|
|
||||||
onLocalTerminalExit(
|
|
||||||
sessionId: string,
|
|
||||||
callback: (exitCode: number) => void,
|
|
||||||
): () => void;
|
|
||||||
|
|
||||||
/** Local disk browsing for the dual-pane file manager (desktop only). */
|
|
||||||
localFs?: {
|
|
||||||
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
|
|
||||||
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
|
|
||||||
mkdir: (
|
|
||||||
parentPath: string,
|
|
||||||
name: string,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
createFile: (
|
|
||||||
parentPath: string,
|
|
||||||
name: string,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
rename: (
|
|
||||||
oldPath: string,
|
|
||||||
newName: string,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
|
|
||||||
ensureDir: (
|
|
||||||
dirPath: string,
|
|
||||||
rootPath?: string,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
|
|
||||||
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
|
|
||||||
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
|
||||||
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Streamed local<->remote transfers driven by the main process. */
|
|
||||||
localTransfer?: {
|
|
||||||
/** The /plugin-api/<id> path that serves the streaming routes. */
|
|
||||||
setApiPath?: (apiPath: string) => Promise<LocalFsResult<object>>;
|
|
||||||
upload: (
|
|
||||||
options: LocalUploadRequest,
|
|
||||||
) => Promise<LocalFsResult<{ bytes: number }>>;
|
|
||||||
download: (
|
|
||||||
options: LocalDownloadRequest,
|
|
||||||
) => Promise<LocalFsResult<{ path: string }>>;
|
|
||||||
cancel: (
|
|
||||||
transferId: string,
|
|
||||||
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
|
|
||||||
onProgress: (
|
|
||||||
callback: (payload: LocalTransferProgress) => void,
|
|
||||||
) => () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalFsResult<T> =
|
|
||||||
({ success: true } & T) | { success: false; error: string; code?: string };
|
|
||||||
|
|
||||||
export interface LocalFsHomeInfo {
|
|
||||||
home: string;
|
|
||||||
separator: string;
|
|
||||||
platform: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalFileEntry {
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
type: "file" | "directory" | "link" | "other";
|
|
||||||
created?: string;
|
|
||||||
modified?: string;
|
|
||||||
permissions?: string;
|
|
||||||
owner?: string;
|
|
||||||
group?: string;
|
|
||||||
size: number;
|
|
||||||
modifiedTimestamp?: number;
|
|
||||||
linkTarget?: string;
|
|
||||||
hidden: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalDirectoryListing {
|
|
||||||
path: string;
|
|
||||||
parent: string | null;
|
|
||||||
entries: LocalFileEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalTrashResult {
|
|
||||||
trashed: number;
|
|
||||||
failed: Array<{ path: string; error: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalWalkFile {
|
|
||||||
localPath: string;
|
|
||||||
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
|
|
||||||
relativePath: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalWalkResult {
|
|
||||||
files: LocalWalkFile[];
|
|
||||||
emptyDirs: string[];
|
|
||||||
totalBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Which Termix backend a transfer talks to. The main process resolves the
|
|
||||||
* actual URL and credentials for the origin; the renderer never supplies them.
|
|
||||||
*/
|
|
||||||
export type LocalTransferOrigin = "local" | "remote";
|
|
||||||
|
|
||||||
export interface LocalUploadRequest {
|
|
||||||
transferId: string;
|
|
||||||
origin: LocalTransferOrigin;
|
|
||||||
fields: Record<string, string>;
|
|
||||||
localPath: string;
|
|
||||||
fileName: string;
|
|
||||||
deviceId?: string;
|
|
||||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
|
||||||
authToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalDownloadRequest {
|
|
||||||
transferId: string;
|
|
||||||
origin: LocalTransferOrigin;
|
|
||||||
body: Record<string, unknown>;
|
|
||||||
destPath: string;
|
|
||||||
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
|
|
||||||
rootPath: string;
|
|
||||||
expectedSize?: number;
|
|
||||||
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
|
|
||||||
overwrite?: boolean;
|
|
||||||
deviceId?: string;
|
|
||||||
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
|
|
||||||
authToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LocalTransferProgress {
|
|
||||||
transferId: string;
|
|
||||||
transferred: number;
|
|
||||||
total?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -248,6 +248,129 @@ export interface TabsApi extends Pick<
|
|||||||
onReady: (listener: () => void) => Disposer;
|
onReady: (listener: () => void) => Disposer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Layouts (plugin API 1.4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-app layout. Core renders the chosen one inside its providers and an
|
||||||
|
* error boundary; everything it shows comes from useShell(). Declare it in
|
||||||
|
* manifest contributes.layouts and add the ui:layout capability.
|
||||||
|
*/
|
||||||
|
export interface LayoutContribution {
|
||||||
|
id: string;
|
||||||
|
/** Your own i18n key or plain text, the same as the manifest's label. */
|
||||||
|
label: string;
|
||||||
|
component: ComponentType;
|
||||||
|
/** Shown on phones and narrow windows. The desktop app never uses it. */
|
||||||
|
mobileComponent?: ComponentType;
|
||||||
|
/** Theme to start with when nobody chose one. */
|
||||||
|
defaultTheme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */
|
||||||
|
export interface ShellTabState extends PluginTabRecord {
|
||||||
|
instanceId: string;
|
||||||
|
customLabel?: string;
|
||||||
|
/** Set while the tab sits in a split pane. */
|
||||||
|
parentSplitTabId?: string;
|
||||||
|
openedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a dock region shows and how it is sized, in Normal-size pixels. */
|
||||||
|
export interface ShellDockState {
|
||||||
|
view: string | null;
|
||||||
|
open: boolean;
|
||||||
|
width: number;
|
||||||
|
dragging: boolean;
|
||||||
|
setView: (view: string | null) => void;
|
||||||
|
setWidth: (width: number) => void;
|
||||||
|
/** Starts a drag from the dock's edge, from a mousedown. */
|
||||||
|
startResize: (event: { clientX: number; preventDefault: () => void }) => void;
|
||||||
|
resetWidth: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live shell: state and actions, nothing drawn. Read with useShell(). */
|
||||||
|
export interface ShellController {
|
||||||
|
tabs: {
|
||||||
|
list: ShellTabState[];
|
||||||
|
/** What a tab bar shows: tabs not sitting in a split. */
|
||||||
|
topLevel: ShellTabState[];
|
||||||
|
activeId: string;
|
||||||
|
/** True once saved tabs were restored after login. */
|
||||||
|
ready: boolean;
|
||||||
|
/** The user turned on tab numbers. */
|
||||||
|
showNumbers: boolean;
|
||||||
|
setActive: (id: string) => void;
|
||||||
|
reorder: (topLevel: ShellTabState[]) => void;
|
||||||
|
close: (id: string) => Promise<void>;
|
||||||
|
rename: (id: string, label: string) => void;
|
||||||
|
/** Reconnects or reloads a session tab. */
|
||||||
|
refresh: (id: string) => void;
|
||||||
|
reconnectAll: () => void;
|
||||||
|
openSingleton: (type: string) => string | undefined;
|
||||||
|
};
|
||||||
|
split: {
|
||||||
|
/** The split shown now, or null. */
|
||||||
|
activeId: string | null;
|
||||||
|
focusedPaneId: string | null;
|
||||||
|
/** Every split with its panes, for a split menu. */
|
||||||
|
summaries: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
full: boolean;
|
||||||
|
panes: Array<{
|
||||||
|
id: string;
|
||||||
|
index: number;
|
||||||
|
tabId: string | null;
|
||||||
|
label: string | null;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
activeFull: boolean;
|
||||||
|
/** Split a tab, apply a preset, unsplit, add to a pane. */
|
||||||
|
handleAction: (action: Record<string, unknown> & { kind: string }) => void;
|
||||||
|
};
|
||||||
|
hosts: {
|
||||||
|
list: PluginHostRecord[];
|
||||||
|
loading: boolean;
|
||||||
|
loaded: boolean;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
};
|
||||||
|
regions: {
|
||||||
|
left: ShellDockState & { view: string };
|
||||||
|
right: ShellDockState;
|
||||||
|
/** What a rail click does: toggles or switches the left dock. */
|
||||||
|
railClick: (view: string) => void;
|
||||||
|
openInRightDock: (view: string) => void;
|
||||||
|
toggleRightDock: () => void;
|
||||||
|
};
|
||||||
|
palette: { isOpen: boolean; open: () => void; close: () => void };
|
||||||
|
fullscreen: { active: boolean; toggle: () => void };
|
||||||
|
/** The same callbacks tabs and panels get. */
|
||||||
|
actions: ShellApi;
|
||||||
|
env: {
|
||||||
|
isMobile: boolean;
|
||||||
|
isElectron: boolean;
|
||||||
|
user: { username: string; isAdmin: boolean };
|
||||||
|
permissions: string[];
|
||||||
|
branding: {
|
||||||
|
appName: string | null;
|
||||||
|
tagline: string | null;
|
||||||
|
logo: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** Core registries, as hooks a layout calls while rendering. */
|
||||||
|
registries: {
|
||||||
|
useRailItems: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||||
|
usePanels: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||||
|
useTabTypes: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||||
|
useDashboardCards: () => ReadonlyArray<
|
||||||
|
{ id: string } & Record<string, unknown>
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
logout: (options?: { manual?: boolean }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Registrations
|
// Registrations
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -530,8 +653,16 @@ export interface HostBadgeContribution {
|
|||||||
id: string;
|
id: string;
|
||||||
when: (host: PluginHostRecord) => boolean;
|
when: (host: PluginHostRecord) => boolean;
|
||||||
component: ComponentType<{ host: PluginHostRecord }>;
|
component: ComponentType<{ host: PluginHostRecord }>;
|
||||||
|
/** Lower comes first. Ties sort by id. */
|
||||||
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The dot or stripe that shows a host's status on its row. */
|
||||||
|
export type HostStatusIndicatorContribution = HostBadgeContribution;
|
||||||
|
|
||||||
|
/** A control in the host editor's toolbar. */
|
||||||
|
export type HostEditorToolbarItemContribution = HostBadgeContribution;
|
||||||
|
|
||||||
export interface HostContextMenuItemContribution {
|
export interface HostContextMenuItemContribution {
|
||||||
id: string;
|
id: string;
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
@@ -715,10 +846,11 @@ export type OnboardingMode = "full" | "partial" | "rerun";
|
|||||||
* Where a plugin step sits in onboarding. "setup" comes right after the
|
* Where a plugin step sits in onboarding. "setup" comes right after the
|
||||||
* appearance steps, for things that need doing before the feature works
|
* appearance steps, for things that need doing before the feature works
|
||||||
* (like pointing at a guacd server). "explore" (the default) comes after
|
* (like pointing at a guacd server). "explore" (the default) comes after
|
||||||
* that, "security" after the account security step. Onboarding is for
|
* that, "security" after the account security step. "start" comes before
|
||||||
|
* the appearance steps, for choices that shape how the rest looks. Onboarding is for
|
||||||
* choices, not for explaining features.
|
* choices, not for explaining features.
|
||||||
*/
|
*/
|
||||||
export type OnboardingSection = "setup" | "explore" | "security";
|
export type OnboardingSection = "start" | "setup" | "explore" | "security";
|
||||||
|
|
||||||
export interface OnboardingStepProps {
|
export interface OnboardingStepProps {
|
||||||
/**
|
/**
|
||||||
@@ -761,6 +893,16 @@ export interface OnboardingStepContribution {
|
|||||||
permission?: string;
|
permission?: string;
|
||||||
section?: OnboardingSection;
|
section?: OnboardingSection;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
/**
|
||||||
|
* Exact place in the flow, 1 to 99, instead of a section. Core steps sit at
|
||||||
|
* 10 (plugins), 20, 25, 30 (preset), 40 (appearance) and 90 (security).
|
||||||
|
*/
|
||||||
|
position?: number;
|
||||||
|
/**
|
||||||
|
* Older step keys this step took over, like "preset". People who already
|
||||||
|
* saw one of them are not shown this step again.
|
||||||
|
*/
|
||||||
|
replaces?: string[];
|
||||||
when?: (context: {
|
when?: (context: {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isDesktop: boolean;
|
isDesktop: boolean;
|
||||||
@@ -932,6 +1074,14 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
registerHostAction: (action: HostActionContribution) => Disposer;
|
registerHostAction: (action: HostActionContribution) => Disposer;
|
||||||
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
|
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
|
||||||
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
|
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
|
||||||
|
/** Draws a host row's status. A layout renders every registered one. */
|
||||||
|
registerHostStatusIndicator: (
|
||||||
|
indicator: HostStatusIndicatorContribution,
|
||||||
|
) => Disposer;
|
||||||
|
/** Adds a control to the host editor's toolbar. */
|
||||||
|
registerHostEditorToolbarItem: (
|
||||||
|
item: HostEditorToolbarItemContribution,
|
||||||
|
) => Disposer;
|
||||||
registerHostContextMenuItem: (
|
registerHostContextMenuItem: (
|
||||||
item: HostContextMenuItemContribution,
|
item: HostContextMenuItemContribution,
|
||||||
) => Disposer;
|
) => Disposer;
|
||||||
@@ -988,6 +1138,12 @@ export interface TermixApp extends TermixAppInfo {
|
|||||||
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
||||||
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
||||||
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
||||||
|
/**
|
||||||
|
* A whole-app layout. Declared in manifest contributes.layouts with the
|
||||||
|
* ui:layout capability. Only the chosen layout's plugin is loaded, so
|
||||||
|
* register it in activate. Needs plugin API 1.4.
|
||||||
|
*/
|
||||||
|
registerLayout: (layout: LayoutContribution) => Disposer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Translates a key from the plugin's own locales, for code outside a
|
* Translates a key from the plugin's own locales, for code outside a
|
||||||
@@ -1122,6 +1278,7 @@ export interface PluginHostBridge {
|
|||||||
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
||||||
useCurrentUser: () => CurrentUser | null;
|
useCurrentUser: () => CurrentUser | null;
|
||||||
useTheme: () => { theme: "light" | "dark" };
|
useTheme: () => { theme: "light" | "dark" };
|
||||||
|
useShell: () => ShellController | null;
|
||||||
toast: ToastApi;
|
toast: ToastApi;
|
||||||
getApi: (pluginId: string) => PluginApiClient;
|
getApi: (pluginId: string) => PluginApiClient;
|
||||||
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
||||||
@@ -1372,6 +1529,14 @@ export function useTheme(): { theme: "light" | "dark" } {
|
|||||||
return requireHost().useTheme();
|
return requireHost().useTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The live shell, for a layout. Null outside the signed-in app. Needs plugin
|
||||||
|
* API 1.4.
|
||||||
|
*/
|
||||||
|
export function useShell(): ShellController | null {
|
||||||
|
return requireHost().useShell();
|
||||||
|
}
|
||||||
|
|
||||||
export function useToast(): ToastApi {
|
export function useToast(): ToastApi {
|
||||||
return requireHost().toast;
|
return requireHost().toast;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type PluginEnvVar,
|
type PluginEnvVar,
|
||||||
} from "./docs.js";
|
} from "./docs.js";
|
||||||
|
|
||||||
|
export { validateThemeCss } from "./theme-css.js";
|
||||||
export {
|
export {
|
||||||
ENV_NAME_PATTERN,
|
ENV_NAME_PATTERN,
|
||||||
MAX_ENV_DESCRIPTION_LENGTH,
|
MAX_ENV_DESCRIPTION_LENGTH,
|
||||||
@@ -36,7 +37,7 @@ export {
|
|||||||
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
|
||||||
* version by the server.
|
* version by the server.
|
||||||
*/
|
*/
|
||||||
export const PLUGIN_API_VERSION = "1.3.0";
|
export const PLUGIN_API_VERSION = "1.5.0";
|
||||||
|
|
||||||
/** The API major, the default engine.api for a new plugin. */
|
/** The API major, the default engine.api for a new plugin. */
|
||||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||||
@@ -426,6 +427,40 @@ export interface PluginAuthContribution {
|
|||||||
keyboardInteractive?: string[];
|
keyboardInteractive?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
|
||||||
|
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-app layout the frontend registers with app.registerLayout. Needs
|
||||||
|
* the ui:layout capability. Declared here so core can list and choose it
|
||||||
|
* before any plugin code runs.
|
||||||
|
*/
|
||||||
|
export interface PluginLayoutContribution {
|
||||||
|
id: string;
|
||||||
|
/** i18n key in the plugin's namespace, or plain text. */
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
/** Theme picked with this layout when nobody chose one. */
|
||||||
|
defaultTheme?: string;
|
||||||
|
/** The layout a default-look plugin starts with. */
|
||||||
|
default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A theme: one CSS file of token rules scoped under `.<id>`, applied as a
|
||||||
|
* class on <html>. Needs the ui:theme capability. No code runs for a theme.
|
||||||
|
*/
|
||||||
|
export interface PluginThemeContribution {
|
||||||
|
id: string;
|
||||||
|
/** i18n key in the plugin's namespace, or plain text. */
|
||||||
|
label: string;
|
||||||
|
mode: "light" | "dark";
|
||||||
|
/** Path of the CSS file inside the plugin. */
|
||||||
|
css: string;
|
||||||
|
/** Colors the theme picker previews it with. */
|
||||||
|
swatch?: { background: string; panel: string; text: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginContributions {
|
export interface PluginContributions {
|
||||||
tabs?: PluginTabContribution[];
|
tabs?: PluginTabContribution[];
|
||||||
panels?: PluginViewContribution[];
|
panels?: PluginViewContribution[];
|
||||||
@@ -487,6 +522,10 @@ export interface PluginContributions {
|
|||||||
* ctx.credentials.resolveHostProtocol.
|
* ctx.credentials.resolveHostProtocol.
|
||||||
*/
|
*/
|
||||||
protocols?: PluginProtocolContribution[];
|
protocols?: PluginProtocolContribution[];
|
||||||
|
/** Whole-app layouts. Needs the ui:layout capability. */
|
||||||
|
layouts?: PluginLayoutContribution[];
|
||||||
|
/** Themes. Needs the ui:theme capability. */
|
||||||
|
themes?: PluginThemeContribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A field a protocol login carries besides username and password. */
|
/** A field a protocol login carries besides username and password. */
|
||||||
@@ -649,6 +688,21 @@ export interface PluginManifest {
|
|||||||
nativeDependencies?: string[];
|
nativeDependencies?: string[];
|
||||||
/** How a linked desktop treats this plugin. Defaults to "mirror". */
|
/** How a linked desktop treats this plugin. Defaults to "mirror". */
|
||||||
desktop?: PluginDesktopMode;
|
desktop?: PluginDesktopMode;
|
||||||
|
/**
|
||||||
|
* "desktop": the plugin only loads on a desktop app's own embedded backend,
|
||||||
|
* never on a server. It implies desktop "local" and may declare desktop:local.
|
||||||
|
*/
|
||||||
|
runtime?: PluginRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginRuntime = "desktop";
|
||||||
|
export const PLUGIN_RUNTIMES: readonly PluginRuntime[] = ["desktop"];
|
||||||
|
|
||||||
|
/** The desktop mode a manifest resolves to. */
|
||||||
|
export function resolvePluginDesktopMode(
|
||||||
|
m: Pick<PluginManifest, "desktop" | "runtime">,
|
||||||
|
): PluginDesktopMode {
|
||||||
|
return m.desktop ?? (m.runtime === "desktop" ? "local" : "mirror");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
|
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
|
||||||
@@ -688,6 +742,7 @@ const ALLOWED_TOP_LEVEL = new Set([
|
|||||||
"platforms",
|
"platforms",
|
||||||
"nativeDependencies",
|
"nativeDependencies",
|
||||||
"desktop",
|
"desktop",
|
||||||
|
"runtime",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_CONTRIBUTES = new Set([
|
const ALLOWED_CONTRIBUTES = new Set([
|
||||||
@@ -708,6 +763,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
|||||||
"uses",
|
"uses",
|
||||||
"keybindingActions",
|
"keybindingActions",
|
||||||
"protocols",
|
"protocols",
|
||||||
|
"layouts",
|
||||||
|
"themes",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ALLOWED_SETTINGS_FIELD = [
|
const ALLOWED_SETTINGS_FIELD = [
|
||||||
@@ -863,6 +920,21 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`,
|
`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
m.runtime !== undefined &&
|
||||||
|
!PLUGIN_RUNTIMES.includes(m.runtime as PluginRuntime)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`Field "runtime" must be one of: ${PLUGIN_RUNTIMES.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
m.runtime === "desktop" &&
|
||||||
|
m.desktop !== undefined &&
|
||||||
|
m.desktop !== "local"
|
||||||
|
) {
|
||||||
|
errors.push(`A "desktop" runtime plugin must use desktop "local"`);
|
||||||
|
}
|
||||||
validateDependencyMap(m.dependencies, "dependencies", errors);
|
validateDependencyMap(m.dependencies, "dependencies", errors);
|
||||||
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
|
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
|
||||||
validateProvides(m.provides, errors);
|
validateProvides(m.provides, errors);
|
||||||
@@ -1300,6 +1372,111 @@ function validateContributes(
|
|||||||
validateUiPresets(contributes.uiPresets, errors);
|
validateUiPresets(contributes.uiPresets, errors);
|
||||||
validateKeybindingActions(contributes.keybindingActions, errors);
|
validateKeybindingActions(contributes.keybindingActions, errors);
|
||||||
validateProtocols(contributes.protocols, errors);
|
validateProtocols(contributes.protocols, errors);
|
||||||
|
validateLayouts(contributes.layouts, errors);
|
||||||
|
validateThemes(contributes.themes, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An array of objects with unique look ids; calls `each` for every valid entry. */
|
||||||
|
function validateLookList(
|
||||||
|
value: unknown,
|
||||||
|
field: string,
|
||||||
|
allowed: string[],
|
||||||
|
errors: string[],
|
||||||
|
each: (raw: Record<string, unknown>, where: string) => void,
|
||||||
|
): void {
|
||||||
|
if (value === undefined) return;
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
errors.push(`Field "contributes.${field}" must be an array`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
value.forEach((raw, index) => {
|
||||||
|
const where = `contributes.${field}[${index}]`;
|
||||||
|
if (!isPlainObject(raw)) {
|
||||||
|
errors.push(`${where} must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(raw, allowed, where, errors);
|
||||||
|
if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
|
||||||
|
errors.push(
|
||||||
|
`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
|
||||||
|
seen.add(raw.id);
|
||||||
|
}
|
||||||
|
requireString(raw.label, `${where}.label`, errors);
|
||||||
|
each(raw, where);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLayouts(value: unknown, errors: string[]): void {
|
||||||
|
validateLookList(
|
||||||
|
value,
|
||||||
|
"layouts",
|
||||||
|
["id", "label", "description", "defaultTheme", "default"],
|
||||||
|
errors,
|
||||||
|
(raw, where) => {
|
||||||
|
if (raw.description !== undefined)
|
||||||
|
requireString(raw.description, `${where}.description`, errors);
|
||||||
|
if (
|
||||||
|
raw.defaultTheme !== undefined &&
|
||||||
|
(typeof raw.defaultTheme !== "string" ||
|
||||||
|
!LOOK_ID_PATTERN.test(raw.defaultTheme))
|
||||||
|
) {
|
||||||
|
errors.push(`${where}.defaultTheme must be a theme id`);
|
||||||
|
}
|
||||||
|
if (raw.default !== undefined && typeof raw.default !== "boolean") {
|
||||||
|
errors.push(`${where}.default must be a boolean`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1
|
||||||
|
) {
|
||||||
|
errors.push('Only one entry in "contributes.layouts" may set default');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||||
|
|
||||||
|
function validateThemes(value: unknown, errors: string[]): void {
|
||||||
|
validateLookList(
|
||||||
|
value,
|
||||||
|
"themes",
|
||||||
|
["id", "label", "mode", "css", "swatch"],
|
||||||
|
errors,
|
||||||
|
(raw, where) => {
|
||||||
|
if (raw.mode !== "light" && raw.mode !== "dark") {
|
||||||
|
errors.push(`${where}.mode must be "light" or "dark"`);
|
||||||
|
}
|
||||||
|
if (requireString(raw.css, `${where}.css`, errors)) {
|
||||||
|
requireRelativePath(raw.css, `${where}.css`, errors);
|
||||||
|
if (!raw.css.endsWith(".css")) {
|
||||||
|
errors.push(`${where}.css must be a .css file`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (raw.swatch !== undefined) {
|
||||||
|
if (!isPlainObject(raw.swatch)) {
|
||||||
|
errors.push(`${where}.swatch must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectUnknown(
|
||||||
|
raw.swatch,
|
||||||
|
["background", "panel", "text"],
|
||||||
|
`${where}.swatch`,
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
for (const key of ["background", "panel", "text"]) {
|
||||||
|
const color = raw.swatch[key];
|
||||||
|
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
|
||||||
|
errors.push(`${where}.swatch.${key} must be a hex color`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||||
@@ -2324,6 +2501,23 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(manifest.contributes?.layouts?.length ?? 0) > 0 &&
|
||||||
|
!manifest.capabilities.includes("ui:layout")
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'contributes.layouts needs the "ui:layout" capability in the capabilities array',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(manifest.contributes?.themes?.length ?? 0) > 0 &&
|
||||||
|
!manifest.capabilities.includes("ui:theme")
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
'contributes.themes needs the "ui:theme" capability in the capabilities array',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const secret of manifest.requiresSecret ?? []) {
|
for (const secret of manifest.requiresSecret ?? []) {
|
||||||
if (secret.plugin === manifest.id) {
|
if (secret.plugin === manifest.id) {
|
||||||
errors.push(
|
errors.push(
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* SSH key helpers for plugins that make, check or deploy keys: generate a key
|
||||||
|
* pair, tell what a key is, derive a public key and check a pair belongs
|
||||||
|
* together. Pure functions over ssh2, which the host supplies. Core keeps its
|
||||||
|
* own copy for the connect pipeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ssh2 from "ssh2";
|
||||||
|
|
||||||
|
export type SshKeyKind = "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256";
|
||||||
|
|
||||||
|
export interface GeneratedSshKeyPair {
|
||||||
|
privateKey: string;
|
||||||
|
publicKey: string;
|
||||||
|
keyType: SshKeyKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateSshKeyPairOptions {
|
||||||
|
/** RSA only. 2048 to 8192, default 4096. */
|
||||||
|
bits?: number;
|
||||||
|
passphrase?: string;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Makes a new key pair; the public half is one authorized_keys line. */
|
||||||
|
export function generateSshKeyPair(
|
||||||
|
type: SshKeyKind,
|
||||||
|
options: GenerateSshKeyPairOptions = {},
|
||||||
|
): GeneratedSshKeyPair {
|
||||||
|
const algorithm =
|
||||||
|
type === "ssh-rsa" ? "rsa" : type === "ssh-ed25519" ? "ed25519" : "ecdsa";
|
||||||
|
if (
|
||||||
|
type !== "ssh-rsa" &&
|
||||||
|
type !== "ssh-ed25519" &&
|
||||||
|
type !== "ecdsa-sha2-nistp256"
|
||||||
|
) {
|
||||||
|
throw new Error(`Unsupported key type: ${String(type)}`);
|
||||||
|
}
|
||||||
|
const generateOptions: Record<string, unknown> = {
|
||||||
|
comment: options.comment ?? "",
|
||||||
|
};
|
||||||
|
if (options.passphrase) {
|
||||||
|
generateOptions.passphrase = options.passphrase;
|
||||||
|
generateOptions.cipher = "aes256-ctr";
|
||||||
|
}
|
||||||
|
if (type === "ssh-rsa") {
|
||||||
|
const bits = options.bits ?? 4096;
|
||||||
|
if (!Number.isInteger(bits) || bits < 2048 || bits > 8192) {
|
||||||
|
throw new Error("RSA keys need 2048 to 8192 bits");
|
||||||
|
}
|
||||||
|
generateOptions.bits = bits;
|
||||||
|
}
|
||||||
|
if (type === "ecdsa-sha2-nistp256") generateOptions.bits = 256;
|
||||||
|
|
||||||
|
const pair = ssh2.utils.generateKeyPairSync(
|
||||||
|
algorithm as "rsa",
|
||||||
|
generateOptions as never,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
privateKey: pair.private,
|
||||||
|
publicKey: pair.public.trim(),
|
||||||
|
keyType: type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(key: string, passphrase?: string) {
|
||||||
|
const parsed = ssh2.utils.parseKey(key.trim(), passphrase || undefined);
|
||||||
|
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||||
|
if (!first || first instanceof Error) {
|
||||||
|
throw first instanceof Error ? first : new Error("Not a readable SSH key");
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The key's algorithm, like "ssh-ed25519", or null when it cannot be read. */
|
||||||
|
export function detectSshKeyType(
|
||||||
|
key: string,
|
||||||
|
passphrase?: string,
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
return parse(key, passphrase).type;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The authorized_keys line for a private key, or null. */
|
||||||
|
export function publicKeyFromPrivate(
|
||||||
|
privateKey: string,
|
||||||
|
passphrase?: string,
|
||||||
|
): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = parse(privateKey, passphrase);
|
||||||
|
return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a public key line belongs to the private key. */
|
||||||
|
export function validateSshKeyPair(
|
||||||
|
privateKey: string,
|
||||||
|
publicKey: string,
|
||||||
|
passphrase?: string,
|
||||||
|
): { isValid: boolean; error?: string } {
|
||||||
|
try {
|
||||||
|
const priv = parse(privateKey, passphrase);
|
||||||
|
const pub = parse(publicKey);
|
||||||
|
const same = priv.getPublicSSH().equals(pub.getPublicSSH());
|
||||||
|
return same
|
||||||
|
? { isValid: true }
|
||||||
|
: {
|
||||||
|
isValid: false,
|
||||||
|
error: "The public key does not match the private key",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
PluginCapabilityError,
|
PluginCapabilityError,
|
||||||
} from "./backend.js";
|
} from "./backend.js";
|
||||||
import type {
|
import type {
|
||||||
|
PluginAuditEntry,
|
||||||
|
PluginAuditFilters,
|
||||||
PluginContext,
|
PluginContext,
|
||||||
|
PluginDesktopLocal,
|
||||||
PluginDisposables,
|
PluginDisposables,
|
||||||
PluginLogger,
|
PluginLogger,
|
||||||
} from "./backend.js";
|
} from "./backend.js";
|
||||||
@@ -70,7 +73,10 @@ import type { PluginDatabase } from "./backend.js";
|
|||||||
import type {
|
import type {
|
||||||
PluginExternalClientRequest,
|
PluginExternalClientRequest,
|
||||||
PluginProtocolTarget,
|
PluginProtocolTarget,
|
||||||
|
PluginHostProbeTarget,
|
||||||
PluginHostStatusEntry,
|
PluginHostStatusEntry,
|
||||||
|
PluginActivityProvider,
|
||||||
|
PluginHostStatusProvider,
|
||||||
PluginNotification,
|
PluginNotification,
|
||||||
PluginNotificationChannel,
|
PluginNotificationChannel,
|
||||||
PluginNotifyHub,
|
PluginNotifyHub,
|
||||||
@@ -101,6 +107,8 @@ export interface FakeContextOptions {
|
|||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
/** Seed for the core settings ctx.settings.readCore can reach. */
|
/** Seed for the core settings ctx.settings.readCore can reach. */
|
||||||
coreSettings?: Record<string, string>;
|
coreSettings?: Record<string, string>;
|
||||||
|
/** What ctx.files.dataDir() answers. Defaults to /tmp/<pluginId>. */
|
||||||
|
dataDir?: string;
|
||||||
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
||||||
sshClient?: unknown;
|
sshClient?: unknown;
|
||||||
/** A real database behind ctx.db, usually createTestDb().database. */
|
/** A real database behind ctx.db, usually createTestDb().database. */
|
||||||
@@ -134,8 +142,18 @@ export interface FakeContextOptions {
|
|||||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||||
/** What ctx.desktop.available() answers. Defaults to false. */
|
/** What ctx.desktop.available() answers. Defaults to false. */
|
||||||
desktopAvailable?: boolean;
|
desktopAvailable?: boolean;
|
||||||
|
/** What ctx.desktop.local is. Calls throw when this is not given. */
|
||||||
|
desktopLocal?: PluginDesktopLocal;
|
||||||
|
/** The stored audit lines ctx.audit.list and exportBatches serve. */
|
||||||
|
auditEntries?: PluginAuditEntry[];
|
||||||
/** What ctx.hosts.status.get and check answer, by host id. */
|
/** What ctx.hosts.status.get and check answer, by host id. */
|
||||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||||
|
/** What ctx.hosts.probeTargets answers. */
|
||||||
|
probeTargets?: PluginHostProbeTarget[];
|
||||||
|
/** Host ids ctx.hosts.sessionActive answers true for. */
|
||||||
|
activeSessions?: number[];
|
||||||
|
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. Defaults to false. */
|
||||||
|
reachable?: (target: string | number, port?: number) => boolean;
|
||||||
/** The acting user's channels, what ctx.notify.channels answers. */
|
/** The acting user's channels, what ctx.notify.channels answers. */
|
||||||
notificationChannels?: PluginNotificationChannel[];
|
notificationChannels?: PluginNotificationChannel[];
|
||||||
/**
|
/**
|
||||||
@@ -263,6 +281,10 @@ export interface FakePluginContext {
|
|||||||
) => Promise<Record<string, string>>;
|
) => Promise<Record<string, string>>;
|
||||||
/** Everything the plugin registered through ctx.disposables. */
|
/** Everything the plugin registered through ctx.disposables. */
|
||||||
disposals: Array<() => void | Promise<void>>;
|
disposals: Array<() => void | Promise<void>>;
|
||||||
|
/** Providers registered through ctx.hosts.status.provide. */
|
||||||
|
hostStatusProviders: PluginHostStatusProvider[];
|
||||||
|
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */
|
||||||
|
activityProviders: PluginActivityProvider[];
|
||||||
/** Topics emitted through ctx.events.emit, in order. */
|
/** Topics emitted through ctx.events.emit, in order. */
|
||||||
emitted: Array<{ topic: string; payload: unknown }>;
|
emitted: Array<{ topic: string; payload: unknown }>;
|
||||||
/** Backing store behind ctx.kv. */
|
/** Backing store behind ctx.kv. */
|
||||||
@@ -338,6 +360,8 @@ export interface FakePluginContext {
|
|||||||
challenges: Map<string, string>;
|
challenges: Map<string, string>;
|
||||||
renewers: number;
|
renewers: number;
|
||||||
};
|
};
|
||||||
|
/** Calls every ctx.audit.subscribe handler, like a line core just accepted. */
|
||||||
|
emitAudit: (entry: PluginAuditEntry) => void;
|
||||||
/** Every ctx.audit.record entry, in order. */
|
/** Every ctx.audit.record entry, in order. */
|
||||||
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
|
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
|
||||||
/** Every ctx.hosts.recordActivity call, in order. */
|
/** Every ctx.hosts.recordActivity call, in order. */
|
||||||
@@ -491,6 +515,8 @@ export function createFakeContext(
|
|||||||
const pluginId = options.pluginId ?? "test-plugin";
|
const pluginId = options.pluginId ?? "test-plugin";
|
||||||
const disposals: Array<() => void | Promise<void>> = [];
|
const disposals: Array<() => void | Promise<void>> = [];
|
||||||
const emitted: Array<{ topic: string; payload: unknown }> = [];
|
const emitted: Array<{ topic: string; payload: unknown }> = [];
|
||||||
|
const hostStatusProviders: PluginHostStatusProvider[] = [];
|
||||||
|
const activityProviders: PluginActivityProvider[] = [];
|
||||||
const kv = new Map<string, unknown>();
|
const kv = new Map<string, unknown>();
|
||||||
const tables: PluginTableDefinition[] = [];
|
const tables: PluginTableDefinition[] = [];
|
||||||
const syncEntities: SyncEntityRegistration[] = [];
|
const syncEntities: SyncEntityRegistration[] = [];
|
||||||
@@ -531,6 +557,7 @@ export function createFakeContext(
|
|||||||
};
|
};
|
||||||
const secretStore = new Map<string, string>();
|
const secretStore = new Map<string, string>();
|
||||||
const audits: FakePluginContext["audits"] = [];
|
const audits: FakePluginContext["audits"] = [];
|
||||||
|
const auditSubscribers = new Set<(entry: PluginAuditEntry) => void>();
|
||||||
const activities: FakePluginContext["activities"] = [];
|
const activities: FakePluginContext["activities"] = [];
|
||||||
const trackedSessions: number[] = [];
|
const trackedSessions: number[] = [];
|
||||||
const interactions: FakePluginContext["interactions"] = [];
|
const interactions: FakePluginContext["interactions"] = [];
|
||||||
@@ -665,7 +692,7 @@ export function createFakeContext(
|
|||||||
files: {
|
files: {
|
||||||
// A fake path; a test that needs a real folder uses the OS temp dir
|
// A fake path; a test that needs a real folder uses the OS temp dir
|
||||||
// itself, not this double.
|
// itself, not this double.
|
||||||
dataDir: async () => `/tmp/${pluginId}`,
|
dataDir: async () => options.dataDir ?? `/tmp/${pluginId}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
db: options.db
|
db: options.db
|
||||||
@@ -902,6 +929,27 @@ export function createFakeContext(
|
|||||||
disposables,
|
disposables,
|
||||||
|
|
||||||
hosts: {
|
hosts: {
|
||||||
|
importMany: async (rows) => {
|
||||||
|
const created = rows.map(() => nextHostId++);
|
||||||
|
return {
|
||||||
|
success: created.length,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [],
|
||||||
|
createdHostIds: created,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
importSshConfig: async () => ({
|
||||||
|
success: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [],
|
||||||
|
createdHostIds: [],
|
||||||
|
}),
|
||||||
|
exportMany: async () => ({ hosts: [] }),
|
||||||
|
registerImportNormalizer: () => () => {},
|
||||||
list: async () => [...hostsById.values()],
|
list: async () => [...hostsById.values()],
|
||||||
get: async (hostId) => hostsById.get(hostId) ?? null,
|
get: async (hostId) => hostsById.get(hostId) ?? null,
|
||||||
checkAccess: async (hostId): Promise<PluginHostAccess> => {
|
checkAccess: async (hostId): Promise<PluginHostAccess> => {
|
||||||
@@ -971,7 +1019,29 @@ export function createFakeContext(
|
|||||||
recordActivity: async (hostId, type, hostName) => {
|
recordActivity: async (hostId, type, hostName) => {
|
||||||
activities.push({ hostId, type, hostName });
|
activities.push({ hostId, type, hostName });
|
||||||
},
|
},
|
||||||
|
probeTargets: async (filter) =>
|
||||||
|
(options.probeTargets ?? []).filter(
|
||||||
|
(target) => !filter?.hostIds || filter.hostIds.includes(target.id),
|
||||||
|
),
|
||||||
|
sessionActive: (hostId) =>
|
||||||
|
options.activeSessions?.includes(hostId) ?? false,
|
||||||
|
activity: {
|
||||||
|
provide: (provider) => {
|
||||||
|
activityProviders.push(provider);
|
||||||
|
return () => {
|
||||||
|
const at = activityProviders.indexOf(provider);
|
||||||
|
if (at >= 0) activityProviders.splice(at, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
|
provide: (provider) => {
|
||||||
|
hostStatusProviders.push(provider);
|
||||||
|
return () => {
|
||||||
|
const at = hostStatusProviders.indexOf(provider);
|
||||||
|
if (at >= 0) hostStatusProviders.splice(at, 1);
|
||||||
|
};
|
||||||
|
},
|
||||||
get: async (hostId) => hostStatuses.get(hostId) ?? null,
|
get: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||||
check: async (hostId) => hostStatuses.get(hostId) ?? null,
|
check: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||||
reportLogin: (hostId, outcome) => {
|
reportLogin: (hostId, outcome) => {
|
||||||
@@ -996,12 +1066,45 @@ export function createFakeContext(
|
|||||||
},
|
},
|
||||||
|
|
||||||
audit: {
|
audit: {
|
||||||
|
subscribe: (handler) => {
|
||||||
|
auditSubscribers.add(handler);
|
||||||
|
return () => {
|
||||||
|
auditSubscribers.delete(handler);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
list: async (listOptions) => {
|
||||||
|
const matching = filterAuditEntries(
|
||||||
|
options.auditEntries ?? [],
|
||||||
|
listOptions?.filters,
|
||||||
|
).reverse();
|
||||||
|
const offset = listOptions?.offset ?? 0;
|
||||||
|
return {
|
||||||
|
entries: matching.slice(offset, offset + (listOptions?.limit ?? 50)),
|
||||||
|
total: matching.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
exportBatches: async function* (filters, batchSize) {
|
||||||
|
const matching = filterAuditEntries(
|
||||||
|
options.auditEntries ?? [],
|
||||||
|
filters,
|
||||||
|
);
|
||||||
|
const size = batchSize ?? 500;
|
||||||
|
for (let at = 0; at < matching.length; at += size) {
|
||||||
|
yield matching.slice(at, at + size);
|
||||||
|
}
|
||||||
|
},
|
||||||
record: async (entry) => {
|
record: async (entry) => {
|
||||||
audits.push({ ...entry });
|
audits.push({ ...entry });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
network: {
|
||||||
|
tcpProbe: async (host, port) => options.reachable?.(host, port) ?? false,
|
||||||
|
},
|
||||||
|
|
||||||
ssh: {
|
ssh: {
|
||||||
|
probe: async (hostId, probeOptions) =>
|
||||||
|
options.reachable?.(hostId, probeOptions?.port) ?? false,
|
||||||
connect: async (host, connectOptions) => {
|
connect: async (host, connectOptions) => {
|
||||||
sshConnections.push({ host, options: connectOptions });
|
sshConnections.push({ host, options: connectOptions });
|
||||||
return {
|
return {
|
||||||
@@ -1013,6 +1116,16 @@ export function createFakeContext(
|
|||||||
dispose: () => {},
|
dispose: () => {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
exec: async (host) => {
|
||||||
|
sshConnections.push({ host });
|
||||||
|
return {
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
truncated: false,
|
||||||
|
timedOut: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
withConnection: async (host, connectOptions, fn) => {
|
withConnection: async (host, connectOptions, fn) => {
|
||||||
sshConnections.push({ host, pool: connectOptions.pool });
|
sshConnections.push({ host, pool: connectOptions.pool });
|
||||||
return fn(sshClient as never);
|
return fn(sshClient as never);
|
||||||
@@ -1169,6 +1282,7 @@ export function createFakeContext(
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
available: () => options.desktopAvailable ?? false,
|
available: () => options.desktopAvailable ?? false,
|
||||||
|
local: options.desktopLocal ?? unavailableDesktopLocal(),
|
||||||
},
|
},
|
||||||
|
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -1195,6 +1309,13 @@ export function createFakeContext(
|
|||||||
credentialReads.push({ hostId, protocol });
|
credentialReads.push({ hostId, protocol });
|
||||||
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
|
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
|
||||||
},
|
},
|
||||||
|
publicKey: async (credentialId) =>
|
||||||
|
(options.sshKeyCredentials ?? []).find((k) => k.id === credentialId)
|
||||||
|
?.publicKey ?? null,
|
||||||
|
resolveKeyRefs: async (key, passphrase) => ({
|
||||||
|
key,
|
||||||
|
passphrase: passphrase ?? undefined,
|
||||||
|
}),
|
||||||
registerSecretResolver: (scheme, resolve) => {
|
registerSecretResolver: (scheme, resolve) => {
|
||||||
auth.secretResolvers.set(scheme, resolve);
|
auth.secretResolvers.set(scheme, resolve);
|
||||||
disposals.push(() => {
|
disposals.push(() => {
|
||||||
@@ -1345,6 +1466,8 @@ export function createFakeContext(
|
|||||||
return errors;
|
return errors;
|
||||||
},
|
},
|
||||||
disposals,
|
disposals,
|
||||||
|
hostStatusProviders,
|
||||||
|
activityProviders,
|
||||||
emitted,
|
emitted,
|
||||||
kv,
|
kv,
|
||||||
tables,
|
tables,
|
||||||
@@ -1368,6 +1491,9 @@ export function createFakeContext(
|
|||||||
tls,
|
tls,
|
||||||
secretStore,
|
secretStore,
|
||||||
audits,
|
audits,
|
||||||
|
emitAudit: (entry) => {
|
||||||
|
for (const handler of auditSubscribers) handler(entry);
|
||||||
|
},
|
||||||
activities,
|
activities,
|
||||||
trackedSessions,
|
trackedSessions,
|
||||||
interactions,
|
interactions,
|
||||||
@@ -1409,6 +1535,8 @@ export interface MockContextOptions {
|
|||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
/** Seed for the core settings readCore can reach. */
|
/** Seed for the core settings readCore can reach. */
|
||||||
coreSettings?: Record<string, string>;
|
coreSettings?: Record<string, string>;
|
||||||
|
/** What ctx.files.dataDir() answers. */
|
||||||
|
dataDir?: string;
|
||||||
/** Acting user returned by currentActor and used by asUser. */
|
/** Acting user returned by currentActor and used by asUser. */
|
||||||
actor?: string;
|
actor?: string;
|
||||||
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
/** What ctx.ssh.connect and withConnection hand back as the client. */
|
||||||
@@ -1434,8 +1562,18 @@ export interface MockContextOptions {
|
|||||||
sshKeyCredentials?: PluginSshKeyCredential[];
|
sshKeyCredentials?: PluginSshKeyCredential[];
|
||||||
/** What ctx.desktop.available() answers. */
|
/** What ctx.desktop.available() answers. */
|
||||||
desktopAvailable?: boolean;
|
desktopAvailable?: boolean;
|
||||||
|
/** What ctx.desktop.local is. See FakeContextOptions. */
|
||||||
|
desktopLocal?: PluginDesktopLocal;
|
||||||
|
/** See FakeContextOptions. */
|
||||||
|
auditEntries?: PluginAuditEntry[];
|
||||||
/** What ctx.hosts.status answers. See FakeContextOptions. */
|
/** What ctx.hosts.status answers. See FakeContextOptions. */
|
||||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||||
|
/** What ctx.hosts.probeTargets answers. */
|
||||||
|
probeTargets?: PluginHostProbeTarget[];
|
||||||
|
/** Host ids ctx.hosts.sessionActive answers true for. */
|
||||||
|
activeSessions?: number[];
|
||||||
|
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. */
|
||||||
|
reachable?: (target: string | number, port?: number) => boolean;
|
||||||
/** The acting user's channels, what ctx.notify.channels answers. */
|
/** The acting user's channels, what ctx.notify.channels answers. */
|
||||||
notificationChannels?: PluginNotificationChannel[];
|
notificationChannels?: PluginNotificationChannel[];
|
||||||
/**
|
/**
|
||||||
@@ -1481,6 +1619,63 @@ export interface MockPluginContext extends FakePluginContext {
|
|||||||
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
|
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
|
||||||
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
* ungated. As the SDK grows a member, add its gate here in the same shape.
|
||||||
*/
|
*/
|
||||||
|
function filterAuditEntries(
|
||||||
|
entries: PluginAuditEntry[],
|
||||||
|
filters: PluginAuditFilters | undefined,
|
||||||
|
): PluginAuditEntry[] {
|
||||||
|
return entries.filter(
|
||||||
|
(entry) =>
|
||||||
|
(filters?.userId === undefined || entry.userId === filters.userId) &&
|
||||||
|
(filters?.action === undefined || entry.action === filters.action) &&
|
||||||
|
(filters?.resourceType === undefined ||
|
||||||
|
entry.resourceType === filters.resourceType) &&
|
||||||
|
(filters?.success === undefined || entry.success === filters.success) &&
|
||||||
|
(filters?.startDate === undefined ||
|
||||||
|
entry.timestamp >= filters.startDate) &&
|
||||||
|
(filters?.endDate === undefined || entry.timestamp <= filters.endDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gateDesktopLocal(
|
||||||
|
target: PluginDesktopLocal,
|
||||||
|
check: () => void,
|
||||||
|
): PluginDesktopLocal {
|
||||||
|
const wrap = (value: unknown): unknown => {
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
check();
|
||||||
|
return (value as (...a: unknown[]) => unknown)(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return new Proxy(value as object, {
|
||||||
|
get: (obj, key) => wrap((obj as Record<string | symbol, unknown>)[key]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
return new Proxy(target, {
|
||||||
|
get: (obj, key) =>
|
||||||
|
key === "available"
|
||||||
|
? obj.available
|
||||||
|
: wrap((obj as unknown as Record<string | symbol, unknown>)[key]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailableDesktopLocal(): PluginDesktopLocal {
|
||||||
|
const fail = (): never => {
|
||||||
|
throw new Error("ctx.desktop.local needs the desktopLocal test option");
|
||||||
|
};
|
||||||
|
const deep = (): unknown =>
|
||||||
|
new Proxy(() => fail(), { get: () => deep(), apply: () => fail() });
|
||||||
|
return {
|
||||||
|
available: () => false,
|
||||||
|
info: fail,
|
||||||
|
fs: deep(),
|
||||||
|
pty: deep(),
|
||||||
|
} as unknown as PluginDesktopLocal;
|
||||||
|
}
|
||||||
|
|
||||||
export function createMockCtx(
|
export function createMockCtx(
|
||||||
options: MockContextOptions = {},
|
options: MockContextOptions = {},
|
||||||
): MockPluginContext {
|
): MockPluginContext {
|
||||||
@@ -1492,6 +1687,7 @@ export function createMockCtx(
|
|||||||
actor: options.actor,
|
actor: options.actor,
|
||||||
settings: options.settings,
|
settings: options.settings,
|
||||||
coreSettings: options.coreSettings,
|
coreSettings: options.coreSettings,
|
||||||
|
dataDir: options.dataDir,
|
||||||
sshClient: options.sshClient,
|
sshClient: options.sshClient,
|
||||||
db: options.db,
|
db: options.db,
|
||||||
router: options.router,
|
router: options.router,
|
||||||
@@ -1504,7 +1700,12 @@ export function createMockCtx(
|
|||||||
protocolTargets: options.protocolTargets,
|
protocolTargets: options.protocolTargets,
|
||||||
sshKeyCredentials: options.sshKeyCredentials,
|
sshKeyCredentials: options.sshKeyCredentials,
|
||||||
desktopAvailable: options.desktopAvailable,
|
desktopAvailable: options.desktopAvailable,
|
||||||
|
desktopLocal: options.desktopLocal,
|
||||||
|
auditEntries: options.auditEntries,
|
||||||
hostStatuses: options.hostStatuses,
|
hostStatuses: options.hostStatuses,
|
||||||
|
probeTargets: options.probeTargets,
|
||||||
|
activeSessions: options.activeSessions,
|
||||||
|
reachable: options.reachable,
|
||||||
notificationChannels: options.notificationChannels,
|
notificationChannels: options.notificationChannels,
|
||||||
fetch: options.fetch,
|
fetch: options.fetch,
|
||||||
process: options.process,
|
process: options.process,
|
||||||
@@ -1650,6 +1851,23 @@ export function createMockCtx(
|
|||||||
},
|
},
|
||||||
|
|
||||||
hosts: {
|
hosts: {
|
||||||
|
importMany: async (rows, importOptions) => {
|
||||||
|
require("hosts:write");
|
||||||
|
if (importOptions?.credentials?.length) require("credentials:write");
|
||||||
|
return ctx.hosts.importMany(rows, importOptions);
|
||||||
|
},
|
||||||
|
importSshConfig: async (content, importOptions) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.importSshConfig(content, importOptions);
|
||||||
|
},
|
||||||
|
exportMany: async (exportOptions) => {
|
||||||
|
require("hosts:export");
|
||||||
|
return ctx.hosts.exportMany(exportOptions);
|
||||||
|
},
|
||||||
|
registerImportNormalizer: (normalizer) => {
|
||||||
|
require("hosts:write");
|
||||||
|
return ctx.hosts.registerImportNormalizer(normalizer);
|
||||||
|
},
|
||||||
list: async () => {
|
list: async () => {
|
||||||
require("hosts:read");
|
require("hosts:read");
|
||||||
return ctx.hosts.list();
|
return ctx.hosts.list();
|
||||||
@@ -1698,7 +1916,26 @@ export function createMockCtx(
|
|||||||
require("hosts:read");
|
require("hosts:read");
|
||||||
return ctx.hosts.recordActivity(hostId, type, hostName);
|
return ctx.hosts.recordActivity(hostId, type, hostName);
|
||||||
},
|
},
|
||||||
|
probeTargets: async (filter) => {
|
||||||
|
require("hosts:read");
|
||||||
|
return ctx.hosts.probeTargets(filter);
|
||||||
|
},
|
||||||
|
sessionActive: (hostId) => {
|
||||||
|
require("hosts:read");
|
||||||
|
return ctx.hosts.sessionActive(hostId);
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("hosts:activity-provide");
|
||||||
|
return ctx.hosts.activity.provide(provider);
|
||||||
|
},
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
|
provide: (provider) => {
|
||||||
|
require("hosts:read");
|
||||||
|
require("hosts:status-provide");
|
||||||
|
return ctx.hosts.status.provide(provider);
|
||||||
|
},
|
||||||
get: async (hostId) => {
|
get: async (hostId) => {
|
||||||
require("hosts:read");
|
require("hosts:read");
|
||||||
return ctx.hosts.status.get(hostId);
|
return ctx.hosts.status.get(hostId);
|
||||||
@@ -1720,11 +1957,20 @@ export function createMockCtx(
|
|||||||
|
|
||||||
ssh: {
|
ssh: {
|
||||||
...ctx.ssh,
|
...ctx.ssh,
|
||||||
|
probe: async (hostId, probeOptions) => {
|
||||||
|
require("ssh:connect");
|
||||||
|
return ctx.ssh.probe(hostId, probeOptions);
|
||||||
|
},
|
||||||
connect: async (host, connectOptions) => {
|
connect: async (host, connectOptions) => {
|
||||||
require("ssh:connect");
|
require("ssh:connect");
|
||||||
require("credentials:use");
|
require("credentials:use");
|
||||||
return ctx.ssh.connect(host, connectOptions);
|
return ctx.ssh.connect(host, connectOptions);
|
||||||
},
|
},
|
||||||
|
exec: async (host, command, execOptions) => {
|
||||||
|
require("ssh:connect");
|
||||||
|
require("credentials:use");
|
||||||
|
return ctx.ssh.exec(host, command, execOptions);
|
||||||
|
},
|
||||||
withConnection: async (host, connectOptions, fn) => {
|
withConnection: async (host, connectOptions, fn) => {
|
||||||
require("ssh:connect");
|
require("ssh:connect");
|
||||||
require("credentials:use");
|
require("credentials:use");
|
||||||
@@ -1820,6 +2066,22 @@ export function createMockCtx(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
audit: {
|
||||||
|
record: (entry) => ctx.audit.record(entry),
|
||||||
|
subscribe: (handler) => {
|
||||||
|
require("audit:read");
|
||||||
|
return ctx.audit.subscribe(handler);
|
||||||
|
},
|
||||||
|
list: async (listOptions) => {
|
||||||
|
require("audit:read");
|
||||||
|
return ctx.audit.list(listOptions);
|
||||||
|
},
|
||||||
|
exportBatches: (filters, batchSize) => {
|
||||||
|
require("audit:read");
|
||||||
|
return ctx.audit.exportBatches(filters, batchSize);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
desktop: {
|
desktop: {
|
||||||
openIsolatedWindow: async (request) => {
|
openIsolatedWindow: async (request) => {
|
||||||
require("desktop:window");
|
require("desktop:window");
|
||||||
@@ -1830,6 +2092,9 @@ export function createMockCtx(
|
|||||||
return ctx.desktop.launchExternalClient(request);
|
return ctx.desktop.launchExternalClient(request);
|
||||||
},
|
},
|
||||||
available: () => ctx.desktop.available(),
|
available: () => ctx.desktop.available(),
|
||||||
|
local: gateDesktopLocal(ctx.desktop.local, () =>
|
||||||
|
require("desktop:local"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -1851,6 +2116,14 @@ export function createMockCtx(
|
|||||||
}
|
}
|
||||||
return ctx.credentials.resolveHostProtocol(hostId, protocol);
|
return ctx.credentials.resolveHostProtocol(hostId, protocol);
|
||||||
},
|
},
|
||||||
|
publicKey: async (credentialId) => {
|
||||||
|
require("credentials:use");
|
||||||
|
return ctx.credentials.publicKey(credentialId);
|
||||||
|
},
|
||||||
|
resolveKeyRefs: async (key, passphrase) => {
|
||||||
|
require("credentials:read");
|
||||||
|
return ctx.credentials.resolveKeyRefs(key, passphrase);
|
||||||
|
},
|
||||||
registerSecretResolver: (scheme, resolve) => {
|
registerSecretResolver: (scheme, resolve) => {
|
||||||
require("auth:provide");
|
require("auth:provide");
|
||||||
ctx.credentials.registerSecretResolver(scheme, resolve);
|
ctx.credentials.registerSecretResolver(scheme, resolve);
|
||||||
@@ -1877,6 +2150,13 @@ export function createMockCtx(
|
|||||||
return ctx.fetch(url, init);
|
return ctx.fetch(url, init);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
network: {
|
||||||
|
tcpProbe: async (host, port, probeOptions) => {
|
||||||
|
require("network:probe");
|
||||||
|
return ctx.network.tcpProbe(host, port, probeOptions);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
process: {
|
process: {
|
||||||
run: async (file, args, runOptions) => {
|
run: async (file, args, runOptions) => {
|
||||||
require("process:spawn");
|
require("process:spawn");
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const offline = () =>
|
|||||||
useHostStatus: () => null,
|
useHostStatus: () => null,
|
||||||
useCurrentUser: () => null,
|
useCurrentUser: () => null,
|
||||||
useTheme: () => ({ theme: "dark" }),
|
useTheme: () => ({ theme: "dark" }),
|
||||||
|
useShell: () => null,
|
||||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||||
getApi: () => ({
|
getApi: () => ({
|
||||||
get: offline,
|
get: offline,
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Checks a theme's CSS before core serves it.
|
||||||
|
*
|
||||||
|
* A theme is colors and styles only, so its file may hold nothing but rules
|
||||||
|
* whose every selector starts with the theme's own class (`.nord`,
|
||||||
|
* `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
|
||||||
|
* Anything that could load from elsewhere or run code is refused: @import,
|
||||||
|
* url() to another host, other at-rules, expression() and javascript:.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
|
||||||
|
|
||||||
|
function stripComments(css: string): string {
|
||||||
|
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits on commas that are not inside parentheses or brackets. */
|
||||||
|
function splitSelectors(prelude: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let current = "";
|
||||||
|
for (const ch of prelude) {
|
||||||
|
if (ch === "(" || ch === "[") depth += 1;
|
||||||
|
if (ch === ")" || ch === "]") depth -= 1;
|
||||||
|
if (ch === "," && depth === 0) {
|
||||||
|
parts.push(current);
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
parts.push(current);
|
||||||
|
return parts.map((part) => part.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutStrings(css: string): string {
|
||||||
|
return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the brace closing the one at `open`, skipping strings. */
|
||||||
|
function matchingBrace(css: string, open: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (let i = open; i < css.length; i += 1) {
|
||||||
|
const ch = css[i];
|
||||||
|
if (quote) {
|
||||||
|
if (ch === "\\") i += 1;
|
||||||
|
else if (ch === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'") quote = ch;
|
||||||
|
else if (ch === "{") depth += 1;
|
||||||
|
else if (ch === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkUrls(css: string, errors: string[]): void {
|
||||||
|
const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
|
||||||
|
for (const match of urls) {
|
||||||
|
const target = match[2].trim();
|
||||||
|
if (target.startsWith("data:")) continue;
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
|
||||||
|
errors.push(`url(${target}) points outside the plugin`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRules(
|
||||||
|
css: string,
|
||||||
|
scope: RegExp,
|
||||||
|
themeId: string,
|
||||||
|
errors: string[],
|
||||||
|
): void {
|
||||||
|
let index = 0;
|
||||||
|
while (index < css.length) {
|
||||||
|
const rest = css.slice(index);
|
||||||
|
if (rest.trim() === "") return;
|
||||||
|
const open = css.indexOf("{", index);
|
||||||
|
const semicolon = css.indexOf(";", index);
|
||||||
|
if (semicolon !== -1 && (open === -1 || semicolon < open)) {
|
||||||
|
const statement = css.slice(index, semicolon).trim();
|
||||||
|
errors.push(
|
||||||
|
statement.startsWith("@")
|
||||||
|
? `${statement.split(/\s/)[0]} is not allowed in a theme`
|
||||||
|
: `Unexpected "${statement}" outside a rule`,
|
||||||
|
);
|
||||||
|
index = semicolon + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (open === -1) {
|
||||||
|
errors.push(`Unexpected "${rest.trim()}" at the end`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const close = matchingBrace(css, open);
|
||||||
|
if (close === -1) {
|
||||||
|
errors.push("A rule is missing its closing brace");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prelude = css.slice(index, open).trim();
|
||||||
|
const body = css.slice(open + 1, close);
|
||||||
|
|
||||||
|
if (prelude.startsWith("@")) {
|
||||||
|
const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
|
||||||
|
if (ALLOWED_BLOCK_AT_RULES.has(name)) {
|
||||||
|
checkRules(body, scope, themeId, errors);
|
||||||
|
} else {
|
||||||
|
errors.push(`@${name} is not allowed in a theme`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const selector of splitSelectors(prelude)) {
|
||||||
|
if (!scope.test(selector)) {
|
||||||
|
errors.push(
|
||||||
|
`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (withoutStrings(body).includes("{")) {
|
||||||
|
errors.push(`Nested rules are not allowed in "${prelude}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index = close + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every problem with a theme's CSS, empty when it is safe to serve. */
|
||||||
|
export function validateThemeCss(css: string, themeId: string): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const clean = stripComments(css);
|
||||||
|
if (/@import\b/i.test(clean))
|
||||||
|
errors.push("@import is not allowed in a theme");
|
||||||
|
if (/expression\s*\(/i.test(clean))
|
||||||
|
errors.push("expression() is not allowed in a theme");
|
||||||
|
if (/javascript:/i.test(clean))
|
||||||
|
errors.push("javascript: is not allowed in a theme");
|
||||||
|
if (/-moz-binding|behavior\s*:/i.test(clean))
|
||||||
|
errors.push("Script bindings are not allowed in a theme");
|
||||||
|
checkUrls(clean, errors);
|
||||||
|
const id = escapeRegExp(themeId);
|
||||||
|
const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
|
||||||
|
checkRules(clean, scope, themeId, errors);
|
||||||
|
return [...new Set(errors)];
|
||||||
|
}
|
||||||
@@ -117,9 +117,19 @@ export function pluginVitestConfig(
|
|||||||
|
|
||||||
const plugins = [wasmBinaryPlugin()];
|
const plugins = [wasmBinaryPlugin()];
|
||||||
|
|
||||||
|
// A local SDK linked into node_modules sits outside the plugin folder, so
|
||||||
|
// 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] } };
|
||||||
|
const resolve = {
|
||||||
|
alias,
|
||||||
|
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins,
|
plugins,
|
||||||
resolve: { alias },
|
server,
|
||||||
|
resolve,
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: [setupFile],
|
setupFiles: [setupFile],
|
||||||
@@ -127,7 +137,8 @@ export function pluginVitestConfig(
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
plugins,
|
plugins,
|
||||||
resolve: { alias },
|
server,
|
||||||
|
resolve,
|
||||||
test: {
|
test: {
|
||||||
name: `${pluginId}:backend`,
|
name: `${pluginId}:backend`,
|
||||||
root: pluginDir,
|
root: pluginDir,
|
||||||
@@ -140,7 +151,8 @@ export function pluginVitestConfig(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
plugins,
|
plugins,
|
||||||
resolve: { alias },
|
server,
|
||||||
|
resolve,
|
||||||
test: {
|
test: {
|
||||||
name: `${pluginId}:frontend`,
|
name: `${pluginId}:frontend`,
|
||||||
root: pluginDir,
|
root: pluginDir,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
"preserveSymlinks": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": false,
|
"strict": false,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
ACCENT_PRESET_COLORS (value): { label: string; value: string; }[]
|
||||||
|
APP_VERSION (value): string
|
||||||
|
AccessRecord (type): AccessRecord
|
||||||
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
||||||
|
ActiveSessionInfo (type): ActiveSessionInfo
|
||||||
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||||
|
AdminSection (type): AdminSection
|
||||||
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
||||||
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
||||||
@@ -14,14 +19,23 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
|
|||||||
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||||
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
|
ApiKey (type): ApiKey
|
||||||
|
AuditLog (type): AuditLog
|
||||||
|
AuditLogFilters (type): AuditLogFilters
|
||||||
|
AuthEnrollmentSections (value): () => Element
|
||||||
|
AuthOverrideProtocol (type): string
|
||||||
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
||||||
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||||
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
||||||
BarSeriesItem (type): BarSeriesItem
|
BarSeriesItem (type): BarSeriesItem
|
||||||
BarSeriesProps (type): BarSeriesProps
|
BarSeriesProps (type): BarSeriesProps
|
||||||
|
BrandingSettings (type): BrandingSettings
|
||||||
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
|
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
|
||||||
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||||
ButtonProps (type): ButtonProps
|
ButtonProps (type): ButtonProps
|
||||||
|
CORE_HOST_DEFAULTS (value): readonly CoreHostDefault[]
|
||||||
|
CORE_NAMESPACE (value): "core"
|
||||||
|
CREDENTIAL_ROW_FIELD_KEYS (value): (keyof CredentialRowFields)[]
|
||||||
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
|
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
|
||||||
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
@@ -33,6 +47,12 @@ CardMasonry (value): ({ columns, stack, className, children, }: { columns: numbe
|
|||||||
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
|
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
|
||||||
|
Command (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandEmpty (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandGroup (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandItem (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandList (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||||
|
CommandPrimitive (value): ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>> & { List: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>>; Item: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Input: ForwardRefExoticComponent<Omit<Pick<Pick<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof InputHTMLAttributes<HTMLInputElement>> & { ref?: Ref<HTMLInputElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof InputHTMLAttributes<HTMLInputElement>>, "type" | "value" | "onChange"> & { value?: string; onValueChange?: (search: string) => void; } & RefAttributes<HTMLInputElement>>; Group: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Separator: ForwardRefExoticComponent<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { alwaysRender?: boolean; } & RefAttributes<HTMLDivElement>>; Dialog: ForwardRefExoticComponent<DialogProps & Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & { overlayClassName?: string; contentClassName?: string; container?: HTMLElement; } & RefAttributes<HTMLDivElement>>; Empty: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>>; Loading: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { progress?: number; label?: string; } & RefAttributes<HTMLDivElement>>; }
|
||||||
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
|
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
|
||||||
ConfirmOptions (type): ConfirmOptions
|
ConfirmOptions (type): ConfirmOptions
|
||||||
ConnectionGate (value): (props: ConnectionGateProps) => Element
|
ConnectionGate (value): (props: ConnectionGateProps) => Element
|
||||||
@@ -45,9 +65,26 @@ ConnectionScreen (value): ({ status, message, detail, errorDetail, unavailable,
|
|||||||
ConnectionStage (type): ConnectionStage
|
ConnectionStage (type): ConnectionStage
|
||||||
ConnectionStatus (type): ConnectionStatus
|
ConnectionStatus (type): ConnectionStatus
|
||||||
ConnectionUnavailable (type): ConnectionUnavailable
|
ConnectionUnavailable (type): ConnectionUnavailable
|
||||||
|
CreatedApiKey (type): CreatedApiKey
|
||||||
|
Credential (type): Credential
|
||||||
|
CredentialDensity (type): CredentialDensity
|
||||||
|
CredentialPermissionLevel (type): CredentialPermissionLevel
|
||||||
|
CredentialRowFields (type): CredentialRowFields
|
||||||
|
CredentialSidebarFilterState (type): CredentialSidebarFilterState
|
||||||
|
CredentialSidebarPreferences (type): CredentialSidebarPreferences
|
||||||
|
CredentialSortKey (type): CredentialSortKey
|
||||||
|
CredentialTrayTrigger (type): CredentialTrayTrigger
|
||||||
CustomKeybinding (type): CustomKeybinding
|
CustomKeybinding (type): CustomKeybinding
|
||||||
|
DASHBOARD_CARDS (value): DashboardCardConfig[]
|
||||||
|
DEFAULT_FOLDER_COLOR (value): "#f59145"
|
||||||
|
DEFAULT_FOLDER_ICON (value): "folder"
|
||||||
|
DashboardCardId (type): DashboardCardId
|
||||||
DataColumn (type): DataColumn<T>
|
DataColumn (type): DataColumn<T>
|
||||||
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
|
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
|
||||||
|
DesktopUpdateState (type): DesktopUpdateState
|
||||||
Dialog (value): ({ ...props }: DialogProps) => Element
|
Dialog (value): ({ ...props }: DialogProps) => Element
|
||||||
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
||||||
@@ -60,6 +97,7 @@ DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<
|
|||||||
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
|
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
|
||||||
DocsLinkProps (type): DocsLinkProps
|
DocsLinkProps (type): DocsLinkProps
|
||||||
|
DocsPage (type): "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry"
|
||||||
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
|
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
|
||||||
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||||
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
|
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
@@ -78,10 +116,14 @@ DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttrib
|
|||||||
EditingWidth (type): EditingWidth
|
EditingWidth (type): EditingWidth
|
||||||
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
||||||
FOLDER_COLORS (value): string[]
|
FOLDER_COLORS (value): string[]
|
||||||
|
FONT_SIZES (value): { id: FontSizeId; label: string; }[]
|
||||||
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||||
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
||||||
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
|
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
|
||||||
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
|
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
|
||||||
|
FolderAccessRule (type): FolderAccessRule
|
||||||
|
FolderIconEl (value): ({ icon, className, style, }: { icon?: string; className?: string; style?: CSSProperties; }) => Element
|
||||||
|
FontSizeId (type): FontSizeId
|
||||||
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
|
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
|
||||||
FullScreenAppPhase (type): FullScreenAppPhase
|
FullScreenAppPhase (type): FullScreenAppPhase
|
||||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||||
@@ -91,11 +133,33 @@ GridColumns (type): GridColumns
|
|||||||
GridLayout (type): GridLayout
|
GridLayout (type): GridLayout
|
||||||
GridSlot (type): GridSlot
|
GridSlot (type): GridSlot
|
||||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
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
|
||||||
|
Host (type): Host
|
||||||
|
HostActionDef (type): HostActionDef
|
||||||
|
HostClickBehavior (type): HostClickBehavior
|
||||||
|
HostData (type): HostData
|
||||||
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
||||||
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||||
|
HostDefaultsChange (type): HostDefaultsChange
|
||||||
|
HostDefaultsContext (value): Context<HostDefaultsContextValue>
|
||||||
|
HostDefaultsContextValue (type): HostDefaultsContextValue
|
||||||
|
HostDefaultsLevel (type): HostDefaultsLevel
|
||||||
|
HostDefaultsTarget (type): HostDefaultsTarget
|
||||||
|
HostDensity (type): HostDensity
|
||||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||||
|
HostFolder (type): HostFolder
|
||||||
|
HostGroupKey (type): HostGroupKey
|
||||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||||
|
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
|
||||||
|
HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||||
|
HostProtocolDef (type): HostProtocolDef
|
||||||
|
HostProtocols (type): HostProtocols
|
||||||
|
HostRowFields (type): HostRowFields
|
||||||
|
HostSidebarFilterState (type): HostSidebarFilterState
|
||||||
|
HostSidebarPreferences (type): HostSidebarPreferences
|
||||||
|
HostTrayTrigger (type): HostTrayTrigger
|
||||||
|
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||||
@@ -103,10 +167,14 @@ KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): El
|
|||||||
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||||
KeyCombo (type): KeyCombo
|
KeyCombo (type): KeyCombo
|
||||||
KeybindingAction (type): KeybindingAction
|
KeybindingAction (type): KeybindingAction
|
||||||
|
KeybindingActionDef (type): KeybindingActionDef
|
||||||
KeybindingActionType (type): string
|
KeybindingActionType (type): string
|
||||||
|
LANGUAGES (value): readonly { code: string; label: string; }[]
|
||||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||||
LineChartSeries (type): LineChartSeries
|
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
|
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
|
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
|
||||||
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||||
@@ -114,7 +182,12 @@ ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dr
|
|||||||
ListRowTone (type): ListRowTone
|
ListRowTone (type): ListRowTone
|
||||||
LogContext (type): LogContext
|
LogContext (type): LogContext
|
||||||
LogEntry (type): LogEntry
|
LogEntry (type): LogEntry
|
||||||
|
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
|
||||||
|
MANAGE_REQUEST_EVENT (value): "manage:request"
|
||||||
|
MAX_PANES (value): 8
|
||||||
MFAPromptMode (type): MFAPromptMode
|
MFAPromptMode (type): MFAPromptMode
|
||||||
|
ManageMode (type): ManageMode
|
||||||
|
ManageRequest (type): ManageRequest
|
||||||
ManagerCardError (type): ManagerCardError
|
ManagerCardError (type): ManagerCardError
|
||||||
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
|
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
|
||||||
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
|
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
|
||||||
@@ -122,10 +195,14 @@ Meter (value): ({ percent, className, }: { percent: number; className?: string;
|
|||||||
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => 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
|
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||||
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
|
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
|
||||||
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||||
|
PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
|
||||||
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||||
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||||
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
PaletteItemDef (type): PaletteItemDef
|
||||||
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
||||||
PanelDensity (type): PanelDensity
|
PanelDensity (type): PanelDensity
|
||||||
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||||
@@ -134,18 +211,35 @@ PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, t
|
|||||||
PanelViewMode (type): PanelViewMode
|
PanelViewMode (type): PanelViewMode
|
||||||
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||||
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
||||||
|
PermissionCatalogEntry (type): PermissionCatalogEntry
|
||||||
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
||||||
|
PluginIcon (value): ({ name, ...props }: { name?: string; } & LucideProps) => Element
|
||||||
|
PluginSettingsErrors (type): PluginSettingsErrors
|
||||||
|
PluginSettingsField (type): PluginSettingsField
|
||||||
|
PluginSettingsValidationError (value): typeof PluginSettingsValidationError
|
||||||
|
PluginSummary (type): PluginSummary
|
||||||
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
|
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
|
||||||
Popover (value): ({ ...props }: PopoverProps) => Element
|
Popover (value): ({ ...props }: PopoverProps) => Element
|
||||||
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
|
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => 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
|
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||||
RadialGaugeProps (type): RadialGaugeProps
|
RadialGaugeProps (type): RadialGaugeProps
|
||||||
|
RailGroup (type): RailGroup
|
||||||
|
RailItemDef (type): RailItemDef
|
||||||
RecentActivityItem (type): RecentActivityItem
|
RecentActivityItem (type): RecentActivityItem
|
||||||
|
ReleaseInfo (type): ReleaseInfo
|
||||||
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
||||||
|
ResolvedHostDefault (type): ResolvedHostDefault
|
||||||
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
||||||
|
Role (type): Role
|
||||||
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
||||||
|
SSHHost (type): Host
|
||||||
|
SSHHostData (type): HostData
|
||||||
|
SSHHostWithStatus (type): SSHHostWithStatus
|
||||||
|
SSH_AUTH_PROTOCOL (value): "ssh"
|
||||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
||||||
@@ -165,6 +259,9 @@ SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerPr
|
|||||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
||||||
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
|
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
|
||||||
|
SettingsFieldRow (value): ({ pluginId, field, values, setValue, running, error, disabled, defaultKey, }: SettingsFieldProps) => Element
|
||||||
|
SharePermissionLevel (type): SharePermissionLevel
|
||||||
|
ShareTarget (type): ShareTarget
|
||||||
Sheet (value): ({ ...props }: DialogProps) => Element
|
Sheet (value): ({ ...props }: DialogProps) => Element
|
||||||
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
||||||
@@ -173,71 +270,372 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
|
|||||||
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||||
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
|
ShellConfirmCapture (value): () => null
|
||||||
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
||||||
|
SortKey (type): SortKey
|
||||||
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
||||||
SparklineProps (type): SparklineProps
|
SparklineProps (type): SparklineProps
|
||||||
|
SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { activeIsSplit: boolean; canAddPane: boolean; onSplit: (edge: "right" | "bottom") => void; onPreset: (id: SplitPresetId) => void; }) => Element
|
||||||
|
SplitOpenTarget (type): SplitOpenTarget
|
||||||
|
SplitSummary (type): SplitSummary
|
||||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
||||||
|
StatusValue (type): StatusValue
|
||||||
SurfaceKind (type): SurfaceKind
|
SurfaceKind (type): SurfaceKind
|
||||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
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
|
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
|
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
|
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||||
|
Tab (type): Tab
|
||||||
|
TabShellCallbacks (type): TabShellCallbacks
|
||||||
|
TabSplitAction (type): TabSplitAction
|
||||||
|
TabStage (value): ({ className }: { className?: string; }) => Element
|
||||||
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
|
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
|
||||||
TabStripItem (type): TabStripItem
|
TabStripItem (type): TabStripItem
|
||||||
|
TabType (type): TabType
|
||||||
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
||||||
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
||||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||||
TextareaProps (type): TextareaProps
|
TextareaProps (type): TextareaProps
|
||||||
|
ThemeId (type): string
|
||||||
|
TlsStatus (type): TlsStatus
|
||||||
|
ToolsTab (type): ToolsTab
|
||||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||||
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
||||||
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
|
UI_AREA_KEYS (value): UiAreaKey[]
|
||||||
|
UI_FONTS (value): { id: UiFontId; label: string; family: string; }[]
|
||||||
|
UiAreaKey (type): UiAreaKey
|
||||||
|
UiFontId (type): UiFontId
|
||||||
|
UiPreset (type): UiPreset
|
||||||
|
UpdateChannelState (type): UpdateChannelState
|
||||||
UptimeInfo (type): UptimeInfo
|
UptimeInfo (type): UptimeInfo
|
||||||
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
|
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
|
||||||
|
UserPreferences (type): UserPreferences
|
||||||
|
UserRole (type): UserRole
|
||||||
|
UserSecondFactorSummary (type): UserSecondFactorSummary
|
||||||
|
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
|
||||||
VersionInfo (type): VersionInfo
|
VersionInfo (type): VersionInfo
|
||||||
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
|
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
|
||||||
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
||||||
|
actionOrder (value): (action: HostActionDef) => number
|
||||||
|
activityTarget (value): (type: string) => ActivityTarget
|
||||||
|
adminCreateUser (value): (username: string, password: string) => Promise<Record<string, unknown>>
|
||||||
|
adminCreateUserCredential (value): (targetUserId: string, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
|
adminCreateUserHost (value): (targetUserId: string, hostData: HostData) => Promise<Host>
|
||||||
|
adminDeleteUserCredential (value): (targetUserId: string, credentialId: number) => Promise<Record<string, unknown>>
|
||||||
|
adminDeleteUserHost (value): (targetUserId: string, hostId: number) => Promise<Record<string, unknown>>
|
||||||
|
adminExportUserData (value): (userId: string) => Promise<Record<string, unknown>>
|
||||||
|
adminGetHostPassword (value): (targetUserId: string, hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||||
|
adminGetUserCredentials (value): (targetUserId: string) => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||||
|
adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
|
||||||
|
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
|
||||||
|
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
|
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
|
||||||
|
applyAccentColor (value): (colorValue: string) => void
|
||||||
|
applyFontSize (value): (id: FontSizeId) => void
|
||||||
|
applyPresetSideEffects (value): (preset: "simple" | "balanced" | "advanced") => Promise<void>
|
||||||
|
applyUiFont (value): (id: UiFontId) => void
|
||||||
|
assignRoleToUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||||
|
beginSplitDrag (value): (source: SplitDragSource, x: number, y: number) => void
|
||||||
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
|
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
|
||||||
|
bulkUpdateSSHHosts (value): (hostIds: number[], updates: Record<string, unknown>) => Promise<{ updated: number; failed: number; errors: string[]; }>
|
||||||
|
canConfigureUpdates (value): (state: DesktopUpdateState) => state is DesktopUpdateState
|
||||||
|
canJoinSplit (value): (tab: Tab) => boolean
|
||||||
|
changeAppLanguage (value): (language: string) => Promise<string>
|
||||||
|
changePassword (value): (oldPassword: string, newPassword: string) => Promise<any>
|
||||||
|
clearLocalAdaptivePreferences (value): () => void
|
||||||
cn (value): (...inputs: ClassValue[]) => string
|
cn (value): (...inputs: ClassValue[]) => string
|
||||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||||
|
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
|
||||||
|
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||||
|
createRole (value): (roleData: { name: string; displayName: string; description?: string; }) => Promise<{ role: Role; }>
|
||||||
|
createSSHHost (value): (hostData: HostData) => Promise<Host>
|
||||||
|
defaultConnectAction (value): (all: HostActionDef[], host: Host) => HostActionDef
|
||||||
|
defaultCredentialRowFields (value): () => CredentialRowFields
|
||||||
|
defaultCredentialSidebarPreferences (value): () => CredentialSidebarPreferences
|
||||||
|
defaultHostRowFields (value): () => HostRowFields
|
||||||
|
defaultHostSidebarPreferences (value): () => HostSidebarPreferences
|
||||||
|
defaultValuesEqual (value): (a: unknown, b: unknown) => boolean
|
||||||
|
deleteAccount (value): (password: string) => Promise<Record<string, unknown>>
|
||||||
|
deleteAllHostsInFolder (value): (folderName: string) => Promise<{ deletedCount: number; }>
|
||||||
|
deleteApiKey (value): (keyId: string) => Promise<{ success: boolean; }>
|
||||||
|
deleteCredential (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||||
|
deleteOpenTab (value): (instanceId: string) => Promise<void>
|
||||||
|
deleteRole (value): (roleId: number) => Promise<{ success: boolean; }>
|
||||||
|
deleteSSHHost (value): (hostId: number) => Promise<Record<string, unknown>>
|
||||||
|
deleteUser (value): (username: string, successorUserId?: string) => Promise<Record<string, unknown>>
|
||||||
|
desktopUpdates (value): () => DesktopUpdatesBridge
|
||||||
|
docsUrl (value): (page?: "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry") => string
|
||||||
|
duplicateCredential (value): (credentialId: number, data: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||||
|
enabledHostProtocols (value): (host: { pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocolDef[]
|
||||||
|
endSplitDrag (value): (commit: boolean) => void
|
||||||
|
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
|
||||||
|
findCoreHostDefault (value): (key: string) => CoreHostDefault
|
||||||
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
||||||
|
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
|
||||||
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
||||||
|
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
|
||||||
|
getAuditLogActions (value): () => Promise<{ actions: string[]; }>
|
||||||
|
getAuditLogs (value): (filters?: AuditLogFilters) => Promise<AuditLogResponse>
|
||||||
getBasePath (value): () => string
|
getBasePath (value): () => string
|
||||||
|
getBranding (value): () => Promise<BrandingSettings>
|
||||||
|
getChannelReleases (value): () => Promise<{ localVersion: string; stable: ReleaseInfo; beta: ReleaseInfo; }>
|
||||||
|
getConnectedRemoteApi (value): () => Promise<AxiosInstance>
|
||||||
|
getCredentialAccess (value): (credentialId: number) => Promise<{ access: AccessRecord[]; }>
|
||||||
|
getCredentialDetails (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||||
|
getCredentialSidebarPreferences (value): () => Promise<CredentialSidebarPreferences>
|
||||||
|
getCredentials (value): () => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||||
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
|
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
|
||||||
|
getDatabaseTransferUrl (value): (operation: "export" | "import", { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions) => string
|
||||||
|
getDefaultConnectionTab (value): (host: Host) => TabType
|
||||||
|
getDeveloperMode (value): () => Promise<DeveloperModeState>
|
||||||
getDeviceId (value): () => string
|
getDeviceId (value): () => string
|
||||||
|
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||||
|
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||||
|
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
||||||
|
getFolderDefaultsId (value): (name: string) => Promise<number>
|
||||||
|
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||||
|
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||||
|
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
|
||||||
|
getHostEditorSection (value): (id: string) => HostEditorSectionDef
|
||||||
|
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||||
|
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
|
||||||
|
getHostTags (value): () => Promise<string[]>
|
||||||
|
getLiveHostStatus (value): (hostId: number) => StatusValue
|
||||||
|
getLogLevel (value): () => Promise<{ level: string; }>
|
||||||
|
getNotificationPrivateEndpoints (value): () => Promise<string[]>
|
||||||
|
getPanel (value): (id: string) => PanelDef
|
||||||
|
getPasswordLoginAllowed (value): () => Promise<{ allowed: boolean; forced?: boolean; }>
|
||||||
|
getPasswordResetAllowed (value): () => Promise<boolean>
|
||||||
|
getPermissionsCatalog (value): () => Promise<{ catalog: PermissionCatalogEntry[]; }>
|
||||||
|
getPluginAdminSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||||
|
getPluginHostSettings (value): (pluginId: string, hostId: number) => Promise<PluginSettingsValues>
|
||||||
|
getPluginUserSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||||
|
getPlugins (value): () => Promise<PluginSummary[]>
|
||||||
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
|
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
|
||||||
|
getPreferredHostAction (value): (hostId: string, candidates: readonly TabType[], fallback: TabType) => TabType
|
||||||
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
|
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
|
||||||
|
getRegisteredDashboardCard (value): (id: string) => RegisteredDashboardCard
|
||||||
|
getRegistrationAllowed (value): () => Promise<{ allowed: boolean; }>
|
||||||
|
getRoles (value): () => Promise<{ roles: Role[]; }>
|
||||||
|
getSSHFolders (value): () => Promise<SSHFolder[]>
|
||||||
|
getSSHHosts (value): () => Promise<SSHHostWithStatus[]>
|
||||||
|
getSecondFactorAfterExternalLogin (value): () => Promise<{ enabled: boolean; }>
|
||||||
|
getSessionTimeout (value): () => Promise<{ timeoutHours: number; }>
|
||||||
|
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>
|
||||||
getUptime (value): () => Promise<UptimeInfo>
|
getUptime (value): () => Promise<UptimeInfo>
|
||||||
|
getUserInfo (value): () => Promise<UserInfo>
|
||||||
|
getUserList (value): (options?: UserListOptions) => Promise<{ users: UserInfo[]; total?: number; }>
|
||||||
|
getUserPreferences (value): () => Promise<UserPreferences>
|
||||||
|
getUserRoles (value): (userId: string) => Promise<{ roles: UserRole[]; }>
|
||||||
|
getUserSecondFactors (value): (userId: string) => Promise<UserSecondFactorSummary[]>
|
||||||
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
|
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
|
||||||
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
|
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
|
||||||
|
groupRailItems (value): (items: RailItemDef[]) => { group: RailGroup; items: RailItemDef[]; }[]
|
||||||
|
hasVisibleFields (value): (fields: PluginSettingsField[]) => boolean
|
||||||
|
hostActionsFor (value): (all: HostActionDef[], host: Host, kind?: "open" | "connect") => HostActionDef[]
|
||||||
|
hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
|
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
|
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
||||||
|
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
||||||
|
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||||
|
invalidateHostsAndStatusCaches (value): () => void
|
||||||
|
invalidateServerStatusCache (value): () => void
|
||||||
|
isCoreRailView (value): (id: string) => boolean
|
||||||
isElectron (value): () => boolean
|
isElectron (value): () => boolean
|
||||||
|
isFieldShown (value): (field: PluginSettingsField, values: Record<string, unknown>) => boolean
|
||||||
isMacPlatform (value): () => boolean
|
isMacPlatform (value): () => boolean
|
||||||
|
isPersistentTabType (value): (type: string) => boolean
|
||||||
|
isPrerelease (value): (version: string) => boolean
|
||||||
|
isSessionTabType (value): (type: string) => boolean
|
||||||
|
isSplitDragging (value): () => boolean
|
||||||
|
isSplitTab (value): (tab: Tab) => tab is SplitTab
|
||||||
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
|
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
|
||||||
|
linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: string) => Promise<{ success: boolean; message: string; }>
|
||||||
linkedServerUrl (value): () => Promise<string>
|
linkedServerUrl (value): () => Promise<string>
|
||||||
|
listHostActions (value): () => HostActionDef[]
|
||||||
|
listHostProtocols (value): () => HostProtocolDef[]
|
||||||
|
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
||||||
|
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
||||||
|
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||||
|
makeHostTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||||
|
makeUserAdmin (value): (userId: string) => Promise<Record<string, unknown>>
|
||||||
|
mapCredentials (value): (res: unknown) => Credential[]
|
||||||
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
|
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
|
||||||
|
markTabSurfaceUsed (value): (type: TabType) => void
|
||||||
|
moveRailItem (value): (items: RailItemDef[], id: string, beforeId: string) => string[]
|
||||||
|
moveSplitDrag (value): (x: number, y: number) => void
|
||||||
|
normalizeLanguageCode (value): (language?: string) => string
|
||||||
|
notifySyncChanged (value): () => void
|
||||||
|
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
||||||
|
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
||||||
|
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
||||||
|
parseServerTime (value): (ts: string) => Date
|
||||||
|
pluginKey (value): { (pluginId: string, key: string): string; (pluginId: string, key: string): string; }
|
||||||
|
pluginName (value): (t: Translator, plugin: { id: string; name: string; }) => string
|
||||||
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
|
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
|
||||||
|
preloadTabSurface (value): (type: TabType) => void
|
||||||
|
previewHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts: number; }>
|
||||||
|
promotableIds (value): () => string[]
|
||||||
|
protocolEnabled (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => boolean
|
||||||
|
protocolPort (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => number
|
||||||
|
quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef[]
|
||||||
|
railItemLabel (value): (id: string, t: (key: string) => string) => string
|
||||||
readFromClipboard (value): () => Promise<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
|
||||||
|
rem (value): (px: number) => string
|
||||||
|
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
|
||||||
|
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||||
|
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||||
|
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||||
|
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||||
|
reorderFolders (value): (positions: { name: string; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||||
|
reorderSSHHosts (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||||
|
reportBetaFeedbackUrl (value): (termixVersion?: string, betaPlugins?: { id: string; version: string; }[]) => string
|
||||||
|
reportCoreIssueUrl (value): (termixVersion?: string) => string
|
||||||
|
requestFromLegacyEvent (value): (name: string, detail?: unknown) => ManageRequest
|
||||||
|
requestManage (value): (request: ManageRequest) => void
|
||||||
|
resetHostToDefaults (value): (hostIds: number[], reset: { all?: boolean; namespaces?: string[]; keys?: string[]; }) => Promise<void>
|
||||||
|
resetRecentActivity (value): () => Promise<{ message: string; }>
|
||||||
|
resetUserSecondFactors (value): (userId: string) => Promise<void>
|
||||||
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
|
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
|
||||||
|
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>
|
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>
|
||||||
|
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||||
|
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||||
|
rightDockableIds (value): () => string[]
|
||||||
|
roleLabel (value): <T extends string | null | undefined>(t: Translator, displayName: T) => string | T
|
||||||
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
|
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
|
||||||
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
|
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
|
||||||
|
runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallbacks) => void
|
||||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||||
|
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
|
||||||
|
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
|
||||||
|
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
|
||||||
|
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
|
||||||
|
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
|
||||||
|
saveHostTags (value): (tags: string[]) => Promise<string[]>
|
||||||
|
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||||
|
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||||
|
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||||
|
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; }>
|
||||||
|
updateLogLevel (value): (level: string) => Promise<void>
|
||||||
|
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||||
|
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||||
|
updatePluginAdminSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||||
|
updatePluginHostSettings (value): (pluginId: string, hostId: number, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||||
|
updatePluginUserSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||||
|
updateRegistrationAllowed (value): (allowed: boolean) => Promise<Record<string, unknown>>
|
||||||
|
updateRole (value): (roleId: number, roleData: { displayName?: string; description?: string; permissions?: string[]; }) => Promise<{ role: Role; }>
|
||||||
|
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
|
usageBarColor (value): (percent: number) => string
|
||||||
usageColor (value): (percent: number) => string
|
usageColor (value): (percent: number) => string
|
||||||
|
useActionSlot (value): (slotId: string, context?: Record<string, unknown>) => SlotContribution[]
|
||||||
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
||||||
useAppTheme (value): () => ThemeProviderState
|
useAppTheme (value): () => ThemeProviderState
|
||||||
|
useAreaPreferences (value): <A extends UiAreaKey>(area: A) => UiAreaPreferences[A]
|
||||||
|
useBranding (value): () => BrandingContextValue
|
||||||
|
useChooseTheme (value): () => (themeId: string) => void
|
||||||
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
||||||
useConnectionGate (value): () => ConnectionGateController
|
useConnectionGate (value): () => ConnectionGateController
|
||||||
useConnectionLog (value): () => ConnectionLogContextType
|
useConnectionLog (value): () => ConnectionLogContextType
|
||||||
|
useDesktopUpdate (value): () => DesktopUpdateState
|
||||||
|
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
|
||||||
|
useHostActions (value): () => HostActionDef[]
|
||||||
|
useHostBadges (value): () => HostBadgeDef[]
|
||||||
|
useHostContextMenuItems (value): () => HostContextMenuItemDef[]
|
||||||
|
useHostEditorSections (value): () => HostEditorSectionDef[]
|
||||||
|
useHostEditorToolbarItems (value): () => HostBadgeDef[]
|
||||||
|
useHostProtocols (value): () => HostProtocolDef[]
|
||||||
|
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
||||||
|
useHostStatusIndicators (value): () => HostBadgeDef[]
|
||||||
|
useInstalledPlugins (value): () => PluginSummary[]
|
||||||
useIsDefaultsEditor (value): () => boolean
|
useIsDefaultsEditor (value): () => boolean
|
||||||
useIsMobile (value): () => boolean
|
useIsMobile (value): () => boolean
|
||||||
|
useKeybindingActions (value): () => KeybindingActionDef[]
|
||||||
|
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
|
||||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||||
|
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
|
||||||
|
usePaletteEntries (value): () => PaletteEntryDef[]
|
||||||
|
usePaletteGroups (value): () => PaletteGroupDef[]
|
||||||
|
usePanels (value): () => PanelDef[]
|
||||||
|
usePermissions (value): () => PermissionsState
|
||||||
|
usePluginDocsUrl (value): (pluginId: string, page?: string, anchor?: string) => string
|
||||||
|
usePluginHostSections (value): () => PluginSummary[]
|
||||||
|
usePluginStore (value): () => StoreState
|
||||||
|
useRailItems (value): () => RailItemDef[]
|
||||||
|
useRegisteredDashboardCards (value): () => RegisteredDashboardCard[]
|
||||||
|
useServerStatus (value): () => ServerStatusContextType
|
||||||
|
useServerStatusMeta (value): () => { initialLoadComplete: boolean; }
|
||||||
|
useShellController (value): () => ShellController
|
||||||
|
useSplitTargets (value): () => SplitTargetsSnapshot
|
||||||
|
useSshAuthEditors (value): () => SshAuthEditorDef[]
|
||||||
|
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
|
||||||
|
useStatusColorScheme (value): () => StatusColorScheme
|
||||||
useSurfaceKind (value): () => SurfaceKind
|
useSurfaceKind (value): () => SurfaceKind
|
||||||
|
useSyncStatus (value): () => any
|
||||||
|
useTabHandlesVersion (value): () => number
|
||||||
useTabs (value): () => TabContextType
|
useTabs (value): () => TabContextType
|
||||||
useTabsSafe (value): () => TabContextType
|
useTabsSafe (value): () => TabContextType
|
||||||
|
useTheme (value): () => ThemeProviderState
|
||||||
|
useThemeOptions (value): () => ThemeOption[]
|
||||||
|
useUiPreference (value): <A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(area: A, key: K) => UiAreaPreferences[A][K]
|
||||||
|
useUiPreferencesContext (value): () => UiPreferencesContextValue
|
||||||
|
waitForDefaultsJob (value): (id: string, intervalMs?: number, maxWaitMs?: number) => Promise<number>
|
||||||
|
withProtocolSettings (value): (pluginSettings: Record<string, Record<string, unknown>>, flags: HostProtocols, list?: HostProtocolDef[]) => Record<string, Record<string, unknown>>
|
||||||
@@ -276,6 +276,33 @@ describe("pinsFromIndex", () => {
|
|||||||
expect(pins[0]).not.toHaveProperty("onboarding");
|
expect(pins[0]).not.toHaveProperty("onboarding");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pins a release kept in gated", () => {
|
||||||
|
const pins = pinsFromIndex(
|
||||||
|
{
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "look",
|
||||||
|
versions: [],
|
||||||
|
gated: [
|
||||||
|
{
|
||||||
|
version: "1.1.0-beta.1",
|
||||||
|
url: "https://example.test/look-1.1.0-beta.1.tmxplug",
|
||||||
|
sha256: "d".repeat(64),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
url: "https://example.test/look-1.0.0.tmxplug",
|
||||||
|
sha256: "e".repeat(64),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(pins[0].url).toBe("https://example.test/look-1.0.0.tmxplug");
|
||||||
|
});
|
||||||
|
|
||||||
it("pins only the ids asked for", () => {
|
it("pins only the ids asked for", () => {
|
||||||
expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([
|
expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([
|
||||||
"beta",
|
"beta",
|
||||||
@@ -416,3 +443,71 @@ describe("rebuilding local plugins", () => {
|
|||||||
expect(run.sort()).toEqual(["a", "b"]);
|
expect(run.sort()).toEqual(["a", "b"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("default look", () => {
|
||||||
|
const entry = { source: "tmxplug", path: "a", sha256: SHA };
|
||||||
|
|
||||||
|
it("reads the defaultLook flag and refuses anything but a boolean", () => {
|
||||||
|
const { plugins, problems } = parseBundledPlugins({
|
||||||
|
plugins: [
|
||||||
|
{ ...entry, id: "look", defaultLook: true },
|
||||||
|
{ ...entry, id: "plain" },
|
||||||
|
{ ...entry, id: "odd", defaultLook: "yes" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(problems).toEqual(["odd defaultLook must be a boolean"]);
|
||||||
|
expect(
|
||||||
|
plugins.map((p: { id: string; defaultLook: boolean }) => [
|
||||||
|
p.id,
|
||||||
|
p.defaultLook,
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
["look", true],
|
||||||
|
["plain", false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries it into the bundled index and through a re-pin", () => {
|
||||||
|
expect(
|
||||||
|
buildBundledIndex(
|
||||||
|
[
|
||||||
|
{ id: "look", onboarding: null, defaultLook: true },
|
||||||
|
{ id: "plain", onboarding: null, defaultLook: false },
|
||||||
|
],
|
||||||
|
["look", "plain"],
|
||||||
|
),
|
||||||
|
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||||
|
|
||||||
|
const index = {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: "look",
|
||||||
|
versions: [
|
||||||
|
{ version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0],
|
||||||
|
).toMatchObject({ id: "look", defaultLook: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies a local build's theme files", () => {
|
||||||
|
const repo = tempDir();
|
||||||
|
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(repo, "themes"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}");
|
||||||
|
fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(repo, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "inks",
|
||||||
|
contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const out = path.join(tempDir(), "inks");
|
||||||
|
copyLocalBuild(repo, out);
|
||||||
|
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,12 +32,18 @@ const OFFICIAL_IDS = [
|
|||||||
"acme-ssl",
|
"acme-ssl",
|
||||||
"ai",
|
"ai",
|
||||||
"alerts",
|
"alerts",
|
||||||
|
"audit-tools",
|
||||||
"automations",
|
"automations",
|
||||||
|
"classic-layout",
|
||||||
|
"classic-themes",
|
||||||
|
"credential-tools",
|
||||||
"docker",
|
"docker",
|
||||||
"file-manager",
|
"file-manager",
|
||||||
"fleets",
|
"fleets",
|
||||||
"homepage",
|
"homepage",
|
||||||
"host-metrics",
|
"host-metrics",
|
||||||
|
"host-status",
|
||||||
|
"import-export",
|
||||||
"ldap",
|
"ldap",
|
||||||
"network-topology",
|
"network-topology",
|
||||||
"opkssh",
|
"opkssh",
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
|||||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||||
|
|
||||||
const ALLOWED = [
|
const ALLOWED = [
|
||||||
|
"ACCENT_PRESET_COLORS",
|
||||||
|
"APP_VERSION",
|
||||||
|
"AccessRecord",
|
||||||
"ActionSlot",
|
"ActionSlot",
|
||||||
|
"ActiveSessionInfo",
|
||||||
"AddButton",
|
"AddButton",
|
||||||
|
"AdminSection",
|
||||||
"Alert",
|
"Alert",
|
||||||
"AlertDescription",
|
"AlertDescription",
|
||||||
"AlertDialog",
|
"AlertDialog",
|
||||||
@@ -35,14 +40,23 @@ const ALLOWED = [
|
|||||||
"AlertDialogTitle",
|
"AlertDialogTitle",
|
||||||
"AlertDialogTrigger",
|
"AlertDialogTrigger",
|
||||||
"AlertTitle",
|
"AlertTitle",
|
||||||
|
"ApiKey",
|
||||||
|
"AuditLog",
|
||||||
|
"AuditLogFilters",
|
||||||
|
"AuthEnrollmentSections",
|
||||||
|
"AuthOverrideProtocol",
|
||||||
"BackButton",
|
"BackButton",
|
||||||
"Badge",
|
"Badge",
|
||||||
"BarSeries",
|
"BarSeries",
|
||||||
"BarSeriesItem",
|
"BarSeriesItem",
|
||||||
"BarSeriesProps",
|
"BarSeriesProps",
|
||||||
|
"BrandingSettings",
|
||||||
"BrowserSignInDialog",
|
"BrowserSignInDialog",
|
||||||
"Button",
|
"Button",
|
||||||
"ButtonProps",
|
"ButtonProps",
|
||||||
|
"CORE_HOST_DEFAULTS",
|
||||||
|
"CORE_NAMESPACE",
|
||||||
|
"CREDENTIAL_ROW_FIELD_KEYS",
|
||||||
"Card",
|
"Card",
|
||||||
"CardAction",
|
"CardAction",
|
||||||
"CardContent",
|
"CardContent",
|
||||||
@@ -54,6 +68,12 @@ const ALLOWED = [
|
|||||||
"CardTitle",
|
"CardTitle",
|
||||||
"Checkbox",
|
"Checkbox",
|
||||||
"ColumnCountStepper",
|
"ColumnCountStepper",
|
||||||
|
"Command",
|
||||||
|
"CommandEmpty",
|
||||||
|
"CommandGroup",
|
||||||
|
"CommandItem",
|
||||||
|
"CommandList",
|
||||||
|
"CommandPrimitive",
|
||||||
"ComponentSlot",
|
"ComponentSlot",
|
||||||
"ConfirmOptions",
|
"ConfirmOptions",
|
||||||
"ConnectionGate",
|
"ConnectionGate",
|
||||||
@@ -66,9 +86,26 @@ const ALLOWED = [
|
|||||||
"ConnectionStage",
|
"ConnectionStage",
|
||||||
"ConnectionStatus",
|
"ConnectionStatus",
|
||||||
"ConnectionUnavailable",
|
"ConnectionUnavailable",
|
||||||
|
"CreatedApiKey",
|
||||||
|
"Credential",
|
||||||
|
"CredentialDensity",
|
||||||
|
"CredentialPermissionLevel",
|
||||||
|
"CredentialRowFields",
|
||||||
|
"CredentialSidebarFilterState",
|
||||||
|
"CredentialSidebarPreferences",
|
||||||
|
"CredentialSortKey",
|
||||||
|
"CredentialTrayTrigger",
|
||||||
"CustomKeybinding",
|
"CustomKeybinding",
|
||||||
|
"DASHBOARD_CARDS",
|
||||||
|
"DEFAULT_FOLDER_COLOR",
|
||||||
|
"DEFAULT_FOLDER_ICON",
|
||||||
|
"DashboardCardId",
|
||||||
"DataColumn",
|
"DataColumn",
|
||||||
"DataView",
|
"DataView",
|
||||||
|
"DefaultOverrides",
|
||||||
|
"DefaultsOnly",
|
||||||
|
"DesktopUpdateMode",
|
||||||
|
"DesktopUpdateState",
|
||||||
"Dialog",
|
"Dialog",
|
||||||
"DialogClose",
|
"DialogClose",
|
||||||
"DialogContent",
|
"DialogContent",
|
||||||
@@ -81,6 +118,7 @@ const ALLOWED = [
|
|||||||
"DialogTrigger",
|
"DialogTrigger",
|
||||||
"DocsLink",
|
"DocsLink",
|
||||||
"DocsLinkProps",
|
"DocsLinkProps",
|
||||||
|
"DocsPage",
|
||||||
"DropdownMenu",
|
"DropdownMenu",
|
||||||
"DropdownMenuCheckboxItem",
|
"DropdownMenuCheckboxItem",
|
||||||
"DropdownMenuContent",
|
"DropdownMenuContent",
|
||||||
@@ -99,10 +137,14 @@ const ALLOWED = [
|
|||||||
"EditingWidth",
|
"EditingWidth",
|
||||||
"EmptyState",
|
"EmptyState",
|
||||||
"FOLDER_COLORS",
|
"FOLDER_COLORS",
|
||||||
|
"FONT_SIZES",
|
||||||
"Facts",
|
"Facts",
|
||||||
"FakeSwitch",
|
"FakeSwitch",
|
||||||
"Field",
|
"Field",
|
||||||
"FieldPair",
|
"FieldPair",
|
||||||
|
"FolderAccessRule",
|
||||||
|
"FolderIconEl",
|
||||||
|
"FontSizeId",
|
||||||
"FormFooter",
|
"FormFooter",
|
||||||
"FullScreenAppPhase",
|
"FullScreenAppPhase",
|
||||||
"FullScreenAppWrapper",
|
"FullScreenAppWrapper",
|
||||||
@@ -112,11 +154,33 @@ const ALLOWED = [
|
|||||||
"GridLayout",
|
"GridLayout",
|
||||||
"GridSlot",
|
"GridSlot",
|
||||||
"GroupHeading",
|
"GroupHeading",
|
||||||
|
"HexColorPicker",
|
||||||
|
"Host",
|
||||||
|
"HostActionDef",
|
||||||
|
"HostClickBehavior",
|
||||||
|
"HostData",
|
||||||
"HostDefaultBadge",
|
"HostDefaultBadge",
|
||||||
"HostDefaultField",
|
"HostDefaultField",
|
||||||
|
"HostDefaultsChange",
|
||||||
|
"HostDefaultsContext",
|
||||||
|
"HostDefaultsContextValue",
|
||||||
|
"HostDefaultsLevel",
|
||||||
|
"HostDefaultsTarget",
|
||||||
|
"HostDensity",
|
||||||
"HostFeatureFields",
|
"HostFeatureFields",
|
||||||
"HostFeatureFieldsProps",
|
"HostFeatureFieldsProps",
|
||||||
|
"HostFolder",
|
||||||
|
"HostGroupKey",
|
||||||
"HostKeyVerificationDialog",
|
"HostKeyVerificationDialog",
|
||||||
|
"HostOnly",
|
||||||
|
"HostProtocolAuthInput",
|
||||||
|
"HostProtocolDef",
|
||||||
|
"HostProtocols",
|
||||||
|
"HostRowFields",
|
||||||
|
"HostSidebarFilterState",
|
||||||
|
"HostSidebarPreferences",
|
||||||
|
"HostTrayTrigger",
|
||||||
|
"IconPicker",
|
||||||
"InlineView",
|
"InlineView",
|
||||||
"Input",
|
"Input",
|
||||||
"Kbd",
|
"Kbd",
|
||||||
@@ -124,10 +188,14 @@ const ALLOWED = [
|
|||||||
"KbdSeparator",
|
"KbdSeparator",
|
||||||
"KeyCombo",
|
"KeyCombo",
|
||||||
"KeybindingAction",
|
"KeybindingAction",
|
||||||
|
"KeybindingActionDef",
|
||||||
"KeybindingActionType",
|
"KeybindingActionType",
|
||||||
|
"LANGUAGES",
|
||||||
"Label",
|
"Label",
|
||||||
"LineChart",
|
"LineChart",
|
||||||
"LineChartSeries",
|
"LineChartSeries",
|
||||||
|
"LinkServerDialog",
|
||||||
|
"LinkedAccountInfo",
|
||||||
"ListBadge",
|
"ListBadge",
|
||||||
"ListRow",
|
"ListRow",
|
||||||
"ListRowAction",
|
"ListRowAction",
|
||||||
@@ -135,7 +203,12 @@ const ALLOWED = [
|
|||||||
"ListRowTone",
|
"ListRowTone",
|
||||||
"LogContext",
|
"LogContext",
|
||||||
"LogEntry",
|
"LogEntry",
|
||||||
|
"LookSettings",
|
||||||
|
"MANAGE_REQUEST_EVENT",
|
||||||
|
"MAX_PANES",
|
||||||
"MFAPromptMode",
|
"MFAPromptMode",
|
||||||
|
"ManageMode",
|
||||||
|
"ManageRequest",
|
||||||
"ManagerCardError",
|
"ManagerCardError",
|
||||||
"ManagerCardShell",
|
"ManagerCardShell",
|
||||||
"ManagerSearch",
|
"ManagerSearch",
|
||||||
@@ -143,11 +216,14 @@ const ALLOWED = [
|
|||||||
"MetricCard",
|
"MetricCard",
|
||||||
"MiniStat",
|
"MiniStat",
|
||||||
"NumberField",
|
"NumberField",
|
||||||
|
"OPEN_PLUGINS_EVENT",
|
||||||
|
"OpenTabRecord",
|
||||||
"PANEL",
|
"PANEL",
|
||||||
"PanePrompt",
|
|
||||||
"PROMPT_BUTTON",
|
"PROMPT_BUTTON",
|
||||||
"PROMPT_DESTRUCTIVE_BUTTON",
|
"PROMPT_DESTRUCTIVE_BUTTON",
|
||||||
"PROMPT_PRIMARY_BUTTON",
|
"PROMPT_PRIMARY_BUTTON",
|
||||||
|
"PaletteItemDef",
|
||||||
|
"PanePrompt",
|
||||||
"PanelDensity",
|
"PanelDensity",
|
||||||
"PanelList",
|
"PanelList",
|
||||||
"PanelSearch",
|
"PanelSearch",
|
||||||
@@ -155,18 +231,36 @@ const ALLOWED = [
|
|||||||
"PanelViewMode",
|
"PanelViewMode",
|
||||||
"PassphraseDialog",
|
"PassphraseDialog",
|
||||||
"PasswordInput",
|
"PasswordInput",
|
||||||
|
"PermissionCatalogEntry",
|
||||||
"PluginComponent",
|
"PluginComponent",
|
||||||
|
"PRESETS",
|
||||||
|
"PluginIcon",
|
||||||
|
"PluginSettingsErrors",
|
||||||
|
"PluginSettingsField",
|
||||||
|
"PluginSettingsValidationError",
|
||||||
|
"PluginSummary",
|
||||||
"PluginViewPlaceholder",
|
"PluginViewPlaceholder",
|
||||||
"Popover",
|
"Popover",
|
||||||
"PopoverAnchor",
|
"PopoverAnchor",
|
||||||
"PopoverContent",
|
"PopoverContent",
|
||||||
"PopoverTrigger",
|
"PopoverTrigger",
|
||||||
|
"ProxyNode",
|
||||||
|
"ProxySettingsFields",
|
||||||
"RadialGauge",
|
"RadialGauge",
|
||||||
"RadialGaugeProps",
|
"RadialGaugeProps",
|
||||||
|
"RailGroup",
|
||||||
|
"RailItemDef",
|
||||||
"RecentActivityItem",
|
"RecentActivityItem",
|
||||||
|
"ReleaseInfo",
|
||||||
"Repeater",
|
"Repeater",
|
||||||
|
"ResolvedHostDefault",
|
||||||
"RobustClipboardProvider",
|
"RobustClipboardProvider",
|
||||||
|
"Role",
|
||||||
"SSHAuthDialog",
|
"SSHAuthDialog",
|
||||||
|
"SSHHost",
|
||||||
|
"SSHHostData",
|
||||||
|
"SSHHostWithStatus",
|
||||||
|
"SSH_AUTH_PROTOCOL",
|
||||||
"ScrollArea",
|
"ScrollArea",
|
||||||
"ScrollBar",
|
"ScrollBar",
|
||||||
"SecretField",
|
"SecretField",
|
||||||
@@ -186,6 +280,9 @@ const ALLOWED = [
|
|||||||
"SelectValue",
|
"SelectValue",
|
||||||
"Separator",
|
"Separator",
|
||||||
"SettingRow",
|
"SettingRow",
|
||||||
|
"SettingsFieldRow",
|
||||||
|
"SharePermissionLevel",
|
||||||
|
"ShareTarget",
|
||||||
"Sheet",
|
"Sheet",
|
||||||
"SheetClose",
|
"SheetClose",
|
||||||
"SheetContent",
|
"SheetContent",
|
||||||
@@ -194,74 +291,375 @@ const ALLOWED = [
|
|||||||
"SheetHeader",
|
"SheetHeader",
|
||||||
"SheetTitle",
|
"SheetTitle",
|
||||||
"SheetTrigger",
|
"SheetTrigger",
|
||||||
|
"ShellConfirmCapture",
|
||||||
"Skeleton",
|
"Skeleton",
|
||||||
"Slider",
|
"Slider",
|
||||||
|
"SortKey",
|
||||||
"Sparkline",
|
"Sparkline",
|
||||||
"SparklineProps",
|
"SparklineProps",
|
||||||
|
"SplitLayoutMenu",
|
||||||
|
"SplitOpenTarget",
|
||||||
|
"SplitSummary",
|
||||||
"StatRow",
|
"StatRow",
|
||||||
|
"StatusValue",
|
||||||
"SurfaceKind",
|
"SurfaceKind",
|
||||||
"SurfaceScope",
|
"SurfaceScope",
|
||||||
"Switch",
|
"Switch",
|
||||||
"SwitchRow",
|
"SwitchRow",
|
||||||
|
"SyncBasicAuth",
|
||||||
|
"SyncConflictItem",
|
||||||
|
"SyncEntityInfo",
|
||||||
|
"SyncErrorItem",
|
||||||
|
"SyncProxyHeader",
|
||||||
|
"SyncStatus",
|
||||||
"TOTPDialog",
|
"TOTPDialog",
|
||||||
|
"Tab",
|
||||||
|
"TabShellCallbacks",
|
||||||
|
"TabSplitAction",
|
||||||
|
"TabStage",
|
||||||
"TabStrip",
|
"TabStrip",
|
||||||
"TabStripItem",
|
"TabStripItem",
|
||||||
|
"TabType",
|
||||||
"TagInput",
|
"TagInput",
|
||||||
"TextAreaField",
|
"TextAreaField",
|
||||||
"TextField",
|
"TextField",
|
||||||
"Textarea",
|
"Textarea",
|
||||||
"TextareaProps",
|
"TextareaProps",
|
||||||
|
"ThemeId",
|
||||||
|
"TlsStatus",
|
||||||
|
"ToolsTab",
|
||||||
"Tooltip",
|
"Tooltip",
|
||||||
"TooltipContent",
|
"TooltipContent",
|
||||||
"TooltipProvider",
|
"TooltipProvider",
|
||||||
"TooltipTrigger",
|
"TooltipTrigger",
|
||||||
|
"UI_AREA_KEYS",
|
||||||
|
"UI_FONTS",
|
||||||
|
"UiAreaKey",
|
||||||
|
"UiFontId",
|
||||||
|
"UiPreset",
|
||||||
|
"UpdateChannelState",
|
||||||
"UptimeInfo",
|
"UptimeInfo",
|
||||||
"UsagePair",
|
"UsagePair",
|
||||||
|
"UserPreferences",
|
||||||
|
"UserRole",
|
||||||
|
"UserSecondFactorSummary",
|
||||||
|
"VersionBadge",
|
||||||
"VersionInfo",
|
"VersionInfo",
|
||||||
"ViewToggle",
|
"ViewToggle",
|
||||||
"WebSocketConnectionTarget",
|
"WebSocketConnectionTarget",
|
||||||
"WidgetTitle",
|
"WidgetTitle",
|
||||||
|
"actionOrder",
|
||||||
|
"activityTarget",
|
||||||
|
"adminCreateUser",
|
||||||
|
"adminCreateUserCredential",
|
||||||
|
"adminCreateUserHost",
|
||||||
|
"adminDeleteUserCredential",
|
||||||
|
"adminDeleteUserHost",
|
||||||
|
"adminExportUserData",
|
||||||
|
"adminGetHostPassword",
|
||||||
|
"adminGetUserCredentials",
|
||||||
|
"adminGetUserHosts",
|
||||||
|
"adminResetUserPassword",
|
||||||
|
"adminUpdateUserCredential",
|
||||||
|
"adminUpdateUserHost",
|
||||||
|
"applyAccentColor",
|
||||||
|
"applyFontSize",
|
||||||
|
"applyPresetSideEffects",
|
||||||
|
"applyUiFont",
|
||||||
|
"assignRoleToUser",
|
||||||
|
"beginSplitDrag",
|
||||||
"buildOriginWsUrl",
|
"buildOriginWsUrl",
|
||||||
|
"bulkUpdateSSHHosts",
|
||||||
|
"canConfigureUpdates",
|
||||||
|
"canJoinSplit",
|
||||||
|
"changeAppLanguage",
|
||||||
|
"changePassword",
|
||||||
|
"clearLocalAdaptivePreferences",
|
||||||
"cn",
|
"cn",
|
||||||
"copyToClipboard",
|
"copyToClipboard",
|
||||||
|
"createApiKey",
|
||||||
|
"createCredential",
|
||||||
"createFrontendLogger",
|
"createFrontendLogger",
|
||||||
"createQuickConnectHost",
|
"createQuickConnectHost",
|
||||||
|
"createRole",
|
||||||
|
"createSSHHost",
|
||||||
|
"defaultConnectAction",
|
||||||
|
"defaultCredentialRowFields",
|
||||||
|
"defaultCredentialSidebarPreferences",
|
||||||
|
"defaultHostRowFields",
|
||||||
|
"defaultHostSidebarPreferences",
|
||||||
|
"defaultValuesEqual",
|
||||||
|
"deleteAccount",
|
||||||
|
"deleteAllHostsInFolder",
|
||||||
|
"deleteApiKey",
|
||||||
|
"deleteCredential",
|
||||||
|
"deleteOpenTab",
|
||||||
|
"deleteRole",
|
||||||
|
"deleteSSHHost",
|
||||||
|
"deleteUser",
|
||||||
|
"desktopUpdates",
|
||||||
|
"docsUrl",
|
||||||
|
"duplicateCredential",
|
||||||
|
"enabledHostProtocols",
|
||||||
|
"endSplitDrag",
|
||||||
|
"filterPaletteItems",
|
||||||
|
"findCoreHostDefault",
|
||||||
"findMatchingKeybinding",
|
"findMatchingKeybinding",
|
||||||
|
"getActiveSessions",
|
||||||
"getAdaptiveResourceBudget",
|
"getAdaptiveResourceBudget",
|
||||||
|
"getApiKeys",
|
||||||
|
"getAuditLogActions",
|
||||||
|
"getAuditLogs",
|
||||||
"getBasePath",
|
"getBasePath",
|
||||||
|
"getBranding",
|
||||||
|
"getChannelReleases",
|
||||||
|
"getConnectedRemoteApi",
|
||||||
|
"getCredentialAccess",
|
||||||
|
"getCredentialDetails",
|
||||||
|
"getCredentialSidebarPreferences",
|
||||||
|
"getCredentials",
|
||||||
"getDatabaseHealth",
|
"getDatabaseHealth",
|
||||||
|
"getDatabaseTransferUrl",
|
||||||
|
"getDefaultConnectionTab",
|
||||||
|
"getDeveloperMode",
|
||||||
"getDeviceId",
|
"getDeviceId",
|
||||||
|
"getErrorMessage",
|
||||||
|
"getExternalAutoProvision",
|
||||||
|
"getFolderAccess",
|
||||||
|
"getFolderDefaultsId",
|
||||||
|
"getHostAccess",
|
||||||
|
"getHostAuthOverride",
|
||||||
|
"getHostDefaultsLevel",
|
||||||
|
"getHostEditorSection",
|
||||||
|
"getHostPassword",
|
||||||
|
"getHostSidebarPreferences",
|
||||||
|
"getHostTags",
|
||||||
|
"getLiveHostStatus",
|
||||||
|
"getLogLevel",
|
||||||
|
"getNotificationPrivateEndpoints",
|
||||||
|
"getPanel",
|
||||||
|
"getPasswordLoginAllowed",
|
||||||
|
"getPasswordResetAllowed",
|
||||||
|
"getPermissionsCatalog",
|
||||||
|
"getPluginAdminSettings",
|
||||||
|
"getPluginHostSettings",
|
||||||
|
"getPluginUserSettings",
|
||||||
|
"getPlugins",
|
||||||
"getPollingEnvironmentMultiplier",
|
"getPollingEnvironmentMultiplier",
|
||||||
|
"getPreferredHostAction",
|
||||||
"getRecentActivity",
|
"getRecentActivity",
|
||||||
|
"getRegisteredDashboardCard",
|
||||||
|
"getRegistrationAllowed",
|
||||||
|
"getRoles",
|
||||||
|
"getSSHFolders",
|
||||||
|
"getSSHHosts",
|
||||||
|
"getSecondFactorAfterExternalLogin",
|
||||||
|
"getSessionTimeout",
|
||||||
|
"getSessionTimeoutMinutes",
|
||||||
|
"getSessions",
|
||||||
|
"getStatusCheckSettings",
|
||||||
|
"getStatusClasses",
|
||||||
|
"getSyncConflicts",
|
||||||
|
"getSyncErrors",
|
||||||
|
"getTabType",
|
||||||
|
"getTlsStatus",
|
||||||
|
"getUpdateChannel",
|
||||||
"getUptime",
|
"getUptime",
|
||||||
|
"getUserInfo",
|
||||||
|
"getUserList",
|
||||||
|
"getUserPreferences",
|
||||||
|
"getUserRoles",
|
||||||
|
"getUserSecondFactors",
|
||||||
"getVersionInfo",
|
"getVersionInfo",
|
||||||
"globalShortcutHandler",
|
"globalShortcutHandler",
|
||||||
|
"groupRailItems",
|
||||||
|
"hasVisibleFields",
|
||||||
|
"hostActionsFor",
|
||||||
|
"hostBadgesFor",
|
||||||
|
"hostEditorToolbarItemsFor",
|
||||||
|
"hostMenuItemsFor",
|
||||||
|
"hostProtocolFlags",
|
||||||
|
"hostStatusIndicatorsFor",
|
||||||
"hydrateLocalSharedHostAuth",
|
"hydrateLocalSharedHostAuth",
|
||||||
|
"invalidateHostsAndStatusCaches",
|
||||||
|
"invalidateServerStatusCache",
|
||||||
|
"isCoreRailView",
|
||||||
"isElectron",
|
"isElectron",
|
||||||
|
"isFieldShown",
|
||||||
"isMacPlatform",
|
"isMacPlatform",
|
||||||
|
"isPersistentTabType",
|
||||||
|
"isPrerelease",
|
||||||
|
"isSessionTabType",
|
||||||
|
"isSplitDragging",
|
||||||
|
"isSplitTab",
|
||||||
"isTabJumpHotkey",
|
"isTabJumpHotkey",
|
||||||
|
"linkExternalToPasswordAccount",
|
||||||
"linkedServerUrl",
|
"linkedServerUrl",
|
||||||
|
"listHostActions",
|
||||||
|
"listHostProtocols",
|
||||||
|
"loadPaletteGroup",
|
||||||
|
"logoutUser",
|
||||||
|
"makeHostSshSubTabs",
|
||||||
|
"makeHostTabs",
|
||||||
|
"makeUserAdmin",
|
||||||
|
"mapCredentials",
|
||||||
"markAdaptiveResourceUsed",
|
"markAdaptiveResourceUsed",
|
||||||
|
"markTabSurfaceUsed",
|
||||||
|
"moveRailItem",
|
||||||
|
"moveSplitDrag",
|
||||||
|
"normalizeLanguageCode",
|
||||||
|
"notifySyncChanged",
|
||||||
|
"openInSplit",
|
||||||
|
"paletteEntriesFor",
|
||||||
|
"parseCustomKeybindings",
|
||||||
|
"parseServerTime",
|
||||||
|
"pluginKey",
|
||||||
|
"pluginName",
|
||||||
"pluginWsUrl",
|
"pluginWsUrl",
|
||||||
|
"preloadTabSurface",
|
||||||
|
"previewHostDefaultsLevel",
|
||||||
|
"promotableIds",
|
||||||
|
"protocolEnabled",
|
||||||
|
"protocolPort",
|
||||||
|
"quickConnectTargets",
|
||||||
|
"railItemLabel",
|
||||||
"readFromClipboard",
|
"readFromClipboard",
|
||||||
|
"recordHostActionPreference",
|
||||||
|
"refreshSyncStatus",
|
||||||
|
"registerLayout",
|
||||||
|
"registerTabCloseGuard",
|
||||||
|
"releaseUrlFrom",
|
||||||
|
"rem",
|
||||||
|
"removeAdminStatus",
|
||||||
|
"removeRoleFromUser",
|
||||||
|
"renameCredentialFolder",
|
||||||
|
"renameFolder",
|
||||||
|
"reorderCredentials",
|
||||||
|
"reorderFolders",
|
||||||
|
"reorderSSHHosts",
|
||||||
|
"reportBetaFeedbackUrl",
|
||||||
|
"reportCoreIssueUrl",
|
||||||
|
"requestFromLegacyEvent",
|
||||||
|
"requestManage",
|
||||||
|
"resetHostToDefaults",
|
||||||
|
"resetRecentActivity",
|
||||||
|
"resetUserSecondFactors",
|
||||||
"resolveConnectionOrigin",
|
"resolveConnectionOrigin",
|
||||||
|
"resolveHostDefaults",
|
||||||
|
"resolveHostTabType",
|
||||||
"resolveRemoteHostId",
|
"resolveRemoteHostId",
|
||||||
|
"retrySyncErrors",
|
||||||
|
"revokeAllUserSessions",
|
||||||
|
"revokeCredentialAccess",
|
||||||
|
"revokeFolderAccess",
|
||||||
|
"revokeHostAccess",
|
||||||
|
"revokeSession",
|
||||||
|
"rightDockableIds",
|
||||||
|
"roleLabel",
|
||||||
"runAdaptiveBackgroundTask",
|
"runAdaptiveBackgroundTask",
|
||||||
"runAdaptivePolling",
|
"runAdaptivePolling",
|
||||||
|
"runHostAction",
|
||||||
"runVisibleInterval",
|
"runVisibleInterval",
|
||||||
|
"sanitizeCredentialSidebarPreferences",
|
||||||
|
"sanitizeHostSidebarPreferences",
|
||||||
|
"saveCredentialSidebarPreferences",
|
||||||
|
"saveHostDefaultsLevel",
|
||||||
|
"saveHostSidebarPreferences",
|
||||||
|
"saveHostTags",
|
||||||
|
"saveUserPreferences",
|
||||||
|
"setAllPluginChannels",
|
||||||
|
"setDeveloperMode",
|
||||||
|
"setHostAuthOverride",
|
||||||
|
"setNotificationPrivateEndpoints",
|
||||||
|
"setPluginOptions",
|
||||||
|
"setSyncStatus",
|
||||||
|
"setUpdateChannel",
|
||||||
|
"settleSyncConflict",
|
||||||
|
"shareCredential",
|
||||||
|
"shareFolder",
|
||||||
|
"shareHost",
|
||||||
|
"shell",
|
||||||
|
"splitDefaultKey",
|
||||||
|
"sshHostToHost",
|
||||||
|
"syncNow",
|
||||||
|
"tabIcon",
|
||||||
|
"tabJumpHotkeyKeys",
|
||||||
|
"takePendingManageRequest",
|
||||||
|
"unlinkExternalFromPasswordAccount",
|
||||||
|
"unlinkServer",
|
||||||
|
"updateCredential",
|
||||||
|
"updateExternalAutoProvision",
|
||||||
|
"updateFolderMetadata",
|
||||||
|
"updateHostAccess",
|
||||||
|
"updateLogLevel",
|
||||||
|
"updatePasswordLoginAllowed",
|
||||||
|
"updatePasswordResetAllowed",
|
||||||
|
"updatePluginAdminSettings",
|
||||||
|
"updatePluginHostSettings",
|
||||||
|
"updatePluginUserSettings",
|
||||||
|
"updateRegistrationAllowed",
|
||||||
|
"updateRole",
|
||||||
|
"updateSSHHost",
|
||||||
|
"updateSecondFactorAfterExternalLogin",
|
||||||
|
"updateSessionTimeout",
|
||||||
|
"updateStatusCheckSettings",
|
||||||
|
"updateSyncSettings",
|
||||||
|
"uploadTlsCertificate",
|
||||||
"usageBarColor",
|
"usageBarColor",
|
||||||
"usageColor",
|
"usageColor",
|
||||||
|
"useActionSlot",
|
||||||
"useAdaptivePolling",
|
"useAdaptivePolling",
|
||||||
"useAppTheme",
|
"useAppTheme",
|
||||||
|
"useAreaPreferences",
|
||||||
|
"useBranding",
|
||||||
|
"useChooseTheme",
|
||||||
"useConfirm",
|
"useConfirm",
|
||||||
"useConnectionGate",
|
"useConnectionGate",
|
||||||
"useConnectionLog",
|
"useConnectionLog",
|
||||||
|
"useDesktopUpdate",
|
||||||
|
"useEditorDefaultKeys",
|
||||||
|
"useHostActions",
|
||||||
|
"useHostBadges",
|
||||||
|
"useHostContextMenuItems",
|
||||||
|
"useHostEditorToolbarItems",
|
||||||
|
"useHostStatusIndicators",
|
||||||
|
"useHostEditorSections",
|
||||||
|
"useHostProtocols",
|
||||||
|
"useHostStatus",
|
||||||
|
"useInstalledPlugins",
|
||||||
"useIsDefaultsEditor",
|
"useIsDefaultsEditor",
|
||||||
"useIsMobile",
|
"useIsMobile",
|
||||||
|
"useKeybindingActions",
|
||||||
|
"useKeybindingDefaults",
|
||||||
"useOptionalConnectionLog",
|
"useOptionalConnectionLog",
|
||||||
|
"usePageVisibleInterval",
|
||||||
|
"usePaletteEntries",
|
||||||
|
"usePaletteGroups",
|
||||||
|
"usePanels",
|
||||||
|
"usePermissions",
|
||||||
|
"usePluginDocsUrl",
|
||||||
|
"usePluginHostSections",
|
||||||
|
"usePluginStore",
|
||||||
|
"useRailItems",
|
||||||
|
"useRegisteredDashboardCards",
|
||||||
|
"useServerStatus",
|
||||||
|
"useServerStatusMeta",
|
||||||
|
"useShellController",
|
||||||
|
"useSplitTargets",
|
||||||
|
"useSshAuthEditors",
|
||||||
|
"useSshAuthProviders",
|
||||||
|
"useStatusColorScheme",
|
||||||
"useSurfaceKind",
|
"useSurfaceKind",
|
||||||
|
"useSyncStatus",
|
||||||
|
"useTabHandlesVersion",
|
||||||
"useTabs",
|
"useTabs",
|
||||||
"useTabsSafe",
|
"useTabsSafe",
|
||||||
|
"useTheme",
|
||||||
|
"useThemeOptions",
|
||||||
|
"useUiPreference",
|
||||||
|
"useUiPreferencesContext",
|
||||||
|
"waitForDefaultsJob",
|
||||||
|
"withProtocolSettings",
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Every name a module exports, resolved the way the compiler sees it. */
|
/** Every name a module exports, resolved the way the compiler sees it. */
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ function start() {
|
|||||||
writeFileSync: vi.fn(),
|
writeFileSync: vi.fn(),
|
||||||
},
|
},
|
||||||
fork: () => child,
|
fork: () => child,
|
||||||
app: { isPackaged: true, getVersion: () => "2.9.0" },
|
app: {
|
||||||
|
isPackaged: true,
|
||||||
|
getVersion: () => "2.9.0",
|
||||||
|
getPath: () => "/user-data",
|
||||||
|
},
|
||||||
process: { env: { LOG_LEVEL: "error" } },
|
process: { env: { LOG_LEVEL: "error" } },
|
||||||
isDev: false,
|
isDev: false,
|
||||||
appRoot: "/app",
|
appRoot: "/app",
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { createRequire } from "node:module";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
const { isLaunchableFileName } = require("../electron/external-editor.cjs") as {
|
|
||||||
isLaunchableFileName: (fileName: unknown) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Electron external editor", () => {
|
|
||||||
it("refuses remote file names the OS would run instead of open", () => {
|
|
||||||
for (const name of [
|
|
||||||
"payload.exe",
|
|
||||||
"run.BAT",
|
|
||||||
"script.ps1",
|
|
||||||
"a.exe. ",
|
|
||||||
"a.cmd...",
|
|
||||||
"../../evil.vbs",
|
|
||||||
"start.command",
|
|
||||||
"launch.desktop",
|
|
||||||
"install.sh",
|
|
||||||
]) {
|
|
||||||
expect(isLaunchableFileName(name)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("opens ordinary text files", () => {
|
|
||||||
for (const name of [
|
|
||||||
"nginx.conf",
|
|
||||||
"README.md",
|
|
||||||
"Dockerfile",
|
|
||||||
".bashrc",
|
|
||||||
"config.json",
|
|
||||||
"index.ts",
|
|
||||||
"",
|
|
||||||
undefined,
|
|
||||||
]) {
|
|
||||||
expect(isLaunchableFileName(name)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -39,12 +39,24 @@ describe("Electron security boundary", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("launches only the editor saved from a file dialog", () => {
|
it("saves an external editor only if it was chosen in a file dialog", () => {
|
||||||
expect(main).not.toContain("fileData?.editorPath");
|
|
||||||
expect(main).toContain("readSetting(EXTERNAL_EDITOR_SETTING)");
|
|
||||||
expect(main).toContain("dialogChosenPaths.has(value)");
|
expect(main).toContain("dialogChosenPaths.has(value)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps local files, the local terminal and the external editor out of Electron", () => {
|
||||||
|
for (const channel of [
|
||||||
|
"open-external-editor",
|
||||||
|
"close-external-editor",
|
||||||
|
"local-terminal-start",
|
||||||
|
"local-fs:",
|
||||||
|
"local-transfer:",
|
||||||
|
"list-local-directory",
|
||||||
|
]) {
|
||||||
|
expect(main).not.toContain(channel);
|
||||||
|
expect(preload).not.toContain(channel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("scopes proxy headers and basic auth to the linked origin", () => {
|
it("scopes proxy headers and basic auth to the linked origin", () => {
|
||||||
expect(main).toContain('"sync-proxy-config"');
|
expect(main).toContain('"sync-proxy-config"');
|
||||||
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
expect(main).toContain("linkedServer.applyLinkHeaders(");
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Builds src/ui/settings/settings-search-index.ts: the i18n keys each core
|
|
||||||
* settings section shows, so the Settings search can match on any label in
|
|
||||||
* a section without mounting it. Run it after changing a settings section.
|
|
||||||
*
|
|
||||||
* --check fails when the committed file is out of date.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require("node:fs");
|
|
||||||
const path = require("node:path");
|
|
||||||
|
|
||||||
const ROOT = path.resolve(__dirname, "..");
|
|
||||||
const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar");
|
|
||||||
const OUT = path.join(
|
|
||||||
ROOT,
|
|
||||||
"src",
|
|
||||||
"ui",
|
|
||||||
"settings",
|
|
||||||
"settings-search-index.ts",
|
|
||||||
);
|
|
||||||
|
|
||||||
function flatKeys(obj, prefix = "") {
|
|
||||||
return Object.entries(obj).flatMap(([key, value]) =>
|
|
||||||
typeof value === "object"
|
|
||||||
? flatKeys(value, `${prefix}${key}.`)
|
|
||||||
: [`${prefix}${key}`],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const EN_KEYS = new Set(
|
|
||||||
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
|
|
||||||
);
|
|
||||||
|
|
||||||
// t("x", { count }) reads x_one or x_other, so index the plural key.
|
|
||||||
function realKey(key) {
|
|
||||||
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
|
|
||||||
? key
|
|
||||||
: `${key}_other`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function keysIn(text) {
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function read(file) {
|
|
||||||
return fs.readFileSync(path.join(SIDEBAR, file), "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The body of one exported or local function, by name. */
|
|
||||||
function functionBody(source, name) {
|
|
||||||
const start = source.search(new RegExp(`function ${name}\\b`));
|
|
||||||
if (start < 0) throw new Error(`No function ${name}`);
|
|
||||||
const next = source.slice(start + 1).search(/\n(export )?function \w+/);
|
|
||||||
return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Profile sections, split on their AccordionSection ids. */
|
|
||||||
function profileSections() {
|
|
||||||
const source = read("UserProfilePanel.tsx");
|
|
||||||
const lines = source.split("\n");
|
|
||||||
const starts = [];
|
|
||||||
lines.forEach((line, i) => {
|
|
||||||
if (/^\s{6}<AccordionSection\b/.test(line)) starts.push(i);
|
|
||||||
});
|
|
||||||
const out = {};
|
|
||||||
starts.forEach((start, k) => {
|
|
||||||
const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
|
|
||||||
const chunk = lines.slice(start, end).join("\n");
|
|
||||||
const id = (chunk.match(/\bid="([^"]+)"/) || [])[1];
|
|
||||||
if (id) out[id] = keysIn(chunk);
|
|
||||||
});
|
|
||||||
out.interface = [
|
|
||||||
...new Set([
|
|
||||||
...(out.interface ?? []),
|
|
||||||
...keysIn(read("InterfacePresetSettings.tsx")),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
out.security = [
|
|
||||||
...new Set([
|
|
||||||
...(out.security ?? []),
|
|
||||||
...keysIn(read("AuthEnrollmentSections.tsx")),
|
|
||||||
...keysIn(functionBody(source, "PasswordChangeSection")),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
out["api-keys"] = [
|
|
||||||
...new Set([
|
|
||||||
...(out["api-keys"] ?? []),
|
|
||||||
...keysIn(functionBody(source, "NewApiKeyDialog")),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
out.keybindings = keysIn(
|
|
||||||
fs.readFileSync(
|
|
||||||
path.join(ROOT, "src", "ui", "settings", "KeybindingsSettings.tsx"),
|
|
||||||
"utf8",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adminSections() {
|
|
||||||
const settings = read("AdminSettingsSections.tsx");
|
|
||||||
const management = read("AdminManagementSections.tsx");
|
|
||||||
return {
|
|
||||||
"admin-general": [
|
|
||||||
...new Set([
|
|
||||||
...keysIn(functionBody(settings, "AdminGeneralSettingsSection")),
|
|
||||||
...keysIn(read("AdminPluginDeveloperMode.tsx")),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
"admin-users": [
|
|
||||||
...new Set([
|
|
||||||
...keysIn(functionBody(management, "AdminUsersSection")),
|
|
||||||
...keysIn(read("AdminUserDialogs.tsx")),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
"admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")),
|
|
||||||
"admin-roles": keysIn(functionBody(management, "AdminRolesSection")),
|
|
||||||
"admin-host-defaults": [
|
|
||||||
...new Set([
|
|
||||||
...keysIn(functionBody(settings, "AdminHostDefaultsSection")),
|
|
||||||
...keysIn(read("AdminHostTags.tsx")),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
"admin-branding": keysIn(read("AdminBrandingSection.tsx")),
|
|
||||||
"admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")),
|
|
||||||
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
|
|
||||||
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
|
|
||||||
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
|
|
||||||
updates: [
|
|
||||||
...new Set(
|
|
||||||
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
|
|
||||||
keysIn(
|
|
||||||
fs.readFileSync(
|
|
||||||
path.join(ROOT, "src", "ui", "settings", file),
|
|
||||||
"utf8",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
const index = { ...profileSections(), ...adminSections() };
|
|
||||||
const body = Object.entries(index)
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(
|
|
||||||
([id, keys]) =>
|
|
||||||
` ${JSON.stringify(id)}: [\n${keys
|
|
||||||
.sort()
|
|
||||||
.map((key) => ` ${JSON.stringify(key)},`)
|
|
||||||
.join("\n")}\n ],`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {\n${body}\n};\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = render();
|
|
||||||
if (process.argv.includes("--check")) {
|
|
||||||
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, "utf8") : "";
|
|
||||||
if (current !== next) {
|
|
||||||
console.error(
|
|
||||||
"src/ui/settings/settings-search-index.ts is out of date. Run: node scripts/generate-settings-search-index.cjs",
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(OUT, next);
|
|
||||||
console.log(`Wrote ${path.relative(ROOT, OUT)}`);
|
|
||||||
}
|
|
||||||
@@ -15,11 +15,17 @@
|
|||||||
* enabledByEnv counts the plugin as recommended when any of those
|
* enabledByEnv counts the plugin as recommended when any of those
|
||||||
* environment variables is set, for a server configured before first boot.
|
* environment variables is set, for a server configured before first boot.
|
||||||
* Plugins without it start unchecked.
|
* Plugins without it start unchecked.
|
||||||
|
*
|
||||||
|
* "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
|
||||||
|
* uninstalled, only swapped for another look.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const crypto = require("node:crypto");
|
const crypto = require("node:crypto");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
const semver = require("semver");
|
||||||
|
|
||||||
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
||||||
@@ -110,6 +116,12 @@ function parseBundledPlugins(raw) {
|
|||||||
}
|
}
|
||||||
const before = problems.length;
|
const before = problems.length;
|
||||||
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
|
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
|
||||||
|
if (
|
||||||
|
entry.defaultLook !== undefined &&
|
||||||
|
typeof entry.defaultLook !== "boolean"
|
||||||
|
) {
|
||||||
|
problems.push(`${entry.id} defaultLook must be a boolean`);
|
||||||
|
}
|
||||||
if (problems.length > before) continue;
|
if (problems.length > before) continue;
|
||||||
plugins.push({
|
plugins.push({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
@@ -118,6 +130,7 @@ function parseBundledPlugins(raw) {
|
|||||||
path: hasPath ? entry.path : null,
|
path: hasPath ? entry.path : null,
|
||||||
sha256: entry.sha256,
|
sha256: entry.sha256,
|
||||||
onboarding,
|
onboarding,
|
||||||
|
defaultLook: entry.defaultLook === true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||||
@@ -218,8 +231,12 @@ function buildBundledIndex(plugins, stagedIds) {
|
|||||||
const staged = new Set(stagedIds);
|
const staged = new Set(stagedIds);
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
|
if (!staged.has(plugin.id)) continue;
|
||||||
out[plugin.id] = { onboarding: plugin.onboarding };
|
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
||||||
|
out[plugin.id] = {
|
||||||
|
...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}),
|
||||||
|
...(plugin.defaultLook ? { defaultLook: true } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { version: 1, plugins: out };
|
return { version: 1, plugins: out };
|
||||||
}
|
}
|
||||||
@@ -235,15 +252,22 @@ function pinsFromIndex(index, ids, current = []) {
|
|||||||
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
const currentById = new Map(current.map((entry) => [entry.id, entry]));
|
||||||
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
||||||
return [...wanted].sort().map((id) => {
|
return [...wanted].sort().map((id) => {
|
||||||
const latest = byId.get(id)?.versions?.[0];
|
const listed = byId.get(id);
|
||||||
|
// gated holds releases that need a newer Termix than old builds check for.
|
||||||
|
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
|
||||||
|
.filter((entry) => semver.valid(entry.version))
|
||||||
|
.filter((entry) => !semver.prerelease(entry.version))
|
||||||
|
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
if (!latest) throw new Error(`${id} is not in the registry index`);
|
||||||
const onboarding = currentById.get(id)?.onboarding;
|
const onboarding = currentById.get(id)?.onboarding;
|
||||||
|
const defaultLook = currentById.get(id)?.defaultLook === true;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
source: "tmxplug",
|
source: "tmxplug",
|
||||||
url: latest.url,
|
url: latest.url,
|
||||||
sha256: latest.sha256,
|
sha256: latest.sha256,
|
||||||
...(onboarding ? { onboarding } : {}),
|
...(onboarding ? { onboarding } : {}),
|
||||||
|
...(defaultLook ? { defaultLook } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -357,9 +381,26 @@ function rebuildLocalPlugins(dir, ids, run) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Copies a local plugin build into the staging folder. */
|
/** Copies a local plugin build into the staging folder. */
|
||||||
|
/** Theme CSS files the repo's manifest declares, which ship with it too. */
|
||||||
|
function localThemeFiles(repo) {
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(repo, "manifest.json"), "utf8"),
|
||||||
|
);
|
||||||
|
const themes = manifest?.contributes?.themes;
|
||||||
|
return Array.isArray(themes)
|
||||||
|
? themes
|
||||||
|
.map((theme) => theme?.css)
|
||||||
|
.filter((css) => typeof css === "string")
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function copyLocalBuild(repo, destination) {
|
function copyLocalBuild(repo, destination) {
|
||||||
fs.mkdirSync(destination, { recursive: true });
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
for (const entry of LOCAL_FILES) {
|
for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) {
|
||||||
const from = path.join(repo, entry);
|
const from = path.join(repo, entry);
|
||||||
if (fs.existsSync(from)) {
|
if (fs.existsSync(from)) {
|
||||||
fs.cpSync(from, path.join(destination, entry), { recursive: true });
|
fs.cpSync(from, path.join(destination, entry), { recursive: true });
|
||||||
|
|||||||
@@ -35,6 +35,42 @@ function fixturePlugin(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("termix-plugin build", () => {
|
describe("termix-plugin build", () => {
|
||||||
|
it("builds a theme only plugin with an empty backend and no source", async () => {
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
|
||||||
|
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "theme-fixture",
|
||||||
|
capabilities: ["ui:theme"],
|
||||||
|
contributes: { themes: [{ id: "paper" }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await build({ cwd: dir });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fs.readFileSync(path.join(dir, "dist", "backend.js"), "utf8"),
|
||||||
|
).toContain("export function activate()");
|
||||||
|
expect(fs.existsSync(path.join(dir, "dist", "frontend.js"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still needs a backend entry once a plugin holds more than themes", async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
|
||||||
|
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "mixed-fixture",
|
||||||
|
capabilities: ["ui:theme", "ui:layout"],
|
||||||
|
contributes: { themes: [{ id: "paper" }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(build({ cwd: dir })).rejects.toThrow("no backend entry");
|
||||||
|
});
|
||||||
|
|
||||||
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
|
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
|
||||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
const dir = fixturePlugin();
|
const dir = fixturePlugin();
|
||||||
|
|||||||
@@ -142,3 +142,29 @@ describe("termix-plugin pack", () => {
|
|||||||
expect(fs.readdirSync(out)).toEqual([]);
|
expect(fs.readdirSync(out)).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("termix-plugin pack themes", () => {
|
||||||
|
it("ships the CSS files the manifest's themes declare", async () => {
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const dir = fixturePlugin({
|
||||||
|
capabilities: ["ui:theme"],
|
||||||
|
contributes: {
|
||||||
|
themes: [
|
||||||
|
{
|
||||||
|
id: "paper",
|
||||||
|
label: "Paper",
|
||||||
|
mode: "light",
|
||||||
|
css: "styles/paper.css",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n");
|
||||||
|
writeFile(dir, "styles/draft.css", ".draft {}\n");
|
||||||
|
const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] });
|
||||||
|
const names = await entries(file);
|
||||||
|
expect(names).toContain("styles/paper.css");
|
||||||
|
expect(names).not.toContain("styles/draft.css");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,3 +49,98 @@ describe("termix-plugin validate migrations", () => {
|
|||||||
expect(output).not.toMatch(/migrations\/\w+ is missing/);
|
expect(output).not.toMatch(/migrations\/\w+ is missing/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("termix-plugin validate themes", () => {
|
||||||
|
function themed(css: string | null) {
|
||||||
|
const root = plugin([]);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "manifest.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
id: "fixture",
|
||||||
|
version: "1.0.0",
|
||||||
|
capabilities: ["ui:theme"],
|
||||||
|
contributes: {
|
||||||
|
themes: [
|
||||||
|
{
|
||||||
|
id: "paper",
|
||||||
|
label: "Paper",
|
||||||
|
mode: "light",
|
||||||
|
css: "themes/paper.css",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (css !== null) {
|
||||||
|
fs.mkdirSync(path.join(root, "themes"));
|
||||||
|
fs.writeFileSync(path.join(root, "themes", "paper.css"), css);
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("reports a missing theme file", async () => {
|
||||||
|
expect(await problems(themed(null))).toContain(
|
||||||
|
"theme paper: themes/paper.css does not exist",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports theme CSS that reaches outside or leaves its class", async () => {
|
||||||
|
const output = await problems(
|
||||||
|
themed('@import "x.css"; body { color: red }'),
|
||||||
|
);
|
||||||
|
expect(output).toContain("theme paper: @import is not allowed in a theme");
|
||||||
|
expect(output).toContain(
|
||||||
|
'theme paper: Selector "body" must start with .paper',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about CSS that is fine", async () => {
|
||||||
|
const output = await problems(themed(".paper { --background: #fff; }"));
|
||||||
|
expect(output).not.toContain("theme paper");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("termix-plugin validate layouts and capabilities", () => {
|
||||||
|
function withManifest(extra: Record<string, unknown>) {
|
||||||
|
const root = plugin([]);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(root, "manifest.json"),
|
||||||
|
JSON.stringify({ id: "fixture", version: "1.0.0", ...extra }),
|
||||||
|
);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("flags ui:theme with no themes and ui:layout with no layouts", async () => {
|
||||||
|
const output = await problems(
|
||||||
|
withManifest({ capabilities: ["ui:theme", "ui:layout"] }),
|
||||||
|
);
|
||||||
|
expect(output).toContain(
|
||||||
|
'"ui:theme" is declared but contributes.themes is empty',
|
||||||
|
);
|
||||||
|
expect(output).toContain(
|
||||||
|
'"ui:layout" is declared but contributes.layouts is empty',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a layout plugin with no frontend source", async () => {
|
||||||
|
const output = await problems(
|
||||||
|
withManifest({
|
||||||
|
capabilities: ["ui:layout"],
|
||||||
|
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(output).toContain("contributes.layouts needs a frontend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a layout plugin that has a frontend entry", async () => {
|
||||||
|
const root = withManifest({
|
||||||
|
capabilities: ["ui:layout"],
|
||||||
|
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.join(root, "src", "frontend"), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, "src", "frontend", "index.tsx"), "");
|
||||||
|
const output = await problems(root);
|
||||||
|
expect(output).not.toContain("contributes.layouts needs a frontend");
|
||||||
|
expect(output).not.toContain("is declared but");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,7 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
|||||||
import rbacRoutes from "./routes/rbac.js";
|
import rbacRoutes from "./routes/rbac.js";
|
||||||
import openTabsRoutes from "./routes/open-tabs.js";
|
import openTabsRoutes from "./routes/open-tabs.js";
|
||||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||||
|
import lookRoutes from "./routes/look.js";
|
||||||
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||||
@@ -1616,6 +1617,7 @@ app.use("/credentials", credentialsRoutes);
|
|||||||
app.use("/ssh-auth", sshAuthRoutes);
|
app.use("/ssh-auth", sshAuthRoutes);
|
||||||
app.use("/rbac", rbacRoutes);
|
app.use("/rbac", rbacRoutes);
|
||||||
app.use("/open-tabs", openTabsRoutes);
|
app.use("/open-tabs", openTabsRoutes);
|
||||||
|
app.use("/user-preferences/look", lookRoutes);
|
||||||
app.use("/user-preferences", userPreferencesRoutes);
|
app.use("/user-preferences", userPreferencesRoutes);
|
||||||
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||||
|
|||||||
@@ -370,17 +370,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
host_id INTEGER NOT NULL,
|
|
||||||
host_name TEXT,
|
|
||||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS host_access (
|
CREATE TABLE IF NOT EXISTS host_access (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
host_id INTEGER NOT NULL,
|
host_id INTEGER NOT NULL,
|
||||||
@@ -985,6 +974,8 @@ const migrateSchema = () => {
|
|||||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
||||||
|
addColumnIfNotExists("user_preferences", "layout_id", "TEXT");
|
||||||
|
addColumnIfNotExists("user_preferences", "theme_id", "TEXT");
|
||||||
|
|
||||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
|
||||||
|
|||||||
@@ -98,12 +98,7 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
|
|||||||
columns: "user_id",
|
columns: "user_id",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Per-user history and file manager surfaces.
|
// Per-user open tabs.
|
||||||
{
|
|
||||||
name: "idx_recent_activity_user_ts",
|
|
||||||
table: "recent_activity",
|
|
||||||
columns: "user_id, timestamp",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "idx_user_open_tabs_user_id",
|
name: "idx_user_open_tabs_user_id",
|
||||||
table: "user_open_tabs",
|
table: "user_open_tabs",
|
||||||
|
|||||||
@@ -414,28 +414,6 @@ export const hostDefaults = mysqlTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const recentActivity = mysqlTable(
|
|
||||||
"recent_activity",
|
|
||||||
{
|
|
||||||
id: int("id").autoincrement().primaryKey(),
|
|
||||||
userId: varchar("user_id", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
type: text("type").notNull(),
|
|
||||||
hostId: int("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
hostName: text("host_name"),
|
|
||||||
timestamp: varchar("timestamp", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
|
||||||
},
|
|
||||||
// Always read newest-first for one user, so timestamp follows user_id.
|
|
||||||
(table) => [
|
|
||||||
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const hostAccess = mysqlTable(
|
export const hostAccess = mysqlTable(
|
||||||
"host_access",
|
"host_access",
|
||||||
{
|
{
|
||||||
@@ -709,6 +687,8 @@ export const userPreferences = mysqlTable("user_preferences", {
|
|||||||
compactHostView: boolean("compact_host_view"),
|
compactHostView: boolean("compact_host_view"),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
customKeybindings: text("custom_keybindings"),
|
customKeybindings: text("custom_keybindings"),
|
||||||
|
layoutId: text("layout_id"),
|
||||||
|
themeId: text("theme_id"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
|||||||
@@ -414,28 +414,6 @@ export const hostDefaults = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const recentActivity = pgTable(
|
|
||||||
"recent_activity",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
userId: varchar("user_id", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
type: text("type").notNull(),
|
|
||||||
hostId: integer("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
hostName: text("host_name"),
|
|
||||||
timestamp: varchar("timestamp", { length: 255 })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
},
|
|
||||||
// Always read newest-first for one user, so timestamp follows user_id.
|
|
||||||
(table) => [
|
|
||||||
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const hostAccess = pgTable(
|
export const hostAccess = pgTable(
|
||||||
"host_access",
|
"host_access",
|
||||||
{
|
{
|
||||||
@@ -709,6 +687,8 @@ export const userPreferences = pgTable("user_preferences", {
|
|||||||
compactHostView: boolean("compact_host_view"),
|
compactHostView: boolean("compact_host_view"),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
customKeybindings: text("custom_keybindings"),
|
customKeybindings: text("custom_keybindings"),
|
||||||
|
layoutId: text("layout_id"),
|
||||||
|
themeId: text("theme_id"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -404,28 +404,6 @@ export const hostDefaults = sqliteTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const recentActivity = sqliteTable(
|
|
||||||
"recent_activity",
|
|
||||||
{
|
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
||||||
userId: text("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
type: text("type").notNull(),
|
|
||||||
hostId: integer("host_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => hosts.id, { onDelete: "cascade" }),
|
|
||||||
hostName: text("host_name"),
|
|
||||||
timestamp: text("timestamp")
|
|
||||||
.notNull()
|
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
},
|
|
||||||
// Always read newest-first for one user, so timestamp follows user_id.
|
|
||||||
(table) => [
|
|
||||||
index("idx_recent_activity_user_ts").on(table.userId, table.timestamp),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
export const hostAccess = sqliteTable(
|
export const hostAccess = sqliteTable(
|
||||||
"host_access",
|
"host_access",
|
||||||
{
|
{
|
||||||
@@ -703,6 +681,8 @@ export const userPreferences = sqliteTable("user_preferences", {
|
|||||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||||
statusColorScheme: text("status_color_scheme"),
|
statusColorScheme: text("status_color_scheme"),
|
||||||
customKeybindings: text("custom_keybindings"),
|
customKeybindings: text("custom_keybindings"),
|
||||||
|
layoutId: text("layout_id"),
|
||||||
|
themeId: text("theme_id"),
|
||||||
updatedAt: text("updated_at")
|
updatedAt: text("updated_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repos
|
|||||||
import { UserAuthRepository } from "./user-auth-repository.js";
|
import { UserAuthRepository } from "./user-auth-repository.js";
|
||||||
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
import { RbacAccessRepository } from "./rbac-access-repository.js";
|
||||||
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
|
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
|
||||||
import { RecentActivityRepository } from "./recent-activity-repository.js";
|
|
||||||
import { RoleRepository } from "./role-repository.js";
|
import { RoleRepository } from "./role-repository.js";
|
||||||
import { SessionRepository } from "./session-repository.js";
|
import { SessionRepository } from "./session-repository.js";
|
||||||
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
import { CredentialAccessRepository } from "./credential-access-repository.js";
|
||||||
@@ -269,13 +268,6 @@ export function createCurrentRbacAccessRepository(): RbacAccessRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCurrentRecentActivityRepository(): RecentActivityRepository {
|
|
||||||
return new RecentActivityRepository(
|
|
||||||
createCurrentRepositoryContext(),
|
|
||||||
createCurrentRepositoryWriteHook("recent_activity_repository_write"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCurrentRoleRepository(): RoleRepository {
|
export function createCurrentRoleRepository(): RoleRepository {
|
||||||
return new RoleRepository(
|
return new RoleRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import { desc, eq, inArray } from "drizzle-orm";
|
|
||||||
import { recentActivity } from "../db/schema.js";
|
|
||||||
import type { DatabaseContext } from "./database-context.js";
|
|
||||||
import { rowsAffected } from "./mutation-result.js";
|
|
||||||
import { insertReturning } from "./returning.js";
|
|
||||||
|
|
||||||
export type RecentActivityRecord = typeof recentActivity.$inferSelect;
|
|
||||||
export type NewRecentActivityRecord = typeof recentActivity.$inferInsert;
|
|
||||||
|
|
||||||
export class RecentActivityRepository {
|
|
||||||
constructor(
|
|
||||||
private readonly context: DatabaseContext,
|
|
||||||
private readonly onWrite?: () => void | Promise<void>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listByUserId(
|
|
||||||
userId: string,
|
|
||||||
limit: number,
|
|
||||||
): Promise<RecentActivityRecord[]> {
|
|
||||||
return this.context.drizzle
|
|
||||||
.select()
|
|
||||||
.from(recentActivity)
|
|
||||||
.where(eq(recentActivity.userId, userId))
|
|
||||||
.orderBy(desc(recentActivity.timestamp))
|
|
||||||
.limit(limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(
|
|
||||||
activity: NewRecentActivityRecord,
|
|
||||||
): Promise<RecentActivityRecord> {
|
|
||||||
const rows = await insertReturning(this.context, recentActivity, activity);
|
|
||||||
|
|
||||||
await this.afterWrite();
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async trimUserActivity(userId: string, keepCount: number): Promise<number> {
|
|
||||||
const rows = await this.context.drizzle
|
|
||||||
.select({ id: recentActivity.id })
|
|
||||||
.from(recentActivity)
|
|
||||||
.where(eq(recentActivity.userId, userId))
|
|
||||||
.orderBy(desc(recentActivity.timestamp));
|
|
||||||
|
|
||||||
const idsToDelete = rows
|
|
||||||
.slice(keepCount)
|
|
||||||
.map((row) => row.id)
|
|
||||||
.filter((id) => typeof id === "number");
|
|
||||||
|
|
||||||
if (idsToDelete.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(recentActivity)
|
|
||||||
.where(inArray(recentActivity.id, idsToDelete));
|
|
||||||
|
|
||||||
if (rowsAffected(result) > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowsAffected(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByUserId(userId: string): Promise<number> {
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(recentActivity)
|
|
||||||
.where(eq(recentActivity.userId, userId));
|
|
||||||
|
|
||||||
if (rowsAffected(result) > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowsAffected(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByHostId(hostId: number): Promise<number> {
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(recentActivity)
|
|
||||||
.where(eq(recentActivity.hostId, hostId));
|
|
||||||
|
|
||||||
if (rowsAffected(result) > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowsAffected(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteByHostIds(hostIds: number[]): Promise<number> {
|
|
||||||
if (hostIds.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.context.drizzle
|
|
||||||
.delete(recentActivity)
|
|
||||||
.where(inArray(recentActivity.hostId, hostIds));
|
|
||||||
|
|
||||||
if (rowsAffected(result) > 0) {
|
|
||||||
await this.afterWrite();
|
|
||||||
}
|
|
||||||
|
|
||||||
return rowsAffected(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async afterWrite(): Promise<void> {
|
|
||||||
await this.onWrite?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,12 +5,6 @@ import {
|
|||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { apiLogger } from "../../utils/logger.js";
|
import { apiLogger } from "../../utils/logger.js";
|
||||||
import { exportFilename, toCsv, toNdjson } from "../../utils/audit-export.js";
|
|
||||||
import {
|
|
||||||
logAudit,
|
|
||||||
getAuditUsername,
|
|
||||||
getRequestMeta,
|
|
||||||
} from "../../utils/audit-logger.js";
|
|
||||||
|
|
||||||
async function isAdminUser(userId: string | undefined): Promise<boolean> {
|
async function isAdminUser(userId: string | undefined): Promise<boolean> {
|
||||||
if (!userId) return false;
|
if (!userId) return false;
|
||||||
@@ -138,125 +132,4 @@ export function registerAuditLogRoutes(
|
|||||||
.json({ error: "Failed to fetch audit log actions" });
|
.json({ error: "Failed to fetch audit log actions" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /audit-logs/export:
|
|
||||||
* get:
|
|
||||||
* summary: Export audit logs
|
|
||||||
* description: Streams the full filtered result set as CSV or NDJSON. Accepts the same filters as GET /audit-logs. Admin only. The export is itself audited.
|
|
||||||
* tags:
|
|
||||||
* - Audit
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: format
|
|
||||||
* schema: { type: string, enum: [csv, ndjson], default: csv }
|
|
||||||
* - in: query
|
|
||||||
* name: userId
|
|
||||||
* schema: { type: string }
|
|
||||||
* - in: query
|
|
||||||
* name: action
|
|
||||||
* schema: { type: string }
|
|
||||||
* - in: query
|
|
||||||
* name: resourceType
|
|
||||||
* schema: { type: string }
|
|
||||||
* - in: query
|
|
||||||
* name: success
|
|
||||||
* schema: { type: string, enum: [true, false] }
|
|
||||||
* - in: query
|
|
||||||
* name: startDate
|
|
||||||
* schema: { type: string, format: date-time }
|
|
||||||
* - in: query
|
|
||||||
* name: endDate
|
|
||||||
* schema: { type: string, format: date-time }
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Audit log file.
|
|
||||||
* 403:
|
|
||||||
* description: Not authorized.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to export audit logs.
|
|
||||||
*/
|
|
||||||
router.get("/audit-logs/export", authenticateJWT, async (req, res) => {
|
|
||||||
const authReq = req as AuthenticatedRequest;
|
|
||||||
try {
|
|
||||||
if (!(await isAdminUser(authReq.userId))) {
|
|
||||||
return res.status(403).json({ error: "Not authorized" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const format = req.query.format === "ndjson" ? "ndjson" : "csv";
|
|
||||||
const { userId, action, resourceType, success, startDate, endDate } =
|
|
||||||
req.query as Record<string, string | undefined>;
|
|
||||||
const filters = {
|
|
||||||
userId,
|
|
||||||
action,
|
|
||||||
resourceType,
|
|
||||||
success:
|
|
||||||
success !== undefined && success !== ""
|
|
||||||
? success === "true"
|
|
||||||
: undefined,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.setHeader(
|
|
||||||
"Content-Type",
|
|
||||||
format === "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson",
|
|
||||||
);
|
|
||||||
res.setHeader(
|
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="${exportFilename(format, new Date())}"`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Streamed in batches: an export is unbounded by definition, and the
|
|
||||||
// whole point is to move data out before retention drops it.
|
|
||||||
const BATCH = 500;
|
|
||||||
let offset = 0;
|
|
||||||
let exported = 0;
|
|
||||||
|
|
||||||
for (;;) {
|
|
||||||
const rows = await createCurrentAuditLogRepository().listForExport({
|
|
||||||
filters,
|
|
||||||
limit: BATCH,
|
|
||||||
offset,
|
|
||||||
});
|
|
||||||
if (rows.length === 0) break;
|
|
||||||
|
|
||||||
if (format === "csv") {
|
|
||||||
// Header only on the first batch.
|
|
||||||
const chunk = toCsv(rows);
|
|
||||||
res.write(
|
|
||||||
offset === 0 ? chunk : chunk.slice(chunk.indexOf("\n") + 1),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
res.write(toNdjson(rows));
|
|
||||||
}
|
|
||||||
|
|
||||||
exported += rows.length;
|
|
||||||
offset += rows.length;
|
|
||||||
if (rows.length < BATCH) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.end();
|
|
||||||
|
|
||||||
// Reading the whole trail is itself worth recording.
|
|
||||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
||||||
void logAudit({
|
|
||||||
userId: authReq.userId!,
|
|
||||||
username: await getAuditUsername(authReq.userId!),
|
|
||||||
action: "export_audit_logs",
|
|
||||||
resourceType: "audit_log",
|
|
||||||
details: JSON.stringify({ format, exported, filters }),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
apiLogger.error("Failed to export audit logs", err);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
return res.status(500).json({ error: "Failed to export audit logs" });
|
|
||||||
}
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -1,98 +1,9 @@
|
|||||||
import type { Request, RequestHandler, Response, Router } from "express";
|
import type { Request, Response, Router } from "express";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
||||||
import {
|
import { resolveBrandingSettings } from "./branding-settings.js";
|
||||||
BRANDING_KEYS,
|
|
||||||
parseBrandingAppName,
|
|
||||||
parseBrandingLogoDataUrl,
|
|
||||||
parseBrandingTagline,
|
|
||||||
resolveBrandingSettings,
|
|
||||||
} from "./branding-settings.js";
|
|
||||||
|
|
||||||
const PATCHABLE_FIELDS = ["appName", "tagline", "logo"] as const;
|
export function registerBrandingRoutes(router: Router): void {
|
||||||
type PatchableField = (typeof PATCHABLE_FIELDS)[number];
|
|
||||||
|
|
||||||
function invalidField(res: Response, field: string): void {
|
|
||||||
res.status(400).json({
|
|
||||||
error: `Invalid value for ${field}`,
|
|
||||||
code: "BRANDING_SETTINGS_INVALID",
|
|
||||||
field,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a PATCH body and returns the settings-table writes/deletes it
|
|
||||||
* implies, or null after the response has already been failed with a 400.
|
|
||||||
* `logo: null` clears the custom logo (falls back to the bundled default).
|
|
||||||
*/
|
|
||||||
async function buildBrandingWrites(
|
|
||||||
body: unknown,
|
|
||||||
res: Response,
|
|
||||||
): Promise<{
|
|
||||||
writes: Array<{ key: string; value: string }>;
|
|
||||||
deletes: string[];
|
|
||||||
} | null> {
|
|
||||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
||||||
res.status(400).json({
|
|
||||||
error: "Invalid request body",
|
|
||||||
code: "BRANDING_SETTINGS_INVALID",
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of Object.keys(body)) {
|
|
||||||
if (!(PATCHABLE_FIELDS as readonly string[]).includes(field)) {
|
|
||||||
res.status(400).json({
|
|
||||||
error: `Unknown setting field: ${field}`,
|
|
||||||
code: "BRANDING_SETTINGS_UNKNOWN_FIELD",
|
|
||||||
field,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = body as Partial<Record<PatchableField, unknown>>;
|
|
||||||
const writes: Array<{ key: string; value: string }> = [];
|
|
||||||
const deletes: string[] = [];
|
|
||||||
|
|
||||||
if (input.appName !== undefined) {
|
|
||||||
const appName = parseBrandingAppName(input.appName);
|
|
||||||
if (appName === null) {
|
|
||||||
invalidField(res, "appName");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
writes.push({ key: BRANDING_KEYS.appName, value: appName });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.tagline !== undefined) {
|
|
||||||
const tagline = parseBrandingTagline(input.tagline);
|
|
||||||
if (tagline === null) {
|
|
||||||
invalidField(res, "tagline");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
writes.push({ key: BRANDING_KEYS.tagline, value: tagline });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.logo !== undefined) {
|
|
||||||
if (input.logo === null) {
|
|
||||||
deletes.push(BRANDING_KEYS.logo);
|
|
||||||
} else {
|
|
||||||
const logo = await parseBrandingLogoDataUrl(input.logo);
|
|
||||||
if (logo === null) {
|
|
||||||
invalidField(res, "logo");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
writes.push({ key: BRANDING_KEYS.logo, value: logo });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { writes, deletes };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerBrandingRoutes(
|
|
||||||
router: Router,
|
|
||||||
requireAdmin: RequestHandler,
|
|
||||||
): void {
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /users/branding:
|
* /users/branding:
|
||||||
@@ -118,48 +29,4 @@ export function registerBrandingRoutes(
|
|||||||
res.status(500).json({ error: "Failed to load branding settings" });
|
res.status(500).json({ error: "Failed to load branding settings" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /users/branding:
|
|
||||||
* patch:
|
|
||||||
* summary: Update white-label branding (admin only)
|
|
||||||
* description: Persists a partial update to appName, tagline and/or logo (a PNG/JPEG/WEBP data URL; pass null to clear the logo). Invalid values are rejected with a 400.
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Updated effective branding settings.
|
|
||||||
* 400:
|
|
||||||
* description: Invalid or unknown setting field.
|
|
||||||
* 401:
|
|
||||||
* description: Not authenticated.
|
|
||||||
* 403:
|
|
||||||
* description: Admin access required.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to save branding settings.
|
|
||||||
*/
|
|
||||||
router.patch(
|
|
||||||
"/branding",
|
|
||||||
requireAdmin,
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const result = await buildBrandingWrites(req.body, res);
|
|
||||||
if (result === null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const settings = createCurrentSettingsRepository();
|
|
||||||
if (result.writes.length > 0) {
|
|
||||||
await settings.setMany(result.writes);
|
|
||||||
}
|
|
||||||
for (const key of result.deletes) {
|
|
||||||
await settings.delete(key);
|
|
||||||
}
|
|
||||||
const resolved = await resolveBrandingSettings(settings);
|
|
||||||
res.json(resolved);
|
|
||||||
} catch (err) {
|
|
||||||
databaseLogger.error("Failed to save branding settings", err);
|
|
||||||
res.status(500).json({ error: "Failed to save branding settings" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import sharp from "sharp";
|
import { consumeByName } from "../../plugins/registry.js";
|
||||||
import { databaseLogger } from "../../utils/logger.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* White-label branding: admin-configurable app name, logo and tagline shown
|
* White-label branding: admin-configurable app name, logo and tagline shown
|
||||||
@@ -20,25 +19,33 @@ export const BRANDING_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const BRANDING_DEFAULT_APP_NAME = "Termix";
|
export const BRANDING_DEFAULT_APP_NAME = "Termix";
|
||||||
const APP_NAME_MAX_LENGTH = 60;
|
|
||||||
const TAGLINE_MAX_LENGTH = 160;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cap applies to the decoded image bytes, not the base64 text. The settings
|
|
||||||
* table is fully cached in memory per backend process (see
|
|
||||||
* settings-cache.ts), so an oversized logo would be permanently resident RAM
|
|
||||||
* on every instance, not just a one-time upload cost.
|
|
||||||
*/
|
|
||||||
export const BRANDING_LOGO_MAX_BYTES = 750 * 1024;
|
|
||||||
const BRANDING_LOGO_MAX_INPUT_PIXELS = 20_000_000;
|
|
||||||
|
|
||||||
interface SettingSource {
|
interface SettingSource {
|
||||||
get(key: string): Promise<string | null>;
|
get(key: string): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const BRANDING_PROVIDER_NAME = "brandingProvider";
|
||||||
|
|
||||||
|
/** A plugin supplies branding by providing "<its id>.brandingProvider". */
|
||||||
|
export type BrandingProvider = () =>
|
||||||
|
Promise<BrandingSettings> | BrandingSettings;
|
||||||
|
|
||||||
export async function resolveBrandingSettings(
|
export async function resolveBrandingSettings(
|
||||||
settings: SettingSource,
|
settings: SettingSource,
|
||||||
): Promise<BrandingSettings> {
|
): Promise<BrandingSettings> {
|
||||||
|
const provider = consumeByName<BrandingProvider>(BRANDING_PROVIDER_NAME);
|
||||||
|
if (provider) {
|
||||||
|
try {
|
||||||
|
const provided = await provider();
|
||||||
|
return {
|
||||||
|
appName: provided.appName?.trim() || BRANDING_DEFAULT_APP_NAME,
|
||||||
|
tagline: provided.tagline ?? "",
|
||||||
|
logo: provided.logo || null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// A broken provider falls back to the stored settings.
|
||||||
|
}
|
||||||
|
}
|
||||||
const [appName, tagline, logo] = await Promise.all([
|
const [appName, tagline, logo] = await Promise.all([
|
||||||
settings.get(BRANDING_KEYS.appName),
|
settings.get(BRANDING_KEYS.appName),
|
||||||
settings.get(BRANDING_KEYS.tagline),
|
settings.get(BRANDING_KEYS.tagline),
|
||||||
@@ -53,74 +60,3 @@ export async function resolveBrandingSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Trimmed, 1-60 chars. Falls back to the default when cleared. */
|
/** Trimmed, 1-60 chars. Falls back to the default when cleared. */
|
||||||
export function parseBrandingAppName(value: unknown): string | null {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed.length === 0) return BRANDING_DEFAULT_APP_NAME;
|
|
||||||
if (trimmed.length > APP_NAME_MAX_LENGTH) return null;
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Trimmed, 0-160 chars. An empty string means "use the built-in default". */
|
|
||||||
export function parseBrandingTagline(value: unknown): string | null {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed.length > TAGLINE_MAX_LENGTH) return null;
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates and re-encodes an uploaded logo. Only raster formats are
|
|
||||||
* accepted (no SVG), sidestepping SVG-script XSS entirely rather than trying
|
|
||||||
* to sanitize markup. The image is always re-encoded to PNG through sharp:
|
|
||||||
* this both proves the payload is a genuine image (not a disguised file
|
|
||||||
* behind a spoofed data-URL prefix) and strips any embedded metadata.
|
|
||||||
*
|
|
||||||
* Returns null on any validation failure; the caller is expected to reject
|
|
||||||
* the request with a 400 without echoing back details of what was rejected.
|
|
||||||
*/
|
|
||||||
export async function parseBrandingLogoDataUrl(
|
|
||||||
value: unknown,
|
|
||||||
): Promise<string | null> {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
const match = value.match(
|
|
||||||
/^data:image\/(png|jpe?g|webp);base64,([a-zA-Z0-9+/]+=*)$/,
|
|
||||||
);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const [, , base64] = match;
|
|
||||||
let buffer: Buffer;
|
|
||||||
try {
|
|
||||||
buffer = Buffer.from(base64, "base64");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (buffer.length === 0 || buffer.length > BRANDING_LOGO_MAX_BYTES * 4) {
|
|
||||||
// Reject wildly oversized input before decoding it with sharp; the final
|
|
||||||
// size check happens below on the re-encoded PNG.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const source = sharp(buffer, {
|
|
||||||
failOn: "error",
|
|
||||||
limitInputPixels: BRANDING_LOGO_MAX_INPUT_PIXELS,
|
|
||||||
});
|
|
||||||
const metadata = await source.metadata();
|
|
||||||
if (
|
|
||||||
!metadata.format ||
|
|
||||||
!["png", "jpeg", "webp"].includes(metadata.format)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalized = await source.rotate().png().toBuffer();
|
|
||||||
if (normalized.length > BRANDING_LOGO_MAX_BYTES) return null;
|
|
||||||
return `data:image/png;base64,${normalized.toString("base64")}`;
|
|
||||||
} catch (err) {
|
|
||||||
databaseLogger.warn("Rejected invalid branding logo upload", {
|
|
||||||
operation: "branding_logo_invalid",
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,436 +0,0 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
|
||||||
import type {
|
|
||||||
AuthenticatedRequest,
|
|
||||||
CredentialBackend,
|
|
||||||
} from "../../../types/index.js";
|
|
||||||
import type { Request, RequestHandler, Response, Router } from "express";
|
|
||||||
import type { Client as SshClient } from "ssh2";
|
|
||||||
import { createCurrentHostResolutionRepository } from "../repositories/factory.js";
|
|
||||||
import { connectHost } from "../../hosts/connect/connect-host.js";
|
|
||||||
import type { SshConnectHost } from "../../hosts/connect/types.js";
|
|
||||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
|
||||||
import { isSecretReference } from "../../hosts/external-secrets.js";
|
|
||||||
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
|
|
||||||
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
|
|
||||||
|
|
||||||
function describeDeployError(err: unknown): string {
|
|
||||||
const message = getErrorMessage(err, "Connection failed");
|
|
||||||
if (message.includes("All configured authentication methods failed")) {
|
|
||||||
return "Authentication failed. Please check your credentials and ensure the SSH service is running.";
|
|
||||||
}
|
|
||||||
if (message.includes("ENOTFOUND") || message.includes("ENOENT")) {
|
|
||||||
return "Could not resolve hostname or connect to server.";
|
|
||||||
}
|
|
||||||
if (message.includes("ECONNREFUSED")) {
|
|
||||||
return "Connection refused. The server may not be running or the port may be incorrect.";
|
|
||||||
}
|
|
||||||
if (message.includes("ETIMEDOUT")) {
|
|
||||||
return "Connection timed out. Check your network connection and server availability.";
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
message.includes("authentication failed") ||
|
|
||||||
message.includes("Permission denied")
|
|
||||||
) {
|
|
||||||
return "Authentication failed. Please check your username and password/key.";
|
|
||||||
}
|
|
||||||
if (message === "SSH connection timeout") return "Connection timeout";
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deploySSHKeyToHost(
|
|
||||||
host: SshConnectHost,
|
|
||||||
userId: string,
|
|
||||||
credData: CredentialBackend,
|
|
||||||
): Promise<{ success: boolean; message?: string; error?: string }> {
|
|
||||||
const publicKey = credData.publicKey as string;
|
|
||||||
|
|
||||||
let conn: SshClient;
|
|
||||||
try {
|
|
||||||
({ client: conn } = await connectHost(host, {
|
|
||||||
userId,
|
|
||||||
purpose: "credential-deploy",
|
|
||||||
timeoutMs: 120000,
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: describeDeployError(error) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await new Promise<void>((resolveCmd, rejectCmd) => {
|
|
||||||
const cmdTimeout = setTimeout(() => {
|
|
||||||
rejectCmd(new Error("mkdir command timeout"));
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
conn.exec(
|
|
||||||
"test -d ~/.ssh || mkdir -p ~/.ssh; chmod 700 ~/.ssh",
|
|
||||||
(err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
clearTimeout(cmdTimeout);
|
|
||||||
return rejectCmd(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.on("close", (code) => {
|
|
||||||
clearTimeout(cmdTimeout);
|
|
||||||
if (code === 0) {
|
|
||||||
resolveCmd();
|
|
||||||
} else {
|
|
||||||
rejectCmd(
|
|
||||||
new Error(`mkdir command failed with code ${code}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("data", () => {
|
|
||||||
// Ignore output
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const keyExists = await new Promise<boolean>(
|
|
||||||
(resolveCheck, rejectCheck) => {
|
|
||||||
const checkTimeout = setTimeout(() => {
|
|
||||||
rejectCheck(new Error("Key check timeout"));
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
let actualPublicKey = publicKey;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(publicKey);
|
|
||||||
if (parsed.data) {
|
|
||||||
actualPublicKey = parsed.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyParts = actualPublicKey.trim().split(" ");
|
|
||||||
if (keyParts.length < 2) {
|
|
||||||
clearTimeout(checkTimeout);
|
|
||||||
return rejectCheck(
|
|
||||||
new Error(
|
|
||||||
"Invalid public key format - must contain at least 2 parts",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyPattern = keyParts[1];
|
|
||||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
|
|
||||||
clearTimeout(checkTimeout);
|
|
||||||
return rejectCheck(new Error("Invalid public key data"));
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.exec(
|
|
||||||
`if [ -f ~/.ssh/authorized_keys ]; then grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?; else echo 1; fi`,
|
|
||||||
(err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
clearTimeout(checkTimeout);
|
|
||||||
return rejectCheck(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = "";
|
|
||||||
stream.on("data", (data) => {
|
|
||||||
output += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("close", () => {
|
|
||||||
clearTimeout(checkTimeout);
|
|
||||||
const exists = output.trim() === "0";
|
|
||||||
resolveCheck(exists);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (keyExists) {
|
|
||||||
conn.end();
|
|
||||||
resolve({ success: true, message: "SSH key already deployed" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolveAdd, rejectAdd) => {
|
|
||||||
const addTimeout = setTimeout(() => {
|
|
||||||
rejectAdd(new Error("Key add timeout"));
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
let actualPublicKey = publicKey;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(publicKey);
|
|
||||||
if (parsed.data) {
|
|
||||||
actualPublicKey = parsed.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapedKey = actualPublicKey
|
|
||||||
.replace(/\\/g, "\\\\")
|
|
||||||
.replace(/'/g, "'\\''");
|
|
||||||
const escapedName = credData.name
|
|
||||||
.replace(/\\/g, "\\\\")
|
|
||||||
.replace(/'/g, "'\\''");
|
|
||||||
|
|
||||||
conn.exec(
|
|
||||||
`printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
|
||||||
(err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
clearTimeout(addTimeout);
|
|
||||||
return rejectAdd(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.on("data", () => {
|
|
||||||
// Consume output
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("close", (code) => {
|
|
||||||
clearTimeout(addTimeout);
|
|
||||||
if (code === 0) {
|
|
||||||
resolveAdd();
|
|
||||||
} else {
|
|
||||||
rejectAdd(
|
|
||||||
new Error(`Key deployment failed with code ${code}`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const verifySuccess = await new Promise<boolean>(
|
|
||||||
(resolveVerify, rejectVerify) => {
|
|
||||||
const verifyTimeout = setTimeout(() => {
|
|
||||||
rejectVerify(new Error("Key verification timeout"));
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
let actualPublicKey = publicKey;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(publicKey);
|
|
||||||
if (parsed.data) {
|
|
||||||
actualPublicKey = parsed.data;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyParts = actualPublicKey.trim().split(" ");
|
|
||||||
if (keyParts.length < 2) {
|
|
||||||
clearTimeout(verifyTimeout);
|
|
||||||
return rejectVerify(
|
|
||||||
new Error(
|
|
||||||
"Invalid public key format - must contain at least 2 parts",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyPattern = keyParts[1];
|
|
||||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(keyPattern)) {
|
|
||||||
clearTimeout(verifyTimeout);
|
|
||||||
return rejectVerify(new Error("Invalid public key data"));
|
|
||||||
}
|
|
||||||
conn.exec(
|
|
||||||
`grep -F "${keyPattern}" ~/.ssh/authorized_keys >/dev/null 2>&1; echo $?`,
|
|
||||||
(err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
clearTimeout(verifyTimeout);
|
|
||||||
return rejectVerify(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = "";
|
|
||||||
stream.on("data", (data) => {
|
|
||||||
output += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("close", () => {
|
|
||||||
clearTimeout(verifyTimeout);
|
|
||||||
const verified = output.trim() === "0";
|
|
||||||
resolveVerify(verified);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
conn.end();
|
|
||||||
|
|
||||||
if (verifySuccess) {
|
|
||||||
resolve({ success: true, message: "SSH key deployed successfully" });
|
|
||||||
} else {
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: "Key deployment verification failed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
conn.end();
|
|
||||||
resolve({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error, "Deployment failed"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerCredentialDeployRoutes(
|
|
||||||
router: Router,
|
|
||||||
authenticateJWT: RequestHandler,
|
|
||||||
requireCredentialViewPermission: RequestHandler,
|
|
||||||
requireHostEditPermission: RequestHandler,
|
|
||||||
requireDataAccess: RequestHandler,
|
|
||||||
): void {
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /credentials/{id}/deploy-to-host:
|
|
||||||
* post:
|
|
||||||
* summary: Deploy SSH key to a host
|
|
||||||
* description: Deploys an SSH public key to a target host's authorized_keys file.
|
|
||||||
* tags:
|
|
||||||
* - Credentials
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* targetHostId:
|
|
||||||
* type: integer
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: SSH key deployed successfully.
|
|
||||||
* 400:
|
|
||||||
* description: Credential ID and target host ID are required.
|
|
||||||
* 401:
|
|
||||||
* description: Authentication required.
|
|
||||||
* 404:
|
|
||||||
* description: Credential or target host not found.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to deploy SSH key.
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/:id/deploy-to-host",
|
|
||||||
authenticateJWT,
|
|
||||||
requireCredentialViewPermission,
|
|
||||||
requireHostEditPermission,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const id = Array.isArray(req.params.id)
|
|
||||||
? req.params.id[0]
|
|
||||||
: req.params.id;
|
|
||||||
const credentialId = parseInt(id);
|
|
||||||
const { targetHostId } = req.body;
|
|
||||||
|
|
||||||
if (!credentialId || !targetHostId) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: "Credential ID and target host ID are required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
if (!userId) {
|
|
||||||
return res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
error: "Authentication required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const repository = createCurrentHostResolutionRepository();
|
|
||||||
const credential = await repository.findCredentialByIdForUser(
|
|
||||||
credentialId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!credential) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: "Credential not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const credData = credential as unknown as CredentialBackend;
|
|
||||||
|
|
||||||
if (credData.authType !== "key") {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: "Only SSH key-based credentials can be deployed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let publicKey = credData.publicKey;
|
|
||||||
const storedKey = credData.privateKey || credData.key;
|
|
||||||
if (!publicKey && isSecretReference(storedKey)) {
|
|
||||||
// A key kept in a secret source has no stored public key; derive
|
|
||||||
// it from the resolved key.
|
|
||||||
const resolved = await resolveKeyReferences(
|
|
||||||
userId,
|
|
||||||
storedKey,
|
|
||||||
(credData as { keyPassword?: string | null }).keyPassword,
|
|
||||||
);
|
|
||||||
publicKey =
|
|
||||||
parseSSHKey(resolved.key, resolved.passphrase).publicKey ||
|
|
||||||
undefined;
|
|
||||||
}
|
|
||||||
if (!publicKey) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: "Public key is required for deployment",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Writing authorized_keys is an owner action, so ownership is checked
|
|
||||||
// before the shared resolver fills in the credentials.
|
|
||||||
const hostData = await repository.findHostByIdForUser(
|
|
||||||
targetHostId,
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hostData) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
error: "Target host not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedHost = await resolveHostById(targetHostId, userId);
|
|
||||||
if (!resolvedHost) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: "Host credential not found",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const deployResult = await deploySSHKeyToHost(
|
|
||||||
resolvedHost as unknown as SshConnectHost,
|
|
||||||
userId,
|
|
||||||
credData,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (deployResult.success) {
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: deployResult.message || "SSH key deployed successfully",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: deployResult.error || "Deployment failed",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error, "Failed to deploy SSH key"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
|
||||||
import type { Request, RequestHandler, Response, Router } from "express";
|
|
||||||
import crypto from "crypto";
|
|
||||||
import ssh2Pkg from "ssh2";
|
|
||||||
import { authLogger } from "../../utils/logger.js";
|
|
||||||
import {
|
|
||||||
parsePublicKey,
|
|
||||||
parseSSHKey,
|
|
||||||
validateKeyPair,
|
|
||||||
} from "../../utils/ssh-key-utils.js";
|
|
||||||
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
|
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
|
||||||
|
|
||||||
const { utils: ssh2Utils } = ssh2Pkg;
|
|
||||||
|
|
||||||
function generateSSHKeyPair(
|
|
||||||
keyType: string,
|
|
||||||
keySize?: number,
|
|
||||||
passphrase?: string,
|
|
||||||
): {
|
|
||||||
success: boolean;
|
|
||||||
privateKey?: string;
|
|
||||||
publicKey?: string;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
try {
|
|
||||||
let ssh2Type = keyType;
|
|
||||||
const options: {
|
|
||||||
bits?: number;
|
|
||||||
passphrase?: string;
|
|
||||||
cipher?: string;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
if (keyType === "ssh-rsa") {
|
|
||||||
ssh2Type = "rsa";
|
|
||||||
options.bits = keySize || 2048;
|
|
||||||
} else if (keyType === "ssh-ed25519") {
|
|
||||||
ssh2Type = "ed25519";
|
|
||||||
} else if (keyType === "ecdsa-sha2-nistp256") {
|
|
||||||
ssh2Type = "ecdsa";
|
|
||||||
options.bits = 256;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (passphrase && passphrase.trim()) {
|
|
||||||
options.passphrase = passphrase;
|
|
||||||
options.cipher = "aes128-cbc";
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
privateKey: keyPair.private,
|
|
||||||
publicKey: keyPair.public,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error, "SSH key generation failed"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerCredentialKeyRoutes(
|
|
||||||
router: Router,
|
|
||||||
authenticateJWT: RequestHandler,
|
|
||||||
requireCredentialViewPermission: RequestHandler,
|
|
||||||
requireDataAccess: RequestHandler,
|
|
||||||
): void {
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /credentials/detect-key-type:
|
|
||||||
* post:
|
|
||||||
* summary: Detect SSH key type
|
|
||||||
* description: Detects the type of an SSH private key.
|
|
||||||
* tags:
|
|
||||||
* - Credentials
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* privateKey:
|
|
||||||
* type: string
|
|
||||||
* keyPassword:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Key type detection result.
|
|
||||||
* 400:
|
|
||||||
* description: Private key is required.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to detect key type.
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/detect-key-type",
|
|
||||||
authenticateJWT,
|
|
||||||
requireCredentialViewPermission,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
|
|
||||||
return res.status(400).json({ error: "Private key is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { key: privateKey, passphrase: keyPassword } =
|
|
||||||
await resolveKeyReferences(
|
|
||||||
(req as AuthenticatedRequest).userId,
|
|
||||||
req.body.privateKey,
|
|
||||||
req.body.keyPassword,
|
|
||||||
);
|
|
||||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
success: keyInfo.success,
|
|
||||||
keyType: keyInfo.keyType,
|
|
||||||
detectedKeyType: keyInfo.keyType,
|
|
||||||
hasPublicKey: !!keyInfo.publicKey,
|
|
||||||
error: keyInfo.error || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error("Failed to detect key type", error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: getErrorMessage(error, "Failed to detect key type"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /credentials/detect-public-key-type:
|
|
||||||
* post:
|
|
||||||
* summary: Detect SSH public key type
|
|
||||||
* description: Detects the type of an SSH public key.
|
|
||||||
* tags:
|
|
||||||
* - Credentials
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* publicKey:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Key type detection result.
|
|
||||||
* 400:
|
|
||||||
* description: Public key is required.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to detect public key type.
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/detect-public-key-type",
|
|
||||||
authenticateJWT,
|
|
||||||
requireCredentialViewPermission,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { publicKey } = req.body;
|
|
||||||
|
|
||||||
if (!publicKey || typeof publicKey !== "string") {
|
|
||||||
return res.status(400).json({ error: "Public key is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const keyInfo = parsePublicKey(publicKey);
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
success: keyInfo.success,
|
|
||||||
keyType: keyInfo.keyType,
|
|
||||||
detectedKeyType: keyInfo.keyType,
|
|
||||||
error: keyInfo.error || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error("Failed to detect public key type", error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: getErrorMessage(error, "Failed to detect public key type"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /credentials/validate-key-pair:
|
|
||||||
* post:
|
|
||||||
* summary: Validate SSH key pair
|
|
||||||
* description: Validates if a given SSH private key and public key match.
|
|
||||||
* tags:
|
|
||||||
* - Credentials
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* privateKey:
|
|
||||||
* type: string
|
|
||||||
* publicKey:
|
|
||||||
* type: string
|
|
||||||
* keyPassword:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Key pair validation result.
|
|
||||||
* 400:
|
|
||||||
* description: Private key and public key are required.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to validate key pair.
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/validate-key-pair",
|
|
||||||
authenticateJWT,
|
|
||||||
requireCredentialViewPermission,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { privateKey, publicKey, keyPassword } = req.body;
|
|
||||||
|
|
||||||
if (!privateKey || typeof privateKey !== "string") {
|
|
||||||
return res.status(400).json({ error: "Private key is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!publicKey || typeof publicKey !== "string") {
|
|
||||||
return res.status(400).json({ error: "Public key is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const validationResult = validateKeyPair(
|
|
||||||
privateKey,
|
|
||||||
publicKey,
|
|
||||||
keyPassword,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
isValid: validationResult.isValid,
|
|
||||||
privateKeyType: validationResult.privateKeyType,
|
|
||||||
publicKeyType: validationResult.publicKeyType,
|
|
||||||
generatedPublicKey: validationResult.generatedPublicKey,
|
|
||||||
error: validationResult.error || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error("Failed to validate key pair", error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: getErrorMessage(error, "Failed to validate key pair"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /credentials/generate-key-pair:
|
|
||||||
* post:
|
|
||||||
* summary: Generate new SSH key pair
|
|
||||||
* description: Generates a new SSH key pair.
|
|
||||||
* tags:
|
|
||||||
* - Credentials
|
|
||||||
* requestBody:
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* keyType:
|
|
||||||
* type: string
|
|
||||||
* keySize:
|
|
||||||
* type: integer
|
|
||||||
* passphrase:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: The new key pair.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to generate SSH key pair.
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/generate-key-pair",
|
|
||||||
authenticateJWT,
|
|
||||||
requireCredentialViewPermission,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = generateSSHKeyPair(keyType, keySize, passphrase);
|
|
||||||
|
|
||||||
if (result.success && result.privateKey && result.publicKey) {
|
|
||||||
const response = {
|
|
||||||
success: true,
|
|
||||||
privateKey: result.privateKey,
|
|
||||||
publicKey: result.publicKey,
|
|
||||||
keyType: keyType,
|
|
||||||
format: "ssh",
|
|
||||||
algorithm: keyType,
|
|
||||||
keySize: keyType === "ssh-rsa" ? keySize : undefined,
|
|
||||||
curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} else {
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: result.error || "Failed to generate SSH key pair",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error("Failed to generate key pair", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error, "Failed to generate key pair"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /credentials/generate-public-key:
|
|
||||||
* post:
|
|
||||||
* summary: Generate public key from private key
|
|
||||||
* description: Generates a public key from a given private key.
|
|
||||||
* tags:
|
|
||||||
* - Credentials
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* privateKey:
|
|
||||||
* type: string
|
|
||||||
* keyPassword:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: The generated public key.
|
|
||||||
* 400:
|
|
||||||
* description: Private key is required.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to generate public key.
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/generate-public-key",
|
|
||||||
authenticateJWT,
|
|
||||||
requireCredentialViewPermission,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
|
|
||||||
return res.status(400).json({ error: "Private key is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { key: privateKey, passphrase: keyPassword } =
|
|
||||||
await resolveKeyReferences(
|
|
||||||
(req as AuthenticatedRequest).userId,
|
|
||||||
req.body.privateKey,
|
|
||||||
req.body.keyPassword,
|
|
||||||
);
|
|
||||||
let privateKeyObj;
|
|
||||||
const parseAttempts = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
privateKeyObj = crypto.createPrivateKey({
|
|
||||||
key: privateKey,
|
|
||||||
passphrase: keyPassword,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
parseAttempts.push(`Method 1 (with passphrase): ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!privateKeyObj) {
|
|
||||||
try {
|
|
||||||
privateKeyObj = crypto.createPrivateKey(privateKey);
|
|
||||||
} catch (error) {
|
|
||||||
parseAttempts.push(
|
|
||||||
`Method 2 (without passphrase): ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!privateKeyObj) {
|
|
||||||
try {
|
|
||||||
privateKeyObj = crypto.createPrivateKey({
|
|
||||||
key: privateKey,
|
|
||||||
format: "pem",
|
|
||||||
type: "pkcs8",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!privateKeyObj &&
|
|
||||||
privateKey.includes("-----BEGIN RSA PRIVATE KEY-----")
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
privateKeyObj = crypto.createPrivateKey({
|
|
||||||
key: privateKey,
|
|
||||||
format: "pem",
|
|
||||||
type: "pkcs1",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!privateKeyObj &&
|
|
||||||
privateKey.includes("-----BEGIN EC PRIVATE KEY-----")
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
privateKeyObj = crypto.createPrivateKey({
|
|
||||||
key: privateKey,
|
|
||||||
format: "pem",
|
|
||||||
type: "sec1",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
parseAttempts.push(`Method 5 (SEC1): ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!privateKeyObj) {
|
|
||||||
try {
|
|
||||||
const keyInfo = parseSSHKey(privateKey, keyPassword);
|
|
||||||
|
|
||||||
if (keyInfo.success && keyInfo.publicKey) {
|
|
||||||
const publicKeyString = String(keyInfo.publicKey);
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
publicKey: publicKeyString,
|
|
||||||
keyType: keyInfo.keyType,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
parseAttempts.push(
|
|
||||||
`SSH2 fallback: ${keyInfo.error || "No public key generated"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
parseAttempts.push(`SSH2 fallback exception: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!privateKeyObj) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: "Unable to parse private key. Tried multiple formats.",
|
|
||||||
details: parseAttempts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicKeyObj = crypto.createPublicKey(privateKeyObj);
|
|
||||||
const publicKeyPem = publicKeyObj.export({
|
|
||||||
type: "spki",
|
|
||||||
format: "pem",
|
|
||||||
});
|
|
||||||
|
|
||||||
const publicKeyString =
|
|
||||||
typeof publicKeyPem === "string"
|
|
||||||
? publicKeyPem
|
|
||||||
: (publicKeyPem as Buffer).toString("utf8");
|
|
||||||
|
|
||||||
let keyType = "unknown";
|
|
||||||
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
|
||||||
|
|
||||||
if (asymmetricKeyType === "rsa") {
|
|
||||||
keyType = "ssh-rsa";
|
|
||||||
} else if (asymmetricKeyType === "ed25519") {
|
|
||||||
keyType = "ssh-ed25519";
|
|
||||||
} else if (asymmetricKeyType === "ec") {
|
|
||||||
keyType = "ecdsa-sha2-nistp256";
|
|
||||||
}
|
|
||||||
|
|
||||||
let finalPublicKey = publicKeyString;
|
|
||||||
let formatType = "pem";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ssh2PrivateKey = ssh2Utils.parseKey(privateKey, keyPassword);
|
|
||||||
if (!(ssh2PrivateKey instanceof Error)) {
|
|
||||||
const publicKeyBuffer = ssh2PrivateKey.getPublicSSH();
|
|
||||||
const base64Data = publicKeyBuffer.toString("base64");
|
|
||||||
finalPublicKey = `${keyType} ${base64Data}`;
|
|
||||||
formatType = "ssh";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore validation errors
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
success: true,
|
|
||||||
publicKey: finalPublicKey,
|
|
||||||
keyType: keyType,
|
|
||||||
format: formatType,
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error("Failed to generate public key", error);
|
|
||||||
res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
error: getErrorMessage(error, "Failed to generate public key"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,8 +8,6 @@ import { authLogger } from "../../utils/logger.js";
|
|||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
|
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
|
||||||
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
|
|
||||||
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
|
|
||||||
import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js";
|
import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js";
|
||||||
import {
|
import {
|
||||||
logAudit,
|
logAudit,
|
||||||
@@ -1211,19 +1209,4 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
registerCredentialKeyRoutes(
|
|
||||||
router,
|
|
||||||
authenticateJWT,
|
|
||||||
permissionManager.requirePermission("credentials.view"),
|
|
||||||
requireDataAccess,
|
|
||||||
);
|
|
||||||
|
|
||||||
registerCredentialDeployRoutes(
|
|
||||||
router,
|
|
||||||
authenticateJWT,
|
|
||||||
permissionManager.requirePermission("credentials.view"),
|
|
||||||
permissionManager.requirePermission("hosts.edit"),
|
|
||||||
requireDataAccess,
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -3,8 +3,7 @@ import express, { type Request, type Response } from "express";
|
|||||||
import { dashboardLogger } from "../../utils/logger.js";
|
import { dashboardLogger } from "../../utils/logger.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
import { createCurrentRecentActivityRepository } from "../repositories/factory.js";
|
import { recentActivity } from "../../plugins/activity-provider.js";
|
||||||
import { recordRecentActivity } from "../../services/recent-activity.js";
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -99,11 +98,7 @@ router.get(
|
|||||||
|
|
||||||
const limit = Number(req.query.limit) || 20;
|
const limit = Number(req.query.limit) || 20;
|
||||||
|
|
||||||
const activities =
|
const activities = await recentActivity.list(userId, limit);
|
||||||
await createCurrentRecentActivityRepository().listByUserId(
|
|
||||||
userId,
|
|
||||||
limit,
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json(activities);
|
res.json(activities);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -169,7 +164,7 @@ router.post(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await recordRecentActivity(userId, {
|
const result = await recentActivity.record(userId, {
|
||||||
type,
|
type,
|
||||||
hostId,
|
hostId,
|
||||||
hostName,
|
hostName,
|
||||||
@@ -185,6 +180,11 @@ router.post(
|
|||||||
message: "Activity already logged recently (rate limited)",
|
message: "Activity already logged recently (rate limited)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (result.status === "unavailable") {
|
||||||
|
return res
|
||||||
|
.status(404)
|
||||||
|
.json({ error: "Recent activity is not available" });
|
||||||
|
}
|
||||||
if (result.status === "denied") {
|
if (result.status === "denied") {
|
||||||
return res
|
return res
|
||||||
.status(404)
|
.status(404)
|
||||||
@@ -222,7 +222,7 @@ router.delete(
|
|||||||
try {
|
try {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
await recentActivity.deleteByUserId(userId);
|
||||||
|
|
||||||
dashboardLogger.success("Recent activity cleared", {
|
dashboardLogger.success("Recent activity cleared", {
|
||||||
operation: "reset_recent_activity",
|
operation: "reset_recent_activity",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
|
import { recentActivity } from "../../plugins/activity-provider.js";
|
||||||
import {
|
import {
|
||||||
createCurrentApiKeyRepository,
|
createCurrentApiKeyRepository,
|
||||||
createCurrentAuditLogRepository,
|
createCurrentAuditLogRepository,
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
createCurrentCredentialSidebarPreferenceRepository,
|
createCurrentCredentialSidebarPreferenceRepository,
|
||||||
createCurrentUiPreferenceRepository,
|
createCurrentUiPreferenceRepository,
|
||||||
createCurrentOpenTabRepository,
|
createCurrentOpenTabRepository,
|
||||||
createCurrentRecentActivityRepository,
|
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
createCurrentSessionRepository,
|
createCurrentSessionRepository,
|
||||||
@@ -64,7 +64,7 @@ export async function deleteUserAndRelatedData(
|
|||||||
|
|
||||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||||
|
|
||||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
await recentActivity.deleteByUserId(userId);
|
||||||
|
|
||||||
await createCurrentHostFolderRepository().deleteByUserId(userId);
|
await createCurrentHostFolderRepository().deleteByUserId(userId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,172 +1,22 @@
|
|||||||
import {
|
|
||||||
prepareHostImports,
|
|
||||||
remapImportedJumpHosts,
|
|
||||||
} from "./host-import-order.js";
|
|
||||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
|
||||||
import {
|
|
||||||
applyDefaultsAfterHostWrites,
|
|
||||||
applyHostDefaultsToWrite,
|
|
||||||
} from "../../hosts/defaults/index.js";
|
|
||||||
import {
|
import {
|
||||||
changeHostOverrides,
|
changeHostOverrides,
|
||||||
type OverrideChange,
|
type OverrideChange,
|
||||||
} from "../../hosts/defaults/overrides.js";
|
} from "../../hosts/defaults/overrides.js";
|
||||||
import { recompute } from "../../hosts/defaults/recompute.js";
|
import { recompute } from "../../hosts/defaults/recompute.js";
|
||||||
import { splitDefaultKey } from "../../../types/host-defaults.js";
|
import { splitDefaultKey } from "../../../types/host-defaults.js";
|
||||||
import {
|
|
||||||
keepUsableProtocolCredentials,
|
|
||||||
readProtocolAuthPayload,
|
|
||||||
writeProtocolAuth,
|
|
||||||
} from "../../hosts/protocol-auth/protocol-auth.js";
|
|
||||||
import { getErrorMessage } from "../../utils/error-message.js";
|
|
||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import type { Request, RequestHandler, Response, Router } from "express";
|
import type { Request, RequestHandler, Response, Router } from "express";
|
||||||
import { sshLogger } from "../../utils/logger.js";
|
import { sshLogger } from "../../utils/logger.js";
|
||||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
import { createCurrentHostRepository } from "../repositories/factory.js";
|
||||||
import {
|
|
||||||
createCurrentCredentialRepository,
|
|
||||||
createCurrentHostRepository,
|
|
||||||
createCurrentHostResolutionRepository,
|
|
||||||
createCurrentHostDefaultsRepository,
|
|
||||||
} from "../repositories/factory.js";
|
|
||||||
import { validateParentHostId } from "./host-parent-validation.js";
|
import { validateParentHostId } from "./host-parent-validation.js";
|
||||||
import {
|
|
||||||
listSshAuthProviders,
|
|
||||||
listSshAuthTypeOwners,
|
|
||||||
} from "../../hosts/connect/auth-provider-registry.js";
|
|
||||||
import {
|
|
||||||
applyPluginHostImportSettings,
|
|
||||||
setHostPluginEnabled,
|
|
||||||
} from "./host-plugin-settings.js";
|
|
||||||
import {
|
|
||||||
isNonEmptyString,
|
|
||||||
isValidPort,
|
|
||||||
normalizeImportedHost,
|
|
||||||
} from "./host-normalizers.js";
|
|
||||||
|
|
||||||
type SSHConfigHost = {
|
import { setHostPluginEnabled } from "./host-plugin-settings.js";
|
||||||
name: string;
|
|
||||||
hostname?: string;
|
|
||||||
user?: string;
|
|
||||||
port?: number;
|
|
||||||
identityFile?: string;
|
|
||||||
proxyJump?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ShareCredential = {
|
|
||||||
alias?: unknown;
|
|
||||||
name?: unknown;
|
|
||||||
description?: unknown;
|
|
||||||
folder?: unknown;
|
|
||||||
tags?: unknown;
|
|
||||||
authType?: unknown;
|
|
||||||
username?: unknown;
|
|
||||||
keyType?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
function textValue(value: unknown): string | null {
|
|
||||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function tagString(value: unknown): string {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value
|
|
||||||
.map((tag) => textValue(tag))
|
|
||||||
.filter((tag): tag is string => !!tag)
|
|
||||||
.join(",");
|
|
||||||
}
|
|
||||||
return textValue(value) || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
|
|
||||||
return value === "key" ? "key" : "password";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseSSHConfig(content: string): SSHConfigHost[] {
|
|
||||||
const results: SSHConfigHost[] = [];
|
|
||||||
let current: SSHConfigHost | null = null;
|
|
||||||
|
|
||||||
for (const rawLine of content.split("\n")) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line || line.startsWith("#")) continue;
|
|
||||||
|
|
||||||
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
|
|
||||||
if (!match) continue;
|
|
||||||
|
|
||||||
const key = match[1].toLowerCase();
|
|
||||||
const value = match[2];
|
|
||||||
|
|
||||||
if (key === "host") {
|
|
||||||
if (current && current.hostname) results.push(current);
|
|
||||||
// Skip wildcard patterns
|
|
||||||
if (value === "*" || value.includes("*") || value.includes("?")) {
|
|
||||||
current = null;
|
|
||||||
} else {
|
|
||||||
current = { name: value };
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!current) continue;
|
|
||||||
|
|
||||||
switch (key) {
|
|
||||||
case "hostname":
|
|
||||||
current.hostname = value;
|
|
||||||
break;
|
|
||||||
case "user":
|
|
||||||
current.user = value;
|
|
||||||
break;
|
|
||||||
case "port": {
|
|
||||||
const p = Number.parseInt(value, 10);
|
|
||||||
if (p > 0 && p <= 65535) current.port = p;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "identityfile":
|
|
||||||
if (!current.identityFile) current.identityFile = value;
|
|
||||||
break;
|
|
||||||
case "proxyjump":
|
|
||||||
current.proxyJump = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current && current.hostname) results.push(current);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tells status checks and other pollers about hosts an import wrote, the way
|
|
||||||
* a single host create or update does.
|
|
||||||
*/
|
|
||||||
export function notifyImportedHosts(
|
|
||||||
userId: string,
|
|
||||||
hostIds: number[],
|
|
||||||
createdHostIds: number[],
|
|
||||||
): void {
|
|
||||||
for (const hostId of createdHostIds) {
|
|
||||||
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
|
||||||
}
|
|
||||||
for (const hostId of hostIds) {
|
|
||||||
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importedHostUsername(
|
|
||||||
connectionType: string,
|
|
||||||
authType: unknown,
|
|
||||||
username: unknown,
|
|
||||||
): string | null {
|
|
||||||
if (isNonEmptyString(username)) return username;
|
|
||||||
if (connectionType !== "ssh" || authType === "credential") return "";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerHostBulkRoutes(
|
export function registerHostBulkRoutes(
|
||||||
router: Router,
|
router: Router,
|
||||||
authenticateJWT: RequestHandler,
|
authenticateJWT: RequestHandler,
|
||||||
requireCreatePermission: RequestHandler,
|
|
||||||
requireEditPermission: RequestHandler,
|
requireEditPermission: RequestHandler,
|
||||||
requireDataAccess: RequestHandler,
|
requireDataAccess: RequestHandler,
|
||||||
): void {
|
): void {
|
||||||
@@ -467,674 +317,6 @@ export function registerHostBulkRoutes(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/bulk-import",
|
|
||||||
authenticateJWT,
|
|
||||||
requireCreatePermission,
|
|
||||||
requireEditPermission,
|
|
||||||
requireDataAccess,
|
|
||||||
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
const {
|
|
||||||
hosts: hostsToImport,
|
|
||||||
overwrite,
|
|
||||||
credentials: credentialsToImport,
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Hosts array is required and must not be empty" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hostsToImport.length > 100) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Maximum 100 hosts allowed per import" });
|
|
||||||
}
|
|
||||||
|
|
||||||
let orderedHosts: ReturnType<typeof prepareHostImports>;
|
|
||||||
try {
|
|
||||||
orderedHosts = prepareHostImports(hostsToImport);
|
|
||||||
} catch (error) {
|
|
||||||
return res.status(400).json({ error: getErrorMessage(error) });
|
|
||||||
}
|
|
||||||
const importedIds = new Map<unknown, number>();
|
|
||||||
|
|
||||||
const results = {
|
|
||||||
success: 0,
|
|
||||||
updated: 0,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
errors: [] as string[],
|
|
||||||
};
|
|
||||||
|
|
||||||
const credentialAliasMap = new Map<string, number>();
|
|
||||||
const addCredentialAlias = (alias: unknown, id: number) => {
|
|
||||||
const key = textValue(alias);
|
|
||||||
if (key) credentialAliasMap.set(key.toLowerCase(), id);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const credentialRepository = createCurrentCredentialRepository();
|
|
||||||
const existingCredentials =
|
|
||||||
await credentialRepository.listDecryptedByUserId(userId);
|
|
||||||
|
|
||||||
for (const credential of existingCredentials) {
|
|
||||||
addCredentialAlias(credential.name, credential.id as number);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(credentialsToImport)) {
|
|
||||||
for (const rawCredential of credentialsToImport as ShareCredential[]) {
|
|
||||||
const alias = textValue(rawCredential.alias);
|
|
||||||
const name = textValue(rawCredential.name) || alias;
|
|
||||||
if (!alias || !name) continue;
|
|
||||||
|
|
||||||
const existingId = credentialAliasMap.get(name.toLowerCase());
|
|
||||||
if (existingId) {
|
|
||||||
addCredentialAlias(alias, existingId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const created = await credentialRepository.createEncryptedForUser(
|
|
||||||
userId,
|
|
||||||
{
|
|
||||||
userId,
|
|
||||||
name,
|
|
||||||
description:
|
|
||||||
textValue(rawCredential.description) ||
|
|
||||||
"Imported placeholder. Add the secret before connecting.",
|
|
||||||
folder: textValue(rawCredential.folder),
|
|
||||||
tags: tagString(rawCredential.tags),
|
|
||||||
authType: normalizeCredentialAuthType(rawCredential.authType),
|
|
||||||
username: textValue(rawCredential.username),
|
|
||||||
password: null,
|
|
||||||
key: null,
|
|
||||||
privateKey: null,
|
|
||||||
publicKey: null,
|
|
||||||
keyPassword: null,
|
|
||||||
keyType: textValue(rawCredential.keyType),
|
|
||||||
detectedKeyType: null,
|
|
||||||
usageCount: 0,
|
|
||||||
lastUsed: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const createdCredential = created as Record<string, unknown>;
|
|
||||||
addCredentialAlias(alias, createdCredential.id as number);
|
|
||||||
addCredentialAlias(name, createdCredential.id as number);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
results.errors.push(
|
|
||||||
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
|
|
||||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
|
||||||
const hostRepository = createCurrentHostRepository();
|
|
||||||
try {
|
|
||||||
const allHosts =
|
|
||||||
await createCurrentHostResolutionRepository().findHostsByUserId(
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
existingHostMap = new Map();
|
|
||||||
for (const h of allHosts) {
|
|
||||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
|
||||||
existingHostMap.set(key, { id: h.id as number });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
existingHostMap = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownAuthTypes = listKnownAuthTypes();
|
|
||||||
const writtenHostIds: number[] = [];
|
|
||||||
const createdHostIds: number[] = [];
|
|
||||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
|
||||||
try {
|
|
||||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
|
||||||
|
|
||||||
if (
|
|
||||||
effectiveConnectionType === "ssh" &&
|
|
||||||
hostData.authType === "credential" &&
|
|
||||||
!hostData.credentialId &&
|
|
||||||
hostData.credentialAlias
|
|
||||||
) {
|
|
||||||
hostData.credentialId = credentialAliasMap.get(
|
|
||||||
hostData.credentialAlias.toLowerCase(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(
|
|
||||||
`Host ${i + 1}: Missing required fields (ip, port)`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = importedHostUsername(
|
|
||||||
effectiveConnectionType,
|
|
||||||
hostData.authType,
|
|
||||||
hostData.username,
|
|
||||||
);
|
|
||||||
if (username === null) {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(
|
|
||||||
`Host ${i + 1}: Username required for SSH connections`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
|
||||||
const existing = existingHostMap?.get(lookupKey);
|
|
||||||
if (existing && !overwrite) {
|
|
||||||
// Hosts that jump through this one still resolve to the kept host.
|
|
||||||
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
|
||||||
results.skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
effectiveConnectionType === "ssh" &&
|
|
||||||
hostData.authType &&
|
|
||||||
!knownAuthTypes.has(hostData.authType)
|
|
||||||
) {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(
|
|
||||||
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
effectiveConnectionType === "ssh" &&
|
|
||||||
hostData.authType === "password" &&
|
|
||||||
!isNonEmptyString(hostData.password)
|
|
||||||
) {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(
|
|
||||||
`Host ${i + 1}: Password required for password authentication`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
effectiveConnectionType === "ssh" &&
|
|
||||||
hostData.authType === "key" &&
|
|
||||||
!isNonEmptyString(hostData.key)
|
|
||||||
) {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(
|
|
||||||
`Host ${i + 1}: Key required for key authentication`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
effectiveConnectionType === "ssh" &&
|
|
||||||
hostData.authType === "credential" &&
|
|
||||||
!hostData.credentialId
|
|
||||||
) {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(
|
|
||||||
`Host ${i + 1}: credentialId required for credential authentication`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
effectiveConnectionType === "ssh" &&
|
|
||||||
hostData.authType === "credential" &&
|
|
||||||
hostData.credentialId
|
|
||||||
) {
|
|
||||||
const credentialRepository = createCurrentCredentialRepository();
|
|
||||||
const cred = await credentialRepository.findByIdForUser(
|
|
||||||
userId,
|
|
||||||
hostData.credentialId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!cred) {
|
|
||||||
const fallback = await credentialRepository.listByUserId(userId);
|
|
||||||
|
|
||||||
if (fallback.length > 0) {
|
|
||||||
hostData.credentialId = fallback[0].id;
|
|
||||||
} else if (isNonEmptyString(hostData.key)) {
|
|
||||||
hostData.authType = "key";
|
|
||||||
hostData.credentialId = undefined;
|
|
||||||
} else if (isNonEmptyString(hostData.password)) {
|
|
||||||
hostData.authType = "password";
|
|
||||||
hostData.credentialId = undefined;
|
|
||||||
} else {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(
|
|
||||||
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jumpHosts = remapImportedJumpHosts(
|
|
||||||
hostData.jumpHosts,
|
|
||||||
importedIds,
|
|
||||||
);
|
|
||||||
const sshDataObj: Record<string, unknown> = {
|
|
||||||
userId: userId,
|
|
||||||
connectionType: effectiveConnectionType,
|
|
||||||
name: hostData.name || `${username}@${hostData.ip}`,
|
|
||||||
folder: hostData.folder || "Default",
|
|
||||||
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
|
|
||||||
ip: hostData.ip,
|
|
||||||
port: hostData.port,
|
|
||||||
username,
|
|
||||||
pin: hostData.pin || false,
|
|
||||||
sudoPassword: hostData.sudoPassword || null,
|
|
||||||
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
|
|
||||||
...importedStatusCheck(hostData as Record<string, unknown>),
|
|
||||||
// A 2.8 export carries these inside terminalConfig.
|
|
||||||
sshOptions:
|
|
||||||
sshOptionsForWrite({
|
|
||||||
sshOptions: hostData.sshOptions,
|
|
||||||
terminalConfig: hostData.terminalConfig,
|
|
||||||
}) ?? null,
|
|
||||||
forceKeyboardInteractive: hostData.forceKeyboardInteractive
|
|
||||||
? "true"
|
|
||||||
: "false",
|
|
||||||
notes: hostData.notes || null,
|
|
||||||
useSocks5: hostData.useSocks5 ? 1 : 0,
|
|
||||||
socks5Host: hostData.socks5Host || null,
|
|
||||||
socks5Port: hostData.socks5Port || null,
|
|
||||||
socks5Username: hostData.socks5Username || null,
|
|
||||||
socks5Password: hostData.socks5Password || null,
|
|
||||||
socks5ProxyChain: hostData.socks5ProxyChain
|
|
||||||
? JSON.stringify(hostData.socks5ProxyChain)
|
|
||||||
: null,
|
|
||||||
portKnockSequence: hostData.portKnockSequence
|
|
||||||
? JSON.stringify(hostData.portKnockSequence)
|
|
||||||
: null,
|
|
||||||
overrideCredentialUsername: hostData.overrideCredentialUsername
|
|
||||||
? 1
|
|
||||||
: 0,
|
|
||||||
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (effectiveConnectionType !== "ssh") {
|
|
||||||
sshDataObj.password = hostData.password || null;
|
|
||||||
sshDataObj.authType = "password";
|
|
||||||
sshDataObj.credentialId = null;
|
|
||||||
sshDataObj.key = null;
|
|
||||||
sshDataObj.keyPassword = null;
|
|
||||||
sshDataObj.keyType = null;
|
|
||||||
} else {
|
|
||||||
sshDataObj.password =
|
|
||||||
hostData.authType === "password" ? hostData.password : null;
|
|
||||||
sshDataObj.authType = hostData.authType || "password";
|
|
||||||
sshDataObj.credentialId =
|
|
||||||
hostData.authType === "credential" ? hostData.credentialId : null;
|
|
||||||
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
|
|
||||||
sshDataObj.keyPassword =
|
|
||||||
hostData.authType === "key" ? hostData.keyPassword || null : null;
|
|
||||||
sshDataObj.keyType =
|
|
||||||
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await withHostDefaults(
|
|
||||||
userId,
|
|
||||||
existing?.id ?? null,
|
|
||||||
sshDataObj,
|
|
||||||
hostData as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
|
|
||||||
let savedHostId: number;
|
|
||||||
if (existing) {
|
|
||||||
const saved = await hostRepository.updateEncryptedForUser(
|
|
||||||
userId,
|
|
||||||
existing.id,
|
|
||||||
sshDataObj,
|
|
||||||
);
|
|
||||||
if (!saved) throw new Error("Host no longer exists");
|
|
||||||
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
|
||||||
savedHostId = existing.id;
|
|
||||||
results.updated++;
|
|
||||||
} else {
|
|
||||||
sshDataObj.createdAt = new Date().toISOString();
|
|
||||||
const saved = await hostRepository.createEncryptedForUser(
|
|
||||||
userId,
|
|
||||||
sshDataObj,
|
|
||||||
);
|
|
||||||
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
|
||||||
savedHostId = saved.id;
|
|
||||||
createdHostIds.push(saved.id);
|
|
||||||
results.success++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const protocolAuth = readProtocolAuthPayload(
|
|
||||||
hostData as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
if (protocolAuth) {
|
|
||||||
await writeProtocolAuth(
|
|
||||||
userId,
|
|
||||||
savedHostId,
|
|
||||||
await keepUsableProtocolCredentials(protocolAuth, userId),
|
|
||||||
{ isOwner: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every enabled plugin that declares host-scope settings and
|
|
||||||
// registered a hostImportNormalizer validates and writes its own
|
|
||||||
// fields here, so this loop does not need to know which plugins
|
|
||||||
// exist. See host-plugin-settings.ts.
|
|
||||||
await applyPluginHostImportSettings(
|
|
||||||
savedHostId,
|
|
||||||
hostData as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
writtenHostIds.push(savedHostId);
|
|
||||||
} catch (error) {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
|
||||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
|
||||||
success: results.success,
|
|
||||||
updated: results.updated,
|
|
||||||
skipped: results.skipped,
|
|
||||||
failed: results.failed,
|
|
||||||
errors: results.errors,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /host/ssh-config-import:
|
|
||||||
* post:
|
|
||||||
* summary: Import hosts from an OpenSSH config file
|
|
||||||
* description: Parses an OpenSSH ~/.ssh/config file and imports the defined hosts.
|
|
||||||
* tags:
|
|
||||||
* - SSH
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* required:
|
|
||||||
* - content
|
|
||||||
* properties:
|
|
||||||
* content:
|
|
||||||
* type: string
|
|
||||||
* description: Raw text content of the SSH config file.
|
|
||||||
* overwrite:
|
|
||||||
* type: boolean
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Import completed.
|
|
||||||
* 400:
|
|
||||||
* description: Invalid request body.
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/ssh-config-import",
|
|
||||||
authenticateJWT,
|
|
||||||
requireCreatePermission,
|
|
||||||
requireEditPermission,
|
|
||||||
requireDataAccess,
|
|
||||||
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
const { content, overwrite } = req.body;
|
|
||||||
|
|
||||||
if (!isNonEmptyString(content)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: "content is required and must be a non-empty string",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: SSHConfigHost[];
|
|
||||||
try {
|
|
||||||
parsed = parseSSHConfig(content);
|
|
||||||
} catch {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Failed to parse SSH config file" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.length === 0) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: "No valid Host entries found in the SSH config file",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.length > 100) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ error: "Maximum 100 hosts allowed per import" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostsToImport = parsed.map((h) => ({
|
|
||||||
name: h.name,
|
|
||||||
ip: h.hostname,
|
|
||||||
port: h.port ?? 22,
|
|
||||||
username: h.user,
|
|
||||||
authType: h.identityFile ? "key" : undefined,
|
|
||||||
connectionType: "ssh",
|
|
||||||
enableSsh: true,
|
|
||||||
...(h.proxyJump
|
|
||||||
? {
|
|
||||||
jumpHosts: [{ host: h.proxyJump, port: 22 }],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const results = {
|
|
||||||
success: 0,
|
|
||||||
updated: 0,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
errors: [] as string[],
|
|
||||||
};
|
|
||||||
|
|
||||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
|
||||||
const hostRepository = createCurrentHostRepository();
|
|
||||||
try {
|
|
||||||
const allHosts =
|
|
||||||
await createCurrentHostResolutionRepository().findHostsByUserId(
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
existingHostMap = new Map();
|
|
||||||
for (const h of allHosts) {
|
|
||||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
|
||||||
existingHostMap.set(key, { id: h.id as number });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
existingHostMap = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const writtenHostIds: number[] = [];
|
|
||||||
const createdHostIds: number[] = [];
|
|
||||||
for (let i = 0; i < hostsToImport.length; i++) {
|
|
||||||
const hostData = normalizeImportedHost(
|
|
||||||
hostsToImport[i] as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(
|
|
||||||
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
|
||||||
const existing = existingHostMap?.get(lookupKey);
|
|
||||||
if (existing && !overwrite) {
|
|
||||||
results.skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sshDataObj: Record<string, unknown> = {
|
|
||||||
userId,
|
|
||||||
connectionType: "ssh",
|
|
||||||
name: hostData.name || hostData.ip,
|
|
||||||
folder: "Default",
|
|
||||||
tags: "",
|
|
||||||
ip: hostData.ip,
|
|
||||||
port: hostData.port,
|
|
||||||
username: hostData.username || null,
|
|
||||||
authType: hostData.authType || "none",
|
|
||||||
password: null,
|
|
||||||
key: null,
|
|
||||||
keyPassword: null,
|
|
||||||
keyType: null,
|
|
||||||
credentialId: null,
|
|
||||||
pin: false,
|
|
||||||
sudoPassword: null,
|
|
||||||
jumpHosts: hostData.jumpHosts
|
|
||||||
? JSON.stringify(hostData.jumpHosts)
|
|
||||||
: null,
|
|
||||||
statusCheckEnabled: true,
|
|
||||||
statusCheckInterval: null,
|
|
||||||
sshOptions: null,
|
|
||||||
forceKeyboardInteractive: "false",
|
|
||||||
notes: null,
|
|
||||||
useSocks5: 0,
|
|
||||||
socks5Host: null,
|
|
||||||
socks5Port: null,
|
|
||||||
socks5Username: null,
|
|
||||||
socks5Password: null,
|
|
||||||
socks5ProxyChain: null,
|
|
||||||
portKnockSequence: null,
|
|
||||||
overrideCredentialUsername: 0,
|
|
||||||
enableSsh: true,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await withHostDefaults(
|
|
||||||
userId,
|
|
||||||
existing?.id ?? null,
|
|
||||||
sshDataObj,
|
|
||||||
hostData as unknown as Record<string, unknown>,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await hostRepository.updateEncryptedForUser(
|
|
||||||
userId,
|
|
||||||
existing.id,
|
|
||||||
sshDataObj,
|
|
||||||
);
|
|
||||||
writtenHostIds.push(existing.id);
|
|
||||||
results.updated++;
|
|
||||||
} else {
|
|
||||||
sshDataObj.createdAt = new Date().toISOString();
|
|
||||||
const saved = await hostRepository.createEncryptedForUser(
|
|
||||||
userId,
|
|
||||||
sshDataObj,
|
|
||||||
);
|
|
||||||
writtenHostIds.push(saved.id);
|
|
||||||
createdHostIds.push(saved.id);
|
|
||||||
results.success++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
results.failed++;
|
|
||||||
results.errors.push(
|
|
||||||
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
|
||||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
|
||||||
success: results.success,
|
|
||||||
updated: results.updated,
|
|
||||||
skipped: results.skipped,
|
|
||||||
failed: results.failed,
|
|
||||||
errors: results.errors,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status check fields from an import row. Exports from before 2.9.0 carried
|
|
||||||
* them inside statsConfig.
|
|
||||||
*/
|
|
||||||
function importedStatusCheck(raw: Record<string, unknown>): {
|
|
||||||
statusCheckEnabled: boolean;
|
|
||||||
statusCheckInterval: number | null;
|
|
||||||
} {
|
|
||||||
const legacy =
|
|
||||||
raw.statsConfig && typeof raw.statsConfig === "object"
|
|
||||||
? (raw.statsConfig as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
|
|
||||||
const useGlobal = legacy.useGlobalStatusInterval !== false;
|
|
||||||
const interval =
|
|
||||||
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
|
|
||||||
const seconds = Number(interval);
|
|
||||||
return {
|
|
||||||
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
|
|
||||||
statusCheckInterval:
|
|
||||||
interval != null && Number.isInteger(seconds) && seconds >= 5
|
|
||||||
? seconds
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const BUILTIN_SSH_AUTH_TYPES = [
|
|
||||||
"password",
|
|
||||||
"key",
|
|
||||||
"credential",
|
|
||||||
"agent",
|
|
||||||
"none",
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Core's own types plus every type a plugin registers or declares. */
|
|
||||||
function listKnownAuthTypes(): Set<string> {
|
|
||||||
return new Set([
|
|
||||||
...BUILTIN_SSH_AUTH_TYPES,
|
|
||||||
...listSshAuthProviders().map((provider) => provider.type),
|
|
||||||
...listSshAuthTypeOwners().map((owner) => owner.type),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fills an imported host's inherited keys and records what it sets itself. */
|
|
||||||
async function withHostDefaults(
|
|
||||||
userId: string,
|
|
||||||
hostId: number | null,
|
|
||||||
columns: Record<string, unknown>,
|
|
||||||
body: Record<string, unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
const stored =
|
|
||||||
hostId === null
|
|
||||||
? null
|
|
||||||
: ((
|
|
||||||
await createCurrentHostDefaultsRepository().listHosts({
|
|
||||||
hostIds: [hostId],
|
|
||||||
})
|
|
||||||
)[0] ?? null);
|
|
||||||
columns.defaultOverrides = JSON.stringify(
|
|
||||||
await applyHostDefaultsToWrite({
|
|
||||||
ownerId: userId,
|
|
||||||
hostId,
|
|
||||||
columns,
|
|
||||||
body,
|
|
||||||
stored,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A resetDefaults body as an override change, or null when there is none. */
|
/** A resetDefaults body as an override change, or null when there is none. */
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import type { Request, RequestHandler, Response, Router } from "express";
|
|||||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
import { databaseLogger, sshLogger } from "../../utils/logger.js";
|
||||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||||
|
import { recentActivity } from "../../plugins/activity-provider.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentHostFolderRepository,
|
createCurrentHostFolderRepository,
|
||||||
createCurrentFolderAccessRepository,
|
createCurrentFolderAccessRepository,
|
||||||
createCurrentRecentActivityRepository,
|
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
@@ -432,9 +432,7 @@ export function registerHostFolderRoutes(
|
|||||||
hostIds,
|
hostIds,
|
||||||
);
|
);
|
||||||
|
|
||||||
await createCurrentRecentActivityRepository().deleteByHostIds(
|
await recentActivity.deleteByHostIds(hostIds);
|
||||||
hostIds,
|
|
||||||
);
|
|
||||||
|
|
||||||
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
|
await createCurrentRbacAccessRepository().deleteHostAccessForHosts(
|
||||||
hostIds,
|
hostIds,
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import type { Request, RequestHandler, Response, Router } from "express";
|
|||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
import { sshLogger } from "../../utils/logger.js";
|
import { sshLogger } from "../../utils/logger.js";
|
||||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_STATUS_INTERVAL,
|
hostStatus,
|
||||||
GLOBAL_STATUS_INTERVAL_KEY,
|
|
||||||
hostStatusService,
|
|
||||||
type HostStatusEntry,
|
type HostStatusEntry,
|
||||||
} from "../../hosts/status/host-status-service.js";
|
} from "../../plugins/host-status-provider.js";
|
||||||
|
|
||||||
interface HostStatusRoutesDeps {
|
interface HostStatusRoutesDeps {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
@@ -71,7 +68,7 @@ export function registerHostStatusRoutes(
|
|||||||
return sessionExpired(res);
|
return sessionExpired(res);
|
||||||
|
|
||||||
const requested = parseStatusHostIds(req.query.hostIds);
|
const requested = parseStatusHostIds(req.query.hostIds);
|
||||||
const statuses = await hostStatusService.statusesFor(userId, requested);
|
const statuses = await hostStatus.statusesFor(userId, requested);
|
||||||
|
|
||||||
// One batched permission check: this is polled every few seconds, and a
|
// One batched permission check: this is polled every few seconds, and a
|
||||||
// per-host check made it linear in host count against the database.
|
// per-host check made it linear in host count against the database.
|
||||||
@@ -110,15 +107,7 @@ export function registerHostStatusRoutes(
|
|||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
async (_req: Request, res: Response) => {
|
async (_req: Request, res: Response) => {
|
||||||
const value = Number(
|
res.json({ statusCheckInterval: await hostStatus.getIntervalSeconds() });
|
||||||
await createCurrentSettingsRepository().get(GLOBAL_STATUS_INTERVAL_KEY),
|
|
||||||
);
|
|
||||||
res.json({
|
|
||||||
statusCheckInterval:
|
|
||||||
Number.isInteger(value) && value >= 5
|
|
||||||
? value
|
|
||||||
: DEFAULT_STATUS_INTERVAL,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -161,11 +150,7 @@ export function registerHostStatusRoutes(
|
|||||||
.json({ error: "Status check interval must be 5 to 3600 seconds" });
|
.json({ error: "Status check interval must be 5 to 3600 seconds" });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createCurrentSettingsRepository().set(
|
await hostStatus.setIntervalSeconds(seconds);
|
||||||
GLOBAL_STATUS_INTERVAL_KEY,
|
|
||||||
String(seconds),
|
|
||||||
);
|
|
||||||
hostStatusService.retimeAll();
|
|
||||||
res.json({ statusCheckInterval: seconds });
|
res.json({ statusCheckInterval: seconds });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sshLogger.error("Failed to save status check settings", error, {
|
sshLogger.error("Failed to save status check settings", error, {
|
||||||
@@ -198,7 +183,7 @@ export function registerHostStatusRoutes(
|
|||||||
if (DataCrypto.getUserDataKey(userId) === null) {
|
if (DataCrypto.getUserDataKey(userId) === null) {
|
||||||
return sessionExpired(res);
|
return sessionExpired(res);
|
||||||
}
|
}
|
||||||
await hostStatusService.refresh(userId);
|
await hostStatus.refresh(userId);
|
||||||
res.json({ message: "Status checks restarted" });
|
res.json({ message: "Status checks restarted" });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -247,9 +232,8 @@ export function registerHostStatusRoutes(
|
|||||||
return res.status(404).json({ error: "Status not available" });
|
return res.status(404).json({ error: "Status not available" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await hostStatusService.statusesFor(userId, null);
|
await hostStatus.statusesFor(userId, null);
|
||||||
const entry =
|
const entry = hostStatus.get(id) ?? (await hostStatus.check(id));
|
||||||
hostStatusService.get(id) ?? (await hostStatusService.check(id));
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return res.status(404).json({ error: "Status not available" });
|
return res.status(404).json({ error: "Status not available" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,25 +7,21 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
|
|||||||
import express, { type Request, type Response } from "express";
|
import express, { type Request, type Response } from "express";
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { sshLogger, databaseLogger } from "../../utils/logger.js";
|
import { sshLogger, databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { resolveHostCredentials } from "../../hosts/resolve-host-credentials.js";
|
||||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||||
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
|
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
|
||||||
import {
|
import { pickResolvedPassword } from "../../hosts/credential-username.js";
|
||||||
pickResolvedPassword,
|
|
||||||
pickResolvedUsername,
|
|
||||||
} from "../../hosts/credential-username.js";
|
|
||||||
import { emitInternalEvent } from "../../hosts/internal-events.js";
|
import { emitInternalEvent } from "../../hosts/internal-events.js";
|
||||||
import { deleteOwnedHost } from "../../hosts/delete-host.js";
|
import { deleteOwnedHost } from "../../hosts/delete-host.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCredentialRepository,
|
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRoleRepository,
|
createCurrentRoleRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
createCurrentSharedHostAuthOverrideRepository,
|
|
||||||
createCurrentHostDefaultsRepository,
|
createCurrentHostDefaultsRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import {
|
import {
|
||||||
@@ -41,7 +37,6 @@ import {
|
|||||||
OWNER_PRIVATE_AUTH_FIELDS,
|
OWNER_PRIVATE_AUTH_FIELDS,
|
||||||
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
OWNER_PRIVATE_SSH_OPTION_FIELDS,
|
||||||
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
|
OWNER_PRIVATE_TERMINAL_CONFIG_FIELDS,
|
||||||
hostTerminalExport,
|
|
||||||
sanitizeHostForRecipient,
|
sanitizeHostForRecipient,
|
||||||
stripSensitiveFields,
|
stripSensitiveFields,
|
||||||
transformHostResponse,
|
transformHostResponse,
|
||||||
@@ -67,14 +62,8 @@ import {
|
|||||||
getAuditUsername,
|
getAuditUsername,
|
||||||
getRequestMeta,
|
getRequestMeta,
|
||||||
} from "../../utils/audit-logger.js";
|
} from "../../utils/audit-logger.js";
|
||||||
import type {
|
import type { HostResolutionCredentialRecord } from "../repositories/host-resolution-repository.js";
|
||||||
HostResolutionCredentialRecord,
|
|
||||||
HostResolutionHostRecord,
|
|
||||||
} from "../repositories/host-resolution-repository.js";
|
|
||||||
import {
|
|
||||||
requiresPersonalHostAuthentication,
|
|
||||||
resolveRecipientSharedHostAuthentication,
|
|
||||||
} from "../../utils/shared-host-auth-resolver.js";
|
|
||||||
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
||||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
||||||
import {
|
import {
|
||||||
@@ -91,15 +80,11 @@ import {
|
|||||||
planProtocolAuthWrite,
|
planProtocolAuthWrite,
|
||||||
ProtocolAuthWriteError,
|
ProtocolAuthWriteError,
|
||||||
readProtocolAuthPayload,
|
readProtocolAuthPayload,
|
||||||
toPortableLogins,
|
|
||||||
withProtocolAuth,
|
withProtocolAuth,
|
||||||
writeProtocolAuth,
|
writeProtocolAuth,
|
||||||
type PlannedProtocolAuth,
|
type PlannedProtocolAuth,
|
||||||
} from "../../hosts/protocol-auth/protocol-auth.js";
|
} from "../../hosts/protocol-auth/protocol-auth.js";
|
||||||
import {
|
import { findHostProtocol } from "../../hosts/protocol-auth/registry.js";
|
||||||
findHostProtocol,
|
|
||||||
listHostProtocols,
|
|
||||||
} from "../../hosts/protocol-auth/registry.js";
|
|
||||||
import {
|
import {
|
||||||
mergeStoredSshOptions,
|
mergeStoredSshOptions,
|
||||||
parseTerminalConfig,
|
parseTerminalConfig,
|
||||||
@@ -1688,340 +1673,6 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /host/db/host/{id}/export:
|
|
||||||
* get:
|
|
||||||
* summary: Export SSH host
|
|
||||||
* description: Exports a specific SSH host with decrypted credentials.
|
|
||||||
* tags:
|
|
||||||
* - SSH
|
|
||||||
* parameters:
|
|
||||||
* - in: path
|
|
||||||
* name: id
|
|
||||||
* required: true
|
|
||||||
* schema:
|
|
||||||
* type: integer
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: The exported SSH host.
|
|
||||||
* 400:
|
|
||||||
* description: Invalid userId or hostId.
|
|
||||||
* 404:
|
|
||||||
* description: SSH host not found.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to export SSH host.
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/db/host/:id/export",
|
|
||||||
authenticateJWT,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const hostId = Array.isArray(req.params.id)
|
|
||||||
? req.params.id[0]
|
|
||||||
: req.params.id;
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
|
|
||||||
if (!isNonEmptyString(userId) || !hostId) {
|
|
||||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const host =
|
|
||||||
await createCurrentHostResolutionRepository().findHostByIdForUser(
|
|
||||||
Number(hostId),
|
|
||||||
userId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!host) {
|
|
||||||
return res.status(404).json({ error: "SSH host not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedHost = (await resolveHostCredentials(host, userId)) || host;
|
|
||||||
const hostPluginSettings = (
|
|
||||||
await loadHostPluginSettings([Number(hostId)])
|
|
||||||
).get(Number(hostId));
|
|
||||||
|
|
||||||
const exportedConnectionType =
|
|
||||||
(resolvedHost.connectionType as string) || "ssh";
|
|
||||||
const isRemoteDesktop = exportedConnectionType !== "ssh";
|
|
||||||
|
|
||||||
const baseExportData = {
|
|
||||||
exportId: resolvedHost.id,
|
|
||||||
connectionType: exportedConnectionType,
|
|
||||||
name: resolvedHost.name,
|
|
||||||
ip: resolvedHost.ip,
|
|
||||||
port: resolvedHost.port,
|
|
||||||
username: resolvedHost.username,
|
|
||||||
password: resolvedHost.password || null,
|
|
||||||
folder: resolvedHost.folder,
|
|
||||||
tags:
|
|
||||||
typeof resolvedHost.tags === "string"
|
|
||||||
? resolvedHost.tags.split(",").filter(Boolean)
|
|
||||||
: resolvedHost.tags || [],
|
|
||||||
pin: !!resolvedHost.pin,
|
|
||||||
notes: resolvedHost.notes || null,
|
|
||||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
|
||||||
pluginSettings: hostPluginSettings ?? {},
|
|
||||||
// Each plugin protocol's login, secrets included like the SSH ones.
|
|
||||||
protocolAuth: toPortableLogins(
|
|
||||||
await listProtocolLogins(Number(hostId), userId),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = isRemoteDesktop
|
|
||||||
? baseExportData
|
|
||||||
: {
|
|
||||||
...baseExportData,
|
|
||||||
authType: resolvedHost.authType,
|
|
||||||
key: resolvedHost.key || null,
|
|
||||||
keyPassword: resolvedHost.keyPassword || null,
|
|
||||||
keyType: resolvedHost.keyType || null,
|
|
||||||
credentialId: resolvedHost.credentialId || null,
|
|
||||||
overrideCredentialUsername:
|
|
||||||
!!resolvedHost.overrideCredentialUsername,
|
|
||||||
sudoPassword: resolvedHost.sudoPassword || null,
|
|
||||||
jumpHosts: resolvedHost.jumpHosts
|
|
||||||
? JSON.parse(resolvedHost.jumpHosts as string)
|
|
||||||
: null,
|
|
||||||
terminalConfig:
|
|
||||||
hostTerminalExport(resolvedHost).terminalConfig ?? null,
|
|
||||||
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
|
|
||||||
forceKeyboardInteractive:
|
|
||||||
resolvedHost.forceKeyboardInteractive === "true",
|
|
||||||
useSocks5: !!resolvedHost.useSocks5,
|
|
||||||
socks5Host: resolvedHost.socks5Host || null,
|
|
||||||
socks5Port: resolvedHost.socks5Port || null,
|
|
||||||
socks5Username: resolvedHost.socks5Username || null,
|
|
||||||
socks5Password: resolvedHost.socks5Password || null,
|
|
||||||
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
|
||||||
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
|
||||||
: null,
|
|
||||||
portKnockSequence: resolvedHost.portKnockSequence
|
|
||||||
? JSON.parse(resolvedHost.portKnockSequence as string)
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
sshLogger.success("Host exported with decrypted credentials", {
|
|
||||||
operation: "host_export",
|
|
||||||
hostId: parseInt(hostId),
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(exportData);
|
|
||||||
} catch (err) {
|
|
||||||
sshLogger.error("Failed to export SSH host", err, {
|
|
||||||
operation: "host_export",
|
|
||||||
hostId: parseInt(hostId),
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: "Failed to export SSH host" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /host/db/hosts/export:
|
|
||||||
* get:
|
|
||||||
* summary: Export all SSH hosts
|
|
||||||
* description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user.
|
|
||||||
* tags:
|
|
||||||
* - SSH
|
|
||||||
* parameters:
|
|
||||||
* - in: query
|
|
||||||
* name: share
|
|
||||||
* required: false
|
|
||||||
* schema:
|
|
||||||
* type: string
|
|
||||||
* description: Set to "1" to export without embedded secrets.
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: All exported SSH hosts.
|
|
||||||
* 400:
|
|
||||||
* description: Invalid userId.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to export SSH hosts.
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/db/hosts/export",
|
|
||||||
authenticateJWT,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req: Request, res: Response) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
const shareMode = req.query.share === "1" || req.query.share === "true";
|
|
||||||
|
|
||||||
if (!isNonEmptyString(userId)) {
|
|
||||||
return res.status(400).json({ error: "Invalid userId" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const allHosts =
|
|
||||||
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
|
||||||
const pluginSettingsByHost = await loadHostPluginSettings(
|
|
||||||
allHosts.map((h) => h.id as number),
|
|
||||||
);
|
|
||||||
|
|
||||||
const exportedHosts = [];
|
|
||||||
const usedCredentialIds = new Set<number>();
|
|
||||||
|
|
||||||
for (const host of allHosts) {
|
|
||||||
const resolvedHost = shareMode
|
|
||||||
? host
|
|
||||||
: (await resolveHostCredentials(host, userId)) || host;
|
|
||||||
const hostPluginSettings = pluginSettingsByHost.get(host.id as number);
|
|
||||||
|
|
||||||
const exportedConnectionType =
|
|
||||||
(resolvedHost.connectionType as string) || "ssh";
|
|
||||||
const isRemoteDesktop = exportedConnectionType !== "ssh";
|
|
||||||
|
|
||||||
const baseExportData = {
|
|
||||||
exportId: resolvedHost.id,
|
|
||||||
connectionType: exportedConnectionType,
|
|
||||||
name: resolvedHost.name,
|
|
||||||
ip: resolvedHost.ip,
|
|
||||||
port: resolvedHost.port,
|
|
||||||
username: resolvedHost.username,
|
|
||||||
password: shareMode ? null : resolvedHost.password || null,
|
|
||||||
folder: resolvedHost.folder,
|
|
||||||
tags:
|
|
||||||
typeof resolvedHost.tags === "string"
|
|
||||||
? resolvedHost.tags.split(",").filter(Boolean)
|
|
||||||
: resolvedHost.tags || [],
|
|
||||||
pin: !!resolvedHost.pin,
|
|
||||||
notes: resolvedHost.notes || null,
|
|
||||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
|
||||||
pluginSettings: hostPluginSettings ?? {},
|
|
||||||
protocolAuth: shareableLogins(
|
|
||||||
toPortableLogins(
|
|
||||||
await listProtocolLogins(host.id as number, userId),
|
|
||||||
),
|
|
||||||
shareMode,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const exportData = isRemoteDesktop
|
|
||||||
? baseExportData
|
|
||||||
: {
|
|
||||||
...baseExportData,
|
|
||||||
authType: resolvedHost.authType,
|
|
||||||
key: shareMode ? null : resolvedHost.key || null,
|
|
||||||
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
|
|
||||||
keyType: resolvedHost.keyType || null,
|
|
||||||
credentialId: resolvedHost.credentialId || null,
|
|
||||||
overrideCredentialUsername:
|
|
||||||
!!resolvedHost.overrideCredentialUsername,
|
|
||||||
sudoPassword: shareMode
|
|
||||||
? null
|
|
||||||
: resolvedHost.sudoPassword || null,
|
|
||||||
jumpHosts: resolvedHost.jumpHosts
|
|
||||||
? JSON.parse(resolvedHost.jumpHosts as string)
|
|
||||||
: null,
|
|
||||||
terminalConfig:
|
|
||||||
hostTerminalExport(resolvedHost).terminalConfig ?? null,
|
|
||||||
sshOptions: hostTerminalExport(resolvedHost).sshOptions,
|
|
||||||
forceKeyboardInteractive:
|
|
||||||
resolvedHost.forceKeyboardInteractive === "true",
|
|
||||||
useSocks5: !!resolvedHost.useSocks5,
|
|
||||||
socks5Host: resolvedHost.socks5Host || null,
|
|
||||||
socks5Port: resolvedHost.socks5Port || null,
|
|
||||||
socks5Username: resolvedHost.socks5Username || null,
|
|
||||||
socks5Password: shareMode
|
|
||||||
? null
|
|
||||||
: resolvedHost.socks5Password || null,
|
|
||||||
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
|
||||||
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
shareMode &&
|
|
||||||
!isRemoteDesktop &&
|
|
||||||
resolvedHost.authType === "credential" &&
|
|
||||||
resolvedHost.credentialId
|
|
||||||
) {
|
|
||||||
usedCredentialIds.add(resolvedHost.credentialId as number);
|
|
||||||
}
|
|
||||||
|
|
||||||
exportedHosts.push(exportData);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shareMode) {
|
|
||||||
sshLogger.success("All hosts exported with decrypted credentials", {
|
|
||||||
operation: "hosts_export_all",
|
|
||||||
count: exportedHosts.length,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({ hosts: exportedHosts });
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportedCredentials: Record<string, unknown>[] = [];
|
|
||||||
if (usedCredentialIds.size > 0) {
|
|
||||||
const credentialRepository = createCurrentCredentialRepository();
|
|
||||||
const ownedCredentials =
|
|
||||||
await credentialRepository.listDecryptedByUserId(userId);
|
|
||||||
const credentialById = new Map(
|
|
||||||
ownedCredentials.map((credential) => [credential.id, credential]),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const host of exportedHosts as Record<string, unknown>[]) {
|
|
||||||
const credentialId = host.credentialId as number | null;
|
|
||||||
if (!credentialId) continue;
|
|
||||||
const credential = credentialById.get(credentialId);
|
|
||||||
if (!credential) continue;
|
|
||||||
|
|
||||||
host.credentialAlias = credential.name;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!exportedCredentials.some(
|
|
||||||
(entry) => entry.alias === credential.name,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
exportedCredentials.push({
|
|
||||||
alias: credential.name,
|
|
||||||
name: credential.name,
|
|
||||||
description: credential.description || null,
|
|
||||||
folder: credential.folder || null,
|
|
||||||
tags:
|
|
||||||
typeof credential.tags === "string"
|
|
||||||
? credential.tags.split(",").filter(Boolean)
|
|
||||||
: [],
|
|
||||||
authType: credential.authType,
|
|
||||||
username: credential.username || null,
|
|
||||||
keyType: credential.keyType || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const host of exportedHosts as Record<string, unknown>[]) {
|
|
||||||
delete host.credentialId;
|
|
||||||
}
|
|
||||||
|
|
||||||
sshLogger.success("All hosts exported for sharing without secrets", {
|
|
||||||
operation: "hosts_export_all_share",
|
|
||||||
count: exportedHosts.length,
|
|
||||||
credentialCount: exportedCredentials.length,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
version: "1",
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
credentials: exportedCredentials,
|
|
||||||
hosts: exportedHosts,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
sshLogger.error("Failed to export all SSH hosts", err, {
|
|
||||||
operation: "hosts_export_all",
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: "Failed to export SSH hosts" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /host/db/host/{id}:
|
* /host/db/host/{id}:
|
||||||
@@ -2117,231 +1768,6 @@ router.delete(
|
|||||||
// the file-manager plugin, under /plugin-api/file-manager/, and command
|
// the file-manager plugin, under /plugin-api/file-manager/, and command
|
||||||
// history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/.
|
// history to the ssh-terminal plugin, under /plugin-api/ssh-terminal/.
|
||||||
|
|
||||||
/**
|
|
||||||
* A share export leaves out every secret and credential link, the same as
|
|
||||||
* it does for SSH.
|
|
||||||
*/
|
|
||||||
function shareableLogins(
|
|
||||||
logins: ReturnType<typeof toPortableLogins>,
|
|
||||||
shareMode: boolean,
|
|
||||||
): ReturnType<typeof toPortableLogins> {
|
|
||||||
if (!shareMode) return logins;
|
|
||||||
const out: ReturnType<typeof toPortableLogins> = {};
|
|
||||||
for (const [protocol, login] of Object.entries(logins)) {
|
|
||||||
const declared = findHostProtocol(protocol);
|
|
||||||
const secret = new Set(
|
|
||||||
(declared?.credentialFields ?? [])
|
|
||||||
.filter((field) => field.secret)
|
|
||||||
.map((field) => field.key),
|
|
||||||
);
|
|
||||||
out[protocol] = {
|
|
||||||
authType: login.authType === "credential" ? "direct" : login.authType,
|
|
||||||
credentialId: null,
|
|
||||||
username: login.username,
|
|
||||||
password: null,
|
|
||||||
fields: Object.fromEntries(
|
|
||||||
Object.entries(login.fields).filter(
|
|
||||||
([key]) => declared && !secret.has(key),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveHostCredentials(
|
|
||||||
host: Record<string, unknown>,
|
|
||||||
requestingUserId?: string,
|
|
||||||
/**
|
|
||||||
* Credentials already fetched for this request, keyed by id. The host list
|
|
||||||
* preloads them in one query; single-host callers omit it and fall back to
|
|
||||||
* fetching the one credential they need.
|
|
||||||
*/
|
|
||||||
preloadedCredentials?: Map<number, HostResolutionCredentialRecord>,
|
|
||||||
): Promise<Record<string, unknown>> {
|
|
||||||
try {
|
|
||||||
const ownerId = (host.ownerId || host.userId) as string | undefined;
|
|
||||||
if (
|
|
||||||
requestingUserId &&
|
|
||||||
ownerId &&
|
|
||||||
requestingUserId !== ownerId &&
|
|
||||||
typeof host.id === "number"
|
|
||||||
) {
|
|
||||||
const authHost = host as unknown as HostResolutionHostRecord;
|
|
||||||
const needsPersonalCredential = requiresPersonalHostAuthentication(
|
|
||||||
authHost,
|
|
||||||
"ssh",
|
|
||||||
);
|
|
||||||
const baseSshOverrideState = {
|
|
||||||
required: needsPersonalCredential,
|
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
|
||||||
};
|
|
||||||
// Owner auth for the remote desktop protocols is always snapshotted
|
|
||||||
// for recipients; only their own override credential varies per user.
|
|
||||||
const authOverrides: Record<string, unknown> = {
|
|
||||||
ssh: baseSshOverrideState,
|
|
||||||
};
|
|
||||||
const overrideCredentialIds =
|
|
||||||
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
|
|
||||||
host.id,
|
|
||||||
requestingUserId,
|
|
||||||
);
|
|
||||||
// Whether a plugin protocol is on is its plugin's host setting; the
|
|
||||||
// client only offers an override for one that is.
|
|
||||||
for (const { id: protocol } of listHostProtocols()) {
|
|
||||||
authOverrides[protocol] = {
|
|
||||||
credentialId: overrideCredentialIds[protocol],
|
|
||||||
required: false,
|
|
||||||
ownerAuthShared: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const recipientHost: Record<string, unknown> = {
|
|
||||||
...host,
|
|
||||||
credentialId: null,
|
|
||||||
password: null,
|
|
||||||
key: null,
|
|
||||||
keyPassword: null,
|
|
||||||
keyType: null,
|
|
||||||
authOverrides,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resolution = await resolveRecipientSharedHostAuthentication(
|
|
||||||
authHost,
|
|
||||||
host.id,
|
|
||||||
requestingUserId,
|
|
||||||
"ssh",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resolution.source === "personal-override") {
|
|
||||||
const credential = resolution.credential;
|
|
||||||
return {
|
|
||||||
...recipientHost,
|
|
||||||
authOverrides: {
|
|
||||||
...authOverrides,
|
|
||||||
ssh: {
|
|
||||||
credentialId: resolution.credentialId,
|
|
||||||
required: false,
|
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authType:
|
|
||||||
credential.key || credential.privateKey
|
|
||||||
? "key"
|
|
||||||
: credential.password
|
|
||||||
? "password"
|
|
||||||
: "none",
|
|
||||||
username: credential.username || recipientHost.username,
|
|
||||||
password: credential.password,
|
|
||||||
key: credential.privateKey || credential.key,
|
|
||||||
keyPassword: credential.keyPassword,
|
|
||||||
keyType: credential.keyType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolution.source === "owner-shared") {
|
|
||||||
if (resolution.authType === "agent") {
|
|
||||||
return {
|
|
||||||
...recipientHost,
|
|
||||||
authOverrides: {
|
|
||||||
...authOverrides,
|
|
||||||
ssh: {
|
|
||||||
required: false,
|
|
||||||
ownerAuthShared: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authType: "agent",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sharedAuth = resolution.secret;
|
|
||||||
if (sharedAuth) {
|
|
||||||
const resolvedUsername = pickResolvedUsername(
|
|
||||||
recipientHost.username,
|
|
||||||
sharedAuth.username,
|
|
||||||
host.overrideCredentialUsername,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...recipientHost,
|
|
||||||
authOverrides: {
|
|
||||||
...authOverrides,
|
|
||||||
ssh: {
|
|
||||||
required: false,
|
|
||||||
ownerAuthShared: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
authType: sharedAuth.key
|
|
||||||
? "key"
|
|
||||||
: sharedAuth.password
|
|
||||||
? "password"
|
|
||||||
: "none",
|
|
||||||
username: resolvedUsername,
|
|
||||||
password: sharedAuth.password,
|
|
||||||
key: sharedAuth.key,
|
|
||||||
keyPassword: sharedAuth.keyPassword,
|
|
||||||
keyType: sharedAuth.keyType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolution.source === "secretless") {
|
|
||||||
return {
|
|
||||||
...recipientHost,
|
|
||||||
authOverrides: {
|
|
||||||
...authOverrides,
|
|
||||||
ssh: {
|
|
||||||
required: false,
|
|
||||||
ownerAuthShared: !!host.shareSshAuth,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// A missing/deleted override or snapshot behaves like unavailable auth.
|
|
||||||
}
|
|
||||||
|
|
||||||
return recipientHost;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (host.credentialId && (host.userId || host.ownerId)) {
|
|
||||||
const credentialId = host.credentialId as number;
|
|
||||||
const credentialOwnerId = (host.ownerId || host.userId) as string;
|
|
||||||
|
|
||||||
const credential =
|
|
||||||
preloadedCredentials?.get(credentialId) ??
|
|
||||||
(await findUsableCredential(credentialId, credentialOwnerId));
|
|
||||||
|
|
||||||
if (credential) {
|
|
||||||
const resolvedHost: Record<string, unknown> = {
|
|
||||||
...host,
|
|
||||||
password: pickResolvedPassword(host.password, credential.password),
|
|
||||||
key: credential.key,
|
|
||||||
keyPassword: credential.keyPassword,
|
|
||||||
keyType: credential.keyType,
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolvedUsername = pickResolvedUsername(
|
|
||||||
host.username,
|
|
||||||
credential.username,
|
|
||||||
host.overrideCredentialUsername,
|
|
||||||
);
|
|
||||||
if (resolvedUsername !== undefined) {
|
|
||||||
resolvedHost.username = resolvedUsername;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedHost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...host };
|
|
||||||
} catch (error) {
|
|
||||||
sshLogger.warn(
|
|
||||||
`Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`,
|
|
||||||
);
|
|
||||||
return host;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerHostFolderRoutes(router, {
|
registerHostFolderRoutes(router, {
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
requireViewPermission: permissionManager.requirePermission("hosts.view"),
|
||||||
@@ -2355,7 +1781,6 @@ registerHostFolderRoutes(router, {
|
|||||||
registerHostBulkRoutes(
|
registerHostBulkRoutes(
|
||||||
router,
|
router,
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
permissionManager.requirePermission("hosts.create"),
|
|
||||||
permissionManager.requirePermission("hosts.edit"),
|
permissionManager.requirePermission("hosts.edit"),
|
||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import express, { type Request, type Response } from "express";
|
||||||
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
|
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||||
|
import {
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
createCurrentUserPreferenceRepository,
|
||||||
|
createCurrentUserRepository,
|
||||||
|
} from "../repositories/factory.js";
|
||||||
|
import { getPluginRuntime } from "../../plugins/index.js";
|
||||||
|
import {
|
||||||
|
LAYOUT_SETTING,
|
||||||
|
LOOK_ID_PATTERN,
|
||||||
|
THEME_SETTING,
|
||||||
|
installedLayoutIds,
|
||||||
|
resolveDefaultLook,
|
||||||
|
safeLayoutForced,
|
||||||
|
unavailableLooks,
|
||||||
|
} from "../../plugins/looks.js";
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const authManager = AuthManager.getInstance();
|
||||||
|
const authenticateJWT = authManager.createAuthMiddleware();
|
||||||
|
|
||||||
|
async function adminDefaults() {
|
||||||
|
const settings = createCurrentSettingsRepository();
|
||||||
|
return {
|
||||||
|
layout: await settings.get(LAYOUT_SETTING),
|
||||||
|
theme: await settings.get(THEME_SETTING),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /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, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||||
|
* tags:
|
||||||
|
* - User Preferences
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: What the look is chosen from.
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* layoutId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* themeId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* defaults:
|
||||||
|
* type: object
|
||||||
|
* description: Admin defaults, null where unset.
|
||||||
|
* properties:
|
||||||
|
* layout:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* theme:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* defaultLook:
|
||||||
|
* type: object
|
||||||
|
* description: The default-look plugin's layout and theme, null where none is installed.
|
||||||
|
* properties:
|
||||||
|
* layout:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* theme:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* safeLayout:
|
||||||
|
* type: boolean
|
||||||
|
* unavailable:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* id:
|
||||||
|
* type: string
|
||||||
|
* version:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* layouts:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* themes:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* error:
|
||||||
|
* type: string
|
||||||
|
*/
|
||||||
|
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const preferences =
|
||||||
|
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||||
|
const { loader } = getPluginRuntime();
|
||||||
|
const installed = installedLayoutIds(loader);
|
||||||
|
const known = (id: string | null | undefined) =>
|
||||||
|
id && installed.has(id) ? id : null;
|
||||||
|
const defaults = await adminDefaults();
|
||||||
|
res.json({
|
||||||
|
layoutId: known(preferences?.layoutId),
|
||||||
|
themeId: preferences?.themeId ?? null,
|
||||||
|
defaults: { ...defaults, layout: known(defaults.layout) },
|
||||||
|
defaultLook: resolveDefaultLook(loader),
|
||||||
|
safeLayout: safeLayoutForced(),
|
||||||
|
unavailable: unavailableLooks(loader),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to get the look", error, {
|
||||||
|
operation: "get_look",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to get the look" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /user-preferences/look/defaults:
|
||||||
|
* put:
|
||||||
|
* summary: Set the default layout and theme (admin only)
|
||||||
|
* description: What everyone sees until they choose their own. Null clears a default so the default look applies.
|
||||||
|
* tags:
|
||||||
|
* - User Preferences
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* layout:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* theme:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The defaults now in effect.
|
||||||
|
* 400:
|
||||||
|
* description: Not a layout or theme id.
|
||||||
|
* 403:
|
||||||
|
* description: Not an admin.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/defaults",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
const actor = await createCurrentUserRepository().findById(userId);
|
||||||
|
if (!actor?.isAdmin) {
|
||||||
|
return res.status(403).json({ error: "Not authorized" });
|
||||||
|
}
|
||||||
|
const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown };
|
||||||
|
const changes: Array<[string, unknown]> = [
|
||||||
|
[LAYOUT_SETTING, body.layout],
|
||||||
|
[THEME_SETTING, body.theme],
|
||||||
|
];
|
||||||
|
for (const [key, value] of changes) {
|
||||||
|
if (
|
||||||
|
value !== undefined &&
|
||||||
|
value !== null &&
|
||||||
|
(typeof value !== "string" || !LOOK_ID_PATTERN.test(value))
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: `${key} must be a layout or theme id, or null` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const settings = createCurrentSettingsRepository();
|
||||||
|
for (const [key, value] of changes) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
if (value === null) await settings.delete(key);
|
||||||
|
else await settings.set(key, value as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId,
|
||||||
|
username: actor.username ?? userId,
|
||||||
|
action: "update_default_look",
|
||||||
|
resourceType: "setting",
|
||||||
|
details: JSON.stringify({ layout: body.layout, theme: body.theme }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
res.json(await adminDefaults());
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to set the default look", error, {
|
||||||
|
operation: "set_default_look",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to set the default look" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
createCurrentPluginRepository,
|
createCurrentPluginRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { getPluginRuntime } from "../../plugins/index.js";
|
import { getPluginRuntime } from "../../plugins/index.js";
|
||||||
|
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||||
|
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
|
||||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||||
@@ -296,7 +298,9 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
let features: string[] = [];
|
let features: string[] = [];
|
||||||
let docs: string | undefined;
|
let docs: string | undefined;
|
||||||
let env: PluginEnvVar[] = [];
|
let env: PluginEnvVar[] = [];
|
||||||
let desktopMode: unknown = loaded?.manifest.desktop;
|
let desktopMode: unknown = loaded
|
||||||
|
? resolvePluginDesktopMode(loaded.manifest)
|
||||||
|
: undefined;
|
||||||
try {
|
try {
|
||||||
const manifest = JSON.parse(record.manifestJson) as {
|
const manifest = JSON.parse(record.manifestJson) as {
|
||||||
contributes?: unknown;
|
contributes?: unknown;
|
||||||
@@ -312,8 +316,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
dependencies?: Record<string, string>;
|
dependencies?: Record<string, string>;
|
||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
desktop?: unknown;
|
desktop?: unknown;
|
||||||
|
runtime?: unknown;
|
||||||
};
|
};
|
||||||
desktopMode = loaded?.manifest.desktop ?? manifest?.desktop;
|
desktopMode = loaded
|
||||||
|
? resolvePluginDesktopMode(loaded.manifest)
|
||||||
|
: (manifest?.desktop ??
|
||||||
|
(manifest?.runtime === "desktop" ? "local" : undefined));
|
||||||
contributes = manifest?.contributes ?? null;
|
contributes = manifest?.contributes ?? null;
|
||||||
capabilities = Array.isArray(manifest?.capabilities)
|
capabilities = Array.isArray(manifest?.capabilities)
|
||||||
? (manifest.capabilities as string[])
|
? (manifest.capabilities as string[])
|
||||||
@@ -352,6 +360,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
dependencies,
|
dependencies,
|
||||||
optionalDependencies,
|
optionalDependencies,
|
||||||
docs,
|
docs,
|
||||||
|
defaultLook: isDefaultLookPlugin(record.id),
|
||||||
...describePluginFrontend(loaded),
|
...describePluginFrontend(loaded),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2279,6 +2288,63 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /plugins/{id}/restore-bundled:
|
||||||
|
* post:
|
||||||
|
* summary: Restore the bundled copy of a plugin
|
||||||
|
* description: >
|
||||||
|
* Drops any update downloaded over a plugin that ships with Termix and
|
||||||
|
* loads the copy from the image again, so a broken release can be rolled
|
||||||
|
* back from the layout repair view. The shipped version is pinned so
|
||||||
|
* auto update does not bring the release back. Dependents restart.
|
||||||
|
* tags:
|
||||||
|
* - Plugins
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The restored version and the plugin's state.
|
||||||
|
* 400:
|
||||||
|
* description: Invalid plugin id.
|
||||||
|
* 403:
|
||||||
|
* description: The caller lacks admin.plugins.manage.
|
||||||
|
* 409:
|
||||||
|
* description: The plugin has no bundled copy, or a linked server manages it.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:id/restore-bundled",
|
||||||
|
authenticateJWT,
|
||||||
|
requireManagePlugins,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const pluginId = String(req.params.id);
|
||||||
|
if (!ID_PATTERN.test(pluginId)) {
|
||||||
|
res.status(400).json({ error: "Invalid plugin id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await refuseOnLinkedDesktop(res)) return;
|
||||||
|
try {
|
||||||
|
const { restoreBundledPlugin } = await import("../../plugins/manage.js");
|
||||||
|
const result = await restoreBundledPlugin(pluginId);
|
||||||
|
await auditPluginAction(req, "restore_bundled_plugin", pluginId, {
|
||||||
|
version: result.version,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
sendManageError(
|
||||||
|
res,
|
||||||
|
error,
|
||||||
|
"Failed to restore the bundled copy",
|
||||||
|
"plugin_restore_bundled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /plugins/{id}:
|
* /plugins/{id}:
|
||||||
@@ -2306,7 +2372,7 @@ router.post(
|
|||||||
* 404:
|
* 404:
|
||||||
* description: No such plugin.
|
* description: No such plugin.
|
||||||
* 409:
|
* 409:
|
||||||
* description: Other running plugins depend on it, or managed by a linked server.
|
* description: Other running plugins depend on it, it is the default look, or a linked server manages it.
|
||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { Router } from "express";
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
import { recentActivity } from "../../plugins/activity-provider.js";
|
||||||
import { AuthManager } from "../../utils/auth-manager.js";
|
import { AuthManager } from "../../utils/auth-manager.js";
|
||||||
import { authLogger } from "../../utils/logger.js";
|
import { authLogger } from "../../utils/logger.js";
|
||||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||||
import {
|
import {
|
||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentRecentActivityRepository,
|
|
||||||
createCurrentSettingsRepository,
|
createCurrentSettingsRepository,
|
||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
@@ -68,7 +68,7 @@ export async function resetUserPassword(
|
|||||||
await userRepository.update(userId, { passwordHash });
|
await userRepository.update(userId, { passwordHash });
|
||||||
|
|
||||||
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
await createCurrentSshCredentialUsageRepository().deleteByUserId(userId);
|
||||||
await createCurrentRecentActivityRepository().deleteByUserId(userId);
|
await recentActivity.deleteByUserId(userId);
|
||||||
await createCurrentHostRepository().deleteByUserId(userId);
|
await createCurrentHostRepository().deleteByUserId(userId);
|
||||||
await createCurrentCredentialRepository().deleteByUserId(userId);
|
await createCurrentCredentialRepository().deleteByUserId(userId);
|
||||||
// A plugin holding this user's data (anything keyed by
|
// A plugin holding this user's data (anything keyed by
|
||||||
|
|||||||
@@ -32,8 +32,13 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
|||||||
compactHostView: row?.compactHostView ?? null,
|
compactHostView: row?.compactHostView ?? null,
|
||||||
statusColorScheme: row?.statusColorScheme ?? null,
|
statusColorScheme: row?.statusColorScheme ?? null,
|
||||||
customKeybindings: row?.customKeybindings ?? null,
|
customKeybindings: row?.customKeybindings ?? null,
|
||||||
|
layoutId: row?.layoutId ?? null,
|
||||||
|
themeId: row?.themeId ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Layout and theme ids, the same shape the plugin manifest allows. */
|
||||||
|
const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /user-preferences:
|
* /user-preferences:
|
||||||
@@ -104,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
|||||||
* type: string
|
* type: string
|
||||||
* nullable: true
|
* nullable: true
|
||||||
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
|
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
|
||||||
|
* layoutId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* description: The layout this user chose, overriding the admin default. Null follows the default.
|
||||||
|
* themeId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* description: The theme this user chose, overriding the admin default. Null follows the default.
|
||||||
*/
|
*/
|
||||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
@@ -165,6 +178,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
* customKeybindings:
|
* customKeybindings:
|
||||||
* type: string
|
* type: string
|
||||||
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
|
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
|
||||||
|
* layoutId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* description: A layout id, or null to follow the admin default.
|
||||||
|
* themeId:
|
||||||
|
* type: string
|
||||||
|
* nullable: true
|
||||||
|
* description: A theme id, or null to follow the admin default.
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Preferences updated successfully.
|
* description: Preferences updated successfully.
|
||||||
@@ -186,6 +207,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
confirmTabClose,
|
confirmTabClose,
|
||||||
hiddenRailTabs,
|
hiddenRailTabs,
|
||||||
customKeybindings,
|
customKeybindings,
|
||||||
|
layoutId,
|
||||||
|
themeId,
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
reopenTabsOnLogin?: boolean;
|
reopenTabsOnLogin?: boolean;
|
||||||
theme?: string | null;
|
theme?: string | null;
|
||||||
@@ -201,6 +224,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
confirmTabClose?: boolean | null;
|
confirmTabClose?: boolean | null;
|
||||||
hiddenRailTabs?: string | null;
|
hiddenRailTabs?: string | null;
|
||||||
customKeybindings?: string | null;
|
customKeybindings?: string | null;
|
||||||
|
layoutId?: string | null;
|
||||||
|
themeId?: string | null;
|
||||||
};
|
};
|
||||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||||
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
||||||
@@ -258,6 +283,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries({ layoutId, themeId })) {
|
||||||
|
if (
|
||||||
|
value !== undefined &&
|
||||||
|
value !== null &&
|
||||||
|
(typeof value !== "string" || !LOOK_ID.test(value))
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: `${key} must be a layout or theme id, or null` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const boolFields: Record<string, boolean | null | undefined> = {
|
const boolFields: Record<string, boolean | null | undefined> = {
|
||||||
commandPaletteEnabled,
|
commandPaletteEnabled,
|
||||||
pinAppRail,
|
pinAppRail,
|
||||||
@@ -290,6 +327,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||||
if (customKeybindings !== undefined)
|
if (customKeybindings !== undefined)
|
||||||
updates.customKeybindings = customKeybindings;
|
updates.customKeybindings = customKeybindings;
|
||||||
|
if (layoutId !== undefined) updates.layoutId = layoutId;
|
||||||
|
if (themeId !== undefined) updates.themeId = themeId;
|
||||||
|
|
||||||
if (Object.keys(updates).length === 1) {
|
if (Object.keys(updates).length === 1) {
|
||||||
return res.status(400).json({ error: "No preferences provided" });
|
return res.status(400).json({ error: "No preferences provided" });
|
||||||
|
|||||||
@@ -6,11 +6,6 @@ import {
|
|||||||
setGlobalLogLevel,
|
setGlobalLogLevel,
|
||||||
} from "../../utils/logger.js";
|
} from "../../utils/logger.js";
|
||||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||||
import {
|
|
||||||
AUDIT_FORWARD_TOKEN_SETTING,
|
|
||||||
AUDIT_FORWARD_URL_ENV,
|
|
||||||
AUDIT_FORWARD_URL_SETTING,
|
|
||||||
} from "../../utils/audit-forwarder.js";
|
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
|
||||||
parseNotificationAllowlist,
|
parseNotificationAllowlist,
|
||||||
@@ -192,125 +187,6 @@ export function registerUserSettingsRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /users/audit-forwarding:
|
|
||||||
* get:
|
|
||||||
* summary: Get audit log forwarding settings (admin only)
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Forwarding target. The token itself is never returned.
|
|
||||||
*/
|
|
||||||
router.get("/audit-forwarding", authenticateJWT, async (req, res) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
try {
|
|
||||||
if (!(await getAdminActor(userId))) {
|
|
||||||
return res.status(403).json({ error: "Not authorized" });
|
|
||||||
}
|
|
||||||
const settingsRepository = createCurrentSettingsRepository();
|
|
||||||
const url =
|
|
||||||
(await settingsRepository.get(AUDIT_FORWARD_URL_SETTING)) ?? "";
|
|
||||||
const hasToken = !!(await settingsRepository.get(
|
|
||||||
AUDIT_FORWARD_TOKEN_SETTING,
|
|
||||||
));
|
|
||||||
res.json({
|
|
||||||
url,
|
|
||||||
hasToken,
|
|
||||||
envConfigured: !!process.env[AUDIT_FORWARD_URL_ENV]?.trim(),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
authLogger.error("Failed to get audit forwarding settings", err);
|
|
||||||
res
|
|
||||||
.status(500)
|
|
||||||
.json({ error: "Failed to get audit forwarding settings" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /users/audit-forwarding:
|
|
||||||
* patch:
|
|
||||||
* summary: Update audit log forwarding settings (admin only)
|
|
||||||
* description: Sets the collector URL audit entries are shipped to. An empty URL disables forwarding and clears the stored token. Omitting token keeps the stored one.
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* properties:
|
|
||||||
* url:
|
|
||||||
* type: string
|
|
||||||
* token:
|
|
||||||
* type: string
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Setting updated.
|
|
||||||
* 403:
|
|
||||||
* description: Not authorized.
|
|
||||||
*/
|
|
||||||
router.patch("/audit-forwarding", 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 { url, token } = req.body ?? {};
|
|
||||||
if (
|
|
||||||
typeof url !== "string" ||
|
|
||||||
(token !== undefined && typeof token !== "string")
|
|
||||||
) {
|
|
||||||
return res.status(400).json({ error: "url must be a string" });
|
|
||||||
}
|
|
||||||
const trimmedUrl = url.trim();
|
|
||||||
if (trimmedUrl && !/^https?:\/\//i.test(trimmedUrl)) {
|
|
||||||
return res.status(400).json({ error: "url must be http(s)" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const settingsRepository = createCurrentSettingsRepository();
|
|
||||||
if (!trimmedUrl) {
|
|
||||||
await settingsRepository.delete(AUDIT_FORWARD_URL_SETTING);
|
|
||||||
await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING);
|
|
||||||
} else {
|
|
||||||
await settingsRepository.set(AUDIT_FORWARD_URL_SETTING, trimmedUrl);
|
|
||||||
if (token !== undefined) {
|
|
||||||
if (token.trim()) {
|
|
||||||
await settingsRepository.set(
|
|
||||||
AUDIT_FORWARD_TOKEN_SETTING,
|
|
||||||
token.trim(),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await settingsRepository.delete(AUDIT_FORWARD_TOKEN_SETTING);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
||||||
await logAudit({
|
|
||||||
userId,
|
|
||||||
username: actor.username ?? userId,
|
|
||||||
action: "update_audit_forwarding",
|
|
||||||
resourceType: "setting",
|
|
||||||
details: JSON.stringify({ enabled: !!trimmedUrl }),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ url: trimmedUrl, hasToken: !!token?.trim() });
|
|
||||||
} catch (err) {
|
|
||||||
authLogger.error("Failed to update audit forwarding settings", err);
|
|
||||||
res
|
|
||||||
.status(500)
|
|
||||||
.json({ error: "Failed to update audit forwarding settings" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
|
* GET/PATCH a comma-list of private hosts an outbound feature may reach.
|
||||||
* One route pair per private endpoint allowlist, each with its own setting key.
|
* One route pair per private endpoint allowlist, each with its own setting key.
|
||||||
|
|||||||
@@ -1543,7 +1543,7 @@ registerUserSocketTicketRoutes(router, { authenticateJWT, authManager });
|
|||||||
registerTlsRoutes(router, authenticateJWT);
|
registerTlsRoutes(router, authenticateJWT);
|
||||||
|
|
||||||
registerUserApiKeyRoutes(router, requireAdmin);
|
registerUserApiKeyRoutes(router, requireAdmin);
|
||||||
registerBrandingRoutes(router, requireAdmin);
|
registerBrandingRoutes(router);
|
||||||
|
|
||||||
registerAuthRoutes(router);
|
registerAuthRoutes(router);
|
||||||
registerAuthCompatRoutes(router);
|
registerAuthCompatRoutes(router);
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import {
|
|||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
createCurrentRbacAccessRepository,
|
createCurrentRbacAccessRepository,
|
||||||
createCurrentRecentActivityRepository,
|
|
||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
} from "../database/repositories/factory.js";
|
} from "../database/repositories/factory.js";
|
||||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||||
|
import { recentActivity } from "../plugins/activity-provider.js";
|
||||||
import { sshLogger } from "../utils/logger.js";
|
import { sshLogger } from "../utils/logger.js";
|
||||||
import { emitInternalEvent } from "./internal-events.js";
|
import { emitInternalEvent } from "./internal-events.js";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export async function deleteOwnedHost(
|
|||||||
|
|
||||||
// Plugin rows tied to a host cascade on their refHost() foreign keys.
|
// Plugin rows tied to a host cascade on their refHost() foreign keys.
|
||||||
await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId);
|
await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId);
|
||||||
await createCurrentRecentActivityRepository().deleteByHostId(hostId);
|
await recentActivity.deleteByHostId(hostId);
|
||||||
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
|
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
|
||||||
await createCurrentHostRepository().deleteForUser(userId, hostId);
|
await createCurrentHostRepository().deleteForUser(userId, hostId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import {
|
||||||
|
createCurrentCredentialRepository,
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
} from "../../database/repositories/factory.js";
|
||||||
|
import { hostTerminalExport } from "../../database/routes/host-normalizers.js";
|
||||||
|
import { loadHostPluginSettings } from "../../database/routes/host-plugin-settings.js";
|
||||||
|
import {
|
||||||
|
listProtocolLogins,
|
||||||
|
toPortableLogins,
|
||||||
|
} from "../protocol-auth/protocol-auth.js";
|
||||||
|
import { findHostProtocol } from "../protocol-auth/registry.js";
|
||||||
|
import { resolveHostCredentials } from "../resolve-host-credentials.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A share export leaves out every secret and credential link, the same as
|
||||||
|
* it does for SSH.
|
||||||
|
*/
|
||||||
|
function shareableLogins(
|
||||||
|
logins: ReturnType<typeof toPortableLogins>,
|
||||||
|
shareMode: boolean,
|
||||||
|
): ReturnType<typeof toPortableLogins> {
|
||||||
|
if (!shareMode) return logins;
|
||||||
|
const out: ReturnType<typeof toPortableLogins> = {};
|
||||||
|
for (const [protocol, login] of Object.entries(logins)) {
|
||||||
|
const declared = findHostProtocol(protocol);
|
||||||
|
const secret = new Set(
|
||||||
|
(declared?.credentialFields ?? [])
|
||||||
|
.filter((field) => field.secret)
|
||||||
|
.map((field) => field.key),
|
||||||
|
);
|
||||||
|
out[protocol] = {
|
||||||
|
authType: login.authType === "credential" ? "direct" : login.authType,
|
||||||
|
credentialId: null,
|
||||||
|
username: login.username,
|
||||||
|
password: null,
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries(login.fields).filter(
|
||||||
|
([key]) => declared && !secret.has(key),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Row = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** One host as the Termix JSON export carries it. */
|
||||||
|
async function exportRow(
|
||||||
|
host: Row,
|
||||||
|
userId: string,
|
||||||
|
shareMode: boolean,
|
||||||
|
pluginSettings: Awaited<ReturnType<typeof loadHostPluginSettings>>,
|
||||||
|
): Promise<{ data: Row; isRemoteDesktop: boolean; resolved: Row }> {
|
||||||
|
const resolvedHost = shareMode
|
||||||
|
? host
|
||||||
|
: (await resolveHostCredentials(host, userId)) || host;
|
||||||
|
const connectionType = (resolvedHost.connectionType as string) || "ssh";
|
||||||
|
const isRemoteDesktop = connectionType !== "ssh";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
exportId: resolvedHost.id,
|
||||||
|
connectionType,
|
||||||
|
name: resolvedHost.name,
|
||||||
|
ip: resolvedHost.ip,
|
||||||
|
port: resolvedHost.port,
|
||||||
|
username: resolvedHost.username,
|
||||||
|
password: shareMode ? null : resolvedHost.password || null,
|
||||||
|
folder: resolvedHost.folder,
|
||||||
|
tags:
|
||||||
|
typeof resolvedHost.tags === "string"
|
||||||
|
? resolvedHost.tags.split(",").filter(Boolean)
|
||||||
|
: resolvedHost.tags || [],
|
||||||
|
pin: !!resolvedHost.pin,
|
||||||
|
notes: resolvedHost.notes || null,
|
||||||
|
// Every plugin's host settings, secrets redacted, for import to hand back.
|
||||||
|
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
||||||
|
// Each plugin protocol's login, secrets included like the SSH ones.
|
||||||
|
protocolAuth: shareableLogins(
|
||||||
|
toPortableLogins(await listProtocolLogins(host.id as number, userId)),
|
||||||
|
shareMode,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isRemoteDesktop) {
|
||||||
|
return { data: base, isRemoteDesktop, resolved: resolvedHost };
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminal = hostTerminalExport(resolvedHost);
|
||||||
|
const data = {
|
||||||
|
...base,
|
||||||
|
authType: resolvedHost.authType,
|
||||||
|
key: shareMode ? null : resolvedHost.key || null,
|
||||||
|
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
|
||||||
|
keyType: resolvedHost.keyType || null,
|
||||||
|
credentialId: resolvedHost.credentialId || null,
|
||||||
|
overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername,
|
||||||
|
sudoPassword: shareMode ? null : resolvedHost.sudoPassword || null,
|
||||||
|
jumpHosts: resolvedHost.jumpHosts
|
||||||
|
? JSON.parse(resolvedHost.jumpHosts as string)
|
||||||
|
: null,
|
||||||
|
terminalConfig: terminal.terminalConfig ?? null,
|
||||||
|
sshOptions: terminal.sshOptions,
|
||||||
|
forceKeyboardInteractive: resolvedHost.forceKeyboardInteractive === "true",
|
||||||
|
useSocks5: !!resolvedHost.useSocks5,
|
||||||
|
socks5Host: resolvedHost.socks5Host || null,
|
||||||
|
socks5Port: resolvedHost.socks5Port || null,
|
||||||
|
socks5Username: resolvedHost.socks5Username || null,
|
||||||
|
socks5Password: shareMode ? null : resolvedHost.socks5Password || null,
|
||||||
|
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
||||||
|
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
||||||
|
: null,
|
||||||
|
portKnockSequence: resolvedHost.portKnockSequence
|
||||||
|
? JSON.parse(resolvedHost.portKnockSequence as string)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
return { data, isRemoteDesktop, resolved: resolvedHost };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostExport {
|
||||||
|
hosts: Row[];
|
||||||
|
/** Share mode only. */
|
||||||
|
version?: string;
|
||||||
|
exportedAt?: string;
|
||||||
|
credentials?: Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All of the user's own hosts. Without shareMode credentials are decrypted and
|
||||||
|
* embedded. With it every secret is left out and credential logins point at a
|
||||||
|
* scrubbed credentials list by alias.
|
||||||
|
*/
|
||||||
|
export async function exportAllHosts(
|
||||||
|
userId: string,
|
||||||
|
shareMode: boolean,
|
||||||
|
hostIds?: number[],
|
||||||
|
): Promise<HostExport> {
|
||||||
|
const owned =
|
||||||
|
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
||||||
|
const wanted = hostIds ? new Set(hostIds) : null;
|
||||||
|
const allHosts = wanted
|
||||||
|
? owned.filter((host) => wanted.has(host.id as number))
|
||||||
|
: owned;
|
||||||
|
const settings = await loadHostPluginSettings(
|
||||||
|
allHosts.map((h) => h.id as number),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hosts: Row[] = [];
|
||||||
|
const usedCredentialIds = new Set<number>();
|
||||||
|
|
||||||
|
for (const host of allHosts) {
|
||||||
|
const { data, isRemoteDesktop, resolved } = await exportRow(
|
||||||
|
host as unknown as Row,
|
||||||
|
userId,
|
||||||
|
shareMode,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
shareMode &&
|
||||||
|
!isRemoteDesktop &&
|
||||||
|
resolved.authType === "credential" &&
|
||||||
|
resolved.credentialId
|
||||||
|
) {
|
||||||
|
usedCredentialIds.add(resolved.credentialId as number);
|
||||||
|
}
|
||||||
|
hosts.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shareMode) return { hosts };
|
||||||
|
|
||||||
|
const credentials: Row[] = [];
|
||||||
|
if (usedCredentialIds.size > 0) {
|
||||||
|
const owned =
|
||||||
|
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
|
||||||
|
const byId = new Map(
|
||||||
|
owned.map((credential) => [credential.id, credential]),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const host of hosts) {
|
||||||
|
const credentialId = host.credentialId as number | null;
|
||||||
|
if (!credentialId) continue;
|
||||||
|
const credential = byId.get(credentialId);
|
||||||
|
if (!credential) continue;
|
||||||
|
|
||||||
|
host.credentialAlias = credential.name;
|
||||||
|
|
||||||
|
if (!credentials.some((entry) => entry.alias === credential.name)) {
|
||||||
|
credentials.push({
|
||||||
|
alias: credential.name,
|
||||||
|
name: credential.name,
|
||||||
|
description: credential.description || null,
|
||||||
|
folder: credential.folder || null,
|
||||||
|
tags:
|
||||||
|
typeof credential.tags === "string"
|
||||||
|
? credential.tags.split(",").filter(Boolean)
|
||||||
|
: [],
|
||||||
|
authType: credential.authType,
|
||||||
|
username: credential.username || null,
|
||||||
|
keyType: credential.keyType || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const host of hosts) delete host.credentialId;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: "1",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
credentials,
|
||||||
|
hosts,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
import {
|
||||||
|
prepareHostImports,
|
||||||
|
remapImportedJumpHosts,
|
||||||
|
} from "../../database/routes/host-import-order.js";
|
||||||
|
import { sshOptionsForWrite } from "../ssh-options.js";
|
||||||
|
import {
|
||||||
|
applyDefaultsAfterHostWrites,
|
||||||
|
applyHostDefaultsToWrite,
|
||||||
|
} from "../defaults/index.js";
|
||||||
|
import {
|
||||||
|
keepUsableProtocolCredentials,
|
||||||
|
readProtocolAuthPayload,
|
||||||
|
writeProtocolAuth,
|
||||||
|
} from "../protocol-auth/protocol-auth.js";
|
||||||
|
import { getErrorMessage } from "../../utils/error-message.js";
|
||||||
|
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||||
|
import {
|
||||||
|
createCurrentCredentialRepository,
|
||||||
|
createCurrentHostRepository,
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentHostDefaultsRepository,
|
||||||
|
} from "../../database/repositories/factory.js";
|
||||||
|
import {
|
||||||
|
listSshAuthProviders,
|
||||||
|
listSshAuthTypeOwners,
|
||||||
|
} from "../connect/auth-provider-registry.js";
|
||||||
|
import { applyPluginHostImportSettings } from "../../database/routes/host-plugin-settings.js";
|
||||||
|
import {
|
||||||
|
isNonEmptyString,
|
||||||
|
isValidPort,
|
||||||
|
normalizeImportedHost,
|
||||||
|
} from "../../database/routes/host-normalizers.js";
|
||||||
|
|
||||||
|
/** A request the caller got wrong. Routes answer it with a 400. */
|
||||||
|
export class ImportError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ImportError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResults {
|
||||||
|
success: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
/** Hosts this call created. */
|
||||||
|
createdHostIds: number[];
|
||||||
|
/** True when atomic was set and a failure undid the creates. */
|
||||||
|
rolledBack?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkImportInput {
|
||||||
|
hosts: unknown;
|
||||||
|
overwrite?: unknown;
|
||||||
|
credentials?: unknown;
|
||||||
|
/** All or nothing: any failure deletes what this call created. */
|
||||||
|
atomic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SSHConfigHost = {
|
||||||
|
name: string;
|
||||||
|
hostname?: string;
|
||||||
|
user?: string;
|
||||||
|
port?: number;
|
||||||
|
identityFile?: string;
|
||||||
|
proxyJump?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShareCredential = {
|
||||||
|
alias?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
folder?: unknown;
|
||||||
|
tags?: unknown;
|
||||||
|
authType?: unknown;
|
||||||
|
username?: unknown;
|
||||||
|
keyType?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function textValue(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagString(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((tag) => textValue(tag))
|
||||||
|
.filter((tag): tag is string => !!tag)
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
return textValue(value) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
|
||||||
|
return value === "key" ? "key" : "password";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSSHConfig(content: string): SSHConfigHost[] {
|
||||||
|
const results: SSHConfigHost[] = [];
|
||||||
|
let current: SSHConfigHost | null = null;
|
||||||
|
|
||||||
|
for (const rawLine of content.split("\n")) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
|
||||||
|
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const key = match[1].toLowerCase();
|
||||||
|
const value = match[2];
|
||||||
|
|
||||||
|
if (key === "host") {
|
||||||
|
if (current && current.hostname) results.push(current);
|
||||||
|
// Skip wildcard patterns
|
||||||
|
if (value === "*" || value.includes("*") || value.includes("?")) {
|
||||||
|
current = null;
|
||||||
|
} else {
|
||||||
|
current = { name: value };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) continue;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "hostname":
|
||||||
|
current.hostname = value;
|
||||||
|
break;
|
||||||
|
case "user":
|
||||||
|
current.user = value;
|
||||||
|
break;
|
||||||
|
case "port": {
|
||||||
|
const p = Number.parseInt(value, 10);
|
||||||
|
if (p > 0 && p <= 65535) current.port = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "identityfile":
|
||||||
|
if (!current.identityFile) current.identityFile = value;
|
||||||
|
break;
|
||||||
|
case "proxyjump":
|
||||||
|
current.proxyJump = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current && current.hostname) results.push(current);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells status checks and other pollers about hosts an import wrote, the way
|
||||||
|
* a single host create or update does.
|
||||||
|
*/
|
||||||
|
export function notifyImportedHosts(
|
||||||
|
userId: string,
|
||||||
|
hostIds: number[],
|
||||||
|
createdHostIds: number[],
|
||||||
|
): void {
|
||||||
|
for (const hostId of createdHostIds) {
|
||||||
|
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
||||||
|
}
|
||||||
|
for (const hostId of hostIds) {
|
||||||
|
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importedHostUsername(
|
||||||
|
connectionType: string,
|
||||||
|
authType: unknown,
|
||||||
|
username: unknown,
|
||||||
|
): string | null {
|
||||||
|
if (isNonEmptyString(username)) return username;
|
||||||
|
if (connectionType !== "ssh" || authType === "credential") return "";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status check fields from an import row. Exports from before 2.9.0 carried
|
||||||
|
* them inside statsConfig.
|
||||||
|
*/
|
||||||
|
function importedStatusCheck(raw: Record<string, unknown>): {
|
||||||
|
statusCheckEnabled: boolean;
|
||||||
|
statusCheckInterval: number | null;
|
||||||
|
} {
|
||||||
|
const legacy =
|
||||||
|
raw.statsConfig && typeof raw.statsConfig === "object"
|
||||||
|
? (raw.statsConfig as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
|
||||||
|
const useGlobal = legacy.useGlobalStatusInterval !== false;
|
||||||
|
const interval =
|
||||||
|
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
|
||||||
|
const seconds = Number(interval);
|
||||||
|
return {
|
||||||
|
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
|
||||||
|
statusCheckInterval:
|
||||||
|
interval != null && Number.isInteger(seconds) && seconds >= 5
|
||||||
|
? seconds
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN_SSH_AUTH_TYPES = [
|
||||||
|
"password",
|
||||||
|
"key",
|
||||||
|
"credential",
|
||||||
|
"agent",
|
||||||
|
"none",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Core's own types plus every type a plugin registers or declares. */
|
||||||
|
function listKnownAuthTypes(): Set<string> {
|
||||||
|
return new Set([
|
||||||
|
...BUILTIN_SSH_AUTH_TYPES,
|
||||||
|
...listSshAuthProviders().map((provider) => provider.type),
|
||||||
|
...listSshAuthTypeOwners().map((owner) => owner.type),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills an imported host's inherited keys and records what it sets itself. */
|
||||||
|
async function withHostDefaults(
|
||||||
|
userId: string,
|
||||||
|
hostId: number | null,
|
||||||
|
columns: Record<string, unknown>,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const stored =
|
||||||
|
hostId === null
|
||||||
|
? null
|
||||||
|
: ((
|
||||||
|
await createCurrentHostDefaultsRepository().listHosts({
|
||||||
|
hostIds: [hostId],
|
||||||
|
})
|
||||||
|
)[0] ?? null);
|
||||||
|
columns.defaultOverrides = JSON.stringify(
|
||||||
|
await applyHostDefaultsToWrite({
|
||||||
|
ownerId: userId,
|
||||||
|
hostId,
|
||||||
|
columns,
|
||||||
|
body,
|
||||||
|
stored,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importSummary(results: ImportResults): string {
|
||||||
|
return `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importResponse(results: ImportResults) {
|
||||||
|
return {
|
||||||
|
message: importSummary(results),
|
||||||
|
success: results.success,
|
||||||
|
updated: results.updated,
|
||||||
|
skipped: results.skipped,
|
||||||
|
failed: results.failed,
|
||||||
|
errors: results.errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_IMPORT_HOSTS = 100;
|
||||||
|
|
||||||
|
/** Deletes what an atomic import created, newest first. */
|
||||||
|
async function rollBackCreated(
|
||||||
|
userId: string,
|
||||||
|
results: ImportResults,
|
||||||
|
): Promise<void> {
|
||||||
|
const repository = createCurrentHostRepository();
|
||||||
|
for (const hostId of [...results.createdHostIds].reverse()) {
|
||||||
|
await repository.deleteForUser(userId, hostId).catch(() => {});
|
||||||
|
}
|
||||||
|
results.rolledBack = true;
|
||||||
|
results.success = 0;
|
||||||
|
results.createdHostIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Termix JSON import: shared by POST /host/bulk-import and
|
||||||
|
* ctx.hosts.importMany. Callers wrap it in DatabaseSaveTrigger.batched.
|
||||||
|
*/
|
||||||
|
export async function runBulkImport(
|
||||||
|
userId: string,
|
||||||
|
input: BulkImportInput,
|
||||||
|
): Promise<ImportResults> {
|
||||||
|
const {
|
||||||
|
hosts: hostsToImport,
|
||||||
|
overwrite,
|
||||||
|
credentials: credentialsToImport,
|
||||||
|
} = input;
|
||||||
|
if (input.atomic && overwrite) {
|
||||||
|
throw new ImportError("An atomic import cannot overwrite existing hosts");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
|
||||||
|
throw new ImportError("Hosts array is required and must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostsToImport.length > 100) {
|
||||||
|
throw new ImportError("Maximum 100 hosts allowed per import");
|
||||||
|
}
|
||||||
|
|
||||||
|
let orderedHosts: ReturnType<typeof prepareHostImports>;
|
||||||
|
try {
|
||||||
|
orderedHosts = prepareHostImports(hostsToImport);
|
||||||
|
} catch (error) {
|
||||||
|
throw new ImportError(getErrorMessage(error));
|
||||||
|
}
|
||||||
|
const importedIds = new Map<unknown, number>();
|
||||||
|
|
||||||
|
const results: ImportResults = {
|
||||||
|
success: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [],
|
||||||
|
createdHostIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const credentialAliasMap = new Map<string, number>();
|
||||||
|
const addCredentialAlias = (alias: unknown, id: number) => {
|
||||||
|
const key = textValue(alias);
|
||||||
|
if (key) credentialAliasMap.set(key.toLowerCase(), id);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
|
const existingCredentials =
|
||||||
|
await credentialRepository.listDecryptedByUserId(userId);
|
||||||
|
|
||||||
|
for (const credential of existingCredentials) {
|
||||||
|
addCredentialAlias(credential.name, credential.id as number);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(credentialsToImport)) {
|
||||||
|
for (const rawCredential of credentialsToImport as ShareCredential[]) {
|
||||||
|
const alias = textValue(rawCredential.alias);
|
||||||
|
const name = textValue(rawCredential.name) || alias;
|
||||||
|
if (!alias || !name) continue;
|
||||||
|
|
||||||
|
const existingId = credentialAliasMap.get(name.toLowerCase());
|
||||||
|
if (existingId) {
|
||||||
|
addCredentialAlias(alias, existingId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const created = await credentialRepository.createEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
description:
|
||||||
|
textValue(rawCredential.description) ||
|
||||||
|
"Imported placeholder. Add the secret before connecting.",
|
||||||
|
folder: textValue(rawCredential.folder),
|
||||||
|
tags: tagString(rawCredential.tags),
|
||||||
|
authType: normalizeCredentialAuthType(rawCredential.authType),
|
||||||
|
username: textValue(rawCredential.username),
|
||||||
|
password: null,
|
||||||
|
key: null,
|
||||||
|
privateKey: null,
|
||||||
|
publicKey: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: textValue(rawCredential.keyType),
|
||||||
|
detectedKeyType: null,
|
||||||
|
usageCount: 0,
|
||||||
|
lastUsed: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdCredential = created as Record<string, unknown>;
|
||||||
|
addCredentialAlias(alias, createdCredential.id as number);
|
||||||
|
addCredentialAlias(name, createdCredential.id as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.errors.push(
|
||||||
|
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
|
||||||
|
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||||
|
const hostRepository = createCurrentHostRepository();
|
||||||
|
try {
|
||||||
|
const allHosts =
|
||||||
|
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
||||||
|
existingHostMap = new Map();
|
||||||
|
for (const h of allHosts) {
|
||||||
|
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||||
|
existingHostMap.set(key, { id: h.id as number });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
existingHostMap = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownAuthTypes = listKnownAuthTypes();
|
||||||
|
const writtenHostIds: number[] = [];
|
||||||
|
const createdHostIds: number[] = [];
|
||||||
|
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||||
|
try {
|
||||||
|
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||||
|
|
||||||
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
hostData.authType === "credential" &&
|
||||||
|
!hostData.credentialId &&
|
||||||
|
hostData.credentialAlias
|
||||||
|
) {
|
||||||
|
hostData.credentialId = credentialAliasMap.get(
|
||||||
|
hostData.credentialAlias.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host ${i + 1}: Missing required fields (ip, port)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = importedHostUsername(
|
||||||
|
effectiveConnectionType,
|
||||||
|
hostData.authType,
|
||||||
|
hostData.username,
|
||||||
|
);
|
||||||
|
if (username === null) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host ${i + 1}: Username required for SSH connections`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||||
|
const existing = existingHostMap?.get(lookupKey);
|
||||||
|
if (existing && !overwrite) {
|
||||||
|
// Hosts that jump through this one still resolve to the kept host.
|
||||||
|
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
hostData.authType &&
|
||||||
|
!knownAuthTypes.has(hostData.authType)
|
||||||
|
) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
hostData.authType === "password" &&
|
||||||
|
!isNonEmptyString(hostData.password)
|
||||||
|
) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host ${i + 1}: Password required for password authentication`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
hostData.authType === "key" &&
|
||||||
|
!isNonEmptyString(hostData.key)
|
||||||
|
) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host ${i + 1}: Key required for key authentication`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
hostData.authType === "credential" &&
|
||||||
|
!hostData.credentialId
|
||||||
|
) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host ${i + 1}: credentialId required for credential authentication`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
effectiveConnectionType === "ssh" &&
|
||||||
|
hostData.authType === "credential" &&
|
||||||
|
hostData.credentialId
|
||||||
|
) {
|
||||||
|
const credentialRepository = createCurrentCredentialRepository();
|
||||||
|
const cred = await credentialRepository.findByIdForUser(
|
||||||
|
userId,
|
||||||
|
hostData.credentialId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cred) {
|
||||||
|
const fallback = await credentialRepository.listByUserId(userId);
|
||||||
|
|
||||||
|
if (fallback.length > 0) {
|
||||||
|
hostData.credentialId = fallback[0].id;
|
||||||
|
} else if (isNonEmptyString(hostData.key)) {
|
||||||
|
hostData.authType = "key";
|
||||||
|
hostData.credentialId = undefined;
|
||||||
|
} else if (isNonEmptyString(hostData.password)) {
|
||||||
|
hostData.authType = "password";
|
||||||
|
hostData.credentialId = undefined;
|
||||||
|
} else {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumpHosts = remapImportedJumpHosts(hostData.jumpHosts, importedIds);
|
||||||
|
const sshDataObj: Record<string, unknown> = {
|
||||||
|
userId: userId,
|
||||||
|
connectionType: effectiveConnectionType,
|
||||||
|
name: hostData.name || `${username}@${hostData.ip}`,
|
||||||
|
folder: hostData.folder || "Default",
|
||||||
|
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
|
||||||
|
ip: hostData.ip,
|
||||||
|
port: hostData.port,
|
||||||
|
username,
|
||||||
|
pin: hostData.pin || false,
|
||||||
|
sudoPassword: hostData.sudoPassword || null,
|
||||||
|
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
|
||||||
|
...importedStatusCheck(hostData as Record<string, unknown>),
|
||||||
|
// A 2.8 export carries these inside terminalConfig.
|
||||||
|
sshOptions:
|
||||||
|
sshOptionsForWrite({
|
||||||
|
sshOptions: hostData.sshOptions,
|
||||||
|
terminalConfig: hostData.terminalConfig,
|
||||||
|
}) ?? null,
|
||||||
|
forceKeyboardInteractive: hostData.forceKeyboardInteractive
|
||||||
|
? "true"
|
||||||
|
: "false",
|
||||||
|
notes: hostData.notes || null,
|
||||||
|
useSocks5: hostData.useSocks5 ? 1 : 0,
|
||||||
|
socks5Host: hostData.socks5Host || null,
|
||||||
|
socks5Port: hostData.socks5Port || null,
|
||||||
|
socks5Username: hostData.socks5Username || null,
|
||||||
|
socks5Password: hostData.socks5Password || null,
|
||||||
|
socks5ProxyChain: hostData.socks5ProxyChain
|
||||||
|
? JSON.stringify(hostData.socks5ProxyChain)
|
||||||
|
: null,
|
||||||
|
portKnockSequence: hostData.portKnockSequence
|
||||||
|
? JSON.stringify(hostData.portKnockSequence)
|
||||||
|
: null,
|
||||||
|
overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0,
|
||||||
|
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (effectiveConnectionType !== "ssh") {
|
||||||
|
sshDataObj.password = hostData.password || null;
|
||||||
|
sshDataObj.authType = "password";
|
||||||
|
sshDataObj.credentialId = null;
|
||||||
|
sshDataObj.key = null;
|
||||||
|
sshDataObj.keyPassword = null;
|
||||||
|
sshDataObj.keyType = null;
|
||||||
|
} else {
|
||||||
|
sshDataObj.password =
|
||||||
|
hostData.authType === "password" ? hostData.password : null;
|
||||||
|
sshDataObj.authType = hostData.authType || "password";
|
||||||
|
sshDataObj.credentialId =
|
||||||
|
hostData.authType === "credential" ? hostData.credentialId : null;
|
||||||
|
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
|
||||||
|
sshDataObj.keyPassword =
|
||||||
|
hostData.authType === "key" ? hostData.keyPassword || null : null;
|
||||||
|
sshDataObj.keyType =
|
||||||
|
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await withHostDefaults(
|
||||||
|
userId,
|
||||||
|
existing?.id ?? null,
|
||||||
|
sshDataObj,
|
||||||
|
hostData as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
|
let savedHostId: number;
|
||||||
|
if (existing) {
|
||||||
|
const saved = await hostRepository.updateEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
existing.id,
|
||||||
|
sshDataObj,
|
||||||
|
);
|
||||||
|
if (!saved) throw new Error("Host no longer exists");
|
||||||
|
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
||||||
|
savedHostId = existing.id;
|
||||||
|
results.updated++;
|
||||||
|
} else {
|
||||||
|
sshDataObj.createdAt = new Date().toISOString();
|
||||||
|
const saved = await hostRepository.createEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
sshDataObj,
|
||||||
|
);
|
||||||
|
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
||||||
|
savedHostId = saved.id;
|
||||||
|
createdHostIds.push(saved.id);
|
||||||
|
results.success++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocolAuth = readProtocolAuthPayload(
|
||||||
|
hostData as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
if (protocolAuth) {
|
||||||
|
await writeProtocolAuth(
|
||||||
|
userId,
|
||||||
|
savedHostId,
|
||||||
|
await keepUsableProtocolCredentials(protocolAuth, userId),
|
||||||
|
{ isOwner: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every enabled plugin that declares host-scope settings and
|
||||||
|
// registered a hostImportNormalizer validates and writes its own
|
||||||
|
// fields here, so this loop does not need to know which plugins
|
||||||
|
// exist. See host-plugin-settings.ts.
|
||||||
|
await applyPluginHostImportSettings(
|
||||||
|
savedHostId,
|
||||||
|
hostData as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
writtenHostIds.push(savedHostId);
|
||||||
|
} catch (error) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.createdHostIds = createdHostIds;
|
||||||
|
if (input.atomic && results.failed > 0) {
|
||||||
|
await rollBackCreated(userId, results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||||
|
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||||
|
results.createdHostIds = createdHostIds;
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The OpenSSH config import, shared by the route and ctx.hosts. */
|
||||||
|
export async function runSshConfigImport(
|
||||||
|
userId: string,
|
||||||
|
input: { content?: unknown; overwrite?: unknown; atomic?: boolean },
|
||||||
|
): Promise<ImportResults> {
|
||||||
|
const { content, overwrite } = input;
|
||||||
|
if (input.atomic && overwrite) {
|
||||||
|
throw new ImportError("An atomic import cannot overwrite existing hosts");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isNonEmptyString(content)) {
|
||||||
|
throw new ImportError("content is required and must be a non-empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: SSHConfigHost[];
|
||||||
|
try {
|
||||||
|
parsed = parseSSHConfig(content);
|
||||||
|
} catch {
|
||||||
|
throw new ImportError("Failed to parse SSH config file");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
throw new ImportError("No valid Host entries found in the SSH config file");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.length > 100) {
|
||||||
|
throw new ImportError("Maximum 100 hosts allowed per import");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostsToImport = parsed.map((h) => ({
|
||||||
|
name: h.name,
|
||||||
|
ip: h.hostname,
|
||||||
|
port: h.port ?? 22,
|
||||||
|
username: h.user,
|
||||||
|
authType: h.identityFile ? "key" : undefined,
|
||||||
|
connectionType: "ssh",
|
||||||
|
enableSsh: true,
|
||||||
|
...(h.proxyJump
|
||||||
|
? {
|
||||||
|
jumpHosts: [{ host: h.proxyJump, port: 22 }],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const results: ImportResults = {
|
||||||
|
success: 0,
|
||||||
|
updated: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: [],
|
||||||
|
createdHostIds: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||||
|
const hostRepository = createCurrentHostRepository();
|
||||||
|
try {
|
||||||
|
const allHosts =
|
||||||
|
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
||||||
|
existingHostMap = new Map();
|
||||||
|
for (const h of allHosts) {
|
||||||
|
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||||
|
existingHostMap.set(key, { id: h.id as number });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
existingHostMap = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const writtenHostIds: number[] = [];
|
||||||
|
const createdHostIds: number[] = [];
|
||||||
|
for (let i = 0; i < hostsToImport.length; i++) {
|
||||||
|
const hostData = normalizeImportedHost(
|
||||||
|
hostsToImport[i] as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||||
|
const existing = existingHostMap?.get(lookupKey);
|
||||||
|
if (existing && !overwrite) {
|
||||||
|
results.skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshDataObj: Record<string, unknown> = {
|
||||||
|
userId,
|
||||||
|
connectionType: "ssh",
|
||||||
|
name: hostData.name || hostData.ip,
|
||||||
|
folder: "Default",
|
||||||
|
tags: "",
|
||||||
|
ip: hostData.ip,
|
||||||
|
port: hostData.port,
|
||||||
|
username: hostData.username || null,
|
||||||
|
authType: hostData.authType || "none",
|
||||||
|
password: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
credentialId: null,
|
||||||
|
pin: false,
|
||||||
|
sudoPassword: null,
|
||||||
|
jumpHosts: hostData.jumpHosts
|
||||||
|
? JSON.stringify(hostData.jumpHosts)
|
||||||
|
: null,
|
||||||
|
statusCheckEnabled: true,
|
||||||
|
statusCheckInterval: null,
|
||||||
|
sshOptions: null,
|
||||||
|
forceKeyboardInteractive: "false",
|
||||||
|
notes: null,
|
||||||
|
useSocks5: 0,
|
||||||
|
socks5Host: null,
|
||||||
|
socks5Port: null,
|
||||||
|
socks5Username: null,
|
||||||
|
socks5Password: null,
|
||||||
|
socks5ProxyChain: null,
|
||||||
|
portKnockSequence: null,
|
||||||
|
overrideCredentialUsername: 0,
|
||||||
|
enableSsh: true,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await withHostDefaults(
|
||||||
|
userId,
|
||||||
|
existing?.id ?? null,
|
||||||
|
sshDataObj,
|
||||||
|
hostData as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await hostRepository.updateEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
existing.id,
|
||||||
|
sshDataObj,
|
||||||
|
);
|
||||||
|
writtenHostIds.push(existing.id);
|
||||||
|
results.updated++;
|
||||||
|
} else {
|
||||||
|
sshDataObj.createdAt = new Date().toISOString();
|
||||||
|
const saved = await hostRepository.createEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
sshDataObj,
|
||||||
|
);
|
||||||
|
writtenHostIds.push(saved.id);
|
||||||
|
createdHostIds.push(saved.id);
|
||||||
|
results.success++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results.failed++;
|
||||||
|
results.errors.push(
|
||||||
|
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.createdHostIds = createdHostIds;
|
||||||
|
if (input.atomic && results.failed > 0) {
|
||||||
|
await rollBackCreated(userId, results);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||||
|
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||||
|
results.createdHostIds = createdHostIds;
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
|
import { sshLogger } from "../utils/logger.js";
|
||||||
|
import { findUsableCredential } from "./usable-credential.js";
|
||||||
|
import {
|
||||||
|
pickResolvedPassword,
|
||||||
|
pickResolvedUsername,
|
||||||
|
} from "./credential-username.js";
|
||||||
|
import { createCurrentSharedHostAuthOverrideRepository } from "../database/repositories/factory.js";
|
||||||
|
import { listHostProtocols } from "./protocol-auth/registry.js";
|
||||||
|
import {
|
||||||
|
requiresPersonalHostAuthentication,
|
||||||
|
resolveRecipientSharedHostAuthentication,
|
||||||
|
} from "../utils/shared-host-auth-resolver.js";
|
||||||
|
import type {
|
||||||
|
HostResolutionCredentialRecord,
|
||||||
|
HostResolutionHostRecord,
|
||||||
|
} from "../database/repositories/host-resolution-repository.js";
|
||||||
|
|
||||||
|
/** A host with its credential's secrets filled in for the asking user. */
|
||||||
|
export async function resolveHostCredentials(
|
||||||
|
host: Record<string, unknown>,
|
||||||
|
requestingUserId?: string,
|
||||||
|
/**
|
||||||
|
* Credentials already fetched for this request, keyed by id. The host list
|
||||||
|
* preloads them in one query; single-host callers omit it and fall back to
|
||||||
|
* fetching the one credential they need.
|
||||||
|
*/
|
||||||
|
preloadedCredentials?: Map<number, HostResolutionCredentialRecord>,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
try {
|
||||||
|
const ownerId = (host.ownerId || host.userId) as string | undefined;
|
||||||
|
if (
|
||||||
|
requestingUserId &&
|
||||||
|
ownerId &&
|
||||||
|
requestingUserId !== ownerId &&
|
||||||
|
typeof host.id === "number"
|
||||||
|
) {
|
||||||
|
const authHost = host as unknown as HostResolutionHostRecord;
|
||||||
|
const needsPersonalCredential = requiresPersonalHostAuthentication(
|
||||||
|
authHost,
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
const baseSshOverrideState = {
|
||||||
|
required: needsPersonalCredential,
|
||||||
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
|
};
|
||||||
|
// Owner auth for the remote desktop protocols is always snapshotted
|
||||||
|
// for recipients; only their own override credential varies per user.
|
||||||
|
const authOverrides: Record<string, unknown> = {
|
||||||
|
ssh: baseSshOverrideState,
|
||||||
|
};
|
||||||
|
const overrideCredentialIds =
|
||||||
|
await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
|
||||||
|
host.id,
|
||||||
|
requestingUserId,
|
||||||
|
);
|
||||||
|
// Whether a plugin protocol is on is its plugin's host setting; the
|
||||||
|
// client only offers an override for one that is.
|
||||||
|
for (const { id: protocol } of listHostProtocols()) {
|
||||||
|
authOverrides[protocol] = {
|
||||||
|
credentialId: overrideCredentialIds[protocol],
|
||||||
|
required: false,
|
||||||
|
ownerAuthShared: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const recipientHost: Record<string, unknown> = {
|
||||||
|
...host,
|
||||||
|
credentialId: null,
|
||||||
|
password: null,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
authOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolution = await resolveRecipientSharedHostAuthentication(
|
||||||
|
authHost,
|
||||||
|
host.id,
|
||||||
|
requestingUserId,
|
||||||
|
"ssh",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resolution.source === "personal-override") {
|
||||||
|
const credential = resolution.credential;
|
||||||
|
return {
|
||||||
|
...recipientHost,
|
||||||
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
|
ssh: {
|
||||||
|
credentialId: resolution.credentialId,
|
||||||
|
required: false,
|
||||||
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authType:
|
||||||
|
credential.key || credential.privateKey
|
||||||
|
? "key"
|
||||||
|
: credential.password
|
||||||
|
? "password"
|
||||||
|
: "none",
|
||||||
|
username: credential.username || recipientHost.username,
|
||||||
|
password: credential.password,
|
||||||
|
key: credential.privateKey || credential.key,
|
||||||
|
keyPassword: credential.keyPassword,
|
||||||
|
keyType: credential.keyType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolution.source === "owner-shared") {
|
||||||
|
if (resolution.authType === "agent") {
|
||||||
|
return {
|
||||||
|
...recipientHost,
|
||||||
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
|
ssh: {
|
||||||
|
required: false,
|
||||||
|
ownerAuthShared: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authType: "agent",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedAuth = resolution.secret;
|
||||||
|
if (sharedAuth) {
|
||||||
|
const resolvedUsername = pickResolvedUsername(
|
||||||
|
recipientHost.username,
|
||||||
|
sharedAuth.username,
|
||||||
|
host.overrideCredentialUsername,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...recipientHost,
|
||||||
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
|
ssh: {
|
||||||
|
required: false,
|
||||||
|
ownerAuthShared: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authType: sharedAuth.key
|
||||||
|
? "key"
|
||||||
|
: sharedAuth.password
|
||||||
|
? "password"
|
||||||
|
: "none",
|
||||||
|
username: resolvedUsername,
|
||||||
|
password: sharedAuth.password,
|
||||||
|
key: sharedAuth.key,
|
||||||
|
keyPassword: sharedAuth.keyPassword,
|
||||||
|
keyType: sharedAuth.keyType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolution.source === "secretless") {
|
||||||
|
return {
|
||||||
|
...recipientHost,
|
||||||
|
authOverrides: {
|
||||||
|
...authOverrides,
|
||||||
|
ssh: {
|
||||||
|
required: false,
|
||||||
|
ownerAuthShared: !!host.shareSshAuth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A missing/deleted override or snapshot behaves like unavailable auth.
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipientHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.credentialId && (host.userId || host.ownerId)) {
|
||||||
|
const credentialId = host.credentialId as number;
|
||||||
|
const credentialOwnerId = (host.ownerId || host.userId) as string;
|
||||||
|
|
||||||
|
const credential =
|
||||||
|
preloadedCredentials?.get(credentialId) ??
|
||||||
|
(await findUsableCredential(credentialId, credentialOwnerId));
|
||||||
|
|
||||||
|
if (credential) {
|
||||||
|
const resolvedHost: Record<string, unknown> = {
|
||||||
|
...host,
|
||||||
|
password: pickResolvedPassword(host.password, credential.password),
|
||||||
|
key: credential.key,
|
||||||
|
keyPassword: credential.keyPassword,
|
||||||
|
keyType: credential.keyType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolvedUsername = pickResolvedUsername(
|
||||||
|
host.username,
|
||||||
|
credential.username,
|
||||||
|
host.overrideCredentialUsername,
|
||||||
|
);
|
||||||
|
if (resolvedUsername !== undefined) {
|
||||||
|
resolvedHost.username = resolvedUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...host };
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.warn(
|
||||||
|
`Failed to resolve credentials for host ${host.id}: ${getErrorMessage(error)}`,
|
||||||
|
);
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
/**
|
|
||||||
* The host list's status dot.
|
|
||||||
*
|
|
||||||
* Every host with status checks on gets a TCP connection to its port on a
|
|
||||||
* timer. A port that answers is "online", one that does not (twice in a row)
|
|
||||||
* is "offline". An open session or a working login also counts as online.
|
|
||||||
* Probing never logs in to the host itself: on RADIUS or Duo backed devices a
|
|
||||||
* login fires a real 2FA push, every interval. A host behind jump hosts is
|
|
||||||
* probed through a chain, which does log in to the hops, so hosts that share
|
|
||||||
* a hop list share one chain (jump-probe-pool.ts).
|
|
||||||
*
|
|
||||||
* Polling is demand driven. A user asking for statuses starts polling their
|
|
||||||
* own hosts; nothing is probed for a user who never opened the app. The first
|
|
||||||
* request waits briefly for those first checks so it is not empty.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
|
||||||
import { hostSessionStatus } from "../host-session-status.js";
|
|
||||||
import { sshLogger } from "../../utils/logger.js";
|
|
||||||
import {
|
|
||||||
createCurrentHostResolutionRepository,
|
|
||||||
getCurrentSettingValue,
|
|
||||||
} from "../../database/repositories/factory.js";
|
|
||||||
import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js";
|
|
||||||
import type { HostStatus } from "./host-status.js";
|
|
||||||
import { ConcurrentLimiter } from "./limiter.js";
|
|
||||||
import { tcpPing } from "./tcp-ping.js";
|
|
||||||
import { JumpProbePool } from "./jump-probe-pool.js";
|
|
||||||
|
|
||||||
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval";
|
|
||||||
export const DEFAULT_STATUS_INTERVAL = 30;
|
|
||||||
/** How long the first request for a user waits for the first checks. */
|
|
||||||
const FIRST_CHECK_WAIT_MS = 6_000;
|
|
||||||
/** A status younger than this is fresh enough for check(). */
|
|
||||||
const FRESH_MS = 30_000;
|
|
||||||
|
|
||||||
export interface HostStatusEntry {
|
|
||||||
status: HostStatus;
|
|
||||||
lastChecked: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HostStatusPayload {
|
|
||||||
hostId: number;
|
|
||||||
ownerUserId: string;
|
|
||||||
status: HostStatus;
|
|
||||||
previous: HostStatus | null;
|
|
||||||
/** Anything but offline, the shape automations' trigger already reads. */
|
|
||||||
online: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatusTarget {
|
|
||||||
id: number;
|
|
||||||
userId: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
connectionType: string;
|
|
||||||
jumpHosts: Array<{ hostId: number }>;
|
|
||||||
statusCheckEnabled: boolean;
|
|
||||||
statusCheckInterval: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PortResolver = (
|
|
||||||
hostId: number,
|
|
||||||
) => number | undefined | Promise<number | undefined>;
|
|
||||||
|
|
||||||
export interface HostStatusDeps {
|
|
||||||
loadTargets: (filter: {
|
|
||||||
userId?: string;
|
|
||||||
hostIds?: number[];
|
|
||||||
}) => Promise<StatusTarget[]>;
|
|
||||||
/** Hosts other users shared with this one, directly or through a role. */
|
|
||||||
loadSharedHostIds?: (userId: string) => Promise<number[]>;
|
|
||||||
ping: (host: string, port: number) => Promise<boolean>;
|
|
||||||
pingThroughJumpHosts: (
|
|
||||||
target: StatusTarget,
|
|
||||||
port: number,
|
|
||||||
) => Promise<boolean>;
|
|
||||||
hasActiveSession?: (hostId: number) => boolean;
|
|
||||||
globalInterval: () => number;
|
|
||||||
emit: (payload: HostStatusPayload) => void;
|
|
||||||
/** Pause before a failed ping is tried again. */
|
|
||||||
retryDelayMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
|
|
||||||
if (!raw) return [];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed)
|
|
||||||
? parsed.filter(
|
|
||||||
(hop): hop is { hostId: number } =>
|
|
||||||
!!hop && Number.isInteger((hop as { hostId?: unknown }).hostId),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toStatusTarget(row: HostStatusTargetRow): StatusTarget {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
userId: row.userId,
|
|
||||||
ip: row.ip,
|
|
||||||
port: row.port,
|
|
||||||
connectionType: row.connectionType || "ssh",
|
|
||||||
jumpHosts: parseJumpHosts(row.jumpHosts),
|
|
||||||
statusCheckEnabled: row.statusCheckEnabled !== false,
|
|
||||||
statusCheckInterval: row.statusCheckInterval ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const jumpProbes = new JumpProbePool(async (jumpHosts, userId) => {
|
|
||||||
const { createJumpHostChain } = await import("../jump-host-chain.js");
|
|
||||||
return createJumpHostChain(jumpHosts, userId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultDeps: HostStatusDeps = {
|
|
||||||
loadTargets: async (filter) =>
|
|
||||||
(
|
|
||||||
await createCurrentHostResolutionRepository().listStatusTargets(filter)
|
|
||||||
).map(toStatusTarget),
|
|
||||||
loadSharedHostIds: async (userId) => {
|
|
||||||
const { createCurrentRbacAccessRepository, createCurrentRoleRepository } =
|
|
||||||
await import("../../database/repositories/factory.js");
|
|
||||||
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
|
||||||
const entries =
|
|
||||||
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
|
||||||
userId,
|
|
||||||
roleIds,
|
|
||||||
);
|
|
||||||
return [...new Set(entries.map((entry) => entry.hostId))];
|
|
||||||
},
|
|
||||||
hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId),
|
|
||||||
ping: (host, port) => tcpPing(host, port, 5000),
|
|
||||||
pingThroughJumpHosts: (target, port) =>
|
|
||||||
jumpProbes.ping(target.jumpHosts, target.userId, target.ip, port, 5000),
|
|
||||||
globalInterval: () => {
|
|
||||||
const value = Number(getCurrentSettingValue(GLOBAL_STATUS_INTERVAL_KEY));
|
|
||||||
return Number.isInteger(value) && value >= 5
|
|
||||||
? value
|
|
||||||
: DEFAULT_STATUS_INTERVAL;
|
|
||||||
},
|
|
||||||
emit: (payload) => pluginEvents.emit(TOPICS.hostStatus, payload),
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Polled {
|
|
||||||
target: StatusTarget;
|
|
||||||
timer?: NodeJS.Timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HostStatusService {
|
|
||||||
private readonly polled = new Map<number, Polled>();
|
|
||||||
private readonly store = new Map<number, HostStatusEntry>();
|
|
||||||
private readonly owners = new Map<number, string>();
|
|
||||||
private readonly inFlight = new Map<number, Promise<void>>();
|
|
||||||
private readonly startedUsers = new Set<string>();
|
|
||||||
private readonly portResolvers = new Map<string, Set<PortResolver>>();
|
|
||||||
private readonly limiter = new ConcurrentLimiter(20);
|
|
||||||
private unsubscribers: Array<() => void> = [];
|
|
||||||
|
|
||||||
constructor(private readonly deps: HostStatusDeps = defaultDeps) {}
|
|
||||||
|
|
||||||
/** Subscribes to the core events the status depends on. */
|
|
||||||
start(): void {
|
|
||||||
if (this.unsubscribers.length > 0) return;
|
|
||||||
this.unsubscribers = [
|
|
||||||
hostSessionStatus.subscribe((hostId, online) => {
|
|
||||||
if (online) this.reportLogin(hostId, { ok: true });
|
|
||||||
}),
|
|
||||||
pluginEvents.on(TOPICS.hostUpdated, (payload) => {
|
|
||||||
const { hostId } = payload as { hostId?: number };
|
|
||||||
if (hostId) void this.reload(hostId);
|
|
||||||
}),
|
|
||||||
pluginEvents.on(TOPICS.hostDeleted, (payload) => {
|
|
||||||
const { hostId } = payload as { hostId?: number };
|
|
||||||
if (hostId) this.forget(hostId);
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
for (const unsubscribe of this.unsubscribers) unsubscribe();
|
|
||||||
this.unsubscribers = [];
|
|
||||||
for (const hostId of [...this.polled.keys()]) this.stopPolling(hostId);
|
|
||||||
this.startedUsers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
get(hostId: number): HostStatusEntry | null {
|
|
||||||
return this.store.get(hostId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Statuses for a user's request. With hostIds, polling is brought in line
|
|
||||||
* with exactly those of their own hosts (the desktop app sends only the
|
|
||||||
* hosts that live on this backend). Without, their own hosts are started
|
|
||||||
* once.
|
|
||||||
*/
|
|
||||||
async statusesFor(
|
|
||||||
userId: string,
|
|
||||||
requestedHostIds: Set<number> | null,
|
|
||||||
): Promise<Map<number, HostStatusEntry>> {
|
|
||||||
const started: Promise<void>[] = [];
|
|
||||||
if (requestedHostIds !== null) {
|
|
||||||
await this.reconcile(userId, requestedHostIds, started);
|
|
||||||
} else if (!this.startedUsers.has(userId)) {
|
|
||||||
await this.startUser(userId, started);
|
|
||||||
}
|
|
||||||
if (started.length > 0) await this.waitFor(started);
|
|
||||||
return this.store;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drops every timer for a user's hosts and starts them again. */
|
|
||||||
async refresh(userId: string): Promise<void> {
|
|
||||||
const targets = await this.deps.loadTargets({ userId });
|
|
||||||
for (const target of targets) this.stopPolling(target.id);
|
|
||||||
for (const target of targets) this.startPolling(target);
|
|
||||||
this.startedUsers.add(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Re-times every polled host, after the global interval changed. */
|
|
||||||
retimeAll(): void {
|
|
||||||
for (const { target } of [...this.polled.values()]) {
|
|
||||||
this.startPolling(target, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Checks now unless the last result is fresh. */
|
|
||||||
async check(hostId: number): Promise<HostStatusEntry | null> {
|
|
||||||
const current = this.store.get(hostId);
|
|
||||||
if (current && Date.now() - Date.parse(current.lastChecked) < FRESH_MS) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
const target =
|
|
||||||
this.polled.get(hostId)?.target ??
|
|
||||||
(await this.deps.loadTargets({ hostIds: [hostId] }))[0];
|
|
||||||
if (!target || !target.statusCheckEnabled) return current ?? null;
|
|
||||||
await this.probe(target);
|
|
||||||
return this.store.get(hostId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A working login proves the host is up. A failed one says nothing about
|
|
||||||
* reachability (a wrong password, another user's login), so it is ignored.
|
|
||||||
*/
|
|
||||||
reportLogin(hostId: number, outcome: { ok: boolean }): void {
|
|
||||||
if (outcome.ok) this.set(hostId, { status: "online" });
|
|
||||||
}
|
|
||||||
|
|
||||||
registerPort(connectionType: string, resolve: PortResolver): () => void {
|
|
||||||
let set = this.portResolvers.get(connectionType);
|
|
||||||
if (!set) {
|
|
||||||
set = new Set();
|
|
||||||
this.portResolvers.set(connectionType, set);
|
|
||||||
}
|
|
||||||
set.add(resolve);
|
|
||||||
return () => {
|
|
||||||
set!.delete(resolve);
|
|
||||||
if (set!.size === 0) this.portResolvers.delete(connectionType);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitFor(started: Promise<void>[]): Promise<void> {
|
|
||||||
let timer: NodeJS.Timeout | undefined;
|
|
||||||
await Promise.race([
|
|
||||||
Promise.allSettled(started),
|
|
||||||
new Promise<void>((resolve) => {
|
|
||||||
timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS);
|
|
||||||
timer.unref?.();
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
private track(started: Promise<void>[], target: StatusTarget): void {
|
|
||||||
const probe = this.startPolling(target);
|
|
||||||
if (probe) started.push(probe);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async startUser(
|
|
||||||
userId: string,
|
|
||||||
started: Promise<void>[],
|
|
||||||
): Promise<void> {
|
|
||||||
this.startedUsers.add(userId);
|
|
||||||
const targets = await this.deps.loadTargets({ userId });
|
|
||||||
for (const target of targets) {
|
|
||||||
if (!this.polled.has(target.id)) this.track(started, target);
|
|
||||||
}
|
|
||||||
await this.startShared(userId, null, started);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hosts shared with the user are checked as their owners, even when no
|
|
||||||
* owner has asked for statuses since the server started.
|
|
||||||
*/
|
|
||||||
private async startShared(
|
|
||||||
userId: string,
|
|
||||||
allowed: Set<number> | null,
|
|
||||||
started: Promise<void>[],
|
|
||||||
): Promise<void> {
|
|
||||||
if (!this.deps.loadSharedHostIds) return;
|
|
||||||
try {
|
|
||||||
const shared = (await this.deps.loadSharedHostIds(userId)).filter(
|
|
||||||
(hostId) =>
|
|
||||||
!this.polled.has(hostId) && (allowed === null || allowed.has(hostId)),
|
|
||||||
);
|
|
||||||
if (shared.length === 0) return;
|
|
||||||
const targets = await this.deps.loadTargets({ hostIds: shared });
|
|
||||||
for (const target of targets) {
|
|
||||||
if (!this.polled.has(target.id)) this.track(started, target);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
sshLogger.warn("Could not start status checks for shared hosts", {
|
|
||||||
operation: "host_status_shared",
|
|
||||||
userId,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async reconcile(
|
|
||||||
userId: string,
|
|
||||||
allowed: Set<number>,
|
|
||||||
started: Promise<void>[],
|
|
||||||
): Promise<void> {
|
|
||||||
const targets = await this.deps.loadTargets({ userId });
|
|
||||||
for (const target of targets) {
|
|
||||||
if (!allowed.has(target.id)) {
|
|
||||||
this.stopPolling(target.id);
|
|
||||||
this.store.delete(target.id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!this.polled.has(target.id)) this.track(started, target);
|
|
||||||
}
|
|
||||||
await this.startShared(userId, allowed, started);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async reload(hostId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const [target] = await this.deps.loadTargets({ hostIds: [hostId] });
|
|
||||||
if (!target) {
|
|
||||||
this.forget(hostId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.polled.has(hostId) || this.startedUsers.has(target.userId)) {
|
|
||||||
this.startPolling(target);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
sshLogger.warn("Could not reload host for status checks", {
|
|
||||||
operation: "host_status_reload",
|
|
||||||
hostId,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private forget(hostId: number): void {
|
|
||||||
this.stopPolling(hostId);
|
|
||||||
this.store.delete(hostId);
|
|
||||||
this.owners.delete(hostId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private intervalMs(target: StatusTarget): number {
|
|
||||||
const seconds = target.statusCheckInterval ?? this.deps.globalInterval();
|
|
||||||
const intervalMs = Math.max(5, seconds) * 1000;
|
|
||||||
// Spread hosts so a fleet does not fire on the same second.
|
|
||||||
const spread = Math.min(intervalMs * 0.2, 15_000);
|
|
||||||
return (
|
|
||||||
intervalMs + ((target.id * 1103515245) % Math.max(1, Math.floor(spread)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Returns the first probe when one was started. */
|
|
||||||
private startPolling(
|
|
||||||
target: StatusTarget,
|
|
||||||
probeNow = true,
|
|
||||||
): Promise<void> | undefined {
|
|
||||||
this.stopPolling(target.id);
|
|
||||||
this.owners.set(target.id, target.userId);
|
|
||||||
if (!target.statusCheckEnabled) {
|
|
||||||
this.store.delete(target.id);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const polled: Polled = { target };
|
|
||||||
this.polled.set(target.id, polled);
|
|
||||||
const first = probeNow ? this.probe(target) : undefined;
|
|
||||||
polled.timer = setInterval(() => {
|
|
||||||
const latest = this.polled.get(target.id);
|
|
||||||
if (latest) void this.probe(latest.target);
|
|
||||||
}, this.intervalMs(target));
|
|
||||||
polled.timer.unref?.();
|
|
||||||
return first;
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopPolling(hostId: number): void {
|
|
||||||
const polled = this.polled.get(hostId);
|
|
||||||
if (polled?.timer) clearInterval(polled.timer);
|
|
||||||
this.polled.delete(hostId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private probe(target: StatusTarget): Promise<void> {
|
|
||||||
const running = this.inFlight.get(target.id);
|
|
||||||
if (running) return running;
|
|
||||||
const job = this.limiter
|
|
||||||
.run(() => this.probeNow(target))
|
|
||||||
.catch((error) => {
|
|
||||||
sshLogger.error("Status check failed", error, {
|
|
||||||
operation: "host_status_probe",
|
|
||||||
hostId: target.id,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => this.inFlight.delete(target.id));
|
|
||||||
this.inFlight.set(target.id, job);
|
|
||||||
return job;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async portFor(target: StatusTarget): Promise<number> {
|
|
||||||
if (target.connectionType !== "ssh") {
|
|
||||||
for (const resolve of this.portResolvers.get(target.connectionType) ??
|
|
||||||
[]) {
|
|
||||||
try {
|
|
||||||
const port = await resolve(target.id);
|
|
||||||
if (Number.isInteger(port) && port! > 0) return port!;
|
|
||||||
} catch {
|
|
||||||
// fall through to the next resolver
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return target.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async pingOnce(target: StatusTarget): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
if (this.deps.hasActiveSession?.(target.id)) return true;
|
|
||||||
const port = await this.portFor(target);
|
|
||||||
return target.jumpHosts.length > 0
|
|
||||||
? await this.deps.pingThroughJumpHosts(target, port)
|
|
||||||
: await this.deps.ping(target.ip, port);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async probeNow(target: StatusTarget): Promise<void> {
|
|
||||||
this.owners.set(target.id, target.userId);
|
|
||||||
let reachable = await this.pingOnce(target);
|
|
||||||
if (!reachable) {
|
|
||||||
// One lost packet should not flip the dot.
|
|
||||||
const delay = this.deps.retryDelayMs ?? 1000;
|
|
||||||
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
reachable = await this.pingOnce(target);
|
|
||||||
}
|
|
||||||
this.set(target.id, { status: reachable ? "online" : "offline" });
|
|
||||||
}
|
|
||||||
|
|
||||||
private set(hostId: number, entry: Omit<HostStatusEntry, "lastChecked">) {
|
|
||||||
const previous = this.store.get(hostId)?.status ?? null;
|
|
||||||
const next: HostStatusEntry = {
|
|
||||||
...entry,
|
|
||||||
lastChecked: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
this.store.set(hostId, next);
|
|
||||||
if (previous === next.status) return;
|
|
||||||
void this.emitChange(hostId, next, previous);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async emitChange(
|
|
||||||
hostId: number,
|
|
||||||
entry: HostStatusEntry,
|
|
||||||
previous: HostStatus | null,
|
|
||||||
): Promise<void> {
|
|
||||||
let ownerUserId = this.owners.get(hostId);
|
|
||||||
if (!ownerUserId) {
|
|
||||||
const [target] = await this.deps
|
|
||||||
.loadTargets({ hostIds: [hostId] })
|
|
||||||
.catch(() => []);
|
|
||||||
if (!target) return;
|
|
||||||
ownerUserId = target.userId;
|
|
||||||
this.owners.set(hostId, ownerUserId);
|
|
||||||
}
|
|
||||||
this.deps.emit({
|
|
||||||
hostId,
|
|
||||||
ownerUserId,
|
|
||||||
status: entry.status,
|
|
||||||
previous,
|
|
||||||
online: entry.status === "online",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hostStatusService = new HostStatusService();
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export type HostStatus = "online" | "offline";
|
|
||||||
|
|
||||||
export function isHostKeyVerificationError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
error instanceof Error &&
|
|
||||||
(error.message.includes("Host denied (verification failed)") ||
|
|
||||||
error.message.includes("Host key changed"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
|
|
||||||
*/
|
|
||||||
export class ConcurrentLimiter {
|
|
||||||
private active = 0;
|
|
||||||
private readonly waiters: Array<() => void> = [];
|
|
||||||
|
|
||||||
constructor(private maxConcurrent: number) {
|
|
||||||
if (maxConcurrent < 1) {
|
|
||||||
throw new Error("maxConcurrent must be >= 1");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeCount(): number {
|
|
||||||
return this.active;
|
|
||||||
}
|
|
||||||
|
|
||||||
get pendingCount(): number {
|
|
||||||
return this.waiters.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
get limit(): number {
|
|
||||||
return this.maxConcurrent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Changes the ceiling at runtime.
|
|
||||||
*
|
|
||||||
* Raising it wakes the waiters the new headroom allows, so a queue that built
|
|
||||||
* up under the old limit drains at once instead of one job at a time as
|
|
||||||
* running work finishes. Lowering it never interrupts work already running;
|
|
||||||
* the new limit simply applies from the next release onward.
|
|
||||||
*/
|
|
||||||
setLimit(maxConcurrent: number): void {
|
|
||||||
if (maxConcurrent < 1) {
|
|
||||||
throw new Error("maxConcurrent must be >= 1");
|
|
||||||
}
|
|
||||||
this.maxConcurrent = maxConcurrent;
|
|
||||||
this.releaseWaiters();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wakes as many queued callers as there is now room for.
|
|
||||||
*
|
|
||||||
* `woken` counts callers that have been resumed but have not yet reached the
|
|
||||||
* `active += 1` on the far side of their await. Without it the occupancy
|
|
||||||
* looks lower than it really is for a microtask, and the loop would release
|
|
||||||
* past the ceiling.
|
|
||||||
*/
|
|
||||||
private releaseWaiters(): void {
|
|
||||||
while (
|
|
||||||
this.active + this.woken < this.maxConcurrent &&
|
|
||||||
this.waiters.length > 0
|
|
||||||
) {
|
|
||||||
this.woken += 1;
|
|
||||||
this.waiters.shift()!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private woken = 0;
|
|
||||||
|
|
||||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
// Queue behind anything already woken, so a caller arriving mid-handoff
|
|
||||||
// cannot jump the queue and push occupancy past the ceiling.
|
|
||||||
if (this.active + this.woken >= this.maxConcurrent) {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
this.waiters.push(resolve);
|
|
||||||
});
|
|
||||||
// Resumed by a slot handoff; that reservation is now consumed.
|
|
||||||
this.woken = Math.max(0, this.woken - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.active += 1;
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
this.active -= 1;
|
|
||||||
this.releaseWaiters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Recent activity is a provider: core owns the callers (the dashboard routes,
|
||||||
|
* ctx.hosts.recordActivity, the cleanup when a user or host goes away) and one
|
||||||
|
* provider owns the storage. A plugin registers with
|
||||||
|
* ctx.hosts.activity.provide. With none, nothing is recorded and the list is
|
||||||
|
* empty. Core checks that the user may reach the host before it records.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ActivityEntry {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
type: string;
|
||||||
|
hostId: number;
|
||||||
|
hostName: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecordActivityResult =
|
||||||
|
| { status: "logged"; id: number }
|
||||||
|
| { status: "rate_limited" }
|
||||||
|
| { status: "invalid_type" }
|
||||||
|
| { status: "denied" }
|
||||||
|
| { status: "unavailable" };
|
||||||
|
|
||||||
|
export interface ActivityProvider {
|
||||||
|
record(
|
||||||
|
userId: string,
|
||||||
|
entry: { type: string; hostId: number; hostName: string },
|
||||||
|
): Promise<RecordActivityResult>;
|
||||||
|
list(userId: string, limit: number): Promise<ActivityEntry[]>;
|
||||||
|
deleteByUserId(userId: string): Promise<void>;
|
||||||
|
deleteByHostIds(hostIds: number[]): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugged: { pluginId: string; provider: ActivityProvider } | null = null;
|
||||||
|
|
||||||
|
export function provideActivity(
|
||||||
|
pluginId: string,
|
||||||
|
provider: ActivityProvider,
|
||||||
|
): () => void {
|
||||||
|
if (plugged) {
|
||||||
|
throw new Error(
|
||||||
|
`Recent activity is already provided by plugin "${plugged.pluginId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const entry = { pluginId, provider };
|
||||||
|
plugged = entry;
|
||||||
|
return () => {
|
||||||
|
if (plugged === entry) plugged = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function active(): ActivityProvider | null {
|
||||||
|
return plugged?.provider ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canReachHost(userId: string, hostId: number): Promise<boolean> {
|
||||||
|
const {
|
||||||
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentRbacAccessRepository,
|
||||||
|
createCurrentRoleRepository,
|
||||||
|
} = await import("../database/repositories/factory.js");
|
||||||
|
if (
|
||||||
|
await createCurrentHostResolutionRepository().isHostOwnedByUser(
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const roleIds = await createCurrentRoleRepository().listUserRoleIds(userId);
|
||||||
|
const shared =
|
||||||
|
await createCurrentRbacAccessRepository().listVisibleHostAccessEntries(
|
||||||
|
userId,
|
||||||
|
roleIds,
|
||||||
|
);
|
||||||
|
return shared.some((access) => access.hostId === hostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the rest of core calls. */
|
||||||
|
export const recentActivity = {
|
||||||
|
async record(
|
||||||
|
userId: string,
|
||||||
|
entry: { type: string; hostId: number; hostName: string },
|
||||||
|
): Promise<RecordActivityResult> {
|
||||||
|
const provider = active();
|
||||||
|
if (!provider) return { status: "unavailable" };
|
||||||
|
if (!(await canReachHost(userId, entry.hostId))) {
|
||||||
|
return { status: "denied" };
|
||||||
|
}
|
||||||
|
return provider.record(userId, entry);
|
||||||
|
},
|
||||||
|
async list(userId: string, limit: number): Promise<ActivityEntry[]> {
|
||||||
|
return (await active()?.list(userId, limit)) ?? [];
|
||||||
|
},
|
||||||
|
async deleteByUserId(userId: string): Promise<void> {
|
||||||
|
await active()?.deleteByUserId(userId);
|
||||||
|
},
|
||||||
|
async deleteByHostId(hostId: number): Promise<void> {
|
||||||
|
await active()?.deleteByHostIds([hostId]);
|
||||||
|
},
|
||||||
|
async deleteByHostIds(hostIds: number[]): Promise<void> {
|
||||||
|
await active()?.deleteByHostIds(hostIds);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
|
// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
|
||||||
|
// CSS files its themes declare.
|
||||||
//
|
//
|
||||||
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
||||||
// bearer header, and nothing here is secret. It only ever serves files from
|
// bearer header, and nothing here is secret. It only ever serves files from
|
||||||
@@ -28,6 +29,22 @@ export interface PluginFrontendInfo {
|
|||||||
assetVersion: string | null;
|
assetVersion: string | null;
|
||||||
/** Language codes with a locale file, "en" plus xx_YY names. */
|
/** Language codes with a locale file, "en" plus xx_YY names. */
|
||||||
locales: string[];
|
locales: string[];
|
||||||
|
/** Changes whenever a theme's CSS does, for its ?v= link. */
|
||||||
|
themeVersion: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a theme's CSS is served: /plugin-assets/<id>/_themes/<theme>.css. */
|
||||||
|
export const THEME_ASSET_PREFIX = "_themes/";
|
||||||
|
|
||||||
|
/** The declared CSS file of each of the plugin's themes, by theme id. */
|
||||||
|
function themeFiles(plugin: AssetPlugin): Map<string, string> {
|
||||||
|
const files = new Map<string, string>();
|
||||||
|
const manifest = plugin.manifest as PluginManifest;
|
||||||
|
for (const theme of manifest.contributes?.themes ?? []) {
|
||||||
|
const file = path.resolve(plugin.dir, theme.css);
|
||||||
|
if (isInside(plugin.dir, file)) files.set(theme.id, file);
|
||||||
|
}
|
||||||
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
function frontendEntry(
|
function frontendEntry(
|
||||||
@@ -72,9 +89,16 @@ export function describePluginFrontend(
|
|||||||
css: false,
|
css: false,
|
||||||
assetVersion: null,
|
assetVersion: null,
|
||||||
locales: [],
|
locales: [],
|
||||||
|
themeVersion: null,
|
||||||
};
|
};
|
||||||
if (!plugin) return none;
|
if (!plugin) return none;
|
||||||
|
|
||||||
|
const themes = [...themeFiles(plugin).values()].filter(fileExists);
|
||||||
|
const themeVersion =
|
||||||
|
themes.length > 0
|
||||||
|
? contentVersion(themes.sort(), plugin.manifest.version)
|
||||||
|
: null;
|
||||||
|
|
||||||
const locales: string[] = [];
|
const locales: string[] = [];
|
||||||
const localeFiles: string[] = [];
|
const localeFiles: string[] = [];
|
||||||
const dir = localesDir(plugin);
|
const dir = localesDir(plugin);
|
||||||
@@ -96,7 +120,8 @@ export function describePluginFrontend(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entry = frontendEntry(plugin);
|
const entry = frontendEntry(plugin);
|
||||||
if (!entry || !fileExists(entry.file)) return { ...none, locales };
|
if (!entry || !fileExists(entry.file))
|
||||||
|
return { ...none, locales, themeVersion };
|
||||||
|
|
||||||
const cssFile = path.join(entry.dir, "frontend.css");
|
const cssFile = path.join(entry.dir, "frontend.css");
|
||||||
const css = fileExists(cssFile);
|
const css = fileExists(cssFile);
|
||||||
@@ -111,6 +136,7 @@ export function describePluginFrontend(
|
|||||||
css,
|
css,
|
||||||
assetVersion,
|
assetVersion,
|
||||||
locales: locales.sort(),
|
locales: locales.sort(),
|
||||||
|
themeVersion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +184,14 @@ export function resolvePluginAsset(
|
|||||||
|
|
||||||
let root: string | null;
|
let root: string | null;
|
||||||
let rest: string;
|
let rest: string;
|
||||||
if (clean.startsWith("locales/")) {
|
if (clean.startsWith(THEME_ASSET_PREFIX)) {
|
||||||
|
// Only a declared theme file, which the loader checked on load.
|
||||||
|
const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length);
|
||||||
|
const file = themeFiles(plugin).get(themeId);
|
||||||
|
if (!file || !clean.endsWith(".css")) return null;
|
||||||
|
root = path.dirname(file);
|
||||||
|
rest = path.basename(file);
|
||||||
|
} else if (clean.startsWith("locales/")) {
|
||||||
root = localesDir(plugin);
|
root = localesDir(plugin);
|
||||||
rest = clean.slice("locales/".length);
|
rest = clean.slice("locales/".length);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* The onboarding defaults of the bundled plugins, from
|
* The onboarding defaults and the default look of the bundled plugins, from
|
||||||
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
|
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
|
||||||
* docker/bundled-plugins.json). Core never names plugins, so which ones a
|
* docker/bundled-plugins.json). Core never names plugins, so which ones a
|
||||||
* fresh install keeps lives in that file and not here.
|
* fresh install keeps lives in that file and not here.
|
||||||
@@ -27,6 +27,7 @@ interface BundledIndex {
|
|||||||
consent?: boolean;
|
consent?: boolean;
|
||||||
enabledByEnv?: string[];
|
enabledByEnv?: string[];
|
||||||
};
|
};
|
||||||
|
defaultLook?: boolean;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null {
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundled plugins whose layout and theme a fresh install shows. Core reads
|
||||||
|
* the default look from here so it never has to name a plugin.
|
||||||
|
*/
|
||||||
|
export function defaultLookPluginIds(): string[] {
|
||||||
|
const index = readBundledIndex();
|
||||||
|
if (!index) return [];
|
||||||
|
return Object.entries(index.plugins)
|
||||||
|
.filter(([, entry]) => entry?.defaultLook === true)
|
||||||
|
.map(([id]) => id)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDefaultLookPlugin(pluginId: string): boolean {
|
||||||
|
return defaultLookPluginIds().includes(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
export function resetBundledIndexCache(): void {
|
export function resetBundledIndexCache(): void {
|
||||||
cached = undefined;
|
cached = undefined;
|
||||||
}
|
}
|
||||||
@@ -96,6 +114,8 @@ export function initialPluginState(
|
|||||||
): "enabled" | "disabled" {
|
): "enabled" | "disabled" {
|
||||||
if (source !== "bundled") return "disabled";
|
if (source !== "bundled") return "disabled";
|
||||||
if (!freshInstall) return "enabled";
|
if (!freshInstall) return "enabled";
|
||||||
|
// The default look is what the app is drawn with.
|
||||||
|
if (isDefaultLookPlugin(pluginId)) return "enabled";
|
||||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||||
return recommended && !consent ? "enabled" : "disabled";
|
return recommended && !consent ? "enabled" : "disabled";
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* ctx.audit.subscribe, list and exportBatches: reading the audit trail,
|
||||||
|
* behind audit:read. Writing lines (record) stays in ctx.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PluginAudit,
|
||||||
|
PluginAuditEntry,
|
||||||
|
PluginAuditFilters,
|
||||||
|
} from "@termix-ssh/plugin-sdk/backend";
|
||||||
|
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||||
|
import { assertCapability, capabilityRefused } from "./permissions.js";
|
||||||
|
import type { DisposableBag } from "./disposables.js";
|
||||||
|
import { onAuditEntry } from "../utils/audit-hooks.js";
|
||||||
|
import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js";
|
||||||
|
|
||||||
|
type AuditFn = (
|
||||||
|
action: string,
|
||||||
|
details: string,
|
||||||
|
outcome: { success: boolean; errorMessage?: string },
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
|
interface Deps {
|
||||||
|
manifest: PluginManifest;
|
||||||
|
bag: DisposableBag;
|
||||||
|
audit: AuditFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_PAGE = 200;
|
||||||
|
const MAX_BATCH = 1000;
|
||||||
|
|
||||||
|
function toEntry(row: AuditLogRecord): PluginAuditEntry {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
timestamp: new Date(
|
||||||
|
row.timestamp as unknown as string | number,
|
||||||
|
).toISOString(),
|
||||||
|
userId: row.userId ?? null,
|
||||||
|
username: row.username,
|
||||||
|
action: row.action,
|
||||||
|
resourceType: row.resourceType,
|
||||||
|
resourceId: row.resourceId ?? null,
|
||||||
|
resourceName: row.resourceName ?? null,
|
||||||
|
details: row.details ?? null,
|
||||||
|
ipAddress: row.ipAddress ?? null,
|
||||||
|
userAgent: row.userAgent ?? null,
|
||||||
|
success: Boolean(row.success),
|
||||||
|
errorMessage: row.errorMessage ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginAuditReader(
|
||||||
|
deps: Deps,
|
||||||
|
): Pick<PluginAudit, "subscribe" | "list" | "exportBatches"> {
|
||||||
|
const { manifest, bag, audit } = deps;
|
||||||
|
const require = (action: string) =>
|
||||||
|
assertCapability(manifest.id, "audit:read", manifest.capabilities, action);
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: (handler) => {
|
||||||
|
if (!manifest.capabilities.includes("audit:read")) {
|
||||||
|
throw capabilityRefused(manifest.id, "audit:read", "audit_subscribe");
|
||||||
|
}
|
||||||
|
let remove: (() => void) | null = null;
|
||||||
|
let forgetBag: (() => void) | null = null;
|
||||||
|
// Granted state is checked once, then every line is delivered until
|
||||||
|
// unsubscribed; a revoked grant stops the next subscribe.
|
||||||
|
void require("audit_subscribe")
|
||||||
|
.then(async () => {
|
||||||
|
await audit("audit_subscribe", "listened to the audit log", {
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
remove = onAuditEntry((entry) => {
|
||||||
|
handler({
|
||||||
|
timestamp: entry.timestamp.toISOString(),
|
||||||
|
userId: entry.userId,
|
||||||
|
username: entry.username,
|
||||||
|
action: entry.action,
|
||||||
|
resourceType: entry.resourceType,
|
||||||
|
resourceId: entry.resourceId ?? null,
|
||||||
|
resourceName: entry.resourceName ?? null,
|
||||||
|
details: entry.details ?? null,
|
||||||
|
ipAddress: entry.ipAddress ?? null,
|
||||||
|
userAgent: entry.userAgent ?? null,
|
||||||
|
success: entry.success,
|
||||||
|
errorMessage: entry.errorMessage ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
forgetBag = bag.add(() => remove?.(), "audit subscription");
|
||||||
|
return () => {
|
||||||
|
remove?.();
|
||||||
|
remove = null;
|
||||||
|
forgetBag?.();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
list: async (options) => {
|
||||||
|
await require("audit_list");
|
||||||
|
const { createCurrentAuditLogRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const limit = Math.min(MAX_PAGE, Math.max(1, options?.limit ?? 50));
|
||||||
|
const page = await createCurrentAuditLogRepository().listPage({
|
||||||
|
filters: options?.filters ?? {},
|
||||||
|
limit,
|
||||||
|
offset: Math.max(0, options?.offset ?? 0),
|
||||||
|
});
|
||||||
|
await audit("audit_list", "read audit log entries", { success: true });
|
||||||
|
return { entries: page.logs.map(toEntry), total: page.total };
|
||||||
|
},
|
||||||
|
|
||||||
|
exportBatches: async function* (
|
||||||
|
filters?: PluginAuditFilters,
|
||||||
|
batchSize?: number,
|
||||||
|
) {
|
||||||
|
await require("audit_export");
|
||||||
|
const { createCurrentAuditLogRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const limit = Math.min(MAX_BATCH, Math.max(1, batchSize ?? 500));
|
||||||
|
let offset = 0;
|
||||||
|
let exported = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const rows = await createCurrentAuditLogRepository().listForExport({
|
||||||
|
filters: filters ?? {},
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
if (rows.length === 0) break;
|
||||||
|
exported += rows.length;
|
||||||
|
offset += rows.length;
|
||||||
|
yield rows.map(toEntry);
|
||||||
|
if (rows.length < limit) break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await audit("audit_export", `exported ${exported} audit log entries`, {
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Loaded 100 of 473 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user