mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
chore: merge dev-2.9.1 into dev-26.10.0
This commit is contained in:
commit
77bcbdb3d6
1310 files changed
+102834
-51010
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user