mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user