chore: merge dev-2.9.1 into dev-26.10.0

This commit is contained in:
LukeGus committed 2026-10-03 16:01:42 -05:00
commit 77bcbdb3d6
1310 files changed
+102834 -51010

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,
});
},
}),
);
}
@@ -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) };
},
);
+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);
+24 -18
View File
@@ -7,8 +7,10 @@
* 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.
* 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.
@@ -28,7 +30,8 @@ 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;
@@ -78,6 +81,7 @@ export interface HostStatusDeps {
target: StatusTarget,
port: number,
) => Promise<boolean>;
hasActiveSession?: (hostId: number) => boolean;
globalInterval: () => number;
emit: (payload: HostStatusPayload) => void;
}
@@ -110,6 +114,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 +135,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
@@ -429,11 +431,15 @@ export class HostStatusService {
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);
if (this.deps.hasActiveSession?.(target.id)) {
reachable = true;
} else {
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;
}
@@ -0,0 +1,87 @@
import type { Client } from "ssh2";
import { tcpPingThroughJumpHost } 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);
return tcpPingThroughJumpHost(client, host, port, timeoutMs, true);
}
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);
}
}
+4 -3
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,
@@ -70,6 +70,7 @@ export function tcpPingThroughJumpHost(
host: string,
port: number,
timeoutMs = 5000,
keepOpen = false,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
@@ -78,7 +79,7 @@ export function tcpPingThroughJumpHost(
if (settled) return;
settled = true;
clearTimeout(timeout);
jumpClient.end();
if (!keepOpen) jumpClient.end();
resolve(result);
};
+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,
};
}
+63 -9
View File
@@ -96,6 +96,34 @@ function describeHost(host: number | PluginSshHost): string {
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
}
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;
}
const LOGIN_FAILED =
/all configured authentication methods failed|permission denied|authentication failed/i;
@@ -134,8 +162,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 +205,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,8 +218,8 @@ 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 };
};
@@ -173,8 +228,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
* 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;
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) {
+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;
@@ -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({
@@ -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]);
},
);
@@ -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"));
},
);
});
@@ -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,6 +52,7 @@ function setup(
loadSharedHostIds: async (userId) => shared[userId] ?? [],
ping,
pingThroughJumpHosts,
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
globalInterval: () => 60,
emit: (payload) => emitted.push(payload),
});
@@ -74,6 +75,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;
@@ -0,0 +1,104 @@
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("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);
});
});
@@ -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) =>
@@ -253,6 +265,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(
+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;
}
+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: [
{
+9
View File
@@ -0,0 +1,9 @@
import { hostApi } from "@/main-axios";
export async function getHostTags(): Promise<string[]> {
return (await hostApi.get<{ tags: string[] }>("/tags")).data.tags;
}
export async function saveHostTags(tags: string[]): Promise<string[]> {
return (await hostApi.put<{ tags: string[] }>("/tags", { tags })).data.tags;
}
+30 -4
View File
@@ -296,6 +296,15 @@ export function Auth({ onLogin }: AuthProps) {
const [pendingToken, setPendingToken] = useState("");
const [secondFactors, setSecondFactors] = useState<SecondFactorRef[]>([]);
const [activeFactorId, setActiveFactorId] = useState("");
const [authPluginsLoading, setAuthPluginsLoading] = useState(true);
const retryAuthPlugins = async () => {
setAuthPluginsLoading(true);
try {
await startPreLoginPlugins({ retryFailed: true });
} finally {
setAuthPluginsLoading(false);
}
};
const loginMethodUIs = useLoginMethods();
const secondFactorUIs = useSecondFactors();
// A server that did not list its factors gets every factor with a UI here.
@@ -434,7 +443,9 @@ export function Auth({ onLogin }: AuthProps) {
.then((allowed) => setPasswordResetAllowed(allowed))
.catch(() => setPasswordResetAllowed(false));
// Plugins that draw login or second-factor UI load before sign-in.
void startPreLoginPlugins().catch(() => {});
void startPreLoginPlugins()
.catch(() => {})
.finally(() => setAuthPluginsLoading(false));
getLoginMethods()
.then((methods) => setAuthMethods(methods))
.catch(() => setAuthMethods([]))
@@ -1364,9 +1375,24 @@ export function Auth({ onLogin }: AuthProps) {
);
if (!ui) {
return (
<p className="text-xs text-destructive">
{t("auth.secondFactorNoUI")}
</p>
<div className="space-y-2" aria-live="polite">
<p className="text-xs text-muted-foreground">
{t(
authPluginsLoading
? "common.loading"
: "auth.secondFactorNoUI",
)}
</p>
<Button
type="button"
disabled={authPluginsLoading}
onClick={() =>
void retryAuthPlugins().catch(() => {})
}
>
{t("auth.retrySecondFactorUI")}
</Button>
</div>
);
}
const Component = ui.component;
+7 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
@@ -53,6 +54,8 @@ function DialogContent({
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const { t } = useTranslation();
return (
<DialogPortal>
<DialogOverlay />
@@ -73,7 +76,7 @@ function DialogContent({
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">{t("common.close")}</span>
</Button>
</DialogPrimitive.Close>
)}
@@ -100,6 +103,8 @@ function DialogFooter({
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
const { t } = useTranslation();
return (
<div
data-slot="dialog-footer"
@@ -112,7 +117,7 @@ function DialogFooter({
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
<Button variant="outline">{t("common.close")}</Button>
</DialogPrimitive.Close>
)}
</div>
+1 -1
View File
@@ -188,7 +188,7 @@ function Select2({
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search..."
placeholder={t("common.search")}
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
autoFocus
/>
+4 -1
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import * as React from "react";
import { Dialog as SheetPrimitive } from "radix-ui";
@@ -53,6 +54,8 @@ function SheetContent({
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
const { t } = useTranslation();
return (
<SheetPortal>
<SheetOverlay />
@@ -74,7 +77,7 @@ function SheetContent({
size="icon-sm"
>
<XIcon />
<span className="sr-only">Close</span>
<span className="sr-only">{t("common.close")}</span>
</Button>
</SheetPrimitive.Close>
)}
+11 -5
View File
@@ -1,6 +1,7 @@
/* eslint-disable react-refresh/only-export-components */
"use client";
import { useTranslation } from "react-i18next";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
@@ -159,6 +160,7 @@ function Sidebar({
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { t } = useTranslation();
if (collapsible === "none") {
return (
@@ -192,8 +194,10 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>{t("common.sidebar")}</SheetTitle>
<SheetDescription>
{t("common.mobileSidebarDescription")}
</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -253,6 +257,7 @@ function SidebarTrigger({
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
const { t } = useTranslation();
return (
<Button
@@ -268,22 +273,23 @@ function SidebarTrigger({
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<span className="sr-only">{t("common.toggleSidebar")}</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
const { t } = useTranslation();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label={t("common.toggleSidebar")}
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title={t("common.toggleSidebar")}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
+22 -5
View File
@@ -116,6 +116,8 @@
"updateLinkLabel": "Update available. Open the release notes on GitHub"
},
"common": {
"scrollTabsLeft": "Scroll tabs left",
"scrollTabsRight": "Scroll tabs right",
"close": "Close",
"minimize": "Minimize",
"online": "Online",
@@ -187,7 +189,11 @@
"remove": "Remove",
"add": "Add",
"update": "Update",
"noResults": "No results found"
"noResults": "No results found",
"sidebar": "Sidebar",
"mobileSidebarDescription": "Displays the mobile sidebar.",
"toggleSidebar": "Toggle Sidebar",
"search": "Search..."
},
"onboarding": {
"stepCounter": "Step {{current}} of {{total}}",
@@ -477,7 +483,7 @@
"sameHost": "This host (direct tunnel)",
"statusChecksLabel": "Status Checks",
"enableStatusChecks": "Enable Status Checks",
"enableStatusChecksDesc": "Periodically ping this host to verify availability",
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
"useGlobalInterval": "Use Global Interval",
"useGlobalIntervalDesc": "Override with the server-wide status check interval",
"checkIntervalS": "Check Interval (s)",
@@ -1058,7 +1064,8 @@
"secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.",
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
"serverAuthentication": "Server sign-in",
"minChars": "min. {{min}} characters"
"minChars": "min. {{min}} characters",
"retrySecondFactorUI": "Reload verification methods"
},
"hostKey": {
"verifyNewHost": "Verify SSH Host Key",
@@ -1119,7 +1126,13 @@
"language": "Language",
"keyPassword": "key password",
"pastePrivateKey": "Paste your private key here...",
"enterPassword": "Enter your password"
"enterPassword": "Enter your password",
"apiKeyName": "e.g., CI Pipeline",
"credentialName": "e.g. Production SSH Key",
"optionalDetails": "Optional details...",
"sshUsername": "e.g. root or deploy",
"hostAddress": "10.0.0.1 or example.com",
"hostName": "e.g. Web Server Production"
},
"dashboard": {
"title": "Dashboard",
@@ -1501,7 +1514,11 @@
"sslIssuer": "Issued by {{issuer}}",
"sslNames": "Covers {{names}}",
"sslManualTitle": "Upload a certificate",
"updateExternalAutoProvisionFailed": "Failed to update the auto-create setting"
"updateExternalAutoProvisionFailed": "Failed to update the auto-create setting",
"hostTags": "Predefined host tags",
"hostTagsDesc": "One tag per line, available to all users. Editing this list changes suggestions only; tags already assigned to hosts are kept.",
"hostTagsSaved": "Host tag suggestions saved.",
"hostTagsError": "Could not load or save host tag suggestions."
},
"newUi": {
"sidebar": {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -449,7 +449,9 @@ async function fetchPreLoginPlugins(): Promise<PluginSummary[]> {
* factors) before anyone has signed in. After sign-in the full runtime
* reconciles, keeping these active when their bundle has not changed.
*/
export async function startPreLoginPlugins(): Promise<void> {
export async function startPreLoginPlugins(
options: { retryFailed?: boolean } = {},
): Promise<void> {
installPluginHostBridge();
let list: PluginSummary[];
try {
@@ -468,6 +470,9 @@ export async function startPreLoginPlugins(): Promise<void> {
// subset would unload everything else. Auth remounts during the
// post-login fade, so this runs after sign-in every time.
if (started) return;
if (options.retryFailed) {
for (const plugin of list) failedVersions.delete(plugin.id);
}
await reconcile(list);
markPluginsSettled();
});
+9 -2
View File
@@ -77,9 +77,12 @@ export function FeatureSettingsSection({
open: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
return (
<AccordionSection
label={plugin.name}
label={t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
})}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open}
onToggle={onToggle}
@@ -206,7 +209,11 @@ function FeatureSettingsForm({
<div className="flex flex-col gap-2 pt-2">
{!running && (
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
{t("settings.featureUnavailable", { name: plugin.name })}
{t("settings.featureUnavailable", {
name: t(pluginKey(plugin.id, "plugin.name"), {
defaultValue: plugin.name,
}),
})}
</div>
)}
+3 -1
View File
@@ -646,7 +646,9 @@ export function CommandPalette({
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
>
<LifeBuoy className="size-4 text-muted-foreground" />
<span className="text-sm font-medium">Support</span>
<span className="text-sm font-medium">
{t("dashboard.support")}
</span>
</CommandItem>
</div>
</CommandGroup>
+1 -1
View File
@@ -150,7 +150,7 @@ export function AdminApiKeysSection({
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="e.g., CI Pipeline"
placeholder={t("placeholders.apiKeyName")}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="text-xs"
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { getHostTags, saveHostTags } from "@/api/host-tags-api";
export function AdminHostTags() {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
let cancelled = false;
getHostTags()
.then((tags) => {
if (cancelled) return;
setValue(tags.join("\n"));
setLoaded(true);
})
.catch(() => {
if (!cancelled) setMessage(t("admin.hostTagsError"));
});
return () => {
cancelled = true;
};
}, [t]);
async function save() {
setSaving(true);
setMessage("");
try {
const tags = await saveHostTags(
value
.split("\n")
.map((tag) => tag.trim())
.filter(Boolean),
);
setValue(tags.join("\n"));
setMessage(t("admin.hostTagsSaved"));
window.dispatchEvent(new Event("termix:host-tags-changed"));
} catch {
setMessage(t("admin.hostTagsError"));
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-2 border-t border-border py-3">
<label htmlFor="global-host-tags" className="text-xs font-medium">
{t("admin.hostTags")}
</label>
<p className="text-[11px] text-muted-foreground">
{t("admin.hostTagsDesc")}
</p>
<textarea
id="global-host-tags"
rows={5}
disabled={!loaded || saving}
value={value}
onChange={(event) => setValue(event.target.value)}
className="w-full border border-border bg-background p-2 text-xs"
/>
<Button
type="button"
size="sm"
disabled={!loaded || saving}
onClick={() => void save()}
>
{t("common.save")}
</Button>
{message && (
<p role="status" className="text-xs">
{message}
</p>
)}
</div>
);
}
+2
View File
@@ -5,6 +5,7 @@ import { Input } from "@/components/input";
import { SettingRow } from "@/components/section-card";
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { AdminHostTags } from "./AdminHostTags";
import type { TlsStatus } from "@/api/tls-api";
type GeneralSettingsSectionProps = {
@@ -68,6 +69,7 @@ export function AdminGeneralSettingsSection({
onToggle={onToggle}
>
<div className="flex flex-col gap-0 pt-2">
<AdminHostTags />
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.notificationPrivateEndpoints")}
+3 -3
View File
@@ -183,7 +183,7 @@ export function CredentialEditorView({
{t("hosts.friendlyNameLabel")}
</label>
<Input
placeholder="e.g. Production SSH Key"
placeholder={t("placeholders.credentialName")}
value={credForm.name}
onChange={(e) => setCredField("name", e.target.value)}
/>
@@ -203,7 +203,7 @@ export function CredentialEditorView({
{t("hosts.descriptionLabel")}
</label>
<Input
placeholder="Optional details..."
placeholder={t("placeholders.optionalDetails")}
value={credForm.description}
onChange={(e) => setCredField("description", e.target.value)}
/>
@@ -278,7 +278,7 @@ export function CredentialEditorView({
{t("hosts.username")}
</label>
<Input
placeholder="e.g. root or deploy"
placeholder={t("placeholders.sshUsername")}
value={credForm.username}
onChange={(e) => setCredField("username", e.target.value)}
/>
+1 -1
View File
@@ -1444,7 +1444,7 @@ export function HostEditor({
{t("hosts.friendlyNameLabel")}
</label>
<Input
placeholder="e.g. Production SSH Key"
placeholder={t("placeholders.credentialName")}
value={quickCredentialName}
onChange={(e) => setQuickCredentialName(e.target.value)}
/>
+57 -7
View File
@@ -1,4 +1,5 @@
import React from "react";
import { getHostTags } from "@/api/host-tags-api";
import type { ProxyNode } from "@/types/index";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
@@ -47,6 +48,39 @@ export function HostEditorGeneralTab({
const { t } = useTranslation();
const syncLinked = !!useSyncStatus()?.linked;
const pluginProtocols = useHostProtocols();
const [predefinedTags, setPredefinedTags] = React.useState<string[]>([]);
React.useEffect(() => {
let cancelled = false;
const load = () => {
void getHostTags()
.then((tags) => {
if (!cancelled) setPredefinedTags(tags);
})
.catch(() => {});
};
load();
window.addEventListener("termix:host-tags-changed", load);
return () => {
cancelled = true;
window.removeEventListener("termix:host-tags-changed", load);
};
}, []);
const suggestedTags = [
...new Set([
...predefinedTags,
...hosts.flatMap((item) => item.tags ?? []),
]),
].filter(
(tag) =>
!form.tags.includes(tag) &&
tag
.toLocaleLowerCase()
.includes(form.tagInput.trim().toLocaleLowerCase()),
);
const addTag = (tag: string) => {
if (!form.tags.includes(tag)) setField("tags", [...form.tags, tag]);
setField("tagInput", "");
};
// Tracks which picker is shown, independent of whether a value is set yet
// -- switching to "parent host" mode with nothing picked shouldn't bounce
@@ -174,7 +208,7 @@ export function HostEditorGeneralTab({
{t("hosts.addressIp")}
</label>
<Input
placeholder="10.0.0.1 or example.com"
placeholder={t("placeholders.hostAddress")}
value={form.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
@@ -186,7 +220,7 @@ export function HostEditorGeneralTab({
{t("hosts.friendlyName")}
</label>
<Input
placeholder="e.g. Web Server Production"
placeholder={t("placeholders.hostName")}
value={form.name}
onChange={(e) => setField("name", e.target.value)}
/>
@@ -283,14 +317,12 @@ export function HostEditorGeneralTab({
onChange={(e) => setField("tagInput", e.target.value)}
onKeyDown={(e) => {
if (
(e.key === " " || e.key === "Enter") &&
e.key === "Enter" &&
!e.nativeEvent.isComposing &&
form.tagInput.trim()
) {
e.preventDefault();
const tag = form.tagInput.trim();
if (!form.tags.includes(tag))
setField("tags", [...form.tags, tag]);
setField("tagInput", "");
addTag(form.tagInput.trim());
} else if (
e.key === "Backspace" &&
!form.tagInput &&
@@ -302,6 +334,24 @@ export function HostEditorGeneralTab({
/>
</div>
</div>
{suggestedTags.length > 0 && (
<div
className="col-span-2 flex flex-wrap gap-1 max-h-24 overflow-y-auto"
aria-label={t("admin.hostTags")}
>
{suggestedTags.map((tag) => (
<Button
type="button"
key={tag}
variant="outline"
size="sm"
onClick={() => addTag(tag)}
>
{tag}
</Button>
))}
</div>
)}
<div className="flex flex-col gap-1.5 col-span-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.privateNotes")}
+75 -7
View File
@@ -1,6 +1,19 @@
/* eslint-disable react-refresh/only-export-components */
import { useEffect, useRef, type ComponentType, type ReactNode } from "react";
import { KeyRound, Settings, Terminal } from "lucide-react";
import {
useEffect,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
ChevronLeft,
ChevronRight,
KeyRound,
Settings,
Terminal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { byOrderThenId, createRegistry } from "@/lib/registry";
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
@@ -197,6 +210,27 @@ export function TabStrip({
variant?: "primary" | "secondary";
}) {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [scroll, setScroll] = useState({ left: false, right: false });
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () =>
setScroll({
left: el.scrollLeft > 1,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
const observer = new ResizeObserver(update);
observer.observe(el);
if (el.firstElementChild) observer.observe(el.firstElementChild);
el.addEventListener("scroll", update);
update();
return () => {
observer.disconnect();
el.removeEventListener("scroll", update);
};
}, [tabs]);
useEffect(() => {
const el = ref.current;
if (!el) return;
@@ -229,14 +263,48 @@ export function TabStrip({
);
};
const overflow = scroll.left || scroll.right;
return (
<div
ref={ref}
className={`overflow-x-auto scrollbar-none ${
variant === "secondary" ? "border-t border-border bg-card" : ""
}`}
className={`flex min-w-0 items-center ${variant === "secondary" ? "border-t border-border bg-card" : ""}`}
>
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsLeft")}
title={t("common.scrollTabsLeft")}
disabled={!scroll.left}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: -ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronLeft className="size-4" />
</button>
)}
<div ref={ref} className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
</div>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsRight")}
title={t("common.scrollTabsRight")}
disabled={!scroll.right}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronRight className="size-4" />
</button>
)}
</div>
);
}
Loaded 100 of 1310 files, more files were not shown because too many files have changed in this diff. Show more