mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move desktop, status, import, audit and branding features into plugins
This commit is contained in:
1 parent
9d93fb0893
commit
1cdbfc674c
166 files changed
+21298
-12041
No files matched your search
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { hostTerminalExport } from "../../database/routes/host-normalizers.js";
|
||||
import { loadHostPluginSettings } from "../../database/routes/host-plugin-settings.js";
|
||||
import {
|
||||
listProtocolLogins,
|
||||
toPortableLogins,
|
||||
} from "../protocol-auth/protocol-auth.js";
|
||||
import { findHostProtocol } from "../protocol-auth/registry.js";
|
||||
import { resolveHostCredentials } from "../resolve-host-credentials.js";
|
||||
|
||||
/**
|
||||
* A share export leaves out every secret and credential link, the same as
|
||||
* it does for SSH.
|
||||
*/
|
||||
function shareableLogins(
|
||||
logins: ReturnType<typeof toPortableLogins>,
|
||||
shareMode: boolean,
|
||||
): ReturnType<typeof toPortableLogins> {
|
||||
if (!shareMode) return logins;
|
||||
const out: ReturnType<typeof toPortableLogins> = {};
|
||||
for (const [protocol, login] of Object.entries(logins)) {
|
||||
const declared = findHostProtocol(protocol);
|
||||
const secret = new Set(
|
||||
(declared?.credentialFields ?? [])
|
||||
.filter((field) => field.secret)
|
||||
.map((field) => field.key),
|
||||
);
|
||||
out[protocol] = {
|
||||
authType: login.authType === "credential" ? "direct" : login.authType,
|
||||
credentialId: null,
|
||||
username: login.username,
|
||||
password: null,
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(login.fields).filter(
|
||||
([key]) => declared && !secret.has(key),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** One host as the Termix JSON export carries it. */
|
||||
async function exportRow(
|
||||
host: Row,
|
||||
userId: string,
|
||||
shareMode: boolean,
|
||||
pluginSettings: Awaited<ReturnType<typeof loadHostPluginSettings>>,
|
||||
): Promise<{ data: Row; isRemoteDesktop: boolean; resolved: Row }> {
|
||||
const resolvedHost = shareMode
|
||||
? host
|
||||
: (await resolveHostCredentials(host, userId)) || host;
|
||||
const connectionType = (resolvedHost.connectionType as string) || "ssh";
|
||||
const isRemoteDesktop = connectionType !== "ssh";
|
||||
|
||||
const base = {
|
||||
exportId: resolvedHost.id,
|
||||
connectionType,
|
||||
name: resolvedHost.name,
|
||||
ip: resolvedHost.ip,
|
||||
port: resolvedHost.port,
|
||||
username: resolvedHost.username,
|
||||
password: shareMode ? null : resolvedHost.password || null,
|
||||
folder: resolvedHost.folder,
|
||||
tags:
|
||||
typeof resolvedHost.tags === "string"
|
||||
? resolvedHost.tags.split(",").filter(Boolean)
|
||||
: resolvedHost.tags || [],
|
||||
pin: !!resolvedHost.pin,
|
||||
notes: resolvedHost.notes || null,
|
||||
// Every plugin's host settings, secrets redacted, for import to hand back.
|
||||
pluginSettings: pluginSettings.get(host.id as number) ?? {},
|
||||
// Each plugin protocol's login, secrets included like the SSH ones.
|
||||
protocolAuth: shareableLogins(
|
||||
toPortableLogins(await listProtocolLogins(host.id as number, userId)),
|
||||
shareMode,
|
||||
),
|
||||
};
|
||||
|
||||
if (isRemoteDesktop) {
|
||||
return { data: base, isRemoteDesktop, resolved: resolvedHost };
|
||||
}
|
||||
|
||||
const terminal = hostTerminalExport(resolvedHost);
|
||||
const data = {
|
||||
...base,
|
||||
authType: resolvedHost.authType,
|
||||
key: shareMode ? null : resolvedHost.key || null,
|
||||
keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
|
||||
keyType: resolvedHost.keyType || null,
|
||||
credentialId: resolvedHost.credentialId || null,
|
||||
overrideCredentialUsername: !!resolvedHost.overrideCredentialUsername,
|
||||
sudoPassword: shareMode ? null : resolvedHost.sudoPassword || null,
|
||||
jumpHosts: resolvedHost.jumpHosts
|
||||
? JSON.parse(resolvedHost.jumpHosts as string)
|
||||
: null,
|
||||
terminalConfig: terminal.terminalConfig ?? null,
|
||||
sshOptions: terminal.sshOptions,
|
||||
forceKeyboardInteractive: resolvedHost.forceKeyboardInteractive === "true",
|
||||
useSocks5: !!resolvedHost.useSocks5,
|
||||
socks5Host: resolvedHost.socks5Host || null,
|
||||
socks5Port: resolvedHost.socks5Port || null,
|
||||
socks5Username: resolvedHost.socks5Username || null,
|
||||
socks5Password: shareMode ? null : resolvedHost.socks5Password || null,
|
||||
socks5ProxyChain: resolvedHost.socks5ProxyChain
|
||||
? JSON.parse(resolvedHost.socks5ProxyChain as string)
|
||||
: null,
|
||||
portKnockSequence: resolvedHost.portKnockSequence
|
||||
? JSON.parse(resolvedHost.portKnockSequence as string)
|
||||
: null,
|
||||
};
|
||||
return { data, isRemoteDesktop, resolved: resolvedHost };
|
||||
}
|
||||
|
||||
export interface HostExport {
|
||||
hosts: Row[];
|
||||
/** Share mode only. */
|
||||
version?: string;
|
||||
exportedAt?: string;
|
||||
credentials?: Row[];
|
||||
}
|
||||
|
||||
/**
|
||||
* All of the user's own hosts. Without shareMode credentials are decrypted and
|
||||
* embedded. With it every secret is left out and credential logins point at a
|
||||
* scrubbed credentials list by alias.
|
||||
*/
|
||||
export async function exportAllHosts(
|
||||
userId: string,
|
||||
shareMode: boolean,
|
||||
hostIds?: number[],
|
||||
): Promise<HostExport> {
|
||||
const owned =
|
||||
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
||||
const wanted = hostIds ? new Set(hostIds) : null;
|
||||
const allHosts = wanted
|
||||
? owned.filter((host) => wanted.has(host.id as number))
|
||||
: owned;
|
||||
const settings = await loadHostPluginSettings(
|
||||
allHosts.map((h) => h.id as number),
|
||||
);
|
||||
|
||||
const hosts: Row[] = [];
|
||||
const usedCredentialIds = new Set<number>();
|
||||
|
||||
for (const host of allHosts) {
|
||||
const { data, isRemoteDesktop, resolved } = await exportRow(
|
||||
host as unknown as Row,
|
||||
userId,
|
||||
shareMode,
|
||||
settings,
|
||||
);
|
||||
if (
|
||||
shareMode &&
|
||||
!isRemoteDesktop &&
|
||||
resolved.authType === "credential" &&
|
||||
resolved.credentialId
|
||||
) {
|
||||
usedCredentialIds.add(resolved.credentialId as number);
|
||||
}
|
||||
hosts.push(data);
|
||||
}
|
||||
|
||||
if (!shareMode) return { hosts };
|
||||
|
||||
const credentials: Row[] = [];
|
||||
if (usedCredentialIds.size > 0) {
|
||||
const owned =
|
||||
await createCurrentCredentialRepository().listDecryptedByUserId(userId);
|
||||
const byId = new Map(
|
||||
owned.map((credential) => [credential.id, credential]),
|
||||
);
|
||||
|
||||
for (const host of hosts) {
|
||||
const credentialId = host.credentialId as number | null;
|
||||
if (!credentialId) continue;
|
||||
const credential = byId.get(credentialId);
|
||||
if (!credential) continue;
|
||||
|
||||
host.credentialAlias = credential.name;
|
||||
|
||||
if (!credentials.some((entry) => entry.alias === credential.name)) {
|
||||
credentials.push({
|
||||
alias: credential.name,
|
||||
name: credential.name,
|
||||
description: credential.description || null,
|
||||
folder: credential.folder || null,
|
||||
tags:
|
||||
typeof credential.tags === "string"
|
||||
? credential.tags.split(",").filter(Boolean)
|
||||
: [],
|
||||
authType: credential.authType,
|
||||
username: credential.username || null,
|
||||
keyType: credential.keyType || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const host of hosts) delete host.credentialId;
|
||||
|
||||
return {
|
||||
version: "1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
credentials,
|
||||
hosts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
import {
|
||||
prepareHostImports,
|
||||
remapImportedJumpHosts,
|
||||
} from "../../database/routes/host-import-order.js";
|
||||
import { sshOptionsForWrite } from "../ssh-options.js";
|
||||
import {
|
||||
applyDefaultsAfterHostWrites,
|
||||
applyHostDefaultsToWrite,
|
||||
} from "../defaults/index.js";
|
||||
import {
|
||||
keepUsableProtocolCredentials,
|
||||
readProtocolAuthPayload,
|
||||
writeProtocolAuth,
|
||||
} from "../protocol-auth/protocol-auth.js";
|
||||
import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostDefaultsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import {
|
||||
listSshAuthProviders,
|
||||
listSshAuthTypeOwners,
|
||||
} from "../connect/auth-provider-registry.js";
|
||||
import { applyPluginHostImportSettings } from "../../database/routes/host-plugin-settings.js";
|
||||
import {
|
||||
isNonEmptyString,
|
||||
isValidPort,
|
||||
normalizeImportedHost,
|
||||
} from "../../database/routes/host-normalizers.js";
|
||||
|
||||
/** A request the caller got wrong. Routes answer it with a 400. */
|
||||
export class ImportError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ImportError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImportResults {
|
||||
success: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
/** Hosts this call created. */
|
||||
createdHostIds: number[];
|
||||
/** True when atomic was set and a failure undid the creates. */
|
||||
rolledBack?: boolean;
|
||||
}
|
||||
|
||||
export interface BulkImportInput {
|
||||
hosts: unknown;
|
||||
overwrite?: unknown;
|
||||
credentials?: unknown;
|
||||
/** All or nothing: any failure deletes what this call created. */
|
||||
atomic?: boolean;
|
||||
}
|
||||
|
||||
type SSHConfigHost = {
|
||||
name: string;
|
||||
hostname?: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
identityFile?: string;
|
||||
proxyJump?: string;
|
||||
};
|
||||
|
||||
type ShareCredential = {
|
||||
alias?: unknown;
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
folder?: unknown;
|
||||
tags?: unknown;
|
||||
authType?: unknown;
|
||||
username?: unknown;
|
||||
keyType?: unknown;
|
||||
};
|
||||
|
||||
function textValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function tagString(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((tag) => textValue(tag))
|
||||
.filter((tag): tag is string => !!tag)
|
||||
.join(",");
|
||||
}
|
||||
return textValue(value) || "";
|
||||
}
|
||||
|
||||
function normalizeCredentialAuthType(value: unknown): "password" | "key" {
|
||||
return value === "key" ? "key" : "password";
|
||||
}
|
||||
|
||||
export function parseSSHConfig(content: string): SSHConfigHost[] {
|
||||
const results: SSHConfigHost[] = [];
|
||||
let current: SSHConfigHost | null = null;
|
||||
|
||||
for (const rawLine of content.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
|
||||
const match = /^(\S+?)(?:\s*=\s*|\s+)(.+)$/.exec(line);
|
||||
if (!match) continue;
|
||||
|
||||
const key = match[1].toLowerCase();
|
||||
const value = match[2];
|
||||
|
||||
if (key === "host") {
|
||||
if (current && current.hostname) results.push(current);
|
||||
// Skip wildcard patterns
|
||||
if (value === "*" || value.includes("*") || value.includes("?")) {
|
||||
current = null;
|
||||
} else {
|
||||
current = { name: value };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) continue;
|
||||
|
||||
switch (key) {
|
||||
case "hostname":
|
||||
current.hostname = value;
|
||||
break;
|
||||
case "user":
|
||||
current.user = value;
|
||||
break;
|
||||
case "port": {
|
||||
const p = Number.parseInt(value, 10);
|
||||
if (p > 0 && p <= 65535) current.port = p;
|
||||
break;
|
||||
}
|
||||
case "identityfile":
|
||||
if (!current.identityFile) current.identityFile = value;
|
||||
break;
|
||||
case "proxyjump":
|
||||
current.proxyJump = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (current && current.hostname) results.push(current);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells status checks and other pollers about hosts an import wrote, the way
|
||||
* a single host create or update does.
|
||||
*/
|
||||
export function notifyImportedHosts(
|
||||
userId: string,
|
||||
hostIds: number[],
|
||||
createdHostIds: number[],
|
||||
): void {
|
||||
for (const hostId of createdHostIds) {
|
||||
pluginEvents.emit(TOPICS.hostCreated, { hostId, userId });
|
||||
}
|
||||
for (const hostId of hostIds) {
|
||||
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
|
||||
}
|
||||
}
|
||||
|
||||
export function importedHostUsername(
|
||||
connectionType: string,
|
||||
authType: unknown,
|
||||
username: unknown,
|
||||
): string | null {
|
||||
if (isNonEmptyString(username)) return username;
|
||||
if (connectionType !== "ssh" || authType === "credential") return "";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status check fields from an import row. Exports from before 2.9.0 carried
|
||||
* them inside statsConfig.
|
||||
*/
|
||||
function importedStatusCheck(raw: Record<string, unknown>): {
|
||||
statusCheckEnabled: boolean;
|
||||
statusCheckInterval: number | null;
|
||||
} {
|
||||
const legacy =
|
||||
raw.statsConfig && typeof raw.statsConfig === "object"
|
||||
? (raw.statsConfig as Record<string, unknown>)
|
||||
: {};
|
||||
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
|
||||
const useGlobal = legacy.useGlobalStatusInterval !== false;
|
||||
const interval =
|
||||
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
|
||||
const seconds = Number(interval);
|
||||
return {
|
||||
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
|
||||
statusCheckInterval:
|
||||
interval != null && Number.isInteger(seconds) && seconds >= 5
|
||||
? seconds
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const BUILTIN_SSH_AUTH_TYPES = [
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"agent",
|
||||
"none",
|
||||
];
|
||||
|
||||
/** Core's own types plus every type a plugin registers or declares. */
|
||||
function listKnownAuthTypes(): Set<string> {
|
||||
return new Set([
|
||||
...BUILTIN_SSH_AUTH_TYPES,
|
||||
...listSshAuthProviders().map((provider) => provider.type),
|
||||
...listSshAuthTypeOwners().map((owner) => owner.type),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Fills an imported host's inherited keys and records what it sets itself. */
|
||||
async function withHostDefaults(
|
||||
userId: string,
|
||||
hostId: number | null,
|
||||
columns: Record<string, unknown>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const stored =
|
||||
hostId === null
|
||||
? null
|
||||
: ((
|
||||
await createCurrentHostDefaultsRepository().listHosts({
|
||||
hostIds: [hostId],
|
||||
})
|
||||
)[0] ?? null);
|
||||
columns.defaultOverrides = JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId,
|
||||
columns,
|
||||
body,
|
||||
stored,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function importSummary(results: ImportResults): string {
|
||||
return `Import completed: ${results.success} created, ${results.updated} updated, ${results.skipped} skipped, ${results.failed} failed`;
|
||||
}
|
||||
|
||||
export function importResponse(results: ImportResults) {
|
||||
return {
|
||||
message: importSummary(results),
|
||||
success: results.success,
|
||||
updated: results.updated,
|
||||
skipped: results.skipped,
|
||||
failed: results.failed,
|
||||
errors: results.errors,
|
||||
};
|
||||
}
|
||||
|
||||
export const MAX_IMPORT_HOSTS = 100;
|
||||
|
||||
/** Deletes what an atomic import created, newest first. */
|
||||
async function rollBackCreated(
|
||||
userId: string,
|
||||
results: ImportResults,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentHostRepository();
|
||||
for (const hostId of [...results.createdHostIds].reverse()) {
|
||||
await repository.deleteForUser(userId, hostId).catch(() => {});
|
||||
}
|
||||
results.rolledBack = true;
|
||||
results.success = 0;
|
||||
results.createdHostIds = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The Termix JSON import: shared by POST /host/bulk-import and
|
||||
* ctx.hosts.importMany. Callers wrap it in DatabaseSaveTrigger.batched.
|
||||
*/
|
||||
export async function runBulkImport(
|
||||
userId: string,
|
||||
input: BulkImportInput,
|
||||
): Promise<ImportResults> {
|
||||
const {
|
||||
hosts: hostsToImport,
|
||||
overwrite,
|
||||
credentials: credentialsToImport,
|
||||
} = input;
|
||||
if (input.atomic && overwrite) {
|
||||
throw new ImportError("An atomic import cannot overwrite existing hosts");
|
||||
}
|
||||
|
||||
if (!Array.isArray(hostsToImport) || hostsToImport.length === 0) {
|
||||
throw new ImportError("Hosts array is required and must not be empty");
|
||||
}
|
||||
|
||||
if (hostsToImport.length > 100) {
|
||||
throw new ImportError("Maximum 100 hosts allowed per import");
|
||||
}
|
||||
|
||||
let orderedHosts: ReturnType<typeof prepareHostImports>;
|
||||
try {
|
||||
orderedHosts = prepareHostImports(hostsToImport);
|
||||
} catch (error) {
|
||||
throw new ImportError(getErrorMessage(error));
|
||||
}
|
||||
const importedIds = new Map<unknown, number>();
|
||||
|
||||
const results: ImportResults = {
|
||||
success: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
createdHostIds: [],
|
||||
};
|
||||
|
||||
const credentialAliasMap = new Map<string, number>();
|
||||
const addCredentialAlias = (alias: unknown, id: number) => {
|
||||
const key = textValue(alias);
|
||||
if (key) credentialAliasMap.set(key.toLowerCase(), id);
|
||||
};
|
||||
|
||||
try {
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const existingCredentials =
|
||||
await credentialRepository.listDecryptedByUserId(userId);
|
||||
|
||||
for (const credential of existingCredentials) {
|
||||
addCredentialAlias(credential.name, credential.id as number);
|
||||
}
|
||||
|
||||
if (Array.isArray(credentialsToImport)) {
|
||||
for (const rawCredential of credentialsToImport as ShareCredential[]) {
|
||||
const alias = textValue(rawCredential.alias);
|
||||
const name = textValue(rawCredential.name) || alias;
|
||||
if (!alias || !name) continue;
|
||||
|
||||
const existingId = credentialAliasMap.get(name.toLowerCase());
|
||||
if (existingId) {
|
||||
addCredentialAlias(alias, existingId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const created = await credentialRepository.createEncryptedForUser(
|
||||
userId,
|
||||
{
|
||||
userId,
|
||||
name,
|
||||
description:
|
||||
textValue(rawCredential.description) ||
|
||||
"Imported placeholder. Add the secret before connecting.",
|
||||
folder: textValue(rawCredential.folder),
|
||||
tags: tagString(rawCredential.tags),
|
||||
authType: normalizeCredentialAuthType(rawCredential.authType),
|
||||
username: textValue(rawCredential.username),
|
||||
password: null,
|
||||
key: null,
|
||||
privateKey: null,
|
||||
publicKey: null,
|
||||
keyPassword: null,
|
||||
keyType: textValue(rawCredential.keyType),
|
||||
detectedKeyType: null,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
);
|
||||
|
||||
const createdCredential = created as Record<string, unknown>;
|
||||
addCredentialAlias(alias, createdCredential.id as number);
|
||||
addCredentialAlias(name, createdCredential.id as number);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
results.errors.push(
|
||||
`Credential placeholders: ${getErrorMessage(error, "failed to prepare credential aliases")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Loaded for both modes: overwrite updates a match, skip leaves it alone.
|
||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
try {
|
||||
const allHosts =
|
||||
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
||||
existingHostMap = new Map();
|
||||
for (const h of allHosts) {
|
||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||
existingHostMap.set(key, { id: h.id as number });
|
||||
}
|
||||
} catch {
|
||||
existingHostMap = undefined;
|
||||
}
|
||||
|
||||
const knownAuthTypes = listKnownAuthTypes();
|
||||
const writtenHostIds: number[] = [];
|
||||
const createdHostIds: number[] = [];
|
||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||
try {
|
||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "credential" &&
|
||||
!hostData.credentialId &&
|
||||
hostData.credentialAlias
|
||||
) {
|
||||
hostData.credentialId = credentialAliasMap.get(
|
||||
hostData.credentialAlias.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Missing required fields (ip, port)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const username = importedHostUsername(
|
||||
effectiveConnectionType,
|
||||
hostData.authType,
|
||||
hostData.username,
|
||||
);
|
||||
if (username === null) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Username required for SSH connections`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
if (existing && !overwrite) {
|
||||
// Hosts that jump through this one still resolve to the kept host.
|
||||
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType &&
|
||||
!knownAuthTypes.has(hostData.authType)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Invalid authType. Must be one of ${[...knownAuthTypes].join(", ")}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "password" &&
|
||||
!isNonEmptyString(hostData.password)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Password required for password authentication`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "key" &&
|
||||
!isNonEmptyString(hostData.key)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: Key required for key authentication`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "credential" &&
|
||||
!hostData.credentialId
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: credentialId required for credential authentication`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
effectiveConnectionType === "ssh" &&
|
||||
hostData.authType === "credential" &&
|
||||
hostData.credentialId
|
||||
) {
|
||||
const credentialRepository = createCurrentCredentialRepository();
|
||||
const cred = await credentialRepository.findByIdForUser(
|
||||
userId,
|
||||
hostData.credentialId,
|
||||
);
|
||||
|
||||
if (!cred) {
|
||||
const fallback = await credentialRepository.listByUserId(userId);
|
||||
|
||||
if (fallback.length > 0) {
|
||||
hostData.credentialId = fallback[0].id;
|
||||
} else if (isNonEmptyString(hostData.key)) {
|
||||
hostData.authType = "key";
|
||||
hostData.credentialId = undefined;
|
||||
} else if (isNonEmptyString(hostData.password)) {
|
||||
hostData.authType = "password";
|
||||
hostData.credentialId = undefined;
|
||||
} else {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host ${i + 1}: credentialId ${hostData.credentialId} not found and no fallback credential available`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jumpHosts = remapImportedJumpHosts(hostData.jumpHosts, importedIds);
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId: userId,
|
||||
connectionType: effectiveConnectionType,
|
||||
name: hostData.name || `${username}@${hostData.ip}`,
|
||||
folder: hostData.folder || "Default",
|
||||
tags: Array.isArray(hostData.tags) ? hostData.tags.join(",") : "",
|
||||
ip: hostData.ip,
|
||||
port: hostData.port,
|
||||
username,
|
||||
pin: hostData.pin || false,
|
||||
sudoPassword: hostData.sudoPassword || null,
|
||||
jumpHosts: jumpHosts ? JSON.stringify(jumpHosts) : null,
|
||||
...importedStatusCheck(hostData as Record<string, unknown>),
|
||||
// A 2.8 export carries these inside terminalConfig.
|
||||
sshOptions:
|
||||
sshOptionsForWrite({
|
||||
sshOptions: hostData.sshOptions,
|
||||
terminalConfig: hostData.terminalConfig,
|
||||
}) ?? null,
|
||||
forceKeyboardInteractive: hostData.forceKeyboardInteractive
|
||||
? "true"
|
||||
: "false",
|
||||
notes: hostData.notes || null,
|
||||
useSocks5: hostData.useSocks5 ? 1 : 0,
|
||||
socks5Host: hostData.socks5Host || null,
|
||||
socks5Port: hostData.socks5Port || null,
|
||||
socks5Username: hostData.socks5Username || null,
|
||||
socks5Password: hostData.socks5Password || null,
|
||||
socks5ProxyChain: hostData.socks5ProxyChain
|
||||
? JSON.stringify(hostData.socks5ProxyChain)
|
||||
: null,
|
||||
portKnockSequence: hostData.portKnockSequence
|
||||
? JSON.stringify(hostData.portKnockSequence)
|
||||
: null,
|
||||
overrideCredentialUsername: hostData.overrideCredentialUsername ? 1 : 0,
|
||||
enableSsh: hostData.enableSsh ?? effectiveConnectionType === "ssh",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (effectiveConnectionType !== "ssh") {
|
||||
sshDataObj.password = hostData.password || null;
|
||||
sshDataObj.authType = "password";
|
||||
sshDataObj.credentialId = null;
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
} else {
|
||||
sshDataObj.password =
|
||||
hostData.authType === "password" ? hostData.password : null;
|
||||
sshDataObj.authType = hostData.authType || "password";
|
||||
sshDataObj.credentialId =
|
||||
hostData.authType === "credential" ? hostData.credentialId : null;
|
||||
sshDataObj.key = hostData.authType === "key" ? hostData.key : null;
|
||||
sshDataObj.keyPassword =
|
||||
hostData.authType === "key" ? hostData.keyPassword || null : null;
|
||||
sshDataObj.keyType =
|
||||
hostData.authType === "key" ? hostData.keyType || "auto" : null;
|
||||
}
|
||||
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
sshDataObj,
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
|
||||
let savedHostId: number;
|
||||
if (existing) {
|
||||
const saved = await hostRepository.updateEncryptedForUser(
|
||||
userId,
|
||||
existing.id,
|
||||
sshDataObj,
|
||||
);
|
||||
if (!saved) throw new Error("Host no longer exists");
|
||||
if (exportId !== undefined) importedIds.set(exportId, existing.id);
|
||||
savedHostId = existing.id;
|
||||
results.updated++;
|
||||
} else {
|
||||
sshDataObj.createdAt = new Date().toISOString();
|
||||
const saved = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
sshDataObj,
|
||||
);
|
||||
if (exportId !== undefined) importedIds.set(exportId, saved.id);
|
||||
savedHostId = saved.id;
|
||||
createdHostIds.push(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
|
||||
const protocolAuth = readProtocolAuthPayload(
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
if (protocolAuth) {
|
||||
await writeProtocolAuth(
|
||||
userId,
|
||||
savedHostId,
|
||||
await keepUsableProtocolCredentials(protocolAuth, userId),
|
||||
{ isOwner: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Every enabled plugin that declares host-scope settings and
|
||||
// registered a hostImportNormalizer validates and writes its own
|
||||
// fields here, so this loop does not need to know which plugins
|
||||
// exist. See host-plugin-settings.ts.
|
||||
await applyPluginHostImportSettings(
|
||||
savedHostId,
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
writtenHostIds.push(savedHostId);
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
results.createdHostIds = createdHostIds;
|
||||
if (input.atomic && results.failed > 0) {
|
||||
await rollBackCreated(userId, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||
results.createdHostIds = createdHostIds;
|
||||
return results;
|
||||
}
|
||||
|
||||
/** The OpenSSH config import, shared by the route and ctx.hosts. */
|
||||
export async function runSshConfigImport(
|
||||
userId: string,
|
||||
input: { content?: unknown; overwrite?: unknown; atomic?: boolean },
|
||||
): Promise<ImportResults> {
|
||||
const { content, overwrite } = input;
|
||||
if (input.atomic && overwrite) {
|
||||
throw new ImportError("An atomic import cannot overwrite existing hosts");
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(content)) {
|
||||
throw new ImportError("content is required and must be a non-empty string");
|
||||
}
|
||||
|
||||
let parsed: SSHConfigHost[];
|
||||
try {
|
||||
parsed = parseSSHConfig(content);
|
||||
} catch {
|
||||
throw new ImportError("Failed to parse SSH config file");
|
||||
}
|
||||
|
||||
if (parsed.length === 0) {
|
||||
throw new ImportError("No valid Host entries found in the SSH config file");
|
||||
}
|
||||
|
||||
if (parsed.length > 100) {
|
||||
throw new ImportError("Maximum 100 hosts allowed per import");
|
||||
}
|
||||
|
||||
const hostsToImport = parsed.map((h) => ({
|
||||
name: h.name,
|
||||
ip: h.hostname,
|
||||
port: h.port ?? 22,
|
||||
username: h.user,
|
||||
authType: h.identityFile ? "key" : undefined,
|
||||
connectionType: "ssh",
|
||||
enableSsh: true,
|
||||
...(h.proxyJump
|
||||
? {
|
||||
jumpHosts: [{ host: h.proxyJump, port: 22 }],
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const results: ImportResults = {
|
||||
success: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
createdHostIds: [],
|
||||
};
|
||||
|
||||
let existingHostMap: Map<string, { id: number }> | undefined;
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
try {
|
||||
const allHosts =
|
||||
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
|
||||
existingHostMap = new Map();
|
||||
for (const h of allHosts) {
|
||||
const key = `${h.ip}:${h.port}:${h.username}`;
|
||||
existingHostMap.set(key, { id: h.id as number });
|
||||
}
|
||||
} catch {
|
||||
existingHostMap = undefined;
|
||||
}
|
||||
|
||||
const writtenHostIds: number[] = [];
|
||||
const createdHostIds: number[] = [];
|
||||
for (let i = 0; i < hostsToImport.length; i++) {
|
||||
const hostData = normalizeImportedHost(
|
||||
hostsToImport[i] as Record<string, unknown>,
|
||||
);
|
||||
|
||||
try {
|
||||
if (!isNonEmptyString(hostData.ip) || !isValidPort(hostData.port)) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host "${parsed[i].name}": Missing required fields (HostName, Port)`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
if (existing && !overwrite) {
|
||||
results.skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId,
|
||||
connectionType: "ssh",
|
||||
name: hostData.name || hostData.ip,
|
||||
folder: "Default",
|
||||
tags: "",
|
||||
ip: hostData.ip,
|
||||
port: hostData.port,
|
||||
username: hostData.username || null,
|
||||
authType: hostData.authType || "none",
|
||||
password: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
keyType: null,
|
||||
credentialId: null,
|
||||
pin: false,
|
||||
sudoPassword: null,
|
||||
jumpHosts: hostData.jumpHosts
|
||||
? JSON.stringify(hostData.jumpHosts)
|
||||
: null,
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: null,
|
||||
sshOptions: null,
|
||||
forceKeyboardInteractive: "false",
|
||||
notes: null,
|
||||
useSocks5: 0,
|
||||
socks5Host: null,
|
||||
socks5Port: null,
|
||||
socks5Username: null,
|
||||
socks5Password: null,
|
||||
socks5ProxyChain: null,
|
||||
portKnockSequence: null,
|
||||
overrideCredentialUsername: 0,
|
||||
enableSsh: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
sshDataObj,
|
||||
hostData as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await hostRepository.updateEncryptedForUser(
|
||||
userId,
|
||||
existing.id,
|
||||
sshDataObj,
|
||||
);
|
||||
writtenHostIds.push(existing.id);
|
||||
results.updated++;
|
||||
} else {
|
||||
sshDataObj.createdAt = new Date().toISOString();
|
||||
const saved = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
sshDataObj,
|
||||
);
|
||||
writtenHostIds.push(saved.id);
|
||||
createdHostIds.push(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Host "${parsed[i].name}": ${getErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
results.createdHostIds = createdHostIds;
|
||||
if (input.atomic && results.failed > 0) {
|
||||
await rollBackCreated(userId, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||
notifyImportedHosts(userId, writtenHostIds, createdHostIds);
|
||||
results.createdHostIds = createdHostIds;
|
||||
return results;
|
||||
}
|
||||
Reference in new issue
Block a user