feat: store rdp, vnc and telnet logins in a generic per-protocol table

This commit is contained in:
LukeGus committed 2026-09-29 12:33:09 -05:00
1 parent e0c30a4e6f
commit bba06f7ff6
107 files changed
+18750 -1224

No files matched your search

+4 -3
View File
@@ -17,9 +17,10 @@ import {
} from "@/main-axios";
import type { Credential, Host } from "@/types/ui-types";
import {
AUTH_PROTOCOL_METADATA,
SSH_AUTH_PROTOCOL,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import { authProtocolLabel } from "./host-permissions";
import { mapCredentials } from "./HostManagerData";
import { getConnectedRemoteApi } from "@/lib/remote-server-api";
@@ -44,7 +45,7 @@ export function HostAuthOverrideModal({
const overrideState = host.authOverrides?.[protocol];
const ownerAuthShared =
overrideState?.ownerAuthShared ??
(protocol === "ssh" ? !!host.shareSshAuth : false);
(protocol === SSH_AUTH_PROTOCOL ? !!host.shareSshAuth : false);
// A shared host's copy on a linked desktop: its override lives on the
// server, against the server's own credentials.
const sharedCopySyncId = host.sharedCopy ? (host.syncId ?? null) : null;
@@ -127,7 +128,7 @@ export function HostAuthOverrideModal({
<DialogHeader>
<DialogTitle>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: AUTH_PROTOCOL_METADATA[protocol].label,
protocol: authProtocolLabel(protocol, t),
})}
</DialogTitle>
<DialogDescription>
-19
View File
@@ -145,25 +145,6 @@ export function HostEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [host]);
useEffect(() => {
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
let cancelled = false;
const loadVncPassword = adminTargetUserId
? adminGetHostPassword(adminTargetUserId, Number(host.id), "vncPassword")
: getHostPassword(Number(host.id), "vncPassword");
loadVncPassword.then((password) => {
if (cancelled || !password) return;
setForm((prev) =>
prev.vncPassword ? prev : { ...prev, vncPassword: password },
);
});
return () => {
cancelled = true;
};
}, [form.vncAuthType, form.vncPassword, host?.id, adminTargetUserId]);
/**
* Writes each plugin's host-scope values through its own route.
*
+70 -80
View File
@@ -1,6 +1,11 @@
import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import type { HostProtocolAuthInput, SSHHostData } from "@/types";
import {
HOST_PROTOCOL_SECRET_KEPT,
type HostDraft,
type HostProtocolAuthForm,
type HostProtocolAuthSummary,
} from "@termix/plugin-sdk/frontend";
import type { HostDefaults } from "@/api/settings-api";
import {
listHostProtocols,
@@ -44,6 +49,66 @@ export function applyHostDraft(
};
}
/** A saved login as the editor form holds it; saved secrets stay behind a placeholder. */
export function protocolAuthForm(
summaries: Record<string, HostProtocolAuthSummary> | undefined,
): Record<string, HostProtocolAuthForm> {
const form: Record<string, HostProtocolAuthForm> = {};
for (const [protocol, summary] of Object.entries(summaries ?? {})) {
const fields: Record<string, string> = { ...(summary.fields ?? {}) };
for (const key of summary.secretFieldKeys ?? []) {
fields[key] = HOST_PROTOCOL_SECRET_KEPT;
}
form[protocol] = {
authType:
summary.authType ?? (summary.credentialId ? "credential" : "direct"),
credentialId:
summary.credentialId != null ? String(summary.credentialId) : "",
username: summary.username ?? "",
password: summary.hasPassword ? HOST_PROTOCOL_SECRET_KEPT : "",
fields,
};
}
return form;
}
/**
* The logins a host save sends: one per plugin protocol the editor knows,
* removed when its switch is off. A saved secret left alone is not sent.
*/
export function protocolAuthPayload(
form: Record<string, HostProtocolAuthForm>,
protocols: HostProtocols,
): Record<string, HostProtocolAuthInput | null> {
const payload: Record<string, HostProtocolAuthInput | null> = {};
for (const protocol of listHostProtocols()) {
if (!protocols[protocol.settingKey]) {
payload[protocol.id] = null;
continue;
}
const login = form[protocol.id];
if (!login) continue;
const direct = login.authType === "direct";
payload[protocol.id] = {
authType: login.authType,
credentialId:
login.authType === "credential" && login.credentialId
? Number(login.credentialId)
: null,
username: direct ? login.username || null : null,
...(direct && login.password !== HOST_PROTOCOL_SECRET_KEPT
? { password: login.password || null }
: {}),
fields: Object.fromEntries(
Object.entries(login.fields).filter(
([, value]) => value !== HOST_PROTOCOL_SECRET_KEPT,
),
),
};
}
return payload;
}
export function createHostEditorForm(
host: Host | null,
defaults?: HostDefaults,
@@ -107,32 +172,8 @@ export function createHostEditorForm(
portKnockSequence:
host?.portKnockSequence ??
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
rdpCredentialId: host?.rdpCredentialId ?? "",
rdpUser: host?.rdpUser ?? "",
rdpPassword: host?.hasRdpPassword
? "existing_rdp_password"
: (host?.rdpPassword ?? ""),
domain: host?.domain ?? "",
vncCredentialId: host?.vncCredentialId ?? "",
vncPassword: host?.hasVncPassword
? "existing_vnc_password"
: (host?.vncPassword ?? ""),
vncUser: host?.vncUser ?? "",
telnetUser: host?.telnetUser ?? "",
telnetPassword: host?.hasTelnetPassword
? "existing_telnet_password"
: (host?.telnetPassword ?? ""),
telnetCredentialId:
host?.telnetCredentialId != null ? String(host.telnetCredentialId) : "",
rdpAuthType: (host?.rdpAuthType ??
(host?.rdpCredentialId ? "credential" : "direct")) as
"direct" | "credential" | "none",
vncAuthType: (host?.vncAuthType ??
(host?.vncCredentialId ? "credential" : "direct")) as
"direct" | "credential",
telnetAuthType: (host?.telnetAuthType ??
(host?.telnetCredentialId ? "credential" : "direct")) as
"direct" | "credential",
// Each plugin protocol's login, edited by that plugin's host editor tab.
protocolAuth: protocolAuthForm(host?.protocolAuth),
statusCheckEnabled:
host?.statusCheckEnabled ?? d?.statusCheckEnabled ?? true,
statusCheckInterval: (host?.statusCheckInterval ?? null) as number | null,
@@ -242,58 +283,7 @@ export function buildHostEditorPayload(
enableSsh: protocols.enableSsh,
sshPort: Number(form.sshPort),
forceKeyboardInteractive: form.forceKeyboardInteractive,
rdpAuthType: protocols.enableRdp ? form.rdpAuthType : null,
rdpCredentialId:
protocols.enableRdp &&
form.rdpAuthType === "credential" &&
form.rdpCredentialId
? Number(form.rdpCredentialId)
: null,
rdpUser:
protocols.enableRdp && form.rdpAuthType === "direct"
? form.rdpUser || null
: null,
rdpPassword:
protocols.enableRdp &&
form.rdpAuthType === "direct" &&
form.rdpPassword !== "existing_rdp_password"
? form.rdpPassword || null
: null,
rdpDomain: form.domain || null,
vncAuthType: protocols.enableVnc ? form.vncAuthType : null,
vncCredentialId:
protocols.enableVnc &&
form.vncAuthType === "credential" &&
form.vncCredentialId
? Number(form.vncCredentialId)
: null,
vncPassword:
protocols.enableVnc &&
form.vncAuthType === "direct" &&
form.vncPassword !== "existing_vnc_password"
? form.vncPassword || null
: null,
vncUser:
protocols.enableVnc && form.vncAuthType === "direct"
? form.vncUser || null
: null,
telnetAuthType: protocols.enableTelnet ? form.telnetAuthType : null,
telnetCredentialId:
protocols.enableTelnet &&
form.telnetAuthType === "credential" &&
form.telnetCredentialId
? Number(form.telnetCredentialId)
: null,
telnetUser:
protocols.enableTelnet && form.telnetAuthType === "direct"
? form.telnetUser || null
: null,
telnetPassword:
protocols.enableTelnet &&
form.telnetAuthType === "direct" &&
form.telnetPassword !== "existing_telnet_password"
? form.telnetPassword || null
: null,
protocolAuth: protocolAuthPayload(form.protocolAuth, protocols),
// The editor keeps ids as strings; the API and every backend lookup take
// a number, and a string id does not compare equal on Postgres/MySQL.
jumpHosts: form.jumpHosts.map((j) => ({ hostId: Number(j.hostId) })),
+1 -1
View File
@@ -417,7 +417,7 @@ export function HostManager({
: makeCredentialTabs(t);
// Collapsing while on a now-hidden tab would leave nothing selected. The
// SSH group collapses to its own "ssh" tab, everything else to General.
// The top-level strip only lists general/ssh/rdp/vnc/telnet -- the SSH
// The top-level strip only lists general, ssh and protocol tabs -- the SSH
// sub-tabs live in the secondary strip, so they count as visible whenever
// the SSH group is expanded.
const sshSubTabs = makeHostSshSubTabs(t, protocols);
+16 -39
View File
@@ -6,9 +6,6 @@ type RawSSHHost = SSHHostWithStatus & {
hasKey?: boolean;
hasKeyPassword?: boolean;
hasSudoPassword?: boolean;
hasRdpPassword?: boolean;
hasVncPassword?: boolean;
hasTelnetPassword?: boolean;
};
type HostJumpHost = NonNullable<Host["jumpHosts"]>[number];
type RawCredential = {
@@ -81,31 +78,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
sshPort:
h.sshPort ??
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
rdpAuthType:
(h.rdpAuthType as "direct" | "credential") ??
(h.rdpCredentialId ? "credential" : "direct"),
rdpCredentialId:
h.rdpCredentialId != null ? String(h.rdpCredentialId) : undefined,
rdpUser: h.rdpUser,
rdpPassword: h.rdpPassword ?? "",
hasRdpPassword: !!host.hasRdpPassword || !!h.rdpPassword,
domain: h.rdpDomain,
vncAuthType:
(h.vncAuthType as "direct" | "credential") ??
(h.vncCredentialId ? "credential" : "direct"),
vncCredentialId:
h.vncCredentialId != null ? String(h.vncCredentialId) : undefined,
vncPassword: h.vncPassword ?? "",
hasVncPassword: !!host.hasVncPassword || !!h.vncPassword,
vncUser: h.vncUser,
telnetAuthType:
(h.telnetAuthType as "direct" | "credential") ??
(h.telnetCredentialId ? "credential" : "direct"),
telnetCredentialId:
h.telnetCredentialId != null ? String(h.telnetCredentialId) : undefined,
telnetUser: h.telnetUser,
telnetPassword: h.telnetPassword ?? "",
hasTelnetPassword: !!host.hasTelnetPassword || !!h.telnetPassword,
protocolAuth: h.protocolAuth ?? {},
jumpHosts: (parseJson<HostJumpHost[]>(h.jumpHosts) ?? []).map((j) => ({
hostId: String(j.hostId ?? j.hostid ?? j),
})),
@@ -126,18 +99,22 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
isShared: h.isShared ?? false,
authOverrides: h.authOverrides
? Object.fromEntries(
Object.entries(h.authOverrides).map(([protocol, state]) => [
protocol,
Object.entries(h.authOverrides).flatMap(([protocol, state]) =>
state
? {
...state,
credentialId:
state.credentialId != null
? String(state.credentialId)
: undefined,
}
: state,
]),
? [
[
protocol,
{
...state,
credentialId:
state.credentialId != null
? String(state.credentialId)
: undefined,
},
],
]
: [],
),
)
: undefined,
permissionLevel: h.permissionLevel,
+26 -6
View File
@@ -1,9 +1,13 @@
import type { Host, SharePermissionLevel } from "@/types/ui-types";
import {
isSupportedAuthOverrideProtocol,
SSH_AUTH_PROTOCOL,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import { listHostProtocols, protocolEnabled } from "./host-protocols";
import {
listHostProtocols,
protocolEnabled,
type HostProtocolDef,
} from "./host-protocols";
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
connect: 1,
@@ -32,14 +36,30 @@ export function canDeleteHost(host: Host): boolean {
return !host.isShared;
}
/** SSH, then every protocol a running plugin registered. */
export function authOverrideProtocols(
list: HostProtocolDef[] = listHostProtocols(),
): AuthOverrideProtocol[] {
return [SSH_AUTH_PROTOCOL, ...list.map((protocol) => protocol.id)];
}
/** How a protocol is named in the override menu and dialog. */
export function authProtocolLabel(
protocol: AuthOverrideProtocol,
t: (key: string) => string,
list: HostProtocolDef[] = listHostProtocols(),
): string {
if (protocol === SSH_AUTH_PROTOCOL) return "SSH";
const def = list.find((entry) => entry.id === protocol);
return def ? t(def.titleKey) : protocol;
}
export function canOverrideHostAuth(
host: Host,
protocol: AuthOverrideProtocol,
): boolean {
if (!host.isShared || !isSupportedAuthOverrideProtocol(protocol)) {
return false;
}
if (protocol === "ssh") return !!host.enableSsh;
if (!host.isShared) return false;
if (protocol === SSH_AUTH_PROTOCOL) return !!host.enableSsh;
// Other protocols are switched on in their plugin's host settings.
const plugin = listHostProtocols().find((entry) => entry.id === protocol);
return !!plugin && protocolEnabled(host.pluginSettings, plugin);
+1 -1
View File
@@ -4,7 +4,7 @@ import { byOrderThenId, createRegistry } from "@/lib/registry";
type Icon = ComponentType<{ className?: string; size?: number | string }>;
/**
* A connection protocol a plugin adds next to SSH (RDP, VNC). Its on/off
* A connection protocol a plugin adds next to SSH. Its on/off
* switch and port are the plugin's own host settings, so core reads them
* from host.pluginSettings[pluginId] and never knows the protocol by name.
*/
+8 -9
View File
@@ -33,19 +33,18 @@ export function isQuickConnectHost(host: Pick<Host, "id">): boolean {
export function createQuickConnectHost(input: QuickConnectInput): Host {
const protocol = input.protocol;
if (protocol) {
// Protocol logins are core host fields named after the protocol.
const login = {
[`${protocol.id}AuthType`]: "direct",
[`${protocol.id}User`]: input.username,
[`${protocol.id}Password`]: input.password,
};
return {
...createQuickConnectHost({ ...input, protocol: undefined, port: 22 }),
...login,
// Never saved, so the login travels in plain text to the protocol's tab.
quickConnectLogin: {
protocol: protocol.id,
username: input.username,
password: input.password,
fields: input.domain ? { domain: input.domain } : {},
},
port: input.port,
enableSsh: false,
quickConnectSavable: false,
domain: input.domain,
pluginSettings: {
[protocol.pluginId]: {
[protocol.settingKey]: true,
@@ -100,7 +99,7 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
}
/**
* Every way to reach the address: connect actions (terminal, RDP, VNC...)
* Every way to reach the address: connect actions (terminal, remote desktop)
* by priority, then tools that opted into Quick Connect. Only actions that
* open a tab work here, since the host is never saved.
*/
+5 -7
View File
@@ -50,13 +50,11 @@ import {
canEditHost,
canOverrideHostAuth,
canShareHost,
authOverrideProtocols as listAuthOverrideProtocols,
authProtocolLabel,
} from "@/sidebar/host-permissions";
import { HostAuthOverrideModal } from "@/sidebar/HostAuthOverrideModal";
import {
AUTH_OVERRIDE_PROTOCOLS,
AUTH_PROTOCOL_METADATA,
type AuthOverrideProtocol,
} from "@/types/auth-protocols";
import type { AuthOverrideProtocol } from "@/types/auth-protocols";
import {
useStatusColorScheme,
getStatusClasses,
@@ -323,7 +321,7 @@ export function HostItem({
!alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly);
const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
const authOverrideProtocols = AUTH_OVERRIDE_PROTOCOLS.filter((protocol) =>
const authOverrideProtocols = listAuthOverrideProtocols().filter((protocol) =>
canOverrideHostAuth(host, protocol),
);
const [authOverrideProtocol, setAuthOverrideProtocol] =
@@ -808,7 +806,7 @@ export function HostItem({
>
<KeyRound className="size-3.5 mr-2" />
{t("hosts.sharing.authOverrideActionProtocol", {
protocol: AUTH_PROTOCOL_METADATA[protocol].label,
protocol: authProtocolLabel(protocol, t),
})}
</DropdownMenuItem>
))}
+12 -11
View File
@@ -614,17 +614,18 @@ export function SidebarTree({
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
enableSsh: host.enableSsh,
sshPort: host.sshPort,
rdpUser: host.rdpUser ?? null,
rdpPassword: host.rdpPassword ?? null,
rdpDomain: host.domain ?? null,
vncAuthType: host.vncAuthType ?? null,
vncCredentialId: host.vncCredentialId
? Number(host.vncCredentialId)
: null,
vncPassword: host.vncPassword ?? null,
vncUser: host.vncUser ?? null,
telnetUser: host.telnetUser ?? null,
telnetPassword: host.telnetPassword ?? null,
// Saved passwords never reach the browser, so a copy starts without them.
protocolAuth: Object.fromEntries(
Object.entries(host.protocolAuth ?? {}).map(([protocol, login]) => [
protocol,
{
authType: login.authType,
credentialId: login.credentialId ?? null,
username: login.username ?? null,
fields: login.fields ?? {},
},
]),
),
forceKeyboardInteractive: host.forceKeyboardInteractive ?? false,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host ?? null,