release-2.9.1 (#1557)

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
Luke GustafsonandZacharyZcR authored and GitHub committed 2026-10-04 15:11:41 -05:00
1 parent 5786eaccbb
commit 6b708106e1
912 files changed
+14220 -2200

No files matched your search

+19
View File
@@ -198,6 +198,25 @@ async function mayClaimLegacyAccount(
const links = await createCurrentUserAuthRepository().listIdentitiesForUser(
legacy.id,
);
// An unscoped legacy subject may already have been migrated under a
// different provider. Creating another account loses access to the old
// data; claiming it automatically could cross an identity-provider boundary.
if (
legacy.ssoProviderId == null &&
identity.ssoProviderId != null &&
identity.legacyIdentifier === identity.subject &&
links.some(
(link) =>
link.subject === identity.subject &&
link.providerId !== identity.provider,
)
) {
throw new LoginMethodError(
"A legacy SSO account has an unresolved provider mapping. Ask an administrator to verify and repair its identity mapping before signing in.",
409,
"legacy_identity_conflict",
);
}
return links.length === 0;
}
+17 -5
View File
@@ -1,7 +1,7 @@
import { getErrorMessage } from "../utils/error-message.js";
import { sshOptionsForWrite } from "../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyDefaultsAfterHostWrites,
applyHostDefaultsToWrite,
} from "../hosts/defaults/index.js";
import { recompute } from "../hosts/defaults/recompute.js";
@@ -687,6 +687,11 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
});
const exportDb = new Database(tempPath);
// A scratch file: without these every row is its own synced commit, and
// plugin rows may point at hosts the export leaves out.
exportDb.pragma("journal_mode = OFF");
exportDb.pragma("synchronous = OFF");
exportDb.pragma("foreign_keys = OFF");
try {
exportDb.exec(`
@@ -990,9 +995,13 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
userId,
filename,
});
});
// "close" also fires when the client aborts mid download, which "end"
// does not, so the decrypted export never stays behind on disk.
fileStream.on("close", () => {
fs.unlink(tempPath, (err) => {
if (err) {
if (err && err.code !== "ENOENT") {
apiLogger.warn("Failed to clean up export file", {
operation: "export_cleanup_failed",
path: tempPath,
@@ -1001,6 +1010,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
}
});
});
res.on("close", () => fileStream.destroy());
fileStream.pipe(res);
} catch (error) {
@@ -1048,7 +1058,7 @@ app.post(
"/database/import",
authenticateJWT,
upload.single("file"),
async (req, res) => {
DatabaseSaveTrigger.batched(async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: "No file uploaded" });
@@ -1150,6 +1160,7 @@ app.post(
const importedHosts = importDb
.prepare("SELECT * FROM ssh_data")
.all();
const importedHostIds: number[] = [];
for (const host of importedHosts) {
try {
const hostRepository = createCurrentHostRepository();
@@ -1224,7 +1235,7 @@ app.post(
Number(created.id),
importedHostPluginSettings(importDb, host),
);
await applyDefaultsAfterHostWrite(Number(created.id));
importedHostIds.push(Number(created.id));
await importHostProtocolLogins(
importDb,
host,
@@ -1238,6 +1249,7 @@ app.post(
);
}
}
await applyDefaultsAfterHostWrites(importedHostIds);
} catch {
apiLogger.info("ssh_data table not found in import file, skipping");
}
@@ -1402,7 +1414,7 @@ app.post(
details: getErrorMessage(error),
});
}
},
}),
);
/**
+2
View File
@@ -1,3 +1,4 @@
import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js";
import { getErrorMessage } from "../../utils/error-message.js";
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
@@ -782,6 +783,7 @@ const relaxPluginGrantGrantedBy = () => {
};
const migrateSchema = () => {
repairSnippetsNoteColumn(sqlite);
addColumnIfNotExists("user_preferences", "theme", "TEXT");
addColumnIfNotExists("user_preferences", "font_size", "TEXT");
addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
@@ -278,6 +278,10 @@ export function createCurrentSessionRepository(): SessionRepository {
return new SessionRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("session_repository_write"),
// Activity timestamps are informational; the periodic flush persists them.
needsExplicitPersist(resolveDatabaseDialect())
? () => DatabaseSaveTrigger.markDirty()
: undefined,
);
}
@@ -13,6 +13,8 @@ export class SessionRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
private readonly onActivity:
(() => void | Promise<void>) | undefined = onWrite,
) {}
async create(session: NewSessionRecord): Promise<SessionRecord> {
@@ -63,7 +65,7 @@ export class SessionRepository {
.where(and(eq(sessions.id, id), lte(sessions.lastActiveAt, cutoff)));
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
await this.onActivity?.();
return true;
}
@@ -9,6 +9,9 @@ import { createCurrentHostResolutionRepository } from "../repositories/factory.j
import { connectHost } from "../../hosts/connect/connect-host.js";
import type { SshConnectHost } from "../../hosts/connect/types.js";
import { resolveHostById } from "../../hosts/host-resolver.js";
import { isSecretReference } from "../../hosts/external-secrets.js";
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
function describeDeployError(err: unknown): string {
const message = getErrorMessage(err, "Connection failed");
@@ -363,7 +366,20 @@ export function registerCredentialDeployRoutes(
});
}
const publicKey = credData.publicKey;
let publicKey = credData.publicKey;
const storedKey = credData.privateKey || credData.key;
if (!publicKey && isSecretReference(storedKey)) {
// A key kept in a secret source has no stored public key; derive
// it from the resolved key.
const resolved = await resolveKeyReferences(
userId,
storedKey,
(credData as { keyPassword?: string | null }).keyPassword,
);
publicKey =
parseSSHKey(resolved.key, resolved.passphrase).publicKey ||
undefined;
}
if (!publicKey) {
return res.status(400).json({
success: false,
@@ -8,6 +8,8 @@ import {
parseSSHKey,
validateKeyPair,
} from "../../utils/ssh-key-utils.js";
import { resolveKeyReferences } from "../../hosts/stored-ssh-key.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
const { utils: ssh2Utils } = ssh2Pkg;
@@ -99,13 +101,17 @@ export function registerCredentialKeyRoutes(
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
if (!privateKey || typeof privateKey !== "string") {
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
const { key: privateKey, passphrase: keyPassword } =
await resolveKeyReferences(
(req as AuthenticatedRequest).userId,
req.body.privateKey,
req.body.keyPassword,
);
const keyInfo = parseSSHKey(privateKey, keyPassword);
const response = {
@@ -352,13 +358,17 @@ export function registerCredentialKeyRoutes(
requireCredentialViewPermission,
requireDataAccess,
async (req: Request, res: Response) => {
const { privateKey, keyPassword } = req.body;
if (!privateKey || typeof privateKey !== "string") {
if (!req.body?.privateKey || typeof req.body.privateKey !== "string") {
return res.status(400).json({ error: "Private key is required" });
}
try {
const { key: privateKey, passphrase: keyPassword } =
await resolveKeyReferences(
(req as AuthenticatedRequest).userId,
req.body.privateKey,
req.body.keyPassword,
);
let privateKeyObj;
const parseAttempts = [];
+25 -9
View File
@@ -7,7 +7,7 @@ import express, { type Request, type Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
import { registerCredentialKeyRoutes } from "./credential-key-routes.js";
import { registerCredentialDeployRoutes } from "./credential-deploy-routes.js";
import { registerCredentialBulkRoutes } from "./credential-bulk-routes.js";
@@ -165,7 +165,7 @@ router.post(
let keyInfo = null;
if (authType === "key" && plainKey) {
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
keyInfo = parseKeyForStorage(plainKey, plainKeyPassword);
if (!keyInfo.success) {
authLogger.warn("SSH key parsing failed", {
operation: "credential_create",
@@ -192,7 +192,11 @@ router.post(
password: plainPassword,
key: plainKey,
privateKey: keyInfo?.privateKey || plainKey,
publicKey: keyInfo?.publicKey || null,
publicKey:
keyInfo?.publicKey ||
(keyInfo?.reference && typeof req.body?.publicKey === "string"
? req.body.publicKey.trim() || null
: null),
keyPassword: plainKeyPassword,
keyType: keyType || null,
detectedKeyType: keyInfo?.keyType || null,
@@ -502,7 +506,7 @@ router.post(
let keyInfo = null;
if (authType === "key" && plainKey) {
keyInfo = parseSSHKey(plainKey, plainKeyPassword);
keyInfo = parseKeyForStorage(plainKey, plainKeyPassword);
if (!keyInfo.success) {
return res.status(400).json({
error: keyInfo.error
@@ -524,7 +528,11 @@ router.post(
password: authType === "password" ? plainPassword : null,
key: authType === "key" ? plainKey : null,
privateKey: authType === "key" ? keyInfo?.privateKey || plainKey : null,
publicKey: authType === "key" ? keyInfo?.publicKey || null : null,
publicKey:
authType === "key"
? keyInfo?.publicKey ||
(keyInfo?.reference ? source.publicKey || null : null)
: null,
keyPassword: authType === "key" ? plainKeyPassword : null,
keyType: source.keyType,
detectedKeyType: authType === "key" ? keyInfo?.keyType || null : null,
@@ -688,8 +696,14 @@ router.put(
if (updateData.key !== undefined) {
updateFields.key = updateData.key || null;
if (updateData.key && existingCredential.authType === "key") {
const keyInfo = parseSSHKey(updateData.key, updateData.keyPassword);
if (
updateData.key &&
(updateData.authType ?? existingCredential.authType) === "key"
) {
const keyInfo = parseKeyForStorage(
updateData.key,
updateData.keyPassword,
);
if (!keyInfo.success) {
authLogger.warn("SSH key parsing failed during update", {
operation: "credential_update",
@@ -704,8 +718,10 @@ router.put(
});
}
updateFields.privateKey = keyInfo.privateKey;
updateFields.publicKey = keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType;
updateFields.publicKey = keyInfo.reference
? updateData.publicKey?.trim() || null
: keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType || null;
}
}
if (updateData.keyPassword !== undefined) {
+17 -12
View File
@@ -4,7 +4,7 @@ import {
} from "./host-import-order.js";
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
import {
applyDefaultsAfterHostWrite,
applyDefaultsAfterHostWrites,
applyHostDefaultsToWrite,
} from "../../hosts/defaults/index.js";
import {
@@ -22,6 +22,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
@@ -183,8 +184,6 @@ export function registerHostBulkRoutes(
* patch:
* summary: Bulk update partial fields on multiple SSH hosts
* tags: [SSH]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
@@ -239,7 +238,7 @@ export function registerHostBulkRoutes(
authenticateJWT,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { hostIds, updates } = req.body;
@@ -368,7 +367,7 @@ export function registerHostBulkRoutes(
sshLogger.error("Failed to bulk update hosts:", error);
return res.status(500).json({ error: "Failed to bulk update hosts" });
}
},
}),
);
/**
@@ -457,7 +456,7 @@ export function registerHostBulkRoutes(
requireCreatePermission,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const {
hosts: hostsToImport,
@@ -577,6 +576,7 @@ export function registerHostBulkRoutes(
}
const knownAuthTypes = listKnownAuthTypes();
const writtenHostIds: number[] = [];
for (const { host: hostData, index: i, exportId } of orderedHosts) {
try {
const effectiveConnectionType = hostData.connectionType || "ssh";
@@ -812,13 +812,15 @@ export function registerHostBulkRoutes(
savedHostId,
hostData as Record<string, unknown>,
);
await applyDefaultsAfterHostWrite(savedHostId);
writtenHostIds.push(savedHostId);
} catch (error) {
results.failed++;
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
}
}
await applyDefaultsAfterHostWrites(writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
@@ -827,7 +829,7 @@ export function registerHostBulkRoutes(
failed: results.failed,
errors: results.errors,
});
},
}),
);
/**
@@ -864,7 +866,7 @@ export function registerHostBulkRoutes(
requireCreatePermission,
requireEditPermission,
requireDataAccess,
async (req: Request, res: Response) => {
DatabaseSaveTrigger.batched(async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
const { content, overwrite } = req.body;
@@ -936,6 +938,7 @@ export function registerHostBulkRoutes(
}
}
const writtenHostIds: number[] = [];
for (let i = 0; i < hostsToImport.length; i++) {
const hostData = normalizeImportedHost(
hostsToImport[i] as Record<string, unknown>,
@@ -1003,7 +1006,7 @@ export function registerHostBulkRoutes(
existing.id,
sshDataObj,
);
await applyDefaultsAfterHostWrite(existing.id);
writtenHostIds.push(existing.id);
results.updated++;
} else {
sshDataObj.createdAt = new Date().toISOString();
@@ -1011,7 +1014,7 @@ export function registerHostBulkRoutes(
userId,
sshDataObj,
);
await applyDefaultsAfterHostWrite(saved.id);
writtenHostIds.push(saved.id);
results.success++;
}
} catch (error) {
@@ -1022,6 +1025,8 @@ export function registerHostBulkRoutes(
}
}
await applyDefaultsAfterHostWrites(writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
success: results.success,
@@ -1030,7 +1035,7 @@ export function registerHostBulkRoutes(
failed: results.failed,
errors: results.errors,
});
},
}),
);
}
@@ -46,7 +46,7 @@ export function registerHostStatusRoutes(
* /host/status:
* get:
* summary: Get host statuses
* description: Returns the online, reachable or offline status of every host the user can see. Starts status checks for the user's own hosts on first call.
* description: Returns the online or offline status of every host the user can see. Starts status checks for the user's own hosts on first call and waits briefly for their first results.
* tags:
* - SSH
* parameters:
@@ -0,0 +1,122 @@
import type { Request, RequestHandler, Response, Router } from "express";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import { databaseLogger } from "../../utils/logger.js";
const CATALOG_KEY = "host_tag_catalog";
function parseCatalog(value: string | null | undefined): string[] {
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((tag): tag is string => typeof tag === "string")
: [];
} catch {
return [];
}
}
export function registerHostTagRoutes(
router: Router,
authenticate: RequestHandler,
requireAdmin: RequestHandler,
): void {
/**
* @openapi
* /host/tags:
* get:
* summary: Get the predefined host tags
* description: The instance-wide tag list host editors suggest. Any signed-in user can read it.
* tags:
* - SSH
* responses:
* 200:
* description: The predefined tags.
* content:
* application/json:
* schema:
* type: object
* properties:
* tags:
* type: array
* items:
* type: string
* 500:
* description: Failed to read the tags.
*/
router.get("/tags", authenticate, async (_req: Request, res: Response) => {
try {
const value = await createCurrentSettingsRepository().get(CATALOG_KEY);
res.json({ tags: parseCatalog(value) });
} catch (error) {
databaseLogger.error("Failed to read host tags", error);
res.status(500).json({ error: "Failed to read host tags" });
}
});
/**
* @openapi
* /host/tags:
* put:
* summary: Replace the predefined host tags
* description: Sets the instance-wide tag list. Tags are trimmed and duplicates dropped. Tags already on hosts are not changed. Requires admin.settings.manage.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* tags:
* type: array
* maxItems: 500
* items:
* type: string
* maxLength: 100
* responses:
* 200:
* description: The saved tags.
* 400:
* description: Not an array of up to 500 non-empty tags of at most 100 characters.
* 403:
* description: Missing admin.settings.manage.
* 500:
* description: Failed to save the tags.
*/
router.put(
"/tags",
authenticate,
requireAdmin,
async (req: Request, res: Response) => {
const tags: unknown = req.body?.tags;
if (
!Array.isArray(tags) ||
tags.length > 500 ||
tags.some(
(tag) =>
typeof tag !== "string" || !tag.trim() || tag.trim().length > 100,
)
) {
res.status(400).json({
error: "Expected up to 500 non-empty tags of at most 100 characters",
});
return;
}
const normalized = [
...new Set((tags as string[]).map((tag) => tag.trim())),
];
try {
await createCurrentSettingsRepository().set(
CATALOG_KEY,
JSON.stringify(normalized),
);
res.json({ tags: normalized });
} catch (error) {
databaseLogger.error("Failed to save host tags", error);
res.status(500).json({ error: "Failed to save host tags" });
}
},
);
}
+10 -5
View File
@@ -11,7 +11,7 @@ import { pluginEvents, TOPICS } from "../../plugins/events.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { parseSSHKey } from "../../utils/ssh-key-utils.js";
import { parseKeyForStorage } from "../../hosts/stored-ssh-key.js";
import {
pickResolvedPassword,
pickResolvedUsername,
@@ -53,6 +53,7 @@ import { registerHostFolderRoutes } from "./host-folder-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js";
import { registerHostTagRoutes } from "./host-tag-routes.js";
import { registerHostStatusRoutes } from "./host-status-routes.js";
import {
applyHostEnrollmentDefaults,
@@ -136,6 +137,12 @@ const permissionManager = PermissionManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
registerHostTagRoutes(
router,
authenticateJWT,
permissionManager.requirePermission("admin.settings.manage"),
);
registerHostStatusRoutes(router, {
authenticateJWT,
requireAdmin: authManager.createAdminMiddleware(),
@@ -351,7 +358,7 @@ router.post(
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key && typeof key === "string") {
const keyValidation = parseSSHKey(
const keyValidation = parseKeyForStorage(
key,
typeof keyPassword === "string" ? keyPassword : undefined,
);
@@ -493,8 +500,6 @@ router.post(
* description: Creates a host owned by the user assigned to the API key. The user's encrypted data must be unlocked by an active sign-in.
* tags:
* - Host Enrollment
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
@@ -935,7 +940,7 @@ router.put(
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key && typeof key === "string") {
const keyValidation = parseSSHKey(
const keyValidation = parseKeyForStorage(
key,
typeof keyPassword === "string" ? keyPassword : undefined,
);
-4
View File
@@ -1990,8 +1990,6 @@ router.delete(
* summary: Set personal authentication for a shared host protocol
* description: Selects one of the authenticated recipient's own credentials, or clears the selection with null. The protocol is "ssh" or one a plugin declares.
* tags: [RBAC]
* security:
* - bearerAuth: []
*/
router.put(
"/host-access/:hostId/auth/:protocol",
@@ -2047,8 +2045,6 @@ router.put(
* get:
* summary: Get the current recipient's shared-host protocol authentication override
* tags: [RBAC]
* security:
* - bearerAuth: []
*/
router.get(
"/host-access/:hostId/auth/:protocol",
@@ -20,6 +20,30 @@ function isNonEmptyString(val: unknown): val is string {
return typeof val === "string" && val.trim().length > 0;
}
/**
* 2.9.0 could not match a 2.8 OIDC account to its provider and made a second
* account on first sign-in. The original keeps only its "legacy-oidc" link
* with the same subject, so the duplicate may be merged back into it even
* though it has no password.
*/
async function isLegacyDuplicate(
externalUserId: string,
targetLinks: Array<{ providerId: string; subject: string }>,
): Promise<boolean> {
if (targetLinks.length === 0) return false;
const sourceSubjects = new Set(
(
await createCurrentUserAuthRepository().listIdentitiesForUser(
externalUserId,
)
).map((link) => link.subject),
);
return targetLinks.every(
(link) =>
link.providerId === "legacy-oidc" && sourceSubjects.has(link.subject),
);
}
/**
* Merging an account that only signs in externally (SSO, LDAP or any login
* plugin) into a password account, and taking the external sign-in away
@@ -34,7 +58,7 @@ export function registerUserExternalAccountRoutes(
* /users/link-external-to-password:
* post:
* summary: Merge an external account into a password account
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
* description: Moves an external-only account's sign-in identities onto a password account and deletes the external-only account (admin only). A 2.8 SSO account whose only sign-in is the matching legacy OIDC link is accepted as the target too, to merge a duplicate made by 2.9.0. The 2.8 path /users/link-oidc-to-password and its oidcUserId field are accepted too.
* tags:
* - Users
* requestBody:
@@ -98,15 +122,18 @@ export function registerUserExternalAccountRoutes(
.status(404)
.json({ error: "Target password user not found" });
}
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
if ((await identities.listIdentitiesForUser(targetUser.id)).length > 0) {
return res.status(400).json({
error: "Target user already has an external sign-in",
});
const targetLinks = await identities.listIdentitiesForUser(targetUser.id);
if (!(await isLegacyDuplicate(externalUserId, targetLinks))) {
if (isExternalAccount(targetUser) || !targetUser.passwordHash) {
return res.status(400).json({
error: "Target user must be a password-based account",
});
}
if (targetLinks.length > 0) {
return res.status(400).json({
error: "Target user already has an external sign-in",
});
}
}
authLogger.info("Linking an external account to a password account", {
@@ -118,7 +145,8 @@ export function registerUserExternalAccountRoutes(
await userRepository.update(targetUser.id, {
isOidc: true,
oidcIdentifier: externalUser.oidcIdentifier,
oidcIdentifier:
externalUser.oidcIdentifier ?? targetUser.oidcIdentifier,
});
await identities.moveIdentities(externalUserId, targetUser.id);
+19
View File
@@ -25,3 +25,22 @@ export async function applyDefaultsAfterHostWrite(
});
}
}
/**
* The same for many hosts written in one go (an import), in a single pass
* instead of one per host.
*/
export async function applyDefaultsAfterHostWrites(
hostIds: number[],
): Promise<void> {
if (hostIds.length === 0) return;
try {
await recompute({ hostIds });
} catch (error) {
databaseLogger.warn("Could not apply host defaults after a write", {
operation: "host_defaults_after_write",
hosts: hostIds.length,
error: error instanceof Error ? error.message : String(error),
});
}
}
+76 -73
View File
@@ -30,6 +30,7 @@ import {
import { hostSettingsPlugins } from "../../database/routes/host-plugin-settings.js";
import { notifySettingChange } from "../../plugins/settings.js";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import {
buildCatalog,
catalogNamespaces,
@@ -342,83 +343,85 @@ async function afterHostsChanged(
* Classifies and rewrites a set of hosts. Returns the hosts whose values or
* overrides changed; with `dryRun` nothing is written.
*/
export async function materializeHosts(
target: HostDefaultsTarget,
options: MaterializeOptions = {},
): Promise<MaterializeResult> {
const repository = createCurrentHostDefaultsRepository();
const rows = (await repository.listHosts(target)).filter(
// A desktop's read-only copy of a shared host follows its owner's server.
(row) => !row.sharedSource,
);
if (rows.length === 0) return { changedHostIds: [] };
const { catalog } = currentCatalog();
const userIds = [...new Set(rows.map((row) => row.userId))];
const [index, placement, pluginRows] = await Promise.all([
loadDefaultsIndex(userIds),
loadPlacement(userIds),
loadPluginRows(rows.map((row) => row.id)),
]);
const withOverlay = applyOverlay(index, options.overlay);
const changed: Array<{ row: HostRow; plan: HostPlan }> = [];
for (const row of rows) {
const levels = levelsForHost(
withOverlay,
row.userId,
effectiveFolderPath(row.id, placement),
export const materializeHosts = DatabaseSaveTrigger.batched(
async (
target: HostDefaultsTarget,
options: MaterializeOptions = {},
): Promise<MaterializeResult> => {
const repository = createCurrentHostDefaultsRepository();
const rows = (await repository.listHosts(target)).filter(
// A desktop's read-only copy of a shared host follows its owner's server.
(row) => !row.sharedSource,
);
const plan = planHost(row, levels, catalog, pluginRows.get(row.id), {
keys: options.keys,
});
const valuesChanged =
Object.keys(plan.columns).some((key) => key !== "defaultOverrides") ||
plan.pluginWrites.length > 0;
if (options.dryRun) {
if (valuesChanged) changed.push({ row, plan });
continue;
if (rows.length === 0) return { changedHostIds: [] };
const { catalog } = currentCatalog();
const userIds = [...new Set(rows.map((row) => row.userId))];
const [index, placement, pluginRows] = await Promise.all([
loadDefaultsIndex(userIds),
loadPlacement(userIds),
loadPluginRows(rows.map((row) => row.id)),
]);
const withOverlay = applyOverlay(index, options.overlay);
const changed: Array<{ row: HostRow; plan: HostPlan }> = [];
for (const row of rows) {
const levels = levelsForHost(
withOverlay,
row.userId,
effectiveFolderPath(row.id, placement),
);
const plan = planHost(row, levels, catalog, pluginRows.get(row.id), {
keys: options.keys,
});
const valuesChanged =
Object.keys(plan.columns).some((key) => key !== "defaultOverrides") ||
plan.pluginWrites.length > 0;
if (options.dryRun) {
if (valuesChanged) changed.push({ row, plan });
continue;
}
if (valuesChanged || plan.overridesChanged) changed.push({ row, plan });
}
if (valuesChanged || plan.overridesChanged) changed.push({ row, plan });
}
if (options.dryRun) {
return { changedHostIds: changed.map(({ row }) => row.id) };
}
if (options.dryRun) {
return { changedHostIds: changed.map(({ row }) => row.id) };
}
await repository.updateHosts(
changed.map(({ row, plan }) => ({
id: row.id,
values: plan.columns as Partial<HostRow>,
})),
);
const settings = createCurrentPluginSettingsRepository();
for (const { plan } of changed) {
for (const write of plan.pluginWrites) {
if (write.value === undefined) {
await settings.delete(
write.pluginId,
"host",
String(write.hostId),
write.key,
);
} else {
await settings.set(
write.pluginId,
"host",
String(write.hostId),
write.key,
JSON.stringify(write.value),
);
await repository.updateHosts(
changed.map(({ row, plan }) => ({
id: row.id,
values: plan.columns as Partial<HostRow>,
})),
);
const settings = createCurrentPluginSettingsRepository();
for (const { plan } of changed) {
for (const write of plan.pluginWrites) {
if (write.value === undefined) {
await settings.delete(
write.pluginId,
"host",
String(write.hostId),
write.key,
);
} else {
await settings.set(
write.pluginId,
"host",
String(write.hostId),
write.key,
JSON.stringify(write.value),
);
}
}
}
}
const valueChanges = changed.filter(
({ plan }) =>
plan.pluginWrites.length > 0 ||
Object.keys(plan.columns).some((key) => key !== "defaultOverrides"),
);
await afterHostsChanged(valueChanges);
return { changedHostIds: valueChanges.map(({ row }) => row.id) };
}
const valueChanges = changed.filter(
({ plan }) =>
plan.pluginWrites.length > 0 ||
Object.keys(plan.columns).some((key) => key !== "defaultOverrides"),
);
await afterHostsChanged(valueChanges);
return { changedHostIds: valueChanges.map(({ row }) => row.id) };
},
);
+31 -2
View File
@@ -84,18 +84,47 @@ export class SSHHostKeyVerifier {
hostId,
);
// No row means nothing to compare against and nowhere to save the
// key, so it is never accepted silently (#1397): the user decides
// when there is a socket to ask on, otherwise it is refused.
if (!host) {
if (!ws) {
sshLogger.error(
"Host not found in database during key verification, rejecting",
{
operation: "host_key_no_host",
hostId,
ip,
port,
userId,
},
);
verify(false);
return;
}
const accepted = await this.promptUserForNewKey(
ws,
ip,
port,
undefined,
fingerprint,
keyType,
algorithm,
);
sshLogger.warn(
"Host not found in database during key verification",
accepted
? "Key for a host missing from the database accepted for this session only"
: "Key for a host missing from the database rejected",
{
operation: "host_key_no_host",
hostId,
ip,
port,
fingerprint,
userId,
},
);
verify(true);
verify(accepted);
return;
}
+4
View File
@@ -129,6 +129,10 @@ export async function resolveHostById(
}
host.terminalConfig = rest;
}
// Stored as the text "true"/"false"; the string "false" is truthy and would
// make the password provider skip password auth.
host.forceKeyboardInteractive =
String(host.forceKeyboardInteractive) === "true";
// A row the boot copy has not reached yet still has them in terminal_config.
host.sshOptions = parseSshOptions(
host.sshOptions != null ? host.sshOptions : host.terminalConfig,
+4
View File
@@ -11,6 +11,10 @@ export class HostSessionStatus {
private counts = new Map<number, number>();
private listeners = new Set<HostSessionStatusListener>();
hasActiveSession(hostId: number): boolean {
return (this.counts.get(hostId) ?? 0) > 0;
}
register(hostId: number): () => void {
const count = this.counts.get(hostId) ?? 0;
this.counts.set(hostId, count + 1);
+96 -94
View File
@@ -2,16 +2,16 @@
* The host list's status dot.
*
* Every host with status checks on gets a TCP connection to its port on a
* timer. A port that answers is "online", one that does not is "offline".
* "reachable" is the warning in between: the port answers but the last login
* failed (a session, a ctx.ssh connection or a plugin's
* ctx.hosts.status.reportLogin said so). It clears on the next working login,
* when the host is edited, or when a changed host key is accepted. Probing
* never logs in: on RADIUS or Duo backed devices a login fires a real 2FA
* push, every interval.
* timer. A port that answers is "online", one that does not (twice in a row)
* is "offline". An open session or a working login also counts as online.
* Probing never logs in to the host itself: on RADIUS or Duo backed devices a
* login fires a real 2FA push, every interval. A host behind jump hosts is
* probed through a chain, which does log in to the hops, so hosts that share
* a hop list share one chain (jump-probe-pool.ts).
*
* Polling is demand driven. A user asking for statuses starts polling their
* own hosts; nothing is probed for a user who never opened the app.
* own hosts; nothing is probed for a user who never opened the app. The first
* request waits briefly for those first checks so it is not empty.
*/
import { pluginEvents, TOPICS } from "../../plugins/events.js";
@@ -22,23 +22,21 @@ import {
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js";
import {
statusAfterAuthentication,
statusAfterReachabilityCheck,
type HostStatus,
} from "./host-status.js";
import type { HostStatus } from "./host-status.js";
import { ConcurrentLimiter } from "./limiter.js";
import { tcpPing, tcpPingThroughJumpHost } from "./tcp-ping.js";
import { tcpPing } from "./tcp-ping.js";
import { JumpProbePool } from "./jump-probe-pool.js";
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval";
export const DEFAULT_STATUS_INTERVAL = 60;
export const DEFAULT_STATUS_INTERVAL = 30;
/** How long the first request for a user waits for the first checks. */
const FIRST_CHECK_WAIT_MS = 6_000;
/** A status younger than this is fresh enough for check(). */
const FRESH_MS = 30_000;
export interface HostStatusEntry {
status: HostStatus;
lastChecked: string;
reason?: "host_key_changed";
}
export interface HostStatusPayload {
@@ -48,7 +46,6 @@ export interface HostStatusPayload {
previous: HostStatus | null;
/** Anything but offline, the shape automations' trigger already reads. */
online: boolean;
reason?: "host_key_changed";
}
export interface StatusTarget {
@@ -78,8 +75,11 @@ export interface HostStatusDeps {
target: StatusTarget,
port: number,
) => Promise<boolean>;
hasActiveSession?: (hostId: number) => boolean;
globalInterval: () => number;
emit: (payload: HostStatusPayload) => void;
/** Pause before a failed ping is tried again. */
retryDelayMs?: number;
}
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
@@ -110,6 +110,11 @@ export function toStatusTarget(row: HostStatusTargetRow): StatusTarget {
};
}
const jumpProbes = new JumpProbePool(async (jumpHosts, userId) => {
const { createJumpHostChain } = await import("../jump-host-chain.js");
return createJumpHostChain(jumpHosts, userId);
});
const defaultDeps: HostStatusDeps = {
loadTargets: async (filter) =>
(
@@ -126,17 +131,10 @@ const defaultDeps: HostStatusDeps = {
);
return [...new Set(entries.map((entry) => entry.hostId))];
},
hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId),
ping: (host, port) => tcpPing(host, port, 5000),
pingThroughJumpHosts: async (target, port) => {
const { createJumpHostChain } = await import("../jump-host-chain.js");
const client = await createJumpHostChain(
target.jumpHosts,
target.userId,
).catch(() => null);
return client
? tcpPingThroughJumpHost(client, target.ip, port, 5000)
: false;
},
pingThroughJumpHosts: (target, port) =>
jumpProbes.ping(target.jumpHosts, target.userId, target.ip, port, 5000),
globalInterval: () => {
const value = Number(getCurrentSettingValue(GLOBAL_STATUS_INTERVAL_KEY));
return Number.isInteger(value) && value >= 5
@@ -156,7 +154,6 @@ export class HostStatusService {
private readonly store = new Map<number, HostStatusEntry>();
private readonly owners = new Map<number, string>();
private readonly inFlight = new Map<number, Promise<void>>();
private readonly loginFailed = new Set<number>();
private readonly startedUsers = new Set<string>();
private readonly portResolvers = new Map<string, Set<PortResolver>>();
private readonly limiter = new ConcurrentLimiter(20);
@@ -173,19 +170,12 @@ export class HostStatusService {
}),
pluginEvents.on(TOPICS.hostUpdated, (payload) => {
const { hostId } = payload as { hostId?: number };
if (!hostId) return;
// An edit may have fixed the login, so the next attempt decides.
this.loginFailed.delete(hostId);
void this.reload(hostId);
if (hostId) void this.reload(hostId);
}),
pluginEvents.on(TOPICS.hostDeleted, (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) this.forget(hostId);
}),
pluginEvents.on(TOPICS.hostKeyUpdated, (payload) => {
const { hostId } = payload as { hostId?: number };
if (hostId) this.clearHostKeyReason(hostId);
}),
];
}
@@ -210,11 +200,13 @@ export class HostStatusService {
userId: string,
requestedHostIds: Set<number> | null,
): Promise<Map<number, HostStatusEntry>> {
const started: Promise<void>[] = [];
if (requestedHostIds !== null) {
await this.reconcile(userId, requestedHostIds);
await this.reconcile(userId, requestedHostIds, started);
} else if (!this.startedUsers.has(userId)) {
await this.startUser(userId);
await this.startUser(userId, started);
}
if (started.length > 0) await this.waitFor(started);
return this.store;
}
@@ -247,20 +239,12 @@ export class HostStatusService {
return this.store.get(hostId) ?? null;
}
reportLogin(
hostId: number,
outcome: { ok: boolean; hostKeyChanged?: boolean },
): void {
if (outcome.ok) {
this.loginFailed.delete(hostId);
this.set(hostId, { status: statusAfterAuthentication(true) });
return;
}
this.loginFailed.add(hostId);
this.set(hostId, {
status: statusAfterAuthentication(false, this.store.get(hostId)?.status),
...(outcome.hostKeyChanged ? { reason: "host_key_changed" } : {}),
});
/**
* A working login proves the host is up. A failed one says nothing about
* reachability (a wrong password, another user's login), so it is ignored.
*/
reportLogin(hostId: number, outcome: { ok: boolean }): void {
if (outcome.ok) this.set(hostId, { status: "online" });
}
registerPort(connectionType: string, resolve: PortResolver): () => void {
@@ -276,13 +260,33 @@ export class HostStatusService {
};
}
private async startUser(userId: string): Promise<void> {
private async waitFor(started: Promise<void>[]): Promise<void> {
let timer: NodeJS.Timeout | undefined;
await Promise.race([
Promise.allSettled(started),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS);
timer.unref?.();
}),
]);
if (timer) clearTimeout(timer);
}
private track(started: Promise<void>[], target: StatusTarget): void {
const probe = this.startPolling(target);
if (probe) started.push(probe);
}
private async startUser(
userId: string,
started: Promise<void>[],
): Promise<void> {
this.startedUsers.add(userId);
const targets = await this.deps.loadTargets({ userId });
for (const target of targets) {
if (!this.polled.has(target.id)) this.startPolling(target);
if (!this.polled.has(target.id)) this.track(started, target);
}
await this.startShared(userId, null);
await this.startShared(userId, null, started);
}
/**
@@ -292,6 +296,7 @@ export class HostStatusService {
private async startShared(
userId: string,
allowed: Set<number> | null,
started: Promise<void>[],
): Promise<void> {
if (!this.deps.loadSharedHostIds) return;
try {
@@ -302,7 +307,7 @@ export class HostStatusService {
if (shared.length === 0) return;
const targets = await this.deps.loadTargets({ hostIds: shared });
for (const target of targets) {
if (!this.polled.has(target.id)) this.startPolling(target);
if (!this.polled.has(target.id)) this.track(started, target);
}
} catch (error) {
sshLogger.warn("Could not start status checks for shared hosts", {
@@ -313,7 +318,11 @@ export class HostStatusService {
}
}
private async reconcile(userId: string, allowed: Set<number>): Promise<void> {
private async reconcile(
userId: string,
allowed: Set<number>,
started: Promise<void>[],
): Promise<void> {
const targets = await this.deps.loadTargets({ userId });
for (const target of targets) {
if (!allowed.has(target.id)) {
@@ -321,9 +330,9 @@ export class HostStatusService {
this.store.delete(target.id);
continue;
}
if (!this.polled.has(target.id)) this.startPolling(target);
if (!this.polled.has(target.id)) this.track(started, target);
}
await this.startShared(userId, allowed);
await this.startShared(userId, allowed, started);
}
private async reload(hostId: number): Promise<void> {
@@ -349,16 +358,6 @@ export class HostStatusService {
this.stopPolling(hostId);
this.store.delete(hostId);
this.owners.delete(hostId);
this.loginFailed.delete(hostId);
}
private clearHostKeyReason(hostId: number): void {
const current = this.store.get(hostId);
if (current?.reason !== "host_key_changed") return;
this.loginFailed.delete(hostId);
this.set(hostId, {
status: current.status === "offline" ? "offline" : "online",
});
}
private intervalMs(target: StatusTarget): number {
@@ -371,21 +370,26 @@ export class HostStatusService {
);
}
private startPolling(target: StatusTarget, probeNow = true): void {
/** Returns the first probe when one was started. */
private startPolling(
target: StatusTarget,
probeNow = true,
): Promise<void> | undefined {
this.stopPolling(target.id);
this.owners.set(target.id, target.userId);
if (!target.statusCheckEnabled) {
this.store.delete(target.id);
return;
return undefined;
}
const polled: Polled = { target };
this.polled.set(target.id, polled);
if (probeNow) void this.probe(target);
const first = probeNow ? this.probe(target) : undefined;
polled.timer = setInterval(() => {
const latest = this.polled.get(target.id);
if (latest) void this.probe(latest.target);
}, this.intervalMs(target));
polled.timer.unref?.();
return first;
}
private stopPolling(hostId: number): void {
@@ -425,29 +429,28 @@ export class HostStatusService {
return target.port;
}
private async pingOnce(target: StatusTarget): Promise<boolean> {
try {
if (this.deps.hasActiveSession?.(target.id)) return true;
const port = await this.portFor(target);
return target.jumpHosts.length > 0
? await this.deps.pingThroughJumpHosts(target, port)
: await this.deps.ping(target.ip, port);
} catch {
return false;
}
}
private async probeNow(target: StatusTarget): Promise<void> {
this.owners.set(target.id, target.userId);
let reachable = false;
try {
const port = await this.portFor(target);
reachable =
target.jumpHosts.length > 0
? await this.deps.pingThroughJumpHosts(target, port)
: await this.deps.ping(target.ip, port);
} catch {
reachable = false;
let reachable = await this.pingOnce(target);
if (!reachable) {
// One lost packet should not flip the dot.
const delay = this.deps.retryDelayMs ?? 1000;
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
reachable = await this.pingOnce(target);
}
const current = this.store.get(target.id);
this.set(target.id, {
status: statusAfterReachabilityCheck(
reachable,
this.loginFailed.has(target.id),
),
...(reachable && current?.reason === "host_key_changed"
? { reason: "host_key_changed" as const }
: {}),
});
this.set(target.id, { status: reachable ? "online" : "offline" });
}
private set(hostId: number, entry: Omit<HostStatusEntry, "lastChecked">) {
@@ -480,8 +483,7 @@ export class HostStatusService {
ownerUserId,
status: entry.status,
previous,
online: entry.status !== "offline",
...(entry.reason ? { reason: entry.reason } : {}),
online: entry.status === "online",
});
}
}
+1 -17
View File
@@ -1,4 +1,4 @@
export type HostStatus = "online" | "reachable" | "offline";
export type HostStatus = "online" | "offline";
export function isHostKeyVerificationError(error: unknown): boolean {
return (
@@ -7,19 +7,3 @@ export function isHostKeyVerificationError(error: unknown): boolean {
error.message.includes("Host key changed"))
);
}
export function statusAfterReachabilityCheck(
reachable: boolean,
loginFailed: boolean,
): HostStatus {
if (!reachable) return "offline";
return loginFailed ? "reachable" : "online";
}
export function statusAfterAuthentication(
authenticated: boolean,
current?: HostStatus,
): HostStatus {
if (authenticated) return "online";
return current === "offline" ? "offline" : "reachable";
}
@@ -0,0 +1,99 @@
import type { Client } from "ssh2";
import { tcpPingThroughJumpHostResult } from "./tcp-ping.js";
type OpenChain = (
jumpHosts: Array<{ hostId: number }>,
userId: string,
) => Promise<Client | null>;
interface Entry {
client: Promise<Client | null>;
idle?: NodeJS.Timeout;
}
/**
* Status probes for hosts behind jump hosts share one chain per hop list.
* Opening a chain logs in to every hop, so doing it for every host on every
* interval made an import of jump-hosted hosts open dozens of SSH sessions
* each minute. A chain closes after it sits idle.
*/
export class JumpProbePool {
private readonly entries = new Map<string, Entry>();
constructor(
private readonly open: OpenChain,
private readonly idleMs = 120_000,
) {}
async ping(
jumpHosts: Array<{ hostId: number }>,
userId: string,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
const key = `${userId}:${jumpHosts.map((hop) => hop.hostId).join(">")}`;
let entry = this.entries.get(key);
if (!entry) {
entry = { client: this.connect(key, jumpHosts, userId) };
this.entries.set(key, entry);
}
const client = await entry.client;
if (!client) {
this.drop(key, entry);
return false;
}
this.touch(key, entry, client);
const result = await tcpPingThroughJumpHostResult(
client,
host,
port,
timeoutMs,
true,
);
if (result === "timeout") {
// The hop never answered, so the next ping opens a fresh chain.
this.drop(key, entry);
client.end();
}
return result === "ok";
}
closeAll(): void {
for (const [key, entry] of [...this.entries]) {
this.drop(key, entry);
void entry.client.then((client) => client?.end()).catch(() => {});
}
}
private async connect(
key: string,
jumpHosts: Array<{ hostId: number }>,
userId: string,
): Promise<Client | null> {
const client = await this.open(jumpHosts, userId).catch(() => null);
if (client) {
const forget = () => {
const current = this.entries.get(key);
if (current) this.drop(key, current);
};
client.once("close", forget);
client.once("error", forget);
}
return client;
}
private touch(key: string, entry: Entry, client: Client): void {
if (entry.idle) clearTimeout(entry.idle);
entry.idle = setTimeout(() => {
this.drop(key, entry);
client.end();
}, this.idleMs);
entry.idle.unref?.();
}
private drop(key: string, entry: Entry): void {
if (entry.idle) clearTimeout(entry.idle);
if (this.entries.get(key) === entry) this.entries.delete(key);
}
}
+34 -9
View File
@@ -2,8 +2,8 @@ import net from "net";
import type { Client } from "ssh2";
/**
* Opens a TCP connection and closes it again. An SSH server gets a polite
* banner back so it does not log a failed handshake.
* Opens a TCP connection and closes it again. SSH probes exchange banners,
* but still close before authentication and may trigger aggressive Fail2Ban rules.
*/
export function tcpPing(
host: string,
@@ -64,29 +64,54 @@ export function tcpPing(
});
}
/** The same check from the far end of a jump host chain. Ends the chain. */
export function tcpPingThroughJumpHost(
/**
* "refused" means the last hop answered that the target is down. "timeout"
* means the hop never answered, so the chain itself may be dead.
*/
export type JumpPingResult = "ok" | "refused" | "timeout";
export function tcpPingThroughJumpHostResult(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
): Promise<boolean> {
keepOpen = false,
): Promise<JumpPingResult> {
return new Promise((resolve) => {
let settled = false;
const finish = (result: boolean) => {
const finish = (result: JumpPingResult) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient.end();
if (!keepOpen) jumpClient.end();
resolve(result);
};
const timeout = setTimeout(() => finish(false), timeoutMs);
const timeout = setTimeout(() => finish("timeout"), timeoutMs);
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
stream?.destroy();
finish(!error && !!stream);
finish(!error && !!stream ? "ok" : "refused");
});
});
}
/** The same check from the far end of a jump host chain. Ends the chain. */
export async function tcpPingThroughJumpHost(
jumpClient: Pick<Client, "forwardOut" | "end">,
host: string,
port: number,
timeoutMs = 5000,
keepOpen = false,
): Promise<boolean> {
return (
(await tcpPingThroughJumpHostResult(
jumpClient,
host,
port,
timeoutMs,
keepOpen,
)) === "ok"
);
}
+43
View File
@@ -0,0 +1,43 @@
import { parseSSHKey, type KeyInfo } from "../utils/ssh-key-utils.js";
import {
isSecretReference,
resolveSecretReference,
} from "./external-secrets.js";
/**
* Checks a private key before it is saved. A secret reference (op://...) is
* resolved when connecting, so it is stored as given: there is nothing to
* parse yet, and parsing the reference itself always failed (#1394).
*/
export function parseKeyForStorage(
key: string,
passphrase?: string | null,
): KeyInfo & { reference: boolean } {
if (isSecretReference(key) || isSecretReference(passphrase)) {
return {
success: true,
privateKey: key.trim(),
publicKey: "",
keyType: "",
reference: true,
};
}
return {
...parseSSHKey(key, passphrase ?? undefined),
reference: false,
};
}
/** The real key and passphrase behind any references, for key helpers. */
export async function resolveKeyReferences(
userId: string,
key: string,
passphrase?: string | null,
): Promise<{ key: string; passphrase?: string }> {
const resolve = async (value: string) =>
isSecretReference(value) ? resolveSecretReference(userId, value) : value;
return {
key: await resolve(key),
passphrase: passphrase ? await resolve(passphrase) : undefined,
};
}
+65 -26
View File
@@ -33,7 +33,6 @@ import {
} from "../hosts/connect/auth-provider-registry.js";
import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js";
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
import { isHostKeyVerificationError } from "../hosts/status/host-status.js";
import {
getLoginMethod,
registerLoginMethod,
@@ -96,8 +95,33 @@ function describeHost(host: number | PluginSshHost): string {
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
}
const LOGIN_FAILED =
/all configured authentication methods failed|permission denied|authentication failed/i;
const OVERRIDE_FIELDS = [
"password",
"key",
"privateKey",
"keyPassword",
"passphrase",
] as const;
/**
* A secret the plugin set on its copy (a password the user just typed) wins
* over the stored one; everything else comes from what core resolved.
*/
function withOverrides(
resolved: SshConnectHost,
given: PluginSshHost,
): SshConnectHost {
if (given === (resolved as unknown)) return resolved;
const source = given as unknown as Record<string, unknown>;
const set = OVERRIDE_FIELDS.filter(
(field) => typeof source[field] === "string" && source[field] !== "",
);
if (set.length === 0) return resolved;
const merged = { ...resolved } as unknown as Record<string, unknown>;
for (const field of set) merged[field] = source[field];
if (typeof source.authType === "string") merged.authType = source.authType;
return merged as unknown as SshConnectHost;
}
export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const pluginId = manifest.id;
@@ -134,8 +158,37 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
{ host: SshConnectHost; userId: string }
>();
const remember = (key: object, host: SshConnectHost, userId: string) =>
resolvedHosts.set(key, { host, userId });
// Plugins often copy a host before connecting ({ ...host, port }). Spread
// copies enumerable symbol keys, so the copy keeps this tag. Its value is an
// opaque token, not the host, so the secrets stay out of the plugin's reach.
const tag = Symbol("resolvedHost");
const tokens = new WeakMap<
object,
{ host: SshConnectHost; userId: string }
>();
const remember = (key: object, host: SshConnectHost, userId: string) => {
const entry = { host, userId };
resolvedHosts.set(key, entry);
const token = Object.freeze({});
tokens.set(token, entry);
if (Object.isExtensible(key)) {
Object.defineProperty(key, tag, {
value: token,
enumerable: true,
configurable: true,
});
}
};
const lookup = (host: object) => {
const known = resolvedHosts.get(host);
if (known) return known;
const token = (host as Record<symbol, unknown>)[tag];
return token && typeof token === "object"
? tokens.get(token as object)
: undefined;
};
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
const redacted = redactHostSecrets(
@@ -148,9 +201,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
/** The acting user for a call about this host. */
const userFor = (host: number | PluginSshHost | undefined): string =>
actingUser(
host && typeof host === "object"
? resolvedHosts.get(host)?.userId
: undefined,
host && typeof host === "object" ? lookup(host)?.userId : undefined,
);
/**
@@ -163,29 +214,18 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
userId: string,
): number | SshConnectHost => {
if (typeof host === "number") return host;
const known = resolvedHosts.get(host);
if (known) return known.host;
const known = lookup(host);
if (known) return withOverrides(known.host, host);
return { ...(host as unknown as SshConnectHost), userId };
};
/**
* A login to a saved host core resolved feeds its status dot. Only a clear
* auth or host key failure counts: a timeout says nothing about the login.
*/
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
const hostId =
typeof host === "number" ? host : resolvedHosts.get(host)?.host.id;
/** A working login to a saved host core resolved marks it online. */
const reportLogin = (host: number | PluginSshHost) => {
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
if (!Number.isInteger(hostId) || hostId <= 0) return;
let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true };
if (error !== undefined) {
const message = error instanceof Error ? error.message : String(error);
const hostKeyChanged = isHostKeyVerificationError(error);
if (!hostKeyChanged && !LOGIN_FAILED.test(message)) return;
outcome = { ok: false, hostKeyChanged };
}
void import("../hosts/status/host-status-service.js")
.then(({ hostStatusService }) =>
hostStatusService.reportLogin(hostId, outcome),
hostStatusService.reportLogin(hostId, { ok: true }),
)
.catch(() => {});
};
@@ -245,7 +285,6 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
reportLogin(host, error);
throw error;
}
};
+4 -4
View File
@@ -156,7 +156,7 @@ async function writeAudit(
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
// Attribution comes from the runtime, never from the plugin.
userId: getActor() ?? "system",
userId: getActor() ?? null,
username: `plugin:${manifest.id}`,
action: `plugin_${options.action}`,
resourceType: "plugin",
@@ -965,12 +965,12 @@ export function createPluginContext(
record: async (entry) => {
try {
const { logAudit } = await import("../utils/audit-logger.js");
const actor = getActor() ?? "system";
const actor = getActor();
const meta = requestMeta(entry.request);
await logAudit({
...meta,
userId: actor,
username: actor,
userId: actor ?? null,
username: actor ?? "system",
action: entry.action,
resourceType: entry.resourceType ?? "plugin",
resourceId: entry.resourceId ?? pluginId,
+1 -1
View File
@@ -351,7 +351,7 @@ async function writePublicRouteAudit(
try {
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
userId: "system",
userId: null,
username: `plugin:${manifest.id}`,
action: "plugin_http_public_routes",
resourceType: "plugin",
+1 -1
View File
@@ -111,7 +111,7 @@ async function auditRefusal(
const { logAudit } = await import("../utils/audit-logger.js");
await logAudit({
// Attribution comes from the runtime, never from the plugin.
userId: getActor() ?? "system",
userId: getActor() ?? null,
username: `plugin:${pluginId}`,
action: `plugin_${action}`,
resourceType: "plugin",
+49 -20
View File
@@ -66,25 +66,37 @@ export function listUserOwnedTables(): UserOwnedTable[] {
type Row = Record<string, unknown>;
async function readOwnedRows(
owned: UserOwnedTable,
userId: string,
): Promise<Row[]> {
try {
return await selectRows<Row>(
sql`SELECT ${sql.join(
owned.columns.map((column) => sql.identifier(column)),
sql`, `,
)} FROM ${sql.identifier(owned.table)} WHERE ${sql.identifier(
owned.userColumn,
)} = ${userId}`,
);
} catch {
// The plugin's migration has not created the table yet.
return [];
}
}
const yieldToEventLoop = () =>
new Promise<void>((resolve) => setImmediate(resolve));
/** The user's rows in every user-owned plugin table, keyed by table. */
export async function readUserPluginRows(
userId: string,
): Promise<Record<string, Row[]>> {
const result: Record<string, Row[]> = {};
for (const owned of listUserOwnedTables()) {
try {
const rows = await selectRows<Row>(
sql`SELECT ${sql.join(
owned.columns.map((column) => sql.identifier(column)),
sql`, `,
)} FROM ${sql.identifier(owned.table)} WHERE ${sql.identifier(
owned.userColumn,
)} = ${userId}`,
);
if (rows.length > 0) result[owned.table] = rows;
} catch {
// The plugin's migration has not created the table yet.
}
const rows = await readOwnedRows(owned, userId);
if (rows.length > 0) result[owned.table] = rows;
await yieldToEventLoop();
}
return result;
}
@@ -95,18 +107,25 @@ interface SqliteLike {
run(...params: unknown[]): unknown;
all(...params: unknown[]): unknown[];
};
transaction?(fn: (rows: Row[]) => void): (rows: Row[]) => unknown;
}
/** Writes the user's plugin rows into a SQLite export file. */
const INSERT_CHUNK = 2000;
/**
* Writes the user's plugin rows into a SQLite export file. One table at a
* time, in chunked transactions with a yield between them: history tables
* (command history, health checks) can hold many thousands of rows, and the
* export runs on the thread that serves every other request.
*/
export async function writeUserPluginTables(
exportDb: SqliteLike,
userId: string,
): Promise<number> {
const rowsByTable = await readUserPluginRows(userId);
let written = 0;
for (const owned of listUserOwnedTables()) {
const rows = rowsByTable[owned.table];
if (!rows?.length) continue;
const rows = await readOwnedRows(owned, userId);
if (!rows.length) continue;
for (const statement of createTableSql(
"sqlite",
owned.pluginId,
@@ -119,9 +138,19 @@ export async function writeUserPluginTables(
.map((column) => `"${column}"`)
.join(", ")}) VALUES (${owned.columns.map(() => "?").join(", ")})`,
);
for (const row of rows) {
insert.run(...owned.columns.map((column) => toSqlite(row[column])));
written++;
const writeChunk = (chunk: Row[]) => {
for (const row of chunk) {
insert.run(...owned.columns.map((column) => toSqlite(row[column])));
}
};
const runChunk = exportDb.transaction
? exportDb.transaction(writeChunk)
: writeChunk;
for (let start = 0; start < rows.length; start += INSERT_CHUNK) {
const chunk = rows.slice(start, start + INSERT_CHUNK);
runChunk(chunk);
written += chunk.length;
await yieldToEventLoop();
}
}
return written;
+1 -1
View File
@@ -382,7 +382,7 @@ function auditPublicSocket(pluginId: string, path: string): void {
void import("../utils/audit-logger.js")
.then(({ logAudit }) =>
logAudit({
userId: "system",
userId: null,
username: `plugin:${pluginId}`,
action: "plugin_ws_public_route",
resourceType: "plugin",
+7 -1
View File
@@ -30,6 +30,7 @@ import {
} from "./host-plugin-settings.js";
import {
exportProtocolLogins,
exportSharedProtocolLogins,
importProtocolLogins,
} from "./host-protocol-auth.js";
import {
@@ -477,7 +478,7 @@ async function writeAccountProfile(userId: string, wire: SyncRow) {
}
}
/** Host columns a shared copy never carries. */
/** Host columns a shared copy never carries as the owner has them. */
const SHARED_HOST_DROP = [
"id",
"userId",
@@ -546,6 +547,11 @@ async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
: value;
}
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
wire.pluginSettings = await exportHostPluginSettings(hostId, {
userId,
permissionLevel,
});
wire.protocolAuth = await exportSharedProtocolLogins(host, userId);
wire.syncId = host.syncId;
wire.shared = {
hostId,
+32 -5
View File
@@ -15,6 +15,7 @@ import { databaseLogger } from "../utils/logger.js";
import type { DefaultOverrides } from "../../types/host-defaults.js";
import {
hostSettingsPlugins,
withHostPluginSettings,
type HostPluginSettings,
} from "../database/routes/host-plugin-settings.js";
@@ -23,19 +24,45 @@ export interface PluginHostSettingsSync {
importValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
}
/** What a host carries to the other side of a sync pair. */
/** Who a shared host's copy is for, and at what share level. */
export interface SharedHostViewer {
userId: string;
permissionLevel: string;
}
/**
* What a host carries to the other side of a sync pair. With a viewer, only
* what that user sees of a host shared with them, as the host routes show it.
*/
export async function exportHostPluginSettings(
hostId: number,
viewer?: SharedHostViewer,
): Promise<HostPluginSettings> {
const visible = viewer
? (((
await withHostPluginSettings(
{
id: hostId,
isShared: true,
permissionLevel: viewer.permissionLevel,
},
viewer.userId,
)
).pluginSettings as HostPluginSettings | undefined) ?? {})
: null;
const result: HostPluginSettings = {};
for (const manifest of hostSettingsPlugins()) {
const fields = declaredFields(manifest, "host").filter(
(field) => field.type !== "secret",
(field) =>
field.type !== "secret" &&
(!visible || field.key in (visible[manifest.id] ?? {})),
);
if (fields.length === 0) continue;
const values = await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
});
const values = visible
? visible[manifest.id]
: await getAllSettings(manifest, "host", hostId, {
redactSecrets: true,
});
const hook = consume<PluginHostSettingsSync>(
`${manifest.id}.hostSettingsSync`,
);
+42
View File
@@ -6,12 +6,15 @@
import type { SyncRow } from "@termix/plugin-sdk/backend";
import type { HostProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js";
import { createCurrentHostProtocolAuthRepository } from "../database/repositories/factory.js";
import {
fromPortableLogin,
listProtocolLogins,
replaceProtocolLogins,
resolveRecipientProtocolLogin,
toPortableLogins,
} from "../hosts/protocol-auth/protocol-auth.js";
import { findHostProtocol } from "../hosts/protocol-auth/registry.js";
import { syncLogger } from "../utils/logger.js";
type ResolveSyncId = (entityType: string, id: number) => Promise<string | null>;
@@ -38,6 +41,45 @@ export async function exportProtocolLogins(
return out;
}
/**
* The logins a user a host is shared with may use, for their desktop's copy:
* their own override or the owner's shared snapshot, never the owner's own
* secret. Each goes as a direct login since its credential is not theirs.
*/
export async function exportSharedProtocolLogins(
host: Record<string, unknown>,
userId: string,
): Promise<SyncRow> {
const out: SyncRow = {};
const hostId = Number(host.id);
const rows =
await createCurrentHostProtocolAuthRepository().listRowsForHost(hostId);
for (const row of rows) {
const declared = findHostProtocol(row.protocol);
if (!declared) continue;
try {
const login = await resolveRecipientProtocolLogin(host, userId, declared);
out[row.protocol] = {
authType: login.authType === "none" ? "none" : "direct",
username: login.username || null,
password: login.password || null,
fields: Object.fromEntries(
Object.entries(login.fields).filter(([, value]) => value),
),
credentialSyncId: null,
};
} catch (error) {
syncLogger.warn("Could not include a shared host's protocol login", {
operation: "sync_shared_host_protocol_auth",
hostId,
protocol: row.protocol,
error: error instanceof Error ? error.message : String(error),
});
}
}
return out;
}
/**
* Writes what a synced host carried. A row from a peer that sends no
* protocolAuth leaves the stored logins alone.
@@ -521,6 +521,89 @@ describe("external identities", () => {
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it.each(["true", "false"])(
"does not duplicate an unscoped migrated account with provisioning %s",
async (enabled) => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
const { runExternalIdentityMigration } =
await import("../../upgrade/external-identity-migration.js");
await runExternalIdentityMigration();
h.state.settings.set("oidc_auto_provision", enabled);
await expect(
runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
}),
).rejects.toMatchObject({ code: "legacy_identity_conflict" });
expect([...h.state.users.keys()]).toEqual(["old"]);
expect(h.state.identities).toHaveLength(1);
expect(h.state.identities[0]).toMatchObject({
userId: "old",
providerId: "legacy-oidc",
subject: "sub-1",
});
},
);
it("keeps equal subjects on explicitly different providers separate", async () => {
addUser({
id: "other",
username: "Other Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: 9,
});
h.state.identities.push({
id: 1,
userId: "other",
providerId: "9",
subject: "sub-1",
email: null,
});
h.state.settings.set("oidc_auto_provision", "true");
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(2);
expect(
h.state.identities.find((link) => link.providerId === "3")?.userId,
).not.toBe("other");
});
it("uses a verified provider link even when the old provider column is empty", async () => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
h.state.identities.push({
id: 1,
userId: "old",
providerId: "3",
subject: "sub-1",
email: null,
});
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
});
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(1);
});
it("re-checks the allowed list for existing users", async () => {
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
h.state.identities.push({
@@ -77,6 +77,34 @@ describe("AuditLogRepository", () => {
]);
});
it("stores entries with no acting user, and refuses ids that are not users", async () => {
const repo = await createRepository();
await repo.create({
userId: null,
username: "plugin:example",
action: "plugin_http_public_routes",
resourceType: "plugin",
success: true,
});
await expect(
repo.create({
userId: "system",
username: "system",
action: "cleanup",
resourceType: "plugin",
success: true,
}),
).rejects.toThrow();
const page = await repo.listPage({ filters: {}, limit: 10, offset: 0 });
expect(page.logs).toHaveLength(1);
expect(page.logs[0]).toMatchObject({
userId: null,
username: "plugin:example",
});
});
it("deletes logs by user id and only runs write hook for deleted rows", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
@@ -4,12 +4,13 @@ import { DATABASE_DIALECT_ENV } from "../../../database/db/dialect.js";
// getDb() throws unless a database was initialized; the context's handle is
// not what this file is about.
vi.mock("../../../database/db/index.js", () => ({
getDb: () => ({}),
getDb: vi.fn(() => ({})),
getSqlite: () => ({}),
DatabaseSaveTrigger: { forceSave: vi.fn(), triggerSave: vi.fn() },
}));
const {
createCurrentSessionRepository,
createCurrentRepositoryContext,
createCurrentRepositoryWriteHook,
createCurrentRepositoryLazyWriteHook,
@@ -86,3 +87,35 @@ describe("createCurrentRepositoryLazyWriteHook", () => {
}
});
});
it("wires SQLite activity to the periodic flush and token changes to immediate persistence", async () => {
const saved = process.env[DATABASE_DIALECT_ENV];
process.env[DATABASE_DIALECT_ENV] = "sqlite";
const { getDb } = await import("../../../database/db/index.js");
const { DatabaseSaveTrigger } =
await import("../../../utils/database-save-trigger.js");
const dirty = vi
.spyOn(DatabaseSaveTrigger, "markDirty")
.mockImplementation(() => {});
const force = vi.spyOn(DatabaseSaveTrigger, "forceSave").mockResolvedValue();
const query = {
update: () => query,
set: () => query,
where: async () => ({ changes: 1 }),
};
vi.mocked(getDb).mockReturnValue(
query as unknown as ReturnType<typeof getDb>,
);
try {
const repo = createCurrentSessionRepository();
await repo.touch("session-1");
expect(dirty).toHaveBeenCalledOnce();
expect(force).not.toHaveBeenCalled();
await repo.updateToken("session-1", "new-token");
expect(force).toHaveBeenCalledOnce();
} finally {
vi.restoreAllMocks();
if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV];
else process.env[DATABASE_DIALECT_ENV] = saved;
}
});
@@ -20,6 +20,7 @@ describe("UserRepository and SessionRepository", () => {
async function createRepositories(options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
onSessionActivity?: () => void | Promise<void>;
}): Promise<{
users: UserRepository;
sessions: SessionRepository;
@@ -28,6 +29,7 @@ describe("UserRepository and SessionRepository", () => {
options: {
onUserWrite?: () => void | Promise<void>;
onSessionWrite?: () => void | Promise<void>;
onSessionActivity?: () => void | Promise<void>;
} = {},
): Promise<{
users: UserRepository;
@@ -38,7 +40,11 @@ describe("UserRepository and SessionRepository", () => {
return {
users: new UserRepository(context, options.onUserWrite),
sessions: new SessionRepository(context, options.onSessionWrite),
sessions: new SessionRepository(
context,
options.onSessionWrite,
options.onSessionActivity,
),
};
}
@@ -228,6 +234,46 @@ describe("UserRepository and SessionRepository", () => {
);
});
it("separates activity persistence from security-critical session writes", async () => {
let writes = 0,
activity = 0;
const repo = await createRepositories({
onSessionWrite: () => {
writes++;
},
onSessionActivity: () => {
activity++;
},
});
await repo.users.create({
id: "user-1",
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
userId: "user-1",
jwtToken: "token",
deviceType: "desktop",
deviceInfo: "Firefox",
createdAt: "2026-06-26T00:00:00.000Z",
expiresAt: "2026-06-27T00:00:00.000Z",
lastActiveAt: "2026-06-26T00:00:00.000Z",
});
expect(writes).toBe(1);
await repo.sessions.touch("session-1", "2026-06-26T00:01:00.000Z");
expect(activity).toBe(1);
expect(writes).toBe(1);
await repo.sessions.touch("session-1", "2026-06-26T00:01:10.000Z");
expect(activity).toBe(1);
await repo.sessions.updateToken("session-1", "renewed");
expect(writes).toBe(2);
await repo.sessions.revoke("session-1");
expect(writes).toBe(3);
});
it("revokes all user sessions except an optional current session", async () => {
const repo = await createRepositories();
await repo.users.create({
@@ -0,0 +1,193 @@
/**
* A key credential whose private key is a secret reference (op://...) is
* saved as given and resolved when connecting (#1394). Parsing the reference
* as a key used to reject it, and an update kept the old private key.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
created: [] as Array<Record<string, unknown>>,
updated: [] as Array<Record<string, unknown>>,
existing: null as Record<string, unknown> | null,
}));
const pass = (_req: unknown, _res: unknown, next: () => void) => next();
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware:
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
req.userId = "u1";
next();
},
createDataAccessMiddleware: () => pass,
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({ requirePermission: () => pass }),
},
}));
vi.mock("../../../sync/shared-copy-guard.js", () => ({
rejectSharedCopyWrites: () => pass,
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async () => {},
getAuditUsername: async () => "u1",
getRequestMeta: () => ({ ipAddress: null, userAgent: null }),
}));
vi.mock("../../../utils/logger.js", () => {
const log = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
debug: vi.fn(),
};
return { authLogger: log, sshLogger: log, databaseLogger: log };
});
vi.mock("../../../database/routes/credential-key-routes.js", () => ({
registerCredentialKeyRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({
registerCredentialDeployRoutes: () => {},
}));
vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({
registerCredentialBulkRoutes: () => {},
}));
vi.mock("../../../hosts/delete-credential.js", () => ({
deleteOwnedCredential: async () => {},
}));
vi.mock("../../../hosts/connect/core-providers.js", () => ({
ensureCoreSshAuthProviders: () => {},
}));
vi.mock("../../../hosts/connect/auth-provider-registry.js", () => ({
listCredentialTypes: () => ["password", "key"],
}));
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
SharedHostSecretsManager: {
getInstance: () => ({ resyncHostsForCredential: async () => {} }),
},
}));
vi.mock("../../../utils/shared-credential-secrets-manager.js", () => ({
SharedCredentialSecretsManager: {
getInstance: () => ({ resyncCredential: async () => {} }),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentCredentialRepository: () => ({
createEncryptedForUser: async (
userId: string,
data: Record<string, unknown>,
) => {
h.created.push(data);
return { id: 1, userId, ...data };
},
findById: async () => (h.existing ? { id: 1, userId: "u1" } : null),
findDecryptedByIdForUser: async () => h.existing,
updateEncryptedForUser: async (
_userId: string,
_id: number,
fields: Record<string, unknown>,
) => {
h.updated.push(fields);
return { ...h.existing, ...fields };
},
}),
createCurrentUserRepository: () => ({}),
createCurrentCredentialAccessRepository: () => ({}),
createCurrentRoleRepository: () => ({}),
createCurrentHostResolutionRepository: () => ({}),
createCurrentHostRepository: () => ({}),
}));
const { default: router } =
await import("../../../database/routes/credentials.js");
// Never parsed here: only the update replacing it matters.
const REAL_KEY = "stored-key-material";
let server: http.Server;
let base: string;
beforeEach(async () => {
h.created = [];
h.updated = [];
h.existing = null;
const app = express();
app.use(express.json());
app.use("/credentials", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
function send(method: string, path: string, body: unknown) {
return fetch(`${base}/credentials${path}`, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("credentials with a secret reference key", () => {
it("saves an op:// private key as given", async () => {
const response = await send("POST", "", {
name: "vault key",
authType: "key",
username: "root",
key: "op://vault/item/private_key",
});
expect(response.status).toBe(201);
expect(h.created[0]).toMatchObject({
key: "op://vault/item/private_key",
privateKey: "op://vault/item/private_key",
publicKey: null,
detectedKeyType: null,
});
});
it("still rejects a key that is neither a key nor a reference", async () => {
const response = await send("POST", "", {
name: "bad",
authType: "key",
key: "not a key",
});
expect(response.status).toBe(400);
});
it("replaces the stored private key when updating to a reference", async () => {
h.existing = {
id: 1,
userId: "u1",
name: "k",
authType: "key",
key: REAL_KEY,
privateKey: REAL_KEY,
publicKey: "ssh-ed25519 AAAA old",
};
const response = await send("PUT", "/1", {
key: "op://vault/item/private_key",
});
expect(response.status).toBe(200);
expect(h.updated[0]).toMatchObject({
key: "op://vault/item/private_key",
privateKey: "op://vault/item/private_key",
publicKey: null,
detectedKeyType: null,
});
});
});
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
update: vi.fn(),
list: vi.fn(),
applyPluginSettings: vi.fn(),
applyDefaults: vi.fn(async () => {}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
applyPluginHostImportSettings: mocks.applyPluginSettings,
@@ -14,6 +15,7 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
vi.mock("../../../hosts/defaults/index.js", () => ({
applyHostDefaultsToWrite: async () => ({ core: [] }),
applyDefaultsAfterHostWrite: async () => {},
applyDefaultsAfterHostWrites: mocks.applyDefaults,
}));
vi.mock("../../../hosts/defaults/overrides.js", () => ({
changeHostOverrides: async () => {},
@@ -141,6 +143,9 @@ it.each([20, "20"])(
]);
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("exportId");
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("id");
// One defaults pass for the whole import, not one per host.
expect(mocks.applyDefaults).toHaveBeenCalledTimes(1);
expect(mocks.applyDefaults).toHaveBeenCalledWith([501, 502]);
},
);
@@ -88,7 +88,7 @@ beforeEach(() => {
state.statuses = new Map([
[1, { status: "online", lastChecked: "t" }],
[2, { status: "offline", lastChecked: "t" }],
[3, { status: "reachable", lastChecked: "t" }],
[3, { status: "offline", lastChecked: "t" }],
]);
service.statusesFor.mockImplementation(async () => state.statuses);
service.get.mockImplementation(
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import express from "express";
import request from "supertest";
const settings = vi.hoisted(() => new Map<string, string>());
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => settings.get(key) ?? null,
set: async (key: string, value: string) => {
settings.set(key, value);
},
}),
}));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { error: vi.fn() },
}));
import { registerHostTagRoutes } from "../../../database/routes/host-tag-routes.js";
function app() {
const server = express();
server.use(express.json());
registerHostTagRoutes(
server,
(req, res, next) =>
req.headers.authorization ? next() : void res.sendStatus(401),
(req, res, next) =>
req.headers.authorization === "admin" ? next() : void res.sendStatus(403),
);
return server;
}
beforeEach(() => settings.clear());
describe("instance host tag catalog", () => {
it("allows authenticated users to read the same admin-managed catalog", async () => {
const server = app();
await request(server)
.put("/tags")
.set("Authorization", "admin")
.send({ tags: [" Linux ", "Domain Controller", "Linux"] })
.expect(200, { tags: ["Linux", "Domain Controller"] });
await request(server)
.get("/tags")
.set("Authorization", "member")
.expect(200, { tags: ["Linux", "Domain Controller"] });
});
it("rejects anonymous reads and non-admin writes", async () => {
await request(app()).get("/tags").expect(401);
await request(app())
.put("/tags")
.set("Authorization", "member")
.send({ tags: ["test"] })
.expect(403);
expect(settings.size).toBe(0);
});
it.each([null, [42], [" "], ["x".repeat(101)], Array(501).fill("tag")])(
"rejects invalid catalogs",
async (tags) => {
await request(app())
.put("/tags")
.set("Authorization", "admin")
.send({ tags })
.expect(400);
expect(settings.size).toBe(0);
},
);
it("allows clearing suggestions without modifying hosts", async () => {
settings.set("host_tag_catalog", '["Linux"]');
await request(app())
.put("/tags")
.set("Authorization", "admin")
.send({ tags: [] })
.expect(200, { tags: [] });
});
it("reads a damaged catalog as empty instead of failing", async () => {
settings.set("host_tag_catalog", "{not json");
await request(app())
.get("/tags")
.set("Authorization", "member")
.expect(200, { tags: [] });
});
});
@@ -201,6 +201,65 @@ describe("linking an external account to a password account", () => {
});
expect(response.status).toBe(400);
});
it("merges a 2.9.0 duplicate back into its 2.8 SSO account", async () => {
h.users.set("orig", {
id: "orig",
username: "bob",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-9",
});
h.users.set("dup", {
id: "dup",
username: "bob-1",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: null,
});
h.identities.push(
{ userId: "orig", providerId: "legacy-oidc", subject: "sub-9" },
{ userId: "dup", providerId: "1", subject: "sub-9" },
);
const response = await post("/link-external-to-password", {
externalUserId: "dup",
targetUsername: "bob",
});
expect(response.status).toBe(200);
expect(h.deleted).toEqual(["dup"]);
expect(h.users.get("orig")!.oidcIdentifier).toBe("sub-9");
expect(
h.identities
.filter((row) => row.userId === "orig")
.map((row) => row.providerId),
).toEqual(["legacy-oidc", "1"]);
});
it("does not treat an unrelated SSO account as a duplicate target", async () => {
h.users.set("other", {
id: "other",
username: "carol",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-x",
});
h.identities.push({
userId: "other",
providerId: "legacy-oidc",
subject: "sub-x",
});
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "carol",
});
expect(response.status).toBe(400);
});
});
describe("unlinking", () => {
+32 -1
View File
@@ -5,9 +5,14 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { copyPluginSdk } =
const { copyPluginSdk, resourcesDirFor } =
require("../../../../packaging/build/after-pack.cjs") as {
copyPluginSdk: (resourcesDir: string, sdkDir?: string) => string;
resourcesDirFor: (context: {
electronPlatformName: string;
appOutDir: string;
packager: { appInfo: { productFilename: string } };
}) => string;
};
let dir: string;
@@ -53,4 +58,30 @@ describe("afterPack plugin SDK copy", () => {
"npm run build:sdk",
);
});
it.each(["darwin", "mas"])(
"copies into the app bundle for %s builds",
(electronPlatformName) => {
const context = {
electronPlatformName,
appOutDir: "/out",
packager: { appInfo: { productFilename: "Termix" } },
};
expect(resourcesDirFor(context)).toBe(
path.join("/out", "Termix.app", "Contents", "Resources"),
);
},
);
it.each(["win32", "linux"])(
"copies into resources for %s builds",
(electronPlatformName) => {
const context = {
electronPlatformName,
appOutDir: "/out",
packager: { appInfo: { productFilename: "Termix" } },
};
expect(resourcesDirFor(context)).toBe(path.join("/out", "resources"));
},
);
});
@@ -76,3 +76,47 @@ describe("host key recovery", () => {
expect(accepted).toEqual([]);
});
});
describe("a host missing from the database", () => {
function answeringSocket(action: "accept" | "reject") {
const socket = new EventEmitter() as EventEmitter & {
send: (data: string) => void;
};
socket.send = () =>
socket.emit(
"message",
Buffer.from(
JSON.stringify({
type: "host_key_verification_response",
data: { action },
}),
),
);
return socket as unknown as WebSocket;
}
async function verifyMissingHost(ws: WebSocket | null) {
const verifier = await SSHHostKeyVerifier.createHostVerifier(
hostId,
"127.0.0.1",
22,
ws,
"user",
false,
null,
);
return new Promise<boolean>((resolve) =>
verifier(Buffer.from("any-key"), resolve),
);
}
it("refuses the key when nobody can be asked", async () => {
expect(await verifyMissingHost(null)).toBe(false);
});
it("asks the user and saves nothing", async () => {
expect(await verifyMissingHost(answeringSocket("accept"))).toBe(true);
expect(await verifyMissingHost(answeringSocket("reject"))).toBe(false);
expect(updateHostKey).not.toHaveBeenCalled();
});
});
@@ -487,6 +487,16 @@ describe("resolveHostById", () => {
expect(host.sshOptions).toEqual({ keepaliveCountMax: 3 });
});
it("turns the stored force_keyboard_interactive text into a boolean", async () => {
state.host = baseHost({ forceKeyboardInteractive: "false" });
const off = (await resolveHostById(42, "owner")) as Record<string, unknown>;
expect(off.forceKeyboardInteractive).toBe(false);
state.host = baseHost({ forceKeyboardInteractive: "true" });
const on = (await resolveHostById(42, "owner")) as Record<string, unknown>;
expect(on.forceKeyboardInteractive).toBe(true);
});
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
state.isAdminBypass = true;
state.host = baseHost({
@@ -13,10 +13,13 @@ describe("HostSessionStatus", () => {
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenLastCalledWith(7, true);
expect(status.hasActiveSession(7)).toBe(true);
closeFirst();
expect(status.hasActiveSession(7)).toBe(true);
expect(listener).toHaveBeenCalledTimes(1);
closeSecond();
expect(status.hasActiveSession(7)).toBe(false);
expect(listener).toHaveBeenLastCalledWith(7, false);
expect(listener).toHaveBeenCalledTimes(2);
});
@@ -52,8 +52,10 @@ function setup(
loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping,
pingThroughJumpHosts,
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
retryDelayMs: 0,
});
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
}
@@ -74,6 +76,34 @@ afterEach(() => {
});
describe("HostStatusService", () => {
it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])(
"skips probes while a session is open, including jump hosts: %j",
async ({ jumpHosts }) => {
const { service, ping, pingThroughJumpHosts } = setup([
target(7, { jumpHosts }),
]);
active = service;
const close = hostSessionStatus.register(7);
try {
await service.statusesFor("owner", null);
await flush();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(service.get(7)?.status).toBe("online");
expect(ping).not.toHaveBeenCalled();
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
close();
await vi.advanceTimersByTimeAsync(60_000);
await flush();
expect(
jumpHosts.length ? pingThroughJumpHosts : ping,
).toHaveBeenCalledOnce();
} finally {
close();
}
},
);
it("starts a user's own hosts and reports them online", async () => {
const { service, emitted, ping } = setup([target(1), target(2)]);
active = service;
@@ -163,86 +193,44 @@ describe("HostStatusService", () => {
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
});
it("warns about a failed login until a login works again", async () => {
it("ignores a failed login and counts a working one as online", async () => {
const { service, emitted } = setup([target(1)]);
active = service;
await service.statusesFor("owner", null);
await flush();
const before = emitted.length;
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("reachable");
expect(emitted.at(-1)).toMatchObject({
status: "reachable",
previous: "online",
});
vi.advanceTimersByTime(75_000);
await flush();
expect(service.get(1)?.status).toBe("reachable");
service.reportLogin(1, { ok: true });
expect(service.get(1)?.status).toBe("online");
vi.advanceTimersByTime(75_000);
await flush();
expect(service.get(1)?.status).toBe("online");
expect(emitted).toHaveLength(before);
});
it("marks a changed host key and clears it once the new key is accepted", async () => {
const { service } = setup([target(1)]);
it("marks a host online when a terminal session opens", async () => {
const { service } = setup([target(1)], false);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false, hostKeyChanged: true });
expect(service.get(1)).toMatchObject({
status: "reachable",
reason: "host_key_changed",
});
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 });
expect(service.get(1)?.reason).toBeUndefined();
expect(service.get(1)?.status).toBe("online");
});
it("clears a failed login when the host is edited", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false });
pluginEvents.emit(TOPICS.hostUpdated, { hostId: 1 });
await flush();
await flush();
expect(service.get(1)?.status).toBe("online");
});
it("clears a failed login when a terminal session opens", async () => {
const { service } = setup([target(1)]);
active = service;
service.start();
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("offline");
const release = hostSessionStatus.register(1);
expect(service.get(1)?.status).toBe("online");
release();
});
it("tries a failed ping again before reporting offline", async () => {
const { service, ping } = setup([target(1)]);
active = service;
ping.mockResolvedValueOnce(false);
await service.statusesFor("owner", null);
expect(ping).toHaveBeenCalledTimes(2);
expect(service.get(1)?.status).toBe("online");
});
it("shows an offline host as offline whatever the login said", async () => {
const { service } = setup([target(1)], false);
it("answers the first request with the first checks done", async () => {
const { service } = setup([target(1), target(2)]);
active = service;
await service.statusesFor("owner", null);
await flush();
service.reportLogin(1, { ok: false });
expect(service.get(1)?.status).toBe("offline");
service.reportLogin(1, { ok: true });
vi.advanceTimersByTime(75_000);
await flush();
expect(service.get(1)?.status).toBe("offline");
const statuses = await service.statusesFor("owner", null);
expect(statuses.get(1)?.status).toBe("online");
expect(statuses.get(2)?.status).toBe("online");
});
it("only checks the requested hosts on the desktop app", async () => {
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
isHostKeyVerificationError,
statusAfterAuthentication,
statusAfterReachabilityCheck,
} from "../../../hosts/status/host-status.js";
import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js";
describe("isHostKeyVerificationError", () => {
it.each([
@@ -19,24 +15,3 @@ describe("isHostKeyVerificationError", () => {
);
});
});
describe("host availability status", () => {
it("calls a host whose port answers online", () => {
expect(statusAfterReachabilityCheck(true, false)).toBe("online");
expect(statusAfterReachabilityCheck(false, false)).toBe("offline");
});
it("warns about a reachable host whose last login failed", () => {
expect(statusAfterReachabilityCheck(true, true)).toBe("reachable");
expect(statusAfterReachabilityCheck(false, true)).toBe("offline");
});
it("marks successful authentication online", () => {
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
});
it("downgrades failed authentication without hiding reachability", () => {
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
});
});
@@ -0,0 +1,127 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js";
function fakeClient(reachable = true) {
const client = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(
(
_srcIp: string,
_srcPort: number,
_host: string,
_port: number,
callback: (error: Error | null, stream?: { destroy(): void }) => void,
) => {
callback(
reachable ? null : new Error("refused"),
reachable ? { destroy: () => {} } : undefined,
);
},
),
});
return client;
}
afterEach(() => {
vi.useRealTimers();
});
describe("JumpProbePool", () => {
it("shares one chain across hosts behind the same hops", async () => {
const client = fakeClient();
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
const hops = [{ hostId: 1 }, { hostId: 2 }];
const results = await Promise.all([
pool.ping(hops, "u1", "10.0.0.3", 22),
pool.ping(hops, "u1", "10.0.0.4", 22),
pool.ping(hops, "u1", "10.0.0.5", 22),
]);
expect(results).toEqual([true, true, true]);
expect(open).toHaveBeenCalledTimes(1);
expect(client.forwardOut).toHaveBeenCalledTimes(3);
expect(client.end).not.toHaveBeenCalled();
pool.closeAll();
});
it("keeps chains apart per user and hop list", async () => {
const open = vi.fn(async () => fakeClient() as never);
const pool = new JumpProbePool(open);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
await pool.ping([{ hostId: 1 }], "u2", "a", 22);
await pool.ping([{ hostId: 2 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
it("reports an unreachable target without dropping the chain", async () => {
const client = fakeClient(false);
const open = vi.fn(async () => client as never);
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "b", 22)).toBe(false);
expect(open).toHaveBeenCalledTimes(1);
pool.closeAll();
});
it("drops a chain whose hop stops answering", async () => {
vi.useFakeTimers();
const stale = Object.assign(new EventEmitter(), {
end: vi.fn(),
forwardOut: vi.fn(),
});
const fresh = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(stale)
.mockResolvedValueOnce(fresh);
const pool = new JumpProbePool(open);
const first = pool.ping([{ hostId: 1 }], "u1", "a", 22, 1000);
await vi.advanceTimersByTimeAsync(1000);
expect(await first).toBe(false);
expect(stale.end).toHaveBeenCalled();
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
expect(open).toHaveBeenCalledTimes(2);
pool.closeAll();
});
it("retries a chain that failed to open", async () => {
const open = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(fakeClient());
const pool = new JumpProbePool(open);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
pool.closeAll();
});
it("opens a new chain after the old one closed or sat idle", async () => {
vi.useFakeTimers();
const first = fakeClient();
const second = fakeClient();
const third = fakeClient();
const open = vi
.fn()
.mockResolvedValueOnce(first)
.mockResolvedValueOnce(second)
.mockResolvedValueOnce(third);
const pool = new JumpProbePool(open, 1000);
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
first.emit("close");
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1500);
expect(second.end).toHaveBeenCalled();
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
expect(open).toHaveBeenCalledTimes(3);
pool.closeAll();
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
const resolve = vi.fn(async (_userId: string, ref: string) => `real:${ref}`);
vi.mock("../../hosts/external-secrets.js", async (importOriginal) => ({
...(await importOriginal<object>()),
resolveSecretReference: (userId: string, ref: string) => resolve(userId, ref),
}));
const { parseKeyForStorage, resolveKeyReferences } =
await import("../../hosts/stored-ssh-key.js");
describe("parseKeyForStorage", () => {
it("accepts a secret reference without parsing it", () => {
expect(parseKeyForStorage(" op://v/i/private_key ")).toEqual({
success: true,
privateKey: "op://v/i/private_key",
publicKey: "",
keyType: "",
reference: true,
});
});
it("does not try to open a real key with a referenced passphrase", () => {
const result = parseKeyForStorage("-----BEGIN...", "op://v/i/pass");
expect(result).toMatchObject({ success: true, reference: true });
});
it("parses anything else as a key", () => {
const result = parseKeyForStorage("not a key");
expect(result.success).toBe(false);
expect(result.reference).toBe(false);
});
});
describe("resolveKeyReferences", () => {
it("resolves only the values that are references", async () => {
expect(await resolveKeyReferences("u1", "op://v/i/key", "plain")).toEqual({
key: "real:op://v/i/key",
passphrase: "plain",
});
expect(resolve).toHaveBeenCalledTimes(1);
});
});
+2 -1
View File
@@ -73,7 +73,8 @@ describe("ctx.audit.record", () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({ action: "cleanup", success: false });
expect(auditEntries[0]).toMatchObject({
userId: "system",
userId: null,
username: "system",
resourceType: "plugin",
resourceId: "audit-fixture",
resourceName: "Audit Fixture",
+5 -7
View File
@@ -129,7 +129,7 @@ vi.mock("../../hosts/host-session-status.js", () => ({
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
get: (hostId: number) =>
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
hostId === 4 ? { status: "online", lastChecked: "t" } : null,
check: async (hostId: number) => ({
status: "offline",
lastChecked: String(hostId),
@@ -496,7 +496,7 @@ describe("ctx.hosts", () => {
}
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
expect(record.pluginSettings).toEqual({ mine: "own-4" });
expect(record.status).toEqual({ status: "reachable", lastChecked: "t" });
expect(record.status).toEqual({ status: "online", lastChecked: "t" });
expect(record.enableSsh).toBe(true);
});
@@ -620,7 +620,7 @@ describe("ctx.hosts.status", () => {
audit: vi.fn(async () => {}),
});
await expect(hosts.status.get(4)).resolves.toEqual({
status: "reachable",
status: "online",
lastChecked: "t",
});
await expect(hosts.status.get(5)).resolves.toBeNull();
@@ -656,11 +656,9 @@ describe("ctx.hosts.status", () => {
manifest: manifest(["hosts:read"]),
audit: vi.fn(async () => {}),
});
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
hosts.status.reportLogin(4, { ok: true });
await settle();
expect(h.statusReports).toEqual([
{ hostId: 4, ok: false, hostKeyChanged: true },
]);
expect(h.statusReports).toEqual([{ hostId: 4, ok: true }]);
});
it("removes a registered port when the plugin is disposed", async () => {
+58 -7
View File
@@ -91,6 +91,18 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
},
}));
vi.mock("../../hosts/host-resolver.js", () => ({
resolveHostById: async (hostId: number, userId: string) => ({
id: hostId,
userId,
ip: "10.0.0.5",
port: 22,
username: "root",
authType: "password",
password: "stored-secret",
}),
resolveHostBySyncId: async () => null,
}));
vi.mock("../../hosts/status/host-status-service.js", () => ({
hostStatusService: {
reportLogin: (hostId: number, outcome: unknown) =>
@@ -208,24 +220,19 @@ describe("ctx.ssh", () => {
expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]);
});
it("reports an auth or host key failure, but not a timeout", async () => {
it("never reports a failed login to the status", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
h.connectError = new Error("Timed out while waiting for handshake");
await expect(ssh.connect(7)).rejects.toThrow();
h.connectError = new Error("All configured authentication methods failed");
await expect(ssh.connect(7)).rejects.toThrow();
h.connectError = new Error("Host key changed");
await expect(ssh.connect(8)).rejects.toThrow();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins).toEqual([
{ hostId: 7, outcome: { ok: false, hostKeyChanged: false } },
{ hostId: 8, outcome: { ok: false, hostKeyChanged: true } },
]);
expect(h.logins).toEqual([]);
});
it("never believes a host object's own userId", async () => {
@@ -253,6 +260,50 @@ describe("ctx.ssh", () => {
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
});
it("connects a copied redacted host with its stored login", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const ssh = createPluginSsh({
manifest: manifest(["ssh:connect", "credentials:use"]),
bag: new DisposableBag("fixture"),
audit: vi.fn(async () => {}),
});
const host = (await ssh.resolveHost(5))!;
expect((host as Record<string, unknown>).password).toBeUndefined();
expect(JSON.stringify(host)).not.toContain("stored-secret");
for (const symbol of Object.getOwnPropertySymbols(host)) {
expect(JSON.stringify((host as never)[symbol])).not.toContain(
"stored-secret",
);
}
await ssh.connect({ ...host, port: host.port || 22 });
expect(h.connects[0].target).toMatchObject({ password: "stored-secret" });
await ssh.connect({ ...host, password: "typed", authType: "password" });
expect(h.connects[1].target).toMatchObject({ password: "typed" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(h.logins.map((login) => login.hostId)).toEqual([5, 5]);
});
it("does not honor another plugin's resolved host", async () => {
h.granted = new Set(["ssh:connect", "credentials:use"]);
const deps = {
manifest: manifest(["ssh:connect", "credentials:use"]),
audit: vi.fn(async () => {}),
};
const first = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const second = createPluginSsh({
...deps,
bag: new DisposableBag("fixture"),
});
const host = (await first.resolveHost(5))!;
await second.connect({ ...host });
expect(h.connects[0].target).not.toHaveProperty("password");
});
it("passes a given stream through to the pipeline, gated like any connect", async () => {
const stream = { throughSource: true };
const ssh = createPluginSsh({
@@ -117,6 +117,32 @@ describe("plugin user data", () => {
expect(again).toMatchObject({ imported: 0, skipped: 1 });
});
it("writes large tables in chunks and lets the event loop run between them", async () => {
const insert = sqlite.prepare(
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
);
sqlite.transaction(() => {
for (let i = 0; i < 4999; i++) insert.run(`/p/${i}`);
})();
const file = new Database(":memory:");
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
file.exec("INSERT INTO users (id) VALUES ('u1')");
const transaction = vi.spyOn(file, "transaction");
let ticks = 0;
const ticker = setInterval(() => ticks++, 0);
const written = await writeUserPluginTables(file, "u1");
clearInterval(ticker);
expect(written).toBe(5000);
expect(transaction).toHaveBeenCalled();
expect(ticks).toBeGreaterThan(0);
expect(
file.prepare("SELECT COUNT(*) AS n FROM p_fixture_bookmark").get(),
).toEqual({ n: 5000 });
});
it("reads a pre-2.9 export by the legacy table name", async () => {
const file = new Database(":memory:");
file.exec(
@@ -11,10 +11,19 @@ const h = vi.hoisted(() => ({
writes: [] as Array<[string, number, string, unknown]>,
hooks: new Map<string, unknown>(),
manifests: [] as PluginManifest[],
visible: null as Record<string, Record<string, unknown>> | null,
viewerCalls: [] as Array<[Record<string, unknown>, string | undefined]>,
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => h.manifests,
withHostPluginSettings: async (
host: Record<string, unknown>,
viewerId?: string,
) => {
h.viewerCalls.push([host, viewerId]);
return h.visible ? { ...host, pluginSettings: h.visible } : host;
},
}));
vi.mock("../../plugins/registry.js", () => ({
consume: (key: string) => h.hooks.get(key),
@@ -65,11 +74,27 @@ const VAULT = {
},
} as unknown as PluginManifest;
const TUNNELS = {
id: "tunnels",
contributes: {
settings: {
host: {
fields: [
{ key: "enableTunnel", type: "boolean", labelKey: "k" },
{ key: "tunnelConnections", type: "custom", labelKey: "k" },
],
},
},
},
} as unknown as PluginManifest;
beforeEach(() => {
h.stored.clear();
h.writes.length = 0;
h.hooks.clear();
h.manifests = [VAULT];
h.visible = null;
h.viewerCalls.length = 0;
h.hooks.set("vault.hostSettingsSync", {
exportValue: (key: string, value: unknown) =>
key === "profileId" && value === 4 ? "profile-sync-4" : value,
@@ -87,6 +112,25 @@ describe("host plugin settings over sync", () => {
});
});
it("exports only what a shared host's viewer sees", async () => {
h.manifests = [VAULT, TUNNELS];
h.visible = {
vault: { token: { set: true } },
tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] },
};
expect(
await exportHostPluginSettings(1, {
userId: "viewer",
permissionLevel: "connect",
}),
).toEqual({
tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] },
});
expect(h.viewerCalls).toEqual([
[{ id: 1, isShared: true, permissionLevel: "connect" }, "viewer"],
]);
});
it("imports them back to local ids", async () => {
await importHostPluginSettings(9, {
vault: { profileId: "profile-sync-4", token: "ignored" },
@@ -8,6 +8,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
logins: [] as Array<Record<string, unknown>>,
replaced: null as null | { ownerId: string; hostId: number; logins: unknown },
rows: [] as Array<{ protocol: string }>,
recipient: new Map<string, Record<string, unknown> | Error>(),
}));
vi.mock(
@@ -20,6 +22,15 @@ vi.mock(
return {
...actual,
listProtocolLogins: async () => state.logins,
resolveRecipientProtocolLogin: async (
_host: unknown,
_userId: string,
declared: { id: string },
) => {
const login = state.recipient.get(declared.id);
if (login instanceof Error) throw login;
return login;
},
replaceProtocolLogins: async (
ownerId: string,
hostId: number,
@@ -30,6 +41,11 @@ vi.mock(
};
},
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostProtocolAuthRepository: () => ({
listRowsForHost: async () => state.rows,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => null,
getSqlite: () => null,
@@ -37,12 +53,15 @@ vi.mock("../../database/db/index.js", () => ({
import {
exportProtocolLogins,
exportSharedProtocolLogins,
importProtocolLogins,
} from "../../sync/host-protocol-auth.js";
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
beforeEach(() => {
state.replaced = null;
state.rows = [];
state.recipient.clear();
state.logins = [
{
protocol: "spice",
@@ -129,3 +148,52 @@ describe("host protocol logins over sync", () => {
expect(state.replaced).toBeNull();
});
});
describe("shared host protocol logins over sync", () => {
it("sends what the recipient may use as direct logins", async () => {
state.rows = [{ protocol: "spice" }, { protocol: "unknown" }];
state.recipient.set("spice", {
authType: "credential",
username: "alice",
password: "pw",
fields: { display: "2", ticket: "" },
});
expect(
await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"),
).toEqual({
spice: {
authType: "direct",
username: "alice",
password: "pw",
fields: { display: "2" },
credentialSyncId: null,
},
});
});
it("keeps a none login and skips one that fails", async () => {
setHostProtocolSource(() => [
{ id: "spice", credentialFields: [], pluginId: "p", pluginName: "P" },
{ id: "vnc", credentialFields: [], pluginId: "p", pluginName: "P" },
]);
state.rows = [{ protocol: "spice" }, { protocol: "vnc" }];
state.recipient.set("spice", {
authType: "none",
username: "",
password: "",
fields: {},
});
state.recipient.set("vnc", new Error("locked"));
expect(
await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"),
).toEqual({
spice: {
authType: "none",
username: null,
password: null,
fields: {},
credentialSyncId: null,
},
});
});
});
+1
View File
@@ -26,6 +26,7 @@ vi.mock("../../utils/database-save-trigger.js", () => ({
DatabaseSaveTrigger: {
triggerSave: vi.fn(),
forceSave: vi.fn(async () => {}),
batched: <T>(work: T) => work,
},
}));
vi.mock("../../utils/system-crypto.js", () => ({
@@ -111,4 +111,20 @@ describe("runExternalIdentityMigration", () => {
expect(second).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
it("does not recreate a legacy-oidc link that was moved to its provider", async () => {
user("oidc-user", { oidcIdentifier: "sub-1" });
h.state.identities.push({
id: 1,
userId: "oidc-user",
providerId: "1",
subject: "sub-1",
email: null,
} as never);
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
});
@@ -0,0 +1,57 @@
import Database from "better-sqlite3";
import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { repairSnippetsNoteColumn } from "../../upgrade/snippets-schema-migration.js";
let sqlite: Database.Database;
beforeEach(() => {
sqlite = new Database(":memory:");
});
afterEach(() => {
sqlite.close();
});
describe("snippet note column upgrade", () => {
it.each(["snippets", "p_snippets_snippets"])(
"repairs %s without losing rows",
(table) => {
sqlite.exec(
`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, user_id TEXT, content TEXT)`,
);
sqlite
.prepare(`INSERT INTO "${table}" VALUES (7, 'user', 'echo hello')`)
.run();
expect(() => sqlite.prepare(`SELECT "is_note" FROM "${table}"`)).toThrow(
/is_note/,
);
repairSnippetsNoteColumn(sqlite);
if (table === "snippets") {
sqlite.exec(
readFileSync(
"plugins/snippets/migrations/sqlite/0001_adopt_snippets_tables.sql",
"utf8",
),
);
}
expect(
sqlite
.prepare("SELECT id, content, is_note FROM p_snippets_snippets")
.get(),
).toEqual({ id: 7, content: "echo hello", is_note: 0 });
sqlite.exec("UPDATE p_snippets_snippets SET is_note = 1");
repairSnippetsNoteColumn(sqlite);
expect(
sqlite.prepare("SELECT is_note FROM p_snippets_snippets").get(),
).toEqual({ is_note: 1 });
},
);
it("does not create tables on a fresh install", () => {
repairSnippetsNoteColumn(sqlite);
expect(
sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all(),
).toEqual([]);
});
});
@@ -0,0 +1,200 @@
/**
* 2.8 OIDC accounts with no provider id were filed under "legacy-oidc", but
* the sso plugin signs in through the migrated provider row (#1381). They move
* to that row when it is the only one from before 2.9 and the env provider is
* not in use; anything ambiguous is left alone.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
db: null as unknown,
warn: null as unknown as ReturnType<typeof vi.fn>,
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
h.warn = log.warn;
return { databaseLogger: log };
});
const { runSsoLegacyIdentityMigration } =
await import("../../upgrade/sso-legacy-identity-migration.js");
const ENV = [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
"OIDC_ENV_OVERRIDE",
];
let sqlite: Database.Database;
function setup(providers: Array<[number, string, number, number]>) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, oidc_identifier TEXT);
CREATE TABLE p_sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT 'x',
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
legacy_callback INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE user_external_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT
);
`);
const insert = sqlite.prepare(
"INSERT INTO p_sso_providers (id, type, enabled, legacy_callback) VALUES (?, ?, ?, ?)",
);
for (const row of providers) insert.run(...row);
h.db = drizzle(sqlite);
}
function addUser(id: string, identifier: string, providerId: string) {
sqlite
.prepare("INSERT INTO users (id, oidc_identifier) VALUES (?, ?)")
.run(id, identifier);
const subject = identifier.replace(/^github:[^:]+:/, "");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run(id, providerId, subject);
}
function links() {
return sqlite
.prepare(
"SELECT user_id, provider_id, subject FROM user_external_identities ORDER BY id",
)
.all();
}
beforeEach(() => {
for (const name of ENV) delete process.env[name];
});
afterEach(() => {
for (const name of ENV) delete process.env[name];
});
describe("runSsoLegacyIdentityMigration", () => {
it("moves legacy OIDC sign-ins to the one migrated provider", async () => {
setup([
[1, "oidc", 1, 1],
[2, "github", 1, 1],
]);
addUser("alice", "sub-a", "legacy-oidc");
addUser("gh", "github:null:42", "legacy-oidc");
const result = await runSsoLegacyIdentityMigration();
expect(result).toEqual({ moved: 1, conflicts: 0 });
expect(links()).toEqual([
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
{ user_id: "gh", provider_id: "legacy-oidc", subject: "42" },
]);
});
it("ignores OIDC providers added after the upgrade", async () => {
setup([
[1, "oidc", 1, 1],
[5, "oidc", 1, 0],
]);
addUser("alice", "sub-a", "legacy-oidc");
await runSsoLegacyIdentityMigration();
expect(links()).toEqual([
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
]);
});
it("leaves them when two providers are from before 2.9", async () => {
setup([
[1, "oidc", 1, 1],
[2, "oidc", 1, 1],
]);
addUser("alice", "sub-a", "legacy-oidc");
expect(await runSsoLegacyIdentityMigration()).toEqual({
moved: 0,
conflicts: 0,
});
expect(links()[0]).toMatchObject({ provider_id: "legacy-oidc" });
});
it("leaves them when the env provider signs in", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
for (const name of ENV.slice(0, 5)) process.env[name] = "x";
process.env.OIDC_ENV_OVERRIDE = "true";
await runSsoLegacyIdentityMigration();
expect(links()[0]).toMatchObject({ provider_id: "legacy-oidc" });
});
it("still moves them when env settings exist but the row is what signs in", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
for (const name of ENV.slice(0, 5)) process.env[name] = "x";
await runSsoLegacyIdentityMigration();
expect(links()[0]).toMatchObject({ provider_id: "1" });
});
it("skips a subject a 2.9.0 duplicate account already holds", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES ('dup', '1', 'sub-a')",
)
.run();
const result = await runSsoLegacyIdentityMigration();
expect(result).toEqual({ moved: 0, conflicts: 1 });
expect(h.warn).toHaveBeenCalled();
expect(links()).toHaveLength(2);
});
it("drops the legacy link once the same account holds the real one", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES ('alice', '1', 'sub-a')",
)
.run();
await runSsoLegacyIdentityMigration();
expect(links()).toEqual([
{ user_id: "alice", provider_id: "1", subject: "sub-a" },
]);
});
it("is idempotent", async () => {
setup([[1, "oidc", 1, 1]]);
addUser("alice", "sub-a", "legacy-oidc");
await runSsoLegacyIdentityMigration();
expect(await runSsoLegacyIdentityMigration()).toEqual({
moved: 0,
conflicts: 0,
});
});
});
@@ -7,6 +7,25 @@ describe("DatabaseSaveTrigger", () => {
DatabaseSaveTrigger.cleanup();
});
it("batches informational touches without scheduling per-session saves", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
for (let minute = 0; minute < 4; minute++) {
DatabaseSaveTrigger.markDirty();
await vi.advanceTimersByTimeAsync(60_000);
}
expect(save).not.toHaveBeenCalled();
expect(DatabaseSaveTrigger.isDirty).toBe(true);
expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(false);
const flushed = DatabaseSaveTrigger.forceSave("periodic_flush");
// A save yields one event-loop turn, which fake timers also control.
await vi.runOnlyPendingTimersAsync();
await flushed;
expect(save).toHaveBeenCalledOnce();
expect(DatabaseSaveTrigger.isDirty).toBe(false);
});
it("force saves through the initialized save function", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
@@ -21,6 +40,18 @@ describe("DatabaseSaveTrigger", () => {
});
});
it("lets the event loop turn before a force save resolves", async () => {
DatabaseSaveTrigger.initialize(vi.fn().mockResolvedValue(undefined));
let turned = false;
setImmediate(() => {
turned = true;
});
await DatabaseSaveTrigger.forceSave("bulk_write");
expect(turned).toBe(true);
});
it("debounces dirty saves and marks the database clean after saving", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
@@ -33,12 +64,24 @@ describe("DatabaseSaveTrigger", () => {
expect(DatabaseSaveTrigger.getStatus().hasPendingTimeout).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
await vi.runOnlyPendingTimersAsync();
expect(save).toHaveBeenCalledTimes(1);
expect(DatabaseSaveTrigger.isDirty).toBe(false);
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
});
it("saves within the max wait even while writes keep coming", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
for (let second = 0; second < 40; second++) {
void DatabaseSaveTrigger.triggerSave("sample");
await vi.advanceTimersByTimeAsync(1000);
}
expect(save).toHaveBeenCalledTimes(1);
});
it("queues a force save behind an in-flight save", async () => {
let finishFirstSave: (() => void) | undefined;
const firstSave = new Promise<void>((resolve) => {
@@ -62,4 +105,77 @@ describe("DatabaseSaveTrigger", () => {
expect(save).toHaveBeenCalledTimes(2);
expect(DatabaseSaveTrigger.getStatus().pendingSave).toBe(false);
});
it("collapses force saves inside a batch into one save", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
const result = await DatabaseSaveTrigger.batched(async (rows: number) => {
for (let i = 0; i < rows; i++) {
await DatabaseSaveTrigger.forceSave("row_write");
}
expect(save).not.toHaveBeenCalled();
return rows;
})(50);
expect(result).toBe(50);
expect(save).toHaveBeenCalledTimes(1);
expect(DatabaseSaveTrigger.isDirty).toBe(false);
});
it("folds a nested batch into the outer one", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
const inner = DatabaseSaveTrigger.batched(() =>
DatabaseSaveTrigger.forceSave("inner_write"),
);
await DatabaseSaveTrigger.batched(async () => {
await inner();
await inner();
})();
expect(save).toHaveBeenCalledTimes(1);
});
it("skips the save when a batch wrote nothing", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
await DatabaseSaveTrigger.batched(async () => {})();
expect(save).not.toHaveBeenCalled();
});
it("still saves what a failed batch wrote", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
await expect(
DatabaseSaveTrigger.batched(async () => {
await DatabaseSaveTrigger.forceSave("row_write");
throw new Error("boom");
})(),
).rejects.toThrow("boom");
expect(save).toHaveBeenCalledTimes(1);
});
it("saves immediately for work that outlives its batch", async () => {
const save = vi.fn().mockResolvedValue(undefined);
DatabaseSaveTrigger.initialize(save);
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
let detached: Promise<void> | undefined;
await DatabaseSaveTrigger.batched(async () => {
detached = gate.then(() => DatabaseSaveTrigger.forceSave("late_write"));
})();
release?.();
await detached;
expect(save).toHaveBeenCalledTimes(1);
});
});
+17 -12
View File
@@ -8,6 +8,7 @@
*/
import { databaseLogger } from "../utils/logger.js";
import { DatabaseSaveTrigger } from "../utils/database-save-trigger.js";
import { getErrorMessage } from "../utils/error-message.js";
import { runTailscaleSettingsMigration } from "./tailscale-settings-migration.js";
import { runProxmoxSettingsMigration } from "./proxmox-settings-migration.js";
@@ -36,6 +37,7 @@ import { runTotpMigration } from "./totp-migration.js";
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
import { runSsoSettingsMigration } from "./sso-settings-migration.js";
import { runSsoLegacyIdentityMigration } from "./sso-legacy-identity-migration.js";
import { runHostDefaultsMigration } from "./host-defaults-migration.js";
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
@@ -68,24 +70,27 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runNotificationChannelMigration", runNotificationChannelMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
["runSsoSettingsMigration", runSsoSettingsMigration],
["runSsoLegacyIdentityMigration", runSsoLegacyIdentityMigration],
["runTelemetrySettingsMigration", runTelemetrySettingsMigration],
// Last: it reads what the moves above wrote.
["runHostDefaultsMigration", runHostDefaultsMigration],
];
export async function runPluginDataMigrations(): Promise<void> {
for (const [name, run] of MIGRATIONS) {
try {
await run();
} catch (error) {
databaseLogger.warn(`Plugin data migration ${name} failed`, {
operation: "plugin_data_migration",
error: getErrorMessage(error),
});
export const runPluginDataMigrations = DatabaseSaveTrigger.batched(
async (): Promise<void> => {
for (const [name, run] of MIGRATIONS) {
try {
await run();
} catch (error) {
databaseLogger.warn(`Plugin data migration ${name} failed`, {
operation: "plugin_data_migration",
error: getErrorMessage(error),
});
}
}
}
await applyHostDefaults();
}
await applyHostDefaults();
},
);
/**
* Classifies hosts nobody classified yet (from an older release, or for a
@@ -79,7 +79,15 @@ export async function runExternalIdentityMigration(): Promise<ExternalIdentityMi
parsed.providerId,
parsed.subject,
);
if (existing) {
// sso-legacy-identity-migration.ts moves "legacy-oidc" rows to
// their provider; recreating one would undo that on every boot.
const moved =
!existing &&
parsed.providerId === LEGACY_OIDC_PROVIDER_ID &&
(await auth.listIdentitiesForUser(user.id)).some(
(link) => link.subject === parsed.subject,
);
if (existing || moved) {
result.skipped++;
} else {
await auth.linkIdentity({
@@ -0,0 +1,19 @@
import type Database from "better-sqlite3";
/** Repair older SQLite tables before adoption, or after an incomplete 2.9 upgrade. */
export function repairSnippetsNoteColumn(sqlite: Database.Database): void {
for (const table of ["snippets", "p_snippets_snippets"]) {
const columns = sqlite.pragma(`table_info("${table}")`) as Array<{
name: string;
}>;
if (
columns.length === 0 ||
columns.some(({ name }) => name === "is_note")
) {
continue;
}
sqlite.exec(
`ALTER TABLE "${table}" ADD COLUMN "is_note" INTEGER NOT NULL DEFAULT 0`,
);
}
}
@@ -0,0 +1,160 @@
/**
* Points 2.8 OIDC sign-ins at the provider row they came from.
*
* external-identity-migration.ts files a bare OIDC subject with no
* users.sso_provider_id under "legacy-oidc", the id the sso plugin gives its
* env-configured provider. Those accounts came from the single 2.8 OIDC
* config, which 2.8 copied into an sso_providers row, and the plugin signs in
* through that row with its id. Without this they could not sign in at all
* (#1381).
*
* Only runs when the answer is clear: the env provider is not what signs in,
* and exactly one OIDC row is from before 2.9. An identity whose target is
* already taken (a duplicate account made by 2.9.0) is left for an admin.
* Idempotent.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../utils/logger.js";
import {
runStatement,
selectRows,
} from "../utils/crypto-migration/raw-rows.js";
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
const SOURCES = ["p_sso_providers", "sso_providers"];
export interface SsoLegacyIdentityMigrationResult {
moved: number;
conflicts: number;
}
async function tableExists(name: string): Promise<boolean> {
try {
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
return true;
} catch {
return false;
}
}
function isOn(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "t";
}
function envProviderConfigured(): boolean {
return [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_ISSUER_URL",
"OIDC_AUTHORIZATION_URL",
"OIDC_TOKEN_URL",
].every((name) => !!process.env[name]);
}
interface ProviderRow {
id: number;
type: string;
enabled: unknown;
legacy_callback?: unknown;
}
async function readProviders(table: string): Promise<ProviderRow[]> {
try {
return await selectRows<ProviderRow>(
sql`SELECT id, type, enabled, legacy_callback FROM ${sql.identifier(table)}`,
);
} catch {
return await selectRows<ProviderRow>(
sql`SELECT id, type, enabled FROM ${sql.identifier(table)}`,
);
}
}
/** The row "legacy-oidc" sign-ins belong to, or null when that is unclear. */
async function findTargetProvider(): Promise<number | null> {
for (const table of SOURCES) {
if (!(await tableExists(table))) continue;
const rows = await readProviders(table);
// The env provider signs in as "legacy-oidc" when it overrides the rows
// or no row is enabled, so those identities are already right.
const enabled = rows.filter((row) => isOn(row.enabled));
if (
envProviderConfigured() &&
(process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true" ||
enabled.length === 0)
) {
return null;
}
const oidc = rows.filter((row) => row.type === "oidc");
const legacy = oidc.filter(
(row) => row.legacy_callback === undefined || isOn(row.legacy_callback),
);
return legacy.length === 1 ? Number(legacy[0].id) : null;
}
return null;
}
export async function runSsoLegacyIdentityMigration(): Promise<SsoLegacyIdentityMigrationResult> {
const result: SsoLegacyIdentityMigrationResult = { moved: 0, conflicts: 0 };
try {
const target = await findTargetProvider();
if (target === null) return result;
const providerId = String(target);
// "github:null:<id>" also landed on "legacy-oidc", but its identifier
// kept the prefix, so matching the bare column picks only OIDC ones.
const identities = await selectRows<{
id: number;
user_id: string;
subject: string;
}>(
sql`SELECT i.id, i.user_id, i.subject FROM user_external_identities i JOIN users u ON u.id = i.user_id WHERE i.provider_id = ${LEGACY_OIDC_PROVIDER_ID} AND u.oidc_identifier = i.subject`,
);
for (const identity of identities) {
const [taken] = await selectRows<{ user_id: string }>(
sql`SELECT user_id FROM user_external_identities WHERE provider_id = ${providerId} AND subject = ${identity.subject}`,
);
if (taken) {
if (taken.user_id !== identity.user_id) {
result.conflicts++;
databaseLogger.warn(
"An SSO subject is linked to two accounts; merge or delete the duplicate",
{
operation: "sso_legacy_identity_conflict",
userId: identity.user_id,
duplicateUserId: taken.user_id,
providerId,
},
);
continue;
}
await runStatement(
sql`DELETE FROM user_external_identities WHERE id = ${identity.id}`,
);
result.moved++;
continue;
}
await runStatement(
sql`UPDATE user_external_identities SET provider_id = ${providerId} WHERE id = ${identity.id}`,
);
result.moved++;
}
if (result.moved > 0) {
databaseLogger.info("Moved 2.8 OIDC sign-ins to their provider", {
operation: "sso_legacy_identity_migration",
providerId,
...result,
});
}
} catch (error) {
databaseLogger.error("SSO legacy identity migration failed", error, {
operation: "sso_legacy_identity_migration_failed",
});
}
return result;
}
+3 -1
View File
@@ -20,7 +20,9 @@ export async function getAuditUsername(userId: string): Promise<string> {
}
export interface AuditLogParams {
userId: string;
// Null when no user acted (a plugin starting up, background work): the
// column references users.id, so anything else would be refused.
userId: string | null;
username: string;
action: string;
resourceType: string;
+63 -2
View File
@@ -1,13 +1,22 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { getErrorMessage } from "./error-message.js";
import { databaseLogger } from "./logger.js";
const DEBOUNCE_MS = 2000;
const MAX_DEBOUNCE_WAIT_MS = 30_000;
export class DatabaseSaveTrigger {
private static saveFunction: (() => Promise<void>) | null = null;
private static isInitialized = false;
private static pendingSave = false;
private static activeSave: Promise<void> | null = null;
private static saveTimeout: NodeJS.Timeout | null = null;
private static firstTriggerAt: number | null = null;
private static _dirty = false;
private static batch = new AsyncLocalStorage<{
open: boolean;
pending: boolean;
}>();
static initialize(saveFunction: () => Promise<void>): void {
this.saveFunction = saveFunction;
@@ -18,6 +27,11 @@ export class DatabaseSaveTrigger {
return this._dirty;
}
/** Informational changes join the next periodic or critical save. */
static markDirty(): void {
if (this.isInitialized) this._dirty = true;
}
static markClean(): void {
this._dirty = false;
}
@@ -39,8 +53,18 @@ export class DatabaseSaveTrigger {
clearTimeout(this.saveTimeout);
}
// Each call pushes the save back, so steady writes (samples every few
// seconds) would keep it from ever running. Cap how long it can wait.
const now = Date.now();
this.firstTriggerAt ??= now;
const delay = Math.max(
0,
Math.min(DEBOUNCE_MS, this.firstTriggerAt + MAX_DEBOUNCE_WAIT_MS - now),
);
this.saveTimeout = setTimeout(async () => {
this.saveTimeout = null;
this.firstTriggerAt = null;
try {
await this.runSave();
@@ -52,7 +76,28 @@ export class DatabaseSaveTrigger {
error: getErrorMessage(error),
});
}
}, 2000);
}, delay);
}
/**
* Wraps `work` so the force saves made while it runs collapse into one save
* when it finishes. Every save serializes and encrypts the whole database,
* so a loop writing one row per host per setting otherwise rewrites the
* file once per row. Nested scopes fold into the outer one; work that
* outlives its scope (fire-and-forget) saves normally again.
*/
static batched<A extends unknown[], T>(
work: (...args: A) => Promise<T>,
): (...args: A) => Promise<T> {
return async (...args) => {
const scope = { open: true, pending: false };
try {
return await this.batch.run(scope, () => work(...args));
} finally {
scope.open = false;
if (scope.pending) await this.forceSave("batched_writes");
}
};
}
static async forceSave(reason: string = "critical_operation"): Promise<void> {
@@ -67,10 +112,18 @@ export class DatabaseSaveTrigger {
return;
}
const scope = this.batch.getStore();
if (scope?.open) {
scope.pending = true;
this._dirty = true;
return;
}
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.firstTriggerAt = null;
try {
await this.runSave();
@@ -94,7 +147,13 @@ export class DatabaseSaveTrigger {
}
}
const save = Promise.resolve().then(() => this.saveFunction!());
// Each save serializes the whole database into fresh buffers, and V8 only
// frees them once the event loop turns. A loop of awaited writes (boot
// migrations, bulk import) never yields, so every save's copies piled up
// until the process ran out of memory.
const save = Promise.resolve()
.then(() => this.saveFunction!())
.finally(() => new Promise<void>((resolve) => setImmediate(resolve)));
this.activeSave = save;
this.pendingSave = true;
@@ -125,10 +184,12 @@ export class DatabaseSaveTrigger {
clearTimeout(this.saveTimeout);
this.saveTimeout = null;
}
this.firstTriggerAt = null;
this.pendingSave = false;
this.activeSave = null;
this.isInitialized = false;
this._dirty = false;
this.saveFunction = null;
}
}
+21
View File
@@ -65,6 +65,21 @@ const swaggerOptions: SwaggerJSDocOptions = {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "Session JWT sent as Authorization: Bearer <JWT>.",
},
cookieAuth: {
type: "apiKey",
in: "cookie",
name: "jwt",
description:
"Session JWT cookie set at sign-in. When present, it takes precedence over the Authorization header.",
},
apiKeyAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "tmx_<api key>",
description:
"API key sent as Authorization: Bearer tmx_<api key>. Permissions follow the key owner's account; encrypted data must be unlocked separately. API keys cannot impersonate another user.",
},
},
schemas: {
@@ -81,6 +96,12 @@ const swaggerOptions: SwaggerJSDocOptions = {
{
bearerAuth: [],
},
{
cookieAuth: [],
},
{
apiKeyAuth: [],
},
],
tags: [
{