mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
Merge branch 'dev-2.9.1' into dev-26.10.0
# Conflicts: # package-lock.json # package.json # plugins/ai/locales/translated/af_ZA.json # plugins/ai/locales/translated/ar_SA.json # plugins/ai/locales/translated/bg_BG.json # plugins/ai/locales/translated/bn_BD.json # plugins/ai/locales/translated/ca_ES.json # plugins/ai/locales/translated/cs_CZ.json # plugins/ai/locales/translated/da_DK.json # plugins/ai/locales/translated/de_DE.json # plugins/ai/locales/translated/el_GR.json # plugins/ai/locales/translated/es_ES.json # plugins/ai/locales/translated/fi_FI.json # plugins/ai/locales/translated/fr_FR.json # plugins/ai/locales/translated/he_IL.json # plugins/ai/locales/translated/hi_IN.json # plugins/ai/locales/translated/hu_HU.json # plugins/ai/locales/translated/id_ID.json # plugins/ai/locales/translated/it_IT.json # plugins/ai/locales/translated/ja_JP.json # plugins/ai/locales/translated/ko_KR.json # plugins/ai/locales/translated/nl_NL.json # plugins/ai/locales/translated/no_NO.json # plugins/ai/locales/translated/pl_PL.json # plugins/ai/locales/translated/pt_BR.json # plugins/ai/locales/translated/pt_PT.json # plugins/ai/locales/translated/ro_RO.json # plugins/ai/locales/translated/ru_RU.json # plugins/ai/locales/translated/sr_SP.json # plugins/ai/locales/translated/sv_SE.json # plugins/ai/locales/translated/th_TH.json # plugins/ai/locales/translated/tr_TR.json # plugins/ai/locales/translated/uk_UA.json # plugins/ai/locales/translated/vi_VN.json # plugins/ai/locales/translated/zh_TW.json
This commit is contained in:
commit
6b91c3a460
996 files changed
+56245
-47648
No files matched your search
@@ -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:
|
||||
|
||||
@@ -2,18 +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 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).
|
||||
* 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";
|
||||
@@ -24,24 +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 } 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 {
|
||||
@@ -51,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 {
|
||||
@@ -84,6 +78,8 @@ export interface HostStatusDeps {
|
||||
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 }> {
|
||||
@@ -158,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);
|
||||
@@ -175,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);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -212,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;
|
||||
}
|
||||
|
||||
@@ -249,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 {
|
||||
@@ -278,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,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 {
|
||||
@@ -304,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", {
|
||||
@@ -315,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)) {
|
||||
@@ -323,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> {
|
||||
@@ -351,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 {
|
||||
@@ -373,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 {
|
||||
@@ -427,33 +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 {
|
||||
if (this.deps.hasActiveSession?.(target.id)) {
|
||||
reachable = true;
|
||||
} else {
|
||||
const port = await this.portFor(target);
|
||||
reachable =
|
||||
target.jumpHosts.length > 0
|
||||
? await this.deps.pingThroughJumpHosts(target, port)
|
||||
: await this.deps.ping(target.ip, port);
|
||||
}
|
||||
} catch {
|
||||
reachable = false;
|
||||
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">) {
|
||||
@@ -486,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";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { tcpPingThroughJumpHost } from "./tcp-ping.js";
|
||||
import { tcpPingThroughJumpHostResult } from "./tcp-ping.js";
|
||||
|
||||
type OpenChain = (
|
||||
jumpHosts: Array<{ hostId: number }>,
|
||||
@@ -44,7 +44,19 @@ export class JumpProbePool {
|
||||
return false;
|
||||
}
|
||||
this.touch(key, entry, client);
|
||||
return tcpPingThroughJumpHost(client, host, port, timeoutMs, true);
|
||||
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 {
|
||||
|
||||
@@ -64,18 +64,23 @@ 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,
|
||||
keepOpen = false,
|
||||
): Promise<boolean> {
|
||||
): Promise<JumpPingResult> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
const finish = (result: JumpPingResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
@@ -83,11 +88,30 @@ export function tcpPingThroughJumpHost(
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -124,9 +123,6 @@ function withOverrides(
|
||||
return merged as unknown as SshConnectHost;
|
||||
}
|
||||
|
||||
const LOGIN_FAILED =
|
||||
/all configured authentication methods failed|permission denied|authentication failed/i;
|
||||
|
||||
export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
@@ -223,23 +219,13 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
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) => {
|
||||
/** 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(() => {});
|
||||
};
|
||||
@@ -299,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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -55,6 +55,7 @@ function setup(
|
||||
hasActiveSession: (id) => hostSessionStatus.hasActiveSession(id),
|
||||
globalInterval: () => 60,
|
||||
emit: (payload) => emitted.push(payload),
|
||||
retryDelayMs: 0,
|
||||
});
|
||||
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
|
||||
}
|
||||
@@ -192,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");
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,29 @@ describe("JumpProbePool", () => {
|
||||
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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -220,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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user