* feat: let the login page send the token back to the mobile app

* feat: add host-scripts entry to the plugin sdk

* feat: let plugins say how they show in the mobile app

* feat: frame host for running one plugin view outside the shell

* fix: build the SDK host modules in production mode

* chore: note the SDK host build fix in the changelog

* feat: list plugin engine ranges for the mobile app

* feat: host color and icon columns that sync

* chore: update translations and plugin pins

* fix: test

* fix: show source address when rejecting proxy auth

* fix: deleting your own account removes all of its data

* fix(deps): update body-parser to 2.4.0 (#1736)

* fix: body-parser 보안 패치 반영

* chore: 자동 검사를 막던 기존 JSON 서식 정리

---------

Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>

* fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735)

* fix: honor shared credentials on shared hosts (#1730)

* fix: honor shared credentials on shared hosts and in personal SSH auth

* fix: snapshot shared SSH auth from a credential shared with the host owner

* fix: size DataView list columns by the table's own width (#1713)

* fix: size DataView list columns by the table's own width

The list grid was built from every column, so columns hidden below md/lg
left empty tracks and the visible cells landed under the wrong headers.
Each width now gets a template of only its visible columns (CSS variables
+ container queries on the table, so a narrow panel counts, not the window),
the header uses the same gap as the rows, and rows use a minimum height so
wrapped cells no longer overlap.

* test: wait for the title bar offset effect before asserting

The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height
is set in a later passive effect, so reading it right after findByText raced
(3-5 of 40 runs under load, on main too).

* chore: rerun CI against dev-26.10.6

* fix: unlock passphrase keys once per connect burst (#1737)

* feat: give ssh transports the host origin and sync id (#1717)

* fix: wake other clients after a sync push (Plugin-Sync#1)

* feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4)

* feat: rail items can show a status dot (Plugin-Sync#5)

* fix: add the desktop shared copy banner text (#1709)

* feat: sidebar preference for alternating rows (#1712)

* fix: striped rows text

* feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36)

* fix: show the logo in the macos menu bar (#1728)

* fix: number shortcuts skip the home tab (#1724)

* fix: show plugins that could not be downloaded as failed (#1714)

* fix: pass the host id as text when opening its editor

* fix: retry a plugin bundle that was not ready yet (#1708)

* fix: let commonjs plugins fall back to core packages on the desktop

* fix: let non docker installs switch nginx to https (#1711)

* feat: remove a folder share from its hosts too (#1719)

* chore: changelog for merged pull requests

* fix: accept the release .sig when installing a plugin from a file

* feat: folder logins for shared hosts, read only plugin sync, full desktop builds

* fix: test

* fix: stop dev runner restart loops and narrow plugin watchers

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.6

---------

Co-authored-by: renovys <renovy@gmail.com>
Co-authored-by: renovys <180682734+renovys@users.noreply.github.com>
Co-authored-by: Sebastian Cao <cycsmail@gmail.com>
Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com>
Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-29 17:59:55 -05:00
1 parent 73c15f7c58
commit f90621a54e
168 files changed
+35063 -339

No files matched your search

+15
View File
@@ -484,6 +484,19 @@ async function initializeCompleteDatabase(): Promise<void> {
);
CREATE INDEX IF NOT EXISTS idx_folder_access_owner_folder ON folder_access (owner_user_id, folder);
CREATE TABLE IF NOT EXISTS shared_folder_auth_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
folder TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'ssh',
credential_id INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS shared_folder_auth_overrides_user_folder_protocol_unique ON shared_folder_auth_overrides (user_id, folder, protocol);
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -1316,6 +1329,8 @@ const migrateSchema = () => {
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
];
for (const migration of sshDataMigrations) {
+35
View File
@@ -193,6 +193,9 @@ export const hosts = mysqlTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: int("ssh_port").default(22),
@@ -487,6 +490,38 @@ export const sharedHostAuthOverrides = mysqlTable(
],
);
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = mysqlTable(
"shared_folder_auth_overrides",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: varchar("folder", { length: 255 }).notNull(),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = mysqlTable(
"shared_host_secrets",
{
+35
View File
@@ -193,6 +193,9 @@ export const hosts = pgTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: integer("ssh_port").default(22),
@@ -487,6 +490,38 @@ export const sharedHostAuthOverrides = pgTable(
],
);
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = pgTable(
"shared_folder_auth_overrides",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: varchar("folder", { length: 255 }).notNull(),
protocol: varchar("protocol", { length: 255 }).notNull().default("ssh"),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = pgTable(
"shared_host_secrets",
{
+35
View File
@@ -183,6 +183,9 @@ export const hosts = sqliteTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
sshPort: integer("ssh_port").default(22),
@@ -477,6 +480,38 @@ export const sharedHostAuthOverrides = sqliteTable(
],
);
/**
* A recipient's own login for the shared hosts in a folder ("Parent / Child")
* and its subfolders. A host's own override still wins.
*/
export const sharedFolderAuthOverrides = sqliteTable(
"shared_folder_auth_overrides",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
folder: text("folder").notNull(),
protocol: text("protocol").notNull().default("ssh"),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("shared_folder_auth_overrides_user_folder_protocol_unique").on(
table.userId,
table.folder,
table.protocol,
),
],
);
export const sharedHostSecrets = sqliteTable(
"shared_host_secrets",
{
@@ -31,6 +31,7 @@ import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedFolderAuthOverrideRepository } from "./shared-folder-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
@@ -316,6 +317,15 @@ export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthO
);
}
export function createCurrentSharedFolderAuthOverrideRepository(): SharedFolderAuthOverrideRepository {
return new SharedFolderAuthOverrideRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"shared_folder_auth_override_repository_write",
),
);
}
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
return new SshCredentialUsageRepository(
createCurrentRepositoryContext(),
@@ -0,0 +1,115 @@
import { and, eq, inArray } from "drizzle-orm";
import type { AuthOverrideProtocol } from "../../../types/auth-protocols.js";
import { sharedFolderAuthOverrides } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { deleteReturning, upsert } from "./returning.js";
/** "A / B / C" and the folders above it, closest first. */
export function folderAndParents(folder: string | null | undefined): string[] {
const parts = (folder ?? "")
.split(" / ")
.map((part) => part.trim())
.filter(Boolean);
const out: string[] = [];
for (let i = parts.length; i > 0; i--) {
out.push(parts.slice(0, i).join(" / "));
}
return out;
}
export class SharedFolderAuthOverrideRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findCredentialId(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
): Promise<number | null> {
const rows = await this.context.drizzle
.select({ credentialId: sharedFolderAuthOverrides.credentialId })
.from(sharedFolderAuthOverrides)
.where(
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.folder, folder),
eq(sharedFolderAuthOverrides.protocol, protocol),
),
)
.limit(1);
return rows[0]?.credentialId ?? null;
}
/**
* The login set on the host's folder or the closest folder above it, for
* one protocol. Null when none is set.
*/
async findClosest(
userId: string,
hostFolder: string | null | undefined,
protocol: AuthOverrideProtocol,
): Promise<{ folder: string; credentialId: number } | null> {
const folders = folderAndParents(hostFolder);
if (folders.length === 0) return null;
const rows = await this.context.drizzle
.select({
folder: sharedFolderAuthOverrides.folder,
credentialId: sharedFolderAuthOverrides.credentialId,
})
.from(sharedFolderAuthOverrides)
.where(
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.protocol, protocol),
inArray(sharedFolderAuthOverrides.folder, folders),
),
);
for (const folder of folders) {
const row = rows.find((candidate) => candidate.folder === folder);
if (row) return row;
}
return null;
}
async setCredential(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
credentialId: number,
): Promise<void> {
await upsert(
this.context,
sharedFolderAuthOverrides,
{ userId, folder, protocol, credentialId },
{
target: [
sharedFolderAuthOverrides.userId,
sharedFolderAuthOverrides.folder,
sharedFolderAuthOverrides.protocol,
],
set: { credentialId, updatedAt: new Date().toISOString() },
},
);
await this.onWrite?.();
}
async clearCredential(
userId: string,
folder: string,
protocol: AuthOverrideProtocol,
): Promise<boolean> {
const rows = await deleteReturning(
this.context,
sharedFolderAuthOverrides,
and(
eq(sharedFolderAuthOverrides.userId, userId),
eq(sharedFolderAuthOverrides.folder, folder),
eq(sharedFolderAuthOverrides.protocol, protocol),
),
);
if (rows.length > 0) await this.onWrite?.();
return rows.length > 0;
}
}
@@ -30,6 +30,33 @@ export function applyHostKeyTypeUpdate(
}
}
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
/**
* Color and icon from a host write. A field left out stays as stored, so
* editors that don't know about them never clear them. Bad values store null.
*/
export function normalizeHostLookFields(values: Record<string, unknown>): {
color?: string | null;
icon?: string | null;
} {
const result: { color?: string | null; icon?: string | null } = {};
if (values.color !== undefined) {
result.color =
typeof values.color === "string" && HOST_COLOR.test(values.color)
? values.color.toLowerCase()
: null;
}
if (values.icon !== undefined) {
result.icon =
typeof values.icon === "string" && HOST_ICON.test(values.icon)
? values.icon
: null;
}
return result;
}
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
export function normalizeProtocolEnableFields(
+3
View File
@@ -33,6 +33,7 @@ import {
isOptionalAuthType,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeProtocolEnableFields,
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS,
@@ -335,6 +336,7 @@ router.post(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
@@ -912,6 +914,7 @@ router.put(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
+23 -2
View File
@@ -244,7 +244,9 @@ export async function listPreLoginPlugins(): Promise<
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
* it, and `locales` lists the languages it ships ("en" and xx_YY).
* `dependencies` and `optionalDependencies` let the loader activate
* frontends in the same order the server does.
* frontends in the same order the server does. `engine` holds the
* manifest's `api` and `termix` ranges, so other clients (the mobile
* app) can tell whether they understand the plugin.
*
* On a desktop linked to a server, `runsOnServer` marks plugins that
* only run on that server and stay off here on purpose, and, for
@@ -303,6 +305,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let features: string[] = [];
let docs: string | undefined;
let env: PluginEnvVar[] = [];
const engine: { api?: string; termix?: string } = {};
let desktopMode: unknown = loaded
? resolvePluginDesktopMode(loaded.manifest)
: undefined;
@@ -322,7 +325,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
optionalDependencies?: Record<string, string>;
desktop?: unknown;
runtime?: unknown;
engine?: { api?: unknown; termix?: unknown };
};
if (typeof manifest?.engine?.api === "string") {
engine.api = manifest.engine.api;
}
if (typeof manifest?.engine?.termix === "string") {
engine.termix = manifest.engine.termix;
}
desktopMode = loaded
? resolvePluginDesktopMode(loaded.manifest)
: (manifest?.desktop ??
@@ -365,6 +375,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
? "safe-mode"
: (loaded?.state ?? record.state),
runsOnServer: linked && desktopMode === "server",
engine,
contributes,
icon,
dependencies,
@@ -2695,6 +2706,10 @@ router.put(
* summary: Upload a .tmxplug to install from a file
* description: >
* Developer mode only. The body is the raw .tmxplug (at most 50 MB).
The release's .sig may come base64 encoded in the
X-Termix-Plugin-Signature header; an official one makes the file
install as an official plugin, and it may then fill in a plugin
whose download failed.
* The file is checked (archive, manifest, plugin API) and held for 30
* minutes, but nothing is loaded. The answer lists what it asks for so
* the admin can agree, then POST /plugins/upload/{token}/install
@@ -2740,7 +2755,13 @@ router.post(
}
try {
const { stageUpload } = await import("../../plugins/manage.js");
res.json(await stageUpload(req.body));
// The release's .sig, base64 encoded, so an official file installs as
// official (and may write the tables its 2.8 version left).
const header = req.get("x-termix-plugin-signature");
const signature = header
? Buffer.from(header, "base64").toString("utf8")
: null;
res.json(await stageUpload(req.body, signature));
} catch (error) {
sendManageError(
res,
+209 -3
View File
@@ -11,6 +11,7 @@ import {
import { isAuthOverrideProtocol } from "../../hosts/protocol-auth/registry.js";
import {
assertHostsUnlocked,
hostLock,
hasHostLocks,
requireUnlockedHost,
sendHostLocked,
@@ -36,8 +37,10 @@ import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSharedFolderAuthOverrideRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import { findUsableCredential } from "../../hosts/usable-credential.js";
const router = express.Router();
@@ -722,14 +725,40 @@ router.get(
},
);
/** The host grants that went to the same user or role as a folder rule. */
export function grantsForFolderRule<
T extends { userId?: string | null; roleId?: number | null },
>(entries: T[], rule: { userId?: string | null; roleId?: number | null }): T[] {
return entries.filter((entry) =>
rule.userId
? entry.userId === rule.userId
: rule.roleId != null && entry.roleId === rule.roleId,
);
}
/**
* @openapi
* /rbac/folder/access/{id}:
* delete:
* summary: Stop a folder share from applying to hosts added later
* description: Grants already fanned out to hosts stay; revoke those per host.
* description: Grants already fanned out to hosts stay, unless fromHosts=true, which also removes the same user's or role's access from every host in the folder and its subfolders that the caller may manage sharing on.
* tags:
* - RBAC
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* - in: query
* name: fromHosts
* schema:
* type: boolean
* responses:
* 200:
* description: Removed. removedFromHosts counts the host grants removed.
* 404:
* description: No such rule.
*/
router.delete(
"/folder/access/:id",
@@ -741,11 +770,35 @@ router.delete(
}
try {
const repository = createCurrentFolderAccessRepository();
if (!(await repository.findById(ruleId, req.userId!))) {
const rule = await repository.findById(ruleId, req.userId!);
if (!rule) {
return res.status(404).json({ error: "Rule not found" });
}
await repository.deleteById(ruleId, req.userId!);
res.json({ success: true });
let removedFromHosts = 0;
if (req.query.fromHosts === "true") {
const hosts =
await createCurrentHostFolderRepository().listHostsInFolder(
req.userId!,
rule.folder,
);
const access = createCurrentRbacAccessRepository();
for (const host of hosts) {
if (hostLock(host.id)) continue;
if (!(await canManageHostSharing(req.userId!, host.id)).allowed)
continue;
let changed = false;
const entries = await access.listHostAccess(host.id);
for (const entry of grantsForFolderRule(entries, rule)) {
await access.revokeHostAccess(entry.id, host.id);
removedFromHosts++;
changed = true;
}
if (changed) await pruneHostGrants(host.id);
}
}
res.json({ success: true, removedFromHosts });
} catch (error) {
databaseLogger.error("Failed to delete folder access rule", error, {
operation: "delete_folder_access",
@@ -2174,4 +2227,157 @@ router.get(
},
);
/** A folder path as hosts store it ("Parent / Child"), or null. */
function folderParam(value: unknown): string | null {
if (typeof value !== "string") return null;
const folder = value
.split(" / ")
.map((part) => part.trim())
.filter(Boolean)
.join(" / ");
return folder && folder.length <= 255 ? folder : null;
}
/**
* @openapi
* /rbac/folder-auth/{protocol}:
* get:
* summary: Get the current user's personal login for shared hosts in a folder
* tags: [RBAC]
* parameters:
* - in: path
* name: protocol
* required: true
* schema: { type: string }
* - in: query
* name: folder
* required: true
* schema: { type: string }
* responses:
* 200:
* description: The folder, protocol and credential id, or null when none is set.
* 400:
* description: Invalid folder or protocol.
*/
router.get(
"/folder-auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const protocol = req.params.protocol;
const folder = folderParam(req.query.folder);
if (!folder) return res.status(400).json({ error: "Invalid folder" });
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
const credentialId =
await createCurrentSharedFolderAuthOverrideRepository().findCredentialId(
userId,
folder,
protocol,
);
res.json({ folder, protocol, credentialId });
} catch (error) {
databaseLogger.error("Failed to get folder login", error);
res.status(500).json({ error: "Failed to fetch credential" });
}
},
);
/**
* @openapi
* /rbac/folder-auth/{protocol}:
* put:
* summary: Set the current user's personal login for shared hosts in a folder
* description: >
* Used for every host shared with the user in this folder and its
* subfolders that has no personal login of its own. credentialId null
* clears it.
* tags: [RBAC]
* parameters:
* - in: path
* name: protocol
* required: true
* schema: { type: string }
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [folder, credentialId]
* properties:
* folder: { type: string }
* credentialId: { type: integer, nullable: true }
* responses:
* 200:
* description: Saved.
* 400:
* description: Invalid folder, protocol or credential id.
* 404:
* description: The credential is not one the user can use.
*/
router.put(
"/folder-auth/:protocol",
authenticateJWT,
requireDataAccess,
async (req: express.Request, res: express.Response) => {
try {
const userId = (req as AuthenticatedRequest).userId!;
const protocol = req.params.protocol;
const folder = folderParam(req.body?.folder);
const credentialId = req.body?.credentialId ?? null;
if (!folder) return res.status(400).json({ error: "Invalid folder" });
if (!isAuthOverrideProtocol(protocol)) {
return res
.status(400)
.json({ error: "Invalid authentication protocol" });
}
if (
credentialId !== null &&
(!Number.isInteger(credentialId) || credentialId <= 0)
) {
return res.status(400).json({
error: "credentialId must be a positive integer or null",
});
}
const repository = createCurrentSharedFolderAuthOverrideRepository();
if (credentialId === null) {
await repository.clearCredential(userId, folder, protocol);
} else {
if (!(await findUsableCredential(credentialId, userId))) {
return res.status(404).json({ error: "Credential not found" });
}
await repository.setCredential(userId, folder, protocol, credentialId);
}
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: await getAuditUsername(userId),
action:
credentialId === null
? "clear_shared_folder_auth_override"
: "set_shared_folder_auth_override",
resourceType: "folder",
resourceName: folder,
details: JSON.stringify({ protocol, credentialId }),
ipAddress,
userAgent,
success: true,
}).catch(() => undefined);
res.json({ success: true, folder, protocol, credentialId });
} catch (error) {
databaseLogger.error("Failed to set folder login", error);
res.status(500).json({ error: "Failed to update credential" });
}
},
);
export default router;
@@ -0,0 +1,95 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import bcrypt from "bcryptjs";
import { authLogger } from "../../utils/logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
export function registerUserDeleteAccountRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account and all of their data.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body ?? {};
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRepository = createCurrentUserRepository();
const userRecord = await userRepository.findById(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin && (await userRepository.countAdmins()) <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
// Same cleanup as an admin delete, so plugins hear user.deleted too.
await deleteUserAndRelatedData(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
}
+3 -81
View File
@@ -31,6 +31,7 @@ import { shownIdentity } from "../../sync/shown-identity.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
import { registerUserDeleteAccountRoutes } from "./user-delete-account-routes.js";
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
@@ -318,7 +319,7 @@ router.post("/proxy-login", async (req, res) => {
try {
if (!isTrustedProxyAddress(sourceAddress, config.trustedProxies)) {
authLogger.warn(
"Rejected proxy authentication from an untrusted source",
`Rejected proxy authentication from an untrusted source: ${sourceAddress ?? "unknown"}`,
{
operation: "trusted_proxy_auth_rejected",
sourceAddress,
@@ -1229,86 +1230,7 @@ router.patch("/password-reset-allowed", authenticateJWT, async (req, res) => {
}
});
/**
* @openapi
* /users/delete-account:
* delete:
* summary: Delete user account
* description: Deletes the authenticated user's account.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* password:
* type: string
* responses:
* 200:
* description: Account deleted successfully.
* 400:
* description: Password is required.
* 401:
* description: Incorrect password.
* 403:
* description: Cannot delete external authentication accounts or the last admin user.
* 404:
* description: User not found.
* 500:
* description: Failed to delete account.
*/
router.delete("/delete-account", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { password } = req.body;
if (!isNonEmptyString(password)) {
return res
.status(400)
.json({ error: "Password is required to delete account" });
}
try {
const userRecord = await findCurrentUser(userId);
if (!userRecord) {
return res.status(404).json({ error: "User not found" });
}
if (userRecord.isExternal) {
return res.status(403).json({
error:
"Cannot delete external authentication accounts through this endpoint",
});
}
const isMatch = await bcrypt.compare(password, userRecord.passwordHash);
if (!isMatch) {
authLogger.warn(
`Incorrect password provided for account deletion: ${userRecord.username}`,
);
return res.status(401).json({ error: "Incorrect password" });
}
if (userRecord.isAdmin) {
const adminCount = await createCurrentUserRepository().countAdmins();
if (adminCount <= 1) {
return res
.status(403)
.json({ error: "Cannot delete the last admin user" });
}
}
await createCurrentUserRepository().delete(userId);
authLogger.success(`User account deleted: ${userRecord.username}`);
res.json({ message: "Account deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete user account", err);
res.status(500).json({ error: "Failed to delete account" });
}
});
registerUserDeleteAccountRoutes(router, authenticateJWT);
registerUserPasswordResetRoutes(router, { authManager });
@@ -7,7 +7,10 @@
*/
import type { Duplex } from "node:stream";
import type { PluginSshTransportProvider } from "@termix-ssh/plugin-sdk/backend";
import type {
PluginSshTransportHost,
PluginSshTransportProvider,
} from "@termix-ssh/plugin-sdk/backend";
interface Entry {
pluginId: string;
@@ -33,15 +36,13 @@ export function clearSshTransports(): void {
}
/** Opens a stream to the host if a provider claims it, else null. */
export async function openProvidedTransport(host: {
id: number;
ip: string;
port: number;
}): Promise<Duplex | null> {
export async function openProvidedTransport(
host: PluginSshTransportHost,
): Promise<Duplex | null> {
for (const { provider } of providers.values()) {
let claimed = false;
try {
claimed = provider.claims(host.id);
claimed = provider.claims(host.id, host);
} catch {
claimed = false;
}
+4
View File
@@ -99,6 +99,10 @@ export async function openSshTransport(
id: host.id,
ip: host.ip,
port: host.port || 22,
userId: host.userId ?? null,
syncId: host.syncId ?? null,
connectionOrigin: host.connectionOrigin ?? null,
localOnly: Boolean(host.localOnly),
});
if (provided) {
config.sock = provided;
+3
View File
@@ -24,6 +24,9 @@ export interface SshConnectHost {
port: number;
username: string;
userId?: string | null;
syncId?: string | null;
connectionOrigin?: "local" | "remote" | null;
localOnly?: boolean | null;
authType?: string | null;
password?: string | null;
key?: string | Buffer | null;
+2
View File
@@ -73,6 +73,8 @@ async function exportRow(
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
color: resolvedHost.color || null,
icon: resolvedHost.icon || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: pluginSettings.get(host.id as number) ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
+2
View File
@@ -28,6 +28,7 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
import {
isNonEmptyString,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
} from "../../database/routes/host-normalizers.js";
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
@@ -571,6 +572,7 @@ export async function runBulkImport(
? "true"
: "false",
notes: hostData.notes || null,
...normalizeHostLookFields(hostData as Record<string, unknown>),
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
+12 -5
View File
@@ -87,11 +87,18 @@ export async function resolveHostCredentials(
...recipientHost,
authOverrides: {
...authOverrides,
ssh: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
ssh: resolution.folder
? {
required: false,
ownerAuthShared: !!host.shareSshAuth,
folder: resolution.folder,
folderCredentialId: resolution.credentialId,
}
: {
credentialId: resolution.credentialId,
required: false,
ownerAuthShared: !!host.shareSshAuth,
},
},
authType:
credential.key || credential.privateKey
+11 -7
View File
@@ -13,7 +13,7 @@ import net from "node:net";
import type { Duplex } from "node:stream";
import { createWebSocketStream, type WebSocket } from "ws";
import type { PluginWebSocketConnection } from "@termix-ssh/plugin-sdk/backend";
import { resolveHostById } from "./host-resolver.js";
import { resolveHostById, resolveHostBySyncId } from "./host-resolver.js";
import { openSshTransport } from "./connect/transport.js";
import type { MutableConnectConfig, SshConnectHost } from "./connect/types.js";
import { sshLogger } from "../utils/logger.js";
@@ -35,12 +35,13 @@ async function handleRelay(
): Promise<void> {
const ws = connection.socket as WebSocket;
const url = String((connection.request as { url?: string }).url ?? "");
const hostId = Number(
new URL(url, "http://relay").searchParams.get("hostId"),
);
const params = new URL(url, "http://relay").searchParams;
const hostId = Number(params.get("hostId"));
// A linked desktop knows its hosts by sync id, not by this server's id.
const syncId = params.get("syncId");
const userId = connection.userId;
if (!userId || !Number.isSafeInteger(hostId) || hostId <= 0) {
if (!userId || (!syncId && (!Number.isSafeInteger(hostId) || hostId <= 0))) {
close(ws, 1008, "A host is required");
return;
}
@@ -49,7 +50,9 @@ async function handleRelay(
return;
}
const host = await resolveHostById(hostId, userId);
const host = syncId
? await resolveHostBySyncId(syncId, userId)
: await resolveHostById(hostId, userId);
if (!host) {
close(ws, 1008, "Host not found or access denied");
return;
@@ -93,7 +96,8 @@ async function handleRelay(
} catch (error) {
sshLogger.warn("A relay could not reach its host", {
operation: "ssh_relay_open",
hostId,
hostId: syncId ? undefined : hostId,
syncId,
error: error instanceof Error ? error.message : String(error),
});
finish();
+20 -3
View File
@@ -38,6 +38,20 @@ export function isUserPluginFile(
);
}
/** Whether a failed import from a plugin should try core's copy. */
export function shouldUseHostModule(
error: unknown,
specifier: string,
parentURL: string | undefined,
pluginsDir: string,
): boolean {
const code = (error as { code?: string })?.code;
// import() says ERR_MODULE_NOT_FOUND, a CommonJS require MODULE_NOT_FOUND.
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
return false;
return isBareSpecifier(specifier) && isUserPluginFile(parentURL, pluginsDir);
}
export function installHostModuleFallback(): void {
if (installed) return;
installed = true;
@@ -49,9 +63,12 @@ export function installHostModuleFallback(): void {
return nextResolve(specifier, context);
} catch (error) {
if (
(error as { code?: string })?.code !== "ERR_MODULE_NOT_FOUND" ||
!isBareSpecifier(specifier) ||
!isUserPluginFile(context.parentURL, getPluginsDir())
!shouldUseHostModule(
error,
specifier,
context.parentURL,
getPluginsDir(),
)
) {
throw error;
}
+64 -9
View File
@@ -73,7 +73,7 @@ import {
pluginFeatures,
type PluginManifest,
} from "./manifest.js";
import { requireSignedPlugins } from "./trust.js";
import { requireSignedPlugins, verifyAnyTrusted } from "./trust.js";
import {
parseChangelog,
parseReleaseNotes,
@@ -1048,7 +1048,15 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
await readUninstalled(),
);
const restored: LoadedPlugin[] = [];
for (const id of ids) {
// Shown on the Plugins tab, so a plugin that could not be fetched is not
// listed as working. It stays on and is tried again on the next start.
const markMissing = (id: string, message: string) =>
recordState(
id,
"failed",
`Not in this image and could not be downloaded: ${message}`,
).catch(() => {});
for (const [index, id] of ids.entries()) {
try {
const record = await repository.findById(id);
const { source, release } = await requireRelease(
@@ -1075,11 +1083,14 @@ export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
operation: "plugin_restore_missing",
});
await markMissing(id, message);
// The registry is down or offline, so the rest would fail the same way.
if (
error instanceof PluginManageError &&
error.code === "REGISTRY_UNREACHABLE"
) {
for (const rest of ids.slice(index + 1))
await markMissing(rest, message);
break;
}
}
@@ -1514,6 +1525,8 @@ interface StagedUpload {
file: string;
manifest: PluginManifest;
expiresAt: number;
/** Set when the upload came with a .sig an official key made. */
official: boolean;
}
const uploads = new Map<string, StagedUpload>();
@@ -1537,6 +1550,29 @@ export interface UploadPreview {
capabilities: string[];
/** The version of an earlier upload this one replaces. */
replaces: string | null;
/** The .sig sent with it was made by the official Termix key. */
official: boolean;
}
/**
* Checks a .sig sent with an upload. An official signature makes the file
* the same as a registry download, so it may stand in for one that could not
* be fetched (an offline install). Anything else is refused.
*/
function checkUploadSignature(
buffer: Buffer,
signature: string,
): { official: boolean } {
const sha256 = crypto.createHash("sha256").update(buffer).digest("hex");
const result = verifyAnyTrusted(buffer, sha256, signature);
if (result.ok === false) {
throw new PluginManageError(
`The signature does not match this file: ${result.reason}`,
400,
"INVALID_SIGNATURE",
);
}
return { official: result.tier === "official" };
}
async function assertUploadsAllowed(): Promise<void> {
@@ -1593,9 +1629,17 @@ function validateUpload(buffer: Buffer): PluginManifest {
return manifest;
}
/** Only another upload of the same id can be replaced from a file. */
async function assertReplaceable(pluginId: string): Promise<string | null> {
/**
* Only another upload of the same id can be replaced from a file. An
* officially signed file may also fill in a plugin whose code is missing, like
* one that was on but could not be downloaded.
*/
async function assertReplaceable(
pluginId: string,
official = false,
): Promise<string | null> {
const { loader } = getPluginRuntime();
if (official && !loader.get(pluginId)) return null;
if (loader.bundledIds().has(pluginId)) {
throw new PluginManageError(
`${pluginId} ships with Termix and can only be replaced by a signed release`,
@@ -1620,21 +1664,30 @@ async function assertReplaceable(pluginId: string): Promise<string | null> {
* Checks an uploaded .tmxplug and holds it until the admin agrees to what
* it asks for. Nothing is loaded yet.
*/
export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
export async function stageUpload(
buffer: Buffer,
signature: string | null = null,
): Promise<UploadPreview> {
await assertUploadsAllowed();
await clearUploads(Date.now());
const manifest = validateUpload(buffer);
const replaces = await assertReplaceable(manifest.id);
const { official } = signature
? checkUploadSignature(buffer, signature)
: { official: false };
const replaces = await assertReplaceable(manifest.id, official);
const token = crypto.randomBytes(16).toString("hex");
const dir = getDownloadsDir();
await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, `upload-${token}.tmxplug`);
await fs.promises.writeFile(file, buffer);
// Next to the file, where the loader looks for it.
if (signature) await fs.promises.writeFile(`${file}.sig`, signature);
uploads.set(token, {
file,
manifest,
expiresAt: Date.now() + UPLOAD_TTL_MS,
official,
});
return {
@@ -1647,6 +1700,7 @@ export async function stageUpload(buffer: Buffer): Promise<UploadPreview> {
features: pluginFeatures(manifest.features),
capabilities: [...manifest.capabilities],
replaces,
official,
};
}
@@ -1676,7 +1730,7 @@ export function installUpload(
);
}
assertConsent(options.capabilities, manifest.capabilities);
await assertReplaceable(pluginId);
await assertReplaceable(pluginId, upload.official);
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
@@ -1689,6 +1743,7 @@ export function installUpload(
plugin = await swapIn(pluginId, { upload: upload.file });
} catch (error) {
await fs.promises.rm(upload.file, { force: true });
await fs.promises.rm(`${upload.file}.sig`, { force: true });
const { loader } = getPluginRuntime();
if (before && wasEnabled && loader.get(pluginId)) {
await startAndRecord(pluginId);
@@ -1701,7 +1756,7 @@ export function installUpload(
}
await repository.update(pluginId, {
tier: UNVERIFIED_TIER,
tier: upload.official ? "official" : UNVERIFIED_TIER,
registryId: null,
pinnedVersion: null,
autoUpdate: false,
@@ -1725,7 +1780,7 @@ export function installUpload(
}
const state = await startAndRecord(pluginId);
pluginLogger.warn(
`Installed unverified plugin ${pluginId}@${manifest.version} from a file`,
`Installed ${upload.official ? "officially signed" : "unverified"} plugin ${pluginId}@${manifest.version} from a file`,
{ operation: "plugin_install" },
);
return { id: pluginId, version: manifest.version, state };
+50 -2
View File
@@ -16,7 +16,7 @@ import type {
SyncRow,
} from "@termix-ssh/plugin-sdk/backend";
/** Core-only extras a plugin registration cannot set. */
/** Extras core sets for its own entities. A plugin may set the first four. */
export interface CoreSyncEntityOptions {
/** Server-to-desktop only. A desktop never pushes it. */
readOnly?: boolean;
@@ -58,6 +58,54 @@ export interface RegisteredSyncEntity
encryptedFields: readonly string[];
}
/** What a plugin registration may carry. Anything else is dropped. */
const PLUGIN_KEYS = [
"type",
"table",
"userColumn",
"encryptedFields",
"references",
"order",
"readOnlyFields",
"singleton",
"answersTo",
"shouldSync",
"permissions",
"serialize",
"deserialize",
"afterWrite",
"remove",
"readOnly",
"load",
"write",
"erase",
] as const;
function pluginEntity(entity: SyncEntityRegistration): SyncEntityRegistration {
const picked: Record<string, unknown> = {};
for (const key of PLUGIN_KEYS) {
if (entity[key] !== undefined) picked[key] = entity[key];
}
const out = picked as unknown as SyncEntityRegistration;
if (out.load || out.write || out.erase) {
if (!out.load || !out.write || !out.erase) {
throw new Error(
`Sync entity "${entity.type}" needs load, write and erase together`,
);
}
// A plugin only ever syncs its own rows both ways; rows it builds for
// a user come down as read only copies.
if (out.readOnly !== true) {
throw new Error(
`Sync entity "${entity.type}" with load must be readOnly`,
);
}
} else if (!out.table) {
throw new Error(`Sync entity "${entity.type}" needs a table or load`);
}
return out;
}
const entities = new Map<string, RegisteredSyncEntity>();
const listeners = new Set<() => void>();
@@ -81,7 +129,7 @@ export function registerEntity(
}
const registered: RegisteredSyncEntity = {
...entity,
...(owner === CORE_OWNER ? entity : pluginEntity(entity)),
...(owner === CORE_OWNER ? core : {}),
owner,
order: entity.order ?? 100,
+6
View File
@@ -38,6 +38,7 @@ import { hosts, sshCredentials } from "../database/db/schema.js";
import {
createResolvers,
deleteStoredRow,
findStoredIds,
listStoredRows,
loadWireRow,
loadWireRows,
@@ -271,6 +272,11 @@ export function createSyncHost(): PluginSyncHost {
ensureCore: () => ensureCoreEntities(),
onChanged: (listener) => onEntitiesChanged(listener),
onLocalWrite: (listener) => onLocalWrite(listener),
idsOf: async (type, userId, syncIds) => {
const entity = getEntity(type);
if (!entity || entity.load) return {};
return findStoredIds(entity, userId, syncIds);
},
},
replica: {
+1 -1
View File
@@ -161,7 +161,7 @@ async function reconcileListeners(): Promise<void> {
}
}
function notifyListeners(userId: string): void {
export function notifyListeners(userId: string): void {
const set = listeners.get(userId);
if (!set) return;
for (const res of set) {
+7 -1
View File
@@ -25,7 +25,7 @@ import {
} from "../store.js";
import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js";
import { orderSelfReferences } from "../wire.js";
import { reconcileUser } from "./feed.js";
import { markChanged, notifyListeners, reconcileUser } from "./feed.js";
export interface PushOp {
entityType: string;
@@ -278,6 +278,12 @@ export async function applyPush(
// Refresh the snapshot so the next pull serves what was just written.
await reconcileUser(userId, { force: true });
// The reconcile finds nothing new, since the push wrote the records itself,
// so tell this user's other clients, and other users who may share a row.
if ([...results.values()].some((result) => result.status === "ok")) {
notifyListeners(userId);
markChanged();
}
return ops.map(
(op) =>
results.get(recordKey(op.entityType, op.syncId)) ?? {
+15
View File
@@ -112,6 +112,21 @@ export async function findStoredRow(
return row ?? null;
}
/** The local ids of the user's own rows, by sync id. Unknown ones are left out. */
export async function findStoredIds(
entity: RegisteredSyncEntity,
userId: string,
syncIds: string[],
): Promise<Record<string, number>> {
const ids: Record<string, number> = {};
if (entity.singleton || !entity.table) return ids;
for (const syncId of new Set(syncIds)) {
const row = await findStoredRow(entity, userId, syncId);
if (row && typeof row.id === "number") ids[syncId] = row.id;
}
return ids;
}
/** Gives an old row with no syncId one, so it can take part. */
async function ensureSyncId(
entity: RegisteredSyncEntity,
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from "vitest";
import { sql } from "drizzle-orm";
import {
folderAndParents,
SharedFolderAuthOverrideRepository,
} from "../../../database/repositories/shared-folder-auth-override-repository.js";
import { TestSqliteDatabase } from "./test-support.js";
describe("folderAndParents", () => {
it("lists the folder, then each folder above it", () => {
expect(folderAndParents("Prod / Web / EU")).toEqual([
"Prod / Web / EU",
"Prod / Web",
"Prod",
]);
expect(folderAndParents(" Prod ")).toEqual(["Prod"]);
expect(folderAndParents("")).toEqual([]);
expect(folderAndParents(null)).toEqual([]);
});
});
describe("SharedFolderAuthOverrideRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
await adapter?.close();
adapter = null;
});
async function createRepository(onWrite?: () => void) {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('recipient', 'recipient', 'hash'), ('other', 'other', 'hash');
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
VALUES (7, 'recipient', 'cred-seven', 'password'),
(8, 'recipient', 'cred-eight', 'password');
`);
return new SharedFolderAuthOverrideRepository(context, onWrite);
}
it("sets, updates and clears a login per user, folder and protocol", async () => {
let writes = 0;
const repository = await createRepository(() => {
writes += 1;
});
await repository.setCredential("recipient", "Prod", "ssh", 7);
await repository.setCredential("recipient", "Prod", "ssh", 8);
await repository.setCredential("recipient", "Prod", "rdp", 7);
await expect(
repository.findCredentialId("recipient", "Prod", "ssh"),
).resolves.toBe(8);
await expect(
repository.findCredentialId("other", "Prod", "ssh"),
).resolves.toBeNull();
await expect(
repository.clearCredential("recipient", "Prod", "ssh"),
).resolves.toBe(true);
await expect(
repository.clearCredential("recipient", "Prod", "ssh"),
).resolves.toBe(false);
expect(writes).toBe(4);
});
it("finds the login of the closest folder above a host", async () => {
const repository = await createRepository();
await repository.setCredential("recipient", "Prod", "ssh", 7);
await repository.setCredential("recipient", "Prod / Web", "ssh", 8);
await expect(
repository.findClosest("recipient", "Prod / Web / EU", "ssh"),
).resolves.toEqual({ folder: "Prod / Web", credentialId: 8 });
await expect(
repository.findClosest("recipient", "Prod / DB", "ssh"),
).resolves.toEqual({ folder: "Prod", credentialId: 7 });
await expect(
repository.findClosest("recipient", "Staging", "ssh"),
).resolves.toBeNull();
await expect(
repository.findClosest("recipient", "Prod", "rdp"),
).resolves.toBeNull();
await expect(
repository.findClosest("recipient", null, "ssh"),
).resolves.toBeNull();
});
it("drops the login when its credential is deleted", async () => {
const repository = await createRepository();
await repository.setCredential("recipient", "Prod", "ssh", 7);
await adapter!.run(sql`DELETE FROM ssh_credentials WHERE id = 7`);
await expect(
repository.findCredentialId("recipient", "Prod", "ssh"),
).resolves.toBeNull();
});
});
@@ -214,3 +214,43 @@ describe("host.created", () => {
}
});
});
describe("host color and icon", () => {
async function stored(id: number) {
const [row] = await ctx.drizzle
.select({ color: hosts.color, icon: hosts.icon })
.from(hosts)
.where(eq(hosts.id, id));
return row;
}
it("saves them on create and keeps them when an update leaves them out", async () => {
const created = await request(app)
.post("/host/db/host")
.send({
...base,
authType: "password",
password: "pw",
color: "#22C55E",
icon: "database",
});
expect(created.status).toBe(200);
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, color: null, icon: null });
expect(await stored(created.body.id)).toEqual({ color: null, icon: null });
});
});
@@ -8,6 +8,7 @@ import {
keepsStoredSshAuth,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
normalizeProtocolEnableFields,
renameFolderPath,
@@ -529,3 +530,25 @@ describe("impliedSshAuthType", () => {
expect(impliedSshAuthType({})).toBeUndefined();
});
});
describe("normalizeHostLookFields", () => {
it("leaves both fields out when the write does not send them", () => {
expect(normalizeHostLookFields({ name: "web" })).toEqual({});
});
it("keeps a hex color and a Lucide name", () => {
expect(
normalizeHostLookFields({ color: "#3B82F6", icon: "server-cog" }),
).toEqual({ color: "#3b82f6", icon: "server-cog" });
});
it("stores null for cleared or bad values", () => {
expect(normalizeHostLookFields({ color: "red", icon: "<script>" })).toEqual(
{ color: null, icon: null },
);
expect(normalizeHostLookFields({ color: null, icon: "" })).toEqual({
color: null,
icon: null,
});
});
});
@@ -488,6 +488,35 @@ describe("plugins route", () => {
}
});
it("lists a plugin's engine ranges to any user", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({
manifestJson: JSON.stringify({
engine: { api: "^1.13", termix: ">=26.10.6", extra: 1 },
}),
}),
);
state.plugins.set(
"bad-engine",
makePlugin({
id: "bad-engine",
manifestJson: JSON.stringify({ engine: { api: 2 } }),
}),
);
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
const body = (await res.json()) as Array<{ id: string; engine: unknown }>;
expect(body.find((p) => p.id === "sample-plugin")?.engine).toEqual({
api: "^1.13",
termix: ">=26.10.6",
});
expect(body.find((p) => p.id === "bad-engine")?.engine).toEqual({});
});
it("reports an enabled plugin that safe mode keeps off", async () => {
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
@@ -643,6 +672,7 @@ describe("plugins route", () => {
enabled: true,
state: "enabled",
runsOnServer: false,
engine: {},
contributes: null,
dependencies: {},
optionalDependencies: {},
@@ -8,9 +8,14 @@ const state = vi.hoisted(() => ({
isShared: true,
isAdminBypass: false,
},
credentialOwned: true,
credentialUsable: true,
credentialId: 7 as number | null,
writes: [] as Array<{ protocol: string; credentialId: number | null }>,
folderWrites: [] as Array<{
folder: string;
protocol: string;
credentialId: number | null;
}>,
auditCalls: [] as Array<Record<string, unknown>>,
}));
@@ -70,11 +75,12 @@ vi.mock("../../../utils/permission-manager.js", () => ({
},
}));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
state.credentialUsable ? { id: credentialId } : null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCredentialRepository: () => ({
findByIdForUser: async () =>
state.credentialOwned ? { id: state.credentialId } : null,
}),
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.credentialId,
setCredential: async (
@@ -96,6 +102,25 @@ vi.mock("../../../database/repositories/factory.js", () => ({
return true;
},
}),
createCurrentSharedFolderAuthOverrideRepository: () => ({
findCredentialId: async () => 7,
setCredential: async (
_userId: string,
folder: string,
protocol: string,
credentialId: number,
) => {
state.folderWrites.push({ folder, protocol, credentialId });
},
clearCredential: async (
_userId: string,
folder: string,
protocol: string,
) => {
state.folderWrites.push({ folder, protocol, credentialId: null });
return true;
},
}),
createCurrentUserRepository: () => ({
findById: async () => ({ id: "recipient", username: "recipient" }),
}),
@@ -108,6 +133,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
vi.mock("../../../utils/audit-logger.js", () => ({
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
getAuditUsername: async (userId: string) => userId,
logAudit: vi.fn(async (entry: Record<string, unknown>) => {
state.auditCalls.push(entry);
}),
@@ -133,6 +159,8 @@ describe("shared host authentication override routes", () => {
method: "get" | "put",
body: Record<string, unknown> = {},
protocol = "ssh",
path = "/host-access/:hostId/auth/:protocol",
query: Record<string, unknown> = {},
): Promise<{ status: number; body: unknown }> {
const routeLayer = (
router as unknown as {
@@ -151,15 +179,14 @@ describe("shared host authentication override routes", () => {
}>;
}
).stack.find(
(layer) =>
layer.route?.path === "/host-access/:hostId/auth/:protocol" &&
layer.route.methods[method],
(layer) => layer.route?.path === path && layer.route.methods[method],
);
if (!routeLayer?.route) throw new Error(`Missing ${method} route`);
const handlers = routeLayer.route.stack.map((layer) => layer.handle);
const req = {
params: { hostId: "42", protocol },
query,
body,
headers: {},
ip: "127.0.0.1",
@@ -206,12 +233,106 @@ describe("shared host authentication override routes", () => {
isShared: true,
isAdminBypass: false,
};
state.credentialOwned = true;
state.credentialUsable = true;
state.credentialId = 7;
state.writes = [];
state.folderWrites = [];
state.auditCalls = [];
});
describe("folder logins", () => {
const FOLDER = "/folder-auth/:protocol";
it("reads the login for a folder", async () => {
const response = await invoke("get", {}, "ssh", FOLDER, {
folder: " Prod / Web ",
});
expect(response).toEqual({
status: 200,
body: { folder: "Prod / Web", protocol: "ssh", credentialId: 7 },
});
});
it("sets and clears the login and audits both", async () => {
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 8 },
"ssh",
FOLDER,
)
).status,
).toBe(200);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: null },
"ssh",
FOLDER,
)
).status,
).toBe(200);
expect(state.folderWrites).toEqual([
{ folder: "Prod", protocol: "ssh", credentialId: 8 },
{ folder: "Prod", protocol: "ssh", credentialId: null },
]);
expect(state.auditCalls.map((call) => call.action)).toEqual([
"set_shared_folder_auth_override",
"clear_shared_folder_auth_override",
]);
});
it("rejects a bad folder, protocol or credential", async () => {
expect(
(await invoke("get", {}, "ssh", FOLDER, { folder: "" })).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "x".repeat(300), credentialId: 8 },
"ssh",
FOLDER,
)
).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 8 },
"smtp",
FOLDER,
)
).status,
).toBe(400);
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: -1 },
"ssh",
FOLDER,
)
).status,
).toBe(400);
state.credentialUsable = false;
expect(
(
await invoke(
"put",
{ folder: "Prod", credentialId: 99 },
"ssh",
FOLDER,
)
).status,
).toBe(404);
expect(state.folderWrites).toEqual([]);
});
});
it("returns the current override for a role-derived shared recipient", async () => {
const response = await invoke("get");
expect(response).toEqual({
@@ -254,7 +375,7 @@ describe("shared host authentication override routes", () => {
const invalidResponse = await invoke("put", { credentialId: 0 });
expect(invalidResponse.status).toBe(400);
state.credentialOwned = false;
state.credentialUsable = false;
const foreignResponse = await invoke("put", { credentialId: 99 });
expect(foreignResponse.status).toBe(404);
@@ -69,7 +69,7 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
const { pruneHostGrants, pruneCredentialGrants } =
const { pruneHostGrants, pruneCredentialGrants, grantsForFolderRule } =
await import("../../../database/routes/rbac.js");
const chain = (): Grant[] => [
@@ -105,3 +105,27 @@ describe("share pruning", () => {
expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]);
});
});
describe("grantsForFolderRule", () => {
const entries = [
{ id: 1, userId: "bob", roleId: null },
{ id: 2, userId: null, roleId: 7 },
{ id: 3, userId: "carol", roleId: null },
];
it("picks the grants of the rule's user or role", () => {
expect(
grantsForFolderRule(entries, { userId: "bob", roleId: null }).map(
(e) => e.id,
),
).toEqual([1]);
expect(
grantsForFolderRule(entries, { userId: null, roleId: 7 }).map(
(e) => e.id,
),
).toEqual([2]);
expect(
grantsForFolderRule(entries, { userId: null, roleId: null }),
).toEqual([]);
});
});
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Request, RequestHandler, Response } from "express";
const state = vi.hoisted(() => ({
users: new Map<
string,
{
id: string;
username: string;
isAdmin: boolean;
isExternal: boolean;
passwordHash: string;
}
>(),
adminCount: 1,
deleted: [] as string[],
}));
vi.mock("../../../database/db/index.js", () => ({ db: {} }));
vi.mock("../../../utils/logger.js", () => ({
authLogger: {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
success: vi.fn(),
},
}));
vi.mock("bcryptjs", () => ({
default: {
compare: async (password: string, hash: string) =>
hash === `hash:${password}`,
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
countAdmins: async () => state.adminCount,
}),
}));
vi.mock("../../../database/routes/delete-user-data.js", () => ({
deleteUserAndRelatedData: async (userId: string) => {
state.deleted.push(userId);
},
}));
const { registerUserDeleteAccountRoutes } =
await import("../../../database/routes/user-delete-account-routes.js");
let handler: RequestHandler | null = null;
registerUserDeleteAccountRoutes(
{
delete: (_path: string, ...handlers: RequestHandler[]) => {
handler = handlers[handlers.length - 1];
},
} as unknown as import("express").Router,
(_req, _res, next) => next(),
);
async function deleteAccount(userId: string, body: Record<string, unknown>) {
const req = { userId, body } as unknown as Request;
const res = {
statusCode: 200,
jsonBody: null as Record<string, unknown> | null,
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: Record<string, unknown>) {
this.jsonBody = payload;
return this;
},
};
await handler!(req, res as unknown as Response, () => {});
return res;
}
beforeEach(() => {
state.adminCount = 1;
state.deleted = [];
state.users = new Map([
[
"u1",
{
id: "u1",
username: "user",
isAdmin: false,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"admin1",
{
id: "admin1",
username: "admin",
isAdmin: true,
isExternal: false,
passwordHash: "hash:secret",
},
],
[
"sso1",
{
id: "sso1",
username: "sso",
isAdmin: false,
isExternal: true,
passwordHash: "hash:secret",
},
],
]);
});
describe("DELETE /delete-account", () => {
it("runs the full user cleanup", async () => {
const res = await deleteAccount("u1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["u1"]);
});
it("needs a password", async () => {
const res = await deleteAccount("u1", {});
expect(res.statusCode).toBe(400);
expect(state.deleted).toEqual([]);
});
it("refuses a wrong password", async () => {
const res = await deleteAccount("u1", { password: "nope" });
expect(res.statusCode).toBe(401);
expect(state.deleted).toEqual([]);
});
it("refuses external accounts", async () => {
const res = await deleteAccount("sso1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("refuses the last admin", async () => {
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(403);
expect(state.deleted).toEqual([]);
});
it("lets an admin go when another admin is left", async () => {
state.adminCount = 2;
const res = await deleteAccount("admin1", { password: "secret" });
expect(res.statusCode).toBe(200);
expect(state.deleted).toEqual(["admin1"]);
});
it("404s for an unknown user", async () => {
const res = await deleteAccount("ghost", { password: "secret" });
expect(res.statusCode).toBe(404);
});
});
@@ -37,6 +37,29 @@ afterEach(() => {
});
describe("ssh transport providers", () => {
it("tells providers the owner, sync id and origin of the host", async () => {
const claims = vi.fn(() => false);
provideSshTransport("a", { claims, open: vi.fn() });
await openSshTransport(
{
...host,
userId: "u1",
syncId: "s1",
connectionOrigin: "remote",
} as never,
{} as never,
).catch(() => {});
expect(claims).toHaveBeenCalledWith(7, {
id: 7,
ip: "10.0.0.7",
port: 22,
userId: "u1",
syncId: "s1",
connectionOrigin: "remote",
localOnly: false,
});
});
it("opens nothing when no provider claims the host", async () => {
provideSshTransport("a", { claims: () => false, open: vi.fn() });
expect(await openProvidedTransport(host)).toBeNull();
@@ -6,6 +6,7 @@ const state = vi.hoisted(() => ({
isAdminBypass: false,
overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(),
sharedCredentials: new Map<string, Record<string, unknown>>(),
auditCalls: [] as Record<string, unknown>[],
sharedSecret: null as Record<string, unknown> | null,
activeHostAccessId: null as number | null,
@@ -37,6 +38,13 @@ vi.mock("../../database/repositories/factory.js", () => ({
}),
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number, userId: string) =>
state.credentials.get(`${credentialId}:${userId}`) ??
state.sharedCredentials.get(`${credentialId}:${userId}`) ??
null,
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (params: Record<string, unknown>) => {
state.auditCalls.push(params);
@@ -125,6 +133,7 @@ beforeEach(() => {
state.isAdminBypass = false;
state.overrideCredentialId = null;
state.credentials.clear();
state.sharedCredentials.clear();
state.auditCalls = [];
state.sharedSecret = null;
state.activeHostAccessId = null;
@@ -376,6 +385,61 @@ describe("resolveHostById", () => {
expect(host.username).toBe("shared-login");
});
it("uses a credential shared with the recipient as their personal override", async () => {
state.overrideCredentialId = 7;
state.sharedCredentials.set("7:recipient", {
id: 7,
username: "shared-cred-user",
password: "shared-cred-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
unknown
>;
expect(host.username).toBe("shared-cred-user");
expect(host.password).toBe("shared-cred-pass");
});
it("uses the host's credential when it is also shared with the recipient", async () => {
state.host = baseHost({
authType: "credential",
password: null,
credentialId: 1,
});
state.sharedCredentials.set("1:recipient", {
id: 1,
username: "deploy",
password: null,
privateKey: "OWNER-KEY-COPY",
key: null,
keyPassword: null,
keyType: "ssh-ed25519",
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
unknown
>;
expect(host.authType).toBe("key");
expect(host.key).toBe("OWNER-KEY-COPY");
expect(host.username).toBe("deploy");
});
it("denies a recipient when the host's credential is not shared with them", async () => {
state.host = baseHost({
authType: "credential",
password: null,
credentialId: 1,
});
expect(await resolveHostById(42, "recipient")).toBeNull();
});
it("denies a non-owner when a secret-bearing host has no personal credential", async () => {
expect(await resolveHostById(42, "recipient")).toBeNull();
});
+19
View File
@@ -7,6 +7,7 @@ import { WebSocket, WebSocketServer } from "ws";
const mocks = vi.hoisted(() => ({
resolveHostById: vi.fn(),
resolveHostBySyncId: vi.fn(),
openSshTransport: vi.fn(),
handler: null as null | ((connection: unknown) => Promise<void>),
}));
@@ -17,6 +18,7 @@ vi.mock("../../utils/logger.js", () => {
});
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: mocks.resolveHostById,
resolveHostBySyncId: mocks.resolveHostBySyncId,
}));
vi.mock("../../hosts/connect/transport.js", () => ({
openSshTransport: mocks.openSshTransport,
@@ -114,6 +116,23 @@ describe("ssh relay", () => {
await vi.waitFor(() => expect(end).toHaveBeenCalled());
});
it("finds the host by sync id for a linked desktop", async () => {
mocks.resolveHostBySyncId.mockResolvedValue({
id: 9,
ip: "127.0.0.1",
port: echoPort,
});
const { socket } = await open("?syncId=abc");
const received = new Promise<string>((resolve) =>
socket.once("message", (data) => resolve(data.toString())),
);
socket.send("ping");
expect(await received).toBe("ping");
expect(mocks.resolveHostBySyncId).toHaveBeenCalledWith("abc", "user-1");
expect(mocks.resolveHostById).not.toHaveBeenCalled();
socket.close();
});
it("refuses a host the user can't connect to", async () => {
mocks.resolveHostById.mockResolvedValue(null);
const { closed } = await open("?hostId=5");
@@ -1,3 +1,4 @@
import { EventEmitter } from "node:events";
import { describe, it, expect } from "vitest";
import {
parsePlatformProbe,
@@ -7,6 +8,7 @@ import {
buildSudoCommand,
parseUpgradable,
execCommand,
decodeCommandOutput,
} from "@termix-ssh/plugin-sdk/host-commands";
describe("parsePlatformProbe", () => {
@@ -116,6 +118,32 @@ describe("parseUpgradable", () => {
});
});
class FakeStream extends EventEmitter {
stderr = new EventEmitter();
destroy() {}
}
function fakeClient(script: (stream: FakeStream) => void) {
return {
exec: (
_command: string,
_options: unknown,
callback: (err: Error | undefined, stream: FakeStream) => void,
) => {
const stream = new FakeStream();
callback(undefined, stream);
script(stream);
},
} as never;
}
// "The system cannot find the path specified." as the GBK bytes a
// Chinese-locale Windows console actually sends.
const GBK_PATH_ERROR = Buffer.from(
"cfb5cdb3d5d2b2bbb5bdd6b8b6a8b5c4c2b7beb6a1a3",
"hex",
);
describe("execCommand", () => {
it("leaves the command out of the timeout error", async () => {
const client = { exec: () => {} } as never;
@@ -124,4 +152,64 @@ describe("execCommand", () => {
/^Command timeout after 5ms$/,
);
});
it("decodes GBK stderr from a localized Windows host", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(0, 7));
stream.stderr.emit("data", GBK_PATH_ERROR.subarray(7));
stream.emit("close", 1);
}),
"dir",
);
expect(result.stderr).toBe("系统找不到指定的路径。");
expect(result.code).toBe(1);
});
it("reassembles a UTF-8 character split across chunks", async () => {
const utf8 = Buffer.from("以太网 2", "utf8");
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", utf8.subarray(0, 4)); // cuts 太 in half
stream.emit("data", utf8.subarray(4));
stream.emit("close", 0);
}),
"ipconfig",
);
expect(result.stdout).toBe("以太网 2");
});
it("passes plain ASCII output through unchanged", async () => {
const result = await execCommand(
fakeClient((stream) => {
stream.emit("data", Buffer.from("ok\n"));
stream.stderr.emit("data", Buffer.from("warn\n"));
stream.emit("close", 0);
}),
"echo ok",
);
expect(result).toEqual({ stdout: "ok\n", stderr: "warn\n", code: 0 });
});
});
describe("decodeCommandOutput", () => {
it("keeps valid UTF-8, including the empty buffer", () => {
expect(decodeCommandOutput(Buffer.from("以太网 2", "utf8"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(Buffer.alloc(0))).toBe("");
});
it("falls back to GBK when the bytes are not valid UTF-8", () => {
// "以太网 2" in GBK, the interface name of the metrics widget issue.
expect(decodeCommandOutput(Buffer.from("d2d4ccabcdf82032", "hex"))).toBe(
"以太网 2",
);
expect(decodeCommandOutput(GBK_PATH_ERROR)).toBe("系统找不到指定的路径。");
});
it("keeps lossy UTF-8 when no decoder matches", () => {
// 0xff is a valid byte in neither UTF-8 nor GBK.
expect(decodeCommandOutput(Buffer.from([0x68, 0x69, 0xff]))).toBe("hi�");
});
});
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
isBareSpecifier,
isUserPluginFile,
shouldUseHostModule,
} from "../../plugins/host-modules.js";
describe("isBareSpecifier", () => {
@@ -46,6 +47,36 @@ describe("isUserPluginFile", () => {
});
});
describe("shouldUseHostModule", () => {
const root = path.resolve(os.tmpdir(), "termix-data", "plugins");
const parent = pathToFileURL(
path.join(root, ".unpacked", "demo", "dist", "backend.js"),
).href;
it("falls back for import and require misses from a plugin", () => {
for (const code of ["ERR_MODULE_NOT_FOUND", "MODULE_NOT_FOUND"]) {
expect(shouldUseHostModule({ code }, "axios", parent, root)).toBe(true);
}
});
it("leaves other errors and files alone", () => {
expect(
shouldUseHostModule({ code: "ERR_REQUIRE_ESM" }, "axios", parent, root),
).toBe(false);
expect(
shouldUseHostModule({ code: "MODULE_NOT_FOUND" }, "./x.js", parent, root),
).toBe(false);
expect(
shouldUseHostModule(
{ code: "MODULE_NOT_FOUND" },
"axios",
undefined,
root,
),
).toBe(false);
});
});
describe("installHostModuleFallback", () => {
let dataDir: string | null = null;
@@ -0,0 +1,211 @@
import { describe, expect, it } from "vitest";
import {
classifyRuntimeError,
composeArgsFor,
containerCommand,
countContainers,
diffContainerStates,
dockerArgs,
parseComposeProjects,
parseContainerList,
parseContainerStates,
parseContainerStats,
parseRuntimeVersion,
parseServices,
parseStackStatus,
stackStateFromServices,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("containerCommand", () => {
it("extends PATH on POSIX hosts", () => {
const command = containerCommand("podman", "ps");
expect(command.startsWith('PATH="')).toBe(true);
expect(command.endsWith(" podman ps")).toBe(true);
});
it("runs the plain CLI on Windows hosts", () => {
expect(containerCommand("docker", "ps -a", true)).toBe("docker ps -a");
});
it("falls back to docker for an unknown runtime", () => {
expect(containerCommand(undefined, "ps", true)).toBe("docker ps");
});
});
describe("dockerArgs", () => {
it("builds the container commands", () => {
expect(dockerArgs.inspect("web")).toBe("inspect web");
expect(dockerArgs.action("restart", "web")).toBe("restart web");
expect(dockerArgs.remove("web", true)).toBe("rm -f web");
expect(dockerArgs.list(true)).toContain("ps -a --format '{");
expect(dockerArgs.stats("web")).toContain("stats web --no-stream");
});
it("refuses container ids that could break the command line", () => {
expect(() => dockerArgs.inspect("web; rm -rf /")).toThrow();
expect(() => dockerArgs.logs("$(id)")).toThrow();
expect(() => dockerArgs.action("restart", "-rf")).toThrow();
expect(() => dockerArgs.action("kill" as never, "web")).toThrow();
});
it("only passes sane log filters", () => {
expect(
dockerArgs.logs("web", {
tail: 50.7,
timestamps: true,
since: "2026-10-01T00:00:00Z",
}),
).toBe("logs web --tail 50 --timestamps --since 2026-10-01T00:00:00Z 2>&1");
expect(dockerArgs.logs("web", { since: "1h; id" })).toBe("logs web 2>&1");
});
});
describe("docker parsing", () => {
it("reads the version and sorts errors", () => {
expect(parseRuntimeVersion("Docker version 27.3.1, build ce12230")).toBe(
"27.3.1",
);
expect(parseRuntimeVersion("podman version 5.2.0")).toBe("5.2.0");
expect(parseRuntimeVersion("nope")).toBe("unknown");
expect(classifyRuntimeError("bash: docker: command not found")).toBe(
"NOT_INSTALLED",
);
expect(
classifyRuntimeError(
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
),
).toBe("DAEMON_NOT_RUNNING");
expect(
classifyRuntimeError("permission denied while trying to connect"),
).toBe("PERMISSION_DENIED");
expect(classifyRuntimeError("something else")).toBe("DOCKER_ERROR");
});
it("skips lines that are not JSON in the list", () => {
const rows = parseContainerList(
'{"id":"a","name":"web"}\nWARNING: x\n{"id":"b","name":"db"}\n',
);
expect(rows.map((r) => r.name)).toEqual(["web", "db"]);
});
it("splits the stats columns", () => {
const stats = parseContainerStats(
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
);
expect(stats).toMatchObject({
cpu: "1.5%",
memoryUsed: "10MiB",
memoryLimit: "1GiB",
netInput: "1kB",
netOutput: "2kB",
pids: "3",
});
});
const ps = [
`{"name":"web","state":"running","status":"Up 2 hours"}`,
`{"name":"db","state":"running","status":"Up 2 hours (unhealthy)"}`,
`{"name":"old","state":"exited","status":"Exited (0)"}`,
].join("\n");
it("counts running, stopped and unhealthy containers", () => {
expect(countContainers(ps)).toEqual({
running: 2,
stopped: 1,
unhealthy: 1,
total: 3,
});
});
it("turns two snapshots into events", () => {
const before = parseContainerStates(ps);
const after = parseContainerStates(
[
`{"name":"web","state":"exited","status":"Exited (1)"}`,
`{"name":"db","state":"running","status":"Up (unhealthy)"}`,
`{"name":"old","state":"running","status":"Up 1 second"}`,
`{"name":"new","state":"running","status":"Up"}`,
].join("\n"),
);
expect(diffContainerStates(before, after)).toEqual([
{ container: "web", event: "exited" },
{ container: "old", event: "started" },
]);
});
});
describe("compose", () => {
it("reads stack status text", () => {
expect(parseStackStatus("running(2), exited(1)")).toEqual({
state: "partial",
running: 2,
total: 3,
});
expect(parseStackStatus("paused(2)")).toMatchObject({ state: "paused" });
expect(parseStackStatus("")).toMatchObject({ state: "not-deployed" });
});
it("reads projects from compose ls", () => {
const stacks = parseComposeProjects(
JSON.stringify([
{
Name: "web",
Status: "running(2)",
ConfigFiles: "/opt/stacks/web/compose.yaml",
},
{ Name: "Bad Name", Status: "running(1)", ConfigFiles: "" },
]),
);
expect(stacks).toEqual([
{
name: "web",
managed: false,
state: "running",
running: 2,
total: 2,
composeFile: "/opt/stacks/web/compose.yaml",
},
]);
});
it("reads services from compose ps, one line or one array", () => {
const row = {
Service: "app",
Name: "web-app-1",
ID: "abc",
Image: "nginx",
State: "running",
Status: "Up",
Health: "",
Publishers: [
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 0, TargetPort: 443 },
],
};
const lines = parseServices(JSON.stringify(row));
expect(lines[0]).toMatchObject({ service: "app", ports: "8080:80" });
expect(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
).toHaveLength(2);
expect(
stackStateFromServices(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
),
).toEqual({
state: "partial",
running: 1,
total: 2,
});
});
it("maps actions to compose commands", () => {
expect(composeArgsFor("redeploy", "app", {})).toEqual([
"pull app",
"up -d --remove-orphans app",
]);
expect(
composeArgsFor("destroy", undefined, { removeVolumes: true }),
).toEqual(["down --remove-orphans -v"]);
});
});
@@ -0,0 +1,400 @@
import { describe, it, expect } from "vitest";
import {
buildFilesystemList,
filterExcludedFilesystems,
findWorstMountIndex,
liveCmd,
mergeMonitoredFilesystems,
parseCpuLine,
parseDfLines,
parseLive,
selectPrimaryFilesystem,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
const output =
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[0].type).toBe("ext4");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay overlay 100 50 50 50% /\n" +
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
it("captures the filesystem type for network shares", () => {
const output =
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
const rows = parseDfLines(output);
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
it("keeps full image and firmware mounts out of disk usage", () => {
const rows = parseDfLines(
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
"/dev/sda1 ext4 1000 400 600 40% /\n",
);
expect(rows.map((row) => row.mount)).toEqual(["/"]);
expect(findWorstMountIndex(rows)).toEqual({
index: 0,
usedBytes: 400,
totalBytes: 1000,
});
});
it("retains real storage without requiring a /dev source", () => {
const rows = parseDfLines(
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
"tank/data zfs 2000 1000 1000 50% /data\n" +
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
);
expect(rows.map((row) => row.type)).toEqual([
"btrfs",
"zfs",
"nfs4",
"cifs",
]);
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
"/dev/sda2 ext4 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
const BYTES_OUTPUT =
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
const HUMAN_OUTPUT =
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
describe("buildFilesystemList", () => {
it("returns every real filesystem with byte maths and human strings", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
mount: "/",
type: "ext4",
percent: 40,
usedHuman: "400",
totalHuman: "1.0K",
availableHuman: "600",
usedBytes: 400,
totalBytes: 1000,
});
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
});
it("matches human rows by mount when the row counts differ", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
);
expect(list[0].totalHuman).toBeNull();
expect(list[1].totalHuman).toBe("2.0K");
});
it("drops filesystems with a zero or invalid total", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
),
[],
);
expect(list).toHaveLength(1);
expect(list[0].mount).toBe("/ok");
});
});
describe("selectPrimaryFilesystem", () => {
it("prefers root over a fuller secondary mount", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
});
it("falls back to the most-utilized mount when there is no root", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
),
[],
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
});
it("returns null for an empty list", () => {
expect(selectPrimaryFilesystem([])).toBeNull();
});
});
describe("filterExcludedFilesystems", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 400 600 40% /\n" +
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
),
[],
);
it("returns the same list when no mounts are excluded", () => {
expect(filterExcludedFilesystems(list)).toHaveLength(3);
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
});
it("excludes an exact mount path match", () => {
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("excludes by filesystem type substring, case-insensitively", () => {
const filtered = filterExcludedFilesystems(list, ["NFS"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("supports excluding multiple network filesystem types at once", () => {
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
});
it("ignores blank/whitespace-only entries", () => {
const filtered = filterExcludedFilesystems(list, [" ", ""]);
expect(filtered).toHaveLength(3);
});
});
describe("mergeMonitoredFilesystems", () => {
it("adds an arbitrary path with a user label", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const custom = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/config", label: "Home Assistant" }],
custom,
);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
mount: "/config",
label: "Home Assistant",
totalBytes: 1000,
});
});
it("labels a path that is already a detected mount", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/data", label: "Media" }],
detected,
);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("Media");
});
});
const out = (
agg: [number, number],
core: [number, number],
rx: number,
up: number,
procs: string,
page = "4096",
) =>
[
`cpu ${agg[0]} 0 0 ${agg[1]} 0 0 0 0 0 0\ncpu0 ${core[0]} 0 0 ${core[1]} 0 0 0 0 0 0`,
"MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB",
`0.1 0.2 0.3 1/2 3\n${up} 5`,
`Inter-|\n eth0: ${rx} 0 0 0 0 0 0 0 ${rx * 2} 0\n lo: 9 0 0 0 0 0 0 0 9 0`,
page,
procs,
].join("\n@@\n");
const proc = (pid: number, ticks: number, start: number, name = "java") =>
`${pid}\t${ticks}\t10\t${start}\tR\t1\t${name}\n`;
describe("parseLive extras", () => {
it("reports state, parent, CPU mix, cache and the running count", () => {
const mk = (user: number, sys: number, wait: number, idle: number) =>
out([user, idle], [user, idle], 1, 10, procs).replace(
/cpu {2}\d+ 0 0 \d+ 0 0 0 0/,
`cpu ${user} 0 ${sys} ${idle} ${wait} 0 0 0`,
);
const procs = "5\t1\t10\t7\tR\t2\tjava\n6\t1\t10\t8\tS\t5\tsh\n";
const a = parseLive(mk(0, 0, 0, 0), undefined, 0);
const b = parseLive(mk(30, 10, 10, 50), a.next, 1000);
expect(b.sample.cpuMix).toMatchObject({ user: 30, system: 10, iowait: 10 });
expect(b.sample.running).toBe(1);
expect(b.sample.procs.find((p) => p.pid === 6)).toMatchObject({
state: "S",
ppid: 5,
});
expect(b.sample.memCachedKb).toBe(0);
});
});
describe("liveCmd", () => {
it("asks for the page size once, then passes the cached number", () => {
expect(liveCmd()).toContain('pg="$(getconf PAGESIZE)"');
expect(liveCmd(16384)).toContain("pg=16384 ");
expect(liveCmd(16384)).not.toContain("getconf");
});
});
describe("parseLive", () => {
it("has no rates on the first sample", () => {
const { sample } = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
expect(sample.memUsedKb).toBe(600);
});
it("derives cpu, per-core, net and process rates from the delta", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
// aggregate: +50 busy, +50 idle; core 0: +25 busy, +75 idle
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 50, 1)),
a.next,
);
expect(sample.cpu).toBeCloseTo(50, 0);
expect(sample.cores[0]).toBeCloseTo(25, 0);
expect(sample.net).toEqual([{ name: "eth0", rxBps: 2000, txBps: 4000 }]);
expect(sample.procs[0]).toMatchObject({ pid: 7, name: "java", memKb: 40 });
expect(sample.procs[0].cpu).toBeCloseTo(50, 0);
});
it("does not charge a reused pid with the old process's ticks", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 5, 1)),
undefined,
);
// same pid, new start time, lower ticks must not look like a huge delta
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 9000, 2)),
a.next,
);
expect(sample.procs[0].cpu).toBe(0);
});
it("keeps the cached page size when a run omits it, and keeps names with tabs", () => {
const a = parseLive(
out([1, 1], [1, 1], 1, 10, proc(1, 0, 1), "16384"),
undefined,
);
expect(a.next.pageBytes).toBe(16384);
const b = parseLive(
out([2, 2], [2, 2], 2, 11, proc(1, 0, 1, "a b"), ""),
a.next,
);
expect(b.next.pageBytes).toBe(16384);
expect(b.sample.procs[0]).toMatchObject({ name: "a\tb", memKb: 160 });
});
it("starts rates again after a reboot", () => {
const a = parseLive(
out([100, 100], [100, 100], 5000, 900, proc(7, 0, 1)),
undefined,
);
const { sample } = parseLive(
out([10, 10], [10, 10], 20, 5, proc(7, 0, 1)),
a.next,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
});
});
@@ -0,0 +1,244 @@
import { describe, expect, it } from "vitest";
import {
isUnit,
journalArgs,
parseBoots,
parseDetect,
parseEntry,
parseIsActive,
parseJournal,
parseJournalQuery,
parseOpenRcList,
parseSystemdTime,
parseTimers,
parseUnitList,
parseUsage,
shellQuote as q,
vacuumScript,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("names", () => {
it("quotes for bash", () => {
expect(q("a'b")).toBe(`'a'\\''b'`);
});
it("takes unit names and refuses anything else", () => {
expect(isUnit("nginx.service")).toBe(true);
expect(isUnit("getty@tty1.service")).toBe(true);
expect(isUnit("dev-disk-by\\x2duuid-1.device")).toBe(true);
expect(isUnit("-rf.service")).toBe(false);
expect(isUnit("a;b.service")).toBe(false);
expect(isUnit("a b.service")).toBe(false);
expect(isUnit("nginx")).toBe(false);
expect(isUnit("$(id).service")).toBe(false);
});
});
describe("systemd parsing", () => {
const list = [
"cron.service loaded active running Regular background program",
"● bad.service loaded failed failed Broken thing",
"getty@tty1.service loaded active running Getty on tty1",
"@@TERMIX_FILES@@",
"cron.service enabled enabled",
"bad.service disabled enabled",
"getty@.service enabled enabled",
"old.service masked -",
"",
].join("\n");
it("merges loaded units with unit files and skips templates", () => {
const units = parseUnitList(list);
expect(units.map((u) => u.name)).toEqual([
"bad.service",
"cron.service",
"getty@tty1.service",
"old.service",
]);
const cron = units.find((u) => u.name === "cron.service")!;
expect(cron).toMatchObject({
active: "active",
sub: "running",
description: "Regular background program",
fileState: "enabled",
preset: "enabled",
});
expect(units.find((u) => u.name === "bad.service")!.active).toBe("failed");
expect(units.find((u) => u.name === "old.service")).toMatchObject({
load: "masked",
active: "inactive",
preset: "",
});
});
it("reads UTC times", () => {
expect(parseSystemdTime("Thu 2026-10-22 02:03:51 UTC")).toBe(
Date.UTC(2026, 9, 22, 2, 3, 51),
);
expect(parseSystemdTime("")).toBeNull();
expect(parseSystemdTime("n/a")).toBeNull();
});
it("reads timers from systemctl show blocks", () => {
const out = [
"TimersCalendar={ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=@1792605600 }",
"NextElapseUSecRealtime=Thu 2026-10-22 02:03:51 UTC",
"LastTriggerUSec=Wed 2026-10-21 17:02:58 UTC",
"Id=apt-daily.timer",
"Triggers=apt-daily.service",
"ActiveState=active",
"",
"TimersMonotonic={ OnUnitActiveUSec=1d ; next_elapse=1d }",
"TimersMonotonic={ OnBootUSec=15min ; next_elapse=15min }",
"NextElapseUSecRealtime=",
"LastTriggerUSec=",
"Id=clean.timer",
"Triggers=clean.service",
].join("\n");
const timers = parseTimers(out);
expect(timers[0]).toMatchObject({
name: "apt-daily.timer",
activates: "apt-daily.service",
next: Date.UTC(2026, 9, 22, 2, 3, 51),
schedule: ["OnCalendar=*-*-* 06,18:00:00"],
});
expect(timers[1]).toMatchObject({
name: "clean.timer",
next: null,
last: null,
schedule: ["OnUnitActiveUSec=1d", "OnBootUSec=15min"],
});
});
it("maps is-active lines to units", () => {
const states = parseIsActive(
["a.service", "b.service"],
"active\nfailed\n",
);
expect(states.get("a.service")).toBe("active");
expect(states.get("b.service")).toBe("failed");
});
it("reads what detect prints", () => {
expect(
parseDetect(
"init=systemd\nversion=257\nos=Debian GNU/Linux 13 (trixie)\nuid=0\njournal=1\nfailed=2\n",
),
).toMatchObject({
init: "systemd",
version: "257",
root: true,
journal: true,
failed: 2,
});
expect(parseDetect("init=weird\nuid=1000\n")).toMatchObject({
init: "none",
root: false,
});
});
});
describe("journal", () => {
it("checks filters", () => {
const query = parseJournalQuery({
unit: ["nginx.service", "ssh.service"],
priority: "3",
since: "-1h",
boot: "-1",
grep: "fail'ed",
lines: "99999",
});
expect(query.lines).toBe(5000);
const args = journalArgs(query);
expect(args).toContain("-u 'nginx.service' -u 'ssh.service'");
expect(args).toContain("-p 3");
expect(args).toContain("--since '-1h'");
expect(args).toContain("-b '-1'");
expect(args).toContain(`-g 'fail'\\''ed'`);
expect(() => parseJournalQuery({ unit: "x;rm" })).toThrow();
expect(() => parseJournalQuery({ priority: "9" })).toThrow();
expect(() => parseJournalQuery({ since: "$(id)" })).toThrow();
expect(() => parseJournalQuery({ boot: "abc" })).toThrow();
});
it("parses entries, binary messages included", () => {
const entry = parseEntry(
JSON.stringify({
__REALTIME_TIMESTAMP: "1792612429684000",
PRIORITY: "3",
_SYSTEMD_UNIT: "x.service",
SYSLOG_IDENTIFIER: "x",
_PID: "12",
MESSAGE: [104, 105],
}),
);
expect(entry).toEqual({
time: 1792612429684,
priority: 3,
unit: "x.service",
ident: "x",
pid: "12",
message: "hi",
});
expect(parseEntry("not json")).toBeNull();
});
it("filters here when journalctl has no -g", () => {
const line = (message: string) =>
JSON.stringify({ __REALTIME_TIMESTAMP: "1000", MESSAGE: message });
const out = `@@TERMIX_NOGREP@@\n${line("Disk Error")}\n${line("ok")}\n`;
const query = parseJournalQuery({ grep: "error", lines: "10" });
expect(parseJournal(out, query).map((e) => e.message)).toEqual([
"Disk Error",
]);
});
it("reads boots and disk use", () => {
const boots = parseBoots(
[
"IDX BOOT ID FIRST ENTRY LAST ENTRY",
"-1 d2785a657f4c4561a1f436db27555b50 Tue 2026-10-20 13:56:45 UTC Wed 2026-10-21 10:37:00 UTC",
" 0 61b15588a75b4c0cb8dea321fcc438ab Wed 2026-10-21 10:38:10 UTC Wed 2026-10-21 20:00:00 UTC",
].join("\n"),
);
expect(boots.map((b) => b.offset)).toEqual([0, -1]);
expect(boots[0].first).toBe("Wed 2026-10-21 10:38:10 UTC");
expect(
parseUsage(
"Archived and active journals take up 378.6M in the file system.",
),
).toBe("378.6M");
});
it("only takes sane vacuum values", () => {
expect(vacuumScript({ size: "500M" })).toContain("--vacuum-size=500M");
expect(vacuumScript({ time: "4w" })).toContain("--vacuum-time=4w");
expect(() => vacuumScript({ size: "1; rm -rf /" })).toThrow();
expect(() => vacuumScript({})).toThrow();
});
});
describe("openrc", () => {
it("maps services and runlevels", () => {
const units = parseOpenRcList(
[
" sshd [ started ]",
" crond [ stopped ]",
" nginx [ crashed ]",
"@@TERMIX_RUNLEVELS@@",
" sshd | default",
" nginx | ",
"",
].join("\n"),
);
expect(units.find((u) => u.name === "sshd")).toMatchObject({
active: "active",
fileState: "enabled",
});
expect(units.find((u) => u.name === "nginx")).toMatchObject({
active: "failed",
fileState: "disabled",
});
expect(units.find((u) => u.name === "crond")!.active).toBe("inactive");
});
});
@@ -0,0 +1,130 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
actionScript,
errLine,
isActiveScript,
listUnitsScript,
openRcActionScript,
parseKeys,
passwordSudoScript,
scriptError,
scriptShell,
SUDO_REFUSED_RE,
SUDO_WRONG_RE,
toBase64,
utf8Decode,
utf8Encode,
watchdog,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("text helpers", () => {
it("encodes base64 like Buffer does", () => {
for (const text of [
"",
"a",
"ab",
"abc",
"héllo wörld",
"emoji 🙂 here",
"line\nbreak",
]) {
expect(toBase64(text)).toBe(Buffer.from(text, "utf8").toString("base64"));
}
});
it("round trips UTF-8 and replaces bad bytes", () => {
const text = "plain, ünïcode, 日本, 🙂";
expect(utf8Decode(utf8Encode(text))).toBe(text);
expect(Array.from(utf8Encode(text))).toEqual(
Array.from(Buffer.from(text, "utf8")),
);
expect(utf8Decode([0x61, 0xff, 0x62])).toBe("a\ufffdb");
expect(utf8Decode([0xe6, 0x97])).toBe("\ufffd");
});
it("reads key=value lines", () => {
expect(parseKeys("a=1\nb = two=2\njunk\n")).toEqual({ a: "1", b: "two=2" });
});
});
describe("script shells", () => {
it("always reads the script from stdin", () => {
for (const mode of ["plain", "auto", "root"] as const) {
expect(scriptShell(mode)).toContain("bash -s");
}
expect(scriptShell("auto")).toContain("sudo -n true");
expect(scriptShell("root-only")).toContain("SCRIPT_ERR:");
});
it("collects error lines, old and new markers", () => {
expect(scriptError("x\nSCRIPT_ERR:one\nSERVICES_ERR:two\n")).toBe(
"one\ntwo",
);
expect(errLine("it's broken")).toBe(`echo 'SCRIPT_ERR:it'\\''s broken'`);
});
it("matches sudo refusals", () => {
expect(SUDO_REFUSED_RE.test("sudo: a password is required")).toBe(true);
expect(SUDO_WRONG_RE.test("sudo: 1 incorrect password attempt")).toBe(true);
});
it("keeps the sudo password out of the command line", () => {
const { command, stdin } = passwordSudoScript(
"systemctl restart 'a.service'\n",
"s3cr'et $(id)",
);
expect(command).not.toContain("s3cr");
expect(stdin.split("\n")[0]).toBe("IFS= read -r TERMIX_PW");
expect(stdin.split("\n")[1]).toBe("s3cr'et $(id)");
expect(stdin).toContain("sudo -k -S -p ''");
expect(stdin).not.toContain("systemctl restart");
expect(() => passwordSudoScript("x", "a\nb")).toThrow();
});
it.runIf(process.platform !== "win32")(
"runs the script and never runs the password",
() => {
const { stdin } = passwordSudoScript(
'echo "ran:$1${TERMIX_PW:-none}"\n',
"touch /tmp/should-not-exist",
);
// No sudo here: swap it for a fake that drops its own stdin line.
const fake = stdin.replace(
/printf '%s\\n' "\$P" \| sudo -k -S -p '' env PATH="\$PATH" [^\n]* bash -c "\$TERMIX_S"/,
'printf "%s\\n" "$P" | { IFS= read -r _; bash -c "$TERMIX_S"; }',
);
expect(fake).not.toBe(stdin);
const out = execFileSync(
"bash",
["-c", "sudo() { return 1; }; export -f sudo; exec bash -s"],
{
input: fake,
encoding: "utf8",
},
);
expect(out.trim()).toBe("ran:none");
},
);
});
describe("unit scripts", () => {
it("quotes units and refuses bad names", () => {
expect(actionScript("a.service", "enable-now")).toContain(
"systemctl enable --now -- 'a.service'",
);
expect(() => actionScript("a;b.service", "start")).toThrow();
expect(isActiveScript(["a.service", "b.timer"])).toContain(
"-- 'a.service' 'b.timer'",
);
expect(() => listUnitsScript("bogus" as never)).toThrow();
expect(openRcActionScript("sshd", "enable")).toContain(
"rc-update add 'sshd' default",
);
expect(() => openRcActionScript("sshd", "mask")).toThrow();
});
it("stops a watched command once stdin closes", () => {
expect(watchdog("journalctl -f")).toContain("cat >/dev/null");
});
});
+51
View File
@@ -902,6 +902,57 @@ describe("installing from a file", () => {
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
});
it("refuses a signature that does not match the file", async () => {
await boot();
await manage.setDeveloperMode(true);
await expect(
manage.stageUpload(await artifactBuffer("notes"), "bm90IGEgc2ln"),
).rejects.toMatchObject({ code: "INVALID_SIGNATURE" });
});
it("keeps a signature from a non official key unverified", async () => {
const { setCustomPluginKeys } = await import("../../plugins/trust.js");
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
try {
await boot();
await manage.setDeveloperMode(true);
setCustomPluginKeys([
{
id: "mine",
publicKey: publicKey
.export({ format: "der", type: "spki" })
.subarray(12)
.toString("base64"),
addedIn: "26.10.6",
},
]);
const buffer = await artifactBuffer("notes");
const digest = crypto.createHash("sha256").update(buffer).digest();
const signature = crypto
.sign(null, digest, privateKey)
.toString("base64");
const preview = await manage.stageUpload(buffer, signature);
expect(preview.official).toBe(false);
await manage.installUpload(preview.token, {
userId: "admin",
capabilities: preview.capabilities,
});
expect(db.rows.get("notes")?.tier).toBe(manage.UNVERIFIED_TIER);
} finally {
setCustomPluginKeys([]);
}
});
it("refuses a file that is not a plugin archive", async () => {
await boot();
await manage.setDeveloperMode(true);
+104
View File
@@ -650,6 +650,110 @@ describe("panels, dashboard cards and guest pages", () => {
});
});
describe("contributes.mobile", () => {
const tabs = [
{ id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] },
];
it("accepts each mode with its own fields", () => {
for (const mobile of [
{
mode: "webview",
entry: "dist/mobile.js",
sizes: ["phone", "tablet"],
views: ["sample"],
note: "mobile.note",
},
{ mode: "webview" },
{ mode: "native", feature: "file-manager" },
{ mode: "none", note: "Not on phones" },
]) {
expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual(
[],
);
}
});
it("needs a known mode and rejects unknown fields", () => {
const errors = validateManifest(
base({ contributes: { mobile: { mode: "app", width: 3 } } }),
).join();
expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/);
expect(errors).toMatch(/contributes\.mobile\.mode must be one of/);
expect(
validateManifest(base({ contributes: { mobile: "webview" } })).join(),
).toMatch(/"contributes\.mobile" must be an object/);
});
it("keeps webview and native fields to their own mode", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
mobile: {
mode: "native",
entry: "dist/mobile.js",
views: ["sample"],
},
},
}),
).join();
expect(errors).toMatch(/mobile\.entry is only for mode "webview"/);
expect(errors).toMatch(/mobile\.views is only for mode "webview"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }),
).join(),
).toMatch(/mobile\.feature is only for mode "native"/);
});
it("puts the entry beside the frontend bundle", () => {
const check = (entry: string, frontend?: string) =>
validateManifest(
base({
...(frontend ? { frontend } : {}),
contributes: { mobile: { mode: "webview", entry } },
}),
).join();
expect(check("dist/mobile.js")).toBe("");
expect(check("./dist/mobile.js")).toBe("");
expect(check("build/mobile.js", "build/frontend.js")).toBe("");
expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/);
expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/);
expect(check("../dist/mobile.js")).toMatch(/relative path inside/);
});
it("checks sizes, views and the feature id", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
panels: [{ id: "side", titleKey: "nav.side" }],
mobile: {
mode: "webview",
sizes: ["phone", "phone", "watch"],
views: ["sample", "side", "missing"],
},
},
}),
).join();
expect(errors).toMatch(/sizes duplicates "phone"/);
expect(errors).toMatch(/sizes entries must be one of: phone, tablet/);
expect(errors).toMatch(/views entries must be ids .*"missing"/);
expect(errors).not.toMatch(/"side"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", sizes: [] } } }),
).join(),
).toMatch(/sizes must be a non-empty array/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "native", feature: "Files" } } }),
).join(),
).toMatch(/mobile\.feature must match/);
});
});
describe("named service providers", () => {
const provides = (names: unknown) => [
{
@@ -117,6 +117,56 @@ describe("entity aliases", () => {
expect(getEntity("thing")?.readOnly).toBeUndefined();
expect(getEntity("thing")?.load).toBeUndefined();
});
it("lets a plugin sync rows it builds as read only copies", () => {
const load = async () => [];
registerEntity("demo", {
type: "sharedIcons",
table: null,
readOnly: true,
load,
write: async () => undefined,
erase: async () => undefined,
});
expect(getEntity("sharedIcons")).toMatchObject({ readOnly: true, load });
});
it("refuses a plugin entity that loads rows but can be pushed", () => {
expect(() =>
registerEntity("demo", {
type: "pushable",
table: null,
load: async () => [],
write: async () => undefined,
erase: async () => undefined,
}),
).toThrow(/readOnly/);
expect(() =>
registerEntity("demo", {
type: "halfDone",
table: null,
readOnly: true,
load: async () => [],
}),
).toThrow(/load, write and erase/);
expect(() =>
registerEntity("demo", { type: "nothing", table: null }),
).toThrow(/table or load/);
});
it("drops core only keys a plugin puts on its registration", () => {
registerEntity("demo", {
type: "sneaky",
table: {},
covers: () => true,
canReference: async () => true,
hashIgnore: ["x"],
} as never);
const entity = getEntity("sneaky")!;
expect(entity.covers).toBeUndefined();
expect(entity.canReference).toBeUndefined();
expect(entity.hashIgnore).toBeUndefined();
});
});
describe("core sync entities", () => {
+25 -2
View File
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
getLink: vi.fn(),
generateJWTToken: vi.fn(),
revokeSession: vi.fn(),
getEntity: vi.fn(),
findStoredIds: vi.fn(),
}));
vi.mock("../../utils/auth-manager.js", () => ({
@@ -28,7 +30,7 @@ vi.mock("../../utils/app-version.js", () => ({
vi.mock("../../notify/core-notify.js", () => ({ sendCoreAlert: vi.fn() }));
vi.mock("../../plugins/index.js", () => ({ getPluginRuntime: vi.fn() }));
vi.mock("../../plugins/sync-registry.js", () => ({
getEntity: vi.fn(),
getEntity: mocks.getEntity,
listEntities: () => [{ type: "hosts", owner: "core", order: 10 }],
onEntitiesChanged: vi.fn(),
}));
@@ -55,7 +57,9 @@ vi.mock("../../utils/permission-manager.js", () => ({
vi.mock("../../sync/entities.js", () => ({
registerCoreSyncEntities: vi.fn(),
}));
vi.mock("../../sync/store.js", () => ({}));
vi.mock("../../sync/store.js", () => ({
findStoredIds: mocks.findStoredIds,
}));
vi.mock("../../sync/wire.js", () => ({}));
vi.mock("../../sync/records.js", () => ({
listChanges: mocks.listChanges,
@@ -215,3 +219,22 @@ describe("host.links", () => {
});
});
});
describe("host.entities.idsOf", () => {
it("maps sync ids to the user's own row ids", async () => {
const entity = { type: "snippets", table: {} };
mocks.getEntity.mockReturnValue(entity);
mocks.findStoredIds.mockResolvedValue({ a: 3 });
expect(
await createSyncHost().entities.idsOf!("snippets", "u", ["a", "b"]),
).toEqual({ a: 3 });
expect(mocks.findStoredIds).toHaveBeenCalledWith(entity, "u", ["a", "b"]);
});
it("answers nothing for an unknown or built type", async () => {
mocks.getEntity.mockReturnValue(undefined);
expect(await createSyncHost().entities.idsOf!("x", "u", ["a"])).toEqual({});
mocks.getEntity.mockReturnValue({ type: "y", load: vi.fn() });
expect(await createSyncHost().entities.idsOf!("y", "u", ["a"])).toEqual({});
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("../../utils/logger.js", () => ({
authLogger: { info: vi.fn(), error: vi.fn() },
}));
const { renderNginxTemplate } = await import("../../tls/nginx-reload.js");
const TEMPLATE = [
"listen ${PORT};",
"listen ${SSL_PORT} ssl;",
"ssl_certificate ${SSL_CERT_PATH};",
" root /app/html;",
"pid /tmp/nginx/nginx.pid;",
].join("\n");
const values = {
port: "8080",
sslPort: "443",
certPath: "/data/ssl/termix.crt",
keyPath: "/data/ssl/termix.key",
};
describe("renderNginxTemplate", () => {
it("fills the ports and certificate", () => {
const out = renderNginxTemplate(TEMPLATE, values);
expect(out).toContain("listen 8080;");
expect(out).toContain("listen 443 ssl;");
expect(out).toContain("ssl_certificate /data/ssl/termix.crt;");
expect(out).toContain("root /app/html;");
});
it("uses the install folder in place of /app", () => {
const out = renderNginxTemplate(TEMPLATE, {
...values,
appDir: "/opt/termix/",
});
expect(out).toContain("root /opt/termix/html;");
expect(out).toContain("pid /tmp/nginx/nginx.pid;");
});
});
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
hostOverride: null as number | null,
folderOverride: null as { folder: string; credentialId: number } | null,
ownerSecret: null as Record<string, unknown> | null,
usable: new Set<number>(),
folderLookups: [] as Array<{ folder: unknown; protocol: string }>,
}));
vi.mock("../../hosts/protocol-auth/registry.js", () => ({
isAuthOverrideProtocol: (protocol: string) => protocol === "ssh",
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number) =>
state.usable.has(id) ? { id, username: `user-${id}` } : null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async () => "owner",
}),
createCurrentRbacAccessRepository: () => ({
findActiveHostAccess: async () => null,
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.hostOverride,
}),
createCurrentSharedFolderAuthOverrideRepository: () => ({
findClosest: async (_userId: string, folder: unknown, protocol: string) => {
state.folderLookups.push({ folder, protocol });
return state.folderOverride;
},
}),
}));
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({
getSecretForUser: async () => state.ownerSecret,
snapshotForUser: async () => undefined,
}),
},
}));
const { resolveRecipientSharedHostAuthentication } =
await import("../../utils/shared-host-auth-resolver.js");
const host = (extra: Record<string, unknown> = {}) =>
({
id: 42,
userId: "owner",
folder: "Prod / Web",
authType: "credential",
credentialId: 99,
shareSshAuth: false,
...extra,
}) as never;
beforeEach(() => {
state.hostOverride = null;
state.folderOverride = null;
state.ownerSecret = null;
state.usable = new Set();
state.folderLookups = [];
});
describe("folder logins for shared hosts", () => {
it("uses the folder's login when the host has none of its own", async () => {
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.usable.add(7);
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({
source: "personal-override",
credentialId: 7,
folder: "Prod",
});
expect(state.folderLookups).toEqual([
{ folder: "Prod / Web", protocol: "ssh" },
]);
});
it("lets the host's own login win over the folder's", async () => {
state.hostOverride = 5;
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.usable.add(5).add(7);
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({ credentialId: 5 });
expect(result).not.toHaveProperty("folder");
});
it("skips a folder login the user can no longer use", async () => {
state.folderOverride = { folder: "Prod", credentialId: 7 };
state.ownerSecret = { authType: "password", password: "x" };
const result = await resolveRecipientSharedHostAuthentication(
host({ shareSshAuth: true }),
42,
"recipient",
"ssh",
);
expect(result).toMatchObject({ source: "owner-shared" });
});
it("asks for a login when nothing is set", async () => {
const result = await resolveRecipientSharedHostAuthentication(
host(),
42,
"recipient",
"ssh",
);
expect(result).toEqual({ source: "required" });
});
});
@@ -17,8 +17,14 @@ const state = vi.hoisted(() => ({
}));
vi.mock("../../hosts/usable-credential.js", () => ({
findUsableCredential: async (credentialId: number) =>
state.credentials.get(credentialId) ?? null,
findUsableCredential: async (credentialId: number, userId: string) => {
const credential = state.credentials.get(credentialId);
if (!credential) return null;
const sharedWith = (credential.sharedWith as string[] | undefined) ?? [];
return credential.userId === userId || sharedWith.includes(userId)
? credential
: null;
},
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -29,8 +35,10 @@ vi.mock("../../database/repositories/factory.js", () => ({
findHostById: async (hostId: number) => state.hosts.get(hostId) ?? null,
findHostOwnerId: async (hostId: number) =>
(state.hosts.get(hostId)?.userId as string) ?? null,
findCredentialByIdForUser: async (credentialId: number) =>
state.credentials.get(credentialId) ?? null,
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
state.credentials.get(credentialId)?.userId === userId
? state.credentials.get(credentialId)
: null,
}),
createCurrentSharedHostSecretsRepository: () => ({
upsert: async (row: Record<string, unknown>) => {
@@ -260,6 +268,39 @@ describe("SharedHostSecretsManager", () => {
});
});
it("snapshots SSH auth from a credential shared with the host owner", async () => {
state.credentials.set(123, {
id: 123,
userId: "cred-owner",
sharedWith: ["owner"],
username: "cred-user",
authType: "password",
password: "cred-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
state.hosts.set(
42,
baseHost({
authType: "credential",
credentialId: 123,
shareSshAuth: true,
password: null,
}),
);
await manager.snapshotForUser(7, 42, "target", "owner");
expect(state.secretRows.map((row) => row.protocol)).toEqual(["ssh"]);
expect(await manager.getSecretForUser(42, "target", "ssh")).toMatchObject({
username: "cred-user",
authType: "password",
password: "cred-pass",
});
});
it("snapshots a credential-backed protocol login from the credential", async () => {
state.credentials.set(55, {
id: 55,
@@ -11,6 +11,7 @@ import {
getFriendlyKeyTypeName,
validateKeyPair,
isSecurityKeyFile,
clearUnlockedKeyCache,
} from "../../utils/ssh-key-utils.js";
const { parsePrivateKey } = sshpk;
@@ -226,6 +227,32 @@ describe("PKCS#8 runtime key normalization", () => {
});
});
describe("encrypted OpenSSH keys", () => {
const key = sshpk.generatePrivateKey("ed25519");
const encrypted = key.toString("openssh", { passphrase: "pw" }).trim();
it("unlocks once and hands ssh2 a key that needs no passphrase", () => {
clearUnlockedKeyCache();
const first = preparePrivateKeyForSSH2(encrypted, "pw");
const parsed = ssh2.utils.parseKey(first);
expect(parsed).not.toBeInstanceOf(Error);
expect(parsePrivateKey(first.toString()).fingerprint().toString()).toBe(
key.fingerprint().toString(),
);
expect(preparePrivateKeyForSSH2(encrypted, "pw").toString()).toBe(
first.toString(),
);
});
it("leaves the key locked for a wrong or missing passphrase", () => {
clearUnlockedKeyCache();
expect(preparePrivateKeyForSSH2(encrypted, "wrong").toString()).toBe(
encrypted,
);
expect(preparePrivateKeyForSSH2(encrypted).toString()).toBe(encrypted);
});
});
describe("security key files", () => {
const str = (v: string | Buffer) => {
const b = Buffer.from(v);
+45 -15
View File
@@ -3,9 +3,40 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import { authLogger } from "../utils/logger.js";
const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template";
const NGINX_CONF = "/tmp/nginx/nginx.conf";
const NGINX_PID = "/tmp/nginx/nginx.pid";
// The Docker image's paths. An install outside Docker (a Proxmox LXC script,
// a source checkout) points these at its own nginx instead.
const nginxTemplate = () =>
process.env.TERMIX_NGINX_TEMPLATE || "/app/nginx/nginx-https.conf.template";
const nginxConf = () =>
process.env.TERMIX_NGINX_CONF || "/tmp/nginx/nginx.conf";
const nginxPid = () => process.env.TERMIX_NGINX_PID || "/tmp/nginx/nginx.pid";
/** The template with Termix's own folder in place of the image's /app. */
export function renderNginxTemplate(
template: string,
values: {
port: string;
sslPort: string;
certPath: string;
keyPath: string;
appDir?: string;
},
): string {
let rendered = template
.split("${PORT}")
.join(values.port)
.split("${SSL_PORT}")
.join(values.sslPort)
.split("${SSL_CERT_PATH}")
.join(values.certPath)
.split("${SSL_KEY_PATH}")
.join(values.keyPath);
const appDir = values.appDir?.replace(/\/+$/, "");
if (appDir && appDir !== "/app") {
rendered = rendered.replace(/(^|[\s;])\/app\//gm, `$1${appDir}/`);
}
return rendered;
}
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
@@ -51,11 +82,13 @@ export function persistSSLEnv(
* restart. No-ops outside the Docker image (template/binary won't exist).
*/
export function reloadNginxWithSSL(): { applied: boolean; message: string } {
if (!existsSync(NGINX_TEMPLATE) || !existsSync(NGINX_PID)) {
const NGINX_TEMPLATE = nginxTemplate();
const NGINX_CONF = nginxConf();
if (!existsSync(NGINX_TEMPLATE) || !existsSync(nginxPid())) {
return {
applied: false,
message:
"nginx is not managed by this environment; restart Termix with ENABLE_SSL=true to apply the certificate.",
"Termix could not find the nginx it runs with, so HTTPS is not on yet. In Docker, restart Termix with ENABLE_SSL=true. On another install, set TERMIX_NGINX_TEMPLATE, TERMIX_NGINX_CONF and TERMIX_NGINX_PID (and TERMIX_APP_DIR if it is not /app), or add a listen 443 ssl block with this certificate to your nginx config.",
};
}
@@ -66,16 +99,13 @@ export function reloadNginxWithSSL(): { applied: boolean; message: string } {
const keyPath = process.env.SSL_KEY_PATH || path.join(SSL_DIR, "termix.key");
try {
const template = readFileSync(NGINX_TEMPLATE, "utf8");
const rendered = template
.split("${PORT}")
.join(port)
.split("${SSL_PORT}")
.join(sslPort)
.split("${SSL_CERT_PATH}")
.join(certPath)
.split("${SSL_KEY_PATH}")
.join(keyPath);
const rendered = renderNginxTemplate(readFileSync(NGINX_TEMPLATE, "utf8"), {
port,
sslPort,
certPath,
keyPath,
appDir: process.env.TERMIX_APP_DIR,
});
writeFileSync(NGINX_CONF, rendered);
@@ -1,10 +1,10 @@
import type { AuthOverrideProtocol } from "../../types/auth-protocols.js";
import { isAuthOverrideProtocol } from "../hosts/protocol-auth/registry.js";
import {
createCurrentCredentialRepository,
createCurrentSharedHostAuthOverrideRepository,
createCurrentUserRepository,
} from "../database/repositories/factory.js";
import { findUsableCredential } from "../hosts/usable-credential.js";
import { logAudit } from "./audit-logger.js";
import { PermissionManager } from "./permission-manager.js";
@@ -60,11 +60,7 @@ export class SharedHostAuthOverrideService {
await this.requireSharedHostAccess(hostId, userId);
if (credentialId !== null) {
const credential =
await createCurrentCredentialRepository().findByIdForUser(
userId,
credentialId,
);
const credential = await findUsableCredential(credentialId, userId);
if (!credential) {
throw new SharedHostAuthOverrideServiceError(
"Credential not found",
+43 -6
View File
@@ -4,6 +4,7 @@ import {
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentSharedFolderAuthOverrideRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../database/repositories/factory.js";
import type {
@@ -12,6 +13,7 @@ import type {
} from "../database/repositories/host-resolution-repository.js";
import { getSshAuthProvider } from "../hosts/connect/auth-provider-registry.js";
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { findUsableCredential } from "../hosts/usable-credential.js";
import {
SharedHostSecretsManager,
type SharedSecretData,
@@ -22,6 +24,8 @@ export type RecipientSharedHostAuthResolution =
source: "personal-override";
credentialId: number;
credential: HostResolutionCredentialRecord;
/** Set when the login comes from the folder, not the host itself. */
folder?: string;
}
| {
source: "owner-shared";
@@ -53,8 +57,8 @@ function isOwnerAuthShared(
/**
* Applies the shared-host authentication precedence independently from any
* transport: recipient override, shared owner auth, secretless auth, then
* "required".
* transport: recipient override on the host, then on its folder, shared
* owner auth, secretless auth, then "required".
*/
export async function resolveRecipientSharedHostAuthentication(
host: HostResolutionHostRecord,
@@ -80,10 +84,7 @@ export async function resolveRecipientSharedHostAuthentication(
}
if (overrideCredentialId) {
const credential = await repository.findCredentialByIdForUser(
overrideCredentialId,
userId,
);
const credential = await findUsableCredential(overrideCredentialId, userId);
if (credential) {
return {
source: "personal-override",
@@ -93,6 +94,31 @@ export async function resolveRecipientSharedHostAuthentication(
}
}
try {
const folderOverride =
await createCurrentSharedFolderAuthOverrideRepository().findClosest(
userId,
host.folder,
protocol,
);
if (folderOverride) {
const credential = await findUsableCredential(
folderOverride.credentialId,
userId,
);
if (credential) {
return {
source: "personal-override",
credentialId: folderOverride.credentialId,
credential,
folder: folderOverride.folder,
};
}
}
} catch {
// Same as a host override: an unreadable one counts as none.
}
if (isOwnerAuthShared(host, protocol)) {
if (protocol === "ssh" && host.authType === "agent") {
return {
@@ -145,6 +171,17 @@ export async function resolveRecipientSharedHostAuthentication(
}
}
if (protocol === "ssh" && host.credentialId) {
const credential = await findUsableCredential(host.credentialId, userId);
if (credential) {
return {
source: "personal-override",
credentialId: host.credentialId,
credential,
};
}
}
return requiresPersonalHostAuthentication(host, protocol)
? { source: "required" }
: { source: "secretless" };
@@ -381,12 +381,11 @@ class SharedHostSecretsManager {
host: HostResolutionHostRecord,
ownerId: string,
): Promise<ProtocolSnapshot[]> {
const repository = createCurrentHostResolutionRepository();
const snapshots: ProtocolSnapshot[] = [];
if (sshEnabled(host) && host.shareSshAuth) {
if (host.credentialId) {
const credential = await repository.findCredentialByIdForUser(
const credential = await findUsableCredential(
host.credentialId,
ownerId,
);
+49 -2
View File
@@ -1,4 +1,4 @@
import { createPrivateKey } from "node:crypto";
import { createHash, createPrivateKey } from "node:crypto";
import sshpk from "sshpk";
import { getErrorMessage } from "./error-message.js";
import ssh2Pkg from "ssh2";
@@ -264,12 +264,59 @@ export function isSecurityKeyFile(privateKeyData: string): boolean {
}
}
// Unlocking an OpenSSH key runs bcrypt on the main thread (about a second per
// try), and every connect used to do it 3 or more times. Keep the unlocked copy
// in memory for a short while so a burst of connects pays for it once.
const UNLOCKED_KEY_TTL_MS = 15 * 60 * 1000;
const UNLOCKED_KEY_MAX = 64;
const unlockedKeys = new Map<string, { text: string; expires: number }>();
function unlockOpenSSHKey(cleanKey: string, passphrase?: string): string {
if (
!passphrase ||
!cleanKey.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")
)
return cleanKey;
const id = createHash("sha256")
.update(cleanKey)
.update("\n")
.update(passphrase)
.digest("hex");
const now = Date.now();
const hit = unlockedKeys.get(id);
if (hit && hit.expires > now) return hit.text;
unlockedKeys.delete(id);
let text: string;
try {
text = parsePrivateKey(cleanKey, "openssh", { passphrase }).toString(
"openssh",
);
} catch {
// Wrong passphrase or a cipher sshpk lacks; the normal path reports it.
return cleanKey;
}
if (ssh2Utils.parseKey(text) instanceof Error) return cleanKey;
if (unlockedKeys.size >= UNLOCKED_KEY_MAX) {
const oldest = unlockedKeys.keys().next().value;
if (oldest !== undefined) unlockedKeys.delete(oldest);
}
unlockedKeys.set(id, { text, expires: now + UNLOCKED_KEY_TTL_MS });
return text;
}
export function clearUnlockedKeyCache(): void {
unlockedKeys.clear();
}
export function preparePrivateKeyForSSH2(
privateKeyData: string,
passphrase?: string,
): Buffer {
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
let cleanKey = normalizePrivateKeyText(privateKeyData);
let cleanKey = unlockOpenSSHKey(
normalizePrivateKeyText(privateKeyData),
passphrase,
);
if (
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error
+5 -1
View File
@@ -12,6 +12,7 @@ import { ThemeProvider } from "@/components/theme-provider";
import "./ui/i18n/i18n";
import { isElectron } from "@/lib/electron";
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
import { captureAppReturn } from "@/lib/app-return";
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
import { Toaster } from "@/components/sonner";
@@ -168,8 +169,11 @@ function App() {
// Keeps the window controls the system draws in the colors of the theme.
useTitleBarOverlayColors();
const stored = getStoredAuth();
// A sign in for the mobile app always shows the login form, so the app gets
// its own fresh session.
const [appReturn] = useState(captureAppReturn);
const [phase, setPhase] = useState<Phase>(
stored?.loggedIn ? "verifying" : "idle-auth",
stored?.loggedIn && !appReturn ? "verifying" : "idle-auth",
);
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
+7
View File
@@ -87,6 +87,8 @@ export interface HostSidebarDisplayPreferences extends HostRowFields {
openOnDoubleClick: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths: boolean;
/** When false, rows keep one background instead of alternating. */
stripedRows: boolean;
hostClickBehavior: HostClickBehavior;
/** When true, the sidebar closes after a host is opened from it. */
closeOnOpen: boolean;
@@ -164,6 +166,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
statusColorScheme: "accent",
openOnDoubleClick: false,
showFolderPaths: true,
stripedRows: true,
hostClickBehavior: "newTab",
closeOnOpen: false,
barActions: {},
@@ -272,6 +275,10 @@ export function sanitizeHostSidebarPreferences(
typeof displayObj.showFolderPaths === "boolean"
? displayObj.showFolderPaths
: defaults.display.showFolderPaths,
stripedRows:
typeof displayObj.stripedRows === "boolean"
? displayObj.stripedRows
: defaults.display.stripedRows,
hostClickBehavior: HOST_CLICK_BEHAVIORS.includes(
displayObj.hostClickBehavior as HostClickBehavior,
)
+8
View File
@@ -72,6 +72,10 @@ export type Host = {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;
@@ -182,6 +186,10 @@ export interface HostData {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;
+15 -2
View File
@@ -643,11 +643,24 @@ export interface PluginUploadPreview {
features?: string[];
capabilities: string[];
replaces: string | null;
official?: boolean;
}
export async function uploadPlugin(file: Blob): Promise<PluginUploadPreview> {
export async function uploadPlugin(
file: Blob,
signature?: string,
): Promise<PluginUploadPreview> {
const response = await rbacApi.post("/plugins/upload", file, {
headers: { "Content-Type": "application/octet-stream" },
headers: {
"Content-Type": "application/octet-stream",
...(signature
? {
"X-Termix-Plugin-Signature": btoa(
String.fromCharCode(...new TextEncoder().encode(signature)),
),
}
: {}),
},
});
return response.data;
}
+55 -2
View File
@@ -238,9 +238,14 @@ export async function getFolderAccess(
}
}
export async function revokeFolderAccess(ruleId: number): Promise<void> {
export async function revokeFolderAccess(
ruleId: number,
options: { fromHosts?: boolean } = {},
): Promise<void> {
try {
await rbacApi.delete(`/rbac/folder/access/${ruleId}`);
await rbacApi.delete(`/rbac/folder/access/${ruleId}`, {
params: options.fromHosts ? { fromHosts: "true" } : undefined,
});
} catch (error) {
throw handleApiError(error, "revoke folder access");
}
@@ -383,6 +388,54 @@ export async function getHostAuthOverride(
}
}
/**
* The user's own login for hosts shared with them in a folder. On a desktop
* linked to a server, pass a shared host from the folder so the call goes to
* that server.
*/
export async function getFolderAuthOverride(
folder: string,
protocol: AuthOverrideProtocol,
sharedCopySyncId?: string | null,
): Promise<{
folder: string;
protocol: AuthOverrideProtocol;
credentialId: number | null;
}> {
try {
const target = await getSharedCopyTarget(0, sharedCopySyncId);
const response = await target.api.get(`/rbac/folder-auth/${protocol}`, {
params: { folder },
});
return response.data;
} catch (error) {
throw handleApiError(error, "fetch folder authentication override");
}
}
export async function setFolderAuthOverride(
folder: string,
protocol: AuthOverrideProtocol,
credentialId: number | null,
sharedCopySyncId?: string | null,
): Promise<{
success: boolean;
folder: string;
protocol: AuthOverrideProtocol;
credentialId: number | null;
}> {
try {
const target = await getSharedCopyTarget(0, sharedCopySyncId);
const response = await target.api.put(`/rbac/folder-auth/${protocol}`, {
folder,
credentialId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "update folder authentication override");
}
}
export async function setHostAuthOverride(
hostId: number,
protocol: AuthOverrideProtocol,
+33 -19
View File
@@ -1,9 +1,11 @@
/* eslint-disable react-refresh/only-export-components */
import {
isEmbeddedAuthFrame,
isInElectronWebView,
isInMobileWebView,
type EmbeddedFrameWindow,
} from "@/lib/embedded-frame";
import { handOffToApp, pendingAppReturn } from "@/lib/app-return";
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -145,6 +147,13 @@ export function storeAuth(username: string) {
);
}
/** A browser login the mobile app asked for goes back to the app. */
async function handOffLogin(token?: string | null): Promise<boolean> {
if (!pendingAppReturn() || isEmbeddedAuthFrame()) return false;
const value = token || (await getCurrentToken());
return handOffToApp(value ? { token: value } : { error: "no_token" });
}
type AuthView = "login" | "register" | "reset" | "second-factor" | "external";
type ResetStep = "email" | "code" | "newpass";
@@ -331,8 +340,9 @@ export function Auth({ onLogin }: AuthProps) {
if (proxySigninHandledRef.current || isElectron()) return;
proxySigninHandledRef.current = true;
requestTrustedProxyLogin()
.then((result) => {
.then(async (result) => {
if (!result.enabled || !result.success) return;
if (await handOffLogin()) return;
storeAuth(result.username || "");
onLogin(
result.username || "",
@@ -625,24 +635,27 @@ export function Auth({ onLogin }: AuthProps) {
}
return;
}
getUserInfo()
.then((meRes) => {
storeAuth(meRes.username || "");
clearDesktopManualLogout();
onLogin(
meRes.username || "",
meRes.userId || undefined,
!!meRes.is_admin,
);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.catch(() => {
toast.error(t("errors.failedUserInfo"));
});
void handOffLogin(urlParams.get("token")).then((handedOff) => {
if (handedOff) return;
getUserInfo()
.then((meRes) => {
storeAuth(meRes.username || "");
clearDesktopManualLogout();
onLogin(
meRes.username || "",
meRes.userId || undefined,
!!meRes.is_admin,
);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.catch(() => {
toast.error(t("errors.failedUserInfo"));
});
});
}
}, [onLogin, t]);
@@ -746,6 +759,7 @@ export function Auth({ onLogin }: AuthProps) {
setWebviewAuthSuccess(true);
return;
}
if (await handOffLogin(res?.token)) return;
const meRes = await getUserInfo();
const name =
meRes.username || res.username || options.fallbackUsername || "";
+52 -12
View File
@@ -14,11 +14,32 @@ export interface DataColumn<T> {
/** Any grid track: "1fr", "120px", "minmax(0,2fr)". */
width: string;
cell: (item: T) => React.ReactNode;
/** Dropped below md, for columns that are nice rather than necessary. */
/** Hidden when the table itself is narrower than md (48rem) / lg (64rem). */
hideBelow?: "md" | "lg";
align?: "start" | "end";
}
/** Tracks for the columns visible at each width, so hidden ones take no space. */
function listColumnTemplates<T>(columns: DataColumn<T>[]): {
base: string;
md: string;
lg: string;
} {
return {
base:
columns
.filter((column) => column.hideBelow === undefined)
.map((column) => column.width)
.join(" ") || "none",
md:
columns
.filter((column) => column.hideBelow !== "lg")
.map((column) => column.width)
.join(" ") || "none",
lg: columns.map((column) => column.width).join(" ") || "none",
};
}
export interface GridColumns {
base?: number;
md?: number;
@@ -52,6 +73,9 @@ const XL_COLS: Record<number, string> = {
4: "xl:grid-cols-4",
};
const LIST_GRID_CLASS =
"grid grid-cols-[var(--data-view-cols)] @min-[48rem]:grid-cols-[var(--data-view-cols-md)] @min-[64rem]:grid-cols-[var(--data-view-cols-lg)] gap-x-2 px-3";
function gridClass(columns: GridColumns): string {
return cn(
COLS[columns.base ?? 1],
@@ -61,6 +85,12 @@ function gridClass(columns: GridColumns): string {
);
}
function columnVisibility(hideBelow?: "md" | "lg"): string {
if (hideBelow === "md") return "hidden @min-[48rem]:block";
if (hideBelow === "lg") return "hidden @min-[64rem]:block";
return "";
}
/** One collection as a card grid or a dense row table. */
export function DataView<T>({
items,
@@ -120,14 +150,26 @@ export function DataView<T>({
);
}
const template = listColumns.map((c) => c.width).join(" ");
const rowHeight = density === "compact" ? "h-7" : "h-9";
const templates = listColumnTemplates(listColumns);
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
return (
<div className={cn("border border-border bg-card", className)} role="table">
<div
className={cn("@container border border-border bg-card", className)}
style={
{
"--data-view-cols": templates.base,
"--data-view-cols-md": templates.md,
"--data-view-cols-lg": templates.lg,
} as React.CSSProperties
}
role="table"
>
<div
className="sticky top-0 z-10 grid items-center border-b border-border bg-card px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: template }}
className={cn(
LIST_GRID_CLASS,
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
)}
>
{listColumns.map((column) => (
<span
@@ -135,8 +177,7 @@ export function DataView<T>({
className={cn(
"truncate py-2",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
columnVisibility(column.hideBelow),
)}
>
{column.header}
@@ -152,12 +193,12 @@ export function DataView<T>({
key={getKey(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn(
"grid items-center gap-x-2 border-b border-border/50 px-3 text-xs transition-colors last:border-0",
LIST_GRID_CLASS,
"items-center border-b border-border/50 text-xs transition-colors last:border-0",
rowHeight,
onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
)}
style={{ gridTemplateColumns: template }}
>
{listColumns.map((column) => (
<div
@@ -165,8 +206,7 @@ export function DataView<T>({
className={cn(
"min-w-0 truncate",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
columnVisibility(column.hideBelow),
)}
>
{column.cell(item)}
+61
View File
@@ -0,0 +1,61 @@
/**
* Sign in for the Termix mobile app in a real browser. The app opens the
* login page with ?return_to=termix-mobile://..., and after any login method
* the page sends the token back to that address. Only the app's own scheme
* is accepted, so the value can never send a token to a website.
*/
const STORAGE_KEY = "termix-app-return";
const PARAM = "return_to";
const APP_RETURN = /^termix-mobile:\/\/[A-Za-z0-9._~/-]*$/;
export function isAppReturn(value: unknown): value is string {
return typeof value === "string" && APP_RETURN.test(value);
}
/** Reads return_to from the address bar once and keeps it for this tab. */
export function captureAppReturn(): string | null {
try {
const url = new URL(window.location.href);
const value = url.searchParams.get(PARAM);
if (value !== null) {
url.searchParams.delete(PARAM);
window.history.replaceState({}, document.title, url.toString());
if (isAppReturn(value)) sessionStorage.setItem(STORAGE_KEY, value);
else sessionStorage.removeItem(STORAGE_KEY);
}
return pendingAppReturn();
} catch {
return null;
}
}
export function pendingAppReturn(): string | null {
try {
const value = sessionStorage.getItem(STORAGE_KEY);
return isAppReturn(value) ? value : null;
} catch {
return null;
}
}
export function appReturnUrl(
base: string,
params: Record<string, string>,
): string {
const query = new URLSearchParams(params).toString();
return query ? `${base}?${query}` : base;
}
/** Sends the result to the app. False when no app asked for it. */
export function handOffToApp(params: Record<string, string>): boolean {
const base = pendingAppReturn();
if (!base) return false;
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// The redirect still goes out.
}
window.location.replace(appReturnUrl(base, params));
return true;
}
+4
View File
@@ -590,6 +590,8 @@
"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.",
"stripedRows": "Alternating row colors",
"stripedRowsDesc": "Shade every other row in the host list",
"rowDetailsTitle": "Row details",
"rowDetailsDesc": "Choose what shows on each host.",
"showAddress": "Address",
@@ -775,6 +777,8 @@
"sharedBadge": "Shared",
"sharedBadgeTooltip": "Shared by {{owner}} ({{level}} access)",
"viewOnlyBanner": "This host is shared with you by {{owner}} with view access. The configuration is read-only.",
"folderRuleRemoveFromHosts": "Also remove this access from every host in the folder and its subfolders",
"sharedCopyBanner": "This host is shared with you by {{owner}}. The desktop keeps a read-only copy, so change it on the server and it syncs here.",
"sharedEditBanner": "This host is shared with you by {{owner}} with edit access. Changes apply to the real host; authentication references can only be changed by the owner.",
"ownerOnlyControl": "Only the host owner can change this",
"ownerAuthPrivate": "The host owner's SSH authentication is private. Use “Set personal SSH authentication” from the host menu to choose your own credential.",
+4
View File
@@ -590,6 +590,8 @@
"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.",
"stripedRows": "Wisselende rykleure",
"stripedRowsDesc": "Skadu elke tweede ry in die gasheerlys",
"rowDetailsTitle": "Rybesonderhede",
"rowDetailsDesc": "Kies wat op elke gasheerbediener verskyn.",
"showAddress": "Adres",
@@ -775,6 +777,8 @@
"sharedBadge": "Gedeel",
"sharedBadgeTooltip": "Gedeel deur {{owner}} ({{level}}-toegang)",
"viewOnlyBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met lees-toegang. Die konfigurasie is lees-slegs.",
"folderRuleRemoveFromHosts": "Verwyder ook hierdie toegang van elke gasheer in die gids en sy subgidse",
"sharedCopyBanner": "Hierdie gasheer word met jou gedeel deur {{owner}}. Die werkskoppie hou 'n lees-slegs kopie, so verander dit op die bediener en dit sinkroniseer hier.",
"sharedEditBanner": "Hierdie gasheer is met jou gedeel deur {{owner}} met wysig-toegang. Veranderinge word op die werklike gasheer toegepas; autentikasie-verwysings kan slegs deur die eienaar verander word.",
"ownerOnlyControl": "Slegs die gasheer-eienaar kan dit verander",
"ownerAuthPrivate": "Die gasheer-eienaar se SSH-authentikasie is privaat. Gebruik “Stel persoonlike SSH-authentikasie” in die gasheer-menu om jou eie geloofsbewys te kies.",
+4
View File
@@ -614,6 +614,8 @@
"closeOnOpenDesc": "إغلاق الشريط الجانبي بعد فتح مضيف منه",
"showFolderPaths": "إظهار مسارات المجلدات",
"showFolderPathsDesc": "إظهار مسار المجلد الأب فوق أسماء المجلدات الفرعية المتداخلة. تظل المجلدات الفرعية مضمنة في كلتا الحالتين.",
"stripedRows": "ألوان صفوف متناوبة",
"stripedRowsDesc": "تظليل كل صف ثانٍ في قائمة المضيفين",
"rowDetailsTitle": "تفاصيل الصف",
"rowDetailsDesc": "اختر ما يظهر على كل جهاز.",
"showAddress": "العنوان",
@@ -823,6 +825,8 @@
"sharedBadge": "مشترك",
"sharedBadgeTooltip": "مشارك من قبل {{owner}} (وصول {{level}})",
"viewOnlyBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية العرض فقط. التكوين للقراءة فقط.",
"folderRuleRemoveFromHosts": "إزالة هذا الوصول أيضاً من كل مضيف في المجلد والمجلدات الفرعية الخاصة به",
"sharedCopyBanner": "تم مشاركة هذا المضيف معك بواسطة {{owner}}. يحتفظ سطح المكتب بنسخة للقراءة فقط، لذا قم بتعديله على الخادم وسيتم مزامنته هنا.",
"sharedEditBanner": "هذا المضيف مشارك معك من قبل {{owner}} بصلاحية التعديل. التغييرات تنطبق على المضيف الفعلي؛ لا يمكن تغيير مراجع المصادقة إلا من قبل المالك.",
"ownerOnlyControl": "يمكن للمالك فقط تغيير هذا",
"ownerAuthPrivate": "مصادقة SSH الخاصة بمالك المضيف خاصة. استخدم “تعيين مصادقة SSH شخصية” من قائمة المضيف لاختيار بيانات الاعتماد الخاصة بك.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Затвори страничната лента след като отвориш хост от нея",
"showFolderPaths": "Показване на пътищата до папките",
"showFolderPathsDesc": "Показване на пътя до родителската папка над имената на вложените подпапки. Подпапките оставят отстъп в двата случая.",
"stripedRows": "Чередуване на цветовете на редовете",
"stripedRowsDesc": "Оцветяване на всеки втори ред в списъка с хостове",
"rowDetailsTitle": "Детайли на реда",
"rowDetailsDesc": "Изберете какво се показва за всеки хост.",
"showAddress": "Адрес",
@@ -775,6 +777,8 @@
"sharedBadge": "Споделен",
"sharedBadgeTooltip": "Споделен от {{owner}} (достъп: {{level}})",
"viewOnlyBanner": "Този хост е споделен с вас от {{owner}} с достъп само за преглед. Конфигурацията е само за четене.",
"folderRuleRemoveFromHosts": "Премахване на достъпа и от всички хостове в папката и подпапките ѝ",
"sharedCopyBanner": "Този хост е споделен с вас от {{owner}}. Работният плот запазва само за четене копия, така че променете го на сървъра и той ще се синхронизира тук.",
"sharedEditBanner": "Този хост е споделен с вас от {{owner}} с достъп за редактиране. Промените се прилагат към реалния хост; референциите за автентикация могат да бъдат променени само от собственика.",
"ownerOnlyControl": "Само собственикът на хоста може да промени това",
"ownerAuthPrivate": "SSH автентикацията на собственика на хоста е частна. Използвайте „Задай лична SSH автентикация“ от менюто на хоста, за да изберете свои собствени данни за вход.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "সাইডবার থেকে কোনো হোস্ট খোলার পর সাইডবার বন্ধ করুন",
"showFolderPaths": "ফোল্ডার পথ দেখান",
"showFolderPathsDesc": "নেস্টেড সাবফোল্ডার নামের উপরে প্যারেন্ট ফোল্ডার পথ দেখান। সাবফোল্ডার যেকোনো ক্ষেত্রেই ইনডেন্ট থাকবে।",
"stripedRows": "বিকল্প সারির রঙ",
"stripedRowsDesc": "হোস্ট তালিকায় প্রতিটি বিকল্প সারি রঙিন করুন",
"rowDetailsTitle": "সারির বিবরণ",
"rowDetailsDesc": "প্রতিটি হোস্টে কী দেখাবে তা নির্বাচন করুন।",
"showAddress": "ঠিকানা",
@@ -775,6 +777,8 @@
"sharedBadge": "শেয়ার করা",
"sharedBadgeTooltip": "{{owner}} দ্বারা শেয়ার করা ({{level}} অ্যাক্সেস)",
"viewOnlyBanner": "{{owner}} এই হোস্টটি আপনার সাথে ভিউ অ্যাক্সেস সহ শেয়ার করেছেন। কনফিগারেশনটি রিড-অনলি।",
"folderRuleRemoveFromHosts": "এই ফোল্ডার এবং এর সাবফোল্ডারে থাকা প্রতিটি হোস্ট থেকেও এই অ্যাক্সেস সরিয়ে দিন",
"sharedCopyBanner": "{{owner}} আপনাকে এই হোস্টটি শেয়ার করেছেন। ডেস্কটপটি একটি রিড-অনলি কপি রাখে, তাই সার্ভারে এটি পরিবর্তন করলে এটি এখানে সিঙ্ক হবে।",
"sharedEditBanner": "{{owner}} এই হোস্টটি আপনার সাথে এডিট অ্যাক্সেস সহ শেয়ার করেছেন। পরিবর্তনগুলো আসল হোস্টে প্রয়োগ হবে; অথেন্টিকেশন রেফারেন্স শুধুমাত্র মালিক পরিবর্তন করতে পারবেন।",
"ownerOnlyControl": "শুধুমাত্র হোস্ট মালিক এটি পরিবর্তন করতে পারেন",
"ownerAuthPrivate": "হোস্ট মালিকের SSH অথেন্টিকেশন প্রাইভেট। আপনার নিজস্ব ক্রেডেনশিয়াল নির্বাচন করতে হোস্ট মেনু থেকে “সেট পার্সোনাল SSH অথেন্টিকেশন” ব্যবহার করুন।",
+4
View File
@@ -596,6 +596,8 @@
"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.",
"stripedRows": "Colors alternats de les files",
"stripedRowsDesc": "Aplicar un color alternat a cada file de la llista d'amfitrions",
"rowDetailsTitle": "Detalls de la fila",
"rowDetailsDesc": "Tria què es mostra en cada amfitrió.",
"showAddress": "Adreça",
@@ -787,6 +789,8 @@
"sharedBadge": "Compartit",
"sharedBadgeTooltip": "Compartit per {{owner}} (accés {{level}})",
"viewOnlyBanner": "Aquest host us ha estat compartit per {{owner}} amb accés de visualització. La configuració és de només lectura.",
"folderRuleRemoveFromHosts": "També elimina aquest accés de tots els amfitrions de la carpeta i les seves subcarpetes",
"sharedCopyBanner": "Aquest amfitrió ha estat compartit amb tu per {{owner}}. L'escriptori manté una còpia de només lectura, de manera que si el modifiques al servidor, els canvis es sincronitzaran aquí.",
"sharedEditBanner": "Aquest host us ha estat compartit per {{owner}} amb accés d'edició. Els canvis s'apliquen a l'host real; les referències d'autenticació només les pot modificar el propietari.",
"ownerOnlyControl": "Només el propietari de l'host pot canviar això",
"ownerAuthPrivate": "L'autenticació SSH del propietari de l'host és privada. Utilitzeu «Establir autenticació SSH personal» al menú de l'host per triar la vostra pròpia credencial.",
+4
View File
@@ -602,6 +602,8 @@
"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é.",
"stripedRows": "Střídavé barvy řádků",
"stripedRowsDesc": "Ztmavit každý druhý řádek v seznamu hostitelů",
"rowDetailsTitle": "Detaily řádku",
"rowDetailsDesc": "Vyberte, co se zobrazí u každého hosta.",
"showAddress": "Adresa",
@@ -799,6 +801,8 @@
"sharedBadge": "Sdílený",
"sharedBadgeTooltip": "Sdíleno uživatelem {{owner}} (přístup {{level}})",
"viewOnlyBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pouze pro zobrazení. Konfigurace je pouze pro čtení.",
"folderRuleRemoveFromHosts": "Tento přístup odebrat také ze všech hostitelů v této složce a jejích podsložkách",
"sharedCopyBanner": "Tento hostitel vám sdílí {{owner}}. Desktop si uchovává kopii pouze pro čtení, takže změny proveďte na serveru a synchronizují se zde.",
"sharedEditBanner": "Tento hostitel vám byl sdílen uživatelem {{owner}} s přístupem pro úpravy. Změny se projeví na skutečném hostiteli; ověřovací údaje může měnit pouze vlastník.",
"ownerOnlyControl": "Toto může změnit pouze vlastník hostitele",
"ownerAuthPrivate": "SSH ověřování vlastníka hostitele je soukromé. V nabídce hostitele použijte „Nastavit vlastní SSH ověřování“ pro výběr vlastních přihlašovacích údajů.",
+4
View File
@@ -590,6 +590,8 @@
"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.",
"stripedRows": "Skiftende rækkefarver",
"stripedRowsDesc": "Skyg hver anden række i værtslisten",
"rowDetailsTitle": "Radedetaljer",
"rowDetailsDesc": "Vælg, hvad der vises for hver vært.",
"showAddress": "Adresse",
@@ -775,6 +777,8 @@
"sharedBadge": "Delt",
"sharedBadgeTooltip": "Delt af {{owner}} ({{level}}-adgang)",
"viewOnlyBanner": "Denne vært er delt med dig af {{owner}} med visningsadgang. Konfigurationen er skrivebeskyttet.",
"folderRuleRemoveFromHosts": "Fjern også denne adgang fra alle værter i mappen og dens undermapper",
"sharedCopyBanner": "Denne vært er delt med dig af {{owner}}. Skrivebordet gemmer en skrivebeskyttet kopi, så ændr den på serveren, og den synkroniseres her.",
"sharedEditBanner": "Denne vært er delt med dig af {{owner}} med redigeringsadgang. Ændringer gælder for den faktiske vært; autentificeringsreferencer kan kun ændres af ejeren.",
"ownerOnlyControl": "Kun værtejeren kan ændre dette",
"ownerAuthPrivate": "Værtejerens SSH-autentificering er privat. Brug “Indstil personlig SSH-autentificering” i værtsmenuen for at vælge dine egne loginoplysninger.",
+4
View File
@@ -590,6 +590,8 @@
"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.",
"stripedRows": "Abwechselnde Zeilenfarben",
"stripedRowsDesc": "Jede zweite Zeile in der Host-Liste einfärben",
"rowDetailsTitle": "Zeilen-Details",
"rowDetailsDesc": "Wählen Sie aus, was bei jedem Host angezeigt wird.",
"showAddress": "Adresse",
@@ -775,6 +777,8 @@
"sharedBadge": "Geteilt",
"sharedBadgeTooltip": "Geteilt von {{owner}} ({{level}}-Zugriff)",
"viewOnlyBanner": "Dieser Host wurde von {{owner}} mit Leserechten mit Ihnen geteilt. Die Konfiguration ist schreibgeschützt.",
"folderRuleRemoveFromHosts": "Diesen Zugriff auch von allen Hosts im Ordner und seinen Unterordnern entfernen",
"sharedCopyBanner": "Dieser Host wurde von {{owner}} mit Ihnen geteilt. Der Desktop behält eine schreibgeschützte Kopie, sodass Änderungen am Server hier synchronisiert werden.",
"sharedEditBanner": "Dieser Host wurde von {{owner}} mit Bearbeitungsberechtigungen mit Ihnen geteilt. Änderungen gelten für den echten Host; Authentifizierungsreferenzen können nur vom Besitzer geändert werden.",
"ownerOnlyControl": "Nur der Host-Besitzer kann dies ändern",
"ownerAuthPrivate": "Die SSH-Authentifizierung des Host-Besitzers ist privat. Wählen Sie im Host-Menü „Persönliche SSH-Authentifizierung festlegen“, um Ihre eigenen Anmeldeinformationen auszuwählen.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Κλείσιμο της πλευρικής γραμμής μετά το άνοιγμα ενός host από αυτήν",
"showFolderPaths": "Εμφάνιση διαδρομών φακέλων",
"showFolderPathsDesc": "Εμφάνιση της διαδρομής του γονικού φακέλου πάνω από τα ονόματα των υποφακέλων. Οι υποφάκελοι παραμένουν εσοχή σε κάθε περίπτωση.",
"stripedRows": "Εναλλασσόμενα χρώματα γραμμών",
"stripedRowsDesc": "Σκίαση κάθε δεύτερης γραμμής στη λίστα των hosts",
"rowDetailsTitle": "Λεπτομέρειες γραμμής",
"rowDetailsDesc": "Επιλέξτε τι εμφανίζεται σε κάθε host.",
"showAddress": "Διεύθυνση",
@@ -775,6 +777,8 @@
"sharedBadge": "Κοινόχρηστο",
"sharedBadgeTooltip": "Κοινόχρηστο από τον {{owner}} (πρόσβαση {{level}})",
"viewOnlyBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα προβολής. Η διαμόρφωση είναι μόνο για ανάγνωση.",
"folderRuleRemoveFromHosts": "Αφαίρεση επίσης αυτής της πρόσβασης από κάθε host στον φάκελο και τους υποφακέλους του",
"sharedCopyBanner": "Αυτός ο host μοιράζεται μαζί σας από τον {{owner}}. Το desktop διατηρεί ένα αντίγραφο μόνο για ανάγνωση, οπότε κάντε αλλαγές στον server και θα συγχρονιστούν εδώ.",
"sharedEditBanner": "Αυτός ο υπολογιστής έχει κοινόχρηστη πρόσβαση από τον {{owner}} με δικαιώματα επεξεργασίας. Οι αλλαγές εφαρμόζονται στον πραγματικό υπολογιστή· οι αναφορές πιστοποίησης μπορούν να αλλάξουν μόνο από τον ιδιοκτήτη.",
"ownerOnlyControl": "Μόνο ο ιδιοκτήτης του υπολογιστή μπορεί να αλλάξει αυτό",
"ownerAuthPrivate": "Η SSH πιστοποίηση του ιδιοκτήτη του υπολογιστή είναι ιδιωτική. Χρησιμοποιήστε την επιλογή «Ορισμός προσωπικής SSH πιστοποίησης» από το μενού του υπολογιστή για να επιλέξετε το δικό σας διαπιστευτήριο.",
+4
View File
@@ -596,6 +596,8 @@
"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.",
"stripedRows": "Colores alternos en las filas",
"stripedRowsDesc": "Aplicar un tono diferente a cada fila alterna en la lista de hosts",
"rowDetailsTitle": "Detalles de la fila",
"rowDetailsDesc": "Elige qué se muestra en cada host.",
"showAddress": "Dirección",
@@ -787,6 +789,8 @@
"sharedBadge": "Compartido",
"sharedBadgeTooltip": "Compartido por {{owner}} (acceso {{level}})",
"viewOnlyBanner": "Este host te ha sido compartido por {{owner}} con acceso de solo lectura. La configuración es de solo lectura.",
"folderRuleRemoveFromHosts": "También eliminar este acceso de todos los hosts de la carpeta y sus subcarpetas",
"sharedCopyBanner": "Este host te ha sido compartido por {{owner}}. El escritorio mantiene una copia de solo lectura, por lo que si lo modificas en el servidor, los cambios se sincronizarán aquí.",
"sharedEditBanner": "Este host te ha sido compartido por {{owner}} con acceso de edición. Los cambios se aplican al host real; solo el propietario puede modificar las referencias de autenticación.",
"ownerOnlyControl": "Solo el propietario del host puede cambiar esto",
"ownerAuthPrivate": "La autenticación SSH del propietario del host es privada. Usa «Establecer autenticación SSH personal» en el menú del host para elegir tu propia credencial.",
+4
View File
@@ -590,6 +590,8 @@
"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.",
"stripedRows": "Vahelduvad ridade värvid",
"stripedRowsDesc": "Varjunda iga teine rida hostide nimekirjas",
"rowDetailsTitle": "Rea detailid",
"rowDetailsDesc": "Vali, mis iga hosti kohta kuvatakse.",
"showAddress": "Aadress",
@@ -775,6 +777,8 @@
"sharedBadge": "Jagatud",
"sharedBadgeTooltip": "Jagatud kasutaja {{owner}} poolt ({{level}} ligipääs)",
"viewOnlyBanner": "See host on jagatud kasutaja {{owner}} poolt vaatamisõigusega. Konfiguratsioon on kirjutuskaitstud.",
"folderRuleRemoveFromHosts": "Eemalda see ligipääs ka kõigilt hostidelt kaustas ja selle alamkaustades",
"sharedCopyBanner": "See host on jagatud sinuga kasutaja {{owner}} poolt. Töölaua rakendus hoiab lugemisõigusega koopiat, seega tee muudatused serveris ja need sünkroonitakse siia.",
"sharedEditBanner": "See host on jagatud kasutaja {{owner}} poolt muutmise õigusega. Muudatused rakenduvad tegelikule hostile; autentimise viiteid saab muuta ainult hosti omanik.",
"ownerOnlyControl": "Seda saab muuta ainult hosti omanik",
"ownerAuthPrivate": "Hosti omaniku SSH-autentimine on privaatne. Kasuta hosti menüüst valikut „Määra isiklik SSH-autentimine“, et valida oma usalduskiri.",
+4
View File
@@ -590,6 +590,8 @@
"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.",
"stripedRows": "Vaihtuvat rivin värit",
"stripedRowsDesc": "Varjostaa toinen toisensa seuraavat rivit isäntälistassa",
"rowDetailsTitle": "Rivin tiedot",
"rowDetailsDesc": "Valitse, mitä näytetään jokaisessa isännässä.",
"showAddress": "Osoite",
@@ -775,6 +777,8 @@
"sharedBadge": "Julkaisu",
"sharedBadgeTooltip": "Julkaissut {{owner}} ({{level}}-käyttöoikeus)",
"viewOnlyBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta katselukäyttöoikeudella. Konfiguraatio on vain luettavissa.",
"folderRuleRemoveFromHosts": "Poista myös tämä pääsy kaikista kansiossa ja sen alikansioissa olevista isännöistä",
"sharedCopyBanner": "Tämä isäntä on jaettu sinulle käyttäjän {{owner}} toimesta. Työpöytä pitää luettavan kopion, joten tee muutokset palvelimella, ja ne synkronoituvat tänne.",
"sharedEditBanner": "Tämän isäntäkonetta on jaettu sinulle käyttäjän {{owner}} toimesta muokkauskäyttöoikeudella. Muutokset vaikuttavat todelliseen isäntäkoneeseen; todennusviittauksia voi muuttaa vain omistaja.",
"ownerOnlyControl": "Vain isäntäkoneen omistaja voi muuttaa tätä",
"ownerAuthPrivate": "Isäntäkoneen omistajan SSH-todennus on yksityinen. Valitse oma tunnistetietosi isäntävalikon kohdasta ”Aseta henkilökohtainen SSH-todennus”.",
+4
View File
@@ -596,6 +596,8 @@
"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.",
"stripedRows": "Couleurs de lignes alternées",
"stripedRowsDesc": "Ombrer chaque ligne sur deux dans la liste des hôtes",
"rowDetailsTitle": "Détails de la ligne",
"rowDetailsDesc": "Choisissez ce qui s'affiche pour chaque hôte.",
"showAddress": "Adresse",
@@ -787,6 +789,8 @@
"sharedBadge": "Partagé",
"sharedBadgeTooltip": "Partagé par {{owner}} (accès {{level}})",
"viewOnlyBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en lecture seule. La configuration est en lecture seule.",
"folderRuleRemoveFromHosts": "Supprimer également cet accès de chaque hôte dans le dossier et ses sous-dossiers",
"sharedCopyBanner": "Cet hôte vous est partagé par {{owner}}. Le bureau conserve une copie en lecture seule ; modifiez-le sur le serveur et il sera synchronisé ici.",
"sharedEditBanner": "Cet hôte vous est partagé par {{owner}} avec un accès en modification. Les modifications s'appliquent à l'hôte réel ; les références d'authentification ne peuvent être modifiées que par le propriétaire.",
"ownerOnlyControl": "Seul le propriétaire de l'hôte peut modifier ceci",
"ownerAuthPrivate": "L'authentification SSH du propriétaire de l'hôte est privée. Utilisez « Définir l'authentification SSH personnelle » dans le menu de l'hôte pour choisir vos propres identifiants.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "סגור את סרגל הצד לאחר פתיחת מארח ממנו",
"showFolderPaths": "הצג נתיבי תיקייה",
"showFolderPathsDesc": "הצג את נתיב התיקייה האם מעל שמות תתי-תיקיות מקוננות. תתי-תיקיות נשארות מושרשות בכל מקרה.",
"stripedRows": "צבעי שורות מתחלפים",
"stripedRowsDesc": "הצלל כל שורה שנייה ברשימת המארחים",
"rowDetailsTitle": "פרטי שורה",
"rowDetailsDesc": "בחר מה יופיע בכל מארח.",
"showAddress": "כתובת",
@@ -787,6 +789,8 @@
"sharedBadge": "משותף",
"sharedBadgeTooltip": "משותף על ידי {{owner}} (גישה {{level}})",
"viewOnlyBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לצפייה בלבד. התצורה היא לקריאה בלבד.",
"folderRuleRemoveFromHosts": "הסר גם גישה זו מכל מארח בתיקייה ובתיקיות המשנה שלה",
"sharedCopyBanner": "מארח זה שותף איתך על ידי {{owner}}. השולחן עבודה שומר עותק לקריאה בלבד, כך ששינויים במארח יסונכרנו לכאן.",
"sharedEditBanner": "מארח זה משותף איתך על ידי {{owner}} עם גישה לעריכה. שינויים מוחלים על המארח האמיתי; הפניות לאימות יכולות להשתנות רק על ידי הבעלים.",
"ownerOnlyControl": "רק בעל המארח יכול לשנות זאת",
"ownerAuthPrivate": "אימות ה-SSH של בעל המארח הוא פרטי. השתמש ב-\"הגדר אימות SSH אישי\" מתפריט המארח כדי לבחור אישור משלך.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "इससे होस्ट खोलने के बाद साइडबार बंद करें",
"showFolderPaths": "फ़ोल्डर पथ दिखाएं",
"showFolderPathsDesc": "नेस्टेड सबफ़ोल्डर नामों के ऊपर माता-पिता फ़ोल्डर का पथ दिखाएं। सबफ़ोल्डर दोनों स्थिति में अंतर्निहित रहते हैं।",
"stripedRows": "बारी-बारी से पंक्ति के रंग",
"stripedRowsDesc": "होस्ट सूची में हर दूसरी पंक्ति को छाया दें",
"rowDetailsTitle": "पंक्ति विवरण",
"rowDetailsDesc": "चुनें कि प्रत्येक होस्ट पर क्या दिखाया जाए।",
"showAddress": "पता",
@@ -775,6 +777,8 @@
"sharedBadge": "साझा किया गया",
"sharedBadgeTooltip": "{{owner}} द्वारा साझा किया गया ({{level}} एक्सेस)",
"viewOnlyBanner": "यह होस्ट {{owner}} द्वारा आपके साथ देखने के एक्सेस के साथ साझा किया गया है। कॉन्फ़िगरेशन रीड-ओनली है।",
"folderRuleRemoveFromHosts": "इस फ़ोल्डर और इसके सबफ़ोल्डर्स में मौजूद हर होस्ट से भी यह एक्सेस हटाएं",
"sharedCopyBanner": "यह होस्ट {{owner}} द्वारा आपके साथ शेयर किया गया है। डेस्कटॉप इसका रीड-ओनली कॉपी रखता है, इसलिए सर्वर पर बदलाव करें और यह यहाँ सिंक हो जाएगा।",
"sharedEditBanner": "यह होस्ट {{owner}} द्वारा आपके साथ एडिट एक्सेस के साथ साझा किया गया है। परिवर्तन असली होस्ट पर लागू होते हैं; प्रमाणीकरण संदर्भों को केवल मालिक ही बदल सकता है।",
"ownerOnlyControl": "केवल होस्ट मालिक ही इसे बदल सकता है",
"ownerAuthPrivate": "होस्ट मालिक का SSH प्रमाणीकरण निजी है। अपना क्रेडेंशियल चुनने के लिए होस्ट मेनू से “व्यक्तिगत SSH प्रमाणीकरण सेट करें” का उपयोग करें।",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Zatvori bočnu traku nakon otvaranja hosta iz nje",
"showFolderPaths": "Prikaži putanje mapa",
"showFolderPathsDesc": "Prikažite putanju nadređene mape iznad naziva ugniježđenih podmapa. Podmape ostaju uvučene u svakom slučaju.",
"stripedRows": "Naizmjenične boje redaka",
"stripedRowsDesc": "Obojite svaki drugi redak u popisu hostova",
"rowDetailsTitle": "Detalji retka",
"rowDetailsDesc": "Odaberite što se prikazuje za svaki host.",
"showAddress": "Adresa",
@@ -787,6 +789,8 @@
"sharedBadge": "Dijeljeno",
"sharedBadgeTooltip": "Dijeli {{owner}} (razina pristupa: {{level}})",
"viewOnlyBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za pregled. Konfiguracija je samo za čitanje.",
"folderRuleRemoveFromHosts": "Ukloni i ovaj pristup sa svih hostova u ovoj mapi i podmapama",
"sharedCopyBanner": "Ovaj host dijeli s vama {{owner}}. Desktop zadržava kopiju samo za čitanje, pa ga promijenite na poslužitelju i sinkronizirat će se ovdje.",
"sharedEditBanner": "Ovaj host dijeli s vama korisnik {{owner}} s pristupom za uređivanje. Promjene se primjenjuju na stvarni host; reference za autentifikaciju može mijenjati samo vlasnik.",
"ownerOnlyControl": "Ovo može promijeniti samo vlasnik hosta",
"ownerAuthPrivate": "SSH autentifikacija vlasnika hosta je privatna. Odaberite vlastite vjerodajnice putem opcije „Postavi osobnu SSH autentifikaciju“ u izborniku hosta.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Az oldalsáv bezárása egy gazdagép megnyitása után",
"showFolderPaths": "Mappa-utak megjelenítése",
"showFolderPathsDesc": "A szülőmappa-út megjelenítése az egymásba ágyazott almappanevek felett. Az almappák mindkét esetben behúzásban maradnak.",
"stripedRows": "Váltakozó sorszínek",
"stripedRowsDesc": "A hosztlista minden második sorának árnyékolása",
"rowDetailsTitle": "Sor részletei",
"rowDetailsDesc": "Válassza ki, mi jelenjen meg minden kiszolgálónál.",
"showAddress": "Cím",
@@ -775,6 +777,8 @@
"sharedBadge": "Megosztott",
"sharedBadgeTooltip": "Megosztotta: {{owner}} ({{level}} hozzáférés)",
"viewOnlyBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled megtekintési jogosultsággal. A konfiguráció csak olvasható.",
"folderRuleRemoveFromHosts": "Az engedélyt eltávolítja a mappában és almappáiban lévő összes hosztról is",
"sharedCopyBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled. Az asztali alkalmazás egy csak olvasható másolatot tart meg, így ha a szerveren módosítod, a változás szinkronizálódik ide.",
"sharedEditBanner": "Ezt a hosztot a(z) {{owner}} osztotta meg veled szerkesztési jogosultsággal. A módosítások a valódi hosztra vonatkoznak; a hitelesítési hivatkozásokat csak a tulajdonos módosíthatja.",
"ownerOnlyControl": "Ezt csak a hoszt tulajdonosa módosíthatja",
"ownerAuthPrivate": "A hoszt tulajdonosa SSH-hitelesítése privát. A saját hitelesítő adataid kiválasztásához használd a „Saját SSH-hitelesítés beállítása” menüpontot a hoszt menüjében.",
+4
View File
@@ -584,6 +584,8 @@
"closeOnOpenDesc": "Tutup bilah sisi setelah Anda membuka host darinya",
"showFolderPaths": "Tampilkan jalur folder",
"showFolderPathsDesc": "Tampilkan jalur folder induk di atas nama subfolder bersarang. Subfolder tetap menjorok ke dalam dalam kedua kasus.",
"stripedRows": "Warna baris bergantian",
"stripedRowsDesc": "Berikan warna pada setiap baris kedua di daftar host",
"rowDetailsTitle": "Detail baris",
"rowDetailsDesc": "Pilih apa yang ditampilkan pada setiap host.",
"showAddress": "Alamat",
@@ -763,6 +765,8 @@
"sharedBadge": "Dibagikan",
"sharedBadgeTooltip": "Dibagikan oleh {{owner}} (akses {{level}})",
"viewOnlyBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses lihat. Konfigurasi bersifat baca-saja.",
"folderRuleRemoveFromHosts": "Hapus akses ini juga dari setiap host di folder dan subfoldernya",
"sharedCopyBanner": "Host ini dibagikan kepada Anda oleh {{owner}}. Desktop menyimpan salinan hanya-baca, sehingga ubah di server dan akan disinkronkan di sini.",
"sharedEditBanner": "Host ini dibagikan kepada Anda oleh {{owner}} dengan akses edit. Perubahan diterapkan pada host asli; referensi autentikasi hanya dapat diubah oleh pemilik.",
"ownerOnlyControl": "Hanya pemilik host yang dapat mengubah ini",
"ownerAuthPrivate": "Autentikasi SSH pemilik host bersifat pribadi. Gunakan “Atur autentikasi SSH pribadi” dari menu host untuk memilih kredensial Anda sendiri.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Chiudi la barra laterale dopo aver aperto un host da essa",
"showFolderPaths": "Mostra percorsi delle cartelle",
"showFolderPathsDesc": "Mostra il percorso della cartella genitore sopra i nomi delle sottocartelle annidate. Le sottocartelle rimangono indentate in ogni caso.",
"stripedRows": "Colori delle righe alternati",
"stripedRowsDesc": "Applica un'ombra a ogni altra riga nell'elenco degli host",
"rowDetailsTitle": "Dettagli riga",
"rowDetailsDesc": "Scegli cosa mostrare per ogni host.",
"showAddress": "Indirizzo",
@@ -787,6 +789,8 @@
"sharedBadge": "Condiviso",
"sharedBadgeTooltip": "Condiviso da {{owner}} (accesso {{level}})",
"viewOnlyBanner": "Questo host è stato condiviso con te da {{owner}} con accesso in sola lettura. La configurazione è di sola lettura.",
"folderRuleRemoveFromHosts": "Rimuovi anche questo accesso da tutti gli host nella cartella e nelle sue sottocartelle",
"sharedCopyBanner": "Questo host è condiviso con te da {{owner}}. Il desktop mantiene una copia in sola lettura, quindi modificalo sul server e le modifiche verranno sincronizzate qui.",
"sharedEditBanner": "Questo host è stato condiviso con te da {{owner}} con accesso di modifica. Le modifiche si applicano all'host reale; i riferimenti di autenticazione possono essere modificati solo dal proprietario.",
"ownerOnlyControl": "Solo il proprietario dell'host può modificare questo elemento",
"ownerAuthPrivate": "L'autenticazione SSH del proprietario dell'host è privata. Usa “Imposta autenticazione SSH personale” dal menu dell'host per scegliere le tue credenziali.",
+4
View File
@@ -584,6 +584,8 @@
"closeOnOpenDesc": "サイドバーからホストを開いた後、サイドバーを閉じます",
"showFolderPaths": "フォルダパスを表示",
"showFolderPathsDesc": "ネストされたサブフォルダ名の上部に親フォルダのパスを表示します。サブフォルダはどちらの場合もインデントされます。",
"stripedRows": "交互行背景色",
"stripedRowsDesc": "在主机列表中为每隔一行添加背景色",
"rowDetailsTitle": "行の詳細",
"rowDetailsDesc": "各ホストに表示する内容を選択します。",
"showAddress": "アドレス",
@@ -763,6 +765,8 @@
"sharedBadge": "共有済み",
"sharedBadgeTooltip": "{{owner}} によって共有({{level}} アクセス)",
"viewOnlyBanner": "このホストは {{owner}} によって閲覧アクセスで共有されています。設定は読み取り専用です。",
"folderRuleRemoveFromHosts": "同时从该文件夹及其子文件夹中的所有主机中移除此访问权限",
"sharedCopyBanner": "此主机由 {{owner}} 与您共享。桌面端保留只读副本,因此在服务器上修改后,此处将同步更新。",
"sharedEditBanner": "このホストは {{owner}} によって編集アクセスで共有されています。変更は実際のホストに反映されますが、認証参照は所有者のみが変更できます。",
"ownerOnlyControl": "この変更はホストの所有者のみが行えます",
"ownerAuthPrivate": "ホスト所有者の SSH 認証情報は非公開です。ホストメニューから「個人用 SSH 認証を設定」を選択して、独自の認証情報を使用してください。",
+4
View File
@@ -584,6 +584,8 @@
"closeOnOpenDesc": "사이드바에서 호스트를 열면 사이드바를 닫습니다",
"showFolderPaths": "폴더 경로 표시",
"showFolderPathsDesc": "중첩된 하위 폴더 이름 위에 상위 폴더 경로를 표시합니다. 하위 폴더는 어느 경우든 들여쓰기가 유지됩니다.",
"stripedRows": "줄무늬 행 색상",
"stripedRowsDesc": "호스트 목록에서 행을 번갈아 가며 색칠합니다",
"rowDetailsTitle": "행 세부 정보",
"rowDetailsDesc": "각 호스트에 표시할 내용을 선택하세요.",
"showAddress": "주소",
@@ -763,6 +765,8 @@
"sharedBadge": "공유됨",
"sharedBadgeTooltip": "{{owner}}님이 공유함 ({{level}} 접근 권한)",
"viewOnlyBanner": "이 호스트는 {{owner}}님이 보기 전용 권한으로 공유했습니다. 설정은 읽기 전용입니다.",
"folderRuleRemoveFromHosts": "이 폴더와 하위 폴더의 모든 호스트에서도 이 접근 권한을 제거합니다",
"sharedCopyBanner": "이 호스트는 {{owner}}님이 공유한 것입니다. 데스크톱은 읽기 전용 복사본을 유지하므로, 서버에서 변경하면 여기로 동기화됩니다.",
"sharedEditBanner": "이 호스트는 {{owner}}님이 수정 권한으로 공유했습니다. 변경 사항은 실제 호스트에 적용되며, 인증 참조는 소유자만 변경할 수 있습니다.",
"ownerOnlyControl": "호스트 소유자만 이 항목을 변경할 수 있습니다",
"ownerAuthPrivate": "호스트 소유자의 SSH 인증 정보는 비공개입니다. 호스트 메뉴에서 '개인 SSH 인증 설정'을 사용하여 본인의 자격 증명을 선택하세요.",
+4
View File
@@ -602,6 +602,8 @@
"closeOnOpenDesc": "Uždaryti šoninę juostą, kai atidarote priimtuvą iš jos",
"showFolderPaths": "Rodyti aplanko kelius",
"showFolderPathsDesc": "Rodyti tėvinio aplanko kelią virš įdėto poaplanko pavadinimo. Poaplanai vis tiek lieka įtraukti.",
"stripedRows": "Kintančios eilučių spalvos",
"stripedRowsDesc": "Paryškinti kas antrą eilutę serverių sąraše",
"rowDetailsTitle": "Eilutės detalės",
"rowDetailsDesc": "Pasirinkite, kas rodoma kiekviename serverio įraše.",
"showAddress": "Adresas",
@@ -799,6 +801,8 @@
"sharedBadge": "Bendrinamas",
"sharedBadgeTooltip": "Bendrinama nuo {{owner}} ({{level}} prieiga)",
"viewOnlyBanner": "Šis serveris bendrinamas su jumis nuo {{owner}} su peržiūros prieiga. Konfigūracija yra tik skaitymo režimu.",
"folderRuleRemoveFromHosts": "Taip pat pašalinti šią prieigą iš visų serverių aplankelyje ir jo poaplankeliuose",
"sharedCopyBanner": "Šis serveris su jumis dalijasi {{owner}}. Darbalaukis saugo tik skaitymui skirtą kopiją, todėl pakeitimai atliekami serveryje ir sinchronizuojami čia.",
"sharedEditBanner": "Šis serveris bendrinamas su jumis nuo {{owner}} su redagavimo prieiga. Pakeitimai taikomi tikram serveriui; autentifikacijos nuorodas gali keisti tik savininkas.",
"ownerOnlyControl": "Tai gali pakeisti tik serverio savininkas",
"ownerAuthPrivate": "Serverio savininko SSH autentifikacija yra privati. Naudokite „Nustatyti asmeninę SSH autentifikaciją“ iš serverio meniu, kad pasirinktumėte savo prisijungimo duomenis.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Aizvērt sānjoslu pēc tam, kad no tās esat atvēris mezglu",
"showFolderPaths": "Rādīt mapju ceļus",
"showFolderPathsDesc": "Rādīt vecākmātes mapes ceļu virs iekšējām apakšmapēm. Apakšmapes joprojām ir atkārtas.",
"stripedRows": "Mainojošu rindu krāsas",
"stripedRowsDesc": "Iekrāsot katru otro rindu resursu sarakstā",
"rowDetailsTitle": "Rindas detaļas",
"rowDetailsDesc": "Izvēlieties, kas tiek rādīts katram mezglam.",
"showAddress": "Adrese",
@@ -787,6 +789,8 @@
"sharedBadge": "Kopīgots",
"sharedBadgeTooltip": "Kopīgojis {{owner}} ({{level}} piekļuve)",
"viewOnlyBanner": "Šo hostu jums ir kopīgojis {{owner}} ar skatīšanas piekļuvi. Konfigurācija ir tikai lasāma.",
"folderRuleRemoveFromHosts": "Noņemt šo piekļuvi arī no visiem resursiem šajā mapē un tās apakšmapēs",
"sharedCopyBanner": "Šis resurss ir kopīgoti ar jums no {{owner}}. Darbvirsmā tiek saglabāta tikai lasāma kopija, tāpēc veiciet izmaiņas serverī, un tās sinhronizēsies šeit.",
"sharedEditBanner": "Šo hostu jums ir kopīgojis {{owner}} ar rediģēšanas piekļuvi. Izmaiņas tiek piemērotas faktiskajam hostam; autentifikācijas atsauces var mainīt tikai īpašnieks.",
"ownerOnlyControl": "To var mainīt tikai hosta īpašnieks",
"ownerAuthPrivate": "Hosta īpašnieka SSH autentifikācija ir privāta. Izmantojiet opciju “Iestatīt personīgo SSH autentifikāciju” hosta izvēlnē, lai izvēlētos savus pieteikšanās datus.",
+4
View File
@@ -584,6 +584,8 @@
"closeOnOpenDesc": "Tutup bar sisi selepas anda membuka hos daripadanya",
"showFolderPaths": "Paparkan laluan folder",
"showFolderPathsDesc": "Paparkan laluan folder induk di atas nama subfolder bersarang. Subfolder kekal bersebelah sama ada pilihan ini diaktifkan atau tidak.",
"stripedRows": "Warna baris berselang-seli",
"stripedRowsDesc": "Warnakan setiap baris kedua dalam senarai hos",
"rowDetailsTitle": "Butiran baris",
"rowDetailsDesc": "Pilih apa yang dipaparkan pada setiap hos.",
"showAddress": "Alamat",
@@ -763,6 +765,8 @@
"sharedBadge": "Dikongsi",
"sharedBadgeTooltip": "Dikongsi oleh {{owner}} (akses {{level}})",
"viewOnlyBanner": "Hos ini dikongsi dengan anda oleh {{owner}} dengan akses paparan. Konfigurasi adalah baca-sahaja.",
"folderRuleRemoveFromHosts": "Juga buang akses ini daripada setiap hos dalam folder dan subfoldernya",
"sharedCopyBanner": "Hos ini dikongsi dengan anda oleh {{owner}}. Desktop menyimpan salinan baca sahaja, jadi ubah pada pelayan dan ia akan disegerakkan di sini.",
"sharedEditBanner": "Hos ini dikongsi dengan anda oleh {{owner}} dengan akses sunting. Perubahan akan digunakan pada hos sebenar; rujukan pengesahan hanya boleh diubah oleh pemilik.",
"ownerOnlyControl": "Hanya pemilik hos boleh mengubah ini",
"ownerAuthPrivate": "Pengesahan SSH pemilik hos adalah peribadi. Gunakan “Tetapkan pengesahan SSH peribadi” dari menu hos untuk memilih kelayakan anda sendiri.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Sluit de zijbalk nadat je een host vanuit de zijbalk hebt geopend",
"showFolderPaths": "Mappaden tonen",
"showFolderPathsDesc": "Toon het pad van de bovenliggende map boven geneste submapnamen. Submappen blijven in beide gevallen ingesprongen.",
"stripedRows": "Afwisselende rijkleuren",
"stripedRowsDesc": "Elke tweede rij in de hostlijst schaduwen",
"rowDetailsTitle": "Regelgegevens",
"rowDetailsDesc": "Kies wat er bij elke host wordt getoond.",
"showAddress": "Adres",
@@ -775,6 +777,8 @@
"sharedBadge": "Gedeeld",
"sharedBadgeTooltip": "Gedeeld door {{owner}} ({{level}}-toegang)",
"viewOnlyBanner": "Deze host is met jou gedeeld door {{owner}} met weergavetoegang. De configuratie is alleen-lezen.",
"folderRuleRemoveFromHosts": "Verwijder deze toegang ook van elke host in de map en de submappen",
"sharedCopyBanner": "Deze host is met jou gedeeld door {{owner}}. De desktop bewaart een alleen-lezen kopie, dus pas het aan op de server en het wordt hier gesynchroniseerd.",
"sharedEditBanner": "Deze host is met jou gedeeld door {{owner}} met bewerktoegang. Wijzigingen worden toegepast op de echte host; authenticatieverwijzingen kunnen alleen door de eigenaar worden gewijzigd.",
"ownerOnlyControl": "Alleen de eigenaar van de host kan dit wijzigen",
"ownerAuthPrivate": "De SSH-authenticatie van de hosteigenaar is privé. Gebruik “Persoonlijke SSH-authenticatie instellen” in het hostmenu om je eigen referenties te kiezen.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Lukk sidefeltet etter at du har åpnet en vert fra det",
"showFolderPaths": "Vis mappestier",
"showFolderPathsDesc": "Vis overordnede mappet over navnene på innrykkede undermapper. Undermapper forblir innrykket uansett.",
"stripedRows": "Veksle farge på rader",
"stripedRowsDesc": "Skygge hver andre rad i vertslisten",
"rowDetailsTitle": "Radens detaljer",
"rowDetailsDesc": "Velg hva som vises for hver vert.",
"showAddress": "Adresse",
@@ -775,6 +777,8 @@
"sharedBadge": "Delt",
"sharedBadgeTooltip": "Delt av {{owner}} ({{level}}-tilgang)",
"viewOnlyBanner": "Denne verten er delt med deg av {{owner}} med visningstilgang. Konfigurasjonen er skrivebeskyttet.",
"folderRuleRemoveFromHosts": "Fjern også denne tilgangen fra alle verter i mappen og undermapper",
"sharedCopyBanner": "Denne verten er delt med deg av {{owner}}. Skrivebordet holder en skrivebeskyttet kopi, så endre den på serveren og den synkroniseres hit.",
"sharedEditBanner": "Denne verten er delt med deg av {{owner}} med redigeringsrettigheter. Endringer gjelder den faktiske verten; autentiseringsreferanser kan kun endres av eieren.",
"ownerOnlyControl": "Kun vertens eier kan endre dette",
"ownerAuthPrivate": "Vertens eiers SSH-autentisering er privat. Bruk «Sett personlig SSH-autentisering» i vertsmenyen for å velge dine egne legitimasjon.",
+4
View File
@@ -602,6 +602,8 @@
"closeOnOpenDesc": "Zamknij pasek boczny po otwarciu hosta z jego listy",
"showFolderPaths": "Pokaż ścieżki folderów",
"showFolderPathsDesc": "Pokaż ścieżkę nadrzędnego folderu nad nazwami zagnieżdżonych podfolderów. Podfoldery pozostają wcięte w obu przypadkach.",
"stripedRows": "Naprzemienne kolory wierszy",
"stripedRowsDesc": "Zatieni co drugi wiersz w liście hostów",
"rowDetailsTitle": "Szczegóły wiersza",
"rowDetailsDesc": "Wybierz, co ma być wyświetlane przy każdym hoście.",
"showAddress": "Adres",
@@ -799,6 +801,8 @@
"sharedBadge": "Udostępniony",
"sharedBadgeTooltip": "Udostępniony przez {{owner}} (dostęp {{level}})",
"viewOnlyBanner": "Ten host został udostępniony przez {{owner}} z dostępem do przeglądania. Konfiguracja jest tylko do odczytu.",
"folderRuleRemoveFromHosts": "Usuń również ten dostęp z każdego hosta w tym folderze i jego podfolderach",
"sharedCopyBanner": "Ten host został udostępniony Ci przez {{owner}}. Pulpit przechowuje kopię tylko do odczytu, więc wprowadź zmiany na serwerze, a zsynchronizują się one tutaj.",
"sharedEditBanner": "Ten host został udostępniony przez {{owner}} z dostępem do edycji. Zmiany dotyczą rzeczywistego hosta; referencje uwierzytelniania mogą być zmieniane tylko przez właściciela.",
"ownerOnlyControl": "Tylko właściciel hosta może to zmienić",
"ownerAuthPrivate": "Uwierzytelnianie SSH właściciela hosta jest prywatne. Użyj opcji „Ustaw własne uwierzytelnianie SSH” w menu hosta, aby wybrać własne dane logowania.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Fechar a barra lateral após abrir um host a partir dela",
"showFolderPaths": "Mostrar caminhos das pastas",
"showFolderPathsDesc": "Mostra o caminho da pasta pai acima dos nomes de subpastas aninhadas. As subpastas permanecem indentadas de qualquer forma.",
"stripedRows": "Cores alternadas nas linhas",
"stripedRowsDesc": "Sombrear linhas alternadas na lista de hosts",
"rowDetailsTitle": "Detalhes da linha",
"rowDetailsDesc": "Escolha o que aparece em cada host.",
"showAddress": "Endereço",
@@ -787,6 +789,8 @@
"sharedBadge": "Compartilhado",
"sharedBadgeTooltip": "Compartilhado por {{owner}} (acesso {{level}})",
"viewOnlyBanner": "Este host foi compartilhado com você por {{owner}} com acesso de visualização. A configuração é somente leitura.",
"folderRuleRemoveFromHosts": "Remover também esse acesso de todos os hosts na pasta e suas subpastas",
"sharedCopyBanner": "Este host foi compartilhado com você por {{owner}}. O desktop mantém uma cópia somente para leitura, então faça alterações no servidor e elas serão sincronizadas aqui.",
"sharedEditBanner": "Este host foi compartilhado com você por {{owner}} com acesso de edição. As alterações são aplicadas ao host real; as referências de autenticação só podem ser alteradas pelo proprietário.",
"ownerOnlyControl": "Somente o proprietário do host pode alterar isso",
"ownerAuthPrivate": "A autenticação SSH do proprietário do host é privada. Use “Definir autenticação SSH pessoal” no menu do host para escolher sua própria credencial.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Fechar a barra lateral após abrir um host a partir dela",
"showFolderPaths": "Mostrar caminhos das pastas",
"showFolderPathsDesc": "Mostrar o caminho da pasta pai acima dos nomes de subpastas aninhadas. As subpastas permanecem indentadas de qualquer forma.",
"stripedRows": "Cores alternadas nas linhas",
"stripedRowsDesc": "Aplicar sombreamento a cada linha alternada na lista de hosts",
"rowDetailsTitle": "Detalhes da linha",
"rowDetailsDesc": "Escolha o que aparece em cada host.",
"showAddress": "Endereço",
@@ -787,6 +789,8 @@
"sharedBadge": "Partilhado",
"sharedBadgeTooltip": "Partilhado por {{owner}} (acesso {{level}})",
"viewOnlyBanner": "Este host foi partilhado consigo por {{owner}} com acesso de visualização. A configuração é apenas de leitura.",
"folderRuleRemoveFromHosts": "Remover também este acesso de todos os hosts na pasta e nas suas subpastas",
"sharedCopyBanner": "Este host foi partilhado consigo por {{owner}}. O ambiente de trabalho mantém uma cópia apenas de leitura, por isso, altere-o no servidor e a sincronização será feita aqui.",
"sharedEditBanner": "Este host foi partilhado consigo por {{owner}} com acesso de edição. As alterações aplicam-se ao host real; as referências de autenticação só podem ser alteradas pelo proprietário.",
"ownerOnlyControl": "Apenas o proprietário do host pode alterar isto",
"ownerAuthPrivate": "A autenticação SSH do proprietário do host é privada. Utilize “Definir autenticação SSH pessoal” no menu do host para escolher a sua própria credencial.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Închide bara laterală după ce deschizi un gazdă din ea",
"showFolderPaths": "Afișează căile dosarelor",
"showFolderPathsDesc": "Afișează calea dosarului părinte deasupra numelor subdosarelor imbricate. Subdosarele rămân indentate în orice caz.",
"stripedRows": "Culori alternative pentru rânduri",
"stripedRowsDesc": "Umple cu o nuanță fiecare al doilea rând din lista de gazde",
"rowDetailsTitle": "Detalii rând",
"rowDetailsDesc": "Alege ce se afișează pentru fiecare gazdă.",
"showAddress": "Adresă",
@@ -787,6 +789,8 @@
"sharedBadge": "Partajat",
"sharedBadgeTooltip": "Partajat de {{owner}} (acces {{level}})",
"viewOnlyBanner": "Acest gazdă este partajat cu tine de {{owner}} cu acces de vizualizare. Configurația este doar pentru citire.",
"folderRuleRemoveFromHosts": "Elimină, de asemenea, acest acces de pe fiecare gazdă din dosar și subdosarele sale",
"sharedCopyBanner": "Această gazdă ți-a fost partajată de {{owner}}. Desktopul păstrează o copie în regim de doar citire, așa că modific-o pe server și se va sincroniza aici.",
"sharedEditBanner": "Acest gazdă este partajat cu tine de {{owner}} cu acces de editare. Modificările se aplică gazdei reale; referințele de autentificare pot fi modificate doar de proprietar.",
"ownerOnlyControl": "Doar proprietarul gazdei poate modifica acest lucru",
"ownerAuthPrivate": "Autentificarea SSH a proprietarului gazdei este privată. Folosește „Setează autentificarea SSH personală” din meniul gazdei pentru a-ți alege propria acreditare.",
+4
View File
@@ -602,6 +602,8 @@
"closeOnOpenDesc": "Закрывать боковую панель после открытия хоста из неё",
"showFolderPaths": "Показывать пути к папкам",
"showFolderPathsDesc": "Показывать путь к родительской папке над вложенными подпапками. Подпапки остаются отступленными в любом случае.",
"stripedRows": "Чередующиеся цвета строк",
"stripedRowsDesc": "Затенять каждую вторую строку в списке хостов",
"rowDetailsTitle": "Детали строки",
"rowDetailsDesc": "Выберите, что отображается для каждого хоста.",
"showAddress": "Адрес",
@@ -799,6 +801,8 @@
"sharedBadge": "Общий доступ",
"sharedBadgeTooltip": "Доступ предоставлен пользователем {{owner}} (уровень: {{level}})",
"viewOnlyBanner": "Этот хост предоставлен вам пользователем {{owner}} с правами только для просмотра. Конфигурация доступна только для чтения.",
"folderRuleRemoveFromHosts": "Также удалить этот доступ у всех хостов в этой папке и её подпапках",
"sharedCopyBanner": "Этот хост был передан вам пользователем {{owner}}. На рабочем столе хранится копия только для чтения, поэтому вносите изменения на сервере, и они синхронизируются здесь.",
"sharedEditBanner": "Этот хост предоставлен вам пользователем {{owner}} с правами редактирования. Изменения применяются к реальному хосту; ссылки на аутентификацию может изменять только владелец.",
"ownerOnlyControl": "Это может изменить только владелец хоста",
"ownerAuthPrivate": "Данные SSH-аутентификации владельца хоста являются частными. Используйте пункт «Настроить личную SSH-аутентификацию» в меню хоста, чтобы выбрать свои учетные данные.",
+4
View File
@@ -602,6 +602,8 @@
"closeOnOpenDesc": "Zavrieť bočný panel po otvorení hostiteľa z neho",
"showFolderPaths": "Zobraziť cesty priečinkov",
"showFolderPathsDesc": "Zobraziť cestu nadriadeného priečinka nad názvami vnorených podpriečinkov. Podpriečinky zostanú odsadené v oboch prípadoch.",
"stripedRows": "Striedavé farby riadkov",
"stripedRowsDesc": "Zafarbiť každý druhý riadok v zozname hostov",
"rowDetailsTitle": "Detaily riadku",
"rowDetailsDesc": "Vyberte, čo sa zobrazí pri každom hostiteľskom serveri.",
"showAddress": "Adresa",
@@ -799,6 +801,8 @@
"sharedBadge": "Zdieľaný",
"sharedBadgeTooltip": "Zdieľané používateľom {{owner}} (prístup {{level}})",
"viewOnlyBanner": "Tento hostiteľ vám bol zdieľaný používateľom {{owner}} s prístupom na čítanie. Konfigurácia je len na čítanie.",
"folderRuleRemoveFromHosts": "Tento prístup odstrániť aj zo všetkých hostov v tejto priečinku a jej podpriečinkoch",
"sharedCopyBanner": "Tento host vám zdieľa {{owner}}. Desktop si udržiava kópiu len na čítanie, takže ak ho upravíte na serveri, zmeny sa synchronizujú sem.",
"sharedEditBanner": "Tento hostiteľ vám bol zdieľaný používateľom {{owner}} s prístupom na úpravu. Zmeny sa aplikujú na skutočný hostiteľ; referencie na overenie môžu zmeniť len vlastníci.",
"ownerOnlyControl": "Toto môže zmeniť len vlastník hostiteľa",
"ownerAuthPrivate": "SSH overenie vlastníka hostiteľa je súkromné. Použite možnosť „Nastaviť osobné SSH overenie“ v ponuke hostiteľa na výber vlastných poverení.",
+4
View File
@@ -602,6 +602,8 @@
"closeOnOpenDesc": "Zapri stransko vrstico po odpiranju gostitelja iz nje",
"showFolderPaths": "Prikaži poti map",
"showFolderPathsDesc": "Prikažite pot nadrejenih map nad imeni vgnjezdenih podmap. Podmape bodo v obeh primerih zamaknjene.",
"stripedRows": "Naizmenične barve vrstic",
"stripedRowsDesc": "Osenči vsako drugo vrstico v seznamu gostiteljev",
"rowDetailsTitle": "Podrobnosti vrstice",
"rowDetailsDesc": "Izberite, kaj naj se prikaže pri vsakem gostitelju.",
"showAddress": "Naslov",
@@ -799,6 +801,8 @@
"sharedBadge": "Deljeno",
"sharedBadgeTooltip": "Deljeno s strani {{owner}} (dostop: {{level}})",
"viewOnlyBanner": "Ta gostitelj vam je deljen s strani {{owner}} z dostopom za ogled. Konfiguracija je samo za branje.",
"folderRuleRemoveFromHosts": "Ta dostop odstrani tudi iz vseh gostiteljev v tej mapi in njenih podmapah",
"sharedCopyBanner": "Ta gostitelj vam ga je posredoval uporabnik {{owner}}. Namizje hrani kopijo samo za branje, zato ga spremenite na strežniku in se bo sinhroniziralo tukaj.",
"sharedEditBanner": "Ta gostitelj vam je deljen s strani {{owner}} z dostopom za urejanje. Spremembe se nanašajo na dejanski gostitelj; reference za preverjanje pristnosti lahko spremeni le lastnik.",
"ownerOnlyControl": "To lahko spremeni le lastnik gostitelja",
"ownerAuthPrivate": "SSH preverjanje pristnosti lastnika gostitelja je zasebno. Uporabite možnost »Nastavi osebno SSH preverjanje pristnosti« v meniju gostitelja, da izberete svoje poverilnice.",
+4
View File
@@ -596,6 +596,8 @@
"closeOnOpenDesc": "Затвори бочну траку након што отвориш хост из ње",
"showFolderPaths": "Прикажи путање фасцикла",
"showFolderPathsDesc": "Прикажи путању родитељске фасцикле изнад имена угнеждених подфасцикла. Подфасцикле остају увлачене у оба случаја.",
"stripedRows": "Наизменичне боје редова",
"stripedRowsDesc": "Осветли сваки други ред у листи хостова",
"rowDetailsTitle": "Детаљи реда",
"rowDetailsDesc": "Изаберите шта се приказује за сваки хост.",
"showAddress": "Адреса",
@@ -787,6 +789,8 @@
"sharedBadge": "Дељено",
"sharedBadgeTooltip": "Дељено од {{owner}} (приступ: {{level}})",
"viewOnlyBanner": "Овај хост је дељен са вама од стране {{owner}} са приступом за преглед. Конфигурација је само за читање.",
"folderRuleRemoveFromHosts": "Уклони и приступ за сваки хост у овој фасцикли и њеним подфасциклама",
"sharedCopyBanner": "Овај хост вам је подељен од стране {{owner}}. Десктоп верзија чува копију само за читање, па га измените на серверу да би се синхронизовало овде.",
"sharedEditBanner": "Овај хост је дељен са вама од стране {{owner}} са приступом за уређивање. Промене се примењују на стварни хост; референце за аутентификацију може променити само власник.",
"ownerOnlyControl": "Само власник хоста може да промени ово",
"ownerAuthPrivate": "SSH аутентификација власника хоста је приватна. Користите „Постави личну SSH аутентификацију“ из менија хоста да бисте изабрали своје акредитиве.",
+4
View File
@@ -590,6 +590,8 @@
"closeOnOpenDesc": "Stäng sidofältet efter att du har öppnat en värd från det",
"showFolderPaths": "Visa mappvägar",
"showFolderPathsDesc": "Visa överordnad mappväg ovanför inbäddade undermappnamn. Undermappar är alltid indragna.",
"stripedRows": "Alternande radfärger",
"stripedRowsDesc": "Skugga varannan rad i värdlistan",
"rowDetailsTitle": "Radens detaljer",
"rowDetailsDesc": "Välj vad som visas för varje värd.",
"showAddress": "Adress",
@@ -775,6 +777,8 @@
"sharedBadge": "Delad",
"sharedBadgeTooltip": "Delad av {{owner}} ({{level}}-åtkomst)",
"viewOnlyBanner": "Denna värd delas med dig av {{owner}} med visningsåtkomst. Konfigurationen är skrivskyddad.",
"folderRuleRemoveFromHosts": "Ta även bort denna åtkomst från varje värd i mappen och dess undermappar",
"sharedCopyBanner": "Den här värden delas med dig av {{owner}}. Skrivbordet behåller en skrivskyddad kopia, så ändra den på servern så synkroniseras den hit.",
"sharedEditBanner": "Denna värd delas med dig av {{owner}} med redigeringsåtkomst. Ändringar gäller för den faktiska värden; autentiseringsreferenser kan endast ändras av ägaren.",
"ownerOnlyControl": "Endast värdens ägare kan ändra detta",
"ownerAuthPrivate": "Värdens ägares SSH-autentisering är privat. Använd ”Ange personlig SSH-autentisering” i värdmenyn för att välja dina egna inloggningsuppgifter.",
+4
View File
@@ -584,6 +584,8 @@
"closeOnOpenDesc": "ปิดแถบด้านข้างหลังจากคุณเปิดโฮสต์จากนั้น",
"showFolderPaths": "แสดงเส้นทางโฟลเดอร์",
"showFolderPathsDesc": "แสดงเส้นทางโฟลเดอร์หลักเหนือชื่อโฟลเดอร์ย่อย โฟลเดอร์ย่อยยังคงเยื้องเสมอ",
"stripedRows": "สลับสีแถว",
"stripedRowsDesc": "ใส่สีพื้นหลังสลับกันในรายการโฮสต์",
"rowDetailsTitle": "รายละเอียดแถว",
"rowDetailsDesc": "เลือกสิ่งที่แสดงในแต่ละโฮสต์",
"showAddress": "ที่อยู่",
@@ -763,6 +765,8 @@
"sharedBadge": "แชร์แล้ว",
"sharedBadgeTooltip": "แชร์โดย {{owner}} (สิทธิ์เข้าถึง {{level}})",
"viewOnlyBanner": "โฮสต์นี้ถูกแชร์ให้คุณโดย {{owner}} พร้อมสิทธิ์ดูเท่านั้น การตั้งค่าเป็นแบบอ่านอย่างเดียว",
"folderRuleRemoveFromHosts": "ลบการเข้าถึงนี้จากโฮสต์ทั้งหมดในโฟลเดอร์และโฟลเดอร์ย่อยด้วย",
"sharedCopyBanner": "โฮสต์นี้ถูกแชร์ให้คุณโดย {{owner}} เดสก์ท็อปจะเก็บสำเนาแบบอ่านอย่างเดียว ดังนั้นหากมีการเปลี่ยนแปลงบนเซิร์ฟเวอร์ ข้อมูลจะซิงค์มาที่นี่",
"sharedEditBanner": "โฮสต์นี้ถูกแชร์ให้คุณโดย {{owner}} พร้อมสิทธิ์แก้ไข การเปลี่ยนแปลงจะส่งผลต่อโฮสต์จริง; การอ้างอิงการตรวจสอบสิทธิ์สามารถเปลี่ยนแปลงได้โดยเจ้าของเท่านั้น",
"ownerOnlyControl": "เฉพาะเจ้าของโฮสต์เท่านั้นที่สามารถเปลี่ยนแปลงสิ่งนี้ได้",
"ownerAuthPrivate": "การตรวจสอบสิทธิ์ SSH ของเจ้าของโฮสต์เป็นแบบส่วนตัว ใช้ “ตั้งค่าการตรวจสอบสิทธิ์ SSH ส่วนตัว” จากเมนูโฮสต์เพื่อเลือกข้อมูลประจำตัวของคุณเอง",
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more