mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
release-2.9.1 (#1557)
* fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
1 parent
5786eaccbb
commit
6b708106e1
912 files changed
+14220
-2200
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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export function registerHostStatusRoutes(
|
||||
* /host/status:
|
||||
* get:
|
||||
* summary: Get host statuses
|
||||
* description: Returns the online, reachable or offline status of every host the user can see. Starts status checks for the user's own hosts on first call.
|
||||
* description: Returns the online or offline status of every host the user can see. Starts status checks for the user's own hosts on first call and waits briefly for their first results.
|
||||
* tags:
|
||||
* - SSH
|
||||
* parameters:
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import { createCurrentSettingsRepository } from "../repositories/factory.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
|
||||
const CATALOG_KEY = "host_tag_catalog";
|
||||
|
||||
function parseCatalog(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((tag): tag is string => typeof tag === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerHostTagRoutes(
|
||||
router: Router,
|
||||
authenticate: RequestHandler,
|
||||
requireAdmin: RequestHandler,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /host/tags:
|
||||
* get:
|
||||
* summary: Get the predefined host tags
|
||||
* description: The instance-wide tag list host editors suggest. Any signed-in user can read it.
|
||||
* tags:
|
||||
* - SSH
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The predefined tags.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* tags:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* 500:
|
||||
* description: Failed to read the tags.
|
||||
*/
|
||||
router.get("/tags", authenticate, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const value = await createCurrentSettingsRepository().get(CATALOG_KEY);
|
||||
res.json({ tags: parseCatalog(value) });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to read host tags", error);
|
||||
res.status(500).json({ error: "Failed to read host tags" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/tags:
|
||||
* put:
|
||||
* summary: Replace the predefined host tags
|
||||
* description: Sets the instance-wide tag list. Tags are trimmed and duplicates dropped. Tags already on hosts are not changed. Requires admin.settings.manage.
|
||||
* tags:
|
||||
* - SSH
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* tags:
|
||||
* type: array
|
||||
* maxItems: 500
|
||||
* items:
|
||||
* type: string
|
||||
* maxLength: 100
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The saved tags.
|
||||
* 400:
|
||||
* description: Not an array of up to 500 non-empty tags of at most 100 characters.
|
||||
* 403:
|
||||
* description: Missing admin.settings.manage.
|
||||
* 500:
|
||||
* description: Failed to save the tags.
|
||||
*/
|
||||
router.put(
|
||||
"/tags",
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
async (req: Request, res: Response) => {
|
||||
const tags: unknown = req.body?.tags;
|
||||
if (
|
||||
!Array.isArray(tags) ||
|
||||
tags.length > 500 ||
|
||||
tags.some(
|
||||
(tag) =>
|
||||
typeof tag !== "string" || !tag.trim() || tag.trim().length > 100,
|
||||
)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: "Expected up to 500 non-empty tags of at most 100 characters",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalized = [
|
||||
...new Set((tags as string[]).map((tag) => tag.trim())),
|
||||
];
|
||||
try {
|
||||
await createCurrentSettingsRepository().set(
|
||||
CATALOG_KEY,
|
||||
JSON.stringify(normalized),
|
||||
);
|
||||
res.json({ tags: normalized });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to save host tags", error);
|
||||
res.status(500).json({ error: "Failed to save host tags" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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) };
|
||||
},
|
||||
);
|
||||
@@ -84,18 +84,47 @@ export class SSHHostKeyVerifier {
|
||||
hostId,
|
||||
);
|
||||
|
||||
// No row means nothing to compare against and nowhere to save the
|
||||
// key, so it is never accepted silently (#1397): the user decides
|
||||
// when there is a socket to ask on, otherwise it is refused.
|
||||
if (!host) {
|
||||
if (!ws) {
|
||||
sshLogger.error(
|
||||
"Host not found in database during key verification, rejecting",
|
||||
{
|
||||
operation: "host_key_no_host",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
verify(false);
|
||||
return;
|
||||
}
|
||||
const accepted = await this.promptUserForNewKey(
|
||||
ws,
|
||||
ip,
|
||||
port,
|
||||
undefined,
|
||||
fingerprint,
|
||||
keyType,
|
||||
algorithm,
|
||||
);
|
||||
sshLogger.warn(
|
||||
"Host not found in database during key verification",
|
||||
accepted
|
||||
? "Key for a host missing from the database accepted for this session only"
|
||||
: "Key for a host missing from the database rejected",
|
||||
{
|
||||
operation: "host_key_no_host",
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
fingerprint,
|
||||
userId,
|
||||
},
|
||||
);
|
||||
verify(true);
|
||||
verify(accepted);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* The host list's status dot.
|
||||
*
|
||||
* Every host with status checks on gets a TCP connection to its port on a
|
||||
* timer. A port that answers is "online", one that does not is "offline".
|
||||
* "reachable" is the warning in between: the port answers but the last login
|
||||
* failed (a session, a ctx.ssh connection or a plugin's
|
||||
* ctx.hosts.status.reportLogin said so). It clears on the next working login,
|
||||
* when the host is edited, or when a changed host key is accepted. Probing
|
||||
* never logs in: on RADIUS or Duo backed devices a login fires a real 2FA
|
||||
* push, every interval.
|
||||
* timer. A port that answers is "online", one that does not (twice in a row)
|
||||
* is "offline". An open session or a working login also counts as online.
|
||||
* Probing never logs in to the host itself: on RADIUS or Duo backed devices a
|
||||
* login fires a real 2FA push, every interval. A host behind jump hosts is
|
||||
* probed through a chain, which does log in to the hops, so hosts that share
|
||||
* a hop list share one chain (jump-probe-pool.ts).
|
||||
*
|
||||
* Polling is demand driven. A user asking for statuses starts polling their
|
||||
* own hosts; nothing is probed for a user who never opened the app.
|
||||
* own hosts; nothing is probed for a user who never opened the app. The first
|
||||
* request waits briefly for those first checks so it is not empty.
|
||||
*/
|
||||
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
@@ -22,23 +22,21 @@ import {
|
||||
getCurrentSettingValue,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js";
|
||||
import {
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
type HostStatus,
|
||||
} from "./host-status.js";
|
||||
import type { HostStatus } from "./host-status.js";
|
||||
import { ConcurrentLimiter } from "./limiter.js";
|
||||
import { tcpPing, tcpPingThroughJumpHost } from "./tcp-ping.js";
|
||||
import { tcpPing } from "./tcp-ping.js";
|
||||
import { JumpProbePool } from "./jump-probe-pool.js";
|
||||
|
||||
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval";
|
||||
export const DEFAULT_STATUS_INTERVAL = 60;
|
||||
export const DEFAULT_STATUS_INTERVAL = 30;
|
||||
/** How long the first request for a user waits for the first checks. */
|
||||
const FIRST_CHECK_WAIT_MS = 6_000;
|
||||
/** A status younger than this is fresh enough for check(). */
|
||||
const FRESH_MS = 30_000;
|
||||
|
||||
export interface HostStatusEntry {
|
||||
status: HostStatus;
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
export interface HostStatusPayload {
|
||||
@@ -48,7 +46,6 @@ export interface HostStatusPayload {
|
||||
previous: HostStatus | null;
|
||||
/** Anything but offline, the shape automations' trigger already reads. */
|
||||
online: boolean;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
export interface StatusTarget {
|
||||
@@ -78,8 +75,11 @@ export interface HostStatusDeps {
|
||||
target: StatusTarget,
|
||||
port: number,
|
||||
) => Promise<boolean>;
|
||||
hasActiveSession?: (hostId: number) => boolean;
|
||||
globalInterval: () => number;
|
||||
emit: (payload: HostStatusPayload) => void;
|
||||
/** Pause before a failed ping is tried again. */
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
|
||||
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
|
||||
@@ -110,6 +110,11 @@ export function toStatusTarget(row: HostStatusTargetRow): StatusTarget {
|
||||
};
|
||||
}
|
||||
|
||||
const jumpProbes = new JumpProbePool(async (jumpHosts, userId) => {
|
||||
const { createJumpHostChain } = await import("../jump-host-chain.js");
|
||||
return createJumpHostChain(jumpHosts, userId);
|
||||
});
|
||||
|
||||
const defaultDeps: HostStatusDeps = {
|
||||
loadTargets: async (filter) =>
|
||||
(
|
||||
@@ -126,17 +131,10 @@ const defaultDeps: HostStatusDeps = {
|
||||
);
|
||||
return [...new Set(entries.map((entry) => entry.hostId))];
|
||||
},
|
||||
hasActiveSession: (hostId) => hostSessionStatus.hasActiveSession(hostId),
|
||||
ping: (host, port) => tcpPing(host, port, 5000),
|
||||
pingThroughJumpHosts: async (target, port) => {
|
||||
const { createJumpHostChain } = await import("../jump-host-chain.js");
|
||||
const client = await createJumpHostChain(
|
||||
target.jumpHosts,
|
||||
target.userId,
|
||||
).catch(() => null);
|
||||
return client
|
||||
? tcpPingThroughJumpHost(client, target.ip, port, 5000)
|
||||
: false;
|
||||
},
|
||||
pingThroughJumpHosts: (target, port) =>
|
||||
jumpProbes.ping(target.jumpHosts, target.userId, target.ip, port, 5000),
|
||||
globalInterval: () => {
|
||||
const value = Number(getCurrentSettingValue(GLOBAL_STATUS_INTERVAL_KEY));
|
||||
return Number.isInteger(value) && value >= 5
|
||||
@@ -156,7 +154,6 @@ export class HostStatusService {
|
||||
private readonly store = new Map<number, HostStatusEntry>();
|
||||
private readonly owners = new Map<number, string>();
|
||||
private readonly inFlight = new Map<number, Promise<void>>();
|
||||
private readonly loginFailed = new Set<number>();
|
||||
private readonly startedUsers = new Set<string>();
|
||||
private readonly portResolvers = new Map<string, Set<PortResolver>>();
|
||||
private readonly limiter = new ConcurrentLimiter(20);
|
||||
@@ -173,19 +170,12 @@ export class HostStatusService {
|
||||
}),
|
||||
pluginEvents.on(TOPICS.hostUpdated, (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (!hostId) return;
|
||||
// An edit may have fixed the login, so the next attempt decides.
|
||||
this.loginFailed.delete(hostId);
|
||||
void this.reload(hostId);
|
||||
if (hostId) void this.reload(hostId);
|
||||
}),
|
||||
pluginEvents.on(TOPICS.hostDeleted, (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) this.forget(hostId);
|
||||
}),
|
||||
pluginEvents.on(TOPICS.hostKeyUpdated, (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) this.clearHostKeyReason(hostId);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -210,11 +200,13 @@ export class HostStatusService {
|
||||
userId: string,
|
||||
requestedHostIds: Set<number> | null,
|
||||
): Promise<Map<number, HostStatusEntry>> {
|
||||
const started: Promise<void>[] = [];
|
||||
if (requestedHostIds !== null) {
|
||||
await this.reconcile(userId, requestedHostIds);
|
||||
await this.reconcile(userId, requestedHostIds, started);
|
||||
} else if (!this.startedUsers.has(userId)) {
|
||||
await this.startUser(userId);
|
||||
await this.startUser(userId, started);
|
||||
}
|
||||
if (started.length > 0) await this.waitFor(started);
|
||||
return this.store;
|
||||
}
|
||||
|
||||
@@ -247,20 +239,12 @@ export class HostStatusService {
|
||||
return this.store.get(hostId) ?? null;
|
||||
}
|
||||
|
||||
reportLogin(
|
||||
hostId: number,
|
||||
outcome: { ok: boolean; hostKeyChanged?: boolean },
|
||||
): void {
|
||||
if (outcome.ok) {
|
||||
this.loginFailed.delete(hostId);
|
||||
this.set(hostId, { status: statusAfterAuthentication(true) });
|
||||
return;
|
||||
}
|
||||
this.loginFailed.add(hostId);
|
||||
this.set(hostId, {
|
||||
status: statusAfterAuthentication(false, this.store.get(hostId)?.status),
|
||||
...(outcome.hostKeyChanged ? { reason: "host_key_changed" } : {}),
|
||||
});
|
||||
/**
|
||||
* A working login proves the host is up. A failed one says nothing about
|
||||
* reachability (a wrong password, another user's login), so it is ignored.
|
||||
*/
|
||||
reportLogin(hostId: number, outcome: { ok: boolean }): void {
|
||||
if (outcome.ok) this.set(hostId, { status: "online" });
|
||||
}
|
||||
|
||||
registerPort(connectionType: string, resolve: PortResolver): () => void {
|
||||
@@ -276,13 +260,33 @@ export class HostStatusService {
|
||||
};
|
||||
}
|
||||
|
||||
private async startUser(userId: string): Promise<void> {
|
||||
private async waitFor(started: Promise<void>[]): Promise<void> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
Promise.allSettled(started),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, FIRST_CHECK_WAIT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
||||
private track(started: Promise<void>[], target: StatusTarget): void {
|
||||
const probe = this.startPolling(target);
|
||||
if (probe) started.push(probe);
|
||||
}
|
||||
|
||||
private async startUser(
|
||||
userId: string,
|
||||
started: Promise<void>[],
|
||||
): Promise<void> {
|
||||
this.startedUsers.add(userId);
|
||||
const targets = await this.deps.loadTargets({ userId });
|
||||
for (const target of targets) {
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
if (!this.polled.has(target.id)) this.track(started, target);
|
||||
}
|
||||
await this.startShared(userId, null);
|
||||
await this.startShared(userId, null, started);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,6 +296,7 @@ export class HostStatusService {
|
||||
private async startShared(
|
||||
userId: string,
|
||||
allowed: Set<number> | null,
|
||||
started: Promise<void>[],
|
||||
): Promise<void> {
|
||||
if (!this.deps.loadSharedHostIds) return;
|
||||
try {
|
||||
@@ -302,7 +307,7 @@ export class HostStatusService {
|
||||
if (shared.length === 0) return;
|
||||
const targets = await this.deps.loadTargets({ hostIds: shared });
|
||||
for (const target of targets) {
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
if (!this.polled.has(target.id)) this.track(started, target);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn("Could not start status checks for shared hosts", {
|
||||
@@ -313,7 +318,11 @@ export class HostStatusService {
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcile(userId: string, allowed: Set<number>): Promise<void> {
|
||||
private async reconcile(
|
||||
userId: string,
|
||||
allowed: Set<number>,
|
||||
started: Promise<void>[],
|
||||
): Promise<void> {
|
||||
const targets = await this.deps.loadTargets({ userId });
|
||||
for (const target of targets) {
|
||||
if (!allowed.has(target.id)) {
|
||||
@@ -321,9 +330,9 @@ export class HostStatusService {
|
||||
this.store.delete(target.id);
|
||||
continue;
|
||||
}
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
if (!this.polled.has(target.id)) this.track(started, target);
|
||||
}
|
||||
await this.startShared(userId, allowed);
|
||||
await this.startShared(userId, allowed, started);
|
||||
}
|
||||
|
||||
private async reload(hostId: number): Promise<void> {
|
||||
@@ -349,16 +358,6 @@ export class HostStatusService {
|
||||
this.stopPolling(hostId);
|
||||
this.store.delete(hostId);
|
||||
this.owners.delete(hostId);
|
||||
this.loginFailed.delete(hostId);
|
||||
}
|
||||
|
||||
private clearHostKeyReason(hostId: number): void {
|
||||
const current = this.store.get(hostId);
|
||||
if (current?.reason !== "host_key_changed") return;
|
||||
this.loginFailed.delete(hostId);
|
||||
this.set(hostId, {
|
||||
status: current.status === "offline" ? "offline" : "online",
|
||||
});
|
||||
}
|
||||
|
||||
private intervalMs(target: StatusTarget): number {
|
||||
@@ -371,21 +370,26 @@ export class HostStatusService {
|
||||
);
|
||||
}
|
||||
|
||||
private startPolling(target: StatusTarget, probeNow = true): void {
|
||||
/** Returns the first probe when one was started. */
|
||||
private startPolling(
|
||||
target: StatusTarget,
|
||||
probeNow = true,
|
||||
): Promise<void> | undefined {
|
||||
this.stopPolling(target.id);
|
||||
this.owners.set(target.id, target.userId);
|
||||
if (!target.statusCheckEnabled) {
|
||||
this.store.delete(target.id);
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
const polled: Polled = { target };
|
||||
this.polled.set(target.id, polled);
|
||||
if (probeNow) void this.probe(target);
|
||||
const first = probeNow ? this.probe(target) : undefined;
|
||||
polled.timer = setInterval(() => {
|
||||
const latest = this.polled.get(target.id);
|
||||
if (latest) void this.probe(latest.target);
|
||||
}, this.intervalMs(target));
|
||||
polled.timer.unref?.();
|
||||
return first;
|
||||
}
|
||||
|
||||
private stopPolling(hostId: number): void {
|
||||
@@ -425,29 +429,28 @@ export class HostStatusService {
|
||||
return target.port;
|
||||
}
|
||||
|
||||
private async pingOnce(target: StatusTarget): Promise<boolean> {
|
||||
try {
|
||||
if (this.deps.hasActiveSession?.(target.id)) return true;
|
||||
const port = await this.portFor(target);
|
||||
return target.jumpHosts.length > 0
|
||||
? await this.deps.pingThroughJumpHosts(target, port)
|
||||
: await this.deps.ping(target.ip, port);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async probeNow(target: StatusTarget): Promise<void> {
|
||||
this.owners.set(target.id, target.userId);
|
||||
let reachable = false;
|
||||
try {
|
||||
const port = await this.portFor(target);
|
||||
reachable =
|
||||
target.jumpHosts.length > 0
|
||||
? await this.deps.pingThroughJumpHosts(target, port)
|
||||
: await this.deps.ping(target.ip, port);
|
||||
} catch {
|
||||
reachable = false;
|
||||
let reachable = await this.pingOnce(target);
|
||||
if (!reachable) {
|
||||
// One lost packet should not flip the dot.
|
||||
const delay = this.deps.retryDelayMs ?? 1000;
|
||||
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
reachable = await this.pingOnce(target);
|
||||
}
|
||||
|
||||
const current = this.store.get(target.id);
|
||||
this.set(target.id, {
|
||||
status: statusAfterReachabilityCheck(
|
||||
reachable,
|
||||
this.loginFailed.has(target.id),
|
||||
),
|
||||
...(reachable && current?.reason === "host_key_changed"
|
||||
? { reason: "host_key_changed" as const }
|
||||
: {}),
|
||||
});
|
||||
this.set(target.id, { status: reachable ? "online" : "offline" });
|
||||
}
|
||||
|
||||
private set(hostId: number, entry: Omit<HostStatusEntry, "lastChecked">) {
|
||||
@@ -480,8 +483,7 @@ export class HostStatusService {
|
||||
ownerUserId,
|
||||
status: entry.status,
|
||||
previous,
|
||||
online: entry.status !== "offline",
|
||||
...(entry.reason ? { reason: entry.reason } : {}),
|
||||
online: entry.status === "online",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type HostStatus = "online" | "reachable" | "offline";
|
||||
export type HostStatus = "online" | "offline";
|
||||
|
||||
export function isHostKeyVerificationError(error: unknown): boolean {
|
||||
return (
|
||||
@@ -7,19 +7,3 @@ export function isHostKeyVerificationError(error: unknown): boolean {
|
||||
error.message.includes("Host key changed"))
|
||||
);
|
||||
}
|
||||
|
||||
export function statusAfterReachabilityCheck(
|
||||
reachable: boolean,
|
||||
loginFailed: boolean,
|
||||
): HostStatus {
|
||||
if (!reachable) return "offline";
|
||||
return loginFailed ? "reachable" : "online";
|
||||
}
|
||||
|
||||
export function statusAfterAuthentication(
|
||||
authenticated: boolean,
|
||||
current?: HostStatus,
|
||||
): HostStatus {
|
||||
if (authenticated) return "online";
|
||||
return current === "offline" ? "offline" : "reachable";
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { tcpPingThroughJumpHostResult } from "./tcp-ping.js";
|
||||
|
||||
type OpenChain = (
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
) => Promise<Client | null>;
|
||||
|
||||
interface Entry {
|
||||
client: Promise<Client | null>;
|
||||
idle?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status probes for hosts behind jump hosts share one chain per hop list.
|
||||
* Opening a chain logs in to every hop, so doing it for every host on every
|
||||
* interval made an import of jump-hosted hosts open dozens of SSH sessions
|
||||
* each minute. A chain closes after it sits idle.
|
||||
*/
|
||||
export class JumpProbePool {
|
||||
private readonly entries = new Map<string, Entry>();
|
||||
|
||||
constructor(
|
||||
private readonly open: OpenChain,
|
||||
private readonly idleMs = 120_000,
|
||||
) {}
|
||||
|
||||
async ping(
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
const key = `${userId}:${jumpHosts.map((hop) => hop.hostId).join(">")}`;
|
||||
let entry = this.entries.get(key);
|
||||
if (!entry) {
|
||||
entry = { client: this.connect(key, jumpHosts, userId) };
|
||||
this.entries.set(key, entry);
|
||||
}
|
||||
const client = await entry.client;
|
||||
if (!client) {
|
||||
this.drop(key, entry);
|
||||
return false;
|
||||
}
|
||||
this.touch(key, entry, client);
|
||||
const result = await tcpPingThroughJumpHostResult(
|
||||
client,
|
||||
host,
|
||||
port,
|
||||
timeoutMs,
|
||||
true,
|
||||
);
|
||||
if (result === "timeout") {
|
||||
// The hop never answered, so the next ping opens a fresh chain.
|
||||
this.drop(key, entry);
|
||||
client.end();
|
||||
}
|
||||
return result === "ok";
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
for (const [key, entry] of [...this.entries]) {
|
||||
this.drop(key, entry);
|
||||
void entry.client.then((client) => client?.end()).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private async connect(
|
||||
key: string,
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
userId: string,
|
||||
): Promise<Client | null> {
|
||||
const client = await this.open(jumpHosts, userId).catch(() => null);
|
||||
if (client) {
|
||||
const forget = () => {
|
||||
const current = this.entries.get(key);
|
||||
if (current) this.drop(key, current);
|
||||
};
|
||||
client.once("close", forget);
|
||||
client.once("error", forget);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private touch(key: string, entry: Entry, client: Client): void {
|
||||
if (entry.idle) clearTimeout(entry.idle);
|
||||
entry.idle = setTimeout(() => {
|
||||
this.drop(key, entry);
|
||||
client.end();
|
||||
}, this.idleMs);
|
||||
entry.idle.unref?.();
|
||||
}
|
||||
|
||||
private drop(key: string, entry: Entry): void {
|
||||
if (entry.idle) clearTimeout(entry.idle);
|
||||
if (this.entries.get(key) === entry) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import net from "net";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
/**
|
||||
* Opens a TCP connection and closes it again. An SSH server gets a polite
|
||||
* banner back so it does not log a failed handshake.
|
||||
* Opens a TCP connection and closes it again. SSH probes exchange banners,
|
||||
* but still close before authentication and may trigger aggressive Fail2Ban rules.
|
||||
*/
|
||||
export function tcpPing(
|
||||
host: string,
|
||||
@@ -64,29 +64,54 @@ export function tcpPing(
|
||||
});
|
||||
}
|
||||
|
||||
/** The same check from the far end of a jump host chain. Ends the chain. */
|
||||
export function tcpPingThroughJumpHost(
|
||||
/**
|
||||
* "refused" means the last hop answered that the target is down. "timeout"
|
||||
* means the hop never answered, so the chain itself may be dead.
|
||||
*/
|
||||
export type JumpPingResult = "ok" | "refused" | "timeout";
|
||||
|
||||
export function tcpPingThroughJumpHostResult(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
keepOpen = false,
|
||||
): Promise<JumpPingResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
const finish = (result: JumpPingResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
jumpClient.end();
|
||||
if (!keepOpen) jumpClient.end();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
const timeout = setTimeout(() => finish("timeout"), timeoutMs);
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
|
||||
stream?.destroy();
|
||||
finish(!error && !!stream);
|
||||
finish(!error && !!stream ? "ok" : "refused");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** The same check from the far end of a jump host chain. Ends the chain. */
|
||||
export async function tcpPingThroughJumpHost(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
keepOpen = false,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await tcpPingThroughJumpHostResult(
|
||||
jumpClient,
|
||||
host,
|
||||
port,
|
||||
timeoutMs,
|
||||
keepOpen,
|
||||
)) === "ok"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
} from "../hosts/connect/auth-provider-registry.js";
|
||||
import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js";
|
||||
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
||||
import { isHostKeyVerificationError } from "../hosts/status/host-status.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
registerLoginMethod,
|
||||
@@ -96,8 +95,33 @@ function describeHost(host: number | PluginSshHost): string {
|
||||
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
|
||||
}
|
||||
|
||||
const LOGIN_FAILED =
|
||||
/all configured authentication methods failed|permission denied|authentication failed/i;
|
||||
const OVERRIDE_FIELDS = [
|
||||
"password",
|
||||
"key",
|
||||
"privateKey",
|
||||
"keyPassword",
|
||||
"passphrase",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A secret the plugin set on its copy (a password the user just typed) wins
|
||||
* over the stored one; everything else comes from what core resolved.
|
||||
*/
|
||||
function withOverrides(
|
||||
resolved: SshConnectHost,
|
||||
given: PluginSshHost,
|
||||
): SshConnectHost {
|
||||
if (given === (resolved as unknown)) return resolved;
|
||||
const source = given as unknown as Record<string, unknown>;
|
||||
const set = OVERRIDE_FIELDS.filter(
|
||||
(field) => typeof source[field] === "string" && source[field] !== "",
|
||||
);
|
||||
if (set.length === 0) return resolved;
|
||||
const merged = { ...resolved } as unknown as Record<string, unknown>;
|
||||
for (const field of set) merged[field] = source[field];
|
||||
if (typeof source.authType === "string") merged.authType = source.authType;
|
||||
return merged as unknown as SshConnectHost;
|
||||
}
|
||||
|
||||
export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
const pluginId = manifest.id;
|
||||
@@ -134,8 +158,37 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
{ host: SshConnectHost; userId: string }
|
||||
>();
|
||||
|
||||
const remember = (key: object, host: SshConnectHost, userId: string) =>
|
||||
resolvedHosts.set(key, { host, userId });
|
||||
// Plugins often copy a host before connecting ({ ...host, port }). Spread
|
||||
// copies enumerable symbol keys, so the copy keeps this tag. Its value is an
|
||||
// opaque token, not the host, so the secrets stay out of the plugin's reach.
|
||||
const tag = Symbol("resolvedHost");
|
||||
const tokens = new WeakMap<
|
||||
object,
|
||||
{ host: SshConnectHost; userId: string }
|
||||
>();
|
||||
|
||||
const remember = (key: object, host: SshConnectHost, userId: string) => {
|
||||
const entry = { host, userId };
|
||||
resolvedHosts.set(key, entry);
|
||||
const token = Object.freeze({});
|
||||
tokens.set(token, entry);
|
||||
if (Object.isExtensible(key)) {
|
||||
Object.defineProperty(key, tag, {
|
||||
value: token,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const lookup = (host: object) => {
|
||||
const known = resolvedHosts.get(host);
|
||||
if (known) return known;
|
||||
const token = (host as Record<symbol, unknown>)[tag];
|
||||
return token && typeof token === "object"
|
||||
? tokens.get(token as object)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
|
||||
const redacted = redactHostSecrets(
|
||||
@@ -148,9 +201,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
/** The acting user for a call about this host. */
|
||||
const userFor = (host: number | PluginSshHost | undefined): string =>
|
||||
actingUser(
|
||||
host && typeof host === "object"
|
||||
? resolvedHosts.get(host)?.userId
|
||||
: undefined,
|
||||
host && typeof host === "object" ? lookup(host)?.userId : undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -163,29 +214,18 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
userId: string,
|
||||
): number | SshConnectHost => {
|
||||
if (typeof host === "number") return host;
|
||||
const known = resolvedHosts.get(host);
|
||||
if (known) return known.host;
|
||||
const known = lookup(host);
|
||||
if (known) return withOverrides(known.host, host);
|
||||
return { ...(host as unknown as SshConnectHost), userId };
|
||||
};
|
||||
|
||||
/**
|
||||
* A login to a saved host core resolved feeds its status dot. Only a clear
|
||||
* auth or host key failure counts: a timeout says nothing about the login.
|
||||
*/
|
||||
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
|
||||
const hostId =
|
||||
typeof host === "number" ? host : resolvedHosts.get(host)?.host.id;
|
||||
/** A working login to a saved host core resolved marks it online. */
|
||||
const reportLogin = (host: number | PluginSshHost) => {
|
||||
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) return;
|
||||
let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true };
|
||||
if (error !== undefined) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const hostKeyChanged = isHostKeyVerificationError(error);
|
||||
if (!hostKeyChanged && !LOGIN_FAILED.test(message)) return;
|
||||
outcome = { ok: false, hostKeyChanged };
|
||||
}
|
||||
void import("../hosts/status/host-status-service.js")
|
||||
.then(({ hostStatusService }) =>
|
||||
hostStatusService.reportLogin(hostId, outcome),
|
||||
hostStatusService.reportLogin(hostId, { ok: true }),
|
||||
)
|
||||
.catch(() => {});
|
||||
};
|
||||
@@ -245,7 +285,6 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
success: false,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
reportLogin(host, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,7 +156,7 @@ async function writeAudit(
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
// Attribution comes from the runtime, never from the plugin.
|
||||
userId: getActor() ?? "system",
|
||||
userId: getActor() ?? null,
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: `plugin_${options.action}`,
|
||||
resourceType: "plugin",
|
||||
@@ -965,12 +965,12 @@ export function createPluginContext(
|
||||
record: async (entry) => {
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
const actor = getActor() ?? "system";
|
||||
const actor = getActor();
|
||||
const meta = requestMeta(entry.request);
|
||||
await logAudit({
|
||||
...meta,
|
||||
userId: actor,
|
||||
username: actor,
|
||||
userId: actor ?? null,
|
||||
username: actor ?? "system",
|
||||
action: entry.action,
|
||||
resourceType: entry.resourceType ?? "plugin",
|
||||
resourceId: entry.resourceId ?? pluginId,
|
||||
|
||||
@@ -351,7 +351,7 @@ async function writePublicRouteAudit(
|
||||
try {
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
userId: "system",
|
||||
userId: null,
|
||||
username: `plugin:${manifest.id}`,
|
||||
action: "plugin_http_public_routes",
|
||||
resourceType: "plugin",
|
||||
|
||||
@@ -111,7 +111,7 @@ async function auditRefusal(
|
||||
const { logAudit } = await import("../utils/audit-logger.js");
|
||||
await logAudit({
|
||||
// Attribution comes from the runtime, never from the plugin.
|
||||
userId: getActor() ?? "system",
|
||||
userId: getActor() ?? null,
|
||||
username: `plugin:${pluginId}`,
|
||||
action: `plugin_${action}`,
|
||||
resourceType: "plugin",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -382,7 +382,7 @@ function auditPublicSocket(pluginId: string, path: string): void {
|
||||
void import("../utils/audit-logger.js")
|
||||
.then(({ logAudit }) =>
|
||||
logAudit({
|
||||
userId: "system",
|
||||
userId: null,
|
||||
username: `plugin:${pluginId}`,
|
||||
action: "plugin_ws_public_route",
|
||||
resourceType: "plugin",
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "./host-plugin-settings.js";
|
||||
import {
|
||||
exportProtocolLogins,
|
||||
exportSharedProtocolLogins,
|
||||
importProtocolLogins,
|
||||
} from "./host-protocol-auth.js";
|
||||
import {
|
||||
@@ -477,7 +478,7 @@ async function writeAccountProfile(userId: string, wire: SyncRow) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Host columns a shared copy never carries. */
|
||||
/** Host columns a shared copy never carries as the owner has them. */
|
||||
const SHARED_HOST_DROP = [
|
||||
"id",
|
||||
"userId",
|
||||
@@ -546,6 +547,11 @@ async function loadSharedHosts(userId: string): Promise<SyncRow[]> {
|
||||
: value;
|
||||
}
|
||||
wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts);
|
||||
wire.pluginSettings = await exportHostPluginSettings(hostId, {
|
||||
userId,
|
||||
permissionLevel,
|
||||
});
|
||||
wire.protocolAuth = await exportSharedProtocolLogins(host, userId);
|
||||
wire.syncId = host.syncId;
|
||||
wire.shared = {
|
||||
hostId,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { databaseLogger } from "../utils/logger.js";
|
||||
import type { DefaultOverrides } from "../../types/host-defaults.js";
|
||||
import {
|
||||
hostSettingsPlugins,
|
||||
withHostPluginSettings,
|
||||
type HostPluginSettings,
|
||||
} from "../database/routes/host-plugin-settings.js";
|
||||
|
||||
@@ -23,19 +24,45 @@ export interface PluginHostSettingsSync {
|
||||
importValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
/** What a host carries to the other side of a sync pair. */
|
||||
/** Who a shared host's copy is for, and at what share level. */
|
||||
export interface SharedHostViewer {
|
||||
userId: string;
|
||||
permissionLevel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a host carries to the other side of a sync pair. With a viewer, only
|
||||
* what that user sees of a host shared with them, as the host routes show it.
|
||||
*/
|
||||
export async function exportHostPluginSettings(
|
||||
hostId: number,
|
||||
viewer?: SharedHostViewer,
|
||||
): Promise<HostPluginSettings> {
|
||||
const visible = viewer
|
||||
? (((
|
||||
await withHostPluginSettings(
|
||||
{
|
||||
id: hostId,
|
||||
isShared: true,
|
||||
permissionLevel: viewer.permissionLevel,
|
||||
},
|
||||
viewer.userId,
|
||||
)
|
||||
).pluginSettings as HostPluginSettings | undefined) ?? {})
|
||||
: null;
|
||||
const result: HostPluginSettings = {};
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const fields = declaredFields(manifest, "host").filter(
|
||||
(field) => field.type !== "secret",
|
||||
(field) =>
|
||||
field.type !== "secret" &&
|
||||
(!visible || field.key in (visible[manifest.id] ?? {})),
|
||||
);
|
||||
if (fields.length === 0) continue;
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
const values = visible
|
||||
? visible[manifest.id]
|
||||
: await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
const hook = consume<PluginHostSettingsSync>(
|
||||
`${manifest.id}.hostSettingsSync`,
|
||||
);
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
|
||||
import type { SyncRow } from "@termix/plugin-sdk/backend";
|
||||
import type { HostProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js";
|
||||
import { createCurrentHostProtocolAuthRepository } from "../database/repositories/factory.js";
|
||||
import {
|
||||
fromPortableLogin,
|
||||
listProtocolLogins,
|
||||
replaceProtocolLogins,
|
||||
resolveRecipientProtocolLogin,
|
||||
toPortableLogins,
|
||||
} from "../hosts/protocol-auth/protocol-auth.js";
|
||||
import { findHostProtocol } from "../hosts/protocol-auth/registry.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
|
||||
type ResolveSyncId = (entityType: string, id: number) => Promise<string | null>;
|
||||
@@ -38,6 +41,45 @@ export async function exportProtocolLogins(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The logins a user a host is shared with may use, for their desktop's copy:
|
||||
* their own override or the owner's shared snapshot, never the owner's own
|
||||
* secret. Each goes as a direct login since its credential is not theirs.
|
||||
*/
|
||||
export async function exportSharedProtocolLogins(
|
||||
host: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Promise<SyncRow> {
|
||||
const out: SyncRow = {};
|
||||
const hostId = Number(host.id);
|
||||
const rows =
|
||||
await createCurrentHostProtocolAuthRepository().listRowsForHost(hostId);
|
||||
for (const row of rows) {
|
||||
const declared = findHostProtocol(row.protocol);
|
||||
if (!declared) continue;
|
||||
try {
|
||||
const login = await resolveRecipientProtocolLogin(host, userId, declared);
|
||||
out[row.protocol] = {
|
||||
authType: login.authType === "none" ? "none" : "direct",
|
||||
username: login.username || null,
|
||||
password: login.password || null,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(login.fields).filter(([, value]) => value),
|
||||
),
|
||||
credentialSyncId: null,
|
||||
};
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not include a shared host's protocol login", {
|
||||
operation: "sync_shared_host_protocol_auth",
|
||||
hostId,
|
||||
protocol: row.protocol,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes what a synced host carried. A row from a peer that sends no
|
||||
* protocolAuth leaves the stored logins alone.
|
||||
|
||||
@@ -521,6 +521,89 @@ describe("external identities", () => {
|
||||
).rejects.toMatchObject({ code: "user_not_allowed" });
|
||||
});
|
||||
|
||||
it.each(["true", "false"])(
|
||||
"does not duplicate an unscoped migrated account with provisioning %s",
|
||||
async (enabled) => {
|
||||
addUser({
|
||||
id: "old",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-1",
|
||||
ssoProviderId: null,
|
||||
});
|
||||
const { runExternalIdentityMigration } =
|
||||
await import("../../upgrade/external-identity-migration.js");
|
||||
await runExternalIdentityMigration();
|
||||
h.state.settings.set("oidc_auto_provision", enabled);
|
||||
await expect(
|
||||
runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
rememberMe: false,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "legacy_identity_conflict" });
|
||||
expect([...h.state.users.keys()]).toEqual(["old"]);
|
||||
expect(h.state.identities).toHaveLength(1);
|
||||
expect(h.state.identities[0]).toMatchObject({
|
||||
userId: "old",
|
||||
providerId: "legacy-oidc",
|
||||
subject: "sub-1",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps equal subjects on explicitly different providers separate", async () => {
|
||||
addUser({
|
||||
id: "other",
|
||||
username: "Other Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-1",
|
||||
ssoProviderId: 9,
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "other",
|
||||
providerId: "9",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
});
|
||||
h.state.settings.set("oidc_auto_provision", "true");
|
||||
const result = await runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("session");
|
||||
expect(h.state.users.size).toBe(2);
|
||||
expect(
|
||||
h.state.identities.find((link) => link.providerId === "3")?.userId,
|
||||
).not.toBe("other");
|
||||
});
|
||||
|
||||
it("uses a verified provider link even when the old provider column is empty", async () => {
|
||||
addUser({
|
||||
id: "old",
|
||||
username: "Alice",
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-1",
|
||||
ssoProviderId: null,
|
||||
});
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "old",
|
||||
providerId: "3",
|
||||
subject: "sub-1",
|
||||
email: null,
|
||||
});
|
||||
const result = await runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("session");
|
||||
expect(h.state.users.size).toBe(1);
|
||||
});
|
||||
|
||||
it("re-checks the allowed list for existing users", async () => {
|
||||
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
|
||||
h.state.identities.push({
|
||||
|
||||
@@ -77,6 +77,34 @@ describe("AuditLogRepository", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores entries with no acting user, and refuses ids that are not users", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
await repo.create({
|
||||
userId: null,
|
||||
username: "plugin:example",
|
||||
action: "plugin_http_public_routes",
|
||||
resourceType: "plugin",
|
||||
success: true,
|
||||
});
|
||||
await expect(
|
||||
repo.create({
|
||||
userId: "system",
|
||||
username: "system",
|
||||
action: "cleanup",
|
||||
resourceType: "plugin",
|
||||
success: true,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const page = await repo.listPage({ filters: {}, limit: 10, offset: 0 });
|
||||
expect(page.logs).toHaveLength(1);
|
||||
expect(page.logs[0]).toMatchObject({
|
||||
userId: null,
|
||||
username: "plugin:example",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes logs by user id and only runs write hook for deleted rows", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
|
||||
@@ -4,12 +4,13 @@ import { DATABASE_DIALECT_ENV } from "../../../database/db/dialect.js";
|
||||
// getDb() throws unless a database was initialized; the context's handle is
|
||||
// not what this file is about.
|
||||
vi.mock("../../../database/db/index.js", () => ({
|
||||
getDb: () => ({}),
|
||||
getDb: vi.fn(() => ({})),
|
||||
getSqlite: () => ({}),
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn(), triggerSave: vi.fn() },
|
||||
}));
|
||||
|
||||
const {
|
||||
createCurrentSessionRepository,
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentRepositoryWriteHook,
|
||||
createCurrentRepositoryLazyWriteHook,
|
||||
@@ -86,3 +87,35 @@ describe("createCurrentRepositoryLazyWriteHook", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("wires SQLite activity to the periodic flush and token changes to immediate persistence", async () => {
|
||||
const saved = process.env[DATABASE_DIALECT_ENV];
|
||||
process.env[DATABASE_DIALECT_ENV] = "sqlite";
|
||||
const { getDb } = await import("../../../database/db/index.js");
|
||||
const { DatabaseSaveTrigger } =
|
||||
await import("../../../utils/database-save-trigger.js");
|
||||
const dirty = vi
|
||||
.spyOn(DatabaseSaveTrigger, "markDirty")
|
||||
.mockImplementation(() => {});
|
||||
const force = vi.spyOn(DatabaseSaveTrigger, "forceSave").mockResolvedValue();
|
||||
const query = {
|
||||
update: () => query,
|
||||
set: () => query,
|
||||
where: async () => ({ changes: 1 }),
|
||||
};
|
||||
vi.mocked(getDb).mockReturnValue(
|
||||
query as unknown as ReturnType<typeof getDb>,
|
||||
);
|
||||
try {
|
||||
const repo = createCurrentSessionRepository();
|
||||
await repo.touch("session-1");
|
||||
expect(dirty).toHaveBeenCalledOnce();
|
||||
expect(force).not.toHaveBeenCalled();
|
||||
await repo.updateToken("session-1", "new-token");
|
||||
expect(force).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
if (saved === undefined) delete process.env[DATABASE_DIALECT_ENV];
|
||||
else process.env[DATABASE_DIALECT_ENV] = saved;
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ describe("UserRepository and SessionRepository", () => {
|
||||
async function createRepositories(options: {
|
||||
onUserWrite?: () => void | Promise<void>;
|
||||
onSessionWrite?: () => void | Promise<void>;
|
||||
onSessionActivity?: () => void | Promise<void>;
|
||||
}): Promise<{
|
||||
users: UserRepository;
|
||||
sessions: SessionRepository;
|
||||
@@ -28,6 +29,7 @@ describe("UserRepository and SessionRepository", () => {
|
||||
options: {
|
||||
onUserWrite?: () => void | Promise<void>;
|
||||
onSessionWrite?: () => void | Promise<void>;
|
||||
onSessionActivity?: () => void | Promise<void>;
|
||||
} = {},
|
||||
): Promise<{
|
||||
users: UserRepository;
|
||||
@@ -38,7 +40,11 @@ describe("UserRepository and SessionRepository", () => {
|
||||
|
||||
return {
|
||||
users: new UserRepository(context, options.onUserWrite),
|
||||
sessions: new SessionRepository(context, options.onSessionWrite),
|
||||
sessions: new SessionRepository(
|
||||
context,
|
||||
options.onSessionWrite,
|
||||
options.onSessionActivity,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -228,6 +234,46 @@ describe("UserRepository and SessionRepository", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("separates activity persistence from security-critical session writes", async () => {
|
||||
let writes = 0,
|
||||
activity = 0;
|
||||
const repo = await createRepositories({
|
||||
onSessionWrite: () => {
|
||||
writes++;
|
||||
},
|
||||
onSessionActivity: () => {
|
||||
activity++;
|
||||
},
|
||||
});
|
||||
await repo.users.create({
|
||||
id: "user-1",
|
||||
username: "user",
|
||||
passwordHash: "hash",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
});
|
||||
await repo.sessions.create({
|
||||
id: "session-1",
|
||||
userId: "user-1",
|
||||
jwtToken: "token",
|
||||
deviceType: "desktop",
|
||||
deviceInfo: "Firefox",
|
||||
createdAt: "2026-06-26T00:00:00.000Z",
|
||||
expiresAt: "2026-06-27T00:00:00.000Z",
|
||||
lastActiveAt: "2026-06-26T00:00:00.000Z",
|
||||
});
|
||||
expect(writes).toBe(1);
|
||||
await repo.sessions.touch("session-1", "2026-06-26T00:01:00.000Z");
|
||||
expect(activity).toBe(1);
|
||||
expect(writes).toBe(1);
|
||||
await repo.sessions.touch("session-1", "2026-06-26T00:01:10.000Z");
|
||||
expect(activity).toBe(1);
|
||||
await repo.sessions.updateToken("session-1", "renewed");
|
||||
expect(writes).toBe(2);
|
||||
await repo.sessions.revoke("session-1");
|
||||
expect(writes).toBe(3);
|
||||
});
|
||||
|
||||
it("revokes all user sessions except an optional current session", async () => {
|
||||
const repo = await createRepositories();
|
||||
await repo.users.create({
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* A key credential whose private key is a secret reference (op://...) is
|
||||
* saved as given and resolved when connecting (#1394). Parsing the reference
|
||||
* as a key used to reject it, and an update kept the old private key.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
created: [] as Array<Record<string, unknown>>,
|
||||
updated: [] as Array<Record<string, unknown>>,
|
||||
existing: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
const pass = (_req: unknown, _res: unknown, next: () => void) => next();
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: { userId?: string }, _res: unknown, next: () => void) => {
|
||||
req.userId = "u1";
|
||||
next();
|
||||
},
|
||||
createDataAccessMiddleware: () => pass,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({ requirePermission: () => pass }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../sync/shared-copy-guard.js", () => ({
|
||||
rejectSharedCopyWrites: () => pass,
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async () => {},
|
||||
getAuditUsername: async () => "u1",
|
||||
getRequestMeta: () => ({ ipAddress: null, userAgent: null }),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return { authLogger: log, sshLogger: log, databaseLogger: log };
|
||||
});
|
||||
vi.mock("../../../database/routes/credential-key-routes.js", () => ({
|
||||
registerCredentialKeyRoutes: () => {},
|
||||
}));
|
||||
vi.mock("../../../database/routes/credential-deploy-routes.js", () => ({
|
||||
registerCredentialDeployRoutes: () => {},
|
||||
}));
|
||||
vi.mock("../../../database/routes/credential-bulk-routes.js", () => ({
|
||||
registerCredentialBulkRoutes: () => {},
|
||||
}));
|
||||
vi.mock("../../../hosts/delete-credential.js", () => ({
|
||||
deleteOwnedCredential: async () => {},
|
||||
}));
|
||||
vi.mock("../../../hosts/connect/core-providers.js", () => ({
|
||||
ensureCoreSshAuthProviders: () => {},
|
||||
}));
|
||||
vi.mock("../../../hosts/connect/auth-provider-registry.js", () => ({
|
||||
listCredentialTypes: () => ["password", "key"],
|
||||
}));
|
||||
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ resyncHostsForCredential: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/shared-credential-secrets-manager.js", () => ({
|
||||
SharedCredentialSecretsManager: {
|
||||
getInstance: () => ({ resyncCredential: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentCredentialRepository: () => ({
|
||||
createEncryptedForUser: async (
|
||||
userId: string,
|
||||
data: Record<string, unknown>,
|
||||
) => {
|
||||
h.created.push(data);
|
||||
return { id: 1, userId, ...data };
|
||||
},
|
||||
findById: async () => (h.existing ? { id: 1, userId: "u1" } : null),
|
||||
findDecryptedByIdForUser: async () => h.existing,
|
||||
updateEncryptedForUser: async (
|
||||
_userId: string,
|
||||
_id: number,
|
||||
fields: Record<string, unknown>,
|
||||
) => {
|
||||
h.updated.push(fields);
|
||||
return { ...h.existing, ...fields };
|
||||
},
|
||||
}),
|
||||
createCurrentUserRepository: () => ({}),
|
||||
createCurrentCredentialAccessRepository: () => ({}),
|
||||
createCurrentRoleRepository: () => ({}),
|
||||
createCurrentHostResolutionRepository: () => ({}),
|
||||
createCurrentHostRepository: () => ({}),
|
||||
}));
|
||||
|
||||
const { default: router } =
|
||||
await import("../../../database/routes/credentials.js");
|
||||
|
||||
// Never parsed here: only the update replacing it matters.
|
||||
const REAL_KEY = "stored-key-material";
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
h.created = [];
|
||||
h.updated = [];
|
||||
h.existing = null;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/credentials", router);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function send(method: string, path: string, body: unknown) {
|
||||
return fetch(`${base}/credentials${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("credentials with a secret reference key", () => {
|
||||
it("saves an op:// private key as given", async () => {
|
||||
const response = await send("POST", "", {
|
||||
name: "vault key",
|
||||
authType: "key",
|
||||
username: "root",
|
||||
key: "op://vault/item/private_key",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(h.created[0]).toMatchObject({
|
||||
key: "op://vault/item/private_key",
|
||||
privateKey: "op://vault/item/private_key",
|
||||
publicKey: null,
|
||||
detectedKeyType: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("still rejects a key that is neither a key nor a reference", async () => {
|
||||
const response = await send("POST", "", {
|
||||
name: "bad",
|
||||
authType: "key",
|
||||
key: "not a key",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("replaces the stored private key when updating to a reference", async () => {
|
||||
h.existing = {
|
||||
id: 1,
|
||||
userId: "u1",
|
||||
name: "k",
|
||||
authType: "key",
|
||||
key: REAL_KEY,
|
||||
privateKey: REAL_KEY,
|
||||
publicKey: "ssh-ed25519 AAAA old",
|
||||
};
|
||||
|
||||
const response = await send("PUT", "/1", {
|
||||
key: "op://vault/item/private_key",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.updated[0]).toMatchObject({
|
||||
key: "op://vault/item/private_key",
|
||||
privateKey: "op://vault/item/private_key",
|
||||
publicKey: null,
|
||||
detectedKeyType: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
||||
update: vi.fn(),
|
||||
list: vi.fn(),
|
||||
applyPluginSettings: vi.fn(),
|
||||
applyDefaults: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: mocks.applyPluginSettings,
|
||||
@@ -14,6 +15,7 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||
vi.mock("../../../hosts/defaults/index.js", () => ({
|
||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||
applyDefaultsAfterHostWrite: async () => {},
|
||||
applyDefaultsAfterHostWrites: mocks.applyDefaults,
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
@@ -141,6 +143,9 @@ it.each([20, "20"])(
|
||||
]);
|
||||
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("exportId");
|
||||
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("id");
|
||||
// One defaults pass for the whole import, not one per host.
|
||||
expect(mocks.applyDefaults).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.applyDefaults).toHaveBeenCalledWith([501, 502]);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ beforeEach(() => {
|
||||
state.statuses = new Map([
|
||||
[1, { status: "online", lastChecked: "t" }],
|
||||
[2, { status: "offline", lastChecked: "t" }],
|
||||
[3, { status: "reachable", lastChecked: "t" }],
|
||||
[3, { status: "offline", lastChecked: "t" }],
|
||||
]);
|
||||
service.statusesFor.mockImplementation(async () => state.statuses);
|
||||
service.get.mockImplementation(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
|
||||
const settings = vi.hoisted(() => new Map<string, string>());
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { error: vi.fn() },
|
||||
}));
|
||||
import { registerHostTagRoutes } from "../../../database/routes/host-tag-routes.js";
|
||||
|
||||
function app() {
|
||||
const server = express();
|
||||
server.use(express.json());
|
||||
registerHostTagRoutes(
|
||||
server,
|
||||
(req, res, next) =>
|
||||
req.headers.authorization ? next() : void res.sendStatus(401),
|
||||
(req, res, next) =>
|
||||
req.headers.authorization === "admin" ? next() : void res.sendStatus(403),
|
||||
);
|
||||
return server;
|
||||
}
|
||||
|
||||
beforeEach(() => settings.clear());
|
||||
describe("instance host tag catalog", () => {
|
||||
it("allows authenticated users to read the same admin-managed catalog", async () => {
|
||||
const server = app();
|
||||
await request(server)
|
||||
.put("/tags")
|
||||
.set("Authorization", "admin")
|
||||
.send({ tags: [" Linux ", "Domain Controller", "Linux"] })
|
||||
.expect(200, { tags: ["Linux", "Domain Controller"] });
|
||||
await request(server)
|
||||
.get("/tags")
|
||||
.set("Authorization", "member")
|
||||
.expect(200, { tags: ["Linux", "Domain Controller"] });
|
||||
});
|
||||
it("rejects anonymous reads and non-admin writes", async () => {
|
||||
await request(app()).get("/tags").expect(401);
|
||||
await request(app())
|
||||
.put("/tags")
|
||||
.set("Authorization", "member")
|
||||
.send({ tags: ["test"] })
|
||||
.expect(403);
|
||||
expect(settings.size).toBe(0);
|
||||
});
|
||||
it.each([null, [42], [" "], ["x".repeat(101)], Array(501).fill("tag")])(
|
||||
"rejects invalid catalogs",
|
||||
async (tags) => {
|
||||
await request(app())
|
||||
.put("/tags")
|
||||
.set("Authorization", "admin")
|
||||
.send({ tags })
|
||||
.expect(400);
|
||||
expect(settings.size).toBe(0);
|
||||
},
|
||||
);
|
||||
it("allows clearing suggestions without modifying hosts", async () => {
|
||||
settings.set("host_tag_catalog", '["Linux"]');
|
||||
await request(app())
|
||||
.put("/tags")
|
||||
.set("Authorization", "admin")
|
||||
.send({ tags: [] })
|
||||
.expect(200, { tags: [] });
|
||||
});
|
||||
|
||||
it("reads a damaged catalog as empty instead of failing", async () => {
|
||||
settings.set("host_tag_catalog", "{not json");
|
||||
await request(app())
|
||||
.get("/tags")
|
||||
.set("Authorization", "member")
|
||||
.expect(200, { tags: [] });
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,65 @@ describe("linking an external account to a password account", () => {
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it("merges a 2.9.0 duplicate back into its 2.8 SSO account", async () => {
|
||||
h.users.set("orig", {
|
||||
id: "orig",
|
||||
username: "bob",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-9",
|
||||
});
|
||||
h.users.set("dup", {
|
||||
id: "dup",
|
||||
username: "bob-1",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: null,
|
||||
});
|
||||
h.identities.push(
|
||||
{ userId: "orig", providerId: "legacy-oidc", subject: "sub-9" },
|
||||
{ userId: "dup", providerId: "1", subject: "sub-9" },
|
||||
);
|
||||
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "dup",
|
||||
targetUsername: "bob",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.deleted).toEqual(["dup"]);
|
||||
expect(h.users.get("orig")!.oidcIdentifier).toBe("sub-9");
|
||||
expect(
|
||||
h.identities
|
||||
.filter((row) => row.userId === "orig")
|
||||
.map((row) => row.providerId),
|
||||
).toEqual(["legacy-oidc", "1"]);
|
||||
});
|
||||
|
||||
it("does not treat an unrelated SSO account as a duplicate target", async () => {
|
||||
h.users.set("other", {
|
||||
id: "other",
|
||||
username: "carol",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "sub-x",
|
||||
});
|
||||
h.identities.push({
|
||||
userId: "other",
|
||||
providerId: "legacy-oidc",
|
||||
subject: "sub-x",
|
||||
});
|
||||
|
||||
const response = await post("/link-external-to-password", {
|
||||
externalUserId: "sso",
|
||||
targetUsername: "carol",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlinking", () => {
|
||||
|
||||
@@ -5,9 +5,14 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { copyPluginSdk } =
|
||||
const { copyPluginSdk, resourcesDirFor } =
|
||||
require("../../../../packaging/build/after-pack.cjs") as {
|
||||
copyPluginSdk: (resourcesDir: string, sdkDir?: string) => string;
|
||||
resourcesDirFor: (context: {
|
||||
electronPlatformName: string;
|
||||
appOutDir: string;
|
||||
packager: { appInfo: { productFilename: string } };
|
||||
}) => string;
|
||||
};
|
||||
|
||||
let dir: string;
|
||||
@@ -53,4 +58,30 @@ describe("afterPack plugin SDK copy", () => {
|
||||
"npm run build:sdk",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["darwin", "mas"])(
|
||||
"copies into the app bundle for %s builds",
|
||||
(electronPlatformName) => {
|
||||
const context = {
|
||||
electronPlatformName,
|
||||
appOutDir: "/out",
|
||||
packager: { appInfo: { productFilename: "Termix" } },
|
||||
};
|
||||
expect(resourcesDirFor(context)).toBe(
|
||||
path.join("/out", "Termix.app", "Contents", "Resources"),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["win32", "linux"])(
|
||||
"copies into resources for %s builds",
|
||||
(electronPlatformName) => {
|
||||
const context = {
|
||||
electronPlatformName,
|
||||
appOutDir: "/out",
|
||||
packager: { appInfo: { productFilename: "Termix" } },
|
||||
};
|
||||
expect(resourcesDirFor(context)).toBe(path.join("/out", "resources"));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -76,3 +76,47 @@ describe("host key recovery", () => {
|
||||
expect(accepted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a host missing from the database", () => {
|
||||
function answeringSocket(action: "accept" | "reject") {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
send: (data: string) => void;
|
||||
};
|
||||
socket.send = () =>
|
||||
socket.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "host_key_verification_response",
|
||||
data: { action },
|
||||
}),
|
||||
),
|
||||
);
|
||||
return socket as unknown as WebSocket;
|
||||
}
|
||||
|
||||
async function verifyMissingHost(ws: WebSocket | null) {
|
||||
const verifier = await SSHHostKeyVerifier.createHostVerifier(
|
||||
hostId,
|
||||
"127.0.0.1",
|
||||
22,
|
||||
ws,
|
||||
"user",
|
||||
false,
|
||||
null,
|
||||
);
|
||||
return new Promise<boolean>((resolve) =>
|
||||
verifier(Buffer.from("any-key"), resolve),
|
||||
);
|
||||
}
|
||||
|
||||
it("refuses the key when nobody can be asked", async () => {
|
||||
expect(await verifyMissingHost(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("asks the user and saves nothing", async () => {
|
||||
expect(await verifyMissingHost(answeringSocket("accept"))).toBe(true);
|
||||
expect(await verifyMissingHost(answeringSocket("reject"))).toBe(false);
|
||||
expect(updateHostKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -487,6 +487,16 @@ describe("resolveHostById", () => {
|
||||
expect(host.sshOptions).toEqual({ keepaliveCountMax: 3 });
|
||||
});
|
||||
|
||||
it("turns the stored force_keyboard_interactive text into a boolean", async () => {
|
||||
state.host = baseHost({ forceKeyboardInteractive: "false" });
|
||||
const off = (await resolveHostById(42, "owner")) as Record<string, unknown>;
|
||||
expect(off.forceKeyboardInteractive).toBe(false);
|
||||
|
||||
state.host = baseHost({ forceKeyboardInteractive: "true" });
|
||||
const on = (await resolveHostById(42, "owner")) as Record<string, unknown>;
|
||||
expect(on.forceKeyboardInteractive).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves an admin bypass like the owner, keeping owner-only secrets", async () => {
|
||||
state.isAdminBypass = true;
|
||||
state.host = baseHost({
|
||||
|
||||
@@ -13,10 +13,13 @@ describe("HostSessionStatus", () => {
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener).toHaveBeenLastCalledWith(7, true);
|
||||
|
||||
expect(status.hasActiveSession(7)).toBe(true);
|
||||
closeFirst();
|
||||
expect(status.hasActiveSession(7)).toBe(true);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
closeSecond();
|
||||
expect(status.hasActiveSession(7)).toBe(false);
|
||||
expect(listener).toHaveBeenLastCalledWith(7, false);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -52,8 +52,10 @@ function setup(
|
||||
loadSharedHostIds: async (userId) => shared[userId] ?? [],
|
||||
ping,
|
||||
pingThroughJumpHosts,
|
||||
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
|
||||
globalInterval: () => 60,
|
||||
emit: (payload) => emitted.push(payload),
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
|
||||
}
|
||||
@@ -74,6 +76,34 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("HostStatusService", () => {
|
||||
it.each([{ jumpHosts: [] }, { jumpHosts: [{ hostId: 9 }] }])(
|
||||
"skips probes while a session is open, including jump hosts: %j",
|
||||
async ({ jumpHosts }) => {
|
||||
const { service, ping, pingThroughJumpHosts } = setup([
|
||||
target(7, { jumpHosts }),
|
||||
]);
|
||||
active = service;
|
||||
const close = hostSessionStatus.register(7);
|
||||
try {
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flush();
|
||||
expect(service.get(7)?.status).toBe("online");
|
||||
expect(ping).not.toHaveBeenCalled();
|
||||
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
|
||||
close();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await flush();
|
||||
expect(
|
||||
jumpHosts.length ? pingThroughJumpHosts : ping,
|
||||
).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("starts a user's own hosts and reports them online", async () => {
|
||||
const { service, emitted, ping } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
@@ -163,86 +193,44 @@ describe("HostStatusService", () => {
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
|
||||
});
|
||||
|
||||
it("warns about a failed login until a login works again", async () => {
|
||||
it("ignores a failed login and counts a working one as online", async () => {
|
||||
const { service, emitted } = setup([target(1)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
const before = emitted.length;
|
||||
service.reportLogin(1, { ok: false });
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
expect(emitted.at(-1)).toMatchObject({
|
||||
status: "reachable",
|
||||
previous: "online",
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(75_000);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
|
||||
service.reportLogin(1, { ok: true });
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
vi.advanceTimersByTime(75_000);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
expect(emitted).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("marks a changed host key and clears it once the new key is accepted", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
it("marks a host online when a terminal session opens", async () => {
|
||||
const { service } = setup([target(1)], false);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false, hostKeyChanged: true });
|
||||
expect(service.get(1)).toMatchObject({
|
||||
status: "reachable",
|
||||
reason: "host_key_changed",
|
||||
});
|
||||
|
||||
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 });
|
||||
expect(service.get(1)?.reason).toBeUndefined();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("clears a failed login when the host is edited", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false });
|
||||
|
||||
pluginEvents.emit(TOPICS.hostUpdated, { hostId: 1 });
|
||||
await flush();
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("clears a failed login when a terminal session opens", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false });
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
|
||||
const release = hostSessionStatus.register(1);
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
release();
|
||||
});
|
||||
|
||||
it("tries a failed ping again before reporting offline", async () => {
|
||||
const { service, ping } = setup([target(1)]);
|
||||
active = service;
|
||||
ping.mockResolvedValueOnce(false);
|
||||
await service.statusesFor("owner", null);
|
||||
expect(ping).toHaveBeenCalledTimes(2);
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("shows an offline host as offline whatever the login said", async () => {
|
||||
const { service } = setup([target(1)], false);
|
||||
it("answers the first request with the first checks done", async () => {
|
||||
const { service } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false });
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
service.reportLogin(1, { ok: true });
|
||||
vi.advanceTimersByTime(75_000);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
const statuses = await service.statusesFor("owner", null);
|
||||
expect(statuses.get(1)?.status).toBe("online");
|
||||
expect(statuses.get(2)?.status).toBe("online");
|
||||
});
|
||||
|
||||
it("only checks the requested hosts on the desktop app", async () => {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHostKeyVerificationError,
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
} from "../../../hosts/status/host-status.js";
|
||||
import { isHostKeyVerificationError } from "../../../hosts/status/host-status.js";
|
||||
|
||||
describe("isHostKeyVerificationError", () => {
|
||||
it.each([
|
||||
@@ -19,24 +15,3 @@ describe("isHostKeyVerificationError", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host availability status", () => {
|
||||
it("calls a host whose port answers online", () => {
|
||||
expect(statusAfterReachabilityCheck(true, false)).toBe("online");
|
||||
expect(statusAfterReachabilityCheck(false, false)).toBe("offline");
|
||||
});
|
||||
|
||||
it("warns about a reachable host whose last login failed", () => {
|
||||
expect(statusAfterReachabilityCheck(true, true)).toBe("reachable");
|
||||
expect(statusAfterReachabilityCheck(false, true)).toBe("offline");
|
||||
});
|
||||
|
||||
it("marks successful authentication online", () => {
|
||||
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
|
||||
});
|
||||
|
||||
it("downgrades failed authentication without hiding reachability", () => {
|
||||
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
|
||||
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { JumpProbePool } from "../../../hosts/status/jump-probe-pool.js";
|
||||
|
||||
function fakeClient(reachable = true) {
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn(
|
||||
(
|
||||
_srcIp: string,
|
||||
_srcPort: number,
|
||||
_host: string,
|
||||
_port: number,
|
||||
callback: (error: Error | null, stream?: { destroy(): void }) => void,
|
||||
) => {
|
||||
callback(
|
||||
reachable ? null : new Error("refused"),
|
||||
reachable ? { destroy: () => {} } : undefined,
|
||||
);
|
||||
},
|
||||
),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("JumpProbePool", () => {
|
||||
it("shares one chain across hosts behind the same hops", async () => {
|
||||
const client = fakeClient();
|
||||
const open = vi.fn(async () => client as never);
|
||||
const pool = new JumpProbePool(open);
|
||||
const hops = [{ hostId: 1 }, { hostId: 2 }];
|
||||
|
||||
const results = await Promise.all([
|
||||
pool.ping(hops, "u1", "10.0.0.3", 22),
|
||||
pool.ping(hops, "u1", "10.0.0.4", 22),
|
||||
pool.ping(hops, "u1", "10.0.0.5", 22),
|
||||
]);
|
||||
|
||||
expect(results).toEqual([true, true, true]);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(client.forwardOut).toHaveBeenCalledTimes(3);
|
||||
expect(client.end).not.toHaveBeenCalled();
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("keeps chains apart per user and hop list", async () => {
|
||||
const open = vi.fn(async () => fakeClient() as never);
|
||||
const pool = new JumpProbePool(open);
|
||||
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
|
||||
await pool.ping([{ hostId: 1 }], "u2", "a", 22);
|
||||
await pool.ping([{ hostId: 2 }], "u1", "a", 22);
|
||||
expect(open).toHaveBeenCalledTimes(3);
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("reports an unreachable target without dropping the chain", async () => {
|
||||
const client = fakeClient(false);
|
||||
const open = vi.fn(async () => client as never);
|
||||
const pool = new JumpProbePool(open);
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "b", 22)).toBe(false);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("drops a chain whose hop stops answering", async () => {
|
||||
vi.useFakeTimers();
|
||||
const stale = Object.assign(new EventEmitter(), {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn(),
|
||||
});
|
||||
const fresh = fakeClient();
|
||||
const open = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(fresh);
|
||||
const pool = new JumpProbePool(open);
|
||||
|
||||
const first = pool.ping([{ hostId: 1 }], "u1", "a", 22, 1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(await first).toBe(false);
|
||||
expect(stale.end).toHaveBeenCalled();
|
||||
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("retries a chain that failed to open", async () => {
|
||||
const open = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(fakeClient());
|
||||
const pool = new JumpProbePool(open);
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(false);
|
||||
expect(await pool.ping([{ hostId: 1 }], "u1", "a", 22)).toBe(true);
|
||||
pool.closeAll();
|
||||
});
|
||||
|
||||
it("opens a new chain after the old one closed or sat idle", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = fakeClient();
|
||||
const second = fakeClient();
|
||||
const third = fakeClient();
|
||||
const open = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(first)
|
||||
.mockResolvedValueOnce(second)
|
||||
.mockResolvedValueOnce(third);
|
||||
const pool = new JumpProbePool(open, 1000);
|
||||
|
||||
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
|
||||
first.emit("close");
|
||||
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(second.end).toHaveBeenCalled();
|
||||
await pool.ping([{ hostId: 1 }], "u1", "a", 22);
|
||||
expect(open).toHaveBeenCalledTimes(3);
|
||||
pool.closeAll();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolve = vi.fn(async (_userId: string, ref: string) => `real:${ref}`);
|
||||
vi.mock("../../hosts/external-secrets.js", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
resolveSecretReference: (userId: string, ref: string) => resolve(userId, ref),
|
||||
}));
|
||||
|
||||
const { parseKeyForStorage, resolveKeyReferences } =
|
||||
await import("../../hosts/stored-ssh-key.js");
|
||||
|
||||
describe("parseKeyForStorage", () => {
|
||||
it("accepts a secret reference without parsing it", () => {
|
||||
expect(parseKeyForStorage(" op://v/i/private_key ")).toEqual({
|
||||
success: true,
|
||||
privateKey: "op://v/i/private_key",
|
||||
publicKey: "",
|
||||
keyType: "",
|
||||
reference: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not try to open a real key with a referenced passphrase", () => {
|
||||
const result = parseKeyForStorage("-----BEGIN...", "op://v/i/pass");
|
||||
expect(result).toMatchObject({ success: true, reference: true });
|
||||
});
|
||||
|
||||
it("parses anything else as a key", () => {
|
||||
const result = parseKeyForStorage("not a key");
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reference).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveKeyReferences", () => {
|
||||
it("resolves only the values that are references", async () => {
|
||||
expect(await resolveKeyReferences("u1", "op://v/i/key", "plain")).toEqual({
|
||||
key: "real:op://v/i/key",
|
||||
passphrase: "plain",
|
||||
});
|
||||
expect(resolve).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,8 @@ describe("ctx.audit.record", () => {
|
||||
const ctx = contextFor("audit-fixture");
|
||||
await ctx.audit.record({ action: "cleanup", success: false });
|
||||
expect(auditEntries[0]).toMatchObject({
|
||||
userId: "system",
|
||||
userId: null,
|
||||
username: "system",
|
||||
resourceType: "plugin",
|
||||
resourceId: "audit-fixture",
|
||||
resourceName: "Audit Fixture",
|
||||
|
||||
@@ -129,7 +129,7 @@ vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
get: (hostId: number) =>
|
||||
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
|
||||
hostId === 4 ? { status: "online", lastChecked: "t" } : null,
|
||||
check: async (hostId: number) => ({
|
||||
status: "offline",
|
||||
lastChecked: String(hostId),
|
||||
@@ -496,7 +496,7 @@ describe("ctx.hosts", () => {
|
||||
}
|
||||
expect(record.sshOptions).toEqual({ keepaliveInterval: 5 });
|
||||
expect(record.pluginSettings).toEqual({ mine: "own-4" });
|
||||
expect(record.status).toEqual({ status: "reachable", lastChecked: "t" });
|
||||
expect(record.status).toEqual({ status: "online", lastChecked: "t" });
|
||||
expect(record.enableSsh).toBe(true);
|
||||
});
|
||||
|
||||
@@ -620,7 +620,7 @@ describe("ctx.hosts.status", () => {
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
await expect(hosts.status.get(4)).resolves.toEqual({
|
||||
status: "reachable",
|
||||
status: "online",
|
||||
lastChecked: "t",
|
||||
});
|
||||
await expect(hosts.status.get(5)).resolves.toBeNull();
|
||||
@@ -656,11 +656,9 @@ describe("ctx.hosts.status", () => {
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
|
||||
hosts.status.reportLogin(4, { ok: true });
|
||||
await settle();
|
||||
expect(h.statusReports).toEqual([
|
||||
{ hostId: 4, ok: false, hostKeyChanged: true },
|
||||
]);
|
||||
expect(h.statusReports).toEqual([{ hostId: 4, ok: true }]);
|
||||
});
|
||||
|
||||
it("removes a registered port when the plugin is disposed", async () => {
|
||||
|
||||
@@ -91,6 +91,18 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({
|
||||
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (hostId: number, userId: string) => ({
|
||||
id: hostId,
|
||||
userId,
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "stored-secret",
|
||||
}),
|
||||
resolveHostBySyncId: async () => null,
|
||||
}));
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
reportLogin: (hostId: number, outcome: unknown) =>
|
||||
@@ -208,24 +220,19 @@ describe("ctx.ssh", () => {
|
||||
expect(h.logins).toEqual([{ hostId: 7, outcome: { ok: true } }]);
|
||||
});
|
||||
|
||||
it("reports an auth or host key failure, but not a timeout", async () => {
|
||||
it("never reports a failed login to the status", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
h.connectError = new Error("Timed out while waiting for handshake");
|
||||
await expect(ssh.connect(7)).rejects.toThrow();
|
||||
h.connectError = new Error("All configured authentication methods failed");
|
||||
await expect(ssh.connect(7)).rejects.toThrow();
|
||||
h.connectError = new Error("Host key changed");
|
||||
await expect(ssh.connect(8)).rejects.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.logins).toEqual([
|
||||
{ hostId: 7, outcome: { ok: false, hostKeyChanged: false } },
|
||||
{ hostId: 8, outcome: { ok: false, hostKeyChanged: true } },
|
||||
]);
|
||||
expect(h.logins).toEqual([]);
|
||||
});
|
||||
|
||||
it("never believes a host object's own userId", async () => {
|
||||
@@ -253,6 +260,50 @@ describe("ctx.ssh", () => {
|
||||
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
|
||||
});
|
||||
|
||||
it("connects a copied redacted host with its stored login", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const host = (await ssh.resolveHost(5))!;
|
||||
expect((host as Record<string, unknown>).password).toBeUndefined();
|
||||
expect(JSON.stringify(host)).not.toContain("stored-secret");
|
||||
for (const symbol of Object.getOwnPropertySymbols(host)) {
|
||||
expect(JSON.stringify((host as never)[symbol])).not.toContain(
|
||||
"stored-secret",
|
||||
);
|
||||
}
|
||||
|
||||
await ssh.connect({ ...host, port: host.port || 22 });
|
||||
expect(h.connects[0].target).toMatchObject({ password: "stored-secret" });
|
||||
|
||||
await ssh.connect({ ...host, password: "typed", authType: "password" });
|
||||
expect(h.connects[1].target).toMatchObject({ password: "typed" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.logins.map((login) => login.hostId)).toEqual([5, 5]);
|
||||
});
|
||||
|
||||
it("does not honor another plugin's resolved host", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const deps = {
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
};
|
||||
const first = createPluginSsh({
|
||||
...deps,
|
||||
bag: new DisposableBag("fixture"),
|
||||
});
|
||||
const second = createPluginSsh({
|
||||
...deps,
|
||||
bag: new DisposableBag("fixture"),
|
||||
});
|
||||
const host = (await first.resolveHost(5))!;
|
||||
await second.connect({ ...host });
|
||||
expect(h.connects[0].target).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("passes a given stream through to the pipeline, gated like any connect", async () => {
|
||||
const stream = { throughSource: true };
|
||||
const ssh = createPluginSsh({
|
||||
|
||||
@@ -117,6 +117,32 @@ describe("plugin user data", () => {
|
||||
expect(again).toMatchObject({ imported: 0, skipped: 1 });
|
||||
});
|
||||
|
||||
it("writes large tables in chunks and lets the event loop run between them", async () => {
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
|
||||
);
|
||||
sqlite.transaction(() => {
|
||||
for (let i = 0; i < 4999; i++) insert.run(`/p/${i}`);
|
||||
})();
|
||||
const file = new Database(":memory:");
|
||||
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
|
||||
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
|
||||
file.exec("INSERT INTO users (id) VALUES ('u1')");
|
||||
const transaction = vi.spyOn(file, "transaction");
|
||||
let ticks = 0;
|
||||
const ticker = setInterval(() => ticks++, 0);
|
||||
|
||||
const written = await writeUserPluginTables(file, "u1");
|
||||
clearInterval(ticker);
|
||||
|
||||
expect(written).toBe(5000);
|
||||
expect(transaction).toHaveBeenCalled();
|
||||
expect(ticks).toBeGreaterThan(0);
|
||||
expect(
|
||||
file.prepare("SELECT COUNT(*) AS n FROM p_fixture_bookmark").get(),
|
||||
).toEqual({ n: 5000 });
|
||||
});
|
||||
|
||||
it("reads a pre-2.9 export by the legacy table name", async () => {
|
||||
const file = new Database(":memory:");
|
||||
file.exec(
|
||||
|
||||
@@ -11,10 +11,19 @@ const h = vi.hoisted(() => ({
|
||||
writes: [] as Array<[string, number, string, unknown]>,
|
||||
hooks: new Map<string, unknown>(),
|
||||
manifests: [] as PluginManifest[],
|
||||
visible: null as Record<string, Record<string, unknown>> | null,
|
||||
viewerCalls: [] as Array<[Record<string, unknown>, string | undefined]>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => h.manifests,
|
||||
withHostPluginSettings: async (
|
||||
host: Record<string, unknown>,
|
||||
viewerId?: string,
|
||||
) => {
|
||||
h.viewerCalls.push([host, viewerId]);
|
||||
return h.visible ? { ...host, pluginSettings: h.visible } : host;
|
||||
},
|
||||
}));
|
||||
vi.mock("../../plugins/registry.js", () => ({
|
||||
consume: (key: string) => h.hooks.get(key),
|
||||
@@ -65,11 +74,27 @@ const VAULT = {
|
||||
},
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
const TUNNELS = {
|
||||
id: "tunnels",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "enableTunnel", type: "boolean", labelKey: "k" },
|
||||
{ key: "tunnelConnections", type: "custom", labelKey: "k" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
beforeEach(() => {
|
||||
h.stored.clear();
|
||||
h.writes.length = 0;
|
||||
h.hooks.clear();
|
||||
h.manifests = [VAULT];
|
||||
h.visible = null;
|
||||
h.viewerCalls.length = 0;
|
||||
h.hooks.set("vault.hostSettingsSync", {
|
||||
exportValue: (key: string, value: unknown) =>
|
||||
key === "profileId" && value === 4 ? "profile-sync-4" : value,
|
||||
@@ -87,6 +112,25 @@ describe("host plugin settings over sync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exports only what a shared host's viewer sees", async () => {
|
||||
h.manifests = [VAULT, TUNNELS];
|
||||
h.visible = {
|
||||
vault: { token: { set: true } },
|
||||
tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] },
|
||||
};
|
||||
expect(
|
||||
await exportHostPluginSettings(1, {
|
||||
userId: "viewer",
|
||||
permissionLevel: "connect",
|
||||
}),
|
||||
).toEqual({
|
||||
tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] },
|
||||
});
|
||||
expect(h.viewerCalls).toEqual([
|
||||
[{ id: 1, isShared: true, permissionLevel: "connect" }, "viewer"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports them back to local ids", async () => {
|
||||
await importHostPluginSettings(9, {
|
||||
vault: { profileId: "profile-sync-4", token: "ignored" },
|
||||
|
||||
@@ -8,6 +8,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const state = vi.hoisted(() => ({
|
||||
logins: [] as Array<Record<string, unknown>>,
|
||||
replaced: null as null | { ownerId: string; hostId: number; logins: unknown },
|
||||
rows: [] as Array<{ protocol: string }>,
|
||||
recipient: new Map<string, Record<string, unknown> | Error>(),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
@@ -20,6 +22,15 @@ vi.mock(
|
||||
return {
|
||||
...actual,
|
||||
listProtocolLogins: async () => state.logins,
|
||||
resolveRecipientProtocolLogin: async (
|
||||
_host: unknown,
|
||||
_userId: string,
|
||||
declared: { id: string },
|
||||
) => {
|
||||
const login = state.recipient.get(declared.id);
|
||||
if (login instanceof Error) throw login;
|
||||
return login;
|
||||
},
|
||||
replaceProtocolLogins: async (
|
||||
ownerId: string,
|
||||
hostId: number,
|
||||
@@ -30,6 +41,11 @@ vi.mock(
|
||||
};
|
||||
},
|
||||
);
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostProtocolAuthRepository: () => ({
|
||||
listRowsForHost: async () => state.rows,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
getDb: () => null,
|
||||
getSqlite: () => null,
|
||||
@@ -37,12 +53,15 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
|
||||
import {
|
||||
exportProtocolLogins,
|
||||
exportSharedProtocolLogins,
|
||||
importProtocolLogins,
|
||||
} from "../../sync/host-protocol-auth.js";
|
||||
import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.replaced = null;
|
||||
state.rows = [];
|
||||
state.recipient.clear();
|
||||
state.logins = [
|
||||
{
|
||||
protocol: "spice",
|
||||
@@ -129,3 +148,52 @@ describe("host protocol logins over sync", () => {
|
||||
expect(state.replaced).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared host protocol logins over sync", () => {
|
||||
it("sends what the recipient may use as direct logins", async () => {
|
||||
state.rows = [{ protocol: "spice" }, { protocol: "unknown" }];
|
||||
state.recipient.set("spice", {
|
||||
authType: "credential",
|
||||
username: "alice",
|
||||
password: "pw",
|
||||
fields: { display: "2", ticket: "" },
|
||||
});
|
||||
expect(
|
||||
await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"),
|
||||
).toEqual({
|
||||
spice: {
|
||||
authType: "direct",
|
||||
username: "alice",
|
||||
password: "pw",
|
||||
fields: { display: "2" },
|
||||
credentialSyncId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a none login and skips one that fails", async () => {
|
||||
setHostProtocolSource(() => [
|
||||
{ id: "spice", credentialFields: [], pluginId: "p", pluginName: "P" },
|
||||
{ id: "vnc", credentialFields: [], pluginId: "p", pluginName: "P" },
|
||||
]);
|
||||
state.rows = [{ protocol: "spice" }, { protocol: "vnc" }];
|
||||
state.recipient.set("spice", {
|
||||
authType: "none",
|
||||
username: "",
|
||||
password: "",
|
||||
fields: {},
|
||||
});
|
||||
state.recipient.set("vnc", new Error("locked"));
|
||||
expect(
|
||||
await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"),
|
||||
).toEqual({
|
||||
spice: {
|
||||
authType: "none",
|
||||
username: null,
|
||||
password: null,
|
||||
fields: {},
|
||||
credentialSyncId: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -20,7 +20,9 @@ export async function getAuditUsername(userId: string): Promise<string> {
|
||||
}
|
||||
|
||||
export interface AuditLogParams {
|
||||
userId: string;
|
||||
// Null when no user acted (a plugin starting up, background work): the
|
||||
// column references users.id, so anything else would be refused.
|
||||
userId: string | null;
|
||||
username: string;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ export type Host = {
|
||||
*/
|
||||
childHosts?: Host[];
|
||||
online: boolean;
|
||||
status?: "online" | "reachable" | "offline" | "unknown";
|
||||
status?: "online" | "offline" | "unknown";
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
lastAccess: string;
|
||||
|
||||
@@ -61,6 +61,7 @@ function isTransientStatusError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Throws when the statuses could not be read, so callers keep what they had. */
|
||||
export async function getAllServerStatuses(): Promise<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
@@ -71,31 +72,24 @@ export async function getAllServerStatuses(): Promise<
|
||||
let remoteHostsBySyncId: Map<string, number> | null = null;
|
||||
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
const hosts = response.data || [];
|
||||
localHostIds = hosts
|
||||
// A host-list failure throws here: sending no ids would stop every check.
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
const hosts = response.data || [];
|
||||
localHostIds = hosts
|
||||
.filter((host) => (host.connectionOrigin ?? defaultOrigin) === "local")
|
||||
.map((host) => host.id);
|
||||
remoteHostsBySyncId = new Map(
|
||||
hosts
|
||||
.filter(
|
||||
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
|
||||
(host) =>
|
||||
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
|
||||
!!host.syncId,
|
||||
)
|
||||
.map((host) => host.id);
|
||||
remoteHostsBySyncId = new Map(
|
||||
hosts
|
||||
.filter(
|
||||
(host) =>
|
||||
(host.connectionOrigin ?? defaultOrigin) === "remote" &&
|
||||
!!host.syncId,
|
||||
)
|
||||
.map((host) => [host.syncId as string, host.id]),
|
||||
);
|
||||
} catch {
|
||||
// A host-list failure must not start local probes for hosts whose
|
||||
// configured origin may be remote.
|
||||
localHostIds = [];
|
||||
}
|
||||
.map((host) => [host.syncId as string, host.id]),
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
@@ -128,10 +122,7 @@ export async function getAllServerStatuses(): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
}
|
||||
if (lastError) handleApiError(lastError, "fetch server statuses");
|
||||
|
||||
if (remoteHostsBySyncId?.size && (await getLinkedSession())) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { hostApi } from "@/main-axios";
|
||||
|
||||
export async function getHostTags(): Promise<string[]> {
|
||||
return (await hostApi.get<{ tags: string[] }>("/tags")).data.tags;
|
||||
}
|
||||
|
||||
export async function saveHostTags(tags: string[]): Promise<string[]> {
|
||||
return (await hostApi.put<{ tags: string[] }>("/tags", { tags })).data.tags;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { getAllServerStatuses, handleApiError, sshHostApi } from "@/main-axios";
|
||||
import { handleApiError, sshHostApi } from "@/main-axios";
|
||||
import type { SSHHost, SSHHostData, ProxyNode } from "@/types/index";
|
||||
import type { ServerStatus, SSHHostWithStatus } from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import {
|
||||
getCachedSSHHosts,
|
||||
invalidateHostsAndStatusCaches,
|
||||
@@ -10,42 +10,16 @@ import {
|
||||
// SSH HOST MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
export type GetSSHHostsOptions = {
|
||||
/** When false, skip the status service call (host config only). Default true. */
|
||||
includeStatus?: boolean;
|
||||
};
|
||||
|
||||
async function loadSSHHostsFromApi(): Promise<SSHHost[]> {
|
||||
const hostsResponse = await sshHostApi.get("/db/host");
|
||||
return Array.isArray(hostsResponse.data) ? hostsResponse.data : [];
|
||||
}
|
||||
|
||||
export async function getSSHHosts(
|
||||
options: GetSSHHostsOptions = {},
|
||||
): Promise<SSHHostWithStatus[]> {
|
||||
const includeStatus = options.includeStatus !== false;
|
||||
|
||||
/** Host config only. Live status comes from ServerStatusContext. */
|
||||
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
|
||||
try {
|
||||
const hosts = await getCachedSSHHosts(loadSSHHostsFromApi);
|
||||
|
||||
if (!includeStatus) {
|
||||
return hosts.map((host) => ({
|
||||
...host,
|
||||
status: "unknown",
|
||||
}));
|
||||
}
|
||||
|
||||
let statuses: Record<number, ServerStatus> = {};
|
||||
try {
|
||||
statuses = (await getAllServerStatuses()) || {};
|
||||
} catch {
|
||||
// Status fetch failure should not prevent host list from loading
|
||||
}
|
||||
|
||||
return hosts.map((host) => ({
|
||||
...host,
|
||||
status: statuses[host.id]?.status || "unknown",
|
||||
}));
|
||||
return hosts.map((host) => ({ ...host, status: "unknown" }));
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch SSH hosts");
|
||||
}
|
||||
|
||||
+30
-4
@@ -296,6 +296,15 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [pendingToken, setPendingToken] = useState("");
|
||||
const [secondFactors, setSecondFactors] = useState<SecondFactorRef[]>([]);
|
||||
const [activeFactorId, setActiveFactorId] = useState("");
|
||||
const [authPluginsLoading, setAuthPluginsLoading] = useState(true);
|
||||
const retryAuthPlugins = async () => {
|
||||
setAuthPluginsLoading(true);
|
||||
try {
|
||||
await startPreLoginPlugins({ retryFailed: true });
|
||||
} finally {
|
||||
setAuthPluginsLoading(false);
|
||||
}
|
||||
};
|
||||
const loginMethodUIs = useLoginMethods();
|
||||
const secondFactorUIs = useSecondFactors();
|
||||
// A server that did not list its factors gets every factor with a UI here.
|
||||
@@ -434,7 +443,9 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
.then((allowed) => setPasswordResetAllowed(allowed))
|
||||
.catch(() => setPasswordResetAllowed(false));
|
||||
// Plugins that draw login or second-factor UI load before sign-in.
|
||||
void startPreLoginPlugins().catch(() => {});
|
||||
void startPreLoginPlugins()
|
||||
.catch(() => {})
|
||||
.finally(() => setAuthPluginsLoading(false));
|
||||
getLoginMethods()
|
||||
.then((methods) => setAuthMethods(methods))
|
||||
.catch(() => setAuthMethods([]))
|
||||
@@ -1364,9 +1375,24 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
);
|
||||
if (!ui) {
|
||||
return (
|
||||
<p className="text-xs text-destructive">
|
||||
{t("auth.secondFactorNoUI")}
|
||||
</p>
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
authPluginsLoading
|
||||
? "common.loading"
|
||||
: "auth.secondFactorNoUI",
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={authPluginsLoading}
|
||||
onClick={() =>
|
||||
void retryAuthPlugins().catch(() => {})
|
||||
}
|
||||
>
|
||||
{t("auth.retrySecondFactorUI")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Component = ui.component;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as React from "react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
@@ -53,6 +54,8 @@ function DialogContent({
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
@@ -73,7 +76,7 @@ function DialogContent({
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
@@ -100,6 +103,8 @@ function DialogFooter({
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
@@ -112,7 +117,7 @@ function DialogFooter({
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t("common.close")}</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -188,7 +188,7 @@ function Select2({
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search..."
|
||||
placeholder={t("common.search")}
|
||||
className="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as React from "react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
@@ -53,6 +54,8 @@ function SheetContent({
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
@@ -74,7 +77,7 @@ function SheetContent({
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
@@ -159,6 +160,7 @@ function Sidebar({
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@@ -192,8 +194,10 @@ function Sidebar({
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
<SheetTitle>{t("common.sidebar")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("common.mobileSidebarDescription")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
@@ -253,6 +257,7 @@ function SidebarTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -268,22 +273,23 @@ function SidebarTrigger({
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<span className="sr-only">{t("common.toggleSidebar")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
aria-label={t("common.toggleSidebar")}
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
title={t("common.toggleSidebar")}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
|
||||
@@ -57,7 +57,11 @@ import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
import { useServerStatusMeta } from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
useServerStatus,
|
||||
useServerStatusMeta,
|
||||
} from "@/lib/ServerStatusContext";
|
||||
import { withLiveHostStatus } from "@/sidebar/live-host-status";
|
||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
|
||||
|
||||
@@ -429,7 +433,7 @@ export function HostStatusCard({
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{online}/{hosts.length} {t("hosts.status.available")}
|
||||
{online}/{hosts.length} {t("hosts.status.online")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-auto flex-1">
|
||||
@@ -439,12 +443,7 @@ export function HostStatusCard({
|
||||
</div>
|
||||
)}
|
||||
{hosts.map((host, i) => {
|
||||
const availability =
|
||||
host.status && host.status !== "unknown"
|
||||
? host.status
|
||||
: host.online
|
||||
? "online"
|
||||
: "offline";
|
||||
const availability = host.status ?? "offline";
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -492,13 +491,11 @@ export function HostStatusCard({
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(availability, statusScheme, "badge", statusLoading)}`}
|
||||
>
|
||||
{statusLoading
|
||||
{statusLoading || availability === "unknown"
|
||||
? t("dashboardTab.checking")
|
||||
: availability === "online"
|
||||
? t("hosts.status.available")
|
||||
: availability === "reachable"
|
||||
? t("hosts.status.reachable")
|
||||
: t("hosts.status.offline")}
|
||||
? t("hosts.status.online")
|
||||
: t("hosts.status.offline")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -582,7 +579,7 @@ function RecentActivityCard({
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.online ?? false, statusScheme, "dot", statusLoading)}`}
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host?.status ?? false, statusScheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
@@ -1102,7 +1099,14 @@ export function DashboardTab({
|
||||
}
|
||||
}, [mainWidthPct]);
|
||||
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [configHosts, setHosts] = useState<Host[]>([]);
|
||||
const { statuses, getStatus } = useServerStatus();
|
||||
const hosts = useMemo(
|
||||
() => withLiveHostStatus(configHosts, getStatus),
|
||||
// statuses changes identity whenever a host's status changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[configHosts, statuses, getStatus],
|
||||
);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [uptimeFormatted, setUptimeFormatted] = useState("");
|
||||
const [versionText, setVersionText] = useState("");
|
||||
|
||||
@@ -48,14 +48,13 @@ export function useStatusColorScheme(): StatusColorScheme {
|
||||
|
||||
/** Returns Tailwind class names for a status dot/stripe. */
|
||||
export function getStatusClasses(
|
||||
status: boolean | "online" | "reachable" | "offline" | "degraded",
|
||||
status: boolean | "online" | "offline" | "unknown",
|
||||
scheme: StatusColorScheme,
|
||||
variant: "dot" | "stripe" | "badge",
|
||||
loading = false,
|
||||
): string {
|
||||
const online = status === true || status === "online";
|
||||
const reachable = status === "reachable";
|
||||
if (loading) {
|
||||
if (loading || status === "unknown") {
|
||||
if (scheme === "status") {
|
||||
if (variant === "dot") return "bg-yellow-400 animate-pulse";
|
||||
if (variant === "stripe") return "bg-yellow-400/40 animate-pulse";
|
||||
@@ -65,11 +64,6 @@ export function getStatusClasses(
|
||||
if (variant === "stripe") return "bg-muted-foreground/20 animate-pulse";
|
||||
return "border-border/50 text-muted-foreground/50 bg-muted/20 animate-pulse";
|
||||
}
|
||||
if (reachable) {
|
||||
if (variant === "dot") return "bg-amber-400";
|
||||
if (variant === "stripe") return "bg-amber-400/50";
|
||||
return "border-amber-400/40 text-amber-400 bg-amber-400/10";
|
||||
}
|
||||
if (scheme === "status") {
|
||||
if (variant === "dot") return online ? "bg-emerald-500" : "bg-red-500";
|
||||
if (variant === "stripe")
|
||||
|
||||
@@ -9,7 +9,8 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAllServerStatuses, getSSHHosts } from "@/main-axios";
|
||||
import { getAllServerStatuses } from "@/main-axios";
|
||||
import { invalidateServerStatusCache } from "./hosts-request-cache";
|
||||
import {
|
||||
ServerStatusStore,
|
||||
type ServerStatusEntry,
|
||||
@@ -19,17 +20,26 @@ import { runAdaptivePolling } from "./adaptive-polling";
|
||||
|
||||
interface ServerStatusContextType {
|
||||
statuses: Map<number, ServerStatusEntry>;
|
||||
isLoading: boolean;
|
||||
initialLoadComplete: boolean;
|
||||
refreshStatuses: () => Promise<void>;
|
||||
/** "unknown" until the host has been checked. */
|
||||
getStatus: (hostId: number) => StatusValue;
|
||||
}
|
||||
|
||||
/** Stable for the provider lifetime — fine-grained hooks only need this. */
|
||||
/** Stable for the provider lifetime. Fine-grained hooks only need this. */
|
||||
const StatusStoreContext = createContext<ServerStatusStore | null>(null);
|
||||
const ServerStatusContext = createContext<ServerStatusContextType | null>(null);
|
||||
|
||||
const POLL_INTERVAL = 30000;
|
||||
/** The mounted provider's store, for code outside React (plugin host lists). */
|
||||
let activeStore: ServerStatusStore | null = null;
|
||||
|
||||
/** A host's live status outside React. "unknown" with no provider mounted. */
|
||||
export function getLiveHostStatus(hostId: number): StatusValue {
|
||||
return activeStore?.getStatus(hostId) ?? "unknown";
|
||||
}
|
||||
|
||||
/** The backend keeps statuses in memory, so reading them often is cheap. */
|
||||
const POLL_INTERVAL = 15_000;
|
||||
|
||||
export function ServerStatusProvider({
|
||||
children,
|
||||
@@ -47,121 +57,48 @@ export function ServerStatusProvider({
|
||||
// Bumps only full-context consumers (dashboard, folder counts, etc.).
|
||||
const [version, setVersion] = useState(0);
|
||||
const mountedRef = useRef(true);
|
||||
const refreshInFlightRef = useRef<Promise<boolean | void> | null>(null);
|
||||
const refreshInFlightRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => store.subscribeAll(() => setVersion((v) => v + 1)), [store]);
|
||||
|
||||
useEffect(() => {
|
||||
return store.subscribeAll(() => {
|
||||
setVersion((v) => v + 1);
|
||||
});
|
||||
activeStore = store;
|
||||
return () => {
|
||||
if (activeStore === store) activeStore = null;
|
||||
};
|
||||
}, [store]);
|
||||
|
||||
useEffect(() => {
|
||||
return store.subscribeMeta(() => {
|
||||
setVersion((v) => v + 1);
|
||||
});
|
||||
}, [store]);
|
||||
|
||||
const fetchEnabledHosts = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
store.setEnabledHostIds(new Set());
|
||||
return new Set<number>();
|
||||
}
|
||||
|
||||
try {
|
||||
const hosts = await getSSHHosts({ includeStatus: false });
|
||||
const enabled = new Set<number>();
|
||||
|
||||
hosts.forEach((host) => {
|
||||
if (host.statusCheckEnabled !== false) enabled.add(host.id);
|
||||
});
|
||||
|
||||
store.setEnabledHostIds(enabled);
|
||||
return enabled;
|
||||
} catch {
|
||||
return store.getEnabledHostIds();
|
||||
}
|
||||
}, [isAuthenticated, store]);
|
||||
|
||||
const refreshStatusesImpl = useCallback(
|
||||
async (rethrow = false) => {
|
||||
if (!mountedRef.current || !isAuthenticated) return;
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.visibilityState === "hidden"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (refreshInFlightRef.current) {
|
||||
return refreshInFlightRef.current;
|
||||
}
|
||||
|
||||
const showLoading = !store.getInitialLoadComplete();
|
||||
if (showLoading) store.setLoading(true);
|
||||
|
||||
const run = (async () => {
|
||||
try {
|
||||
const data = await getAllServerStatuses();
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
const newStatuses = new Map<number, ServerStatusEntry>();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
Object.entries(data).forEach(([idStr, statusData]) => {
|
||||
const id = parseInt(idStr, 10);
|
||||
if (!isNaN(id)) {
|
||||
const status =
|
||||
statusData?.status === "online" ||
|
||||
statusData?.status === "reachable"
|
||||
? statusData.status
|
||||
: "offline";
|
||||
newStatuses.set(id, {
|
||||
status,
|
||||
lastChecked: statusData?.lastChecked || now,
|
||||
...(statusData?.reason === "host_key_changed"
|
||||
? { reason: statusData.reason }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const previousStatuses = store.getStatuses();
|
||||
const changed =
|
||||
previousStatuses.size !== newStatuses.size ||
|
||||
[...newStatuses].some(
|
||||
([id, entry]) =>
|
||||
previousStatuses.get(id)?.status !== entry.status ||
|
||||
previousStatuses.get(id)?.reason !== entry.reason,
|
||||
);
|
||||
store.applyStatuses(newStatuses);
|
||||
return changed;
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
store.markDegraded(store.getEnabledHostIds());
|
||||
}
|
||||
if (rethrow) throw error;
|
||||
return false;
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
if (showLoading) store.setLoading(false);
|
||||
store.setInitialLoadComplete(true);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
refreshInFlightRef.current = run.finally(() => {
|
||||
refreshInFlightRef.current = null;
|
||||
});
|
||||
return refreshInFlightRef.current;
|
||||
},
|
||||
[isAuthenticated, store],
|
||||
);
|
||||
|
||||
const refreshStatuses = useCallback(async () => {
|
||||
await refreshStatusesImpl();
|
||||
}, [refreshStatusesImpl]);
|
||||
if (!mountedRef.current || !isAuthenticated) return;
|
||||
if (refreshInFlightRef.current) return refreshInFlightRef.current;
|
||||
|
||||
const run = (async () => {
|
||||
try {
|
||||
const data = await getAllServerStatuses();
|
||||
if (!mountedRef.current || !data || typeof data !== "object") return;
|
||||
const next = new Map<number, ServerStatusEntry>();
|
||||
const now = new Date().toISOString();
|
||||
for (const [idStr, entry] of Object.entries(data)) {
|
||||
const id = parseInt(idStr, 10);
|
||||
if (isNaN(id)) continue;
|
||||
next.set(id, {
|
||||
status: entry?.status === "online" ? "online" : "offline",
|
||||
lastChecked: entry?.lastChecked || now,
|
||||
});
|
||||
}
|
||||
store.applyStatuses(next);
|
||||
} catch {
|
||||
// Keep the last known statuses rather than flipping every host.
|
||||
} finally {
|
||||
if (mountedRef.current) store.setInitialLoadComplete(true);
|
||||
}
|
||||
})();
|
||||
|
||||
refreshInFlightRef.current = run.finally(() => {
|
||||
refreshInFlightRef.current = null;
|
||||
});
|
||||
return refreshInFlightRef.current;
|
||||
}, [isAuthenticated, store]);
|
||||
|
||||
const getStatus = useCallback(
|
||||
(hostId: number): StatusValue => store.getStatus(hostId),
|
||||
@@ -170,35 +107,31 @@ export function ServerStatusProvider({
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
if (!isAuthenticated) {
|
||||
store.clear();
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}
|
||||
|
||||
let stopPolling: (() => void) | null = null;
|
||||
|
||||
const init = async () => {
|
||||
await fetchEnabledHosts();
|
||||
if (!mountedRef.current) return;
|
||||
stopPolling = runAdaptivePolling(
|
||||
() => refreshStatusesImpl(true),
|
||||
{
|
||||
minIntervalMs: POLL_INTERVAL,
|
||||
maxIntervalMs: 120_000,
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ enabled: () => isAuthenticated },
|
||||
);
|
||||
};
|
||||
|
||||
void init();
|
||||
const stopPolling = runAdaptivePolling(refreshStatuses, {
|
||||
minIntervalMs: POLL_INTERVAL,
|
||||
maxIntervalMs: POLL_INTERVAL,
|
||||
});
|
||||
const onFocus = () => void refreshStatuses();
|
||||
window.addEventListener("focus", onFocus);
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
stopPolling?.();
|
||||
stopPolling();
|
||||
window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}, [fetchEnabledHosts, isAuthenticated, refreshStatusesImpl]);
|
||||
}, [isAuthenticated, refreshStatuses, store]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHostsChanged = async () => {
|
||||
await fetchEnabledHosts();
|
||||
await refreshStatuses();
|
||||
const handleHostsChanged = () => {
|
||||
invalidateServerStatusCache();
|
||||
void refreshStatuses();
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
@@ -208,17 +141,16 @@ export function ServerStatusProvider({
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
window.removeEventListener("hosts:refresh", handleHostsChanged);
|
||||
};
|
||||
}, [fetchEnabledHosts, refreshStatuses]);
|
||||
}, [refreshStatuses]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
statuses: store.getStatuses(),
|
||||
isLoading: store.getIsLoading(),
|
||||
initialLoadComplete: store.getInitialLoadComplete(),
|
||||
refreshStatuses,
|
||||
getStatus,
|
||||
}),
|
||||
// version refreshes statuses/isLoading/initialLoadComplete snapshots
|
||||
// version refreshes the statuses/initialLoadComplete snapshots
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[version, refreshStatuses, getStatus],
|
||||
);
|
||||
@@ -252,85 +184,49 @@ export function useServerStatus() {
|
||||
|
||||
/**
|
||||
* Subscribe to a single host's status. Only re-renders when that host's
|
||||
* status value changes (or its enabled flag flips). Does not re-render when
|
||||
* other hosts update.
|
||||
* status changes. Null when status checks are off for the host.
|
||||
*/
|
||||
export function useHostStatus(
|
||||
hostId: number,
|
||||
statusCheckEnabled: boolean = true,
|
||||
): StatusValue | null {
|
||||
const store = useStatusStore();
|
||||
|
||||
const snapshot = useSyncExternalStore(
|
||||
const status = useSyncExternalStore(
|
||||
(onChange) => store.subscribeHost(hostId, onChange),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
() => store.getStatus(hostId),
|
||||
() => store.getStatus(hostId),
|
||||
);
|
||||
|
||||
if (!statusCheckEnabled) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.split(":", 1)[0] as StatusValue;
|
||||
}
|
||||
|
||||
export function useHostStatusReason(
|
||||
hostId: number,
|
||||
statusCheckEnabled: boolean = true,
|
||||
): ServerStatusEntry["reason"] | null {
|
||||
const store = useStatusStore();
|
||||
const snapshot = useSyncExternalStore(
|
||||
(onChange) => store.subscribeHost(hostId, onChange),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
() => store.getHostSnapshot(hostId),
|
||||
);
|
||||
if (!statusCheckEnabled) return null;
|
||||
return snapshot.endsWith(":host_key_changed")
|
||||
? "host_key_changed"
|
||||
: undefined;
|
||||
return statusCheckEnabled ? status : null;
|
||||
}
|
||||
|
||||
/** Meta flags without depending on the full status map. */
|
||||
export function useServerStatusMeta(): {
|
||||
initialLoadComplete: boolean;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
export function useServerStatusMeta(): { initialLoadComplete: boolean } {
|
||||
const store = useStatusStore();
|
||||
|
||||
useSyncExternalStore(
|
||||
(onChange) => store.subscribeMeta(onChange),
|
||||
() => store.getMetaSnapshot(),
|
||||
() => store.getMetaSnapshot(),
|
||||
);
|
||||
|
||||
return {
|
||||
initialLoadComplete: store.getInitialLoadComplete(),
|
||||
isLoading: store.getIsLoading(),
|
||||
};
|
||||
return { initialLoadComplete: store.getInitialLoadComplete() };
|
||||
}
|
||||
|
||||
const noStatus = () => "";
|
||||
const noStatus = (): StatusValue => "unknown";
|
||||
|
||||
/**
|
||||
* One host's status for plugin views, which can render outside the app shell
|
||||
* (a standalone window). Null there, and while the host has not been checked.
|
||||
*/
|
||||
export function useOptionalHostStatusEntry(hostId: number | undefined): {
|
||||
status: StatusValue;
|
||||
reason?: "host_key_changed";
|
||||
} | null {
|
||||
export function useOptionalHostStatusEntry(
|
||||
hostId: number | undefined,
|
||||
): { status: "online" | "offline" } | null {
|
||||
const store = useContext(StatusStoreContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
const status = useSyncExternalStore(
|
||||
(onChange) =>
|
||||
store && hostId !== undefined
|
||||
? store.subscribeHost(hostId, onChange)
|
||||
: () => {},
|
||||
() => (store && hostId !== undefined ? store.getHostSnapshot(hostId) : ""),
|
||||
() => (store && hostId !== undefined ? store.getStatus(hostId) : "unknown"),
|
||||
noStatus,
|
||||
);
|
||||
if (!snapshot) return null;
|
||||
const [status] = snapshot.split(":", 1);
|
||||
if (!status) return null;
|
||||
return snapshot.endsWith(":host_key_changed")
|
||||
? { status: status as StatusValue, reason: "host_key_changed" }
|
||||
: { status: status as StatusValue };
|
||||
return status === "unknown" ? null : { status };
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
export type StatusValue = "online" | "reachable" | "offline" | "degraded";
|
||||
export type StatusValue = "online" | "offline" | "unknown";
|
||||
|
||||
export interface ServerStatusEntry {
|
||||
status: StatusValue;
|
||||
status: "online" | "offline";
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
@@ -14,44 +13,26 @@ type Listener = () => void;
|
||||
*/
|
||||
export class ServerStatusStore {
|
||||
private statuses = new Map<number, ServerStatusEntry>();
|
||||
private enabledHostIds = new Set<number>();
|
||||
private initialLoadComplete = false;
|
||||
private isLoading = false;
|
||||
private readonly hostListeners = new Map<number, Set<Listener>>();
|
||||
private readonly allListeners = new Set<Listener>();
|
||||
private readonly metaListeners = new Set<Listener>();
|
||||
|
||||
/** "unknown" until the host has been checked. */
|
||||
getStatus(hostId: number): StatusValue {
|
||||
if (!this.enabledHostIds.has(hostId)) {
|
||||
return "offline";
|
||||
}
|
||||
return this.statuses.get(hostId)?.status || "degraded";
|
||||
return this.statuses.get(hostId)?.status ?? "unknown";
|
||||
}
|
||||
|
||||
getStatuses(): Map<number, ServerStatusEntry> {
|
||||
return this.statuses;
|
||||
}
|
||||
|
||||
getEnabledHostIds(): Set<number> {
|
||||
return this.enabledHostIds;
|
||||
}
|
||||
|
||||
getInitialLoadComplete(): boolean {
|
||||
return this.initialLoadComplete;
|
||||
}
|
||||
|
||||
getIsLoading(): boolean {
|
||||
return this.isLoading;
|
||||
}
|
||||
|
||||
/** Snapshot string for a host — used as useSyncExternalStore getSnapshot. */
|
||||
getHostSnapshot(hostId: number): string {
|
||||
const status = this.getStatus(hostId);
|
||||
return `${status}:${this.statuses.get(hostId)?.reason ?? ""}`;
|
||||
}
|
||||
|
||||
getMetaSnapshot(): string {
|
||||
return `${this.initialLoadComplete ? 1 : 0}:${this.isLoading ? 1 : 0}:${this.enabledHostIds.size}`;
|
||||
return this.initialLoadComplete ? "1" : "0";
|
||||
}
|
||||
|
||||
subscribeHost(hostId: number, listener: Listener): () => void {
|
||||
@@ -83,28 +64,6 @@ export class ServerStatusStore {
|
||||
};
|
||||
}
|
||||
|
||||
setEnabledHostIds(next: Set<number>): void {
|
||||
if (setsEqual(this.enabledHostIds, next)) return;
|
||||
const prev = this.enabledHostIds;
|
||||
this.enabledHostIds = next;
|
||||
this.emitMeta();
|
||||
// Hosts that entered/left the enabled set need a status re-read.
|
||||
for (const id of next) {
|
||||
if (!prev.has(id)) this.emitHost(id);
|
||||
}
|
||||
for (const id of prev) {
|
||||
if (!next.has(id)) this.emitHost(id);
|
||||
}
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
setLoading(loading: boolean): void {
|
||||
if (this.isLoading === loading) return;
|
||||
this.isLoading = loading;
|
||||
this.emitMeta();
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
setInitialLoadComplete(complete: boolean): void {
|
||||
if (this.initialLoadComplete === complete) return;
|
||||
this.initialLoadComplete = complete;
|
||||
@@ -120,50 +79,24 @@ export class ServerStatusStore {
|
||||
const changedIds: number[] = [];
|
||||
|
||||
for (const [id, entry] of next) {
|
||||
const prev = this.statuses.get(id);
|
||||
if (
|
||||
!prev ||
|
||||
prev.status !== entry.status ||
|
||||
prev.reason !== entry.reason
|
||||
) {
|
||||
changedIds.push(id);
|
||||
}
|
||||
if (this.statuses.get(id)?.status !== entry.status) changedIds.push(id);
|
||||
}
|
||||
for (const id of this.statuses.keys()) {
|
||||
if (!next.has(id)) {
|
||||
changedIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (changedIds.length === 0) {
|
||||
// Still refresh lastChecked silently without notifying.
|
||||
this.statuses = next;
|
||||
return;
|
||||
if (!next.has(id)) changedIds.push(id);
|
||||
}
|
||||
|
||||
this.statuses = next;
|
||||
if (changedIds.length === 0) return;
|
||||
for (const id of changedIds) {
|
||||
this.emitHost(id);
|
||||
}
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
markDegraded(enabledIds: Iterable<number>): void {
|
||||
const next = new Map(this.statuses);
|
||||
let changed = false;
|
||||
const now = new Date().toISOString();
|
||||
for (const id of enabledIds) {
|
||||
const existing = next.get(id);
|
||||
if (existing?.status === "degraded") continue;
|
||||
changed = true;
|
||||
next.set(id, {
|
||||
status: "degraded",
|
||||
lastChecked: existing?.lastChecked || now,
|
||||
});
|
||||
}
|
||||
if (changed) {
|
||||
this.applyStatuses(next);
|
||||
}
|
||||
/** Forgets everything, on sign out. */
|
||||
clear(): void {
|
||||
this.applyStatuses(new Map());
|
||||
this.setInitialLoadComplete(false);
|
||||
}
|
||||
|
||||
private emitHost(hostId: number): void {
|
||||
@@ -180,11 +113,3 @@ export class ServerStatusStore {
|
||||
for (const listener of this.metaListeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function setsEqual(a: Set<number>, b: Set<number>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const id of a) {
|
||||
if (!b.has(id)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+24
-9
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Update available. Open the release notes on GitHub"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Close",
|
||||
"minimize": "Minimize",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Sign in failed",
|
||||
"unknown": "Unknown",
|
||||
"continue": "Continue",
|
||||
"maintenance": "Maintenance",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Remove",
|
||||
"add": "Add",
|
||||
"update": "Update",
|
||||
"noResults": "No results found"
|
||||
"noResults": "No results found",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Step {{current}} of {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Host Metrics",
|
||||
"status": {
|
||||
"available": "Available",
|
||||
"reachable": "Up, but the last sign in failed",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitoring disabled",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "This host (direct tunnel)",
|
||||
"statusChecksLabel": "Status Checks",
|
||||
"enableStatusChecks": "Enable Status Checks",
|
||||
"enableStatusChecksDesc": "Periodically ping this host to verify availability",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Use Global Interval",
|
||||
"useGlobalIntervalDesc": "Override with the server-wide status check interval",
|
||||
"checkIntervalS": "Check Interval (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
||||
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin.",
|
||||
"serverAuthentication": "Server sign-in",
|
||||
"minChars": "min. {{min}} characters"
|
||||
"minChars": "min. {{min}} characters",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verify SSH Host Key",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Language",
|
||||
"keyPassword": "key password",
|
||||
"pastePrivateKey": "Paste your private key here...",
|
||||
"enterPassword": "Enter your password"
|
||||
"enterPassword": "Enter your password",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Issued by {{issuer}}",
|
||||
"sslNames": "Covers {{names}}",
|
||||
"sslManualTitle": "Upload a certificate",
|
||||
"updateExternalAutoProvisionFailed": "Failed to update the auto-create setting"
|
||||
"updateExternalAutoProvisionFailed": "Failed to update the auto-create setting",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Opdatering beskikbaar. Maak die vrystellingsnotas op GitHub oop."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Maak toe",
|
||||
"minimize": "Minimaliseer",
|
||||
"online": "Aanlyn",
|
||||
"offline": "Vanlyn",
|
||||
"signInFailed": "Aanmelding het misluk",
|
||||
"unknown": "Onbekend",
|
||||
"continue": "Gaan voort",
|
||||
"maintenance": "Onderhoud",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Verwyder",
|
||||
"add": "Voeg by",
|
||||
"update": "Opdatering",
|
||||
"noResults": "Geen resultate gevind nie"
|
||||
"noResults": "Geen resultate gevind nie",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Stap {{current}} van {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tonnel",
|
||||
"serverStats": "Gasheermetrieke",
|
||||
"status": {
|
||||
"available": "Beskikbaar",
|
||||
"reachable": "Aan, maar die laaste aanmelding het misluk",
|
||||
"online": "Online",
|
||||
"offline": "Vanlyn",
|
||||
"monitoringDisabled": "Monitering gedeaktiveer",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Lêer \"{{oldName}}\" is suksesvol hernoem na \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Hierdie gasheer (direkte tonnel)",
|
||||
"statusChecksLabel": "Statuskontroles",
|
||||
"enableStatusChecks": "Aktiveer statuskontroles",
|
||||
"enableStatusChecksDesc": "Ping hierdie gasheer gereeld om beskikbaarheid te verifieer",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Gebruik globale interval",
|
||||
"useGlobalIntervalDesc": "Oorskryf met die bedienerwye statuskontrole-interval",
|
||||
"checkIntervalS": "Kontrole-interval (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Jou rekening gebruik 'n tweede faktor wat tans nie beskikbaar is nie. Kontak 'n administrateur om dit terug te stel.",
|
||||
"secondFactorNoUI": "Hierdie tweede faktor kan nie op hierdie skerm gewys word nie. Kontak 'n administrateur.",
|
||||
"serverAuthentication": "Bediener-aanmelding",
|
||||
"minChars": "min. {{min}} karakters"
|
||||
"minChars": "min. {{min}} karakters",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verifieer SSH-gasheersleutel",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Taal",
|
||||
"keyPassword": "sleutelwagwoord",
|
||||
"pastePrivateKey": "Plak jou privaat sleutel hier...",
|
||||
"enterPassword": "Voer jou wagwoord in"
|
||||
"enterPassword": "Voer jou wagwoord in",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Uitgereik deur {{issuer}}",
|
||||
"sslNames": "Omslae {{names}}",
|
||||
"sslManualTitle": "Laai 'n sertifikaat op",
|
||||
"updateExternalAutoProvisionFailed": "Kon nie die outomatiese skep-instelling opdateer nie"
|
||||
"updateExternalAutoProvisionFailed": "Kon nie die outomatiese skep-instelling opdateer nie",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "يتوفر تحديث. اطلع على ملاحظات الإصدار على GitHub"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "إغلاق",
|
||||
"minimize": "تصغير",
|
||||
"online": "متصل",
|
||||
"offline": "غير متصل",
|
||||
"signInFailed": "فشل تسجيل الدخول",
|
||||
"unknown": "غير معروف",
|
||||
"continue": "متابعة",
|
||||
"maintenance": "صيانة",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "إزالة",
|
||||
"add": "إضافة",
|
||||
"update": "تحديث",
|
||||
"noResults": "لم يتم العثور على نتائج"
|
||||
"noResults": "لم يتم العثور على نتائج",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "الخطوة {{current}} من {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "النفق",
|
||||
"serverStats": "مقاييس المضيف",
|
||||
"status": {
|
||||
"available": "متاح",
|
||||
"reachable": "تم تسجيل الدخول، لكن آخر تسجيل دخول فشل",
|
||||
"online": "Online",
|
||||
"offline": "غير متصل بالإنترنت",
|
||||
"monitoringDisabled": "تم تعطيل المراقبة",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "حالة"
|
||||
},
|
||||
"folderRenamed": "تمت إعادة تسمية المجلد \"{{oldName}}\" إلى \"{{newName}}\" بنجاح",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "هذا المضيف (نفق مباشر)",
|
||||
"statusChecksLabel": "فحوصات الحالة",
|
||||
"enableStatusChecks": "تمكين فحوصات الحالة",
|
||||
"enableStatusChecksDesc": "إرسال ping دوريًا لهذا المضيف للتحقق من توفره",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "استخدام الفاصل الزمني العام",
|
||||
"useGlobalIntervalDesc": "تجاوز باستخدام فترة فحص الحالة على مستوى الخادم",
|
||||
"checkIntervalS": "فترة الفحص (ثانية)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "يستخدم حسابك عامل مصادقة ثانٍ غير متوفر حاليًا. تواصل مع أحد المسؤولين لإعادة تعيينه.",
|
||||
"secondFactorNoUI": "لا يمكن عرض هذا العامل الثاني على هذه الشاشة. تواصل مع أحد المسؤولين.",
|
||||
"serverAuthentication": "تسجيل الدخول إلى الخادم",
|
||||
"minChars": "الحد الأدنى {{min}} حرفًا"
|
||||
"minChars": "الحد الأدنى {{min}} حرفًا",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "تحقق من مفتاح مضيف SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "اللغة",
|
||||
"keyPassword": "كلمة مرور المفتاح",
|
||||
"pastePrivateKey": "الصق مفتاحك الخاص هنا...",
|
||||
"enterPassword": "أدخل كلمة المرور"
|
||||
"enterPassword": "أدخل كلمة المرور",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "لوحة التحكم",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "صادر عن {{issuer}}",
|
||||
"sslNames": "يغطي {{names}}",
|
||||
"sslManualTitle": "قم بتحميل شهادة",
|
||||
"updateExternalAutoProvisionFailed": "فشل تحديث إعداد الإنشاء التلقائي"
|
||||
"updateExternalAutoProvisionFailed": "فشل تحديث إعداد الإنشاء التلقائي",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Налична е актуализация. Отворете бележките за изданието в GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Затвори",
|
||||
"minimize": "Минимизиране",
|
||||
"online": "Онлайн",
|
||||
"offline": "Офлайн",
|
||||
"signInFailed": "Влизането не бе успешно",
|
||||
"unknown": "Неизвестен",
|
||||
"continue": "Продължи",
|
||||
"maintenance": "Поддръжка",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Премахване",
|
||||
"add": "Добавяне",
|
||||
"update": "Актуализация",
|
||||
"noResults": "Няма намерени резултати"
|
||||
"noResults": "Няма намерени резултати",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Стъпка {{current}} от {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Тунел",
|
||||
"serverStats": "Метрики на хоста",
|
||||
"status": {
|
||||
"available": "Налично",
|
||||
"reachable": "Включено, но последното влизане не беше успешно",
|
||||
"online": "Online",
|
||||
"offline": "Офлайн",
|
||||
"monitoringDisabled": "Мониторингът е деактивиран",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Статус"
|
||||
},
|
||||
"folderRenamed": "Папка „{{oldName}}“ е преименувана успешно на „{{newName}}“",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Този хост (директен тунел)",
|
||||
"statusChecksLabel": "Проверки на състоянието",
|
||||
"enableStatusChecks": "Активиране на проверки на състоянието",
|
||||
"enableStatusChecksDesc": "Периодично проверявайте наличността на този хост чрез ping.",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Използване на глобален интервал",
|
||||
"useGlobalIntervalDesc": "Замяна с интервала за проверка на състоянието на целия сървър",
|
||||
"checkIntervalS": "Интервал(и) на проверка",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Вашият акаунт използва втори фактор, който не е наличен в момента. Свържете се с администратор, за да го нулира.",
|
||||
"secondFactorNoUI": "Този втори фактор не може да бъде показан на този екран. Свържете се с администратор.",
|
||||
"serverAuthentication": "Влизане в сървъра",
|
||||
"minChars": "мин. {{min}} символа"
|
||||
"minChars": "мин. {{min}} символа",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Проверете SSH хост ключа",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Език",
|
||||
"keyPassword": "ключова парола",
|
||||
"pastePrivateKey": "Поставете личния си ключ тук...",
|
||||
"enterPassword": "Въведете паролата си"
|
||||
"enterPassword": "Въведете паролата си",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Табло за управление",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Издадено от {{issuer}}",
|
||||
"sslNames": "Обхваща {{names}}",
|
||||
"sslManualTitle": "Качване на сертификат",
|
||||
"updateExternalAutoProvisionFailed": "Актуализирането на настройката за автоматично създаване не бе успешно"
|
||||
"updateExternalAutoProvisionFailed": "Актуализирането на настройката за автоматично създаване не бе успешно",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "আপডেট উপলব্ধ। গিটহাবে রিলিজ নোট খুলুন।"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "বন্ধ করুন",
|
||||
"minimize": "ন্যূনতম করুন",
|
||||
"online": "অনলাইন",
|
||||
"offline": "অফলাইন",
|
||||
"signInFailed": "সাইন ইন ব্যর্থ হয়েছে",
|
||||
"unknown": "অজানা",
|
||||
"continue": "চালিয়ে যান",
|
||||
"maintenance": "রক্ষণাবেক্ষণ",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "অপসারণ করুন",
|
||||
"add": "যোগ করুন",
|
||||
"update": "আপডেট",
|
||||
"noResults": "কোনো ফলাফল পাওয়া যায়নি"
|
||||
"noResults": "কোনো ফলাফল পাওয়া যায়নি",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "{{total}} এর {{current}} ধাপ",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "টানেল",
|
||||
"serverStats": "হোস্ট মেট্রিক্স",
|
||||
"status": {
|
||||
"available": "উপলব্ধ",
|
||||
"reachable": "চালু হয়েছে, কিন্তু শেষ সাইন-ইনটি ব্যর্থ হয়েছে।",
|
||||
"online": "Online",
|
||||
"offline": "অফলাইন",
|
||||
"monitoringDisabled": "পর্যবেক্ষণ অক্ষম করা হয়েছে",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "অবস্থা"
|
||||
},
|
||||
"folderRenamed": "\"{{oldName}}\" ফোল্ডারটির নাম সফলভাবে \"{{newName}}\" রাখা হয়েছে।",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "এই হোস্ট (সরাসরি টানেল)",
|
||||
"statusChecksLabel": "অবস্থা যাচাই",
|
||||
"enableStatusChecks": "স্ট্যাটাস চেক সক্ষম করুন",
|
||||
"enableStatusChecksDesc": "প্রাপ্যতা যাচাই করতে পর্যায়ক্রমে এই হোস্টটিকে পিং করুন।",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "গ্লোবাল ইন্টারভাল ব্যবহার করুন",
|
||||
"useGlobalIntervalDesc": "সার্ভার-ব্যাপী স্ট্যাটাস চেক ব্যবধান দিয়ে ওভাররাইড করুন",
|
||||
"checkIntervalS": "চেক ব্যবধান (গুলি)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "আপনার অ্যাকাউন্টে একটি দ্বিতীয় ফ্যাক্টর ব্যবহৃত হয় যা এই মুহূর্তে উপলব্ধ নেই। এটি রিসেট করতে একজন অ্যাডমিনের সাথে যোগাযোগ করুন।",
|
||||
"secondFactorNoUI": "এই দ্বিতীয় ফ্যাক্টরটি এই স্ক্রিনে দেখানো যাবে না। একজন অ্যাডমিনের সাথে যোগাযোগ করুন।",
|
||||
"serverAuthentication": "সার্ভার সাইন-ইন",
|
||||
"minChars": "সর্বনিম্ন {{min}} অক্ষর"
|
||||
"minChars": "সর্বনিম্ন {{min}} অক্ষর",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "SSH হোস্ট কী যাচাই করুন",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "ভাষা",
|
||||
"keyPassword": "কী পাসওয়ার্ড",
|
||||
"pastePrivateKey": "আপনার প্রাইভেট কী এখানে পেস্ট করুন...",
|
||||
"enterPassword": "আপনার পাসওয়ার্ড লিখুন"
|
||||
"enterPassword": "আপনার পাসওয়ার্ড লিখুন",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "ড্যাশবোর্ড",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "{{issuer}} দ্বারা জারি করা হয়েছে",
|
||||
"sslNames": "{{names}} কভার করে",
|
||||
"sslManualTitle": "একটি সার্টিফিকেট আপলোড করুন",
|
||||
"updateExternalAutoProvisionFailed": "স্বয়ংক্রিয়-সৃষ্টি সেটিং আপডেট করতে ব্যর্থ হয়েছে"
|
||||
"updateExternalAutoProvisionFailed": "স্বয়ংক্রিয়-সৃষ্টি সেটিং আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Actualització disponible. Obriu les notes de la versió a GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Tanca",
|
||||
"minimize": "Minimitzar",
|
||||
"online": "En línia",
|
||||
"offline": "Fora de línia",
|
||||
"signInFailed": "No s'ha pogut iniciar la sessió",
|
||||
"unknown": "Desconegut",
|
||||
"continue": "Continua",
|
||||
"maintenance": "Manteniment",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Elimina",
|
||||
"add": "Afegeix",
|
||||
"update": "Actualització",
|
||||
"noResults": "No s'han trobat resultats"
|
||||
"noResults": "No s'han trobat resultats",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Pas {{current}} de {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Túnel",
|
||||
"serverStats": "Mètriques de l'amfitrió",
|
||||
"status": {
|
||||
"available": "Disponible",
|
||||
"reachable": "Activat, però l'últim inici de sessió ha fallat",
|
||||
"online": "Online",
|
||||
"offline": "Fora de línia",
|
||||
"monitoringDisabled": "Monitorització desactivada",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Estat"
|
||||
},
|
||||
"folderRenamed": "La carpeta \"{{oldName}}\" s'ha canviat de nom a \"{{newName}}\" correctament",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Aquest host (túnel directe)",
|
||||
"statusChecksLabel": "Comprovacions d'estat",
|
||||
"enableStatusChecks": "Activa les comprovacions d'estat",
|
||||
"enableStatusChecksDesc": "Fes ping periòdicament a aquest host per verificar la disponibilitat",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Utilitza l'interval global",
|
||||
"useGlobalIntervalDesc": "Substitueix amb l'interval de comprovació d'estat de tot el servidor",
|
||||
"checkIntervalS": "Interval (s) de comprovació",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "El teu compte utilitza un segon factor que no està disponible ara mateix. Contacta amb un administrador per restablir-lo.",
|
||||
"secondFactorNoUI": "Aquest segon factor no es pot mostrar en aquesta pantalla. Contacta amb un administrador.",
|
||||
"serverAuthentication": "Inici de sessió al servidor",
|
||||
"minChars": "mínim {{min}} caràcters"
|
||||
"minChars": "mínim {{min}} caràcters",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verifica la clau d'amfitrió SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Llengua",
|
||||
"keyPassword": "contrasenya clau",
|
||||
"pastePrivateKey": "Enganxa la teva clau privada aquí...",
|
||||
"enterPassword": "Introdueix la teva contrasenya"
|
||||
"enterPassword": "Introdueix la teva contrasenya",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tauler de control",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Publicat per {{issuer}}",
|
||||
"sslNames": "Cobertes {{names}}",
|
||||
"sslManualTitle": "Puja un certificat",
|
||||
"updateExternalAutoProvisionFailed": "No s'ha pogut actualitzar la configuració de creació automàtica"
|
||||
"updateExternalAutoProvisionFailed": "No s'ha pogut actualitzar la configuració de creació automàtica",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Aktualizace je k dispozici. Otevřít poznámky k verzi na GitHubu."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Blízko",
|
||||
"minimize": "Minimalizovat",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Přihlášení se nezdařilo",
|
||||
"unknown": "Neznámý",
|
||||
"continue": "Pokračovat",
|
||||
"maintenance": "Údržba",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Odstranit",
|
||||
"add": "Přidat",
|
||||
"update": "Aktualizovat",
|
||||
"noResults": "Nenalezeny žádné výsledky"
|
||||
"noResults": "Nenalezeny žádné výsledky",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Krok {{current}} z {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunel",
|
||||
"serverStats": "Metriky hostitele",
|
||||
"status": {
|
||||
"available": "K dispozici",
|
||||
"reachable": "Nahoře, ale poslední přihlášení se nezdařilo",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitorování zakázáno",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Postavení"
|
||||
},
|
||||
"folderRenamed": "Složka „{{oldName}}“ byla úspěšně přejmenována na „{{newName}}“",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Tento hostitel (přímý tunel)",
|
||||
"statusChecksLabel": "Kontroly stavu",
|
||||
"enableStatusChecks": "Povolit kontroly stavu",
|
||||
"enableStatusChecksDesc": "Pravidelně pingovat tento hostitel pro ověření dostupnosti",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Použít globální interval",
|
||||
"useGlobalIntervalDesc": "Přepsat intervalem kontroly stavu na úrovni celého serveru",
|
||||
"checkIntervalS": "Kontrolní interval(y)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Váš účet používá druhý faktor, který momentálně není k dispozici. Pro jeho resetování kontaktujte administrátora.",
|
||||
"secondFactorNoUI": "Tento druhý faktor nelze na této obrazovce zobrazit. Kontaktujte administrátora.",
|
||||
"serverAuthentication": "Přihlášení k serveru",
|
||||
"minChars": "min. {{min}} znaků"
|
||||
"minChars": "min. {{min}} znaků",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Ověření klíče hostitele SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Jazyk",
|
||||
"keyPassword": "klíčové heslo",
|
||||
"pastePrivateKey": "Vložte sem svůj soukromý klíč...",
|
||||
"enterPassword": "Zadejte své heslo"
|
||||
"enterPassword": "Zadejte své heslo",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Vydáno uživatelem {{issuer}}",
|
||||
"sslNames": "Kryty {{names}}",
|
||||
"sslManualTitle": "Nahrát certifikát",
|
||||
"updateExternalAutoProvisionFailed": "Nepodařilo se aktualizovat nastavení automatického vytváření"
|
||||
"updateExternalAutoProvisionFailed": "Nepodařilo se aktualizovat nastavení automatického vytváření",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Opdatering tilgængelig. Åbn udgivelsesnoterne på GitHub"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Tæt",
|
||||
"minimize": "Minimer",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Log ind mislykkedes",
|
||||
"unknown": "Ukendt",
|
||||
"continue": "Fortsætte",
|
||||
"maintenance": "Opretholdelse",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Fjerne",
|
||||
"add": "Tilføje",
|
||||
"update": "Opdatering",
|
||||
"noResults": "Ingen resultater fundet"
|
||||
"noResults": "Ingen resultater fundet",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Trin {{current}} af {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Værtsmålinger",
|
||||
"status": {
|
||||
"available": "Tilgængelig",
|
||||
"reachable": "Oppe, men den sidste login mislykkedes",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Overvågning deaktiveret",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Mappen \"{{oldName}}\" er omdøbt til \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Denne vært (direkte tunnel)",
|
||||
"statusChecksLabel": "Statuskontroller",
|
||||
"enableStatusChecks": "Aktivér statuskontroller",
|
||||
"enableStatusChecksDesc": "Ping denne vært regelmæssigt for at bekræfte tilgængelighed",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Brug globalt interval",
|
||||
"useGlobalIntervalDesc": "Tilsidesæt med det serveromfattende statuskontrolinterval",
|
||||
"checkIntervalS": "Kontrolinterval (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Din konto bruger en anden faktor, der ikke er tilgængelig lige nu. Kontakt en administrator for at nulstille den.",
|
||||
"secondFactorNoUI": "Denne anden faktor kan ikke vises på denne skærm. Kontakt en administrator.",
|
||||
"serverAuthentication": "Serverlogin",
|
||||
"minChars": "min. {{min}} tegn"
|
||||
"minChars": "min. {{min}} tegn",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Bekræft SSH-værtsnøgle",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Sprog",
|
||||
"keyPassword": "nøgleadgangskode",
|
||||
"pastePrivateKey": "Indsæt din private nøgle her...",
|
||||
"enterPassword": "Indtast din adgangskode"
|
||||
"enterPassword": "Indtast din adgangskode",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Udstedt af {{issuer}}",
|
||||
"sslNames": "Dækker {{names}}",
|
||||
"sslManualTitle": "Upload et certifikat",
|
||||
"updateExternalAutoProvisionFailed": "Kunne ikke opdatere indstillingen for automatisk oprettelse"
|
||||
"updateExternalAutoProvisionFailed": "Kunne ikke opdatere indstillingen for automatisk oprettelse",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Update verfügbar. Die Versionshinweise finden Sie auf GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Schließen",
|
||||
"minimize": "Minimieren",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Anmeldung fehlgeschlagen",
|
||||
"unknown": "Unbekannt",
|
||||
"continue": "Weiter",
|
||||
"maintenance": "Wartung",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Entfernen",
|
||||
"add": "Hinzufügen",
|
||||
"update": "Aktualisieren",
|
||||
"noResults": "Keine Ergebnisse gefunden"
|
||||
"noResults": "Keine Ergebnisse gefunden",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Schritt {{current}} von {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Host-Metriken",
|
||||
"status": {
|
||||
"available": "Verfügbar",
|
||||
"reachable": "Oben angekommen, aber die letzte Anmeldung schlug fehl.",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Überwachung deaktiviert",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Ordner \"{{oldName}}\" erfolgreich in \"{{newName}}\" umbenannt",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Dieser Host (direkter Tunnel)",
|
||||
"statusChecksLabel": "Statusprüfungen",
|
||||
"enableStatusChecks": "Statusprüfungen aktivieren",
|
||||
"enableStatusChecksDesc": "Diesen Host regelmäßig anpingen, um die Verfügbarkeit zu überprüfen",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Globales Intervall verwenden",
|
||||
"useGlobalIntervalDesc": "Mit dem serverweiten Statusprüfungsintervall überschreiben",
|
||||
"checkIntervalS": "Prüfintervall (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Ihr Konto verwendet einen zweiten Faktor, der momentan nicht verfügbar ist. Wenden Sie sich an einen Administrator, um ihn zurückzusetzen.",
|
||||
"secondFactorNoUI": "Dieser zweite Faktor kann auf diesem Bildschirm nicht angezeigt werden. Wenden Sie sich an einen Administrator.",
|
||||
"serverAuthentication": "Serveranmeldung",
|
||||
"minChars": "min. {{min}} Zeichen"
|
||||
"minChars": "min. {{min}} Zeichen",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "SSH-Host-Key überprüfen",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Sprache",
|
||||
"keyPassword": "Schlüsselpasswort",
|
||||
"pastePrivateKey": "Privaten Schlüssel hier einfügen...",
|
||||
"enterPassword": "Passwort eingeben"
|
||||
"enterPassword": "Passwort eingeben",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Herausgegeben von {{issuer}}",
|
||||
"sslNames": "Cover {{names}}",
|
||||
"sslManualTitle": "Laden Sie ein Zertifikat hoch",
|
||||
"updateExternalAutoProvisionFailed": "Die Aktualisierung der Einstellung für die automatische Erstellung ist fehlgeschlagen."
|
||||
"updateExternalAutoProvisionFailed": "Die Aktualisierung der Einstellung für die automatische Erstellung ist fehlgeschlagen.",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Διαθέσιμη ενημέρωση. Ανοίξτε τις σημειώσεις έκδοσης στο GitHub"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Κοντά",
|
||||
"minimize": "Ελαττώνω",
|
||||
"online": "Διαδικτυακά",
|
||||
"offline": "Εκτός σύνδεσης",
|
||||
"signInFailed": "Η σύνδεση απέτυχε",
|
||||
"unknown": "Αγνωστος",
|
||||
"continue": "Συνεχίζω",
|
||||
"maintenance": "Συντήρηση",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Αφαιρώ",
|
||||
"add": "Προσθέτω",
|
||||
"update": "Εκσυγχρονίζω",
|
||||
"noResults": "Δεν βρέθηκαν αποτελέσματα"
|
||||
"noResults": "Δεν βρέθηκαν αποτελέσματα",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Βήμα {{current}} από {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Σήραγγα",
|
||||
"serverStats": "Μετρήσεις κεντρικού υπολογιστή",
|
||||
"status": {
|
||||
"available": "Διαθέσιμος",
|
||||
"reachable": "Ενεργοποιημένο, αλλά η τελευταία σύνδεση απέτυχε",
|
||||
"online": "Online",
|
||||
"offline": "Εκτός σύνδεσης",
|
||||
"monitoringDisabled": "Η παρακολούθηση απενεργοποιήθηκε",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Κατάσταση"
|
||||
},
|
||||
"folderRenamed": "Ο φάκελος \"{{oldName}}\" μετονομάστηκε με επιτυχία σε \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Αυτός ο κεντρικός υπολογιστής (άμεση σήραγγα)",
|
||||
"statusChecksLabel": "Έλεγχοι κατάστασης",
|
||||
"enableStatusChecks": "Ενεργοποίηση ελέγχων κατάστασης",
|
||||
"enableStatusChecksDesc": "Περιοδική ping σε αυτόν τον κεντρικό υπολογιστή για επαλήθευση διαθεσιμότητας",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Χρήση καθολικού διαστήματος",
|
||||
"useGlobalIntervalDesc": "Παράκαμψη με το διάστημα ελέγχου κατάστασης σε ολόκληρο τον διακομιστή",
|
||||
"checkIntervalS": "Διάστημα ελέγχου (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Ο λογαριασμός σας χρησιμοποιεί έναν δεύτερο παράγοντα που δεν είναι διαθέσιμος αυτήν τη στιγμή. Επικοινωνήστε με έναν διαχειριστή για να τον επαναφέρετε.",
|
||||
"secondFactorNoUI": "Αυτός ο δεύτερος παράγοντας δεν μπορεί να εμφανιστεί σε αυτήν την οθόνη. Επικοινωνήστε με έναν διαχειριστή.",
|
||||
"serverAuthentication": "Σύνδεση διακομιστή",
|
||||
"minChars": "ελάχ. {{min}} χαρακτήρες"
|
||||
"minChars": "ελάχ. {{min}} χαρακτήρες",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Επαλήθευση κλειδιού κεντρικού υπολογιστή SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Γλώσσα",
|
||||
"keyPassword": "κωδικός πρόσβασης κλειδιού",
|
||||
"pastePrivateKey": "Επικολλήστε το ιδιωτικό σας κλειδί εδώ...",
|
||||
"enterPassword": "Εισαγάγετε τον κωδικό πρόσβασής σας"
|
||||
"enterPassword": "Εισαγάγετε τον κωδικό πρόσβασής σας",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ταμπλό",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Εκδόθηκε από {{issuer}}",
|
||||
"sslNames": "Καλύμματα {{names}}",
|
||||
"sslManualTitle": "Μεταφόρτωση πιστοποιητικού",
|
||||
"updateExternalAutoProvisionFailed": "Η ενημέρωση της ρύθμισης αυτόματης δημιουργίας απέτυχε"
|
||||
"updateExternalAutoProvisionFailed": "Η ενημέρωση της ρύθμισης αυτόματης δημιουργίας απέτυχε",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Actualización disponible. Consulta las notas de la versión en GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Cerrar",
|
||||
"minimize": "Minimizar",
|
||||
"online": "En línea",
|
||||
"offline": "Sin conexión",
|
||||
"signInFailed": "Error al iniciar sesión",
|
||||
"unknown": "Desconocido",
|
||||
"continue": "Continuar",
|
||||
"maintenance": "Mantenimiento",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Quitar",
|
||||
"add": "Añadir",
|
||||
"update": "Actualizar",
|
||||
"noResults": "No se encontraron resultados"
|
||||
"noResults": "No se encontraron resultados",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Paso {{current}} de {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Túnel",
|
||||
"serverStats": "Métricas del host",
|
||||
"status": {
|
||||
"available": "Disponible",
|
||||
"reachable": "Arriba, pero el último cartel falló.",
|
||||
"online": "Online",
|
||||
"offline": "Desconectado",
|
||||
"monitoringDisabled": "Monitoreo desactivado",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Estado"
|
||||
},
|
||||
"folderRenamed": "Carpeta \"{{oldName}}\" renombrada a \"{{newName}}\" correctamente",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Este host (túnel directo)",
|
||||
"statusChecksLabel": "Comprobaciones de estado",
|
||||
"enableStatusChecks": "Habilitar comprobaciones de estado",
|
||||
"enableStatusChecksDesc": "Hacer ping periódicamente a este host para verificar su disponibilidad.",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Usar intervalo global",
|
||||
"useGlobalIntervalDesc": "Sobrescribir con el intervalo de comprobación de estado global del servidor.",
|
||||
"checkIntervalS": "Intervalo de comprobación (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Tu cuenta utiliza un segundo factor de autenticación que no está disponible en este momento. Ponte en contacto con un administrador para restablecerlo.",
|
||||
"secondFactorNoUI": "Este segundo factor no se puede mostrar en esta pantalla. Contacta con un administrador.",
|
||||
"serverAuthentication": "Inicio de sesión en el servidor",
|
||||
"minChars": "min. {{min}} caracteres"
|
||||
"minChars": "min. {{min}} caracteres",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verificar clave de host SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Idioma",
|
||||
"keyPassword": "contraseña de la clave",
|
||||
"pastePrivateKey": "Pegue su clave privada aquí...",
|
||||
"enterPassword": "Introduce tu contraseña"
|
||||
"enterPassword": "Introduce tu contraseña",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Panel de control",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Emitido por {{issuer}}",
|
||||
"sslNames": "Portadas {{names}}",
|
||||
"sslManualTitle": "Subir un certificado",
|
||||
"updateExternalAutoProvisionFailed": "No se pudo actualizar la configuración de creación automática."
|
||||
"updateExternalAutoProvisionFailed": "No se pudo actualizar la configuración de creación automática.",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Päivitys saatavilla. Avaa julkaisutiedot GitHubissa."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Lähellä",
|
||||
"minimize": "Minimoida",
|
||||
"online": "Verkossa",
|
||||
"offline": "Offline-tilassa",
|
||||
"signInFailed": "Kirjautuminen epäonnistui",
|
||||
"unknown": "Tuntematon",
|
||||
"continue": "Jatkaa",
|
||||
"maintenance": "Huolto",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Poistaa",
|
||||
"add": "Lisätä",
|
||||
"update": "Päivittää",
|
||||
"noResults": "Ei tuloksia"
|
||||
"noResults": "Ei tuloksia",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Vaihe {{current}} / {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunneli",
|
||||
"serverStats": "Isännän mittarit",
|
||||
"status": {
|
||||
"available": "Saatavilla",
|
||||
"reachable": "Ylös, mutta viimeisin kirjautuminen epäonnistui",
|
||||
"online": "Online",
|
||||
"offline": "Offline-tilassa",
|
||||
"monitoringDisabled": "Valvonta poistettu käytöstä",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Kansio \"{{oldName}}\" nimettiin uudelleen onnistuneesti muotoon \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Tämä isäntä (suora tunneli)",
|
||||
"statusChecksLabel": "Tilatarkastukset",
|
||||
"enableStatusChecks": "Ota käyttöön tilan tarkistukset",
|
||||
"enableStatusChecksDesc": "Lähetä tälle isännälle säännöllisesti ping-viesti saatavuuden varmistamiseksi.",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Käytä globaalia väliä",
|
||||
"useGlobalIntervalDesc": "Ohita palvelimen laajuisella tilan tarkistusvälillä",
|
||||
"checkIntervalS": "Tarkistusväli (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Tilisi käyttää toista vahvistustekijää, joka ei ole tällä hetkellä käytettävissä. Ota yhteyttä ylläpitäjään sen palauttamiseksi.",
|
||||
"secondFactorNoUI": "Tätä toista tekijää ei voida näyttää tällä näytöllä. Ota yhteyttä ylläpitäjään.",
|
||||
"serverAuthentication": "Palvelimelle kirjautuminen",
|
||||
"minChars": "vähintään {{min}} merkkiä"
|
||||
"minChars": "vähintään {{min}} merkkiä",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Vahvista SSH-isäntäavain",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Kieli",
|
||||
"keyPassword": "avainsalasana",
|
||||
"pastePrivateKey": "Liitä yksityinen avaimesi tähän...",
|
||||
"enterPassword": "Anna salasanasi"
|
||||
"enterPassword": "Anna salasanasi",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Kojelauta",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Myöntäjä {{issuer}}",
|
||||
"sslNames": "Kannet {{names}}",
|
||||
"sslManualTitle": "Lähetä sertifikaatti",
|
||||
"updateExternalAutoProvisionFailed": "Automaattisen luomisen asetuksen päivittäminen epäonnistui"
|
||||
"updateExternalAutoProvisionFailed": "Automaattisen luomisen asetuksen päivittäminen epäonnistui",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Mise à jour disponible. Consultez les notes de version sur GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Fermer",
|
||||
"minimize": "Réduire",
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"signInFailed": "Échec de la connexion",
|
||||
"unknown": "Inconnu",
|
||||
"continue": "Continuer",
|
||||
"maintenance": "Maintenance",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Retirer",
|
||||
"add": "Ajouter",
|
||||
"update": "Mettre à jour",
|
||||
"noResults": "Aucun résultat trouvé"
|
||||
"noResults": "Aucun résultat trouvé",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Étape {{current}} de {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Métriques de l'hôte",
|
||||
"status": {
|
||||
"available": "Disponible",
|
||||
"reachable": "J'ai réussi, mais la dernière connexion a échoué.",
|
||||
"online": "Online",
|
||||
"offline": "Hors ligne",
|
||||
"monitoringDisabled": "Surveillance désactivée",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Statut"
|
||||
},
|
||||
"folderRenamed": "Dossier « {{oldName}} » renommé en « {{newName}} » avec succès",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Cet hôte (tunnel direct)",
|
||||
"statusChecksLabel": "Vérifications d'état",
|
||||
"enableStatusChecks": "Activer les vérifications d'état",
|
||||
"enableStatusChecksDesc": "Envoyer périodiquement un ping à cet hôte pour vérifier sa disponibilité",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Utiliser l'intervalle global",
|
||||
"useGlobalIntervalDesc": "Remplacer par l'intervalle de vérification d'état défini à l'échelle du serveur",
|
||||
"checkIntervalS": "Intervalle de vérification (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Votre compte utilise une authentification à deux facteurs actuellement indisponible. Veuillez contacter un administrateur pour la réinitialiser.",
|
||||
"secondFactorNoUI": "Ce deuxième facteur ne peut pas être affiché sur cet écran. Veuillez contacter un administrateur.",
|
||||
"serverAuthentication": "Connexion au serveur",
|
||||
"minChars": "min. {{min}} caractères"
|
||||
"minChars": "min. {{min}} caractères",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Vérifier la clé d'hôte SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Langue",
|
||||
"keyPassword": "mot de passe de la clé",
|
||||
"pastePrivateKey": "Collez votre clé privée ici...",
|
||||
"enterPassword": "Saisissez votre mot de passe"
|
||||
"enterPassword": "Saisissez votre mot de passe",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Émis par {{issuer}}",
|
||||
"sslNames": "Couvertures {{names}}",
|
||||
"sslManualTitle": "Téléverser un certificat",
|
||||
"updateExternalAutoProvisionFailed": "Échec de la mise à jour du paramètre de création automatique"
|
||||
"updateExternalAutoProvisionFailed": "Échec de la mise à jour du paramètre de création automatique",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "עדכון זמין. פתח את הערות הגרסה ב-GitHub"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "לִסְגוֹר",
|
||||
"minimize": "לְצַמְצֵם",
|
||||
"online": "באינטרנט",
|
||||
"offline": "לא מקוון",
|
||||
"signInFailed": "הכניסה נכשלה",
|
||||
"unknown": "לֹא יְדוּעַ",
|
||||
"continue": "לְהַמשִׁיך",
|
||||
"maintenance": "תַחזוּקָה",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "לְהַסִיר",
|
||||
"add": "לְהוֹסִיף",
|
||||
"update": "לְעַדְכֵּן",
|
||||
"noResults": "לא נמצאו תוצאות"
|
||||
"noResults": "לא נמצאו תוצאות",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "שלב {{current}} של {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "מִנהָרָה",
|
||||
"serverStats": "מדדי מארח",
|
||||
"status": {
|
||||
"available": "זָמִין",
|
||||
"reachable": "למעלה, אבל הכניסה האחרונה נכשלה",
|
||||
"online": "Online",
|
||||
"offline": "לא מקוון",
|
||||
"monitoringDisabled": "ניטור מושבת",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "סטָטוּס"
|
||||
},
|
||||
"folderRenamed": "תיקיית \"{{oldName}}\" שמה שונה בהצלחה ל- \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "מארח זה (מנהרה ישירה)",
|
||||
"statusChecksLabel": "בדיקות סטטוס",
|
||||
"enableStatusChecks": "הפעל בדיקות סטטוס",
|
||||
"enableStatusChecksDesc": "שלח פינג למארח הזה מעת לעת כדי לוודא זמינות",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "השתמש במרווח גלובלי",
|
||||
"useGlobalIntervalDesc": "עקיפה עם מרווח בדיקת המצב הכולל של השרת",
|
||||
"checkIntervalS": "מרווח זמן בדיקה (ים)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "החשבון שלך משתמש בגורם שני שאינו זמין כרגע. צור קשר עם מנהל כדי לאפס אותו.",
|
||||
"secondFactorNoUI": "לא ניתן להציג את הגורם השני במסך זה. צור קשר עם מנהל.",
|
||||
"serverAuthentication": "כניסה לשרת",
|
||||
"minChars": "מינימום {{min}} תווים"
|
||||
"minChars": "מינימום {{min}} תווים",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "אימות מפתח מארח SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "שָׂפָה",
|
||||
"keyPassword": "סיסמת מפתח",
|
||||
"pastePrivateKey": "הדבק את המפתח הפרטי שלך כאן...",
|
||||
"enterPassword": "הזן את הסיסמה שלך"
|
||||
"enterPassword": "הזן את הסיסמה שלך",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "לוּחַ מַחווָנִים",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "הונפק על ידי {{issuer}}",
|
||||
"sslNames": "כריכות {{names}}",
|
||||
"sslManualTitle": "העלה תעודה",
|
||||
"updateExternalAutoProvisionFailed": "נכשל עדכון הגדרת היצירה האוטומטית"
|
||||
"updateExternalAutoProvisionFailed": "נכשל עדכון הגדרת היצירה האוטומטית",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "अपडेट उपलब्ध है। GitHub पर रिलीज़ नोट्स देखें।"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "बंद करें",
|
||||
"minimize": "न्यूनतम करें",
|
||||
"online": "ऑनलाइन",
|
||||
"offline": "ऑफ़लाइन",
|
||||
"signInFailed": "भाग लेना विफल हुआ",
|
||||
"unknown": "अज्ञात",
|
||||
"continue": "जारी रखें",
|
||||
"maintenance": "रखरखाव",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "हटाएं",
|
||||
"add": "जोड़ें",
|
||||
"update": "अपडेट करें",
|
||||
"noResults": "कोई परिणाम नहीं मिला"
|
||||
"noResults": "कोई परिणाम नहीं मिला",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "चरण {{current}} का {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "टनल",
|
||||
"serverStats": "होस्ट मेट्रिक्स",
|
||||
"status": {
|
||||
"available": "उपलब्ध",
|
||||
"reachable": "ऊपर तो आ गया, लेकिन आखिरी बार साइन इन करने में विफल रहा।",
|
||||
"online": "Online",
|
||||
"offline": "ऑफलाइन",
|
||||
"monitoringDisabled": "निगरानी अक्षम है",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "स्थिति"
|
||||
},
|
||||
"folderRenamed": "फ़ोल्डर \"{{oldName}}\" का नाम बदलकर \"{{newName}}\" सफलतापूर्वक किया गया",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "यह होस्ट (डायरेक्ट टनल)",
|
||||
"statusChecksLabel": "स्थिति जाँच",
|
||||
"enableStatusChecks": "स्थिति जाँच सक्षम करें",
|
||||
"enableStatusChecksDesc": "उपलब्धता सत्यापित करने के लिए समय-समय पर इस होस्ट को पिंग करें",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "वैश्विक अंतराल का उपयोग करें",
|
||||
"useGlobalIntervalDesc": "सर्वर-व्यापी स्थिति जाँच अंतराल से ओवरराइड करें",
|
||||
"checkIntervalS": "जाँच अंतराल (सेकंड)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "आपके खाते में एक दूसरा फैक्टर फैक्टर इस्तेमाल होता है जो अभी उपलब्ध नहीं है। इसे रीसेट करने के लिए एडमिन से संपर्क करें।",
|
||||
"secondFactorNoUI": "यह दूसरा कारक इस स्क्रीन पर नहीं दिखाया जा सकता। कृपया व्यवस्थापक से संपर्क करें।",
|
||||
"serverAuthentication": "सर्वर साइन-इन",
|
||||
"minChars": "न्यूनतम {{min}} अक्षर"
|
||||
"minChars": "न्यूनतम {{min}} अक्षर",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "SSH होस्ट कुंजी सत्यापित करें",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "भाषा",
|
||||
"keyPassword": "कुंजी पासवर्ड",
|
||||
"pastePrivateKey": "अपनी निजी कुंजी यहाँ चिपकाएँ...",
|
||||
"enterPassword": "अपना पासवर्ड दर्ज करें"
|
||||
"enterPassword": "अपना पासवर्ड दर्ज करें",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "डैशबोर्ड",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "{{issuer}} द्वारा जारी किया गया",
|
||||
"sslNames": "कवर {{names}}",
|
||||
"sslManualTitle": "प्रमाणपत्र अपलोड करें",
|
||||
"updateExternalAutoProvisionFailed": "ऑटो-क्रिएट सेटिंग को अपडेट करने में विफलता"
|
||||
"updateExternalAutoProvisionFailed": "ऑटो-क्रिएट सेटिंग को अपडेट करने में विफलता",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Frissítés elérhető. Nyissa meg a kiadási megjegyzéseket a GitHubon."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Közeli",
|
||||
"minimize": "Minimalizálás",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Bejelentkezés sikertelen",
|
||||
"unknown": "Ismeretlen",
|
||||
"continue": "Folytatás",
|
||||
"maintenance": "Karbantartás",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Eltávolítás",
|
||||
"add": "Hozzáadás",
|
||||
"update": "Frissítés",
|
||||
"noResults": "Nincs találat"
|
||||
"noResults": "Nincs találat",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "{{total}} {{current}} lépése",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Alagút",
|
||||
"serverStats": "Gazdagép-metrikák",
|
||||
"status": {
|
||||
"available": "Elérhető",
|
||||
"reachable": "Felment, de az utolsó bejelentkezés sikertelen",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitorozás letiltva",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Állapot"
|
||||
},
|
||||
"folderRenamed": "A(z) „{{oldName}}” mappa sikeresen átnevezve erre: „{{newName}}”",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Ez a gazdagép (közvetlen alagút)",
|
||||
"statusChecksLabel": "Állapotellenőrzések",
|
||||
"enableStatusChecks": "Állapotellenőrzések engedélyezése",
|
||||
"enableStatusChecksDesc": "Rendszeresen pingelje ezt a gazdagépet az elérhetőség ellenőrzéséhez",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Globális intervallum használata",
|
||||
"useGlobalIntervalDesc": "Felülbírálás a szerver szintű állapotellenőrzési intervallummal",
|
||||
"checkIntervalS": "Ellenőrzési intervallum (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "A fiókod egy második faktort használ, ami jelenleg nem érhető el. Lépj kapcsolatba egy adminisztrátorral a visszaállításához.",
|
||||
"secondFactorNoUI": "Ez a második tényező nem jeleníthető meg ezen a képernyőn. Lépjen kapcsolatba egy adminisztrátorral.",
|
||||
"serverAuthentication": "Szerver bejelentkezés",
|
||||
"minChars": "min. {{min}} karakter"
|
||||
"minChars": "min. {{min}} karakter",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "SSH host kulcs ellenőrzése",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Nyelv",
|
||||
"keyPassword": "kulcs jelszó",
|
||||
"pastePrivateKey": "Illeszd be ide a privát kulcsodat...",
|
||||
"enterPassword": "Írja be a jelszavát"
|
||||
"enterPassword": "Írja be a jelszavát",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Műszerfal",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Kibocsátó: {{issuer}}",
|
||||
"sslNames": "Borítók {{names}}",
|
||||
"sslManualTitle": "Tanúsítvány feltöltése",
|
||||
"updateExternalAutoProvisionFailed": "Nem sikerült frissíteni az automatikus létrehozási beállítást"
|
||||
"updateExternalAutoProvisionFailed": "Nem sikerült frissíteni az automatikus létrehozási beállítást",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Pembaruan tersedia. Buka catatan rilis di GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Menutup",
|
||||
"minimize": "Memperkecil",
|
||||
"online": "On line",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Login gagal.",
|
||||
"unknown": "Tidak dikenal",
|
||||
"continue": "Melanjutkan",
|
||||
"maintenance": "Pemeliharaan",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Menghapus",
|
||||
"add": "Menambahkan",
|
||||
"update": "Memperbarui",
|
||||
"noResults": "Tidak ada hasil yang ditemukan"
|
||||
"noResults": "Tidak ada hasil yang ditemukan",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Langkah {{current}} dari {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Terowongan",
|
||||
"serverStats": "Metrik Host",
|
||||
"status": {
|
||||
"available": "Tersedia",
|
||||
"reachable": "Berhasil, tetapi tanda terakhir gagal.",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Pemantauan dinonaktifkan",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Status"
|
||||
},
|
||||
"folderRenamed": "Folder \"{{oldName}}\" berhasil diganti namanya menjadi \"{{newName}}\"",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Host ini (terowongan langsung)",
|
||||
"statusChecksLabel": "Pemeriksaan Status",
|
||||
"enableStatusChecks": "Aktifkan Pemeriksaan Status",
|
||||
"enableStatusChecksDesc": "Lakukan ping secara berkala ke host ini untuk memverifikasi ketersediaan.",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Gunakan Interval Global",
|
||||
"useGlobalIntervalDesc": "Ganti dengan interval pemeriksaan status di seluruh server.",
|
||||
"checkIntervalS": "Interval Pemeriksaan (detik)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Akun Anda menggunakan faktor kedua yang saat ini tidak tersedia. Hubungi admin untuk mengatur ulang faktor tersebut.",
|
||||
"secondFactorNoUI": "Faktor kedua ini tidak dapat ditampilkan di layar ini. Hubungi administrator.",
|
||||
"serverAuthentication": "Masuk ke server",
|
||||
"minChars": "min. {{min}} karakter"
|
||||
"minChars": "min. {{min}} karakter",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verifikasi Kunci Host SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Bahasa",
|
||||
"keyPassword": "kata sandi kunci",
|
||||
"pastePrivateKey": "Tempelkan kunci pribadi Anda di sini...",
|
||||
"enterPassword": "Masukkan kata sandi Anda"
|
||||
"enterPassword": "Masukkan kata sandi Anda",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dasbor",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Dikeluarkan oleh {{issuer}}",
|
||||
"sslNames": "Sampul {{names}}",
|
||||
"sslManualTitle": "Unggah sertifikat",
|
||||
"updateExternalAutoProvisionFailed": "Gagal memperbarui pengaturan pembuatan otomatis."
|
||||
"updateExternalAutoProvisionFailed": "Gagal memperbarui pengaturan pembuatan otomatis.",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "Aggiornamento disponibile. Consulta le note di rilascio su GitHub."
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "Chiudi",
|
||||
"minimize": "Riduci a icona",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"signInFailed": "Accesso non riuscito",
|
||||
"unknown": "Sconosciuto",
|
||||
"continue": "Continua",
|
||||
"maintenance": "Manutenzione",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "Rimuovi",
|
||||
"add": "Aggiungi",
|
||||
"update": "Aggiorna",
|
||||
"noResults": "Nessun risultato trovato"
|
||||
"noResults": "Nessun risultato trovato",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Passo {{current}} di {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "Tunnel",
|
||||
"serverStats": "Metriche host",
|
||||
"status": {
|
||||
"available": "Disponibile",
|
||||
"reachable": "Su, ma l'ultimo accesso non è riuscito",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"monitoringDisabled": "Monitoraggio disabilitato",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "Stato"
|
||||
},
|
||||
"folderRenamed": "Cartella \"{{oldName}}\" rinominata in \"{{newName}}\" con successo",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "Questo host (tunnel diretto)",
|
||||
"statusChecksLabel": "Controlli di stato",
|
||||
"enableStatusChecks": "Abilita controlli di stato",
|
||||
"enableStatusChecksDesc": "Esegui periodicamente il ping di questo host per verificarne la disponibilità",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "Usa intervallo globale",
|
||||
"useGlobalIntervalDesc": "Sovrascrivi con l'intervallo di controllo dello stato del server",
|
||||
"checkIntervalS": "Intervallo di controllo (s)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "Il tuo account utilizza un secondo fattore di autenticazione al momento non disponibile. Contatta un amministratore per reimpostarlo.",
|
||||
"secondFactorNoUI": "Questo secondo fattore non può essere visualizzato in questa schermata. Contatta un amministratore.",
|
||||
"serverAuthentication": "Accesso al server",
|
||||
"minChars": "min. {{min}} caratteri"
|
||||
"minChars": "min. {{min}} caratteri",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "Verifica chiave host SSH",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "Lingua",
|
||||
"keyPassword": "password chiave",
|
||||
"pastePrivateKey": "Incolla qui la tua chiave privata...",
|
||||
"enterPassword": "Inserisci la password"
|
||||
"enterPassword": "Inserisci la password",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "Emesso da {{issuer}}",
|
||||
"sslNames": "Copertine {{names}}",
|
||||
"sslManualTitle": "Carica un certificato",
|
||||
"updateExternalAutoProvisionFailed": "Impossibile aggiornare l'impostazione di creazione automatica"
|
||||
"updateExternalAutoProvisionFailed": "Impossibile aggiornare l'impostazione di creazione automatica",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
"updateLinkLabel": "アップデートが利用可能です。GitHubのリリースノートをご覧ください。"
|
||||
},
|
||||
"common": {
|
||||
"scrollTabsLeft": "Scroll tabs left",
|
||||
"scrollTabsRight": "Scroll tabs right",
|
||||
"close": "閉じる",
|
||||
"minimize": "最小化",
|
||||
"online": "オンライン",
|
||||
"offline": "オフライン",
|
||||
"signInFailed": "サインインに失敗しました",
|
||||
"unknown": "不明",
|
||||
"continue": "続行",
|
||||
"maintenance": "メンテナンス",
|
||||
@@ -187,7 +188,11 @@
|
||||
"remove": "削除",
|
||||
"add": "追加",
|
||||
"update": "更新",
|
||||
"noResults": "検索結果が見つかりませんでした"
|
||||
"noResults": "検索結果が見つかりませんでした",
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "ステップ {{current}} / {{total}}",
|
||||
@@ -352,10 +357,10 @@
|
||||
"tunnel": "トンネル",
|
||||
"serverStats": "ホストメトリクス",
|
||||
"status": {
|
||||
"available": "利用可能",
|
||||
"reachable": "上昇したが、最後のサインインが失敗した",
|
||||
"online": "Online",
|
||||
"offline": "オフライン",
|
||||
"monitoringDisabled": "監視が無効になっています",
|
||||
"checking": "Checking",
|
||||
"monitoringDisabled": "Status checks off",
|
||||
"label": "状態"
|
||||
},
|
||||
"folderRenamed": "フォルダ「{{oldName}}」を「{{newName}}」に正常にリネームしました",
|
||||
@@ -477,7 +482,7 @@
|
||||
"sameHost": "このホスト(直接トンネル)",
|
||||
"statusChecksLabel": "ステータスチェック",
|
||||
"enableStatusChecks": "ステータスチェックを有効にする",
|
||||
"enableStatusChecksDesc": "定期的にこのホストに ping を送信して可用性を確認します",
|
||||
"enableStatusChecksDesc": "Check host reachability. Active sessions skip extra probes. SSH probes without an active session may trigger aggressive Fail2Ban rules; disable status checks on those hosts.",
|
||||
"useGlobalInterval": "グローバル間隔を使用する",
|
||||
"useGlobalIntervalDesc": "サーバー全体のステータスチェック間隔で上書きします",
|
||||
"checkIntervalS": "チェック間隔 (秒)",
|
||||
@@ -1058,7 +1063,8 @@
|
||||
"secondFactorUnavailable": "お客様のアカウントには、現在ご利用いただけない二段階認証が設定されています。リセットするには、管理者にお問い合わせください。",
|
||||
"secondFactorNoUI": "この2つ目の要素は、この画面には表示できません。管理者にお問い合わせください。",
|
||||
"serverAuthentication": "サーバーへのサインイン",
|
||||
"minChars": "最小 {{min}} 文字"
|
||||
"minChars": "最小 {{min}} 文字",
|
||||
"retrySecondFactorUI": "Reload verification methods"
|
||||
},
|
||||
"hostKey": {
|
||||
"verifyNewHost": "SSHホストキーを確認",
|
||||
@@ -1119,7 +1125,13 @@
|
||||
"language": "言語",
|
||||
"keyPassword": "キーパスワード",
|
||||
"pastePrivateKey": "秘密鍵をここに貼り付け...",
|
||||
"enterPassword": "パスワードを入力"
|
||||
"enterPassword": "パスワードを入力",
|
||||
"apiKeyName": "e.g., CI Pipeline",
|
||||
"credentialName": "e.g. Production SSH Key",
|
||||
"optionalDetails": "Optional details...",
|
||||
"sshUsername": "e.g. root or deploy",
|
||||
"hostAddress": "10.0.0.1 or example.com",
|
||||
"hostName": "e.g. Web Server Production"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "ダッシュボード",
|
||||
@@ -1501,7 +1513,10 @@
|
||||
"sslIssuer": "{{issuer}} によって発行されました",
|
||||
"sslNames": "カバー {{names}}",
|
||||
"sslManualTitle": "証明書をアップロードしてください",
|
||||
"updateExternalAutoProvisionFailed": "自動作成設定の更新に失敗しました"
|
||||
"updateExternalAutoProvisionFailed": "自動作成設定の更新に失敗しました",
|
||||
"hostTags": "Predefined host tags",
|
||||
"hostTagsDesc": "Tags every user sees as suggestions in the host editor. Press Space or Enter to add one. Removing a tag here does not remove it from hosts.",
|
||||
"hostTagsError": "Could not load or save host tag suggestions."
|
||||
},
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
|
||||
Loaded 100 of 912 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user