mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
548 lines
16 KiB
TypeScript
548 lines
16 KiB
TypeScript
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
|
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
|
|
import type { DatabaseContext } from "./database-context.js";
|
|
import { DataCrypto } from "../../utils/data-crypto.js";
|
|
|
|
export type HostResolutionHostRecord = typeof hosts.$inferSelect;
|
|
export type HostResolutionCredentialRecord = typeof sshCredentials.$inferSelect;
|
|
export interface HostKeyVerificationRecord {
|
|
hostKeyFingerprint: string | null;
|
|
hostKeyType: string | null;
|
|
hostKeyAlgorithm: string | null;
|
|
hostKeyChangedCount: number | null;
|
|
name: string | null;
|
|
}
|
|
export interface HostUpdateStateRecord {
|
|
userId: string;
|
|
credentialId: number | null;
|
|
rdpCredentialId: number | null;
|
|
vncCredentialId: number | null;
|
|
telnetCredentialId: number | null;
|
|
vaultProfileId: number | null;
|
|
authType: string;
|
|
parentHostId: number | null;
|
|
folder: string | null;
|
|
}
|
|
export interface HostListAccessEntry {
|
|
hostId: number;
|
|
permissionLevel: string;
|
|
expiresAt: string | null;
|
|
}
|
|
|
|
const HOST_PERMISSION_RANK: Record<string, number> = {
|
|
connect: 1,
|
|
view: 2,
|
|
edit: 3,
|
|
manage: 4,
|
|
};
|
|
|
|
function preferHostAccess(
|
|
current: HostListAccessEntry,
|
|
candidate: HostListAccessEntry,
|
|
): HostListAccessEntry {
|
|
const currentRank = HOST_PERMISSION_RANK[current.permissionLevel] ?? 0;
|
|
const candidateRank = HOST_PERMISSION_RANK[candidate.permissionLevel] ?? 0;
|
|
if (candidateRank !== currentRank) {
|
|
return candidateRank > currentRank ? candidate : current;
|
|
}
|
|
if (current.expiresAt === null) return current;
|
|
if (candidate.expiresAt === null) return candidate;
|
|
return candidate.expiresAt > current.expiresAt ? candidate : current;
|
|
}
|
|
|
|
export type HostListRow = HostResolutionHostRecord & {
|
|
ownerId: string;
|
|
isShared: boolean;
|
|
permissionLevel?: string;
|
|
expiresAt?: string | null;
|
|
};
|
|
|
|
export interface HostStatusTargetRow {
|
|
id: number;
|
|
userId: string;
|
|
ip: string;
|
|
port: number;
|
|
connectionType: string;
|
|
jumpHosts: string | null;
|
|
statusCheckEnabled: boolean;
|
|
statusCheckInterval: number | null;
|
|
}
|
|
|
|
export class HostResolutionRepository {
|
|
constructor(
|
|
private readonly context: DatabaseContext,
|
|
private readonly onWrite?: () => void | Promise<void>,
|
|
// Informational only -- touchHostKeyLastVerified fires on every SSH
|
|
// connect and does not need an immediate encrypted-file rewrite the way
|
|
// storeHostKey/updateHostKey do. Keeping it separate from onWrite lets
|
|
// those two stay on the immediate forceSave path.
|
|
private readonly onLazyWrite?: () => void | Promise<void>,
|
|
) {}
|
|
|
|
async findHostById(
|
|
hostId: number,
|
|
userId: string,
|
|
): Promise<HostResolutionHostRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(hosts)
|
|
.where(eq(hosts.id, hostId))
|
|
.limit(1);
|
|
|
|
return this.decryptOne("ssh_data", rows[0], userId);
|
|
}
|
|
|
|
/**
|
|
* Translates a sync identity into this database's own row id.
|
|
*
|
|
* Deliberately not scoped to a user: `sync_id` is unique across the table,
|
|
* and a host shared with the caller belongs to someone else. Whether the
|
|
* caller may reach the row is decided by the permission check that follows,
|
|
* not here.
|
|
*/
|
|
async findHostIdBySyncId(syncId: string): Promise<number | null> {
|
|
const rows = await this.context.drizzle
|
|
.select({ id: hosts.id })
|
|
.from(hosts)
|
|
.where(eq(hosts.syncId, syncId))
|
|
.limit(1);
|
|
|
|
return rows[0]?.id ?? null;
|
|
}
|
|
|
|
async findHostByIdForUser(
|
|
hostId: number,
|
|
userId: string,
|
|
): Promise<HostResolutionHostRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(hosts)
|
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
|
.limit(1);
|
|
|
|
return this.decryptOne("ssh_data", rows[0], userId);
|
|
}
|
|
|
|
async findHostIdBySyncIdForUser(
|
|
syncId: string,
|
|
userId: string,
|
|
): Promise<number | null> {
|
|
const rows = await this.context.drizzle
|
|
.select({ id: hosts.id })
|
|
.from(hosts)
|
|
.where(and(eq(hosts.syncId, syncId), eq(hosts.userId, userId)))
|
|
.limit(1);
|
|
|
|
return rows[0]?.id ?? null;
|
|
}
|
|
|
|
async findHostUpdateState(
|
|
hostId: number,
|
|
): Promise<HostUpdateStateRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select({
|
|
userId: hosts.userId,
|
|
credentialId: hosts.credentialId,
|
|
rdpCredentialId: hosts.rdpCredentialId,
|
|
vncCredentialId: hosts.vncCredentialId,
|
|
telnetCredentialId: hosts.telnetCredentialId,
|
|
vaultProfileId: hosts.vaultProfileId,
|
|
authType: hosts.authType,
|
|
parentHostId: hosts.parentHostId,
|
|
folder: hosts.folder,
|
|
})
|
|
.from(hosts)
|
|
.where(eq(hosts.id, hostId))
|
|
.limit(1);
|
|
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Minimal (id, parentHostId) rows for every host a user owns, used to walk
|
|
* ancestor chains when validating a sub-host parent assignment for cycles.
|
|
* No decryption needed -- parentHostId is a plain, unencrypted integer.
|
|
*/
|
|
async listOwnHostParentLinks(
|
|
userId: string,
|
|
): Promise<{ id: number; parentHostId: number | null }[]> {
|
|
return this.context.drizzle
|
|
.select({ id: hosts.id, parentHostId: hosts.parentHostId })
|
|
.from(hosts)
|
|
.where(eq(hosts.userId, userId));
|
|
}
|
|
|
|
async findHostsByUserId(userId: string): Promise<HostResolutionHostRecord[]> {
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(hosts)
|
|
.where(eq(hosts.userId, userId));
|
|
|
|
return this.decryptMany("ssh_data", rows, userId);
|
|
}
|
|
|
|
async listHostRowsForAccessList(
|
|
userId: string,
|
|
accessEntries: HostListAccessEntry[],
|
|
): Promise<HostListRow[]> {
|
|
const ownHostRows = await this.context.drizzle
|
|
.select()
|
|
.from(hosts)
|
|
.where(eq(hosts.userId, userId));
|
|
|
|
const accessByHostId = new Map<number, HostListAccessEntry>();
|
|
for (const access of accessEntries) {
|
|
const current = accessByHostId.get(access.hostId);
|
|
accessByHostId.set(
|
|
access.hostId,
|
|
current ? preferHostAccess(current, access) : access,
|
|
);
|
|
}
|
|
const sharedHostIds = Array.from(accessByHostId.keys());
|
|
const sharedHostRows =
|
|
sharedHostIds.length > 0
|
|
? await this.context.drizzle
|
|
.select()
|
|
.from(hosts)
|
|
.where(inArray(hosts.id, sharedHostIds))
|
|
: [];
|
|
const sharedHostsById = new Map(
|
|
sharedHostRows.map((host) => [host.id, host]),
|
|
);
|
|
|
|
return [
|
|
...ownHostRows.map((host) => ({
|
|
...host,
|
|
ownerId: host.userId,
|
|
isShared: false,
|
|
permissionLevel: undefined,
|
|
expiresAt: undefined,
|
|
})),
|
|
...Array.from(accessByHostId.values()).flatMap((access) => {
|
|
const host = sharedHostsById.get(access.hostId);
|
|
if (!host || host.userId === userId) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
...host,
|
|
ownerId: host.userId,
|
|
isShared: host.userId !== userId,
|
|
permissionLevel: access.permissionLevel,
|
|
expiresAt: access.expiresAt,
|
|
},
|
|
];
|
|
}),
|
|
];
|
|
}
|
|
|
|
async findHostOwnerId(hostId: number): Promise<string | null> {
|
|
const rows = await this.context.drizzle
|
|
.select({ ownerId: hosts.userId })
|
|
.from(hosts)
|
|
.where(eq(hosts.id, hostId))
|
|
.limit(1);
|
|
|
|
return rows[0]?.ownerId ?? null;
|
|
}
|
|
|
|
/**
|
|
* Ids of the hosts this user owns, as a set.
|
|
*
|
|
* Callers that need to check ownership of many hosts at once (the status
|
|
* poll being the hot one) would otherwise issue isHostOwnedByUser per host,
|
|
* which is a query each and repeats on every poll.
|
|
*/
|
|
async listOwnedHostIds(userId: string): Promise<Set<number>> {
|
|
const rows = await this.context.drizzle
|
|
.select({ id: hosts.id })
|
|
.from(hosts)
|
|
.where(eq(hosts.userId, userId));
|
|
|
|
return new Set(rows.map((row) => row.id));
|
|
}
|
|
|
|
async isHostOwnedByUser(hostId: number, userId: string): Promise<boolean> {
|
|
const rows = await this.context.drizzle
|
|
.select({ id: hosts.id })
|
|
.from(hosts)
|
|
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
|
|
.limit(1);
|
|
|
|
return rows.length > 0;
|
|
}
|
|
|
|
/**
|
|
* What the status checker needs, without decrypting anything: none of these
|
|
* columns are encrypted. Filtered by owner, by id, or both.
|
|
*/
|
|
async listStatusTargets(filter: {
|
|
userId?: string;
|
|
hostIds?: number[];
|
|
}): Promise<HostStatusTargetRow[]> {
|
|
if (filter.hostIds && filter.hostIds.length === 0) return [];
|
|
const conditions = [
|
|
filter.userId ? eq(hosts.userId, filter.userId) : undefined,
|
|
filter.hostIds ? inArray(hosts.id, filter.hostIds) : undefined,
|
|
].filter((condition) => condition !== undefined);
|
|
return this.context.drizzle
|
|
.select({
|
|
id: hosts.id,
|
|
userId: hosts.userId,
|
|
ip: hosts.ip,
|
|
port: hosts.port,
|
|
connectionType: hosts.connectionType,
|
|
jumpHosts: hosts.jumpHosts,
|
|
statusCheckEnabled: hosts.statusCheckEnabled,
|
|
statusCheckInterval: hosts.statusCheckInterval,
|
|
})
|
|
.from(hosts)
|
|
.where(conditions.length > 0 ? and(...conditions) : undefined);
|
|
}
|
|
|
|
async listAllHosts(): Promise<HostResolutionHostRecord[]> {
|
|
const rows = await this.context.drizzle.select().from(hosts);
|
|
|
|
return this.decryptManyByOwner("ssh_data", rows);
|
|
}
|
|
|
|
async listHostsWithTunnelConnections(): Promise<HostResolutionHostRecord[]> {
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(hosts)
|
|
.where(
|
|
and(eq(hosts.enableTunnel, true), isNotNull(hosts.tunnelConnections)),
|
|
);
|
|
|
|
return this.decryptManyByOwner("ssh_data", rows);
|
|
}
|
|
|
|
async listHostsUsingCredentialForUser(
|
|
userId: string,
|
|
credentialId: number,
|
|
): Promise<HostResolutionHostRecord[]> {
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(hosts)
|
|
.where(
|
|
and(eq(hosts.credentialId, credentialId), eq(hosts.userId, userId)),
|
|
);
|
|
|
|
return this.decryptMany("ssh_data", rows, userId);
|
|
}
|
|
|
|
async findHostKeyVerificationData(
|
|
hostId: number,
|
|
): Promise<HostKeyVerificationRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select({
|
|
hostKeyFingerprint: hosts.hostKeyFingerprint,
|
|
hostKeyType: hosts.hostKeyType,
|
|
hostKeyAlgorithm: hosts.hostKeyAlgorithm,
|
|
hostKeyChangedCount: hosts.hostKeyChangedCount,
|
|
name: hosts.name,
|
|
})
|
|
.from(hosts)
|
|
.where(eq(hosts.id, hostId))
|
|
.limit(1);
|
|
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
async storeHostKey(
|
|
hostId: number,
|
|
fingerprint: string,
|
|
keyType: string,
|
|
algorithm: string,
|
|
now = new Date().toISOString(),
|
|
): Promise<void> {
|
|
await this.context.drizzle
|
|
.update(hosts)
|
|
.set({
|
|
hostKeyFingerprint: fingerprint,
|
|
hostKeyType: keyType,
|
|
hostKeyAlgorithm: algorithm,
|
|
hostKeyFirstSeen: now,
|
|
hostKeyLastVerified: now,
|
|
updatedAt: sql`CURRENT_TIMESTAMP`,
|
|
})
|
|
.where(eq(hosts.id, hostId));
|
|
await this.afterWrite();
|
|
}
|
|
|
|
async updateHostKey(
|
|
hostId: number,
|
|
fingerprint: string,
|
|
keyType: string,
|
|
algorithm: string,
|
|
currentChangeCount: number,
|
|
now = new Date().toISOString(),
|
|
): Promise<void> {
|
|
await this.context.drizzle
|
|
.update(hosts)
|
|
.set({
|
|
hostKeyFingerprint: fingerprint,
|
|
hostKeyType: keyType,
|
|
hostKeyAlgorithm: algorithm,
|
|
hostKeyLastVerified: now,
|
|
hostKeyChangedCount: currentChangeCount + 1,
|
|
updatedAt: sql`CURRENT_TIMESTAMP`,
|
|
})
|
|
.where(eq(hosts.id, hostId));
|
|
await this.afterWrite();
|
|
}
|
|
|
|
async touchHostKeyLastVerified(
|
|
hostId: number,
|
|
now = new Date().toISOString(),
|
|
): Promise<void> {
|
|
await this.context.drizzle
|
|
.update(hosts)
|
|
.set({ hostKeyLastVerified: now, updatedAt: sql`CURRENT_TIMESTAMP` })
|
|
.where(eq(hosts.id, hostId));
|
|
await (this.onLazyWrite?.() ?? this.afterWrite());
|
|
}
|
|
|
|
async findCredentialByIdForUser(
|
|
credentialId: number,
|
|
userId: string,
|
|
): Promise<HostResolutionCredentialRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, credentialId),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
return this.decryptOne("ssh_credentials", rows[0], userId);
|
|
}
|
|
|
|
/**
|
|
* Batch form of findCredentialByIdForUser.
|
|
*
|
|
* The host list resolves a credential for every host it returns; issued one
|
|
* id at a time that is a query and a decrypt per host, which is the dominant
|
|
* cost of the list once an install has more than a few hundred of them.
|
|
*/
|
|
async listCredentialsByIdsForUser(
|
|
credentialIds: number[],
|
|
userId: string,
|
|
): Promise<Map<number, HostResolutionCredentialRecord>> {
|
|
const unique = Array.from(new Set(credentialIds));
|
|
if (unique.length === 0) return new Map();
|
|
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
inArray(sshCredentials.id, unique),
|
|
eq(sshCredentials.userId, userId),
|
|
),
|
|
);
|
|
|
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
|
if (!userDataKey) return new Map();
|
|
|
|
const byId = new Map<number, HostResolutionCredentialRecord>();
|
|
for (const row of rows) {
|
|
byId.set(
|
|
row.id,
|
|
DataCrypto.decryptRecord("ssh_credentials", row, userId, userDataKey),
|
|
);
|
|
}
|
|
return byId;
|
|
}
|
|
|
|
async findCredentialByIdForOwnerDecryptedAs(
|
|
credentialId: number,
|
|
ownerUserId: string,
|
|
decryptUserId: string,
|
|
): Promise<HostResolutionCredentialRecord | null> {
|
|
const rows = await this.context.drizzle
|
|
.select()
|
|
.from(sshCredentials)
|
|
.where(
|
|
and(
|
|
eq(sshCredentials.id, credentialId),
|
|
eq(sshCredentials.userId, ownerUserId),
|
|
),
|
|
)
|
|
.limit(1);
|
|
|
|
return this.decryptOne("ssh_credentials", rows[0], decryptUserId);
|
|
}
|
|
|
|
/**
|
|
* Resolve the nearest assigned credential for a folder path, walking up
|
|
* through parent folders (e.g. "Switches / Floor1" falls back to
|
|
* "Switches" if the child folder has no credential of its own).
|
|
*/
|
|
async findFolderCredentialId(
|
|
userId: string,
|
|
folderPath: string,
|
|
): Promise<number | null> {
|
|
const segments = folderPath.split(" / ").filter(Boolean);
|
|
if (segments.length === 0) return null;
|
|
|
|
const paths = segments.map((_, i) => segments.slice(0, i + 1).join(" / "));
|
|
const rows = await this.context.drizzle
|
|
.select({ name: sshFolders.name, credentialId: sshFolders.credentialId })
|
|
.from(sshFolders)
|
|
.where(
|
|
and(eq(sshFolders.userId, userId), inArray(sshFolders.name, paths)),
|
|
);
|
|
|
|
const byName = new Map(rows.map((row) => [row.name, row.credentialId]));
|
|
for (let i = paths.length - 1; i >= 0; i--) {
|
|
const credentialId = byName.get(paths[i]);
|
|
if (credentialId) return credentialId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private decryptOne<T extends Record<string, unknown>>(
|
|
tableName: "ssh_data" | "ssh_credentials",
|
|
record: T | undefined,
|
|
userId: string,
|
|
): T | null {
|
|
if (!record) return null;
|
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
|
if (!userDataKey) return null;
|
|
return DataCrypto.decryptRecord(tableName, record, userId, userDataKey);
|
|
}
|
|
|
|
private decryptMany<T extends Record<string, unknown>>(
|
|
tableName: "ssh_data" | "ssh_credentials",
|
|
records: T[],
|
|
userId: string,
|
|
): T[] {
|
|
const userDataKey = DataCrypto.getUserDataKey(userId);
|
|
if (!userDataKey) return [];
|
|
return records.map((record) =>
|
|
DataCrypto.decryptRecord(tableName, record, userId, userDataKey),
|
|
);
|
|
}
|
|
|
|
private decryptManyByOwner<
|
|
T extends Record<string, unknown> & { userId: string },
|
|
>(tableName: "ssh_data" | "ssh_credentials", records: T[]): T[] {
|
|
return records.flatMap((record) => {
|
|
const userDataKey = DataCrypto.getUserDataKey(record.userId);
|
|
if (!userDataKey) return [];
|
|
return [
|
|
DataCrypto.decryptRecord(tableName, record, record.userId, userDataKey),
|
|
];
|
|
});
|
|
}
|
|
|
|
private async afterWrite(): Promise<void> {
|
|
await this.onWrite?.();
|
|
}
|
|
}
|