mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
v26.10.2 (#1672)
* fix: keep concurrent ssh pool connects from overwriting each other (#1619) * fix: stop auto-accepting changed jump host keys (#1620) A jump hop whose key no longer matched the pinned fingerprint was silently accepted and the pin overwritten. Background status probes reconnect to jump hosts every cycle, so a MITM on that hop got the credentials with no user interaction. Changed jump host keys now go through the same path as any other host: prompt when there is a terminal socket, otherwise refuse. * fix(electron): validate external login URL scheme and callback port (#1621) * fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622) The consent check read the first manifest.json with a hand-written tar reader, while the unpack keeps the last one and also follows pax and long-name headers. An archive with two manifests could pass the check with one capability list and get granted another. Read the manifest with node-tar, count only plain files, and refuse archives that hold more than one manifest.json. * fix: only trust the server's origin in the desktop login frame (#1624) The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it. The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent. * fix: check trusted proxy login against nginx's peer, not nginx (#1625) The backend only sees the bundled nginx on loopback, so the allowlist could only work by trusting 127.0.0.1, which let any client that reached nginx log in as anyone by sending the identity headers. nginx now passes its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and loopback connections are judged by that address instead. * fix: keep foreign keys on during user data import (#1626) Import credentials before hosts and remap the file's credential ids to the ones created here, so hosts and protocol logins no longer point at another server's ids. With rows written in dependency order, the import no longer turns off SQLite foreign keys on the shared connection. The uploaded file is now removed on every exit path. * fix: enforce unique usernames (#1628) * fix: enforce unique usernames Registration checked for a taken name, hashed the password, then inserted, with nothing in the schema enforcing uniqueness. Concurrent sign-ups could create two accounts with one name, after which login, password reset, proxy login and admin lookups by username picked between them arbitrarily. Add a unique index on users.username for every dialect. Existing duplicates are renamed (later rows get a -<id> suffix, the oldest keeps the name) rather than deleted. Registration and admin user creation now return the same 409 when the insert loses the race. * fix: avoid username collisions during migration * fix: don't auto-enable synced plugins signed only by a server-sent key (#1623) * fix: don't auto-enable synced plugins signed only by a server-sent key Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust. * fix: enforce local plugin trust across remote source labels * fix: require positive local trust for synced plugin keys * fix(desktop): harden external editor and session cookie IPC (#1627) * fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie open-external-editor now launches only the editor saved from a file dialog and refuses to hand executable file types to the OS opener. The unused get-session-cookie and wait-session-cookie channels, which returned the httpOnly jwt, are removed. * fix: validate setting keys before editor authorization * fix(sidebar): sort hosts by IP address numerically (#1629) * fix(desktop): find spawn-helper in universal macOS builds (#1630) The universal build ships app-arm64.asar and app-x64.asar instead of app.asar, so node-pty's helper path rewrite never pointed at the unpacked copy and every local terminal failed with posix_spawnp failed. * fix(sync): send shared hosts with text columns in their stored form (#1631) resolveHostById turns forceKeyboardInteractive into a boolean, and the shared-host export passed it on as is. A desktop could not bind the boolean into the text column, so every shared host failed to apply. * fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633) The vault plugin renames vault_profiles to p_vault_profiles, and SQLite rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then dropped p_vault_profiles and left ssh_data pointing at a missing table, so creating, cloning and deleting hosts failed with "no such table: main.p_vault_profiles". Postgres and MySQL refused the uninstall instead. The column was already copied into the vault plugin's host settings by 2.9, so retire it on every engine. The SQLite boot drop also repairs databases already in the broken state; MySQL drops the column's foreign key first. * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632) * fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own * fix(sync): switch off an untrusted mirrored plugin when the server updates it * fix: require local consent for mirrored plugin activation * docs: describe consent fields on the plugin state endpoint * fix(i18n): keep Indonesian technical terms consistent (#1636) Use the English terms Indonesian users know for tunnel, snippet, deploy, file manager, remote desktop, jump host, passphrase, sidebar, toolbar, endpoint, browser and SSH agent, instead of mixing literal translations (terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the English words. Match the split shortcuts to the split screen menu. Also fix the user data export description, which said secrets are encrypted while the English says they are decrypted. * fix(settings): leave user manage view when switching admin sections (#1649) The admin panel stays mounted across Settings pages, so an open user manage view kept replacing every other admin section until Back to users was clicked. Reset it whenever the selected section changes. * fix(sync): don't delete a local row when the server can't send its copy (#1650) * fix(sync): don't delete a local row when the server can't send its copy A push conflict on a live record whose server row failed to serialize came back as row: null, deleted: false, and the desktop treated that as a delete. The server now rejects that op as unavailable, the desktop never deletes on a conflict without a row, and serialization failures are logged. * fix(sync): retry temporarily unavailable records * fix: smooth host editor jumps to lower sections * chore: add changelog entries for 26.10.2 fixes * fix: stop building ssh2 native crypto in the docker image (#1644) * chore: test that an update keeps a changed auth type (#1601) * feat: option to hide the sidebar after opening a host (#1652) * feat: sort the dashboard host status card (Plugin-Host-Metrics#6) * fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2) * fix: say the server is down and keep retrying instead of a database error (#1637) * fix: show why a client tunnel failed to start (Plugin-Tunnels#2) * fix(sdk): skip comment-only chunks when splitting migrations * feat: setting to let the hover side menu push content again * fix: use the vite proxy for plugin sockets on any dev port * feat(sdk): let a plugin raise its public route rate limit * chore: update translations * chore: add the overview video to the readme * chore: update the overview video and add a play button to its thumbnail * feat: plugin builds bundle worker entries and import wasm and fonts as bytes * feat: plugin packs include LICENSE and NOTICE * chore: drop guacd from compose files and dev runner * chore: update remote desktop origin comment for the built-in engine * fix: show plugin names and descriptions in the UI language (#1658) * fix: show plugin names and descriptions in the UI language Read plugin.name and plugin.description from each plugin's own locale namespace, falling back to the manifest, everywhere core shows a plugin's name: the Plugins manager, onboarding picker, host editor tabs and sections, host filters and bulk actions, beta updates, interface presets and plugin crash/loading notices. * docs(sdk): document plugin.name and plugin.description locale keys --------- Co-authored-by: ZacharyZcR <zkl205305@gmail.com> * fix(hosts): reject or infer a missing authType on host create (#1664) Creating an SSH host through the API without authType hit the NOT NULL auth_type column and came back as a 500. The create route now infers the type from the credential, key or password in the body, falls back to the host defaults, and otherwise answers 400. A non-string authType is a 400 on create and update. * chore: changelog for merged plugin name and host auth type PRs * fix: move macOS split pane navigation to Cmd+Option+Arrow * fix: pick a free port for the desktop backend when 30001 is taken (#1663) * feat: build the Windows desktop app for ARM64 (#1615) * fix(sync): keep the changes cursor within the 32-bit seq column (#1665) The desktop learned the server's entity types by asking for changes after Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres, which rejects that bound value, so every pass needing the probe failed with 22003 out of range. The server now clamps the cursor to the largest seq the column holds and answers 400 for a cursor or limit that is not a non-negative integer. The desktop asks for a one-row page from its saved cursor instead. * fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667) Successful plugin_as_user and plugin_service_* entries written by a plugin with the same user, action, resource and details are recorded once per 10 minutes. Failures and every other action are still written each time. * feat(plugins): publish host.created when a host is added (#1668) Plugins that keep an inventory of hosts had no way to hear about a new one short of polling. Core now publishes host.created with the same { hostId, userId } payload as host.updated wherever a host row is created: the host create route, ctx.hosts.create, JSON and SSH config bulk import, database file import, and sync writing a host it did not have yet. * fix: show the login for signed-out full-screen view links and return to them (#1669) A ?view= link opened without a session spun forever: the gate rendered the bare view when the session check failed, so the plugin runtime never started and the view never registered. A regular browser now keeps the link in sessionStorage, shows the normal login and opens the link again after any login, including redirect logins. Only same-origin paths with a view are restored. The desktop app, embedded sign-in frames and guest views keep their current behavior. * fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670) * fix(desktop): send linked server proxy headers on WebSocket upgrades isLinkedOrigin compared the request origin with the link's http(s) origin, so the terminal WebSocket (wss://host/...) never matched and its upgrade went out without the proxy headers and basic auth. Behind a forward-auth proxy that upgrade was redirected to the login and the terminal closed with "connection closed unexpectedly". ws and wss now count as http and https for the comparison, the same mapping the WebSocket handshake itself uses, with host and port still matched exactly. * fix(desktop): allow a linked server's self-signed certificate for its WebSockets isInvalidCertificateAllowedForUrl compared the URL with a local getOrigin copy, so wss://host never matched the link's https://host origin and an allowed self-signed certificate did not cover the terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin, which maps ws and wss the same way as the proxy headers, and the local copy is removed. * feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666) ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background work a session for calling Termix's own API as an existing user. It needs users:impersonate, defaults to 300s and is capped at 900s, is stored as a normal session row marked as plugin-issued, and every issue is audited. Such sessions are refused on routes that change the user's password, second factors, API keys or linked logins. ctx.users.getLocale(userId) reads the user's UI language under users:read. SDK 1.0.11, plugin API 1.3.0. * chore: changelog for merged sync, audit, event and desktop PRs * feat: add shell.rightEdge slot so plugin panels can push the page over * chore: pin bundled plugins to the latest releases * chore: lint, format, and bump version to 26.10.2 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com> Co-authored-by: ZacharyZcR <zkl205305@gmail.com> Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
This commit is contained in:
227 files changed
+20364
-1340
No files matched your search
+219
-223
@@ -38,6 +38,7 @@ import { attachPluginWebSockets } from "../plugins/ws.js";
|
||||
import pluginRoutes from "./routes/plugins.js";
|
||||
import { createPluginAssetsRouter } from "../plugins/assets.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import { createCorsMiddleware } from "../utils/cors-config.js";
|
||||
import { createCompressionMiddleware } from "../utils/compression-config.js";
|
||||
import fs from "fs";
|
||||
@@ -79,7 +80,6 @@ import {
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "./repositories/factory.js";
|
||||
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
|
||||
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
|
||||
import {
|
||||
selectExportableSettings,
|
||||
@@ -1235,227 +1235,221 @@ app.post(
|
||||
};
|
||||
|
||||
try {
|
||||
await withCurrentSqliteForeignKeysDisabled(async () => {
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
const importedHostIds: number[] = [];
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const exists = await hostRepository.existsForImportIdentity(
|
||||
userId,
|
||||
host.ip,
|
||||
host.port,
|
||||
host.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
credentialId: host.credential_id || null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
jumpHosts: host.jump_hosts,
|
||||
...legacyStatusCheck(host),
|
||||
// Exports from before 2.9.0 carry these in terminal_config.
|
||||
sshOptions:
|
||||
host.ssh_options ??
|
||||
sshOptionsForWrite({
|
||||
terminalConfig: host.terminal_config,
|
||||
}) ??
|
||||
null,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
(hostData as Record<string, unknown>).defaultOverrides =
|
||||
JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns: hostData as Record<string, unknown>,
|
||||
body: hostData as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
const created = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
hostData,
|
||||
);
|
||||
await applyPluginHostImportSettings(
|
||||
Number(created.id),
|
||||
importedHostPluginSettings(importDb, host),
|
||||
);
|
||||
importedHostIds.push(Number(created.id));
|
||||
await importHostProtocolLogins(
|
||||
importDb,
|
||||
host,
|
||||
Number(created.id),
|
||||
userId,
|
||||
);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||
} catch {
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const credentialRepository =
|
||||
createCurrentCredentialRepository();
|
||||
const exists =
|
||||
await credentialRepository.existsForImportIdentity(
|
||||
userId,
|
||||
cred.name,
|
||||
cred.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
const pluginRows = await importUserPluginRows(importDb, userId);
|
||||
result.summary.pluginItemsImported += pluginRows.imported;
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
result.summary.errors.push(...pluginRows.errors);
|
||||
|
||||
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||
result.summary.skippedItems += hostDefaults.skipped;
|
||||
if (hostDefaults.imported > 0) {
|
||||
await recompute({ userIds: [userId] });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
// The file's credential ids belong to the server that wrote it.
|
||||
const credentialIds = new Map<number, number>();
|
||||
try {
|
||||
const importedCreds = importDb
|
||||
.prepare("SELECT * FROM ssh_credentials")
|
||||
.all();
|
||||
for (const cred of importedCreds) {
|
||||
try {
|
||||
const importedSettings = readImportedSettings(importDb);
|
||||
for (const setting of importedSettings) {
|
||||
try {
|
||||
await upsertImportedSetting(setting);
|
||||
result.summary.settingsImported++;
|
||||
} catch (settingError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
);
|
||||
}
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const existingId =
|
||||
await credentialRepository.findIdForImportIdentity(
|
||||
userId,
|
||||
cred.name,
|
||||
cred.username,
|
||||
);
|
||||
|
||||
if (existingId !== null) {
|
||||
credentialIds.set(cred.id, existingId);
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"settings table not found in import file, skipping",
|
||||
|
||||
const credData = {
|
||||
userId: userId,
|
||||
name: cred.name,
|
||||
description: cred.description,
|
||||
folder: cred.folder,
|
||||
tags: cred.tags,
|
||||
authType: cred.auth_type,
|
||||
username: cred.username,
|
||||
password: cred.password,
|
||||
key: cred.key,
|
||||
privateKey: cred.private_key,
|
||||
publicKey: cred.public_key,
|
||||
keyPassword: cred.key_password,
|
||||
keyType: cred.key_type,
|
||||
detectedKeyType: cred.detected_key_type,
|
||||
usageCount: cred.usage_count || 0,
|
||||
lastUsed: cred.last_used,
|
||||
createdAt: cred.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const created = await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
credData,
|
||||
);
|
||||
credentialIds.set(cred.id, Number(created.id));
|
||||
result.summary.sshCredentialsImported++;
|
||||
} catch (credError) {
|
||||
result.summary.errors.push(
|
||||
`SSH credential import error: ${credError.message}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
.all();
|
||||
const importedHostIds: number[] = [];
|
||||
for (const host of importedHosts) {
|
||||
try {
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const exists = await hostRepository.existsForImportIdentity(
|
||||
userId,
|
||||
},
|
||||
);
|
||||
host.ip,
|
||||
host.port,
|
||||
host.username,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
result.summary.skippedItems++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostData = {
|
||||
userId: userId,
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: Boolean(host.pin),
|
||||
authType: host.auth_type,
|
||||
forceKeyboardInteractive: host.force_keyboard_interactive,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
keyPassword: host.key_password,
|
||||
keyType: host.key_type,
|
||||
sudoPassword: host.sudo_password,
|
||||
credentialId: credentialIds.get(host.credential_id) ?? null,
|
||||
overrideCredentialUsername: Boolean(
|
||||
host.override_credential_username,
|
||||
),
|
||||
jumpHosts: host.jump_hosts,
|
||||
...legacyStatusCheck(host),
|
||||
// Exports from before 2.9.0 carry these in terminal_config.
|
||||
sshOptions:
|
||||
host.ssh_options ??
|
||||
sshOptionsForWrite({
|
||||
terminalConfig: host.terminal_config,
|
||||
}) ??
|
||||
null,
|
||||
notes: host.notes,
|
||||
useSocks5: Boolean(host.use_socks5),
|
||||
socks5Host: host.socks5_host,
|
||||
socks5Port: host.socks5_port,
|
||||
socks5Username: host.socks5_username,
|
||||
socks5Password: host.socks5_password,
|
||||
socks5ProxyChain: host.socks5_proxy_chain,
|
||||
createdAt: host.created_at || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
(hostData as Record<string, unknown>).defaultOverrides =
|
||||
JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns: hostData as Record<string, unknown>,
|
||||
body: hostData as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
const created = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
hostData,
|
||||
);
|
||||
await applyPluginHostImportSettings(
|
||||
Number(created.id),
|
||||
importedHostPluginSettings(importDb, host),
|
||||
);
|
||||
importedHostIds.push(Number(created.id));
|
||||
await importHostProtocolLogins(
|
||||
importDb,
|
||||
host,
|
||||
Number(created.id),
|
||||
userId,
|
||||
credentialIds,
|
||||
);
|
||||
result.summary.sshHostsImported++;
|
||||
} catch (hostError) {
|
||||
result.summary.errors.push(
|
||||
`SSH host import error: ${hostError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||
for (const hostId of importedHostIds) {
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
|
||||
const pluginRows = await importUserPluginRows(importDb, userId);
|
||||
result.summary.pluginItemsImported += pluginRows.imported;
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
result.summary.errors.push(...pluginRows.errors);
|
||||
|
||||
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||
result.summary.skippedItems += hostDefaults.skipped;
|
||||
if (hostDefaults.imported > 0) {
|
||||
await recompute({ userIds: [userId] });
|
||||
}
|
||||
|
||||
const targetUser = await userRepository.findById(userId);
|
||||
if (targetUser?.isAdmin) {
|
||||
try {
|
||||
const importedSettings = readImportedSettings(importDb);
|
||||
for (const setting of importedSettings) {
|
||||
try {
|
||||
await upsertImportedSetting(setting);
|
||||
result.summary.settingsImported++;
|
||||
} catch (settingError) {
|
||||
result.summary.errors.push(
|
||||
`Setting import error (${setting.key}): ${settingError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
apiLogger.info("settings table not found in import file, skipping");
|
||||
}
|
||||
} else {
|
||||
apiLogger.info(
|
||||
"Settings import skipped - only admin users can import settings",
|
||||
);
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
|
||||
try {
|
||||
await DatabaseSaveTrigger.forceSave("database_import");
|
||||
} catch (saveError) {
|
||||
apiLogger.error(
|
||||
"Failed to persist imported data to disk",
|
||||
saveError,
|
||||
{
|
||||
operation: "import_force_save_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (importDb) {
|
||||
importDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file", {
|
||||
operation: "file_cleanup_warning",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
message: result.success
|
||||
@@ -1472,17 +1466,6 @@ app.post(
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (req.file?.path && fs.existsSync(req.file.path)) {
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file after error", {
|
||||
operation: "file_cleanup_error",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
apiLogger.error("SQLite import failed", error, {
|
||||
operation: "sqlite_import_api_failed",
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
@@ -1491,6 +1474,17 @@ app.post(
|
||||
error: "Failed to import SQLite data",
|
||||
details: getErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
if (req.file) {
|
||||
try {
|
||||
fs.rmSync(req.file.path, { force: true });
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file", {
|
||||
operation: "file_cleanup_warning",
|
||||
filePath: req.file.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1726,7 +1720,8 @@ app.use(
|
||||
},
|
||||
);
|
||||
|
||||
const HTTP_PORT = 30001;
|
||||
// The desktop app moves its embedded backend off 30001 when that is taken.
|
||||
const HTTP_PORT = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
|
||||
|
||||
async function initializeSecurity() {
|
||||
try {
|
||||
@@ -2012,14 +2007,15 @@ function legacyStatusCheck(row: Record<string, unknown>): {
|
||||
|
||||
/**
|
||||
* A host's plugin protocol logins from an export. The file's credential ids
|
||||
* belong to the server that wrote it, so a login keeps one only when it is
|
||||
* a credential the importing user can use.
|
||||
* belong to the server that wrote it, so a login keeps one only when it maps
|
||||
* to a credential imported alongside it.
|
||||
*/
|
||||
async function importHostProtocolLogins(
|
||||
importDb: Database.Database,
|
||||
host: Record<string, unknown>,
|
||||
hostId: number,
|
||||
userId: string,
|
||||
credentialIds: Map<number, number>,
|
||||
): Promise<void> {
|
||||
let rows: Array<Record<string, unknown>>;
|
||||
try {
|
||||
@@ -2040,7 +2036,7 @@ async function importHostProtocolLogins(
|
||||
}
|
||||
protocolAuth[String(row.protocol)] = {
|
||||
authType: row.auth_type,
|
||||
credentialId: row.credential_id,
|
||||
credentialId: credentialIds.get(row.credential_id as number) ?? null,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
fields,
|
||||
|
||||
@@ -925,6 +925,38 @@ const dropRetiredColumns = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Registration checked for a taken username and then inserted with nothing
|
||||
// enforcing it, so concurrent sign-ups could create two accounts with one
|
||||
// name, and every lookup by username picked between them arbitrarily. Later
|
||||
// duplicates are renamed rather than deleted, so no account is lost.
|
||||
const enforceUniqueUsernames = () => {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
WITH ranked AS MATERIALIZED (
|
||||
SELECT id, username,
|
||||
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
|
||||
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
|
||||
FROM users
|
||||
), candidates AS (
|
||||
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
|
||||
), available AS (
|
||||
SELECT MIN(candidate) AS candidate FROM candidates
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
|
||||
)
|
||||
)
|
||||
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
|
||||
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users (username);
|
||||
`);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to enforce unique usernames", {
|
||||
operation: "schema_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const migrateSchema = () => {
|
||||
repairSnippetsColumns(sqlite);
|
||||
addColumnIfNotExists("user_preferences", "theme", "TEXT");
|
||||
@@ -996,6 +1028,7 @@ const migrateSchema = () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
enforceUniqueUsernames();
|
||||
addColumnIfNotExists("sessions", "external_session_ref", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_data", "name", "TEXT");
|
||||
@@ -1893,6 +1926,23 @@ async function moveRemoteLegacySudoPasswords(remote: {
|
||||
}
|
||||
}
|
||||
|
||||
async function dropMysqlForeignKeys(
|
||||
remote: { execute: (query: SQL) => Promise<unknown> },
|
||||
table: string,
|
||||
column: string,
|
||||
): Promise<void> {
|
||||
const keys = resultRows<{ name: string }>(
|
||||
await remote.execute(
|
||||
sql`SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${table} AND COLUMN_NAME = ${column} AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||
),
|
||||
);
|
||||
for (const key of keys) {
|
||||
await remote.execute(
|
||||
sql`ALTER TABLE ${sql.identifier(table)} DROP FOREIGN KEY ${sql.identifier(key.name)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The columns 2.9.0 left in place without a drizzle migration, so drizzle no
|
||||
* longer knows them. Dropped by hand, only where they still exist.
|
||||
@@ -1928,6 +1978,10 @@ async function dropRemoteRetired(
|
||||
for (const column of columns) {
|
||||
if (!existing.has(`${table}.${column}`)) continue;
|
||||
try {
|
||||
// Postgres drops a column's foreign keys with it; MySQL refuses.
|
||||
if (dialect === "mysql") {
|
||||
await dropMysqlForeignKeys(remote, table, column);
|
||||
}
|
||||
await remote.execute(
|
||||
sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`,
|
||||
);
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
*
|
||||
* Kept on purpose: columns a move only reads once the owner's data key opens
|
||||
* at a password login (users.totp_*, the rdp, vnc and telnet logins and
|
||||
* domain), so a user who never signed in to 2.9 still gets them, and
|
||||
* ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place.
|
||||
* domain), so a user who never signed in to 2.9 still gets them.
|
||||
*
|
||||
* ssh_data.vault_profile_id goes because its foreign key follows the vault
|
||||
* plugin's rename of vault_profiles: once the plugin is uninstalled, SQLite
|
||||
* refuses every write to ssh_data and the other engines refuse the uninstall.
|
||||
*/
|
||||
|
||||
/** Columns drizzle never knew were removed, dropped by hand on every engine. */
|
||||
@@ -52,6 +55,7 @@ export const REMOTE_RETIRED_COLUMNS: Record<string, string[]> = {
|
||||
"telnet_port",
|
||||
"tunnel_connections",
|
||||
"use_warpgate",
|
||||
"vault_profile_id",
|
||||
"vnc_port",
|
||||
"web_ui_config",
|
||||
"wol_broadcast_address",
|
||||
|
||||
@@ -21,13 +21,19 @@ import {
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = mysqlTable("users", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
export const users = mysqlTable(
|
||||
"users",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||
);
|
||||
|
||||
export const settings = mysqlTable("settings", {
|
||||
key: varchar("key", { length: 255 }).primaryKey(),
|
||||
|
||||
@@ -21,13 +21,19 @@ import {
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: boolean("is_admin").notNull().default(false),
|
||||
registeredAt: text("registered_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||
);
|
||||
|
||||
export const settings = pgTable("settings", {
|
||||
key: varchar("key", { length: 255 }).primaryKey(),
|
||||
|
||||
@@ -9,13 +9,19 @@ import {
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
registeredAt: text("registered_at").notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
export const users = sqliteTable(
|
||||
"users",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
isAdmin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
|
||||
registeredAt: text("registered_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [uniqueIndex("idx_users_username").on(table.username)],
|
||||
);
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
|
||||
@@ -103,11 +103,11 @@ export class CredentialRepository {
|
||||
.orderBy(desc(sshCredentials.updatedAt));
|
||||
}
|
||||
|
||||
async existsForImportIdentity(
|
||||
async findIdForImportIdentity(
|
||||
userId: string,
|
||||
name: string,
|
||||
username: string | null,
|
||||
): Promise<boolean> {
|
||||
): Promise<number | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
@@ -120,7 +120,7 @@ export class CredentialRepository {
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0;
|
||||
return rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async findDecryptedByIdForUser(
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { getCurrentRepositorySqlite } from "./factory.js";
|
||||
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
|
||||
|
||||
export interface SqliteForeignKeyClient {
|
||||
exec(sql: string): unknown;
|
||||
}
|
||||
|
||||
export async function withSqliteForeignKeysDisabled<T>(
|
||||
sqlite: SqliteForeignKeyClient,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
sqlite.exec("PRAGMA foreign_keys = OFF");
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
sqlite.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a bulk import with foreign keys relaxed.
|
||||
*
|
||||
* Backup restore writes tables in an order that is not dependency-safe, so the
|
||||
* constraints have to stand down for the duration.
|
||||
*
|
||||
* Postgres and MySQL keep their constraints enabled. The portable importer
|
||||
* writes through repositories and handles individual row failures, so it must
|
||||
* still be allowed to run there; only SQLite needs this connection-local
|
||||
* relaxation for legacy backups whose rows are not dependency ordered.
|
||||
*/
|
||||
export async function withCurrentSqliteForeignKeysDisabled<T>(
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const dialect = resolveDatabaseDialect();
|
||||
if (!needsExplicitPersist(dialect)) {
|
||||
return operation();
|
||||
}
|
||||
|
||||
return withSqliteForeignKeysDisabled(getCurrentRepositorySqlite(), operation);
|
||||
}
|
||||
@@ -24,6 +24,27 @@ const userColumns = {
|
||||
),
|
||||
};
|
||||
|
||||
// The unique-violation codes of better-sqlite3, node-postgres and mysql2.
|
||||
const UNIQUE_VIOLATION_CODES = new Set([
|
||||
"SQLITE_CONSTRAINT_UNIQUE",
|
||||
"23505",
|
||||
"ER_DUP_ENTRY",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether creating a user failed because the username is taken. The id is
|
||||
* generated, so the username index is the only one a new row can collide on.
|
||||
* drizzle wraps driver errors, so the code may sit on a cause.
|
||||
*/
|
||||
export function isUsernameTaken(error: unknown): boolean {
|
||||
for (let e = error; e instanceof Error; e = e.cause) {
|
||||
if (UNIQUE_VIOLATION_CODES.has((e as { code?: string }).code ?? "")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export class UserRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
|
||||
@@ -140,7 +140,14 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
|
||||
* 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[]): void {
|
||||
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 });
|
||||
}
|
||||
@@ -587,6 +594,7 @@ export function registerHostBulkRoutes(
|
||||
|
||||
const knownAuthTypes = listKnownAuthTypes();
|
||||
const writtenHostIds: number[] = [];
|
||||
const createdHostIds: number[] = [];
|
||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
@@ -803,6 +811,7 @@ export function registerHostBulkRoutes(
|
||||
);
|
||||
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
||||
savedHostId = saved.id;
|
||||
createdHostIds.push(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
|
||||
@@ -834,7 +843,7 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||
|
||||
res.json({
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
||||
@@ -952,6 +961,7 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
|
||||
const writtenHostIds: number[] = [];
|
||||
const createdHostIds: number[] = [];
|
||||
for (let i = 0; i < hostsToImport.length; i++) {
|
||||
const hostData = normalizeImportedHost(
|
||||
hostsToImport[i] as Record<string, unknown>,
|
||||
@@ -1032,6 +1042,7 @@ export function registerHostBulkRoutes(
|
||||
sshDataObj,
|
||||
);
|
||||
writtenHostIds.push(saved.id);
|
||||
createdHostIds.push(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1043,7 +1054,7 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||
|
||||
res.json({
|
||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`,
|
||||
|
||||
@@ -88,6 +88,23 @@ export function keepsStoredSshAuth(authType: unknown): boolean {
|
||||
return authType === undefined || authType === null;
|
||||
}
|
||||
|
||||
export function isOptionalAuthType(value: unknown): boolean {
|
||||
return value === undefined || value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth type a new SSH host's secrets point to when the body names none,
|
||||
* as an API client creating a host from a credential does.
|
||||
*/
|
||||
export function impliedSshAuthType(
|
||||
body: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
if (body.credentialId) return "credential";
|
||||
if (body.key) return "key";
|
||||
if (body.password) return "password";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const FOLDER_PATH_SEPARATOR = " / ";
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,8 +31,10 @@ import {
|
||||
import {
|
||||
applyHostKeyTypeUpdate,
|
||||
containsOwnerPrivateAuthUpdate,
|
||||
impliedSshAuthType,
|
||||
keepsStoredSshAuth,
|
||||
isNonEmptyString,
|
||||
isOptionalAuthType,
|
||||
isOptionalBoolean,
|
||||
isValidPort,
|
||||
normalizeProtocolEnableFields,
|
||||
@@ -259,7 +261,9 @@ router.post(
|
||||
!isNonEmptyString(ip) ||
|
||||
!isValidPort(port) ||
|
||||
!isOptionalBoolean(shareSshAuth) ||
|
||||
!isOptionalBoolean(enableSsh)
|
||||
!isOptionalBoolean(enableSsh) ||
|
||||
!isOptionalAuthType(authType) ||
|
||||
!isOptionalAuthType(authMethod)
|
||||
) {
|
||||
sshLogger.warn("Invalid SSH data input validation failed", {
|
||||
operation: "host_create",
|
||||
@@ -292,7 +296,9 @@ router.post(
|
||||
const effectiveAuthType =
|
||||
authType ||
|
||||
authMethod ||
|
||||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
|
||||
(effectiveConnectionType !== "ssh"
|
||||
? "password"
|
||||
: impliedSshAuthType(hostData));
|
||||
const effectiveUsername =
|
||||
username || firstProtocolUsername(protocolAuthPatch);
|
||||
const effectiveName =
|
||||
@@ -403,6 +409,13 @@ router.post(
|
||||
body: hostData,
|
||||
}),
|
||||
);
|
||||
// Host defaults may have supplied it.
|
||||
if (!sshDataObj.authType) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"authType is required (password, key, credential, agent or none)",
|
||||
});
|
||||
}
|
||||
const result = await createCurrentHostRepository().createEncryptedForUser(
|
||||
userId,
|
||||
sshDataObj,
|
||||
@@ -473,6 +486,7 @@ router.post(
|
||||
});
|
||||
|
||||
res.json(await withProtocolAuth(stripSensitiveFields(resolvedHost)));
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId: createdHost.id, userId });
|
||||
notifyStatsHostUpdated(createdHost.id as number, userId, "host_create");
|
||||
} catch (err) {
|
||||
sshLogger.error("Failed to save SSH host to database", err, {
|
||||
@@ -827,6 +841,8 @@ router.put(
|
||||
!isValidPort(port) ||
|
||||
!isOptionalBoolean(shareSshAuth) ||
|
||||
!isOptionalBoolean(enableSsh) ||
|
||||
!isOptionalAuthType(authType) ||
|
||||
!isOptionalAuthType(authMethod) ||
|
||||
!hostId
|
||||
) {
|
||||
sshLogger.warn("Invalid SSH data input validation failed for update", {
|
||||
|
||||
@@ -243,7 +243,9 @@ export async function listPreLoginPlugins(): Promise<
|
||||
* frontends in the same order the server does.
|
||||
*
|
||||
* On a desktop linked to a server, `runsOnServer` marks plugins that
|
||||
* only run on that server and stay off here on purpose.
|
||||
* only run on that server and stay off here on purpose, and, for
|
||||
* admins, `managedByServer` marks plugins whose on/off state the server
|
||||
* decides.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
@@ -370,6 +372,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
features,
|
||||
env,
|
||||
signedBy: loaded?.signedBy ?? null,
|
||||
managedByServer: linked && (await isManagedByLinkedServer(record.id)),
|
||||
capabilities,
|
||||
grantedCapabilities: grantsByPlugin.get(record.id) ?? [],
|
||||
lastError: loaded?.lastError ?? record.lastError ?? null,
|
||||
@@ -393,9 +396,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
|
||||
const { getLink } = await import("../../sync/client/link-store.js");
|
||||
if (!(await getLink())) return false;
|
||||
if (!(await isLinkedDesktop())) return false;
|
||||
const { isServerManaged } = await import("../../sync/client/plugins.js");
|
||||
return isServerManaged(pluginId);
|
||||
}
|
||||
@@ -500,7 +501,7 @@ router.get(
|
||||
* /plugins/onboarding/apply:
|
||||
* post:
|
||||
* summary: Apply the onboarding plugin picker
|
||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would.
|
||||
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
@@ -887,7 +888,7 @@ router.delete(
|
||||
* /plugins/{id}/state:
|
||||
* patch:
|
||||
* summary: Enable or disable a plugin
|
||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would.
|
||||
* description: Persists the new state and starts or stops the plugin immediately. Disabling releases everything the plugin owns, including any port it was listening on. Enabling also starts any installed hard dependency that is off, and disabling also stops the running plugins that depend on it. With dryRun nothing changes and the response lists what would. On a linked desktop, locally managed plugins require version and capabilities matching the reviewed manifest when enabling.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
@@ -909,6 +910,12 @@ router.delete(
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* version:
|
||||
* type: string
|
||||
* capabilities:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin's new state.
|
||||
@@ -962,7 +969,26 @@ router.patch(
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await setPluginState(pluginId, enabled);
|
||||
let result;
|
||||
if (enabled && (await isLinkedDesktop())) {
|
||||
const version = readVersion(req.body);
|
||||
const capabilities = readCapabilities(req.body);
|
||||
if (version === null || capabilities === null) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: "Invalid plugin version or capabilities" });
|
||||
return;
|
||||
}
|
||||
const { enablePluginWithConsent } =
|
||||
await import("../../plugins/manage.js");
|
||||
result = await enablePluginWithConsent(pluginId, {
|
||||
version,
|
||||
capabilities,
|
||||
userId: (req as AuthenticatedRequest).userId as string,
|
||||
});
|
||||
} else {
|
||||
result = await setPluginState(pluginId, enabled);
|
||||
}
|
||||
await auditPluginAction(
|
||||
req,
|
||||
enabled ? "enable_plugin" : "disable_plugin",
|
||||
|
||||
@@ -17,9 +17,10 @@ import {
|
||||
listUserSecondFactors,
|
||||
resetUserSecondFactors,
|
||||
} from "../../auth/second-factor-admin.js";
|
||||
import type {
|
||||
UserRecord,
|
||||
UserRepository,
|
||||
import {
|
||||
isUsernameTaken,
|
||||
type UserRecord,
|
||||
type UserRepository,
|
||||
} from "../repositories/user-repository.js";
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
@@ -541,6 +542,9 @@ export function registerUserAdminRoutes(
|
||||
toast: { type: "success", message: `User created: ${username}` },
|
||||
});
|
||||
} catch (err) {
|
||||
if (isUsernameTaken(err)) {
|
||||
return res.status(409).json({ error: "Username already exists" });
|
||||
}
|
||||
authLogger.error("Failed to admin-create user", err);
|
||||
res.status(500).json({ error: "Failed to create user" });
|
||||
}
|
||||
|
||||
@@ -41,9 +41,13 @@ import {
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
import {
|
||||
isUsernameTaken,
|
||||
type UserRecord,
|
||||
} from "../repositories/user-repository.js";
|
||||
import {
|
||||
getTrustedProxyAuthConfig,
|
||||
getTrustedProxySourceAddress,
|
||||
isTrustedProxyAddress,
|
||||
isTrustedProxyAuthEnabled,
|
||||
resolveTrustedProxyRoles,
|
||||
@@ -264,6 +268,10 @@ router.post("/create", async (req, res) => {
|
||||
toast: { type: "success", message: `User created: ${username}` },
|
||||
});
|
||||
} catch (err) {
|
||||
// Lost a race with a concurrent sign-up for the same name.
|
||||
if (isUsernameTaken(err)) {
|
||||
return res.status(409).json({ error: "Username already exists" });
|
||||
}
|
||||
authLogger.error("Failed to create user", err);
|
||||
res.status(500).json({ error: "Failed to create user" });
|
||||
}
|
||||
@@ -304,7 +312,7 @@ router.post("/proxy-login", async (req, res) => {
|
||||
}
|
||||
if (!config.enabled) return res.json({ enabled: false });
|
||||
|
||||
const sourceAddress = req.socket.remoteAddress;
|
||||
const sourceAddress = getTrustedProxySourceAddress(req);
|
||||
try {
|
||||
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
|
||||
authLogger.warn(
|
||||
|
||||
@@ -263,28 +263,6 @@ export class SSHHostKeyVerifier {
|
||||
changeCount: host.hostKeyChangedCount || 0,
|
||||
});
|
||||
|
||||
if (isJumpHost) {
|
||||
await this.updateHostKey(
|
||||
hostId,
|
||||
fingerprint,
|
||||
keyType,
|
||||
algorithm,
|
||||
host.hostKeyChangedCount || 0,
|
||||
);
|
||||
sshLogger.warn("Jump host key changed - auto-accepted", {
|
||||
operation: "host_key_updated",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
fingerprint,
|
||||
keyType,
|
||||
userId,
|
||||
isJumpHost: true,
|
||||
});
|
||||
verify(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ws) {
|
||||
sshLogger.error(
|
||||
"Host key changed - please connect via Terminal to verify the new key",
|
||||
|
||||
@@ -86,7 +86,6 @@ export class SSHConnectionPool {
|
||||
private async createPooledClient(
|
||||
key: string,
|
||||
factory: () => Promise<Client>,
|
||||
existing: PooledConnection[],
|
||||
): Promise<Client> {
|
||||
const generation = this.generations.get(key) || 0;
|
||||
this.pendingConnections.set(
|
||||
@@ -109,8 +108,11 @@ export class SSHConnectionPool {
|
||||
inUse: true,
|
||||
hostKey: key,
|
||||
};
|
||||
existing.push(pooled);
|
||||
this.connections.set(key, existing);
|
||||
// Re-read after the await: concurrent connects and removals may have
|
||||
// replaced this key's array in the meantime.
|
||||
const connections = this.connections.get(key) ?? [];
|
||||
connections.push(pooled);
|
||||
this.connections.set(key, connections);
|
||||
|
||||
client.on("end", () => {
|
||||
this.removeConnection(key, client);
|
||||
@@ -187,7 +189,7 @@ export class SSHConnectionPool {
|
||||
if (queue.length === 0) this.waiters.delete(key);
|
||||
else this.waiters.set(key, queue);
|
||||
clearTimeout(waiter.timer);
|
||||
this.createPooledClient(key, waiter.factory, connections)
|
||||
this.createPooledClient(key, waiter.factory)
|
||||
.then(waiter.resolve)
|
||||
.catch(waiter.reject);
|
||||
return;
|
||||
@@ -225,7 +227,7 @@ export class SSHConnectionPool {
|
||||
}
|
||||
|
||||
if (this.hasCapacity(key, connections)) {
|
||||
return this.createPooledClient(key, factory, connections);
|
||||
return this.createPooledClient(key, factory);
|
||||
}
|
||||
|
||||
return this.enqueueWaiter(key, factory);
|
||||
|
||||
@@ -1,50 +1,59 @@
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import * as tar from "tar";
|
||||
|
||||
const MAX_UNPACKED_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Reads one file out of a .tmxplug (a gzipped tar) without unpacking it to
|
||||
* disk. Returns null when the archive has no such file.
|
||||
* Reads every copy of one file out of a .tmxplug (a gzipped tar) without
|
||||
* unpacking it to disk. Uses the same tar parser as the loader's unpack, so
|
||||
* pax and long-name headers resolve the same way, and only plain files count
|
||||
* because they are the only kind the unpack writes. Names compare
|
||||
* case-insensitively since a case-insensitive disk folds them together.
|
||||
*/
|
||||
export function readArtifactFile(
|
||||
buffer: Buffer,
|
||||
wanted: string,
|
||||
): Buffer | null {
|
||||
const tar = zlib.gunzipSync(buffer, { maxOutputLength: MAX_UNPACKED_BYTES });
|
||||
let offset = 0;
|
||||
while (offset + 512 <= tar.length) {
|
||||
const header = tar.subarray(offset, offset + 512);
|
||||
if (header.every((byte) => byte === 0)) break;
|
||||
const field = (start: number, length: number) =>
|
||||
header
|
||||
.subarray(start, start + length)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/s, "");
|
||||
const prefix = field(345, 155);
|
||||
const name = (
|
||||
prefix ? `${prefix}/${field(0, 100)}` : field(0, 100)
|
||||
).replace(/^\.\//, "");
|
||||
const size = Number.parseInt(field(124, 12).trim() || "0", 8);
|
||||
if (!Number.isFinite(size) || size < 0) break;
|
||||
const start = offset + 512;
|
||||
if (name === wanted) return tar.subarray(start, start + size);
|
||||
offset = start + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return null;
|
||||
export function readArtifactFiles(buffer: Buffer, wanted: string): Buffer[] {
|
||||
const archive = zlib.gunzipSync(buffer, {
|
||||
maxOutputLength: MAX_UNPACKED_BYTES,
|
||||
});
|
||||
const key = wanted.toLowerCase();
|
||||
const found: Buffer[] = [];
|
||||
const parser = new tar.Parser({
|
||||
strict: true,
|
||||
onReadEntry: (entry) => {
|
||||
const name = path.posix.normalize(entry.path.replace(/\\/g, "/"));
|
||||
if (entry.type !== "File" || name.toLowerCase() !== key) {
|
||||
entry.resume();
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
entry.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
entry.on("end", () => found.push(Buffer.concat(chunks)));
|
||||
},
|
||||
});
|
||||
// The parser runs synchronously on an in-memory buffer.
|
||||
parser.end(archive);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The manifest.json packed in a .tmxplug, parsed but not validated. */
|
||||
/**
|
||||
* The manifest.json packed in a .tmxplug, parsed but not validated. An
|
||||
* archive with more than one is refused: the unpack keeps the last copy, so
|
||||
* whichever one this read the other could carry different capabilities.
|
||||
*/
|
||||
export function readArtifactManifest(buffer: Buffer): Record<string, unknown> {
|
||||
let raw: Buffer | null;
|
||||
let copies: Buffer[];
|
||||
try {
|
||||
raw = readArtifactFile(buffer, "manifest.json");
|
||||
copies = readArtifactFiles(buffer, "manifest.json");
|
||||
} catch {
|
||||
throw new Error("the file is not a valid .tmxplug archive");
|
||||
}
|
||||
if (!raw) throw new Error("the archive has no manifest.json");
|
||||
if (copies.length === 0) throw new Error("the archive has no manifest.json");
|
||||
if (copies.length > 1) {
|
||||
throw new Error("the archive has more than one manifest.json");
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString("utf8"));
|
||||
parsed = JSON.parse(copies[0].toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("manifest.json is not valid JSON");
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { hostStatusService } from "../hosts/status/host-status-service.js";
|
||||
import { parseSshOptions } from "../hosts/ssh-options.js";
|
||||
import { hostTerminalExport } from "../database/routes/host-normalizers.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { pluginEvents, TOPICS } from "./events.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
|
||||
type AuditFn = (
|
||||
@@ -502,6 +503,7 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
await writeSettings(created.id, writes);
|
||||
await markSettingsOwn(created.id, writes);
|
||||
await audit("hosts_create", `host ${created.id}`, { success: true });
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId: created.id, userId });
|
||||
return (
|
||||
await toRecords([created as unknown as Record<string, unknown>])
|
||||
)[0];
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { getActor, getActorSessionId } from "./actor.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
import { redactHostSecrets } from "./ctx-hosts.js";
|
||||
import { issueUserToken } from "./ctx-users.js";
|
||||
import {
|
||||
getSshAuthProvider,
|
||||
listSshAuthProviders,
|
||||
@@ -901,5 +902,7 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentUserAuthRepository().countUsersForProvider(provider);
|
||||
},
|
||||
issueUserToken: (userId, options) =>
|
||||
issueUserToken(manifest, userId, options),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* ctx.users, and ctx.auth.issueUserToken: what a plugin may learn about a
|
||||
* user, and a short-lived session for calling core's own API as one.
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginUserToken,
|
||||
PluginUserTokenOptions,
|
||||
PluginUsers,
|
||||
} from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { getActor } from "./actor.js";
|
||||
|
||||
const DEFAULT_TOKEN_TTL_SECONDS = 300;
|
||||
const MAX_TOKEN_TTL_SECONDS = 900;
|
||||
const MAX_PURPOSE_LENGTH = 200;
|
||||
|
||||
export function createPluginUsers(manifest: PluginManifest): PluginUsers {
|
||||
return {
|
||||
getLocale: async (userId) => {
|
||||
await assertCapability(
|
||||
manifest.id,
|
||||
"users:read",
|
||||
manifest.capabilities,
|
||||
"users_get_locale",
|
||||
);
|
||||
const { createCurrentUserPreferenceRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const preferences =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
return preferences?.language ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function issueUserToken(
|
||||
manifest: PluginManifest,
|
||||
userId: string,
|
||||
options: PluginUserTokenOptions,
|
||||
): Promise<PluginUserToken> {
|
||||
await assertCapability(
|
||||
manifest.id,
|
||||
"users:impersonate",
|
||||
manifest.capabilities,
|
||||
"user_token_issued",
|
||||
);
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
const purpose = String(options?.purpose ?? "")
|
||||
.trim()
|
||||
.slice(0, MAX_PURPOSE_LENGTH);
|
||||
const ttlSeconds = Math.min(
|
||||
options?.ttlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS,
|
||||
MAX_TOKEN_TTL_SECONDS,
|
||||
);
|
||||
const audit = (success: boolean, errorMessage?: string) =>
|
||||
logAudit({
|
||||
userId: getActor() ?? null,
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: "plugin_user_token_issued",
|
||||
resourceType: "user",
|
||||
resourceId: userId,
|
||||
details: JSON.stringify({ pluginId: manifest.id, purpose, ttlSeconds }),
|
||||
success,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!purpose) {
|
||||
throw new Error("ctx.auth.issueUserToken needs a purpose");
|
||||
}
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
||||
throw new Error("ttlSeconds must be a positive whole number");
|
||||
}
|
||||
const { createCurrentUserRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const user = userId
|
||||
? await createCurrentUserRepository().findById(userId)
|
||||
: null;
|
||||
if (!user) throw new Error(`User "${userId}" does not exist`);
|
||||
|
||||
const { AuthManager } = await import("../utils/auth-manager.js");
|
||||
const issued = await AuthManager.getInstance().issuePluginSession(user.id, {
|
||||
pluginId: manifest.id,
|
||||
purpose,
|
||||
ttlSeconds,
|
||||
});
|
||||
await audit(true);
|
||||
return issued;
|
||||
} catch (error) {
|
||||
await audit(false, error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
|
||||
import { createPluginProcess } from "./ctx-process.js";
|
||||
import { createPluginSystem } from "./ctx-system.js";
|
||||
import { createPluginPlugins } from "./ctx-plugins.js";
|
||||
import { createPluginUsers } from "./ctx-users.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
@@ -957,6 +958,7 @@ export function createPluginContext(
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
plugins: createPluginPlugins(manifest),
|
||||
users: createPluginUsers(manifest),
|
||||
|
||||
desktop: {
|
||||
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
|
||||
|
||||
@@ -26,6 +26,8 @@ export const TOPICS = {
|
||||
hostStatus: "host.status",
|
||||
/** A host's key was accepted again after it changed. */
|
||||
hostKeyUpdated: "host.key.updated",
|
||||
/** A host was added and anything inventorying hosts should pick it up. */
|
||||
hostCreated: "host.created",
|
||||
/** A host's connection details changed and pollers should re-read them. */
|
||||
hostUpdated: "host.updated",
|
||||
/** A host was deleted and any poller holding it should drop it. */
|
||||
|
||||
@@ -42,9 +42,16 @@ const routers = new Map<string, Router>();
|
||||
* Requests per minute one address may make to one plugin's public routes.
|
||||
* They run without a login, so they are the cheap way to hammer a plugin.
|
||||
*/
|
||||
function publicRateLimit(): number {
|
||||
const MAX_PLUGIN_PUBLIC_RATE_LIMIT = 10_000;
|
||||
|
||||
/** The admin's limit when set, else the plugin's own, else 120. */
|
||||
function publicRateLimit(pluginLimit?: number): number {
|
||||
const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 120;
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
if (Number.isFinite(pluginLimit) && (pluginLimit as number) > 0) {
|
||||
return Math.min(pluginLimit as number, MAX_PLUGIN_PUBLIC_RATE_LIMIT);
|
||||
}
|
||||
return 120;
|
||||
}
|
||||
const PUBLIC_RATE_WINDOW_MS = 60_000;
|
||||
const publicHits = new Map<string, { count: number; resetAt: number }>();
|
||||
@@ -54,6 +61,7 @@ export function overPublicRateLimit(
|
||||
pluginId: string,
|
||||
ip: string,
|
||||
now = Date.now(),
|
||||
pluginLimit?: number,
|
||||
): boolean {
|
||||
if (publicHits.size > 10_000) {
|
||||
for (const [hitKey, entry] of publicHits) {
|
||||
@@ -67,7 +75,7 @@ export function overPublicRateLimit(
|
||||
return false;
|
||||
}
|
||||
entry.count++;
|
||||
return entry.count > publicRateLimit();
|
||||
return entry.count > publicRateLimit(pluginLimit);
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
@@ -319,7 +327,9 @@ export function createPluginRouter({
|
||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||
if (isPublicRequest(req, publicPaths, inner)) {
|
||||
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
||||
if (overPublicRateLimit(pluginId, ip)) {
|
||||
if (
|
||||
overPublicRateLimit(pluginId, ip, Date.now(), options?.publicRateLimit)
|
||||
) {
|
||||
res.status(429).json({ error: "Too many requests" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -715,6 +715,28 @@ export async function planStateChange(
|
||||
};
|
||||
}
|
||||
|
||||
/** A local admin consents to the exact mirrored version before granting it access. */
|
||||
export function enablePluginWithConsent(
|
||||
pluginId: string,
|
||||
options: { version?: string; capabilities?: string[]; userId: string },
|
||||
): Promise<StateChangePlan & { state: string }> {
|
||||
return serialized(async () => {
|
||||
const plugin = getPluginRuntime().loader.get(pluginId);
|
||||
if (!plugin)
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
if (options.version !== plugin.manifest.version) {
|
||||
throw new PluginManageError(
|
||||
"The plugin version changed; review it again",
|
||||
409,
|
||||
"CONSENT_MISMATCH",
|
||||
);
|
||||
}
|
||||
assertConsent(options.capabilities, plugin.manifest.capabilities);
|
||||
await grantDeclared(plugin, options.userId);
|
||||
return setPluginStateUnlocked(pluginId, true);
|
||||
});
|
||||
}
|
||||
|
||||
export function setPluginState(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
type RegistrySource,
|
||||
} from "./registry-index.js";
|
||||
import {
|
||||
COMMUNITY_PLUGIN_KEYS,
|
||||
TRUSTED_PLUGIN_KEYS,
|
||||
getCustomPluginKeys,
|
||||
isPluginPublicKey,
|
||||
pluginKeyId,
|
||||
setCustomPluginKeys,
|
||||
@@ -28,6 +31,8 @@ import {
|
||||
const CONFIG_KEY = "plugin_registries";
|
||||
/** Keys a linked desktop copied from its server, so mirrored plugins load. */
|
||||
const SYNCED_KEYS_KEY = "plugin_registry_synced_keys";
|
||||
/** `addedIn` of a key only the linked server vouches for. */
|
||||
const SYNCED = "synced";
|
||||
const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const MAX_ID_LENGTH = 40;
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
@@ -117,8 +122,8 @@ async function writeRegistriesConfig(config: RegistriesConfig): Promise<void> {
|
||||
await applyCustomKeys(config);
|
||||
}
|
||||
|
||||
function toKey(publicKey: string): TrustedPluginKey {
|
||||
return { id: pluginKeyId(publicKey), publicKey, addedIn: "admin" };
|
||||
function toKey(publicKey: string, addedIn = "admin"): TrustedPluginKey {
|
||||
return { id: pluginKeyId(publicKey), publicKey, addedIn };
|
||||
}
|
||||
|
||||
async function readSyncedKeys(): Promise<string[]> {
|
||||
@@ -134,19 +139,32 @@ async function readSyncedKeys(): Promise<string[]> {
|
||||
|
||||
/**
|
||||
* Trusts the key of every custom registry, on or off: a plugin installed
|
||||
* from a registry that was later turned off still has to load.
|
||||
* from a registry that was later turned off still has to load. A key added
|
||||
* here wins over the same key synced from a server.
|
||||
*/
|
||||
async function applyCustomKeys(config: RegistriesConfig): Promise<void> {
|
||||
const keys = new Map<string, TrustedPluginKey>();
|
||||
for (const publicKey of await readSyncedKeys()) {
|
||||
keys.set(pluginKeyId(publicKey), toKey(publicKey, SYNCED));
|
||||
}
|
||||
for (const entry of config.custom) {
|
||||
keys.set(entry.keyId, toKey(entry.publicKey));
|
||||
}
|
||||
for (const publicKey of await readSyncedKeys()) {
|
||||
keys.set(pluginKeyId(publicKey), toKey(publicKey));
|
||||
}
|
||||
setCustomPluginKeys([...keys.values()]);
|
||||
}
|
||||
|
||||
/** Whether this app trusts the key independently of its linked server. */
|
||||
export function isLocallyTrustedPluginKey(keyId: string): boolean {
|
||||
return (
|
||||
[...TRUSTED_PLUGIN_KEYS, ...COMMUNITY_PLUGIN_KEYS].some(
|
||||
(key) => key.id === keyId,
|
||||
) ||
|
||||
getCustomPluginKeys().some(
|
||||
(key) => key.id === keyId && key.addedIn !== SYNCED,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Called before the boot scan so plugins from custom registries load. */
|
||||
export async function loadCustomRegistryKeys(): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -351,7 +351,10 @@ export class SyncEngine {
|
||||
const key = recordKey(entity.type, row.syncId);
|
||||
const record = records.get(key);
|
||||
if (record && !record.deleted && record.hash === row.hash) continue;
|
||||
if (record?.errorHash === row.hash) continue;
|
||||
// Unavailable server rows can recover without another local edit.
|
||||
if (record?.errorHash === row.hash && record.error !== "unavailable") {
|
||||
continue;
|
||||
}
|
||||
ops.push({
|
||||
entityType: entity.type,
|
||||
syncId: row.syncId,
|
||||
@@ -367,7 +370,12 @@ export class SyncEngine {
|
||||
if (record.revision === 0) continue;
|
||||
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
|
||||
if (entity.covers && !entity.covers(record.syncId)) continue;
|
||||
if (record.errorHash === DELETED_HASH) continue;
|
||||
if (
|
||||
record.errorHash === DELETED_HASH &&
|
||||
record.error !== "unavailable"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ops.push({
|
||||
entityType: entity.type,
|
||||
syncId: record.syncId,
|
||||
@@ -433,6 +441,9 @@ export class SyncEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
// A live row the server could not send says nothing; keep ours to retry.
|
||||
if (!result.deleted && !result.row) return;
|
||||
|
||||
// Conflict: the server moved on since this device last saw the row. Its
|
||||
// version wins here, and a local edit it replaced is kept for the user.
|
||||
if (op.op === "upsert" && op.row) {
|
||||
@@ -445,7 +456,7 @@ export class SyncEngine {
|
||||
);
|
||||
}
|
||||
const resolvers = createResolvers(userId);
|
||||
if (result.deleted || !result.row) {
|
||||
if (result.deleted) {
|
||||
await deleteStoredRow(entity, userId, op.syncId);
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
@@ -488,17 +499,21 @@ export class SyncEngine {
|
||||
);
|
||||
all.push(...result.changes);
|
||||
next = result.nextCursor;
|
||||
this.serverTypes = new Set(result.entityTypes.map((entry) => entry.type));
|
||||
this.state.serverTypes = result.entityTypes.map((entry) => ({
|
||||
type: entry.type,
|
||||
owner: entry.owner,
|
||||
readOnly: entry.readOnly,
|
||||
}));
|
||||
this.learnTypes(result);
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
return { changes: all, cursor: next };
|
||||
}
|
||||
|
||||
private learnTypes(page: ChangesPage): void {
|
||||
this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type));
|
||||
this.state.serverTypes = page.entityTypes.map((entry) => ({
|
||||
type: entry.type,
|
||||
owner: entry.owner,
|
||||
readOnly: entry.readOnly,
|
||||
}));
|
||||
}
|
||||
|
||||
private async pull(link: SyncLink): Promise<void> {
|
||||
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
|
||||
const known = new Set(link.knownTypes);
|
||||
@@ -517,7 +532,14 @@ export class SyncEngine {
|
||||
/** Pulls every row of types this device has not pulled before. */
|
||||
private async backfill(link: SyncLink): Promise<void> {
|
||||
if (!this.serverTypes) {
|
||||
await this.fetchChanges(link, Number.MAX_SAFE_INTEGER);
|
||||
// Only the entity types are wanted, so a one-row page is enough.
|
||||
this.learnTypes(
|
||||
await remoteJson<ChangesPage>(
|
||||
link,
|
||||
`/sync/v2/changes?cursor=${link.cursor}&limit=1`,
|
||||
{ timeoutMs: 120_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
const fresh = listEntities()
|
||||
.map((entity) => entity.type)
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* For every plugin in "mirror" mode, the server decides: a plugin installed
|
||||
* there is installed here, on or off to match, with the capabilities an
|
||||
* admin granted it there. Plugins marked "local" are left to the desktop and
|
||||
* "server" ones never run on a linked desktop.
|
||||
* "server" ones never run on a linked desktop. A user plugin no key trusted
|
||||
* here signed is only ever switched off; the desktop's admin turns it on.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
@@ -21,7 +22,10 @@ import {
|
||||
unloadPlugin,
|
||||
} from "../../plugins/index.js";
|
||||
import { getPluginsDir } from "../../plugins/paths.js";
|
||||
import { setSyncedRegistryKeys } from "../../plugins/registries.js";
|
||||
import {
|
||||
isLocallyTrustedPluginKey,
|
||||
setSyncedRegistryKeys,
|
||||
} from "../../plugins/registries.js";
|
||||
import type { LoadedPlugin } from "../../plugins/loader.js";
|
||||
import {
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
@@ -57,6 +61,14 @@ async function isEnabledLocally(pluginId: string): Promise<boolean> {
|
||||
return record?.state === "enabled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed by a key this desktop trusts on its own. A key the server sent
|
||||
* would let the server's admin sign anything, so it does not count.
|
||||
*/
|
||||
function vouchedHere(local: LoadedPlugin): boolean {
|
||||
return !!local.signedBy && isLocallyTrustedPluginKey(local.signedBy);
|
||||
}
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
/** Downloads next to the installed copy, which stays until the swap. */
|
||||
@@ -112,6 +124,10 @@ async function replaceWithDownload(
|
||||
if (previous) await unloadPlugin(pluginId);
|
||||
try {
|
||||
const installed = await installPluginArtifact(file);
|
||||
// Approval belongs to the reviewed version, regardless of remote metadata.
|
||||
if (wasEnabled && installed.source === "user" && !vouchedHere(installed)) {
|
||||
await setPluginEnabled(pluginId, false);
|
||||
}
|
||||
await fs.promises.rm(backup, { force: true });
|
||||
await fs.promises.rm(`${backup}.sig`, { force: true });
|
||||
return installed;
|
||||
@@ -155,7 +171,7 @@ async function matchGrants(plugin: RemotePlugin): Promise<void> {
|
||||
if (!local || local.source !== "user") return;
|
||||
// Grants are only copied for code a trusted key signed. Anything else the
|
||||
// desktop's own admin has to review, as with any plugin dropped on disk.
|
||||
if (!local.signedBy) return;
|
||||
if (!vouchedHere(local)) return;
|
||||
const declared = new Set(local.manifest.capabilities);
|
||||
const wanted = new Set(plugin.granted.filter((cap) => declared.has(cap)));
|
||||
const existing = await repository.listByPlugin(plugin.id);
|
||||
@@ -196,7 +212,7 @@ async function alertUnsigned(plugin: RemotePlugin): Promise<void> {
|
||||
alertedVersions.add(key);
|
||||
await sendCoreAlert({
|
||||
title: "Review a feature from your server",
|
||||
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed, so it stays off here until you turn it on.`,
|
||||
body: `${plugin.name} (${plugin.version}) was copied from your server but is not signed by a key this app trusts, so it stays off here until you turn it on.`,
|
||||
severity: "warning",
|
||||
category: "termix.sync.unsigned_plugin",
|
||||
dedupeKey: `sync:${key}`,
|
||||
@@ -239,7 +255,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
await alertOlderDesktop(plugin);
|
||||
throw error;
|
||||
}
|
||||
if (wasEnabled) await setPluginEnabled(plugin.id, true);
|
||||
if (wasEnabled && local.source === "bundled") {
|
||||
await setPluginEnabled(plugin.id, true);
|
||||
}
|
||||
} else if (newer) {
|
||||
await alertOlderDesktop(plugin);
|
||||
}
|
||||
@@ -263,19 +281,19 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
}
|
||||
}
|
||||
await matchGrants(plugin);
|
||||
// Unsigned code is only ever switched off from here, never on.
|
||||
if (!local.signedBy) {
|
||||
const enabledHere = await isEnabledLocally(plugin.id);
|
||||
if (!plugin.enabled && enabledHere) {
|
||||
await setPluginEnabled(plugin.id, false);
|
||||
} else if (plugin.enabled && !enabledHere) {
|
||||
await alertUnsigned(plugin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!local) return;
|
||||
// Remote metadata cannot confer bundled trust on a locally loaded user plugin.
|
||||
if (local.source === "user" && !vouchedHere(local)) {
|
||||
const enabledHere = await isEnabledLocally(plugin.id);
|
||||
if (!plugin.enabled && enabledHere) {
|
||||
await setPluginEnabled(plugin.id, false);
|
||||
} else if (plugin.enabled && !enabledHere) {
|
||||
await alertUnsigned(plugin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (plugin.enabled !== (await isEnabledLocally(plugin.id))) {
|
||||
await setPluginEnabled(plugin.id, plugin.enabled);
|
||||
} else if (plugin.enabled && local.state !== "active") {
|
||||
@@ -284,6 +302,7 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
}
|
||||
|
||||
export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||
const { serialized } = await import("../../plugins/manage.js");
|
||||
const { plugins, registryKeys } = await remoteJson<{
|
||||
plugins: RemotePlugin[];
|
||||
registryKeys?: unknown[];
|
||||
@@ -295,7 +314,8 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
await mirrorOne(link, plugin);
|
||||
// Share the consent/enable lock so an update cannot swap code mid-approval.
|
||||
await serialized(() => mirrorOne(link, plugin));
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not match a feature to the server", {
|
||||
operation: "sync_plugin_mirror",
|
||||
@@ -315,8 +335,13 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a plugin's on/off state belongs to the linked server. */
|
||||
/**
|
||||
* Whether a plugin's on/off state belongs to the linked server. Code no key
|
||||
* trusted here vouches for is the desktop's own admin's call to turn on.
|
||||
*/
|
||||
export function isServerManaged(pluginId: string): boolean {
|
||||
const local = getPluginRuntime().loader.get(pluginId);
|
||||
return (local?.manifest.desktop ?? "mirror") !== "local";
|
||||
const mode = local?.manifest.desktop ?? "mirror";
|
||||
if (mode !== "mirror") return mode === "server";
|
||||
return local?.source !== "user" || vouchedHere(local);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* an entity in without renumbering; anything referenced syncs first.
|
||||
*/
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, getTableColumns } from "drizzle-orm";
|
||||
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import { singletonSyncId } from "./wire.js";
|
||||
import { parseDefaultOverrides } from "../../types/host-defaults.js";
|
||||
import {
|
||||
@@ -174,7 +175,7 @@ export function registerCoreSyncEntities(): void {
|
||||
)
|
||||
: {},
|
||||
}),
|
||||
afterWrite: async ({ id, userId, wire, resolveId }) => {
|
||||
afterWrite: async ({ id, userId, wire, created, resolveId }) => {
|
||||
if (id === null) return;
|
||||
// The server owns host defaults: it keeps what the host set itself
|
||||
// and resolves the rest. A desktop takes the server's values as sent.
|
||||
@@ -195,6 +196,9 @@ export function registerCoreSyncEntities(): void {
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
await SharedHostSecretsManager.getInstance().resyncHost(id);
|
||||
if (created) {
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId: id, userId });
|
||||
}
|
||||
},
|
||||
remove: async (row, userId) => {
|
||||
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
||||
@@ -493,6 +497,25 @@ const SHARED_HOST_DROP = [
|
||||
...HOST_LOCAL_FIELDS,
|
||||
];
|
||||
|
||||
const HOST_TEXT_COLUMNS = new Set(
|
||||
Object.entries(getTableColumns(hosts))
|
||||
.filter(([, column]) => column.dataType === "string")
|
||||
.map(([field]) => field),
|
||||
);
|
||||
|
||||
/**
|
||||
* A resolved host field back in the form its column stores, as an owned host
|
||||
* sends it: the resolver parses JSON columns and turns the "true"/"false" of
|
||||
* forceKeyboardInteractive into a boolean, which a text column cannot bind.
|
||||
*/
|
||||
function storedForm(field: string, value: unknown): unknown {
|
||||
if (value !== null && typeof value === "object") return JSON.stringify(value);
|
||||
if (typeof value === "boolean" && HOST_TEXT_COLUMNS.has(field)) {
|
||||
return String(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
||||
const {
|
||||
createCurrentRbacAccessRepository,
|
||||
@@ -540,10 +563,7 @@ async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
||||
const wire: SyncRow = {};
|
||||
for (const [field, value] of Object.entries(host)) {
|
||||
if (SHARED_HOST_DROP.includes(field)) continue;
|
||||
wire[field] =
|
||||
value !== null && typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: value;
|
||||
wire[field] = storedForm(field, value);
|
||||
}
|
||||
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
|
||||
wire.pluginSettings = await exportHostPluginSettings(hostId, {
|
||||
|
||||
@@ -140,6 +140,9 @@ export async function listRecordErrors(userId: string): Promise<SyncRecord[]> {
|
||||
return rows.filter((row) => !!row.error);
|
||||
}
|
||||
|
||||
/** The largest seq the column holds: a 32-bit integer on Postgres and MySQL. */
|
||||
const MAX_SEQ = 2_147_483_647;
|
||||
|
||||
export async function listChanges(
|
||||
userId: string,
|
||||
cursor: number,
|
||||
@@ -148,7 +151,8 @@ export async function listChanges(
|
||||
): Promise<SyncRecord[]> {
|
||||
const conditions = [
|
||||
eq(syncRecords.userId, userId),
|
||||
gt(syncRecords.seq, cursor),
|
||||
// Postgres rejects a bound value the column cannot hold.
|
||||
gt(syncRecords.seq, Math.min(cursor, MAX_SEQ)),
|
||||
];
|
||||
if (types && types.length > 0) {
|
||||
conditions.push(inArray(syncRecords.entityType, types));
|
||||
|
||||
@@ -134,12 +134,23 @@ export async function applyPush(
|
||||
const record = await getRecord(userId, op.entityType, op.syncId);
|
||||
const currentRevision = record?.revision ?? 0;
|
||||
if (op.baseRevision !== currentRevision) {
|
||||
const row = record?.deleted ? null : (wire.get(key) ?? null);
|
||||
// A live record with no row failed to serialize; a conflict without a
|
||||
// row would read as a delete on the desktop.
|
||||
if (record && !record.deleted && !row) {
|
||||
results.set(key, {
|
||||
...base,
|
||||
status: "rejected",
|
||||
reason: "unavailable",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
results.set(key, {
|
||||
...base,
|
||||
status: "conflict",
|
||||
revision: currentRevision,
|
||||
deleted: !!record?.deleted,
|
||||
row: record?.deleted ? null : (wire.get(key) ?? null),
|
||||
row,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,17 @@ function userOf(req: Request): string {
|
||||
return (req as AuthenticatedRequest).userId;
|
||||
}
|
||||
|
||||
/** A non-negative integer query value, the fallback when absent, null when malformed. */
|
||||
function queryInt(value: unknown, fallback: number): number | null {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
return typeof value === "string" &&
|
||||
/^\d+$/.test(value) &&
|
||||
Number.isSafeInteger(parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/info:
|
||||
@@ -224,6 +235,8 @@ router.post(
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A page of changes, the next cursor and whether more remain.
|
||||
* 400:
|
||||
* description: The cursor or limit is not a non-negative integer.
|
||||
* 500:
|
||||
* description: Failed to read changes.
|
||||
*/
|
||||
@@ -232,11 +245,12 @@ router.get(
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const cursor = Math.max(0, Number(req.query.cursor) || 0);
|
||||
const limit = Math.min(
|
||||
MAX_PAGE,
|
||||
Math.max(1, Number(req.query.limit) || MAX_PAGE),
|
||||
);
|
||||
const cursor = queryInt(req.query.cursor, 0);
|
||||
const pageSize = queryInt(req.query.limit, MAX_PAGE);
|
||||
if (cursor === null || pageSize === null) {
|
||||
return res.status(400).json({ error: "Invalid cursor or limit" });
|
||||
}
|
||||
const limit = Math.min(MAX_PAGE, Math.max(1, pageSize));
|
||||
const types =
|
||||
typeof req.query.types === "string" && req.query.types
|
||||
? req.query.types.split(",").filter(Boolean)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { SystemCrypto } from "../utils/system-crypto.js";
|
||||
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
import {
|
||||
getEntity,
|
||||
type RegisteredSyncEntity,
|
||||
@@ -229,7 +230,13 @@ export async function loadWireRows(
|
||||
);
|
||||
const wire = toWire(entity, serialized, syncId);
|
||||
rows.set(syncId, { syncId, wire, hash: hashWire(entity, wire, key) });
|
||||
} catch {
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not serialize a sync row", {
|
||||
operation: "sync_serialize",
|
||||
entityType: entity.type,
|
||||
syncId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
failed.add(syncId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Registration checked for a taken username before inserting, with nothing in
|
||||
* the schema to back it up, so two concurrent sign-ups could both get the same
|
||||
* name. Startup renames the later duplicates instead of deleting anyone, then
|
||||
* makes the name unique.
|
||||
*/
|
||||
describe("users.username uniqueness", () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-unique-user-"));
|
||||
vi.resetModules();
|
||||
process.env.DATA_DIR = dataDir;
|
||||
process.env.DB_FILE_ENCRYPTION = "false";
|
||||
process.env.ALLOW_EMPTY_DATA_DIR = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_FILE_ENCRYPTION;
|
||||
delete process.env.ALLOW_EMPTY_DATA_DIR;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function writeDatabaseWithDuplicates(): void {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO users (id, username, password_hash, registered_at) VALUES
|
||||
('late', 'alice', 'hash', '2025-03-01 00:00:00'),
|
||||
('first', 'alice', 'hash', '2025-01-01 00:00:00'),
|
||||
('tie-b', 'bob', 'hash', '2025-01-01 00:00:00'),
|
||||
('tie-a', 'bob', 'hash', '2025-01-01 00:00:00'),
|
||||
('only', 'Alice', 'hash', '2025-02-01 00:00:00');
|
||||
`);
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
}
|
||||
|
||||
async function bootAndGetSqlite(): Promise<Database.Database> {
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
return db.getSqlite();
|
||||
}
|
||||
|
||||
it("keeps the oldest holder of a name and renames the rest", async () => {
|
||||
writeDatabaseWithDuplicates();
|
||||
|
||||
const sqlite = await bootAndGetSqlite();
|
||||
|
||||
const names = Object.fromEntries(
|
||||
sqlite
|
||||
.prepare("SELECT id, username FROM users")
|
||||
.raw()
|
||||
.all() as [string, string][],
|
||||
);
|
||||
expect(names).toEqual({
|
||||
first: "alice",
|
||||
late: "termix-recovered-0-5",
|
||||
"tie-a": "bob",
|
||||
"tie-b": "termix-recovered-0-3",
|
||||
only: "Alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a second user with a taken name", async () => {
|
||||
const sqlite = await bootAndGetSqlite();
|
||||
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)",
|
||||
);
|
||||
insert.run("u1", "alice", "hash");
|
||||
|
||||
expect(() => insert.run("u2", "alice", "hash")).toThrow(/UNIQUE/i);
|
||||
});
|
||||
});
|
||||
|
||||
// Run the checked-in migration itself, including occupied recovery prefixes.
|
||||
it("migrates duplicate names without colliding with existing names", () => {
|
||||
const db = new Database(":memory:");
|
||||
try {
|
||||
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL, registered_at TEXT NOT NULL);
|
||||
INSERT INTO users VALUES
|
||||
('first', 'alice', '2025-01-01'),
|
||||
('late', 'alice', '2025-02-01'),
|
||||
('other', 'alice-late', '2025-01-01'),
|
||||
('occupied', 'termix-recovered-0-1', '2025-01-01');`);
|
||||
db.exec(fs.readFileSync("drizzle/sqlite/0067_unique_username.sql", "utf8"));
|
||||
const rows = db.prepare("SELECT username FROM users").pluck().all();
|
||||
expect(new Set(rows).size).toBe(4);
|
||||
expect(rows).toContain("alice");
|
||||
expect(rows).toContain("alice-late");
|
||||
expect(rows).toContain("termix-recovered-0-1");
|
||||
expect(() => db.exec("INSERT INTO users VALUES ('new', 'alice', '2026-01-01')")).toThrow(/UNIQUE/i);
|
||||
} finally { db.close(); }
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ssh_data.vault_profile_id points at vault_profiles. The vault plugin renames
|
||||
// that table to p_vault_profiles, which SQLite follows into ssh_data's foreign
|
||||
// key, so uninstalling the plugin used to leave every host write failing with
|
||||
// "no such table: main.p_vault_profiles".
|
||||
describe("ssh_data and the vault plugin's tables", () => {
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-vault-fk-"));
|
||||
vi.resetModules();
|
||||
vi.stubEnv("DATA_DIR", dataDir);
|
||||
vi.stubEnv("DB_FILE_ENCRYPTION", "false");
|
||||
vi.stubEnv("ALLOW_EMPTY_DATA_DIR", "true");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function seed(afterAdoption: string): void {
|
||||
const seed = new Database(":memory:");
|
||||
seed.exec(
|
||||
fs.readFileSync(
|
||||
new URL("../../fixtures/sqlite-2.8-schema.sql", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
seed.exec(`
|
||||
CREATE TABLE plugin_migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plugin_id TEXT NOT NULL,
|
||||
migration_id TEXT NOT NULL,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (plugin_id, migration_id),
|
||||
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('owner', 'alice', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type)
|
||||
VALUES (1, 'owner', '192.0.2.1', 22, 'root', 'password');
|
||||
ALTER TABLE vault_profiles RENAME TO p_vault_profiles;
|
||||
ALTER TABLE vault_tokens RENAME TO p_vault_tokens;
|
||||
${afterAdoption}
|
||||
`);
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
|
||||
seed.close();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const db = await import("../../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
return db.getSqlite();
|
||||
}
|
||||
|
||||
function writeHosts(sqlite: Database.Database): void {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO ssh_data (user_id, ip, port, username, auth_type) VALUES ('owner', '192.0.2.2', 22, 'root', 'password')",
|
||||
)
|
||||
.run();
|
||||
sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run();
|
||||
}
|
||||
|
||||
it("repairs a database the plugin was already uninstalled from", async () => {
|
||||
seed("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;");
|
||||
const sqlite = await boot();
|
||||
|
||||
expect(() => writeHosts(sqlite)).not.toThrow();
|
||||
});
|
||||
|
||||
it("leaves nothing in ssh_data pointing at the plugin", async () => {
|
||||
seed("");
|
||||
const sqlite = await boot();
|
||||
|
||||
expect(
|
||||
sqlite.prepare("PRAGMA foreign_key_list(ssh_data)").all(),
|
||||
).not.toContainEqual(expect.objectContaining({ table: "p_vault_profiles" }));
|
||||
sqlite.exec("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;");
|
||||
expect(() => writeHosts(sqlite)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as fs from "node:fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import Database from "better-sqlite3";
|
||||
import ts from "typescript";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
const source = readFileSync(
|
||||
new URL("../../database/database.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
"database.ts",
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const route = ast.statements.find(
|
||||
(s) =>
|
||||
ts.isExpressionStatement(s) &&
|
||||
ts.isCallExpression(s.expression) &&
|
||||
s.expression.arguments[0]?.getText(ast) === '"/database/import"',
|
||||
) as ts.ExpressionStatement;
|
||||
const batched = (route.expression as ts.CallExpression)
|
||||
.arguments[3] as ts.CallExpression;
|
||||
const callback = batched.arguments[0];
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "termix-import-route-"));
|
||||
|
||||
function exportFile(): string {
|
||||
const path = join(dir, `export-${Math.random()}.db`);
|
||||
const db = new Database(path);
|
||||
db.exec(`
|
||||
CREATE TABLE ssh_credentials (id INTEGER, name TEXT, username TEXT);
|
||||
CREATE TABLE ssh_data (id INTEGER, ip TEXT, port INTEGER, username TEXT, credential_id INTEGER);
|
||||
INSERT INTO ssh_credentials VALUES (7, 'fresh', 'root'), (8, 'kept', 'admin');
|
||||
INSERT INTO ssh_data VALUES
|
||||
(1, '10.0.0.1', 22, 'root', 7),
|
||||
(2, '10.0.0.2', 22, 'root', 8),
|
||||
(3, '10.0.0.3', 22, 'root', 99),
|
||||
(4, '10.0.0.4', 22, 'root', NULL);
|
||||
`);
|
||||
db.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
async function invoke(path: string, user: unknown = { isExternal: false }) {
|
||||
const writes: string[] = [];
|
||||
const events: Array<[string, unknown]> = [];
|
||||
const hosts: Array<Record<string, unknown>> = [];
|
||||
const importHostProtocolLogins = vi.fn();
|
||||
const handler = runInNewContext(
|
||||
ts.transpileModule(`(${callback.getText(ast)})`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText,
|
||||
{
|
||||
fs,
|
||||
Buffer,
|
||||
Database,
|
||||
parseUserAgent: () => ({ type: "web" }),
|
||||
createCurrentUserRepository: () => ({ findById: async () => user }),
|
||||
DataCrypto: { getUserDataKey: () => Buffer.from("key") },
|
||||
apiLogger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
createCurrentCredentialRepository: () => ({
|
||||
findIdForImportIdentity: async (_: string, name: string) =>
|
||||
name === "kept" ? 42 : null,
|
||||
createEncryptedForUser: async () => {
|
||||
writes.push("credential");
|
||||
return { id: 100 };
|
||||
},
|
||||
}),
|
||||
createCurrentHostRepository: () => ({
|
||||
existsForImportIdentity: async () => false,
|
||||
createEncryptedForUser: async (_: string, host: never) => {
|
||||
writes.push("host");
|
||||
hosts.push(host);
|
||||
return { id: 500 + hosts.length };
|
||||
},
|
||||
}),
|
||||
legacyStatusCheck: () => ({}),
|
||||
sshOptionsForWrite: () => null,
|
||||
applyHostDefaultsToWrite: async () => ({}),
|
||||
applyPluginHostImportSettings: async () => {},
|
||||
importedHostPluginSettings: () => ({}),
|
||||
importHostProtocolLogins,
|
||||
applyDefaultsAfterHostWrites: async () => {},
|
||||
importUserPluginRows: async () => ({
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: [],
|
||||
}),
|
||||
importHostDefaults: async () => ({ imported: 0, skipped: 0 }),
|
||||
DatabaseSaveTrigger: { forceSave: async () => {} },
|
||||
getErrorMessage: (error: Error) => error.message,
|
||||
pluginEvents: {
|
||||
emit: (topic: string, payload: unknown) =>
|
||||
events.push([topic, payload]),
|
||||
},
|
||||
TOPICS: { hostCreated: "host.created" },
|
||||
},
|
||||
);
|
||||
const res = { json: vi.fn(), status: vi.fn() };
|
||||
res.status.mockReturnValue(res);
|
||||
await handler(
|
||||
{ userId: "user-1", file: { path, originalname: "export.db" } },
|
||||
res,
|
||||
);
|
||||
return { res, writes, hosts, events, importHostProtocolLogins };
|
||||
}
|
||||
|
||||
it("points imported hosts at the credentials imported with them", async () => {
|
||||
const { res, writes, hosts, importHostProtocolLogins } =
|
||||
await invoke(exportFile());
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(writes).toEqual(["credential", "host", "host", "host", "host"]);
|
||||
expect(hosts.map((host) => host.credentialId)).toEqual([100, 42, null, null]);
|
||||
expect([...importHostProtocolLogins.mock.calls[0][4]]).toEqual([
|
||||
[7, 100],
|
||||
[8, 42],
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes host.created once for each imported host", async () => {
|
||||
const { events } = await invoke(exportFile());
|
||||
|
||||
expect(events).toEqual(
|
||||
[501, 502, 503, 504].map((hostId) => [
|
||||
"host.created",
|
||||
{ hostId, userId: "user-1" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes the uploaded file when the user is gone", async () => {
|
||||
const path = exportFile();
|
||||
const { res } = await invoke(path, null);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the uploaded file when it is not a SQLite database", async () => {
|
||||
const path = join(dir, "not-sqlite.db");
|
||||
writeFileSync(path, "definitely not a database");
|
||||
const { res } = await invoke(path);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(existsSync(path)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes the uploaded file after a successful import", async () => {
|
||||
const path = exportFile();
|
||||
await invoke(path);
|
||||
|
||||
expect(existsSync(path)).toBe(false);
|
||||
});
|
||||
@@ -213,7 +213,7 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
it("checks credential import identity", async () => {
|
||||
const repo = await createRepositories();
|
||||
|
||||
await repo.credentials.create({
|
||||
const created = await repo.credentials.create({
|
||||
userId: "user-1",
|
||||
name: "primary",
|
||||
authType: "password",
|
||||
@@ -221,11 +221,11 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
repo.credentials.existsForImportIdentity("user-1", "primary", "root"),
|
||||
).resolves.toBe(true);
|
||||
repo.credentials.findIdForImportIdentity("user-1", "primary", "root"),
|
||||
).resolves.toBe(created.id);
|
||||
await expect(
|
||||
repo.credentials.existsForImportIdentity("user-1", "primary", "admin"),
|
||||
).resolves.toBe(false);
|
||||
repo.credentials.findIdForImportIdentity("user-1", "primary", "admin"),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("renames credential folders through the write boundary", async () => {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const factory = vi.hoisted(() => ({
|
||||
getCurrentRepositorySqlite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => factory);
|
||||
|
||||
import {
|
||||
withCurrentSqliteForeignKeysDisabled,
|
||||
withSqliteForeignKeysDisabled,
|
||||
} from "../../../database/repositories/sqlite-foreign-keys.js";
|
||||
|
||||
const previousDatabaseDialect = process.env.DATABASE_DIALECT;
|
||||
|
||||
afterEach(() => {
|
||||
if (previousDatabaseDialect === undefined)
|
||||
delete process.env.DATABASE_DIALECT;
|
||||
else process.env.DATABASE_DIALECT = previousDatabaseDialect;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("withSqliteForeignKeysDisabled", () => {
|
||||
it("restores foreign keys after an import", async () => {
|
||||
const sqlite = { exec: vi.fn() };
|
||||
|
||||
await expect(
|
||||
withSqliteForeignKeysDisabled(sqlite, async () => "imported"),
|
||||
).resolves.toBe("imported");
|
||||
expect(sqlite.exec.mock.calls).toEqual([
|
||||
["PRAGMA foreign_keys = OFF"],
|
||||
["PRAGMA foreign_keys = ON"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withCurrentSqliteForeignKeysDisabled", () => {
|
||||
it.each(["postgres", "mysql"])(
|
||||
"runs portable imports with constraints enabled on %s",
|
||||
async (dialect) => {
|
||||
process.env.DATABASE_DIALECT = dialect;
|
||||
const operation = vi.fn().mockResolvedValue("imported");
|
||||
|
||||
await expect(
|
||||
withCurrentSqliteForeignKeysDisabled(operation),
|
||||
).resolves.toBe("imported");
|
||||
expect(operation).toHaveBeenCalledOnce();
|
||||
expect(factory.getCurrentRepositorySqlite).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
context: null as DatabaseContext | null,
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentRepositoryContext: () => state.context,
|
||||
}));
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { triggerSave: vi.fn() },
|
||||
}));
|
||||
|
||||
const { listChanges } = await import("../../../sync/records.js");
|
||||
|
||||
// Runs against Postgres and MySQL too in the database-dialects CI job, where
|
||||
// sync_records.seq is a 32-bit integer.
|
||||
describe("listChanges", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
state.context = null;
|
||||
});
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
state.context = await adapter.connect();
|
||||
await adapter.run(
|
||||
sql`INSERT INTO users (id, username, password_hash) VALUES ('user-1', 'user-1', '')`,
|
||||
);
|
||||
await adapter.run(
|
||||
sql`INSERT INTO sync_records (user_id, entity_type, sync_id, revision, seq) VALUES ('user-1', 'hosts', 'a', 1, 1), ('user-1', 'hosts', 'b', 1, 2)`,
|
||||
);
|
||||
}
|
||||
|
||||
it("lists records after the cursor in seq order", async () => {
|
||||
await seed();
|
||||
const records = await listChanges("user-1", 1, 10);
|
||||
expect(records.map((record) => record.syncId)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("answers no records for a cursor past what seq can hold", async () => {
|
||||
await seed();
|
||||
expect(await listChanges("user-1", Number.MAX_SAFE_INTEGER, 10)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { expect, it } from "vitest";
|
||||
import { TestSqliteDatabase, testDialect } from "./test-support.js";
|
||||
|
||||
it("migrates duplicate usernames without colliding with occupied recovery names", async () => {
|
||||
const adapter = new TestSqliteDatabase();
|
||||
await adapter.connect();
|
||||
try {
|
||||
await adapter.exec(`CREATE TABLE username_migration_fixture (
|
||||
id varchar(255) PRIMARY KEY, username TEXT NOT NULL, registered_at varchar(255) NOT NULL
|
||||
);
|
||||
INSERT INTO username_migration_fixture VALUES
|
||||
('first', 'alice', '2025-01-01'),
|
||||
('late', 'alice', '2025-02-01'),
|
||||
('other', 'alice-late', '2025-01-01'),
|
||||
('occupied', 'termix-recovered-0-1', '2025-01-01');`);
|
||||
const dialect = testDialect();
|
||||
const number = dialect === "sqlite" ? "0067" : "0072";
|
||||
const migration = fs
|
||||
.readFileSync(`drizzle/${dialect}/${number}_unique_username.sql`, "utf8")
|
||||
.replace(/\busers\b/g, "username_migration_fixture")
|
||||
.replaceAll("idx_users_username", "idx_fixture_username");
|
||||
for (const statement of migration.split("--> statement-breakpoint")) {
|
||||
if (statement.trim()) await adapter.run(sql.raw(statement));
|
||||
}
|
||||
const rows = await adapter.query<{ username: string }>(
|
||||
sql`SELECT username FROM username_migration_fixture`,
|
||||
);
|
||||
const names = rows.map((row) => row.username);
|
||||
expect(new Set(names).size).toBe(4);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(["alice", "alice-late", "termix-recovered-0-1"]),
|
||||
);
|
||||
await expect(async () => {
|
||||
await adapter.run(
|
||||
sql`INSERT INTO username_migration_fixture VALUES ('new', 'alice', '2026-01-01')`,
|
||||
);
|
||||
}).rejects.toThrow();
|
||||
} finally {
|
||||
await adapter.exec("DROP TABLE IF EXISTS username_migration_fixture");
|
||||
await adapter.close();
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { UserRepository } from "../../../database/repositories/user-repository.js";
|
||||
import {
|
||||
UserRepository,
|
||||
isUsernameTaken,
|
||||
} from "../../../database/repositories/user-repository.js";
|
||||
|
||||
describe("UserRepository.listPage", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
@@ -93,3 +96,27 @@ describe("UserRepository.listPage", () => {
|
||||
expect(page.total).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserRepository.create", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a taken username in a way callers can recognise", async () => {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const repo = new UserRepository(await adapter.connect());
|
||||
await repo.create({ id: "u1", username: "alice", passwordHash: "hash" });
|
||||
|
||||
const error = await repo
|
||||
.create({ id: "u2", username: "alice", passwordHash: "hash" })
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(isUsernameTaken(error)).toBe(true);
|
||||
expect(isUsernameTaken(new Error("unrelated"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,7 @@ describe("notifyImportedHosts", () => {
|
||||
seen.push(payload);
|
||||
});
|
||||
try {
|
||||
notifyImportedHosts("user-1", [4, 7]);
|
||||
notifyImportedHosts("user-1", [4, 7], []);
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* The create and update host routes against a real SQLite database. A body
|
||||
* that leaves out authType must never reach the NOT NULL auth_type column.
|
||||
*/
|
||||
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from "express";
|
||||
import request from "supertest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DatabaseContext } from "../../../database/repositories/database-context.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
ctx: null as null | { drizzle: unknown },
|
||||
}));
|
||||
|
||||
const USER = "user-1";
|
||||
const pass = (_req: Request, _res: Response, next: NextFunction) => next();
|
||||
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => state.ctx!.drizzle,
|
||||
getSqlite: () => null,
|
||||
}));
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: {
|
||||
triggerSave: vi.fn(),
|
||||
forceSave: vi.fn(async () => {}),
|
||||
batched: <T>(work: T) => work,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({
|
||||
getEncryptionKey: async () => Buffer.alloc(32, 7),
|
||||
getJWTSecret: async () => "secret",
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: {
|
||||
getUserDataKey: () => Buffer.alloc(32, 3),
|
||||
validateUserAccess: () => Buffer.alloc(32, 3),
|
||||
encryptRecord: (_table: string, record: unknown) => record,
|
||||
decryptRecord: (_table: string, record: unknown) => record,
|
||||
decryptRecords: (_table: string, records: unknown) => records,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: Request, _res: Response, next: NextFunction) => {
|
||||
(req as Request & { userId: string }).userId = USER;
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware: () => pass,
|
||||
createAdminMiddleware: () => pass,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
requirePermission: () => pass,
|
||||
canAccessHost: async () => ({ hasAccess: true, isOwner: true }),
|
||||
hasPermission: async () => true,
|
||||
isAdmin: async () => true,
|
||||
getUserPermissions: async () => [],
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async () => {},
|
||||
getAuditUsername: async () => USER,
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
|
||||
const { TestSqliteDatabase } = await import("../repositories/test-support.js");
|
||||
const { hosts, sshCredentials } =
|
||||
await import("../../../database/db/schema.js");
|
||||
const { saveLevel } = await import("../../../hosts/defaults/service.js");
|
||||
const { default: hostRouter } =
|
||||
await import("../../../database/routes/host.js");
|
||||
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/host", hostRouter);
|
||||
|
||||
let db: InstanceType<typeof TestSqliteDatabase>;
|
||||
let ctx: DatabaseContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new TestSqliteDatabase("sqlite");
|
||||
ctx = await db.connect();
|
||||
state.ctx = ctx;
|
||||
await ctx.drizzle.run(
|
||||
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES (${USER}, ${USER}, '', 1)`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
async function addCredential(): Promise<number> {
|
||||
const [row] = await ctx.drizzle
|
||||
.insert(sshCredentials)
|
||||
.values({
|
||||
userId: USER,
|
||||
name: "ops",
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "secret",
|
||||
})
|
||||
.returning({ id: sshCredentials.id });
|
||||
return row.id;
|
||||
}
|
||||
|
||||
async function storedAuthType(id: number) {
|
||||
const [row] = await ctx.drizzle
|
||||
.select({ authType: hosts.authType })
|
||||
.from(hosts)
|
||||
.where(eq(hosts.id, id));
|
||||
return row?.authType;
|
||||
}
|
||||
|
||||
const base = { ip: "10.0.0.5", port: 22, username: "root" };
|
||||
|
||||
describe("host create without authType", () => {
|
||||
it("infers credential auth from a credentialId", async () => {
|
||||
const credentialId = await addCredential();
|
||||
const res = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, credentialId });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedAuthType(res.body.id)).toBe("credential");
|
||||
});
|
||||
|
||||
it("infers password auth from a password", async () => {
|
||||
const res = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, password: "pw" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedAuthType(res.body.id)).toBe("password");
|
||||
});
|
||||
|
||||
it("rejects a body that says nothing about authentication", async () => {
|
||||
const res = await request(app).post("/host/db/host").send(base);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/authType is required/);
|
||||
});
|
||||
|
||||
it("takes the auth type from the user's host defaults", async () => {
|
||||
const credentialId = await addCredential();
|
||||
await saveLevel(
|
||||
{ level: "user", userId: USER },
|
||||
{
|
||||
set: new Map([["core.auth", { authType: "credential", credentialId }]]),
|
||||
unset: new Set(),
|
||||
errors: {},
|
||||
},
|
||||
USER,
|
||||
);
|
||||
const res = await request(app).post("/host/db/host").send(base);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedAuthType(res.body.id)).toBe("credential");
|
||||
});
|
||||
});
|
||||
|
||||
describe("host update authType", () => {
|
||||
it("keeps the stored auth type when the update leaves it out", async () => {
|
||||
const created = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, authType: "password", password: "pw" });
|
||||
const res = await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, name: "renamed" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(await storedAuthType(created.body.id)).toBe("password");
|
||||
});
|
||||
|
||||
it("rejects an authType that is not a string", async () => {
|
||||
const created = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, authType: "password", password: "pw" });
|
||||
const res = await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, authType: { bad: true } });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host.created", () => {
|
||||
it("is published once when a host is created, and not on update", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
try {
|
||||
const created = await request(app)
|
||||
.post("/host/db/host")
|
||||
.send({ ...base, authType: "password", password: "pw" });
|
||||
expect(created.status).toBe(200);
|
||||
await request(app)
|
||||
.put(`/host/db/host/${created.body.id}`)
|
||||
.send({ ...base, name: "renamed" });
|
||||
expect(seen).toEqual([{ hostId: created.body.id, userId: USER }]);
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,8 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
}),
|
||||
}));
|
||||
import { registerHostBulkRoutes } from "../../../database/routes/host-bulk-routes.js";
|
||||
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload.js";
|
||||
import { buildExportPayload } from "../../../../ui/sidebar/host-export-payload";
|
||||
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
|
||||
|
||||
let handler: RequestHandler;
|
||||
let sshConfigHandler: RequestHandler;
|
||||
@@ -259,3 +260,53 @@ it("does not save dependent hosts when importing their jump host fails", async (
|
||||
);
|
||||
expect(mocks.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
function collectCreated() {
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
return { seen, off };
|
||||
}
|
||||
|
||||
it("publishes host.created once for each host a JSON import creates", async () => {
|
||||
mocks.list.mockResolvedValue([{ ...host, id: 19 }]);
|
||||
mocks.create.mockResolvedValueOnce({ id: 501 });
|
||||
const { seen, off } = collectCreated();
|
||||
try {
|
||||
await importHosts([host, { ...host, ip: "192.0.2.2" }], true);
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
// The overwritten host already existed, so only the new one is announced.
|
||||
expect(seen).toEqual([{ hostId: 501, userId: "user-1" }]);
|
||||
});
|
||||
|
||||
it("publishes host.created once for each host an SSH config import creates", async () => {
|
||||
mocks.create
|
||||
.mockResolvedValueOnce({ id: 601 })
|
||||
.mockResolvedValueOnce({ id: 602 });
|
||||
const { seen, off } = collectCreated();
|
||||
try {
|
||||
await sshConfigHandler(
|
||||
{
|
||||
userId: "user-1",
|
||||
body: {
|
||||
content:
|
||||
"Host a\n HostName 10.0.0.1\n User root\nHost b\n HostName 10.0.0.2\n User root\n",
|
||||
},
|
||||
} as unknown as Request,
|
||||
{
|
||||
json: vi.fn(),
|
||||
status: vi.fn().mockReturnThis(),
|
||||
} as unknown as Response,
|
||||
vi.fn(),
|
||||
);
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
expect(seen).toEqual([
|
||||
{ hostId: 601, userId: "user-1" },
|
||||
{ hostId: 602, userId: "user-1" },
|
||||
]);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { setHostProtocolSource } from "../../../hosts/protocol-auth/registry.js"
|
||||
import {
|
||||
applyHostKeyTypeUpdate,
|
||||
containsOwnerPrivateAuthUpdate,
|
||||
impliedSshAuthType,
|
||||
isNonEmptyString,
|
||||
keepsStoredSshAuth,
|
||||
isOptionalBoolean,
|
||||
@@ -515,3 +516,16 @@ describe("keepsStoredSshAuth", () => {
|
||||
expect(keepsStoredSshAuth("none")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("impliedSshAuthType", () => {
|
||||
it("reads the auth type from the secret a create body carries", () => {
|
||||
expect(impliedSshAuthType({ credentialId: 2, password: "pw" })).toBe(
|
||||
"credential",
|
||||
);
|
||||
expect(impliedSshAuthType({ key: "-----BEGIN", password: "pw" })).toBe(
|
||||
"key",
|
||||
);
|
||||
expect(impliedSshAuthType({ password: "pw" })).toBe("password");
|
||||
expect(impliedSshAuthType({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -176,6 +176,7 @@ const manageMock = vi.hoisted(() => ({
|
||||
deletePluginData: vi.fn(),
|
||||
planStateChange: vi.fn(),
|
||||
setPluginState: vi.fn(),
|
||||
enablePluginWithConsent: vi.fn(),
|
||||
getDeveloperMode: vi.fn(),
|
||||
setDeveloperMode: vi.fn(),
|
||||
stageUpload: vi.fn(),
|
||||
@@ -215,6 +216,12 @@ const onboardingMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
|
||||
|
||||
const syncPluginsMock = vi.hoisted(() => ({
|
||||
isServerManaged: vi.fn((_id: string) => true),
|
||||
}));
|
||||
|
||||
vi.mock("../../../sync/client/plugins.js", () => syncPluginsMock);
|
||||
|
||||
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
|
||||
.default;
|
||||
|
||||
@@ -1139,6 +1146,65 @@ describe("plugins route", () => {
|
||||
expect(manageMock.installPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("on a linked desktop", () => {
|
||||
const switchOn = (id: string) =>
|
||||
fetch(`${baseUrl}/plugins/${id}/state`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own"],
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
state.plugins.set("sample-plugin", makePlugin({ state: "disabled" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
syncPluginsMock.isServerManaged.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
it("refuses to switch a plugin the server decides", async () => {
|
||||
const res = await switchOn("sample-plugin");
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).code).toBe("MANAGED_BY_SERVER");
|
||||
expect(manageMock.setPluginState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets the desktop's admin switch one the server does not", async () => {
|
||||
syncPluginsMock.isServerManaged.mockImplementation(() => false);
|
||||
manageMock.enablePluginWithConsent.mockResolvedValue({
|
||||
enable: [],
|
||||
disable: [],
|
||||
});
|
||||
const res = await switchOn("sample-plugin");
|
||||
expect(res.status).toBe(200);
|
||||
expect(manageMock.enablePluginWithConsent).toHaveBeenCalledWith(
|
||||
"sample-plugin",
|
||||
expect.objectContaining({
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("says per plugin whether the server decides it", async () => {
|
||||
state.plugins.set("other", makePlugin({ id: "other" }));
|
||||
syncPluginsMock.isServerManaged.mockImplementation(
|
||||
(id) => id === "other",
|
||||
);
|
||||
const listed = (await (
|
||||
await fetch(`${baseUrl}/plugins`)
|
||||
).json()) as Array<{ id: string; managedByServer: boolean }>;
|
||||
expect(
|
||||
Object.fromEntries(listed.map((p) => [p.id, p.managedByServer])),
|
||||
).toEqual({ "sample-plugin": false, other: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("previews a state change without making it", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
manageMock.planStateChange.mockResolvedValue({
|
||||
|
||||
@@ -145,6 +145,35 @@ describe("client tunnel runtime authentication", () => {
|
||||
expect(sockets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the relay's reason when it closes after an error", async () => {
|
||||
const { context, runtimes, sockets } = setup();
|
||||
const runtime = { status: { connected: true } };
|
||||
runtimes.set("web", runtime);
|
||||
const test = context.testC2SRelay(
|
||||
{ name: "web" },
|
||||
"127.0.0.1",
|
||||
8443,
|
||||
null,
|
||||
runtime,
|
||||
);
|
||||
await vi.waitFor(() => expect(sockets).toHaveLength(1));
|
||||
sockets[0].emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: "Target 127.0.0.1:8443 refused",
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
sockets[0].close();
|
||||
expect(await test).toMatchObject({ success: false });
|
||||
expect(runtimes.get("web").status.reason).toBe(
|
||||
"Target 127.0.0.1:8443 refused",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the tunnel connected when one of its connections fails", async () => {
|
||||
const { context, runtimes, sockets } = setup();
|
||||
runtimes.set("web", { status: { connected: true, status: "CONNECTED" } });
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import vm from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const linkedServer = require("../../../../electron/linked-server.cjs") as {
|
||||
setLinkedServer: (config: unknown) => void;
|
||||
};
|
||||
|
||||
const source = fs.readFileSync(
|
||||
new URL("../../../../electron/main.cjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const tls = source.slice(
|
||||
source.indexOf("const INSECURE_MODE_VALUES"),
|
||||
source.indexOf("function httpFetch("),
|
||||
);
|
||||
|
||||
const ORIGIN = "https://termix.example";
|
||||
const TERMINAL = "wss://termix.example/plugin-ws/ssh-terminal/terminal";
|
||||
|
||||
function load(link: Record<string, unknown> | null) {
|
||||
linkedServer.setLinkedServer(link);
|
||||
const context = vm.createContext({
|
||||
URL,
|
||||
process: { env: {} },
|
||||
linkedServer,
|
||||
});
|
||||
vm.runInContext(tls, context);
|
||||
return context as unknown as {
|
||||
isInvalidCertificateAllowedForUrl: (url: string) => boolean;
|
||||
getWebSocketOptions: (
|
||||
url: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("linked server certificate exception", () => {
|
||||
it("covers WebSockets to the linked server", () => {
|
||||
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
|
||||
expect(main.isInvalidCertificateAllowedForUrl(TERMINAL)).toBe(true);
|
||||
expect(main.getWebSocketOptions(TERMINAL, { headers: {} })).toEqual({
|
||||
headers: {},
|
||||
rejectUnauthorized: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ws://termix.example/relay",
|
||||
"wss://termix.example:8443/relay",
|
||||
"wss://elsewhere.example/relay",
|
||||
])("keeps verifying %s", (url) => {
|
||||
const main = load({ origin: ORIGIN, allowInvalidCertificate: true });
|
||||
expect(main.isInvalidCertificateAllowedForUrl(url)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps verifying when the link does not allow it", () => {
|
||||
const main = load({ origin: ORIGIN, allowInvalidCertificate: false });
|
||||
expect(main.getWebSocketOptions(TERMINAL)).toEqual({
|
||||
rejectUnauthorized: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,48 @@ describe("linked server in Electron main", () => {
|
||||
).toEqual({ Accept: "x" });
|
||||
});
|
||||
|
||||
it("treats WebSocket upgrades to the linked server as the linked origin", () => {
|
||||
linked.setLinkedServer({ origin: ORIGIN });
|
||||
expect(linked.isLinkedOrigin(`${ORIGIN}/host/status`)).toBe(true);
|
||||
expect(
|
||||
linked.isLinkedOrigin("wss://termix.example/plugin-ws/ssh-terminal"),
|
||||
).toBe(true);
|
||||
expect(linked.isLinkedOrigin("wss://termix.example:443/")).toBe(true);
|
||||
expect(linked.isLinkedOrigin("ws://termix.example/")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("wss://termix.example:8443/")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("wss://other.example/")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("not a url")).toBe(false);
|
||||
|
||||
linked.setLinkedServer({ origin: "http://termix.example:8080" });
|
||||
expect(linked.isLinkedOrigin("ws://termix.example:8080/ws")).toBe(true);
|
||||
expect(linked.isLinkedOrigin("wss://termix.example:8080/ws")).toBe(false);
|
||||
expect(linked.isLinkedOrigin("ws://termix.example/ws")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds proxy headers and basic auth to the terminal WebSocket", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
headers: [{ name: "CF-Access-Client-Id", value: "id" }],
|
||||
basicAuth: { username: "gate", password: "pw" },
|
||||
});
|
||||
expect(
|
||||
linked.applyLinkHeaders(
|
||||
"wss://termix.example/plugin-ws/ssh-terminal/terminal",
|
||||
{ Upgrade: "websocket" },
|
||||
),
|
||||
).toEqual({
|
||||
Upgrade: "websocket",
|
||||
"CF-Access-Client-Id": "id",
|
||||
Authorization: `Basic ${Buffer.from("gate:pw").toString("base64")}`,
|
||||
});
|
||||
expect(
|
||||
linked.answerLogin({}, "wss://termix.example/plugin-ws/ssh-terminal"),
|
||||
).toEqual({ username: "gate", password: "pw" });
|
||||
expect(
|
||||
linked.applyLinkHeaders("wss://other.example/", { Upgrade: "websocket" }),
|
||||
).toEqual({ Upgrade: "websocket" });
|
||||
});
|
||||
|
||||
it("moves a Termix bearer into the jwt cookie when basic auth is in front", () => {
|
||||
linked.setLinkedServer({
|
||||
origin: ORIGIN,
|
||||
|
||||
@@ -80,4 +80,20 @@ describe("repairSpawnHelpers", () => {
|
||||
expect(chmods[0][1] & 0o111).not.toBe(0);
|
||||
expect(chmods[0][0]).toContain("app.asar.unpacked");
|
||||
});
|
||||
|
||||
it.each(["app-arm64.asar", "app-x64.asar"])(
|
||||
"repairs the helper in the unpacked copy of a universal build's %s",
|
||||
(asar) => {
|
||||
const chmods: string[] = [];
|
||||
const fsImpl = {
|
||||
statSync: () => ({ mode: 0o644 }),
|
||||
chmodSync: (file: string) => {
|
||||
chmods.push(file.split(require("node:path").sep).join("/"));
|
||||
},
|
||||
};
|
||||
const dir = `/Applications/Termix.app/Contents/Resources/${asar}/node_modules/node-pty`;
|
||||
repairSpawnHelpers(dir, fsImpl);
|
||||
expect(chmods[0]).toContain(`/${asar}.unpacked/node_modules/node-pty/`);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -11,7 +11,48 @@ import { tmpdir } from "node:os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chmodSpawnHelpers } = require("../../../../scripts/patch-node-pty.cjs");
|
||||
const {
|
||||
chmodSpawnHelpers,
|
||||
patchUnixTerminalHelperRewrite,
|
||||
} = require("../../../../scripts/patch-node-pty.cjs");
|
||||
|
||||
const upstreamRewrite = [
|
||||
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
].join("\n");
|
||||
|
||||
const previouslyPatchedRewrite = [
|
||||
"if (!helperPath.includes('app.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
|
||||
"}",
|
||||
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
|
||||
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
|
||||
"}",
|
||||
].join("\n");
|
||||
|
||||
const resources = "/Applications/Termix.app/Contents/Resources";
|
||||
const helperPaths = [
|
||||
["app.asar", "app.asar.unpacked"],
|
||||
["app-arm64.asar", "app-arm64.asar.unpacked"],
|
||||
["app-x64.asar", "app-x64.asar.unpacked"],
|
||||
["app.asar.unpacked", "app.asar.unpacked"],
|
||||
["app-arm64.asar.unpacked", "app-arm64.asar.unpacked"],
|
||||
].map(([from, to]) => [
|
||||
`${resources}/${from}/node_modules/node-pty/build/Release/spawn-helper`,
|
||||
`${resources}/${to}/node_modules/node-pty/build/Release/spawn-helper`,
|
||||
]);
|
||||
|
||||
function writeUnixTerminal(rewrite: string) {
|
||||
const file = join(
|
||||
tmpdir(),
|
||||
`termix-unix-terminal-${Date.now()}-${Math.random()}.cjs`,
|
||||
);
|
||||
writeFileSync(
|
||||
file,
|
||||
`module.exports = function (helperPath) {\n${rewrite}\nreturn helperPath;\n};\n`,
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
// chmod's execute bit is a POSIX concept; Windows/NTFS has no equivalent mode bit.
|
||||
const itPosix = process.platform === "win32" ? it.skip : it;
|
||||
@@ -30,4 +71,22 @@ describe("patch-node-pty", () => {
|
||||
expect(chmodSpawnHelpers(root)).toBe(0);
|
||||
expect(existsSync(helper)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["upstream", upstreamRewrite],
|
||||
["previously patched", previouslyPatchedRewrite],
|
||||
])(
|
||||
"points the %s helper path at the unpacked copy of any app asar",
|
||||
(_, rewrite) => {
|
||||
const file = writeUnixTerminal(rewrite);
|
||||
|
||||
expect(patchUnixTerminalHelperRewrite(file)).toBe(true);
|
||||
expect(patchUnixTerminalHelperRewrite(file)).toBe(false);
|
||||
|
||||
const rewriteHelperPath = require(file);
|
||||
for (const [from, to] of helperPaths) {
|
||||
expect(rewriteHelperPath(from)).toBe(to);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -106,6 +106,31 @@ describe("applyHostDefaultsToWrite", () => {
|
||||
expect(overrides.core).toEqual(["statusCheckEnabled", "username"]);
|
||||
});
|
||||
|
||||
it("keeps a changed auth type on an update without a list", async () => {
|
||||
userDefault("auth", { authType: "credential", credentialId: 3 });
|
||||
const stored = {
|
||||
id: 5,
|
||||
userId: "u1",
|
||||
authType: "credential",
|
||||
credentialId: 3,
|
||||
defaultOverrides: JSON.stringify({ core: ["username"] }),
|
||||
};
|
||||
const columns: Record<string, unknown> = {
|
||||
authType: "vault",
|
||||
credentialId: null,
|
||||
overrideCredentialUsername: 0,
|
||||
};
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
hostId: 5,
|
||||
columns,
|
||||
body: { authType: "vault", credentialId: 0 },
|
||||
stored: stored as never,
|
||||
});
|
||||
expect(columns.authType).toBe("vault");
|
||||
expect(overrides.core).toContain("auth");
|
||||
});
|
||||
|
||||
it("keeps a locked key's state for a shared editor", async () => {
|
||||
const overrides = await applyHostDefaultsToWrite({
|
||||
ownerId: "u1",
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { WebSocket } from "ws";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
updateHostKey: vi.fn(async () => {}),
|
||||
storeHostKey: vi.fn(async () => {}),
|
||||
touchHostKeyLastVerified: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: () => ({}),
|
||||
createCurrentHostResolutionRepository: () => repo,
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
@@ -57,3 +63,50 @@ describe("quick connect host keys", () => {
|
||||
expect(await verifyUnsaved(null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const pinned = {
|
||||
hostKeyFingerprint: Buffer.from("old-key").toString("hex"),
|
||||
hostKeyType: "ssh-ed25519",
|
||||
hostKeyAlgorithm: "sha256",
|
||||
hostKeyChangedCount: 0,
|
||||
name: "bastion",
|
||||
};
|
||||
|
||||
async function verifyJumpHop(socket: WebSocket | null, key: string) {
|
||||
const verifier = await SSHHostKeyVerifier.createHostVerifier(
|
||||
7,
|
||||
"10.0.0.1",
|
||||
22,
|
||||
socket,
|
||||
"user",
|
||||
true,
|
||||
pinned,
|
||||
);
|
||||
return new Promise<boolean>((resolve) => verifier(Buffer.from(key), resolve));
|
||||
}
|
||||
|
||||
describe("jump host key changes", () => {
|
||||
beforeEach(() => {
|
||||
repo.updateHostKey.mockClear();
|
||||
});
|
||||
|
||||
it("refuses a changed key and keeps the pin when nobody can be asked", async () => {
|
||||
expect(await verifyJumpHop(null, "new-key")).toBe(false);
|
||||
expect(repo.updateHostKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks the user and only replaces the pin when they accept", async () => {
|
||||
const no = socketAnswering("reject");
|
||||
expect(await verifyJumpHop(no.socket, "new-key")).toBe(false);
|
||||
expect(no.sent[0]).toContain("host_key_changed");
|
||||
expect(repo.updateHostKey).not.toHaveBeenCalled();
|
||||
|
||||
const yes = socketAnswering("accept");
|
||||
expect(await verifyJumpHop(yes.socket, "new-key")).toBe(true);
|
||||
expect(repo.updateHostKey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still accepts the pinned key without asking", async () => {
|
||||
expect(await verifyJumpHop(null, "old-key")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,52 @@ describe("SSHConnectionPool", () => {
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("tracks every client from concurrent connects to a new host", async () => {
|
||||
const pool = new SSHConnectionPool();
|
||||
const clients = Array.from({ length: 3 }, deferredClient);
|
||||
let factoryCalls = 0;
|
||||
const factory = vi.fn(() => clients[factoryCalls++].promise);
|
||||
|
||||
const requests = Array.from({ length: 3 }, () =>
|
||||
pool.getConnection("new-host", factory),
|
||||
);
|
||||
for (const pending of clients) pending.resolve(pending.client);
|
||||
const connected = await Promise.all(requests);
|
||||
|
||||
for (const client of connected) pool.releaseConnection("new-host", client);
|
||||
const reused = await Promise.all(
|
||||
Array.from({ length: 3 }, () => pool.getConnection("new-host", factory)),
|
||||
);
|
||||
expect(new Set(reused)).toEqual(new Set(connected));
|
||||
expect(factory).toHaveBeenCalledTimes(3);
|
||||
|
||||
pool.clearKeyConnections("new-host");
|
||||
for (const pending of clients) {
|
||||
expect(pending.client.end).toHaveBeenCalledOnce();
|
||||
}
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("does not resurrect a connection removed while another connects", async () => {
|
||||
const pool = new SSHConnectionPool();
|
||||
const first = deferredClient();
|
||||
const second = deferredClient();
|
||||
first.resolve(first.client);
|
||||
await pool.getConnection("host", () => first.promise);
|
||||
|
||||
const request = pool.getConnection("host", () => second.promise);
|
||||
first.client.emit("close");
|
||||
second.resolve(second.client);
|
||||
await expect(request).resolves.toBe(second.client);
|
||||
|
||||
pool.releaseConnection("host", first.client);
|
||||
pool.releaseConnection("host", second.client);
|
||||
await expect(
|
||||
pool.getConnection("host", () => deferredClient().promise),
|
||||
).resolves.toBe(second.client);
|
||||
pool.destroy();
|
||||
});
|
||||
|
||||
it("discards a pending connection when its host pool is cleared", async () => {
|
||||
const pool = new SSHConnectionPool();
|
||||
const pending = deferredClient();
|
||||
|
||||
@@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
import * as tar from "tar";
|
||||
import {
|
||||
readArtifactFile,
|
||||
readArtifactFiles,
|
||||
readArtifactManifest,
|
||||
sameCapabilities,
|
||||
} from "../../plugins/artifact-manifest.js";
|
||||
@@ -27,20 +28,42 @@ function pack(files: Record<string, string>, prefix = ""): Buffer {
|
||||
}
|
||||
}
|
||||
|
||||
describe("readArtifactFile", () => {
|
||||
/** Two archives' entries back to back, so a name can appear twice. */
|
||||
function concat(first: Buffer, second: Buffer): Buffer {
|
||||
const entries = (buffer: Buffer) => {
|
||||
const raw = zlib.gunzipSync(buffer);
|
||||
// Drop the two zero blocks that end every tar.
|
||||
return raw.subarray(0, raw.length - 1024);
|
||||
};
|
||||
return zlib.gzipSync(
|
||||
Buffer.concat([entries(first), entries(second), Buffer.alloc(1024)]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("readArtifactFiles", () => {
|
||||
it("finds a file by name, with or without a ./ prefix", () => {
|
||||
const plain = pack({ "manifest.json": "{}", "dist/a.js": "x" });
|
||||
expect(readArtifactFile(plain, "dist/a.js")?.toString()).toBe("x");
|
||||
expect(
|
||||
readArtifactFiles(plain, "dist/a.js").map((b) => b.toString()),
|
||||
).toEqual(["x"]);
|
||||
const dotted = pack({ "manifest.json": '{"id":"a"}' }, "./");
|
||||
expect(readArtifactFile(dotted, "manifest.json")?.toString()).toBe(
|
||||
'{"id":"a"}',
|
||||
expect(
|
||||
readArtifactFiles(dotted, "manifest.json").map((b) => b.toString()),
|
||||
).toEqual(['{"id":"a"}']);
|
||||
});
|
||||
|
||||
it("returns nothing for a missing file", () => {
|
||||
expect(readArtifactFiles(pack({ "a.txt": "a" }), "manifest.json")).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for a missing file", () => {
|
||||
expect(readArtifactFile(pack({ "a.txt": "a" }), "manifest.json")).toBe(
|
||||
null,
|
||||
);
|
||||
it("resolves names that need a pax header", () => {
|
||||
const long = `dist/${"x".repeat(150)}.js`;
|
||||
const archive = pack({ "manifest.json": "{}", [long]: "x" });
|
||||
expect(readArtifactFiles(archive, long).map((b) => b.toString())).toEqual([
|
||||
"x",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +91,18 @@ describe("readArtifactManifest", () => {
|
||||
readArtifactManifest(pack({ "manifest.json": "[1]" })),
|
||||
).toThrow(/not an object/);
|
||||
});
|
||||
|
||||
it("refuses an archive with two manifests", () => {
|
||||
const first = pack({ "manifest.json": '{"capabilities":[]}' });
|
||||
const second = pack({ "manifest.json": '{"capabilities":["ssh"]}' });
|
||||
expect(() => readArtifactManifest(concat(first, second))).toThrow(
|
||||
/more than one manifest.json/,
|
||||
);
|
||||
const upper = pack({ "MANIFEST.JSON": '{"capabilities":["ssh"]}' });
|
||||
expect(() => readArtifactManifest(concat(first, upper))).toThrow(
|
||||
/more than one manifest.json/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sameCapabilities", () => {
|
||||
|
||||
@@ -207,6 +207,7 @@ vi.mock("../../hosts/delete-host.js", () => ({
|
||||
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
|
||||
function manifest(capabilities: string[]) {
|
||||
return {
|
||||
@@ -436,6 +437,29 @@ describe("ctx.hosts", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("publishes host.created for a host it creates", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:write"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
try {
|
||||
await hosts.create({
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
});
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
expect(seen).toEqual([{ hostId: 99, userId: "user-1" }]);
|
||||
});
|
||||
|
||||
it("refuses a field the SDK does not take instead of dropping it", async () => {
|
||||
h.granted = new Set(["hosts:write"]);
|
||||
const hosts = createPluginHosts({
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* ctx.auth.issueUserToken mints a short-lived session core's own auth
|
||||
* middleware accepts as the named user, under users:impersonate, audited and
|
||||
* kept off account security routes. ctx.users.getLocale reads the user's UI
|
||||
* language under users:read.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
grants: [] as string[],
|
||||
users: new Map<string, { id: string; username: string; isAdmin: boolean }>(),
|
||||
sessions: new Map<string, Record<string, unknown>>(),
|
||||
languages: new Map<string, string | null>(),
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
db: {},
|
||||
getDb: () => ({}),
|
||||
saveMemoryDatabaseToFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async (pluginId: string) =>
|
||||
state.grants.map((capability) => ({ pluginId, capability })),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentSessionRepository: () => ({
|
||||
create: async (session: Record<string, unknown>) => {
|
||||
state.sessions.set(session.id as string, session);
|
||||
return session;
|
||||
},
|
||||
findById: async (id: string) => state.sessions.get(id) ?? null,
|
||||
touch: async () => {},
|
||||
revoke: async (id: string) => state.sessions.delete(id),
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => state.users.get(id) ?? null,
|
||||
}),
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async (userId: string) =>
|
||||
state.languages.has(userId)
|
||||
? { userId, language: state.languages.get(userId) }
|
||||
: null,
|
||||
}),
|
||||
createCurrentApiKeyRepository: () => ({}),
|
||||
createCurrentTrustedDeviceRepository: () => ({}),
|
||||
getCurrentSettingValue: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: {
|
||||
getInstance: () => ({
|
||||
getJWTSecret: async () => "a".repeat(64),
|
||||
getEncryptionKey: async () => crypto.randomBytes(32),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/user-keys.js", () => ({
|
||||
UserKeyManager: {
|
||||
getInstance: () => ({
|
||||
hasUserDEK: () => true,
|
||||
tryGetUserDEK: () => null,
|
||||
invalidate: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/crypto-migration/dek-migration.js", () => ({
|
||||
adoptRecoveredDEK: vi.fn(async () => {}),
|
||||
migratePasswordUserAtLogin: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const { createPluginContext, createPluginHandle } =
|
||||
await import("../../plugins/ctx.js");
|
||||
const { invalidatePluginPermissionCache } =
|
||||
await import("../../plugins/permissions.js");
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const { registerSecondFactor, resetAuthRegistryForTests } =
|
||||
await import("../../auth/registry.js");
|
||||
const { PluginCapabilityError } =
|
||||
await import("@termix-ssh/plugin-sdk/backend");
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticate = authManager.createAuthMiddleware();
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
function contextFor(capabilities: string[], grants = capabilities) {
|
||||
state.grants = grants;
|
||||
const manifest = {
|
||||
id: "ai",
|
||||
name: "AI",
|
||||
version: "1.0.0",
|
||||
capabilities,
|
||||
} as never;
|
||||
return createPluginContext(manifest, createPluginHandle("ai", {} as never));
|
||||
}
|
||||
|
||||
type Middleware = typeof authenticate;
|
||||
|
||||
async function call(middleware: Middleware, token: string, url: string) {
|
||||
const req = {
|
||||
cookies: {},
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
method: "POST",
|
||||
originalUrl: url,
|
||||
url,
|
||||
} as unknown as Parameters<Middleware>[0] & { userId?: string };
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return res;
|
||||
},
|
||||
clearCookie() {
|
||||
return res;
|
||||
},
|
||||
};
|
||||
let passed = false;
|
||||
await middleware(req, res as never, () => {
|
||||
passed = true;
|
||||
});
|
||||
return { passed, status: res.statusCode, body: res.body, userId: req.userId };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.users = new Map([
|
||||
["u1", { id: "u1", username: "alice", isAdmin: false }],
|
||||
["admin", { id: "admin", username: "root", isAdmin: true }],
|
||||
]);
|
||||
state.sessions.clear();
|
||||
state.languages.clear();
|
||||
state.audits = [];
|
||||
invalidatePluginPermissionCache();
|
||||
resetAuthRegistryForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ctx.auth.issueUserToken", () => {
|
||||
it("mints a session core's auth middleware accepts as that user", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "nightly report",
|
||||
});
|
||||
const result = await call(authenticate, token, "/host/db/host");
|
||||
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.userId).toBe("u1");
|
||||
const session = [...state.sessions.values()][0];
|
||||
expect(session).toMatchObject({
|
||||
userId: "u1",
|
||||
deviceType: "plugin",
|
||||
deviceInfo: "ai: nightly report",
|
||||
expiresAt,
|
||||
});
|
||||
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||
expect(payload.exp - payload.iat).toBe(300);
|
||||
});
|
||||
|
||||
it("keeps the user's own permissions", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const user = await ctx.auth.issueUserToken("u1", { purpose: "check" });
|
||||
const admin = await ctx.auth.issueUserToken("admin", { purpose: "check" });
|
||||
|
||||
const refused = await call(requireAdmin, user.token, "/users/list");
|
||||
expect(refused.passed).toBe(false);
|
||||
expect(refused.status).toBe(403);
|
||||
expect((await call(requireAdmin, admin.token, "/users/list")).passed).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("caps the lifetime at 900 seconds", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
const { token, expiresAt } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "long task",
|
||||
ttlSeconds: 86_400,
|
||||
});
|
||||
|
||||
const payload = jwt.decode(token) as { exp: number; iat: number };
|
||||
expect(payload.exp - payload.iat).toBe(900);
|
||||
expect(new Date(expiresAt).getTime() - Date.now()).toBeLessThanOrEqual(
|
||||
900_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops working when the session expires", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const { token } = await ctx.auth.issueUserToken("u1", {
|
||||
purpose: "short",
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
vi.useFakeTimers({ now: Date.now() + 61_000 });
|
||||
const result = await call(authenticate, token, "/host/db/host");
|
||||
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.status).toBe(401);
|
||||
});
|
||||
|
||||
it("audits every issue with the plugin, the user and the purpose", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
await ctx.auth.issueUserToken("u1", { purpose: "nightly report" });
|
||||
|
||||
const entry = state.audits.find(
|
||||
(audit) => audit.action === "plugin_user_token_issued",
|
||||
);
|
||||
expect(entry).toMatchObject({
|
||||
username: "plugin:ai",
|
||||
resourceType: "user",
|
||||
resourceId: "u1",
|
||||
success: true,
|
||||
});
|
||||
expect(JSON.parse(entry!.details as string)).toEqual({
|
||||
pluginId: "ai",
|
||||
purpose: "nightly report",
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("is refused without a granted users:impersonate", async () => {
|
||||
for (const ctx of [
|
||||
contextFor([]),
|
||||
contextFor(["users:impersonate"], []),
|
||||
contextFor(["users:read"]),
|
||||
]) {
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("u1", { purpose: "x" }),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
}
|
||||
expect(state.sessions.size).toBe(0);
|
||||
expect(state.audits.every((audit) => audit.success === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("is refused for a user that does not exist, or without a purpose", async () => {
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("ghost", { purpose: "x" }),
|
||||
).rejects.toThrow(/does not exist/);
|
||||
await expect(
|
||||
ctx.auth.issueUserToken("u1", { purpose: " " }),
|
||||
).rejects.toThrow(/purpose/);
|
||||
expect(state.sessions.size).toBe(0);
|
||||
expect(
|
||||
state.audits.filter(
|
||||
(audit) =>
|
||||
audit.action === "plugin_user_token_issued" && !audit.success,
|
||||
),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("cannot change how the user signs in or mint lasting credentials", async () => {
|
||||
registerSecondFactor({ id: "totp", pluginId: "totp" } as never);
|
||||
const ctx = contextFor(["users:impersonate"]);
|
||||
const { token } = await ctx.auth.issueUserToken("admin", {
|
||||
purpose: "ops",
|
||||
});
|
||||
|
||||
for (const url of [
|
||||
"/users/change-password",
|
||||
"/users/link-external-to-password",
|
||||
"/users/admin/reset-password",
|
||||
"/users/admin/u1/second-factors",
|
||||
"/plugin-api/totp/setup",
|
||||
]) {
|
||||
const result = await call(authenticate, token, url);
|
||||
expect(result.passed, url).toBe(false);
|
||||
expect(result.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||
}
|
||||
const apiKeys = await call(requireAdmin, token, "/users/api-keys");
|
||||
expect(apiKeys.passed).toBe(false);
|
||||
expect(apiKeys.body).toMatchObject({ code: "PLUGIN_SESSION_NOT_ALLOWED" });
|
||||
expect(
|
||||
(await call(authenticate, token, "/plugin-api/other/run")).passed,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a normal login session free to use those routes", async () => {
|
||||
const token = await authManager.generateJWTToken("u1", {
|
||||
deviceType: "web",
|
||||
deviceInfo: "Chrome",
|
||||
});
|
||||
|
||||
expect(
|
||||
(await call(authenticate, token, "/users/change-password")).passed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.users.getLocale", () => {
|
||||
it("returns the stored language, or null", async () => {
|
||||
state.languages.set("u1", "zh_CN");
|
||||
state.languages.set("admin", null);
|
||||
const ctx = contextFor(["users:read"]);
|
||||
|
||||
await expect(ctx.users.getLocale("u1")).resolves.toBe("zh_CN");
|
||||
await expect(ctx.users.getLocale("admin")).resolves.toBeNull();
|
||||
await expect(ctx.users.getLocale("nobody")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("is refused without a granted users:read", async () => {
|
||||
state.languages.set("u1", "zh_CN");
|
||||
|
||||
await expect(
|
||||
contextFor(["users:read"], []).users.getLocale("u1"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
await expect(
|
||||
contextFor(["users:impersonate"]).users.getLocale("u1"),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
});
|
||||
@@ -461,6 +461,19 @@ describe("public route rate limit", () => {
|
||||
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
|
||||
});
|
||||
|
||||
it("lets a plugin raise its own limit, capped, unless the admin set one", () => {
|
||||
const hits = (limit: number, count: number) =>
|
||||
Array.from({ length: count }, () =>
|
||||
http.overPublicRateLimit("proxy", "ip", 0, limit),
|
||||
).filter(Boolean).length;
|
||||
expect(hits(500, 500)).toBe(0);
|
||||
http.resetPublicRateLimits();
|
||||
expect(hits(50_000, 10_001)).toBe(1);
|
||||
http.resetPublicRateLimits();
|
||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "2";
|
||||
expect(hits(500, 3)).toBe(1);
|
||||
});
|
||||
|
||||
it("starts a new window after a minute", () => {
|
||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
|
||||
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
|
||||
|
||||
@@ -91,8 +91,8 @@ vi.mock("../../plugins/data.js", () => ({
|
||||
forgetTables: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/permissions.js", () => ({
|
||||
invalidatePluginPermissionCache: () => {},
|
||||
vi.mock("../../plugins/permissions.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../plugins/permissions.js")>()),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/registry-index.js", async (importOriginal) => {
|
||||
@@ -1084,3 +1084,48 @@ describe("more registries", () => {
|
||||
).rejects.toMatchObject({ code: "KEY_CHANGED" });
|
||||
});
|
||||
});
|
||||
|
||||
it("requires exact consent before enabling a mirrored plugin with capabilities", async () => {
|
||||
createFixturePlugin({
|
||||
id: "manual-mirror",
|
||||
root: path.join(data, "plugins"),
|
||||
capabilities: ["kv:own"],
|
||||
backendSource:
|
||||
'export async function activate(ctx) { await ctx.kv.set("probe", "value"); }',
|
||||
});
|
||||
await boot();
|
||||
const options = {
|
||||
version: "1.0.0",
|
||||
capabilities: ["kv:own"],
|
||||
userId: "admin",
|
||||
};
|
||||
await expect(
|
||||
manage.enablePluginWithConsent("manual-mirror", {
|
||||
...options,
|
||||
version: "0.9.0",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||
await expect(
|
||||
manage.enablePluginWithConsent("manual-mirror", {
|
||||
...options,
|
||||
capabilities: [],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONSENT_MISMATCH" });
|
||||
await expect(
|
||||
manage.enablePluginWithConsent("manual-mirror", {
|
||||
version: "1.0.0",
|
||||
userId: "admin",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "CONSENT_REQUIRED" });
|
||||
expect(db.grants).toEqual([]);
|
||||
expect(db.rows.get("manual-mirror")?.state).toBe("disabled");
|
||||
const result = await manage.enablePluginWithConsent("manual-mirror", options);
|
||||
expect(result.state).toBe("active");
|
||||
expect(db.grants).toContainEqual(
|
||||
expect.objectContaining({
|
||||
pluginId: "manual-mirror",
|
||||
capability: "kv:own",
|
||||
grantedBy: "admin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -256,4 +256,25 @@ describe("keys from a linked server", () => {
|
||||
await registries.setSyncedRegistryKeys([]);
|
||||
expect(getCustomPluginKeys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks them as synced unless an admin added the same key here", async () => {
|
||||
const synced = newKey();
|
||||
const shared = newKey();
|
||||
serve(URL_A, { registry: "acme", publicKey: shared });
|
||||
await registries.addRegistry(URL_A, pluginKeyId(shared));
|
||||
await registries.setSyncedRegistryKeys([synced, shared]);
|
||||
|
||||
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(shared))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(registries.isLocallyTrustedPluginKey("ec40e29bcc4986b2")).toBe(true);
|
||||
await registries.setSyncedRegistryKeys([]);
|
||||
expect(registries.isLocallyTrustedPluginKey(pluginKeyId(synced))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(registries.isLocallyTrustedPluginKey("unknown-key")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,8 @@ const UNGATED: Record<string, string> = {
|
||||
"schedule.every": "the plugin's own timers",
|
||||
"schedule.after": "the plugin's own timers",
|
||||
"audit.record": "writes under the runtime's actor, never a plugin value",
|
||||
asUser: "always audited as plugin_as_user",
|
||||
asUser:
|
||||
"audited as plugin_as_user (identical successful repeats coalesced for 10 minutes)",
|
||||
currentActor: "reads the actor, cannot set it",
|
||||
"desktop.available": "a yes/no about the environment",
|
||||
"http.baseUrl": "reads the request's public origin",
|
||||
|
||||
@@ -308,4 +308,11 @@ describe("adoptTableSql", () => {
|
||||
const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n");
|
||||
expect(splitStatements(joined)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("drops a chunk that is only comments", () => {
|
||||
expect(splitStatements("-- nothing to do here.\n")).toEqual([]);
|
||||
expect(
|
||||
splitStatements("/* note */;\n-- why\nSELECT 1;\n-- trailing"),
|
||||
).toEqual(["-- why\nSELECT 1"]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ const state = vi.hoisted(() => ({
|
||||
fetch: vi.fn(),
|
||||
loadBundled: vi.fn(),
|
||||
syncedKeys: vi.fn(async (_keys: unknown[]) => {}),
|
||||
syncedOnlyKeys: new Set<string>(),
|
||||
grants: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
@@ -28,10 +30,16 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
state: state.enabled.has(id) ? "enabled" : "disabled",
|
||||
}),
|
||||
}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({}),
|
||||
createCurrentPluginPermissionGrantRepository: () => ({
|
||||
listByPlugin: async () => [],
|
||||
grant: async ({ capability }: { capability: string }) =>
|
||||
void state.grants.push(capability),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../plugins/registries.js", () => ({
|
||||
setSyncedRegistryKeys: state.syncedKeys,
|
||||
isLocallyTrustedPluginKey: (keyId: string) =>
|
||||
keyId === "local-key" && !state.syncedOnlyKeys.has(keyId),
|
||||
}));
|
||||
vi.mock("../../../sync/client/http.js", () => ({
|
||||
remoteFetch: state.fetch,
|
||||
@@ -56,8 +64,10 @@ vi.mock("../../../plugins/index.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mirrorPlugins } = await import("../../../sync/client/plugins.js");
|
||||
const { mirrorPlugins, isServerManaged } =
|
||||
await import("../../../sync/client/plugins.js");
|
||||
const { remoteJson } = await import("../../../sync/client/http.js");
|
||||
const { sendCoreAlert } = await import("../../../notify/core-notify.js");
|
||||
|
||||
const bundled = {
|
||||
id: "ssh-terminal",
|
||||
@@ -92,6 +102,9 @@ beforeEach(() => {
|
||||
state.install.mockReset();
|
||||
state.fetch.mockReset();
|
||||
state.loadBundled.mockReset();
|
||||
state.syncedOnlyKeys = new Set();
|
||||
state.grants = [];
|
||||
vi.mocked(sendCoreAlert).mockClear();
|
||||
state.loadBundled.mockImplementation(async (id: string) => {
|
||||
const plugin = { ...bundled, id };
|
||||
state.loaded.set(id, plugin);
|
||||
@@ -171,4 +184,226 @@ describe("mirrorPlugins", () => {
|
||||
expect(state.loaded.has("ssh-terminal")).toBe(true);
|
||||
expect(state.enabled.has("ssh-terminal")).toBe(true);
|
||||
});
|
||||
|
||||
describe("a user plugin the server has on", () => {
|
||||
function serverRunsSignedBy(
|
||||
keyId: string,
|
||||
enabled = true,
|
||||
version = keyId === "removed-key" ? "1.0.1" : "1.0.0",
|
||||
) {
|
||||
state.loaded.set("extra", {
|
||||
id: "extra",
|
||||
source: "user",
|
||||
state: "loaded",
|
||||
signedBy: keyId,
|
||||
manifest: { id: "extra", version, capabilities: ["net"] },
|
||||
});
|
||||
vi.mocked(remoteJson).mockResolvedValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "extra",
|
||||
name: "Extra",
|
||||
version,
|
||||
source: "user",
|
||||
artifact: true,
|
||||
enabled,
|
||||
active: enabled,
|
||||
desktop: "mirror",
|
||||
syncEntities: [],
|
||||
granted: ["net"],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("turns it on when a key this desktop trusts signed it", async () => {
|
||||
serverRunsSignedBy("local-key");
|
||||
|
||||
await mirrorPlugins({} as never);
|
||||
|
||||
expect(state.enabled.has("extra")).toBe(true);
|
||||
expect(state.grants).toEqual(["net"]);
|
||||
});
|
||||
|
||||
it.each(["server-key", "removed-key"])(
|
||||
"leaves it off for untrusted key %s",
|
||||
async (keyId) => {
|
||||
serverRunsSignedBy(keyId);
|
||||
state.syncedOnlyKeys.add("server-key");
|
||||
|
||||
await mirrorPlugins({} as never);
|
||||
|
||||
expect(state.enabled.has("extra")).toBe(false);
|
||||
expect(state.grants).toEqual([]);
|
||||
expect(sendCoreAlert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ category: "termix.sync.unsigned_plugin" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves the server in charge of one a trusted key signed", () => {
|
||||
serverRunsSignedBy("local-key");
|
||||
expect(isServerManaged("extra")).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["server-key", "removed-key"])(
|
||||
"lets this desktop's admin decide on one signed by %s",
|
||||
async (keyId) => {
|
||||
serverRunsSignedBy(keyId);
|
||||
state.syncedOnlyKeys.add("server-key");
|
||||
expect(isServerManaged("extra")).toBe(false);
|
||||
|
||||
state.enabled.add("extra");
|
||||
await mirrorPlugins({} as never);
|
||||
expect(state.enabled.has("extra")).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("still switches an untrusted one off when the server does", async () => {
|
||||
serverRunsSignedBy("server-key", false);
|
||||
state.syncedOnlyKeys.add("server-key");
|
||||
state.enabled.add("extra");
|
||||
|
||||
await mirrorPlugins({} as never);
|
||||
|
||||
expect(state.enabled.has("extra")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["server-key", false],
|
||||
["local-key", true],
|
||||
])(
|
||||
"after an update signed by %s, keeps it on: %s",
|
||||
async (keyId, stays) => {
|
||||
serverRunsSignedBy(keyId, true, "2.0.0");
|
||||
state.syncedOnlyKeys.add("server-key");
|
||||
const reviewed = state.loaded.get("extra")!;
|
||||
state.loaded.set("extra", {
|
||||
...reviewed,
|
||||
manifest: { id: "extra", version: "1.0.0", capabilities: ["net"] },
|
||||
});
|
||||
state.enabled.add("extra");
|
||||
state.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(4),
|
||||
headers: new Headers(),
|
||||
});
|
||||
state.install.mockImplementation(async () => {
|
||||
const plugin = { ...reviewed };
|
||||
state.loaded.set("extra", plugin);
|
||||
return plugin;
|
||||
});
|
||||
|
||||
await mirrorPlugins({} as never);
|
||||
|
||||
expect(state.install).toHaveBeenCalled();
|
||||
expect(state.enabled.has("extra")).toBe(stays);
|
||||
expect(vi.mocked(sendCoreAlert).mock.calls.length > 0).toBe(!stays);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("server bundled label must not enable a synced-only user plugin", async () => {
|
||||
state.syncedOnlyKeys.add("server-key");
|
||||
state.loaded.set("extra", {
|
||||
id: "extra",
|
||||
source: "user",
|
||||
state: "loaded",
|
||||
signedBy: "server-key",
|
||||
manifest: { id: "extra", version: "1.0.0", capabilities: [] },
|
||||
});
|
||||
vi.mocked(remoteJson).mockResolvedValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "extra",
|
||||
name: "Extra",
|
||||
version: "1.0.0",
|
||||
source: "bundled",
|
||||
artifact: true,
|
||||
enabled: true,
|
||||
active: true,
|
||||
desktop: "mirror",
|
||||
syncEntities: [],
|
||||
granted: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
await mirrorPlugins({} as never);
|
||||
expect(state.enabled.has("extra")).toBe(false);
|
||||
});
|
||||
|
||||
it("remote bundled label cannot preserve approval across an untrusted update", async () => {
|
||||
const reviewed = {
|
||||
id: "extra",
|
||||
source: "user",
|
||||
state: "loaded",
|
||||
signedBy: "server-key",
|
||||
manifest: { id: "extra", version: "1.0.0", capabilities: [] },
|
||||
};
|
||||
state.loaded.set("extra", reviewed);
|
||||
state.syncedOnlyKeys.add("server-key");
|
||||
state.enabled.add("extra");
|
||||
vi.mocked(remoteJson).mockResolvedValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "extra",
|
||||
name: "Extra",
|
||||
version: "2.0.0",
|
||||
source: "bundled",
|
||||
artifact: true,
|
||||
enabled: true,
|
||||
active: true,
|
||||
desktop: "mirror",
|
||||
syncEntities: [],
|
||||
granted: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
state.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new ArrayBuffer(4),
|
||||
headers: new Headers(),
|
||||
});
|
||||
state.install.mockImplementation(async () => {
|
||||
const plugin = {
|
||||
...reviewed,
|
||||
manifest: { ...reviewed.manifest, version: "2.0.0" },
|
||||
};
|
||||
state.loaded.set("extra", plugin);
|
||||
return plugin;
|
||||
});
|
||||
await mirrorPlugins({} as never);
|
||||
expect(state.install).toHaveBeenCalledOnce();
|
||||
expect(state.enabled.has("extra")).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for an in-flight local plugin change before applying a remote update", async () => {
|
||||
const { serialized } = await import("../../../plugins/manage.js");
|
||||
let release!: () => void;
|
||||
let entered!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const barrier = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const localChange = serialized(async () => {
|
||||
entered();
|
||||
await barrier;
|
||||
});
|
||||
await ready;
|
||||
serverHas("1.1.0");
|
||||
state.fetch.mockResolvedValue({ ok: false, status: 502 });
|
||||
vi.mocked(remoteJson).mockClear();
|
||||
const syncing = mirrorPlugins({} as never);
|
||||
try {
|
||||
await vi.waitFor(() => expect(remoteJson).toHaveBeenCalled());
|
||||
expect(state.fetch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await localChange;
|
||||
await syncing;
|
||||
expect(state.fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const { listChanges } = vi.hoisted(() => ({ listChanges: vi.fn() }));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => ({ createAuthMiddleware: () => vi.fn() }) },
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({ syncLogger: { error: vi.fn() } }));
|
||||
vi.mock("../../../utils/app-version.js", () => ({ getLocalVersion: vi.fn() }));
|
||||
vi.mock("../../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
|
||||
vi.mock("../../../plugins/sync-registry.js", () => ({
|
||||
getEntity: vi.fn(),
|
||||
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||
vi.mock("../../../sync/entities.js", () => ({
|
||||
registerCoreSyncEntities: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../sync/records.js", () => ({
|
||||
listChanges,
|
||||
recordKey: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../../sync/server/feed.js", () => ({
|
||||
withUserLock: (_userId: string, fn: () => unknown) => fn(),
|
||||
reconcileUser: async () => ({ wire: new Map() }),
|
||||
}));
|
||||
vi.mock("../../../sync/server/push.js", () => ({}));
|
||||
vi.mock("../../../database/routes/branding-settings.js", () => ({}));
|
||||
|
||||
import router from "../../../sync/server/routes.js";
|
||||
|
||||
const route = router.stack.find(
|
||||
(layer) => layer.route?.path === "/v2/changes",
|
||||
)!.route!;
|
||||
const handler = route.stack[route.stack.length - 1].handle;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listChanges.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
async function changes(query: Record<string, string>) {
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };
|
||||
await handler({ userId: "user-1", query } as never, res as never, vi.fn());
|
||||
return res;
|
||||
}
|
||||
|
||||
it("answers an empty page with the entity types past the newest change", async () => {
|
||||
const cursor = String(Number.MAX_SAFE_INTEGER);
|
||||
const res = await changes({ cursor, limit: "1000" });
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
changes: [],
|
||||
nextCursor: Number.MAX_SAFE_INTEGER,
|
||||
hasMore: false,
|
||||
entityTypes: [{ type: "hosts", owner: "core", order: 10, readOnly: false }],
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a missing cursor and limit", async () => {
|
||||
await changes({});
|
||||
expect(listChanges).toHaveBeenCalledWith("user-1", 0, 1001, undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ cursor: "abc" },
|
||||
{ cursor: "-1" },
|
||||
{ cursor: "1.5" },
|
||||
{ cursor: "Infinity" },
|
||||
{ limit: "1.5" },
|
||||
{ limit: "-5" },
|
||||
])("rejects a malformed query %o", async (query) => {
|
||||
const res = await changes(query);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(listChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -14,6 +14,8 @@ const state = vi.hoisted(() => ({
|
||||
current: null as null | { drizzle: unknown },
|
||||
server: null as null | { drizzle: unknown },
|
||||
allow: true,
|
||||
sharedHostIds: new Set<number>(),
|
||||
paths: [] as string[],
|
||||
}));
|
||||
|
||||
const SERVER_USER = "srv-user";
|
||||
@@ -38,13 +40,18 @@ vi.mock("../../utils/system-crypto.js", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: () => Buffer.alloc(32, 3) },
|
||||
DataCrypto: {
|
||||
getUserDataKey: () => Buffer.alloc(32, 3),
|
||||
decryptRecord: (_table: string, record: unknown) => record,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
hasPermission: async () => state.allow,
|
||||
canAccessHost: async () => ({ hasAccess: false }),
|
||||
canAccessHost: async (_userId: string, hostId: number) => ({
|
||||
hasAccess: state.sharedHostIds.has(hostId),
|
||||
}),
|
||||
getUserPermissions: async () => [],
|
||||
}),
|
||||
},
|
||||
@@ -117,6 +124,7 @@ vi.mock("../../sync/client/http.js", async (importOriginal) => {
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown } = {},
|
||||
) => {
|
||||
state.paths.push(path);
|
||||
const previous = state.current;
|
||||
state.current = state.server;
|
||||
try {
|
||||
@@ -147,11 +155,15 @@ const { default: syncRouter } = await import("../../sync/server/routes.js");
|
||||
const { SyncEngine } = await import("../../sync/client/engine.js");
|
||||
const { createLink, getLink, updateLink } =
|
||||
await import("../../sync/client/link-store.js");
|
||||
const { markChanged, resetFeed } = await import("../../sync/server/feed.js");
|
||||
const { markChanged, reconcileUser, resetFeed } =
|
||||
await import("../../sync/server/feed.js");
|
||||
const { resetSeqAllocator, listConflicts, getConflict, deleteConflict } =
|
||||
await import("../../sync/records.js");
|
||||
const { createResolvers, writeWireRow } = await import("../../sync/store.js");
|
||||
const { getEntity } = await import("../../plugins/sync-registry.js");
|
||||
const { FieldCrypto } = await import("../../utils/field-crypto.js");
|
||||
const { syncLogger } = await import("../../utils/logger.js");
|
||||
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "5mb" }));
|
||||
@@ -239,6 +251,8 @@ beforeEach(async () => {
|
||||
resetFeed();
|
||||
resetSeqAllocator();
|
||||
state.allow = true;
|
||||
state.sharedHostIds.clear();
|
||||
state.paths = [];
|
||||
server = await side(SERVER_USER);
|
||||
state.server = server.ctx;
|
||||
desktops = [await side("desk-a"), await side("desk-b")];
|
||||
@@ -307,6 +321,60 @@ describe("linking a desktop", () => {
|
||||
expect(host.ip).toBe("new");
|
||||
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("never asks for a cursor past what the server's seq column holds", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "srv" }));
|
||||
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const cursors = state.paths
|
||||
.filter((path) => path.startsWith("/sync/v2/changes?"))
|
||||
.map((path) =>
|
||||
Number(new URLSearchParams(path.split("?")[1]).get("cursor")),
|
||||
);
|
||||
expect(cursors.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...cursors)).toBeLessThanOrEqual(2_147_483_647);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a synced host", () => {
|
||||
it("publishes host.created when it is written for the first time only", async () => {
|
||||
const [desk] = desktops;
|
||||
const seen: unknown[] = [];
|
||||
const off = pluginEvents.on(TOPICS.hostCreated, (payload) => {
|
||||
seen.push(payload);
|
||||
});
|
||||
const wire = {
|
||||
syncId: "host-new",
|
||||
name: "new",
|
||||
ip: "10.0.0.2",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
};
|
||||
try {
|
||||
await on(desk, async () => {
|
||||
const resolvers = createResolvers(desk.userId);
|
||||
const first = await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
wire,
|
||||
resolvers,
|
||||
);
|
||||
await writeWireRow(
|
||||
getEntity("hosts")!,
|
||||
desk.userId,
|
||||
{ ...wire, name: "renamed" },
|
||||
resolvers,
|
||||
);
|
||||
expect(seen).toEqual([{ hostId: first.id, userId: desk.userId }]);
|
||||
});
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("after linking", () => {
|
||||
@@ -480,6 +548,181 @@ describe("after linking", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a server row that cannot be read", () => {
|
||||
async function recordOf(where: Side, syncId: string) {
|
||||
const [record] = await on(where, () =>
|
||||
db(where)
|
||||
.select()
|
||||
.from(schema.syncRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.syncRecords.entityType, "hosts"),
|
||||
eq(schema.syncRecords.syncId, syncId),
|
||||
),
|
||||
),
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
it.each(["upsert", "delete"] as const)(
|
||||
"retries a local %s after the server row becomes readable",
|
||||
async (op) => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "a" }));
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, () =>
|
||||
(op === "delete"
|
||||
? db(desk).delete(schema.hosts)
|
||||
: db(desk).update(schema.hosts).set({ ip: "desk-edit" })
|
||||
).where(eq(schema.hosts.syncId, "host-a")),
|
||||
);
|
||||
await on(server, async () => {
|
||||
await db(server)
|
||||
.update(schema.hosts)
|
||||
.set({ ip: "server-edit" })
|
||||
.where(eq(schema.hosts.syncId, "host-a"));
|
||||
await reconcileUser(SERVER_USER, { force: true });
|
||||
// Then its secret stops decrypting, as under a different key.
|
||||
const [host] = await hostsOf(server);
|
||||
await db(server)
|
||||
.update(schema.hosts)
|
||||
.set({
|
||||
password: FieldCrypto.encryptField(
|
||||
"s3cret",
|
||||
Buffer.alloc(32, 9),
|
||||
String(host.id),
|
||||
"password",
|
||||
),
|
||||
})
|
||||
.where(eq(schema.hosts.id, host.id));
|
||||
});
|
||||
|
||||
const warn = vi.spyOn(syncLogger, "warn");
|
||||
try {
|
||||
await pass(desk, engine);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
entityType: "hosts",
|
||||
syncId: "host-a",
|
||||
error: expect.any(String),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
|
||||
const [local] = await on(desk, () => hostsOf(desk));
|
||||
expect(local?.ip).toBe(op === "upsert" ? "desk-edit" : undefined);
|
||||
expect(await on(desk, () => listConflicts(desk.userId))).toEqual([]);
|
||||
expect((await recordOf(desk, "host-a")).error).toBe("unavailable");
|
||||
|
||||
// Restore the same server content without another local edit or a new
|
||||
// server revision. The failed operation must still be retried.
|
||||
await on(server, () =>
|
||||
db(server)
|
||||
.update(schema.hosts)
|
||||
.set({ password: null })
|
||||
.where(eq(schema.hosts.syncId, "host-a")),
|
||||
);
|
||||
await on(server, () => reconcileUser(SERVER_USER, { force: true }));
|
||||
await pass(desk, engine);
|
||||
const [after] = await on(desk, () => hostsOf(desk));
|
||||
expect(after?.ip).toBe("server-edit");
|
||||
expect((await recordOf(desk, "host-a")).error).toBeNull();
|
||||
const conflicts = await on(desk, () => listConflicts(desk.userId));
|
||||
expect(conflicts.map((c) => JSON.parse(c.localRow).ip)).toEqual(
|
||||
op === "upsert" ? ["desk-edit"] : [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the local row when a conflict carries no row", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, () => addHost(server, { name: "a" }));
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
await on(desk, async () => {
|
||||
const link = (await getLink())!;
|
||||
const settle = (
|
||||
engine as unknown as {
|
||||
settle: (...args: unknown[]) => Promise<void>;
|
||||
}
|
||||
).settle.bind(engine);
|
||||
await settle(
|
||||
link,
|
||||
{
|
||||
entityType: "hosts",
|
||||
syncId: "host-a",
|
||||
baseRevision: 1,
|
||||
op: "upsert",
|
||||
row: { syncId: "host-a", name: "a" },
|
||||
},
|
||||
{
|
||||
entityType: "hosts",
|
||||
syncId: "host-a",
|
||||
status: "conflict",
|
||||
revision: 2,
|
||||
deleted: false,
|
||||
row: null,
|
||||
},
|
||||
"hash",
|
||||
);
|
||||
});
|
||||
|
||||
expect((await on(desk, () => hostsOf(desk))).map((h) => h.name)).toEqual([
|
||||
"a",
|
||||
]);
|
||||
const record = await recordOf(desk, "host-a");
|
||||
expect(record.deleted).toBe(false);
|
||||
expect(record.revision).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hosts shared with the account", () => {
|
||||
it("arrive on the desktop with their text columns as stored", async () => {
|
||||
const [desk] = desktops;
|
||||
await on(server, async () => {
|
||||
await server.db.run(
|
||||
sql`INSERT INTO users (id, username, password_hash, is_admin) VALUES ('owner', 'owner', '', 0)`,
|
||||
);
|
||||
await db(server).insert(schema.hosts).values({
|
||||
userId: "owner",
|
||||
name: "theirs",
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "none",
|
||||
forceKeyboardInteractive: "true",
|
||||
syncId: "host-theirs",
|
||||
});
|
||||
const [host] = await db(server).select().from(schema.hosts);
|
||||
await db(server).insert(schema.hostAccess).values({
|
||||
hostId: host.id,
|
||||
userId: SERVER_USER,
|
||||
grantedBy: "owner",
|
||||
permissionLevel: "view",
|
||||
});
|
||||
state.sharedHostIds.add(host.id);
|
||||
markChanged();
|
||||
});
|
||||
|
||||
const engine = await linkDesktop(desk);
|
||||
await pass(desk, engine);
|
||||
|
||||
const [copy] = await on(desk, () => hostsOf(desk));
|
||||
expect(copy?.name).toBe("theirs");
|
||||
expect(copy.forceKeyboardInteractive).toBe("true");
|
||||
expect(JSON.parse(copy.sharedSource!)).toMatchObject({
|
||||
owner: "owner",
|
||||
permissionLevel: "view",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("two desktops on one account", () => {
|
||||
it("converge on edits, creates and deletes made offline on each", async () => {
|
||||
const [a, b] = desktops;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const createMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -60,6 +60,101 @@ describe("logAudit", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("logAudit plugin routine coalescing", () => {
|
||||
const WINDOW_MS = 10 * 60 * 1000;
|
||||
let run = 0;
|
||||
let details = "";
|
||||
|
||||
const routine = (overrides: Record<string, unknown> = {}) =>
|
||||
logAudit({
|
||||
userId: null,
|
||||
username: "plugin:automations",
|
||||
action: "plugin_as_user",
|
||||
resourceType: "plugin",
|
||||
resourceId: "automations",
|
||||
details,
|
||||
success: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createMock.mockResolvedValue(undefined);
|
||||
vi.useFakeTimers();
|
||||
// The coalescing memory is module state: a fresh key per test keeps them
|
||||
// independent.
|
||||
details = `automations ran background work as user-${++run}`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("writes one row for identical successful plugin_as_user repeats in the window", async () => {
|
||||
await routine();
|
||||
vi.advanceTimersByTime(WINDOW_MS - 1);
|
||||
await routine();
|
||||
await routine();
|
||||
|
||||
expect(createMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("writes one row for identical successful plugin service calls in the window", async () => {
|
||||
const service = {
|
||||
action: "plugin_service_host_metrics_open",
|
||||
resourceId: "host-metrics",
|
||||
};
|
||||
await routine(service);
|
||||
await routine(service);
|
||||
|
||||
expect(createMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("writes again once the window has passed", async () => {
|
||||
await routine();
|
||||
vi.advanceTimersByTime(WINDOW_MS);
|
||||
await routine();
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("always writes failures", async () => {
|
||||
await routine();
|
||||
await routine({ success: false, errorMessage: "boom" });
|
||||
await routine({ success: false, errorMessage: "boom" });
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("never coalesces non-plugin or non-routine actions", async () => {
|
||||
const login = {
|
||||
userId: "user-1",
|
||||
username: "alice",
|
||||
action: "login",
|
||||
resourceType: "user",
|
||||
};
|
||||
await routine(login);
|
||||
await routine(login);
|
||||
const secretRead = { action: "plugin_secret_shared_read" };
|
||||
await routine(secretRead);
|
||||
await routine(secretRead);
|
||||
const notPlugin = { username: "alice" };
|
||||
await routine(notPlugin);
|
||||
await routine(notPlugin);
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("does not coalesce entries with different details or resourceId", async () => {
|
||||
await routine();
|
||||
await routine({ details: `${details} (other)` });
|
||||
await routine({ resourceId: "other-plugin" });
|
||||
await routine({ userId: "user-2" });
|
||||
|
||||
expect(createMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequestMeta", () => {
|
||||
it("uses the proxy-validated Express IP", () => {
|
||||
const req = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getTrustedProxyAuthConfig,
|
||||
getTrustedProxySourceAddress,
|
||||
isTrustedProxyAddress,
|
||||
parseTrustedProxyRoleMap,
|
||||
resolveTrustedProxyRoles,
|
||||
@@ -28,6 +29,35 @@ describe("trusted proxy authentication config", () => {
|
||||
expect(isTrustedProxyAddress("2001:db8::5", trusted)).toBe(true);
|
||||
});
|
||||
|
||||
it("judges requests relayed by the bundled nginx by nginx's own peer", () => {
|
||||
const viaNginx = (peer?: string) => ({
|
||||
headers: peer ? { "x-termix-proxy-peer": peer } : {},
|
||||
socket: { remoteAddress: "::ffff:127.0.0.1" },
|
||||
});
|
||||
expect(getTrustedProxySourceAddress(viaNginx("172.20.0.10"))).toBe(
|
||||
"172.20.0.10",
|
||||
);
|
||||
// A client reaching nginx directly is reported as itself, so trusting
|
||||
// the auth proxy no longer means trusting nginx on loopback.
|
||||
expect(
|
||||
isTrustedProxyAddress(
|
||||
getTrustedProxySourceAddress(viaNginx("203.0.113.7")),
|
||||
["127.0.0.1"],
|
||||
),
|
||||
).toBe(false);
|
||||
// A local reverse proxy that does not set the header keeps working.
|
||||
expect(getTrustedProxySourceAddress(viaNginx())).toBe("::ffff:127.0.0.1");
|
||||
});
|
||||
|
||||
it("ignores the peer header on connections that are not from loopback", () => {
|
||||
expect(
|
||||
getTrustedProxySourceAddress({
|
||||
headers: { "x-termix-proxy-peer": "172.20.0.10" },
|
||||
socket: { remoteAddress: "198.51.100.9" },
|
||||
}),
|
||||
).toBe("198.51.100.9");
|
||||
});
|
||||
|
||||
it("fails closed when a supplied external role is not mapped", () => {
|
||||
const roleMap = parseTrustedProxyRoleMap(
|
||||
JSON.stringify({ operators: ["operator"], viewers: "readonly" }),
|
||||
|
||||
@@ -35,7 +35,49 @@ export interface AuditLogParams {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// Plugin routine calls (a headless viewer's heartbeat through ctx.asUser and a
|
||||
// service) repeat identically every few seconds; written each time they push
|
||||
// real security entries out of the capped log. Only successful plugin_as_user
|
||||
// and plugin_service_* lines are coalesced; failures and everything else are
|
||||
// always written.
|
||||
const ROUTINE_COALESCE_MS = 10 * 60 * 1000;
|
||||
const ROUTINE_COALESCE_MAX_KEYS = 5000;
|
||||
const routineLastWritten = new Map<string, number>();
|
||||
|
||||
function isCoalescedRoutine(params: AuditLogParams): boolean {
|
||||
if (
|
||||
!params.success ||
|
||||
!params.username.startsWith("plugin:") ||
|
||||
!(
|
||||
params.action === "plugin_as_user" ||
|
||||
params.action.startsWith("plugin_service_")
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const key = JSON.stringify([
|
||||
params.userId,
|
||||
params.username,
|
||||
params.action,
|
||||
params.resourceType,
|
||||
params.resourceId,
|
||||
params.details,
|
||||
]);
|
||||
const now = Date.now();
|
||||
const last = routineLastWritten.get(key);
|
||||
if (last !== undefined && now - last < ROUTINE_COALESCE_MS) return true;
|
||||
if (routineLastWritten.size >= ROUTINE_COALESCE_MAX_KEYS) {
|
||||
for (const [k, at] of routineLastWritten) {
|
||||
if (now - at >= ROUTINE_COALESCE_MS) routineLastWritten.delete(k);
|
||||
}
|
||||
}
|
||||
routineLastWritten.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function logAudit(params: AuditLogParams): Promise<void> {
|
||||
if (isCoalescedRoutine(params)) return;
|
||||
|
||||
// Local storage is the source of truth and runs first; forwarding is a copy
|
||||
// and must never delay or fail the audited operation.
|
||||
void forwardAuditEntry(params).catch(() => {});
|
||||
|
||||
@@ -15,6 +15,7 @@ import bcrypt from "bcryptjs";
|
||||
import { nanoid } from "nanoid";
|
||||
import { inArray, isNotNull } from "drizzle-orm";
|
||||
import type { ExternalSessionRef } from "../auth/types.js";
|
||||
import { listSecondFactors } from "../auth/registry.js";
|
||||
import type { DeviceType } from "./user-agent-parser.js";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sessions } from "../database/db/schema.js";
|
||||
@@ -44,6 +45,11 @@ interface JWTPayload {
|
||||
pendingTOTP?: boolean;
|
||||
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */
|
||||
purpose?: "ws";
|
||||
/**
|
||||
* A plugin minted the session. Read from the session row on verify, never
|
||||
* from the token, so a refreshed token stays marked.
|
||||
*/
|
||||
pluginSession?: boolean;
|
||||
dataKeyWrap?: WrappedDataKey;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
@@ -103,6 +109,26 @@ const IMPERSONATION_PATH_ALLOWLIST = [/^\/host\/db\//, /^\/credentials(\/|$)/];
|
||||
|
||||
const PLUGIN_API_PATH = /^\/plugin-api\/([a-z][a-z0-9-]*)(\/|$)/;
|
||||
|
||||
/** sessions.device_type of a session a plugin minted with ctx.auth.issueUserToken. */
|
||||
export const PLUGIN_SESSION_DEVICE_TYPE = "plugin";
|
||||
|
||||
// Routes that change how a user signs in or mint a credential that outlives
|
||||
// the session. A plugin-minted session is refused on them, and on every
|
||||
// route of a plugin that provides a second factor.
|
||||
const PLUGIN_SESSION_BLOCKLIST = [
|
||||
/^\/users\/(change-password|api-keys|link-external-to-password|unlink-external-from-password|admin\/reset-password)(\/|$)/,
|
||||
/^\/users\/admin\/[^/]+\/second-factors(\/|$)/,
|
||||
];
|
||||
|
||||
/** Whether a session a plugin minted may call this path. */
|
||||
export function allowsPluginSession(path: string): boolean {
|
||||
if (PLUGIN_SESSION_BLOCKLIST.some((pattern) => pattern.test(path))) {
|
||||
return false;
|
||||
}
|
||||
const plugin = PLUGIN_API_PATH.exec(path)?.[1];
|
||||
return !listSecondFactors().some((factor) => factor.pluginId === plugin);
|
||||
}
|
||||
|
||||
let pluginAllowsImpersonation: (pluginId: string) => boolean = () => false;
|
||||
|
||||
/** Set by the plugin runtime: whether a running plugin opted in. */
|
||||
@@ -407,6 +433,38 @@ class AuthManager {
|
||||
} as jwt.SignOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session a plugin mints for a user, to call core's API as them. It is
|
||||
* an ordinary session row, so it verifies, expires and shows up like a
|
||||
* login, marked with the plugin and why.
|
||||
*/
|
||||
async issuePluginSession(
|
||||
userId: string,
|
||||
options: { pluginId: string; purpose: string; ttlSeconds: number },
|
||||
): Promise<{ token: string; expiresAt: string }> {
|
||||
const sessionId = nanoid();
|
||||
const token = jwt.sign(
|
||||
{ userId, sessionId } satisfies JWTPayload,
|
||||
await this.systemCrypto.getJWTSecret(),
|
||||
{ expiresIn: options.ttlSeconds },
|
||||
);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(
|
||||
now.getTime() + options.ttlSeconds * 1000,
|
||||
).toISOString();
|
||||
await createCurrentSessionRepository().create({
|
||||
id: sessionId,
|
||||
userId,
|
||||
jwtToken: token,
|
||||
deviceType: PLUGIN_SESSION_DEVICE_TYPE,
|
||||
deviceInfo: `${options.pluginId}: ${options.purpose}`,
|
||||
createdAt: now.toISOString(),
|
||||
expiresAt,
|
||||
lastActiveAt: now.toISOString(),
|
||||
});
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async verifyJWTToken(
|
||||
token: string,
|
||||
options: { allowSocketTicket?: boolean } = {},
|
||||
@@ -440,6 +498,8 @@ class AuthManager {
|
||||
if (new Date(sessionRecord.expiresAt).getTime() < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
payload.pluginSession =
|
||||
sessionRecord.deviceType === PLUGIN_SESSION_DEVICE_TYPE;
|
||||
|
||||
await this.migrateDataKeyFromPayload(payload);
|
||||
} catch (dbError) {
|
||||
@@ -787,6 +847,8 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refusePluginSession(req, res, payload)) return;
|
||||
|
||||
if (payload.sessionId) {
|
||||
try {
|
||||
const sessionRepository = createCurrentSessionRepository();
|
||||
@@ -953,6 +1015,21 @@ class AuthManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Sends the 403 and returns true when a plugin session may not use this route. */
|
||||
private refusePluginSession(
|
||||
req: Request,
|
||||
res: Response,
|
||||
payload: JWTPayload,
|
||||
): boolean {
|
||||
const path = (req.originalUrl || req.url || "").split("?")[0];
|
||||
if (!payload.pluginSession || allowsPluginSession(path)) return false;
|
||||
res.status(403).json({
|
||||
error: "A session a plugin issued cannot use this route",
|
||||
code: "PLUGIN_SESSION_NOT_ALLOWED",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
createDataAccessMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
@@ -1007,6 +1084,8 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.refusePluginSession(req, res, payload)) return;
|
||||
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(
|
||||
payload.userId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
export interface TrustedProxyAuthConfig {
|
||||
@@ -108,6 +109,31 @@ export function isTrustedProxyAddress(
|
||||
);
|
||||
}
|
||||
|
||||
function isLoopbackAddress(address: string): boolean {
|
||||
const normalized = normalizeAddress(address);
|
||||
return normalized === "::1" || normalized.startsWith("127.");
|
||||
}
|
||||
|
||||
/**
|
||||
* The address checked against the allowlist. The bundled nginx always
|
||||
* connects from loopback and sets X-Termix-Proxy-Peer to its own TCP peer,
|
||||
* overwriting whatever the client sent, so behind it the proxy that counts
|
||||
* is that peer rather than nginx. Any other connection is judged by its
|
||||
* socket address.
|
||||
*/
|
||||
export function getTrustedProxySourceAddress(req: {
|
||||
headers: IncomingHttpHeaders;
|
||||
socket: { remoteAddress?: string };
|
||||
}): string | undefined {
|
||||
const socketAddress = req.socket.remoteAddress;
|
||||
const peer = req.headers["x-termix-proxy-peer"];
|
||||
return socketAddress &&
|
||||
isLoopbackAddress(socketAddress) &&
|
||||
typeof peer === "string"
|
||||
? peer
|
||||
: socketAddress;
|
||||
}
|
||||
|
||||
export function resolveTrustedProxyRoles(
|
||||
header: string,
|
||||
roleMap: Map<string, string[]>,
|
||||
|
||||
+27
-2
@@ -6,6 +6,8 @@ import "./ui/index.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./ui/i18n/i18n";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
|
||||
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
|
||||
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
|
||||
import { Toaster } from "@/components/sonner";
|
||||
import {
|
||||
@@ -84,6 +86,15 @@ function FullscreenApp() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-out browser gets the normal login for a full-screen link and comes
|
||||
* back to it afterwards. The desktop app and the embedded sign-in frames
|
||||
* hand a login to their own shell, so they keep the bare view.
|
||||
*/
|
||||
function signsInForViewLink(): boolean {
|
||||
return !isElectron() && !isEmbeddedAuthFrame();
|
||||
}
|
||||
|
||||
function FullscreenAppGate() {
|
||||
const { t } = useTranslation();
|
||||
const [ready, setReady] = useState(false);
|
||||
@@ -109,7 +120,16 @@ function FullscreenAppGate() {
|
||||
if (!cancelled) setReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAuthFailed(true);
|
||||
if (cancelled) return;
|
||||
if (signsInForViewLink()) {
|
||||
rememberViewLink();
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
}
|
||||
setAuthFailed(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -118,7 +138,7 @@ function FullscreenAppGate() {
|
||||
}, []);
|
||||
|
||||
if (authFailed) {
|
||||
return <FullscreenApp />;
|
||||
return signsInForViewLink() ? <App /> : <FullscreenApp />;
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
@@ -181,6 +201,7 @@ function App() {
|
||||
// Non-fatal: WebSocket connections will fall back to cookie auth
|
||||
}
|
||||
}
|
||||
if (resumeViewLink()) return;
|
||||
setPhase("loading-app");
|
||||
await Promise.all([
|
||||
startPluginRuntime()
|
||||
@@ -262,6 +283,10 @@ function App() {
|
||||
function handleLogin(u: string) {
|
||||
loggingOutRef.current = false;
|
||||
clearDesktopManualLogout();
|
||||
if (resumeViewLink()) {
|
||||
setPhase("loading-app");
|
||||
return;
|
||||
}
|
||||
setAuthUsername(u);
|
||||
setPhase("loading-app");
|
||||
void (async () => {
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface HostSidebarDisplayPreferences extends HostRowFields {
|
||||
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
||||
showFolderPaths: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
/** When true, the sidebar closes after a host is opened from it. */
|
||||
closeOnOpen: boolean;
|
||||
/**
|
||||
* Host action id to whether it shows in the row's connect bar. An action
|
||||
* missing here follows its own default. Every action stays in the menu.
|
||||
@@ -163,6 +165,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
|
||||
openOnDoubleClick: false,
|
||||
showFolderPaths: true,
|
||||
hostClickBehavior: "newTab",
|
||||
closeOnOpen: false,
|
||||
barActions: {},
|
||||
...defaultHostRowFields(),
|
||||
},
|
||||
@@ -274,6 +277,10 @@ export function sanitizeHostSidebarPreferences(
|
||||
)
|
||||
? (displayObj.hostClickBehavior as HostClickBehavior)
|
||||
: defaults.display.hostClickBehavior,
|
||||
closeOnOpen:
|
||||
typeof displayObj.closeOnOpen === "boolean"
|
||||
? displayObj.closeOnOpen
|
||||
: defaults.display.closeOnOpen,
|
||||
barActions: sanitizeBarActions(displayObj.barActions),
|
||||
};
|
||||
|
||||
|
||||
+10
-14
@@ -125,6 +125,7 @@ import type {
|
||||
} from "@/types/ui-types";
|
||||
import { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||
import { getPaneNavDirection } from "@/lib/pane-nav-hotkey";
|
||||
import { getTabJumpDigit } from "@/lib/tab-jump-hotkey";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import {
|
||||
@@ -829,20 +830,11 @@ export function AppShell({
|
||||
}
|
||||
}
|
||||
|
||||
// Alt+Arrow moves between panes of the active split
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
||||
const direction = (
|
||||
{
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
} as Record<string, NavDirection>
|
||||
)[e.code];
|
||||
if (direction && splitActionsRef.current?.navigate(direction)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Alt+Arrow (Cmd+Option+Arrow on macOS) moves between panes of the active split
|
||||
const paneDirection = getPaneNavDirection(e);
|
||||
if (paneDirection && splitActionsRef.current?.navigate(paneDirection)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+1..9 on macOS, Alt+1..9 elsewhere: jump directly to the tab at that position
|
||||
@@ -2698,6 +2690,7 @@ export function AppShell({
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
onEditHost={editHostInManager}
|
||||
onCloseSidebar={() => setSidebarOpen(false)}
|
||||
hostTree={realHostTree ?? undefined}
|
||||
loading={hostsLoading}
|
||||
onEditingChange={panelEditingSetter("hosts")}
|
||||
@@ -3133,6 +3126,9 @@ export function AppShell({
|
||||
{renderSidebarPanels(rightRailView, false)}
|
||||
</DockPanel>
|
||||
)}
|
||||
|
||||
{/* Plugin panels pinned to the right edge, sized by the plugin */}
|
||||
<ComponentSlot slotId="shell.rightEdge" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -263,6 +263,8 @@ export interface PluginSummary {
|
||||
/** Environment variables the plugin reads. Admins only. */
|
||||
env?: PluginEnvVar[];
|
||||
signedBy?: string | null;
|
||||
/** A linked server decides whether it runs here. Admins only. */
|
||||
managedByServer?: boolean;
|
||||
}
|
||||
|
||||
export async function getPlugins(): Promise<PluginSummary[]> {
|
||||
@@ -510,10 +512,12 @@ export async function previewPluginState(
|
||||
export async function setPluginState(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
consent?: { version: string; capabilities: string[] },
|
||||
): Promise<PluginStateChange> {
|
||||
try {
|
||||
const response = await rbacApi.patch(`${pluginPath(pluginId)}/state`, {
|
||||
enabled,
|
||||
...consent,
|
||||
});
|
||||
return response.data;
|
||||
} finally {
|
||||
|
||||
@@ -434,10 +434,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// Past the first few tries, show the error but keep checking, so
|
||||
// the page moves on by itself once the server is back.
|
||||
if (attemptNumber >= maxAttempts) {
|
||||
setDbConnectionFailed(true);
|
||||
setDbHealthChecking(false);
|
||||
return;
|
||||
}
|
||||
const delay = Math.min(500 * 2 ** attemptNumber, 5000);
|
||||
retryTimer = setTimeout(() => {
|
||||
|
||||
@@ -73,11 +73,21 @@ export function ElectronLoginForm({
|
||||
);
|
||||
|
||||
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
||||
// Uses '*' as target origin because the iframe may be cross-origin (e.g. remote Docker server).
|
||||
// Only the server's own origin is trusted: the iframe can navigate away
|
||||
// (identity provider, redirects), and those pages must not hand us a token,
|
||||
// start a system browser login, or receive the replies.
|
||||
useEffect(() => {
|
||||
let serverOrigin: string;
|
||||
try {
|
||||
serverOrigin = new URL(serverUrl).origin;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
try {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (event.origin !== serverOrigin) return;
|
||||
if (!event.data || typeof event.data !== "object") return;
|
||||
const {
|
||||
type,
|
||||
@@ -115,7 +125,7 @@ export function ElectronLoginForm({
|
||||
providerId,
|
||||
...result,
|
||||
},
|
||||
"*",
|
||||
serverOrigin,
|
||||
);
|
||||
};
|
||||
const electronAPI = (
|
||||
@@ -181,14 +191,14 @@ export function ElectronLoginForm({
|
||||
success: false,
|
||||
error,
|
||||
},
|
||||
"*",
|
||||
serverOrigin,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [handleAuthSuccess, t]);
|
||||
}, [handleAuthSuccess, serverUrl, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
} from "@/lib/ServerStatusContext";
|
||||
import { withLiveHostStatus } from "@/sidebar/live-host-status";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { sortHostTree, type SortKey } from "@/sidebar/host-sort";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import {
|
||||
CountersStrip,
|
||||
HostStatusList,
|
||||
@@ -66,6 +68,17 @@ import { docsUrl } from "@/lib/docs";
|
||||
|
||||
export { HostStatusList as HostStatusCard } from "./dashboard-core-cards";
|
||||
|
||||
const HOST_STATUS_SORT_LABELS: Partial<Record<SortKey, string>> = {
|
||||
default: "hosts.sortDefault",
|
||||
"name-asc": "hosts.sortNameAsc",
|
||||
"name-desc": "hosts.sortNameDesc",
|
||||
"ip-asc": "hosts.sortIpAsc",
|
||||
"ip-desc": "hosts.sortIpDesc",
|
||||
"status-online": "hosts.sortOnlineFirst",
|
||||
"status-offline": "hosts.sortOfflineFirst",
|
||||
};
|
||||
const HOST_STATUS_SORTS = Object.keys(HOST_STATUS_SORT_LABELS) as SortKey[];
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type PanelId = "main" | "side";
|
||||
@@ -460,7 +473,20 @@ export function DashboardTab({
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
const [credentialCount, setCredentialCount] = useState(0);
|
||||
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
||||
const statusCheckHosts = hosts.filter(isStatusCheckEnabled);
|
||||
const [hostStatusSort, setHostStatusSort] = useState<SortKey>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("dashboardTab.hostStatusSort");
|
||||
return HOST_STATUS_SORTS.includes(saved as SortKey)
|
||||
? (saved as SortKey)
|
||||
: "default";
|
||||
} catch {
|
||||
return "default";
|
||||
}
|
||||
});
|
||||
const statusCheckHosts = sortHostTree(
|
||||
{ name: "", children: hosts.filter(isStatusCheckEnabled) },
|
||||
hostStatusSort,
|
||||
).children as Host[];
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -580,7 +606,28 @@ export function DashboardTab({
|
||||
icon: core.icon,
|
||||
frame: core.frame,
|
||||
action:
|
||||
id === "recent_activity" && activity.length > 0 ? (
|
||||
id === "host_status" && statusCheckHosts.length > 1 ? (
|
||||
<Select2
|
||||
value={hostStatusSort}
|
||||
aria-label={t("hosts.sortHosts")}
|
||||
onChange={(e) => {
|
||||
const key = e.target.value as SortKey;
|
||||
setHostStatusSort(key);
|
||||
try {
|
||||
localStorage.setItem("dashboardTab.hostStatusSort", key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
className="h-6 border border-border bg-background px-1.5 text-[10px] outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{HOST_STATUS_SORTS.map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{t(HOST_STATUS_SORT_LABELS[key]!)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
) : id === "recent_activity" && activity.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClearActivity()}
|
||||
|
||||
@@ -20,11 +20,10 @@ interface OriginResolvableHost {
|
||||
* the desktop-wide default.
|
||||
*
|
||||
* `defaultRemote` makes a host left on Default resolve to "remote" instead of
|
||||
* following the desktop-wide setting. Remote desktop passes it: it needs a
|
||||
* guacd, which the desktop does not ship, so originating it here only works
|
||||
* once the user has pointed Termix at one of their own. Making that opt-in
|
||||
* per host keeps an upgrade from moving working connections onto a guacd
|
||||
* that isn't there -- see Termix-SSH/Support#1240.
|
||||
* following the desktop-wide setting. Remote desktop passes it when this
|
||||
* desktop is set to use an external guacd, so a host is not moved onto a
|
||||
* guacd that isn't there (see Termix-SSH/Support#1240). With the built-in
|
||||
* engine it runs locally like SSH.
|
||||
*/
|
||||
export async function resolveConnectionOrigin(
|
||||
host: OriginResolvableHost,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getBasePath } from "./base-path";
|
||||
import { localBackendPort } from "./electron";
|
||||
|
||||
type BrowserLocation = Pick<
|
||||
Location,
|
||||
@@ -16,11 +17,14 @@ export function getDatabaseTransferUrl(
|
||||
{ electron, configuredServerUrl, location }: DatabaseTransferUrlOptions,
|
||||
): string {
|
||||
if (electron) {
|
||||
const serverUrl = configuredServerUrl || "http://localhost:30001";
|
||||
const serverUrl =
|
||||
configuredServerUrl || `http://localhost:${localBackendPort()}`;
|
||||
return `${serverUrl.replace(/\/$/, "")}/database/${operation}`;
|
||||
}
|
||||
|
||||
const development = location.port === "5173";
|
||||
// Vite moves off 5173 when another dev server holds it.
|
||||
const development =
|
||||
process.env.NODE_ENV === "development" || location.port === "5173";
|
||||
|
||||
if (development) {
|
||||
return `http://localhost:30001/database/${operation}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ type ElectronWindow = Window &
|
||||
IS_ELECTRON?: boolean;
|
||||
electronAPI?: {
|
||||
isElectron?: boolean;
|
||||
backendPort?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,3 +17,20 @@ export function isElectron(): boolean {
|
||||
|
||||
return hasISElectron || hasElectronAPI || isElectronProp;
|
||||
}
|
||||
|
||||
const DEFAULT_LOCAL_BACKEND_PORT = 30001;
|
||||
|
||||
/**
|
||||
* The port of the desktop app's embedded backend. It moves off 30001 when
|
||||
* another program holds that port.
|
||||
*/
|
||||
export function localBackendPort(): number {
|
||||
if (typeof window === "undefined") return DEFAULT_LOCAL_BACKEND_PORT;
|
||||
const port = (window as ElectronWindow).electronAPI?.backendPort;
|
||||
return typeof port === "number" &&
|
||||
Number.isInteger(port) &&
|
||||
port > 0 &&
|
||||
port < 65536
|
||||
? port
|
||||
: DEFAULT_LOCAL_BACKEND_PORT;
|
||||
}
|
||||
@@ -16,9 +16,14 @@ export function isInMobileWebView(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// Being framed alone is not enough: any site could frame this page and
|
||||
// receive the token posted to window.parent. The desktop app tags every
|
||||
// request and frame with its own user agent, which a web page cannot fake
|
||||
// in someone else's browser.
|
||||
export function isInElectronWebView(): boolean {
|
||||
if (isInMobileWebView()) return false;
|
||||
if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true;
|
||||
if (!/Termix-Desktop\//.test(navigator.userAgent)) return false;
|
||||
try {
|
||||
if (window.self !== window.top) return true;
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isMacPlatform } from "./tab-jump-hotkey";
|
||||
|
||||
export type PaneNavDirection = "left" | "right" | "up" | "down";
|
||||
|
||||
const ARROWS: Record<string, PaneNavDirection> = {
|
||||
ArrowLeft: "left",
|
||||
ArrowRight: "right",
|
||||
ArrowUp: "up",
|
||||
ArrowDown: "down",
|
||||
};
|
||||
|
||||
/**
|
||||
* The split pane to move to for this key, if any. Cmd+Option+Arrow on macOS,
|
||||
* where Option+Arrow moves the shell cursor by word, Alt+Arrow elsewhere.
|
||||
*/
|
||||
export function getPaneNavDirection(e: KeyboardEvent): PaneNavDirection | null {
|
||||
const direction = ARROWS[e.code];
|
||||
if (!direction || e.ctrlKey || e.shiftKey || !e.altKey) return null;
|
||||
if (e.metaKey !== isMacPlatform()) return null;
|
||||
return direction;
|
||||
}
|
||||
|
||||
export function paneNavHotkeyLabel(): string {
|
||||
return isMacPlatform() ? "Cmd+Option+Arrows" : "Alt+Arrows";
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
/**
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
*
|
||||
@@ -19,3 +21,25 @@ export function pluginKey(
|
||||
if (!key) return key;
|
||||
return key.includes(":") ? key : `${pluginId}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin's name in the UI language: "plugin.name" from its own locales,
|
||||
* else the manifest's. A plugin whose strings are not loaded, like one only
|
||||
* listed in a registry, shows the manifest name.
|
||||
*/
|
||||
export function pluginName(
|
||||
t: TFunction,
|
||||
plugin: { id: string; name: string },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
|
||||
}
|
||||
|
||||
/** Same as pluginName, for "plugin.description". */
|
||||
export function pluginDescription(
|
||||
t: TFunction,
|
||||
plugin: { id: string; description?: string | null },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.description"), {
|
||||
defaultValue: plugin.description ?? "",
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { isElectron, localBackendPort } from "@/lib/electron";
|
||||
import { getDeviceId } from "@/lib/device-id";
|
||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||
import {
|
||||
@@ -165,7 +165,7 @@ export async function pluginWsUrl(
|
||||
if (isElectron()) {
|
||||
return buildOriginWsUrl({
|
||||
origin: options.origin ?? "local",
|
||||
localPort: BACKEND_PORT,
|
||||
localPort: localBackendPort(),
|
||||
localPath: route,
|
||||
remotePath: route,
|
||||
});
|
||||
@@ -175,11 +175,10 @@ export async function pluginWsUrl(
|
||||
const protocols = websocketAuthProtocols(token);
|
||||
|
||||
// Dev without a configured API host goes through Vite's proxy, which is
|
||||
// reached on the page's own origin rather than the backend port.
|
||||
// reached on the page's own origin rather than the backend port. Vite moves
|
||||
// to another port when 5173 is taken, so the port says nothing here.
|
||||
const devProxy =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!import.meta.env.VITE_API_HOST &&
|
||||
(window.location.port === "3000" || window.location.port === "5173");
|
||||
process.env.NODE_ENV === "development" && !import.meta.env.VITE_API_HOST;
|
||||
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Brings a signed-out full-screen `?view=` link back after sign-in. The link
|
||||
* waits in sessionStorage, which survives a redirect login in the same tab,
|
||||
* so no return path is ever sent to the server.
|
||||
*/
|
||||
|
||||
const PENDING_VIEW_LINK_KEY = "termix-pending-view-link";
|
||||
|
||||
/** The value as a same-origin path with a `view` parameter, or null. */
|
||||
export function toViewLinkPath(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, window.location.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.origin !== window.location.origin) return null;
|
||||
if (!url.searchParams.get("view")) return null;
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
export function rememberViewLink(): void {
|
||||
const { pathname, search, hash } = window.location;
|
||||
const path = toViewLinkPath(`${pathname}${search}${hash}`);
|
||||
if (!path) return;
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_VIEW_LINK_KEY, path);
|
||||
} catch {
|
||||
// Without storage the user just lands on the app after signing in.
|
||||
}
|
||||
}
|
||||
|
||||
/** The remembered link, cleared whether or not it is still valid. */
|
||||
export function consumeViewLink(): string | null {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(PENDING_VIEW_LINK_KEY);
|
||||
sessionStorage.removeItem(PENDING_VIEW_LINK_KEY);
|
||||
return toViewLinkPath(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the remembered link, if there is one. The navigation waits a tick so
|
||||
* history updates the login callback makes right after sign-in cannot cancel
|
||||
* it.
|
||||
*/
|
||||
export function resumeViewLink(): boolean {
|
||||
const path = consumeViewLink();
|
||||
if (!path) return false;
|
||||
setTimeout(() => window.location.replace(path), 0);
|
||||
return true;
|
||||
}
|
||||
@@ -644,6 +644,8 @@
|
||||
"actionsClick": "Click to expand/collapse",
|
||||
"openOnDoubleClick": "Open on double click",
|
||||
"openOnDoubleClickDesc": "Require a double click to open a host session instead of a single click",
|
||||
"closeOnOpen": "Hide sidebar after opening",
|
||||
"closeOnOpenDesc": "Close the sidebar after you open a host from it",
|
||||
"showFolderPaths": "Show folder paths",
|
||||
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
|
||||
"rowDetailsTitle": "Row details",
|
||||
@@ -1097,7 +1099,7 @@
|
||||
"acceptNewKey": "Accept New Key & Continue"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Could not connect to the database",
|
||||
"databaseConnection": "The Termix server is not responding",
|
||||
"embeddedServerFailed": "Termix's local server could not start",
|
||||
"unknownError": "Unknown error",
|
||||
"syncedHostMissing": "The synced host does not exist on the linked server",
|
||||
@@ -1124,7 +1126,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "You have no Termix account yet. Ask an admin to turn on Auto-create external accounts.",
|
||||
"userNotAllowed": "Your account is not authorized to register. Please contact an administrator.",
|
||||
"databaseConnectionFailed": "Failed to connect to the database server",
|
||||
"databaseConnectionFailed": "It may still be starting, or it stopped. This page keeps trying and continues on its own once the server is back.",
|
||||
"embeddedServerPortInUse": "Port {{port}} is already in use by another program. This is usually a Termix Docker container or a leftover Termix process from a previous session. Close it, then restart Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "One of the ports Termix needs is already in use by another program. Close whatever is using it, then restart Termix.",
|
||||
"embeddedServerOldDatabase": "Your local data is from Termix 2.8 or older and has to pass through 2.9 first. Install Termix 2.9.1, open it once so it can move your data, then update to this version again.",
|
||||
@@ -1672,6 +1674,8 @@
|
||||
"pinAppRailDesc": "Keep the left sidebar app rail always expanded instead of expanding on hover",
|
||||
"expandAppRailOnHover": "Expand App Rail on Hover",
|
||||
"expandAppRailOnHoverDesc": "Allow the left sidebar app rail to expand when the pointer moves over it",
|
||||
"appRailHoverPushesContent": "Push content when it expands",
|
||||
"appRailHoverPushesContentDesc": "Make room for the expanded rail instead of opening it over the page. Open sessions, like remote desktop, resize each time",
|
||||
"showPinAppRailButton": "Show Pin App Rail Button",
|
||||
"showPinAppRailButtonDesc": "Show a button in the app rail to pin it open. Off by default.",
|
||||
"settingsNavigation": "Navigation",
|
||||
@@ -1967,6 +1971,7 @@
|
||||
"syncEntities_other": "Desktop sync"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Enable {{name}}?",
|
||||
"installTitle": "Install {{name}}",
|
||||
"updateTitle": "Update {{name}}",
|
||||
"agreeUpdate": "Agree and update",
|
||||
|
||||
@@ -644,6 +644,8 @@
|
||||
"actionsClick": "Klik om uit/in te vou",
|
||||
"openOnDoubleClick": "Maak oop met dubbelklik",
|
||||
"openOnDoubleClickDesc": "Vereis 'n dubbelklik om 'n gasheerbediener-sessie oop te maak in plaas van 'n enkele klik",
|
||||
"closeOnOpen": "Versteek sybalk na oopmaak",
|
||||
"closeOnOpenDesc": "Maak die sybalk toe nadat jy 'n gasheer daaruit oopgemaak het",
|
||||
"showFolderPaths": "Wys gidsroetes",
|
||||
"showFolderPathsDesc": "Wys die ouergidsroete bo ingeslote subgidsname. Subgidse bly ingespring in beide gevalle.",
|
||||
"rowDetailsTitle": "Rybesonderhede",
|
||||
@@ -1097,7 +1099,7 @@
|
||||
"acceptNewKey": "Aanvaar Nuwe Sleutel & Gaan Voort"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Kon nie met die databasis verbind nie",
|
||||
"databaseConnection": "Die Termix-bedienaar reageer nie",
|
||||
"embeddedServerFailed": "Termix se plaaslike bediener kon nie begin nie",
|
||||
"unknownError": "Onbekende fout",
|
||||
"syncedHostMissing": "Die gesinkroniseerde gasheer bestaan nie op die gekoppelde bediener nie",
|
||||
@@ -1124,7 +1126,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Jy het nog nie 'n Termix-rekening nie. Vra 'n admin om Eksterne rekenings outomaties skep aan te skakel.",
|
||||
"userNotAllowed": "Jou rekening is nie gemagtig om te registreer nie. Kontak asseblief 'n administrateur.",
|
||||
"databaseConnectionFailed": "Kon nie met die databasisbediener verbind nie",
|
||||
"databaseConnectionFailed": "Dit is dalk nog besig om te begin, of dit het gestop. Hierdie bladsy gaan voortdurend probeer en sal outomaties hervat sodra die bedienaar weer beskikbaar is.",
|
||||
"embeddedServerPortInUse": "Poort {{port}} word reeds deur 'n ander program gebruik. Dit is gewoonlik 'n Termix Docker-houer of 'n oorblywende Termix-proses van 'n vorige sessie. Maak dit toe en herbegin dan Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Een van die poorte wat Termix benodig, word reeds deur 'n ander program gebruik. Maak wat dit gebruik toe en herbegin dan Termix.",
|
||||
"embeddedServerOldDatabase": "U plaaslike data is van Termix 2.8 of ouer en moet eers deur 2.9 gaan. Installeer Termix 2.9.1, open dit een keer sodat dit u data kan verskuif, en dateer dan weer na hierdie weergawe op.",
|
||||
@@ -1672,6 +1674,8 @@
|
||||
"pinAppRailDesc": "Hou die linkersybalke-toepingsbalk altyd uitgebrei in plaas daarvan om dit slegs by hover uit te brei",
|
||||
"expandAppRailOnHover": "Brei toepingsbalk uit by hover",
|
||||
"expandAppRailOnHoverDesc": "Laat die linkersybalke-toepingsbalk uitbrei wanneer die aanwyser daaroor beweeg",
|
||||
"appRailHoverPushesContent": "Druk inhoud uit wanneer dit uitbrei",
|
||||
"appRailHoverPushesContentDesc": "Maak plek vir die uitgebreide spoor in plaas daarvan om dit oor die bladsy oop te maak. Oop sessies, soos afgeleë bureblad, word elke keer herskaal",
|
||||
"showPinAppRailButton": "Wys speld-toepingsbalk-knoppie",
|
||||
"showPinAppRailButtonDesc": "Wys 'n knoppie in die toepingsbalk om dit oop te speld. Standaard af.",
|
||||
"settingsNavigation": "Navigasie",
|
||||
@@ -1967,6 +1971,7 @@
|
||||
"syncEntities_other": "Buro-sinkronisering"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Aktiveer {{name}}?",
|
||||
"installTitle": "Installeer {{name}}",
|
||||
"updateTitle": "Dateer {{name}} op",
|
||||
"agreeUpdate": "Stem saam en dateer op",
|
||||
|
||||
@@ -668,6 +668,8 @@
|
||||
"actionsClick": "انقر للتوسيع/الطي",
|
||||
"openOnDoubleClick": "الفتح عند النقر المزدوج",
|
||||
"openOnDoubleClickDesc": "تطلب نقرًا مزدوجًا لفتح جلسة الجهاز بدلاً من نقر واحد",
|
||||
"closeOnOpen": "إخفاء الشريط الجانبي بعد الفتح",
|
||||
"closeOnOpenDesc": "إغلاق الشريط الجانبي بعد فتح مضيف منه",
|
||||
"showFolderPaths": "إظهار مسارات المجلدات",
|
||||
"showFolderPathsDesc": "إظهار مسار المجلد الأب فوق أسماء المجلدات الفرعية المتداخلة. تظل المجلدات الفرعية مضمنة في كلتا الحالتين.",
|
||||
"rowDetailsTitle": "تفاصيل الصف",
|
||||
@@ -1153,7 +1155,7 @@
|
||||
"acceptNewKey": "قبول المفتاح الجديد ومتابعة"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "تعذر الاتصال بقاعدة البيانات",
|
||||
"databaseConnection": "خادم Termix لا يستجيب",
|
||||
"embeddedServerFailed": "تعذر بدء الخادم المحلي لـ Termix",
|
||||
"unknownError": "خطأ غير معروف",
|
||||
"syncedHostMissing": "المضيف المتزامن غير موجود على الخادم المرتبط",
|
||||
@@ -1180,7 +1182,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "ليس لديك حساب Termix بعد. اطلب من المسؤول تفعيل إنشاء الحسابات الخارجية تلقائيًا.",
|
||||
"userNotAllowed": "حسابك غير مصرح له بالتسجيل. يرجى الاتصال بمسؤول النظام.",
|
||||
"databaseConnectionFailed": "فشل في الاتصال بخادم قاعدة البيانات",
|
||||
"databaseConnectionFailed": "قد لا يزال قيد التشغيل أو توقف. تستمر هذه الصفحة في المحاولة وستعمل تلقائياً بمجرد عودة الخادم.",
|
||||
"embeddedServerPortInUse": "المنفذ {{port}} مستخدم بالفعل بواسطة برنامج آخر. عادةً ما يكون هذا حاوية Docker لـ Termix أو عملية متبقية من Termix من جلسة سابقة. أغلقها، ثم أعد تشغيل Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "أحد المنافذ التي يحتاجها Termix مستخدم بالفعل بواسطة برنامج آخر. أغلق ما يستخدمه، ثم أعد تشغيل Termix.",
|
||||
"embeddedServerOldDatabase": "بياناتك المحلية من إصدار Termix 2.8 أو أقدم، ويجب أولاً تحديثها عبر الإصدار 2.9. قم بتثبيت Termix 2.9.1، وافتحه مرة واحدة للسماح بنقل بياناتك، ثم قم بالتحديث إلى هذا الإصدار مرة أخرى.",
|
||||
@@ -1764,6 +1766,8 @@
|
||||
"pinAppRailDesc": "أبقِ شريط التطبيقات الجانبي الأيسر موسعًا دائمًا بدلاً من توسيعه عند التمرير فوقه",
|
||||
"expandAppRailOnHover": "توسيع شريط التطبيقات عند التمرير",
|
||||
"expandAppRailOnHoverDesc": "اسمح لشريط التطبيقات الجانبي الأيسر بالتوسع عند تحريك المؤشر فوقه",
|
||||
"appRailHoverPushesContent": "دفع المحتوى عند التوسيع",
|
||||
"appRailHoverPushesContentDesc": "توفير مساحة للشريط الموسع بدلاً من فتحه فوق الصفحة. سيتم تغيير حجم الجلسات المفتوحة، مثل سطح المكتب عن بُعد، في كل مرة",
|
||||
"showPinAppRailButton": "إظهار زر تثبيت شريط التطبيقات",
|
||||
"showPinAppRailButtonDesc": "اعرض زرًا في شريط التطبيقات لتثبيته مفتوحًا. معطّل افتراضيًا.",
|
||||
"settingsNavigation": "التنقل",
|
||||
@@ -2107,6 +2111,7 @@
|
||||
"syncEntities_other": "مزامنة سطح المكتب"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "تفعيل {{name}}؟",
|
||||
"installTitle": "تثبيت {{name}}",
|
||||
"updateTitle": "تحديث {{name}}",
|
||||
"agreeUpdate": "موافقة وتحديث",
|
||||
|
||||
@@ -644,6 +644,8 @@
|
||||
"actionsClick": "Кликване за разгъване/свиване",
|
||||
"openOnDoubleClick": "Отваряне при двойно кликване",
|
||||
"openOnDoubleClickDesc": "Изискване на двойно кликване за отваряне на сесия на хост вместо едно кликване",
|
||||
"closeOnOpen": "Скрий страничната лента след отваряне",
|
||||
"closeOnOpenDesc": "Затвори страничната лента след като отвориш хост от нея",
|
||||
"showFolderPaths": "Показване на пътищата до папките",
|
||||
"showFolderPathsDesc": "Показване на пътя до родителската папка над имената на вложените подпапки. Подпапките оставят отстъп в двата случая.",
|
||||
"rowDetailsTitle": "Детайли на реда",
|
||||
@@ -1097,7 +1099,7 @@
|
||||
"acceptNewKey": "Приеми новия ключ и продължи"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Неуспешно свързване с базата данни",
|
||||
"databaseConnection": "Сървърът на Termix не отговаря",
|
||||
"embeddedServerFailed": "Локалният сървър на Termix не може да се стартира",
|
||||
"unknownError": "Неизвестна грешка",
|
||||
"syncedHostMissing": "Синхронизианият хост не съществува на свързания сървър",
|
||||
@@ -1124,7 +1126,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Все още нямате Termix акаунт. Попитайте администратор да активира автоматичното създаване на външни акаунти.",
|
||||
"userNotAllowed": "Вашият акаунт не е упълномощен за регистрация. Моля, свържете се с администратор.",
|
||||
"databaseConnectionFailed": "Неуспешно свързване със сървъра на базата данни",
|
||||
"databaseConnectionFailed": "Възможно е все още да се стартира или да е спрял. Тази страница продължава да опитва и ще се възстанови автоматично, когато сървърът е отново наличен.",
|
||||
"embeddedServerPortInUse": "Порт {{port}} вече се използва от друга програма. Обикновено това е Termix Docker контейнер или остатъчен Termix процес от предишна сесия. Затворете го и рестартирайте Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Един от портовете, нужни на Termix, вече се използва от друга програма. Затворете програмата, която го използва, и рестартирайте Termix.",
|
||||
"embeddedServerOldDatabase": "Вашите локални данни са от Termix 2.8 или по-стара версия и трябва първо да преминат през версия 2.9. Инсталирайте Termix 2.9.1, отворете го веднъж, за да може да прехвърли данните ви, след което актуализирайте отново до тази версия.",
|
||||
@@ -1672,6 +1674,8 @@
|
||||
"pinAppRailDesc": "Държане на лявата лента с приложения винаги разгъната, вместо да се разгъва при посочване",
|
||||
"expandAppRailOnHover": "Разгъване на лентата с приложения при посочване",
|
||||
"expandAppRailOnHoverDesc": "Разрешаване на разгъване на лявата лента с приложения, когато курсорът се премести върху нея",
|
||||
"appRailHoverPushesContent": "Избутвай съдържанието при разгъване",
|
||||
"appRailHoverPushesContentDesc": "Остави място за разгънатата лента вместо да я отваряш върху страницата. Отворените сесии, като отдалечен плот, ще се преоразмеряват всеки път",
|
||||
"showPinAppRailButton": "Показване на бутона за закачане на лентата с приложения",
|
||||
"showPinAppRailButtonDesc": "Показване на бутон в лентата с приложения за нейното закачване. Изключено по подразбиране.",
|
||||
"settingsNavigation": "Навигация",
|
||||
@@ -1967,6 +1971,7 @@
|
||||
"syncEntities_other": "Синхронизиране на работния плот"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Активиране на {{name}}?",
|
||||
"installTitle": "Инсталиране на {{name}}",
|
||||
"updateTitle": "Актуализиране на {{name}}",
|
||||
"agreeUpdate": "Съгласявам се и актуализирам",
|
||||
|
||||
@@ -644,6 +644,8 @@
|
||||
"actionsClick": "প্রসারিত/সংকুচিত করতে ক্লিক করুন",
|
||||
"openOnDoubleClick": "ডাবল ক্লিকে খুলুন",
|
||||
"openOnDoubleClickDesc": "একক ক্লিকের পরিবর্তে হোস্ট সেশন খুলতে ডাবল ক্লিক প্রয়োজন করুন",
|
||||
"closeOnOpen": "খোলা পর সাইডবার লুকান",
|
||||
"closeOnOpenDesc": "সাইডবার থেকে কোনো হোস্ট খোলার পর সাইডবার বন্ধ করুন",
|
||||
"showFolderPaths": "ফোল্ডার পথ দেখান",
|
||||
"showFolderPathsDesc": "নেস্টেড সাবফোল্ডার নামের উপরে প্যারেন্ট ফোল্ডার পথ দেখান। সাবফোল্ডার যেকোনো ক্ষেত্রেই ইনডেন্ট থাকবে।",
|
||||
"rowDetailsTitle": "সারির বিবরণ",
|
||||
@@ -1097,7 +1099,7 @@
|
||||
"acceptNewKey": "নতুন কী গ্রহণ করুন ও চালিয়ে যান"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "ডেটাবেসের সাথে সংযোগ স্থাপন করা যায়নি",
|
||||
"databaseConnection": "Termix সার্ভার কোনো প্রতিক্রিয়া দিচ্ছে না",
|
||||
"embeddedServerFailed": "টার্মিক্সের স্থানীয় সার্ভার শুরু করতে পারেনি",
|
||||
"unknownError": "অজানা ত্রুটি",
|
||||
"syncedHostMissing": "সিঙ্ক করা হোস্টটি লিঙ্ক করা সার্ভারে বিদ্যমান নেই",
|
||||
@@ -1124,7 +1126,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "আপনার এখনো কোনো Termix অ্যাকাউন্ট নেই। একজন প্রশাসককে 'Auto-create external accounts' চালু করতে বলুন।",
|
||||
"userNotAllowed": "রেজিস্ট্রেশনের জন্য আপনার অ্যাকাউন্ট অনুমোদিত নয়। অনুগ্রহ করে একজন প্রশাসকের সাথে যোগাযোগ করুন।",
|
||||
"databaseConnectionFailed": "ডেটাবেস সার্ভারে সংযোগ করতে ব্যর্থ হয়েছে",
|
||||
"databaseConnectionFailed": "এটি এখনও শুরু হতে পারে, অথবা থেমে গেছে। এই পৃষ্ঠাটি বারবার চেষ্টা করে এবং সার্ভার ফিরে আসলে স্বয়ংক্রিয়ভাবে চালু হয়।",
|
||||
"embeddedServerPortInUse": "{{port}} পোর্টটি ইতিমধ্যে অন্য একটি প্রোগ্রাম দ্বারা ব্যবহৃত হচ্ছে। এটি সাধারণত একটি Termix Docker কন্টেইনার বা আগের সেশনের একটি অবশিষ্ট Termix প্রক্রিয়া হয়। এটি বন্ধ করুন, তারপর Termix পুনরায় চালু করুন।",
|
||||
"embeddedServerPortInUseUnknownPort": "Termix-এর প্রয়োজনীয় পোর্টগুলোর একটি ইতিমধ্যে অন্য একটি প্রোগ্রাম দ্বারা ব্যবহৃত হচ্ছে। যা তা ব্যবহার করছে তা বন্ধ করুন, তারপর Termix পুনরায় চালু করুন।",
|
||||
"embeddedServerOldDatabase": "আপনার স্থানীয় ডেটাটি Termix 2.8 বা তার পূর্ববর্তী সংস্করণের এবং এটি প্রথমে 2.9 সংস্করণের মধ্য দিয়ে যেতে হবে। Termix 2.9.1 ইনস্টল করুন, এটি একবার ওপেন করুন যাতে এটি আপনার ডেটা স্থানান্তর করতে পারে, তারপর আবার এই সংস্করণে আপডেট করুন।",
|
||||
@@ -1672,6 +1674,8 @@
|
||||
"pinAppRailDesc": "হোভার করলে প্রসারিত হওয়ার পরিবর্তে বাম সাইডবারের অ্যাপ রেল সবসময় প্রসারিত রাখুন",
|
||||
"expandAppRailOnHover": "হোভার করলে অ্যাপ রেল প্রসারিত করুন",
|
||||
"expandAppRailOnHoverDesc": "পয়েন্টার এর উপর দিয়ে চলাচল করলে বাম সাইডবারের অ্যাপ রেল প্রসারিত হতে দিন",
|
||||
"appRailHoverPushesContent": "বিস্তারিত হলে কন্টেন্ট সরান",
|
||||
"appRailHoverPushesContentDesc": "পৃষ্ঠার উপরে না খুলে বিস্তৃত রেলের জন্য জায়গা তৈরি করুন। রিমোট ডেস্কটপের মতো খোলা সেশনগুলো প্রতিবার আকার পরিবর্তন করে",
|
||||
"showPinAppRailButton": "অ্যাপ রেল পিন বাটন দেখান",
|
||||
"showPinAppRailButtonDesc": "এটি খোলা রাখতে অ্যাপ রেলের মধ্যে একটি বাটন দেখান। ডিফল্টভাবে বন্ধ থাকে।",
|
||||
"settingsNavigation": "নেভিগেশন",
|
||||
@@ -1967,6 +1971,7 @@
|
||||
"syncEntities_other": "ডেস্কটপ সিঙ্ক"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "{{name}} সক্রিয় করবেন?",
|
||||
"installTitle": "{{name}} ইনস্টল করুন",
|
||||
"updateTitle": "{{name}} আপডেট করুন",
|
||||
"agreeUpdate": "সম্মতি দিন এবং আপডেট করুন",
|
||||
|
||||
@@ -650,6 +650,8 @@
|
||||
"actionsClick": "Fes clic per expandir/reduir",
|
||||
"openOnDoubleClick": "Obre en fer doble clic",
|
||||
"openOnDoubleClickDesc": "Requereix un doble clic per obrir una sessió d'amfitrió en lloc d'un sol clic",
|
||||
"closeOnOpen": "Amaga la barra lateral en obrir",
|
||||
"closeOnOpenDesc": "Tanca la barra lateral després d'obrir un amfitrió des d'ella",
|
||||
"showFolderPaths": "Mostra les rutes de les carpetes",
|
||||
"showFolderPathsDesc": "Mostra la ruta de la carpeta principal sobre els noms de les subcarpetes anidades. Les subcarpetes es mantenen amb sagnat en qualsevol cas.",
|
||||
"rowDetailsTitle": "Detalls de la fila",
|
||||
@@ -1111,7 +1113,7 @@
|
||||
"acceptNewKey": "Accepta la clau nova i continua"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "No s'ha pogut connectar a la base de dades",
|
||||
"databaseConnection": "El servidor de Termix no respon",
|
||||
"embeddedServerFailed": "El servidor local de Termix no ha pogut iniciar-se",
|
||||
"unknownError": "Error desconegut",
|
||||
"syncedHostMissing": "L'amfitrió sincronitzat no existeix al servidor enllaçat",
|
||||
@@ -1138,7 +1140,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Encara no tens un compte de Termix. Demana a un administrador que activi la creació automàtica de comptes externs.",
|
||||
"userNotAllowed": "El teu compte no està autoritzat per registrar-se. Si us plau, contacta amb un administrador.",
|
||||
"databaseConnectionFailed": "No s'ha pogut connectar al servidor de bases de dades",
|
||||
"databaseConnectionFailed": "Potser encara s'està iniciant o s'ha aturat. Aquesta pàgina continuarà intentant-ho i es recuperarà automàticament quan el servidor torni a estar disponible.",
|
||||
"embeddedServerPortInUse": "El port {{port}} ja està en ús per un altre programa. Normalment és un contenidor Docker de Termix o un procés de Termix d'una sessió anterior. Tanca'l i reinicia Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Un dels ports que necessita Termix ja està en ús per un altre programa. Tanca el que l'està utilitzant i reinicia Termix.",
|
||||
"embeddedServerOldDatabase": "Les dades locals són de Termix 2.8 o anterior i han de passar primer per la versió 2.9. Instal·la Termix 2.9.1, obre-la un cop perquè pugui moure les dades i, a continuació, actualitza a aquesta versió.",
|
||||
@@ -1695,6 +1697,8 @@
|
||||
"pinAppRailDesc": "Mantén la barra d'aplicacions de la barra lateral esquerra sempre ampliada en lloc d'ampliar-la en passar el cursor per sobre",
|
||||
"expandAppRailOnHover": "Amplia la barra d'aplicacions en passar el cursor",
|
||||
"expandAppRailOnHoverDesc": "Permet que la barra d'aplicacions de la barra lateral esquerra s'ampliï quan el cursor passi per sobre",
|
||||
"appRailHoverPushesContent": "Desplaça el contingut en expandir-se",
|
||||
"appRailHoverPushesContentDesc": "Deixa espai per a la barra lateral expandida en lloc d'obrir-la sobre la pàgina. Les sessions obertes, com l'escriptori remot, es redimensionen cada vegada.",
|
||||
"showPinAppRailButton": "Mostra el botó per fixar la barra d'aplicacions",
|
||||
"showPinAppRailButtonDesc": "Mostra un botó a la barra d'aplicacions per fixar-la oberta. Desactivat per defecte.",
|
||||
"settingsNavigation": "Navegació",
|
||||
@@ -2002,6 +2006,7 @@
|
||||
"syncEntities_other": "Sincronització d'escriptori"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Vols habilitar {{name}}?",
|
||||
"installTitle": "Instal·lar {{name}}",
|
||||
"updateTitle": "Actualitzar {{name}}",
|
||||
"agreeUpdate": "Acceptar i actualitzar",
|
||||
|
||||
@@ -656,6 +656,8 @@
|
||||
"actionsClick": "Kliknutím rozbalit/sbalit",
|
||||
"openOnDoubleClick": "Otevřít dvojklikem",
|
||||
"openOnDoubleClickDesc": "Požadovat dvojklik pro otevření relace hosta místo jednokliku",
|
||||
"closeOnOpen": "Skrýt postranní panel po otevření",
|
||||
"closeOnOpenDesc": "Zavřít postranní panel po otevření hostitele z něj",
|
||||
"showFolderPaths": "Zobrazit cesty složek",
|
||||
"showFolderPathsDesc": "Zobrazit cestu nadřazené složky nad jmény vnořených podsložek. Podsložky zůstanou v obou případech odsazené.",
|
||||
"rowDetailsTitle": "Detaily řádku",
|
||||
@@ -1125,7 +1127,7 @@
|
||||
"acceptNewKey": "Přijmout nový klíč a pokračovat"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Nelze se připojit k databázi",
|
||||
"databaseConnection": "Server Termix neodpovídá",
|
||||
"embeddedServerFailed": "Lokální server Termix se nepodařilo spustit",
|
||||
"unknownError": "Neznámá chyba",
|
||||
"syncedHostMissing": "Synchronizovaný hostitel neexistuje na propojeném serveru",
|
||||
@@ -1152,7 +1154,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Zatím nemáte účet Termix. Požádejte administrátora, aby zapnul možnost Automatické vytváření externích účtů.",
|
||||
"userNotAllowed": "Váš účet není oprávněn k registraci. Kontaktujte prosím administrátora.",
|
||||
"databaseConnectionFailed": "Nepodařilo se připojit k databázovému serveru",
|
||||
"databaseConnectionFailed": "Server se možná stále spouští, nebo byl zastaven. Tato stránka se bude neustále pokoušet o připojení a automaticky se obnoví, jakmile bude server znovu dostupný.",
|
||||
"embeddedServerPortInUse": "Port {{port}} je již používán jiným programem. Obvykle se jedná o Docker kontejner Termix nebo zbytkový proces Termix z předchozí relace. Zavřete jej a poté restartujte Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Jeden z portů, které Termix potřebuje, je již používán jiným programem. Zavřete aplikaci, která jej používá, a poté restartujte Termix.",
|
||||
"embeddedServerOldDatabase": "Vaše místní data pocházejí z Termix 2.8 nebo starší verze a musí být nejprve převedena verzí 2.9. Nainstalujte Termix 2.9.1, otevřete jej jednou, aby mohl přesunout vaše data, a poté aktualizujte znovu na tuto verzi.",
|
||||
@@ -1718,6 +1720,8 @@
|
||||
"pinAppRailDesc": "Nechat levý postranní panel aplikací vždy rozbalený místo rozbalení při najetí myší",
|
||||
"expandAppRailOnHover": "Rozbalit panel aplikací při najetí myší",
|
||||
"expandAppRailOnHoverDesc": "Povolit rozbalení levého postranního panelu aplikací při najetí ukazatelem",
|
||||
"appRailHoverPushesContent": "Posunout obsah při rozbalení",
|
||||
"appRailHoverPushesContentDesc": "Uvolnit místo pro rozbalený panel místo překrytí stránky. Otevřené relace, jako je vzdálená plocha, se při každém rozbalení přizpůsobí velikosti.",
|
||||
"showPinAppRailButton": "Zobrazit tlačítko pro připnutí panelu aplikací",
|
||||
"showPinAppRailButtonDesc": "Zobrazí tlačítko v panelu aplikací pro jeho připnutí. Ve výchozím nastavení vypnuto.",
|
||||
"settingsNavigation": "Navigace",
|
||||
@@ -2037,6 +2041,7 @@
|
||||
"syncEntities_other": "Synchronizace plochy"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Povolit {{name}}?",
|
||||
"installTitle": "Nainstalovat {{name}}",
|
||||
"updateTitle": "Aktualizovat {{name}}",
|
||||
"agreeUpdate": "Souhlasit a aktualizovat",
|
||||
|
||||
@@ -644,6 +644,8 @@
|
||||
"actionsClick": "Klik for at udvide/skjule",
|
||||
"openOnDoubleClick": "Åbn ved dobbeltklik",
|
||||
"openOnDoubleClickDesc": "Kræv et dobbeltklik for at åbne en værtssession i stedet for et enkelt klik",
|
||||
"closeOnOpen": "Skjul sidebjælken efter åbning",
|
||||
"closeOnOpenDesc": "Luk sidebjælken, når du åbner en vært fra den",
|
||||
"showFolderPaths": "Vis mappesstier",
|
||||
"showFolderPathsDesc": "Vis overordnede mappesstier over navne på indlejrede undermapper. Undermapper forbliver indrykket uanset hvad.",
|
||||
"rowDetailsTitle": "Radedetaljer",
|
||||
@@ -1097,7 +1099,7 @@
|
||||
"acceptNewKey": "Accepter ny nøgle & fortsæt"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Kunne ikke oprette forbindelse til databasen",
|
||||
"databaseConnection": "Termix-serveren svarer ikke",
|
||||
"embeddedServerFailed": "Termix' lokale server kunne ikke starte",
|
||||
"unknownError": "Ukendt fejl",
|
||||
"syncedHostMissing": "Den synkroniserede vært findes ikke på den linkede server",
|
||||
@@ -1124,7 +1126,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Du har ikke en Termix-konto endnu. Bed en administrator om at slå 'Auto-create external accounts' til.",
|
||||
"userNotAllowed": "Din konto er ikke autoriseret til at registrere. Kontakt venligst en administrator.",
|
||||
"databaseConnectionFailed": "Kunne ikke oprette forbindelse til databasens server",
|
||||
"databaseConnectionFailed": "Den starter muligvis stadig, eller den er stoppet. Denne side fortsætter med at prøve og genoptager automatisk, når serveren er tilbage.",
|
||||
"embeddedServerPortInUse": "Port {{port}} er allerede i brug af et andet program. Dette er typisk en Termix Docker-container eller en efterladt Termix-proces fra en tidligere session. Luk den, og genstart derefter Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "En af de porte, som Termix har brug for, er allerede i brug af et andet program. Luk det, der bruger den, og genstart derefter Termix.",
|
||||
"embeddedServerOldDatabase": "Dine lokale data stammer fra Termix 2.8 eller ældre og skal først migreres gennem version 2.9. Installer Termix 2.9.1, åbn den én gang, så den kan flytte dine data, og opdater derefter til denne version igen.",
|
||||
@@ -1672,6 +1674,8 @@
|
||||
"pinAppRailDesc": "Hold venstre app-stolpe altid udvidet i stedet for at udvide den ved hover",
|
||||
"expandAppRailOnHover": "Udvid app-stolpe ved hover",
|
||||
"expandAppRailOnHoverDesc": "Tillad, at venstre app-stolpe udvides, når markøren bevæger sig over den",
|
||||
"appRailHoverPushesContent": "Skub indholdet, når den udvides",
|
||||
"appRailHoverPushesContentDesc": "Gør plads til den udvidede sidebjælke i stedet for at åbne den over siden. Åbne sessioner, som f.eks. fjernskrivebord, ændrer størrelse hver gang",
|
||||
"showPinAppRailButton": "Vis knap til fastgørelse af app-stolpe",
|
||||
"showPinAppRailButtonDesc": "Vis en knap i app-stolpen til at fastgøre den åben. Slukket som standard.",
|
||||
"settingsNavigation": "Navigation",
|
||||
@@ -1967,6 +1971,7 @@
|
||||
"syncEntities_other": "Skrivebords-synkronisering"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Aktiver {{name}}?",
|
||||
"installTitle": "Installer {{name}}",
|
||||
"updateTitle": "Opdater {{name}}",
|
||||
"agreeUpdate": "Accepter og opdater",
|
||||
|
||||
@@ -644,6 +644,8 @@
|
||||
"actionsClick": "Zum Erweitern/Zuklappen klicken",
|
||||
"openOnDoubleClick": "Beim Doppelklick öffnen",
|
||||
"openOnDoubleClickDesc": "Erfordert einen Doppelklick zum Öffnen einer Host-Sitzung statt eines einzelnen Klicks",
|
||||
"closeOnOpen": "Seitenleiste nach dem Öffnen ausblenden",
|
||||
"closeOnOpenDesc": "Schließe die Seitenleiste, nachdem du einen Host daraus geöffnet hast",
|
||||
"showFolderPaths": "Verzeichnispfade anzeigen",
|
||||
"showFolderPathsDesc": "Zeigt den Pfad des übergeordneten Verzeichnisses über verschachtelten Unterverzeichnisnamen an. Unterverzeichnisse bleiben in jedem Fall eingerückt.",
|
||||
"rowDetailsTitle": "Zeilen-Details",
|
||||
@@ -1097,7 +1099,7 @@
|
||||
"acceptNewKey": "Neuen Schlüssel akzeptieren & Fortfahren"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Verbindung zur Datenbank fehlgeschlagen",
|
||||
"databaseConnection": "Der Termix-Server antwortet nicht",
|
||||
"embeddedServerFailed": "Der lokale Server von Termix konnte nicht gestartet werden",
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"syncedHostMissing": "Der synchronisierte Host existiert nicht auf dem verknüpften Server",
|
||||
@@ -1124,7 +1126,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Sie haben noch kein Termix-Konto. Bitten Sie einen Administrator, die automatische Erstellung externer Konten zu aktivieren.",
|
||||
"userNotAllowed": "Ihr Konto ist nicht zur Registrierung berechtigt. Bitte wenden Sie sich an einen Administrator.",
|
||||
"databaseConnectionFailed": "Verbindung zum Datenbankserver fehlgeschlagen",
|
||||
"databaseConnectionFailed": "Er startet möglicherweise noch oder wurde gestoppt. Diese Seite versucht es weiterhin und geht automatisch weiter, sobald der Server wieder verfügbar ist.",
|
||||
"embeddedServerPortInUse": "Der Port {{port}} wird bereits von einem anderen Programm verwendet. Dies ist in der Regel ein Termix-Docker-Container oder ein verbliebener Termix-Prozess aus einer früheren Sitzung. Schließen Sie dieses Programm und starten Sie Termix neu.",
|
||||
"embeddedServerPortInUseUnknownPort": "Einer der von Termix benötigten Ports wird bereits von einem anderen Programm verwendet. Schließen Sie das entsprechende Programm und starten Sie Termix neu.",
|
||||
"embeddedServerOldDatabase": "Ihre lokalen Daten stammen von Termix 2.8 oder älter und müssen zuerst durch Version 2.9 migriert werden. Installieren Sie Termix 2.9.1, öffnen Sie es einmal, damit Ihre Daten verschoben werden können, und aktualisieren Sie anschließend erneut auf diese Version.",
|
||||
@@ -1672,6 +1674,8 @@
|
||||
"pinAppRailDesc": "Halten Sie die linke App-Leiste der Seitenleiste immer erweitert, anstatt sie beim Hovern zu erweitern",
|
||||
"expandAppRailOnHover": "App-Leiste beim Hovern erweitern",
|
||||
"expandAppRailOnHoverDesc": "Erlauben Sie der linken App-Leiste der Seitenleiste, sich zu erweitern, wenn der Mauszeiger darüber bewegt wird",
|
||||
"appRailHoverPushesContent": "Inhalt verschieben, wenn sie sich ausdehnt",
|
||||
"appRailHoverPushesContentDesc": "Platz für die ausgeklappte Leiste schaffen, anstatt sie über die Seite zu legen. Offene Sitzungen, wie Remote Desktop, werden jedes Mal neu angepasst",
|
||||
"showPinAppRailButton": "Schaltfläche zum Anheften der App-Leiste anzeigen",
|
||||
"showPinAppRailButtonDesc": "Zeigt eine Schaltfläche in der App-Leiste zum Anheften an. Standardmäßig deaktiviert.",
|
||||
"settingsNavigation": "Navigation",
|
||||
@@ -1967,6 +1971,7 @@
|
||||
"syncEntities_other": "Desktop-Synchronisierung"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "{{name}} aktivieren?",
|
||||
"installTitle": "{{name}} installieren",
|
||||
"updateTitle": "{{name}} aktualisieren",
|
||||
"agreeUpdate": "Zustimmen und aktualisieren",
|
||||
|
||||
@@ -644,6 +644,8 @@
|
||||
"actionsClick": "Κλικ για ανάπτυξη/σύμπτυξη",
|
||||
"openOnDoubleClick": "Άνοιγμα με διπλό κλικ",
|
||||
"openOnDoubleClickDesc": "Απαιτείται διπλό κλικ για να ανοίξει μια συνεδρία host αντί για μονό κλικ",
|
||||
"closeOnOpen": "Απόκρυψη πλευρικής γραμμής μετά το άνοιγμα",
|
||||
"closeOnOpenDesc": "Κλείσιμο της πλευρικής γραμμής μετά το άνοιγμα ενός host από αυτήν",
|
||||
"showFolderPaths": "Εμφάνιση διαδρομών φακέλων",
|
||||
"showFolderPathsDesc": "Εμφάνιση της διαδρομής του γονικού φακέλου πάνω από τα ονόματα των υποφακέλων. Οι υποφάκελοι παραμένουν εσοχή σε κάθε περίπτωση.",
|
||||
"rowDetailsTitle": "Λεπτομέρειες γραμμής",
|
||||
@@ -1097,7 +1099,7 @@
|
||||
"acceptNewKey": "Αποδοχή νέου κλειδιού & Συνέχεια"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Αδυναμία σύνδεσης με τη βάση δεδομένων",
|
||||
"databaseConnection": "Ο διακομιστής Termix δεν ανταποκρίνεται",
|
||||
"embeddedServerFailed": "Ο τοπικός διακομιστής του Termix δεν μπόρεσε να ξεκινήσει",
|
||||
"unknownError": "Άγνωστο σφάλμα",
|
||||
"syncedHostMissing": "Ο συγχρονισμένος διακομιστής δεν υπάρχει στον συνδεδεμένο διακομιστή",
|
||||
@@ -1124,7 +1126,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Δεν έχετε ακόμη λογαριασμό Termix. Ζητήστε από έναν διαχειριστή να ενεργοποιήσει τη δημιουργία εξωτερικών λογαριασμών αυτόματα.",
|
||||
"userNotAllowed": "Ο λογαριασμός σας δεν είναι εξουσιοδοτημένος για εγγραφή. Παρακαλώ επικοινωνήστε με έναν διαχειριστή.",
|
||||
"databaseConnectionFailed": "Αποτυχία σύνδεσης με τον διακομιστή βάσης δεδομένων",
|
||||
"databaseConnectionFailed": "Ίσως να ξεκινά ακόμη ή να έχει σταματήσει. Αυτή η σελίδα συνεχίζει να προσπαθεί και θα επανέλθει αυτόματα μόλις ο διακομιστής είναι ξανά διαθέσιμος.",
|
||||
"embeddedServerPortInUse": "Η θύρα {{port}} χρησιμοποιείται ήδη από ένα άλλο πρόγραμμα. Συνήθως πρόκειται για ένα Docker container του Termix ή μια διαδικασία του Termix από προηγούμενη συνεδρία. Κλείστε το και επανεκκινήστε το Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Μία από τις θύρες που χρειάζεται το Termix χρησιμοποιείται ήδη από ένα άλλο πρόγραμμα. Κλείστε ό,τι τη χρησιμοποιεί και επανεκκινήστε το Termix.",
|
||||
"embeddedServerOldDatabase": "Τα τοπικά σας δεδομένα προέρχονται από την έκδοση Termix 2.8 ή παλαιότερη και πρέπει πρώτα να περάσουν από την έκδοση 2.9. Εγκαταστήστε την Termix 2.9.1, ανοίξτε την μία φορά ώστε να μετακινηθούν τα δεδομένα σας και στη συνέχεια ενημερώστε ξανά σε αυτή την έκδοση.",
|
||||
@@ -1672,6 +1674,8 @@
|
||||
"pinAppRailDesc": "Διατήρηση της αριστερής ράγας εφαρμογών πάντα αναπτυγμένη αντί να αναπτύσσεται με hover",
|
||||
"expandAppRailOnHover": "Ανάπτυξη Ράγας Εφαρμογών με Hover",
|
||||
"expandAppRailOnHoverDesc": "Επιτρέψτε στην αριστερή ράγα εφαρμογών να αναπτύσσεται όταν ο δείκτης περνά από πάνω της",
|
||||
"appRailHoverPushesContent": "Μετακίνηση περιεχομένου κατά την επέκταση",
|
||||
"appRailHoverPushesContentDesc": "Δημιουργία χώρου για την επεκταμένη γραμμή αντί να ανοίγει πάνω από τη σελίδα. Οι ανοιχτές συνεδρίες, όπως το απομακρυσμένο γραφείο, αλλάζουν μέγεθος κάθε φορά",
|
||||
"showPinAppRailButton": "Εμφάνιση Κουμπιού Καρφίωματος Ράγας Εφαρμογών",
|
||||
"showPinAppRailButtonDesc": "Εμφάνιση κουμπιού στη ράγα εφαρμογών για να την κρατά ανοιχτή. Ανενεργό από προεπιλογή.",
|
||||
"settingsNavigation": "Πλοήγηση",
|
||||
@@ -1967,6 +1971,7 @@
|
||||
"syncEntities_other": "Συγχρονισμός επιφάνειας εργασίας"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Ενεργοποίηση του {{name}};",
|
||||
"installTitle": "Εγκατάσταση {{name}}",
|
||||
"updateTitle": "Ενημέρωση {{name}}",
|
||||
"agreeUpdate": "Συμφωνώ και ενημερώνω",
|
||||
|
||||
@@ -650,6 +650,8 @@
|
||||
"actionsClick": "Hacer clic para expandir/contraer",
|
||||
"openOnDoubleClick": "Abrir al hacer doble clic",
|
||||
"openOnDoubleClickDesc": "Requiere un doble clic para abrir una sesión de host en lugar de un solo clic",
|
||||
"closeOnOpen": "Ocultar la barra lateral al abrir",
|
||||
"closeOnOpenDesc": "Cerrar la barra lateral después de abrir un host desde ella",
|
||||
"showFolderPaths": "Mostrar rutas de carpetas",
|
||||
"showFolderPathsDesc": "Muestra la ruta de la carpeta principal encima de los nombres de las subcarpetas anidadas. Las subcarpetas siguen estando sangradas de todos modos.",
|
||||
"rowDetailsTitle": "Detalles de la fila",
|
||||
@@ -1111,7 +1113,7 @@
|
||||
"acceptNewKey": "Aceptar nueva clave y continuar"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "No se pudo conectar a la base de datos",
|
||||
"databaseConnection": "El servidor de Termix no responde",
|
||||
"embeddedServerFailed": "El servidor local de Termix no pudo iniciarse",
|
||||
"unknownError": "Error desconocido",
|
||||
"syncedHostMissing": "El host sincronizado no existe en el servidor vinculado",
|
||||
@@ -1138,7 +1140,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Aún no tienes una cuenta de Termix. Pide a un administrador que active la opción «Crear cuentas externas automáticamente».",
|
||||
"userNotAllowed": "Tu cuenta no está autorizada para registrarse. Póngase en contacto con un administrador.",
|
||||
"databaseConnectionFailed": "Error al conectarse al servidor de base de datos",
|
||||
"databaseConnectionFailed": "Es posible que aún se esté iniciando o que se haya detenido. Esta página seguirá intentando reconectar y continuará automáticamente cuando el servidor vuelva a estar disponible.",
|
||||
"embeddedServerPortInUse": "El puerto {{port}} ya está en uso por otro programa. Por lo general, se trata de un contenedor Docker de Termix o de un proceso residual de Termix de una sesión anterior. Ciérrelo y reinicie Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "Uno de los puertos que necesita Termix ya está en uso por otro programa. Cierre lo que lo esté usando y reinicie Termix.",
|
||||
"embeddedServerOldDatabase": "Tus datos locales son de Termix 2.8 o una versión anterior y deben pasar primero por la versión 2.9. Instala Termix 2.9.1, ábrelo una vez para que pueda migrar tus datos y, a continuación, actualiza a esta versión.",
|
||||
@@ -1695,6 +1697,8 @@
|
||||
"pinAppRailDesc": "Mantén la barra lateral de aplicaciones izquierda siempre expandida en lugar de expandirla al pasar el cursor",
|
||||
"expandAppRailOnHover": "Expandir barra lateral al pasar el cursor",
|
||||
"expandAppRailOnHoverDesc": "Permite que la barra lateral de aplicaciones izquierda se expanda cuando el cursor pasa sobre ella",
|
||||
"appRailHoverPushesContent": "Desplazar el contenido al expandirse",
|
||||
"appRailHoverPushesContentDesc": "Dejar espacio para la barra expandida en lugar de superponerla sobre la página. Las sesiones abiertas, como el escritorio remoto, se redimensionarán cada vez.",
|
||||
"showPinAppRailButton": "Mostrar botón para fijar barra lateral",
|
||||
"showPinAppRailButtonDesc": "Muestra un botón en la barra lateral para fijarla abierta. Desactivado por defecto.",
|
||||
"settingsNavigation": "Navegación",
|
||||
@@ -2002,6 +2006,7 @@
|
||||
"syncEntities_other": "Sincronización de escritorio"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "¿Habilitar {{name}}?",
|
||||
"installTitle": "Instalar {{name}}",
|
||||
"updateTitle": "Actualizar {{name}}",
|
||||
"agreeUpdate": "Aceptar y actualizar",
|
||||
|
||||
@@ -644,6 +644,8 @@
|
||||
"actionsClick": "Klõpsa avamiseks/kokku kerimiseks",
|
||||
"openOnDoubleClick": "Ava topeltklõpsuga",
|
||||
"openOnDoubleClickDesc": "Nõua hosti sessiooni avamiseks topeltklõpsu ühe klõpsu asemel",
|
||||
"closeOnOpen": "Peida külgriba pärast avamist",
|
||||
"closeOnOpenDesc": "Sulge külgriba pärast hosti sellest avamist",
|
||||
"showFolderPaths": "Kuva kausta teed",
|
||||
"showFolderPathsDesc": "Kuva vanemkausta tee sisestatud alamkaustade nimede kohal. Alamkaustad on igal juhul sisse tõstetud.",
|
||||
"rowDetailsTitle": "Rea detailid",
|
||||
@@ -1097,7 +1099,7 @@
|
||||
"acceptNewKey": "Nõustu uue võtmega ja jätkamine"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Andmebaasiga ei õnnestunud ühendust võtta",
|
||||
"databaseConnection": "Termixi server ei vasta",
|
||||
"embeddedServerFailed": "Termixi kohalik server ei suutnud käivituda",
|
||||
"unknownError": "Tundmatu viga",
|
||||
"syncedHostMissing": "Sünkroonitud hosti ei eksisteeri linkitud serveris",
|
||||
@@ -1124,7 +1126,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Sul puudub Termixi konto. Palu administraatoril lubada väliskontode automaatne loomine.",
|
||||
"userNotAllowed": "Sinu kontol pole registreerumiseks õigusi. Palun võta ühendust administraatoriga.",
|
||||
"databaseConnectionFailed": "Andmebaasi serveriga ühendumine ebaõnnestus",
|
||||
"databaseConnectionFailed": "Server võib veel käivituda või olla peatunud. See leht proovib pidevalt uuesti ja jätkab tööd automaatselt, kui server on tagasi.",
|
||||
"embeddedServerPortInUse": "Porti {{port}} kasutab juba teine programm. Tavaliselt on tegemist Termixi Docker-i konteineriga või eelmisest seansist allesjäänud Termixi protsessiga. Sulge see ja käivita Termix uuesti.",
|
||||
"embeddedServerPortInUseUnknownPort": "Üht Termixi jaoks vajalikust portidest kasutab juba teine programm. Sulge see programm ja käivita Termix uuesti.",
|
||||
"embeddedServerOldDatabase": "Teie kohalikud andmed pärinevad Termixi versioonist 2.8 või vanemast ja tuleb esmalt läbida versioon 2.9. Paigaldage Termix 2.9.1, avage see üks kord, et andmed saaksid ümber paigutada, ja uuendage seejärel uuesti sellele versioonile.",
|
||||
@@ -1672,6 +1674,8 @@
|
||||
"pinAppRailDesc": "Hoia vasakut külgriba rakenduse rööpi alati lahtina, mitte ainult hiire kohal hõljutades",
|
||||
"expandAppRailOnHover": "Laienda rakenduse rööpi hiire kohal hõljutades",
|
||||
"expandAppRailOnHoverDesc": "Luba vasaku külgriba rakenduse rööbi laienemine, kui hiirekursor selle kohale liigub",
|
||||
"appRailHoverPushesContent": "Lükka sisu, kui see laieneb",
|
||||
"appRailHoverPushesContentDesc": "Tee ruumi laienenud ribale selle asemel, et see avaneks lehe peale. Avatud sessioonid, nagu kauglaud, muudavad oma suurust iga kord",
|
||||
"showPinAppRailButton": "Kuva rakenduse rööbi kinnitamise nupp",
|
||||
"showPinAppRailButtonDesc": "Kuva rakenduse rööbis nupp selle avatuks kinnitamiseks. Vaikimisi väljas.",
|
||||
"settingsNavigation": "Navigatsioon",
|
||||
@@ -1967,6 +1971,7 @@
|
||||
"syncEntities_other": "Töölaua sünkroonimine"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Kas lubada {{name}}?",
|
||||
"installTitle": "Paigalda {{name}}",
|
||||
"updateTitle": "Uuenda {{name}}",
|
||||
"agreeUpdate": "Nõustu ja uuenda",
|
||||
|
||||
@@ -644,6 +644,8 @@
|
||||
"actionsClick": "Laajenna/tiivistä napsauttamalla",
|
||||
"openOnDoubleClick": "Avaa kaksoisnapsauttamalla",
|
||||
"openOnDoubleClickDesc": "Vaadi kaksoisnapsautus isäntäistunnon avaamiseen yksittäisen napsautuksen sijaan",
|
||||
"closeOnOpen": "Piilota sivupalkki avaamisen jälkeen",
|
||||
"closeOnOpenDesc": "Sulje sivupalkki, kun avaat isäntäsen palvelimen siitä",
|
||||
"showFolderPaths": "Näytä kansioiden polut",
|
||||
"showFolderPathsDesc": "Näytä vanhemman kansion polku sisäkkäisten alikansioiden nimien yläpuolella. Alikansiot pysyvät sisennettyinä joka tapauksessa.",
|
||||
"rowDetailsTitle": "Rivin tiedot",
|
||||
@@ -1097,7 +1099,7 @@
|
||||
"acceptNewKey": "Hyväksy uusi avain ja jatka"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Yhteys tietokantaan epäonnistui",
|
||||
"databaseConnection": "Termix-palvelin ei vastaa",
|
||||
"embeddedServerFailed": "Termixin paikallinen palvelin ei voinut käynnistyä",
|
||||
"unknownError": "Tuntematon virhe",
|
||||
"syncedHostMissing": "Synkronoitu palvelin ei ole olemassa linkitettyä palvelinta",
|
||||
@@ -1124,7 +1126,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Sinulla ei vielä ole Termix-tiliä. Pyydä järjestelmänvalvojaa ottamaan käyttöön ulkoisten tilien automaattinen luonti.",
|
||||
"userNotAllowed": "Tiliäsi ei ole valtuutettu rekisteröitymään. Ota yhteyttä järjestelmänvalvojaan.",
|
||||
"databaseConnectionFailed": "Yhteys tietokantapalvelimeen epäonnistui",
|
||||
"databaseConnectionFailed": "Se saattaa olla vielä käynnistymässä tai se on pysähtynyt. Tämä sivu yrittää uudelleen ja jatkaa automaattisesti, kun palvelin on takaisin.",
|
||||
"embeddedServerPortInUse": "Porttia {{port}} käyttää jo toinen ohjelma. Kyseessä on yleensä Termixin Docker-kontti tai edellisestä istunnosta jäänyt Termix-prosessi. Sulje se ja käynnistä Termix uudelleen.",
|
||||
"embeddedServerPortInUseUnknownPort": "Termixin tarvitsema portti on jo toisen ohjelman käytössä. Sulje porttia käyttävä ohjelma ja käynnistä Termix uudelleen.",
|
||||
"embeddedServerOldDatabase": "Paikalliset tietosi ovat peräisin Termix 2.8 tai vanhemmasta versiosta, ja niiden on käyty ensin versiota 2.9. Asenna Termix 2.9.1, avaa se kerran, jotta se voi siirtää tietosi, ja päivitä sitten uudelleen tähän versioon.",
|
||||
@@ -1672,6 +1674,8 @@
|
||||
"pinAppRailDesc": "Pidä vasen sovellusraide aina laajennettuna sen sijaan, että se laajenee hiiren vierityksellä",
|
||||
"expandAppRailOnHover": "Laajenna sovellusraide hiiren vierityksellä",
|
||||
"expandAppRailOnHoverDesc": "Salli vasemman sovellusraiteen laajentuminen, kun osoitin liikkuu sen päällä",
|
||||
"appRailHoverPushesContent": "Työnnä sisältöä, kun se laajenee",
|
||||
"appRailHoverPushesContentDesc": "Varaa tilaa laajennetulle palkille sen sijaan, että se avautuisi sivun päälle. Avoinna olevat istunnot, kuten etätyöpöytä, muuttavat kokoaan joka kerta",
|
||||
"showPinAppRailButton": "Näytä sovellusraiteen kiinnitys-painike",
|
||||
"showPinAppRailButtonDesc": "Näytä painike sovellusraiteessa sen kiinnittämiseksi auki. Oletuksena pois päältä.",
|
||||
"settingsNavigation": "Navigointi",
|
||||
@@ -1967,6 +1971,7 @@
|
||||
"syncEntities_other": "Työpöydän synkronointi"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Haluatko ottaa {{name}} käyttöön?",
|
||||
"installTitle": "Asenna {{name}}",
|
||||
"updateTitle": "Päivitä {{name}}",
|
||||
"agreeUpdate": "Hyväksy ja päivitä",
|
||||
|
||||
@@ -650,6 +650,8 @@
|
||||
"actionsClick": "Cliquer pour développer/réduire",
|
||||
"openOnDoubleClick": "Ouvrir au double-clic",
|
||||
"openOnDoubleClickDesc": "Exiger un double-clic pour ouvrir une session d'hôte au lieu d'un clic simple",
|
||||
"closeOnOpen": "Masquer la barre latérale après l'ouverture",
|
||||
"closeOnOpenDesc": "Fermer la barre latérale après avoir ouvert un hôte depuis celle-ci",
|
||||
"showFolderPaths": "Afficher les chemins des dossiers",
|
||||
"showFolderPathsDesc": "Afficher le chemin du dossier parent au-dessus des noms des sous-dossiers imbriqués. Les sous-dossiers restent indentés dans les deux cas.",
|
||||
"rowDetailsTitle": "Détails de la ligne",
|
||||
@@ -1111,7 +1113,7 @@
|
||||
"acceptNewKey": "Accepter la nouvelle clé et continuer"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "Impossible de se connecter à la base de données",
|
||||
"databaseConnection": "Le serveur Termix ne répond pas",
|
||||
"embeddedServerFailed": "Le serveur local de Termix n'a pas pu démarrer",
|
||||
"unknownError": "Erreur inconnue",
|
||||
"syncedHostMissing": "L'hôte synchronisé n'existe pas sur le serveur lié",
|
||||
@@ -1138,7 +1140,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "Vous n'avez pas encore de compte Termix. Demandez à un administrateur d'activer la création automatique de comptes externes.",
|
||||
"userNotAllowed": "Votre compte n'est pas autorisé à s'inscrire. Veuillez contacter un administrateur.",
|
||||
"databaseConnectionFailed": "Échec de la connexion au serveur de base de données",
|
||||
"databaseConnectionFailed": "Il est peut-être encore en cours de démarrage, ou il s'est arrêté. Cette page continue d'essayer et reprend automatiquement une fois le serveur de retour.",
|
||||
"embeddedServerPortInUse": "Le port {{port}} est déjà utilisé par un autre programme. Il s'agit généralement d'un conteneur Docker Termix ou d'un processus Termix résidant d'une session précédente. Fermez-le, puis redémarrez Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "L'un des ports requis par Termix est déjà utilisé par un autre programme. Fermez l'application qui l'utilise, puis redémarrez Termix.",
|
||||
"embeddedServerOldDatabase": "Vos données locales proviennent de Termix 2.8 ou d'une version antérieure et doivent d'abord être migrées vers la version 2.9. Installez Termix 2.9.1, ouvrez-le une fois pour permettre la migration de vos données, puis mettez à jour vers cette version.",
|
||||
@@ -1695,6 +1697,8 @@
|
||||
"pinAppRailDesc": "Garder la barre d'applications de la barre latérale gauche toujours déployée au lieu de la déployer au survol",
|
||||
"expandAppRailOnHover": "Déployer la barre d'applications au survol",
|
||||
"expandAppRailOnHoverDesc": "Permettre à la barre d'applications de la barre latérale gauche de se déployer lorsque le pointeur passe dessus",
|
||||
"appRailHoverPushesContent": "Déplacer le contenu lors de l'expansion",
|
||||
"appRailHoverPushesContentDesc": "Faire de la place pour la barre latérale élargie au lieu de l'afficher par-dessus la page. Les sessions ouvertes, comme le bureau à distance, sont redimensionnées à chaque fois.",
|
||||
"showPinAppRailButton": "Afficher le bouton d'épinglage de la barre d'applications",
|
||||
"showPinAppRailButtonDesc": "Affiche un bouton dans la barre d'applications pour l'épingler ouverte. Désactivé par défaut.",
|
||||
"settingsNavigation": "Navigation",
|
||||
@@ -2002,6 +2006,7 @@
|
||||
"syncEntities_other": "Synchronisation du bureau"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "Activer {{name}} ?",
|
||||
"installTitle": "Installer {{name}}",
|
||||
"updateTitle": "Mettre à jour {{name}}",
|
||||
"agreeUpdate": "Accepter et mettre à jour",
|
||||
|
||||
@@ -650,6 +650,8 @@
|
||||
"actionsClick": "לחץ להרחבה/כיווץ",
|
||||
"openOnDoubleClick": "פתח בלחיצה כפולה",
|
||||
"openOnDoubleClickDesc": "דרוש לחיצה כפולה כדי לפתוח פעילות מארח במקום לחיצה בודדת",
|
||||
"closeOnOpen": "הסתר סרגל צד לאחר פתיחה",
|
||||
"closeOnOpenDesc": "סגור את סרגל הצד לאחר פתיחת מארח ממנו",
|
||||
"showFolderPaths": "הצג נתיבי תיקייה",
|
||||
"showFolderPathsDesc": "הצג את נתיב התיקייה האם מעל שמות תתי-תיקיות מקוננות. תתי-תיקיות נשארות מושרשות בכל מקרה.",
|
||||
"rowDetailsTitle": "פרטי שורה",
|
||||
@@ -1111,7 +1113,7 @@
|
||||
"acceptNewKey": "קבל מפתח חדש והמשך"
|
||||
},
|
||||
"errors": {
|
||||
"databaseConnection": "לא ניתן להתחבר למסד הנתונים",
|
||||
"databaseConnection": "שרת Termix אינו מגיב",
|
||||
"embeddedServerFailed": "שרת Termix המקומי לא הצליח להתחיל",
|
||||
"unknownError": "שגיאה לא ידועה",
|
||||
"syncedHostMissing": "המארח המסונכרן אינו קיים בשרת המקושר",
|
||||
@@ -1138,7 +1140,7 @@
|
||||
"messages": {
|
||||
"registrationDisabled": "אין לך עדיין חשבון ב-Termix. בקש ממנהל מערכת להפעיל את האפשרות 'צור חשבונות חיצוניים אוטומטית'.",
|
||||
"userNotAllowed": "החשבון שלך אינו מורשה להרשמה. אנא פנה למנהל המערכת.",
|
||||
"databaseConnectionFailed": "החיבור לשרת המסד נתונים נכשל",
|
||||
"databaseConnectionFailed": "ייתכן שהוא עדיין מתחיל להפעיל, או שהופסק. דף זה ממשיך לנסות וממשיך באופן אוטומטי ברגע שהשרת חוזר.",
|
||||
"embeddedServerPortInUse": "הפורט {{port}} כבר בשימוש על ידי תוכנית אחרת. לרוב מדובר במיכל Docker של Termix או בתהליך Termix שנותר מהפעלה קודמת. סגור אותו, ואז הפעל מחדש את Termix.",
|
||||
"embeddedServerPortInUseUnknownPort": "אחד מהפורטים ש-Termix זקוק להם כבר בשימוש על ידי תוכנית אחרת. סגור את מה שמשתמש בו, ואז הפעל מחדש את Termix.",
|
||||
"embeddedServerOldDatabase": "הנתונים המקומיים שלך הם מ-Termix 2.8 או קודם יותר, ועליהם לעבור דרך גרסה 2.9 תחילה. התקן את Termix 2.9.1, פתח אותו פעם אחת כדי שיוכל להעביר את הנתונים שלך, ואז עדכן שוב לגרסה זו.",
|
||||
@@ -1695,6 +1697,8 @@
|
||||
"pinAppRailDesc": "שמור על רצועת היישומים בצד שמאל תמיד מורחבת במקום להרחיב רק בעת מעבר עכבר",
|
||||
"expandAppRailOnHover": "הרחב רצועת יישומים בעת מעבר עכבר",
|
||||
"expandAppRailOnHoverDesc": "אפשר להרחיב את רצועת היישומים בצד שמאל כאשר הסמן עובר מעליה",
|
||||
"appRailHoverPushesContent": "דחוף תוכן בעת הרחבה",
|
||||
"appRailHoverPushesContentDesc": "השאר מקום לסרגל המורחב במקום לפתוח אותו מעל הדף. סשנים פתוחים, כמו שולחן עבודה מרוחק, משנים גודל בכל פעם",
|
||||
"showPinAppRailButton": "הצג כפתור עיגון רצועת יישומים",
|
||||
"showPinAppRailButtonDesc": "הצג כפתור ברצועת היישומים כדי לעגן אותה פתוחה. כבוי כברירת מחדל.",
|
||||
"settingsNavigation": "ניווט",
|
||||
@@ -2002,6 +2006,7 @@
|
||||
"syncEntities_other": "סנכרון שולחן עבודה"
|
||||
},
|
||||
"consent": {
|
||||
"enableTitle": "להפעיל את {{name}}?",
|
||||
"installTitle": "התקן את {{name}}",
|
||||
"updateTitle": "עדכן את {{name}}",
|
||||
"agreeUpdate": "סכים ומעדכן",
|
||||
|
||||
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user