mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: store rdp, vnc and telnet logins in a generic per-protocol table
This commit is contained in:
1 parent
e0c30a4e6f
commit
bba06f7ff6
107 files changed
+18750
-1224
No files matched your search
@@ -99,14 +99,7 @@ export async function adminDeleteUserHost(
|
||||
export async function adminGetHostPassword(
|
||||
targetUserId: string,
|
||||
hostId: number,
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "rdpPassword"
|
||||
| "vncPassword"
|
||||
| "telnetPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
field: "password" | "sudoPassword" | "key" | "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
|
||||
@@ -116,14 +116,7 @@ export async function getSSHHostWithCredentials(
|
||||
|
||||
export async function getHostPassword(
|
||||
hostId: number,
|
||||
field:
|
||||
| "password"
|
||||
| "sudoPassword"
|
||||
| "rdpPassword"
|
||||
| "vncPassword"
|
||||
| "telnetPassword"
|
||||
| "key"
|
||||
| "keyPassword" = "password",
|
||||
field: "password" | "sudoPassword" | "key" | "keyPassword" = "password",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await sshHostApi.get(
|
||||
|
||||
@@ -10,7 +10,7 @@ interface OriginResolvableHost {
|
||||
|
||||
/**
|
||||
* Resolves which backend a given host's interactive connection (SSH, Docker
|
||||
* console, RDP/VNC/Telnet) should dial: the desktop app's embedded local
|
||||
* console, remote desktop) should dial: the desktop app's embedded local
|
||||
* backend, or the server it is linked to. A plugin whose connection is
|
||||
* always local regardless of this setting (serial: the hardware is
|
||||
* physically attached to this desktop machine) skips this helper entirely
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
} from "@/sidebar/host-contributions";
|
||||
|
||||
/**
|
||||
* Which tab a click on a host opens. The ways to connect (SSH terminal, RDP,
|
||||
* VNC, Telnet) are host actions that plugins register, so the answer is the
|
||||
* Which tab a click on a host opens. The ways to connect (the SSH terminal,
|
||||
* remote desktop) are host actions that plugins register, so the answer is the
|
||||
* highest-priority connect action the host allows, or null when no running
|
||||
* plugin can connect to it.
|
||||
*/
|
||||
|
||||
@@ -664,7 +664,7 @@ export function getRemoteCoreApi(): AxiosInstance {
|
||||
}
|
||||
|
||||
function initializeApiInstances() {
|
||||
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
||||
// Host Management API (port 30001) - SSH and plugin protocols
|
||||
hostApi = createApiInstance(getApiUrl("/host", 30001), "HOST");
|
||||
sshHostApi = hostApi;
|
||||
|
||||
@@ -675,7 +675,7 @@ function initializeApiInstances() {
|
||||
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
||||
}
|
||||
|
||||
// Host Management API (port 30001) - supports SSH, RDP, VNC, Telnet
|
||||
// Host Management API (port 30001) - SSH and plugin protocols
|
||||
export let hostApi: AxiosInstance;
|
||||
// Backward compatibility
|
||||
export let sshHostApi: AxiosInstance;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) })),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -49,7 +49,7 @@ const host = {
|
||||
ownerAuthShared: false,
|
||||
},
|
||||
},
|
||||
} as Host;
|
||||
} as unknown as Host;
|
||||
|
||||
beforeEach(() => {
|
||||
api.getCredentials.mockReset();
|
||||
|
||||
@@ -115,7 +115,9 @@ describe("HostCredentialList credential usage", () => {
|
||||
name: "rdp-only",
|
||||
authType: "password",
|
||||
credentialId: undefined,
|
||||
rdpCredentialId: "1",
|
||||
protocolAuth: {
|
||||
rdp: { authType: "credential", credentialId: 1 },
|
||||
},
|
||||
enableSsh: false,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type HostProtocols,
|
||||
} from "../../sidebar/HostEditorData";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { HOST_PROTOCOL_SECRET_KEPT } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const sshOnly: HostProtocols = {
|
||||
enableSsh: true,
|
||||
@@ -24,20 +25,6 @@ const rdpOnly: HostProtocols = {
|
||||
enableTelnet: false,
|
||||
};
|
||||
|
||||
const vncOnly: HostProtocols = {
|
||||
enableSsh: false,
|
||||
enableRdp: false,
|
||||
enableVnc: true,
|
||||
enableTelnet: false,
|
||||
};
|
||||
|
||||
const telnetOnly: HostProtocols = {
|
||||
enableSsh: false,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: true,
|
||||
};
|
||||
|
||||
describe("omitOwnerSshAuthFromSharedEdit", () => {
|
||||
it("keeps editable host settings but removes all owner SSH authentication fields", () => {
|
||||
const form = createHostEditorForm(null);
|
||||
@@ -308,75 +295,112 @@ describe("plugin host settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("RDP/VNC/Telnet password persistence indicator", () => {
|
||||
it("seeds a sentinel value when the host reports a saved rdp password", () => {
|
||||
const host = {
|
||||
hasRdpPassword: true,
|
||||
rdpAuthType: "direct",
|
||||
} as Host;
|
||||
describe("plugin protocol logins", () => {
|
||||
const spice = {
|
||||
id: "spice",
|
||||
pluginId: "spice-plugin",
|
||||
settingKey: "enableSpice",
|
||||
defaultPort: 5930,
|
||||
titleKey: "spice",
|
||||
icon: () => null,
|
||||
};
|
||||
const spiceOn: HostProtocols = { enableSsh: false, enableSpice: true };
|
||||
const spiceOff: HostProtocols = { enableSsh: true, enableSpice: false };
|
||||
const saved = {
|
||||
protocolAuth: {
|
||||
spice: {
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "0" },
|
||||
hasPassword: true,
|
||||
secretFieldKeys: ["ticket"],
|
||||
},
|
||||
},
|
||||
} as unknown as Host;
|
||||
|
||||
const form = createHostEditorForm(host);
|
||||
it("holds a saved password and secret fields behind the placeholder", () => {
|
||||
const form = createHostEditorForm(saved);
|
||||
|
||||
expect(form.rdpPassword).toBe("existing_rdp_password");
|
||||
expect(form.protocolAuth.spice).toEqual({
|
||||
authType: "direct",
|
||||
credentialId: "",
|
||||
username: "viewer",
|
||||
password: HOST_PROTOCOL_SECRET_KEPT,
|
||||
fields: { display: "0", ticket: HOST_PROTOCOL_SECRET_KEPT },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send the rdp sentinel back to the backend unchanged", () => {
|
||||
const host = { hasRdpPassword: true, rdpAuthType: "direct" } as Host;
|
||||
const form = { ...createHostEditorForm(host) };
|
||||
|
||||
const payload = buildHostEditorPayload(form, rdpOnly);
|
||||
|
||||
expect(payload.rdpPassword).toBeNull();
|
||||
it("leaves unchanged secrets out of the payload", () => {
|
||||
const dispose = registerHostProtocol(spice);
|
||||
try {
|
||||
const payload = buildHostEditorPayload(
|
||||
createHostEditorForm(saved),
|
||||
spiceOn,
|
||||
);
|
||||
expect(payload.protocolAuth?.spice).toEqual({
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
fields: { display: "0" },
|
||||
});
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("sends a newly typed rdp password", () => {
|
||||
const host = { hasRdpPassword: true, rdpAuthType: "direct" } as Host;
|
||||
const form = {
|
||||
...createHostEditorForm(host),
|
||||
rdpPassword: "new-rdp-pass",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, rdpOnly);
|
||||
|
||||
expect(payload.rdpPassword).toBe("new-rdp-pass");
|
||||
it("sends a newly typed password", () => {
|
||||
const dispose = registerHostProtocol(spice);
|
||||
try {
|
||||
const form = createHostEditorForm(saved);
|
||||
form.protocolAuth.spice.password = "new-pass";
|
||||
const payload = buildHostEditorPayload(form, spiceOn);
|
||||
expect(payload.protocolAuth?.spice?.password).toBe("new-pass");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds a sentinel value when the host reports a saved vnc password", () => {
|
||||
const host = { hasVncPassword: true, vncAuthType: "direct" } as Host;
|
||||
const form = createHostEditorForm(host);
|
||||
|
||||
expect(form.vncPassword).toBe("existing_vnc_password");
|
||||
it("sends only the credential for a credential login", () => {
|
||||
const dispose = registerHostProtocol(spice);
|
||||
try {
|
||||
const form = createHostEditorForm(saved);
|
||||
form.protocolAuth.spice = {
|
||||
...form.protocolAuth.spice,
|
||||
authType: "credential",
|
||||
credentialId: "7",
|
||||
};
|
||||
const login = buildHostEditorPayload(form, spiceOn).protocolAuth?.spice;
|
||||
expect(login).toMatchObject({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
username: null,
|
||||
});
|
||||
expect(login).not.toHaveProperty("password");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not send the vnc sentinel back to the backend unchanged", () => {
|
||||
const host = { hasVncPassword: true, vncAuthType: "direct" } as Host;
|
||||
const form = { ...createHostEditorForm(host) };
|
||||
|
||||
const payload = buildHostEditorPayload(form, vncOnly);
|
||||
|
||||
expect(payload.vncPassword).toBeNull();
|
||||
it("removes the login of a protocol switched off", () => {
|
||||
const dispose = registerHostProtocol(spice);
|
||||
try {
|
||||
const payload = buildHostEditorPayload(
|
||||
createHostEditorForm(saved),
|
||||
spiceOff,
|
||||
);
|
||||
expect(payload.protocolAuth).toEqual({ spice: null });
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds a sentinel value when the host reports a saved telnet password", () => {
|
||||
const host = {
|
||||
hasTelnetPassword: true,
|
||||
telnetAuthType: "direct",
|
||||
} as Host;
|
||||
const form = createHostEditorForm(host);
|
||||
|
||||
expect(form.telnetPassword).toBe("existing_telnet_password");
|
||||
});
|
||||
|
||||
it("does not send the telnet sentinel back to the backend unchanged", () => {
|
||||
const host = {
|
||||
hasTelnetPassword: true,
|
||||
telnetAuthType: "direct",
|
||||
} as Host;
|
||||
const form = { ...createHostEditorForm(host) };
|
||||
|
||||
const payload = buildHostEditorPayload(form, telnetOnly);
|
||||
|
||||
expect(payload.telnetPassword).toBeNull();
|
||||
it("leaves a protocol no running plugin registered alone", () => {
|
||||
const payload = buildHostEditorPayload(
|
||||
createHostEditorForm(saved),
|
||||
spiceOn,
|
||||
);
|
||||
expect(payload.protocolAuth).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -91,12 +91,13 @@ describe("createQuickConnectHost for remote desktop protocols", () => {
|
||||
expect(host).toMatchObject({
|
||||
enableSsh: false,
|
||||
pluginSettings: { demo: { enableDemo: true, demoPort: 3390 } },
|
||||
domain: "CORP",
|
||||
});
|
||||
// The login sits on the core fields named after the protocol.
|
||||
expect(host).toMatchObject({
|
||||
"demo-desktopUser": "admin",
|
||||
"demo-desktopPassword": "pw",
|
||||
// The unsaved login travels to the protocol's tab as it is.
|
||||
expect(host.quickConnectLogin).toEqual({
|
||||
protocol: "demo-desktop",
|
||||
username: "admin",
|
||||
password: "pw",
|
||||
fields: { domain: "CORP" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user