feat(remote): add desktop SSH remote instances lifecycle and UX (#515)
* feat(remote): add desktop SSH remote instances lifecycle and settings UX * fix(remote): cancel SSH modal connect and correct desktop runtime detection * fix(remote): unblock ssh auth flow and disconnect on instance removal * fix(remote-ssh): harden remote lifecycle, auth probing, and port forwarding reliability Improve SSH remote stability by correctly handling authenticated external probes, accepting ControlMaster handoff behavior, making reconnect detection tunnel-aware, and applying extra forwards through the shared master connection with local listener validation. --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
5250a20578
commit
27321d454b
@@ -0,0 +1,454 @@
|
||||
import { isTauriShell } from '@/lib/desktop';
|
||||
|
||||
type TauriInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
type TauriGlobal = {
|
||||
core?: {
|
||||
invoke?: TauriInvoke;
|
||||
};
|
||||
event?: {
|
||||
listen?: (
|
||||
event: string,
|
||||
handler: (evt: { payload?: unknown }) => void,
|
||||
) => Promise<() => void>;
|
||||
};
|
||||
};
|
||||
|
||||
export type DesktopSshRemoteMode = 'managed' | 'external';
|
||||
export type DesktopSshInstallMethod = 'npm' | 'bun' | 'download_release' | 'upload_bundle';
|
||||
export type DesktopSshSecretStore = 'never' | 'settings';
|
||||
|
||||
export type DesktopSshStoredSecret = {
|
||||
enabled: boolean;
|
||||
value?: string;
|
||||
store: DesktopSshSecretStore;
|
||||
};
|
||||
|
||||
export type DesktopSshPortForwardType = 'local' | 'remote' | 'dynamic';
|
||||
|
||||
export type DesktopSshPortForward = {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
type: DesktopSshPortForwardType;
|
||||
localHost?: string;
|
||||
localPort?: number;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
};
|
||||
|
||||
export type DesktopSshInstance = {
|
||||
id: string;
|
||||
nickname?: string;
|
||||
sshCommand: string;
|
||||
sshParsed?: {
|
||||
destination: string;
|
||||
args: string[];
|
||||
};
|
||||
connectionTimeoutSec: number;
|
||||
remoteOpenchamber: {
|
||||
mode: DesktopSshRemoteMode;
|
||||
keepRunning: boolean;
|
||||
preferredPort?: number;
|
||||
installMethod: DesktopSshInstallMethod;
|
||||
uploadBundleOverSsh: boolean;
|
||||
};
|
||||
localForward: {
|
||||
preferredLocalPort?: number;
|
||||
bindHost: '127.0.0.1' | 'localhost' | '0.0.0.0';
|
||||
};
|
||||
auth: {
|
||||
sshPassword?: DesktopSshStoredSecret;
|
||||
openchamberPassword?: DesktopSshStoredSecret;
|
||||
};
|
||||
portForwards: DesktopSshPortForward[];
|
||||
};
|
||||
|
||||
export type DesktopSshInstancesConfig = {
|
||||
instances: DesktopSshInstance[];
|
||||
};
|
||||
|
||||
export type DesktopSshPhase =
|
||||
| 'idle'
|
||||
| 'config_resolved'
|
||||
| 'auth_check'
|
||||
| 'master_connecting'
|
||||
| 'remote_probe'
|
||||
| 'installing'
|
||||
| 'updating'
|
||||
| 'server_detecting'
|
||||
| 'server_starting'
|
||||
| 'forwarding'
|
||||
| 'ready'
|
||||
| 'degraded'
|
||||
| 'error';
|
||||
|
||||
export type DesktopSshInstanceStatus = {
|
||||
id: string;
|
||||
phase: DesktopSshPhase;
|
||||
detail?: string;
|
||||
localUrl?: string;
|
||||
localPort?: number;
|
||||
remotePort?: number;
|
||||
startedByUs: boolean;
|
||||
retryAttempt: number;
|
||||
requiresUserAction: boolean;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
export type DesktopSshImportCandidate = {
|
||||
host: string;
|
||||
pattern: boolean;
|
||||
source: string;
|
||||
sshCommand: string;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return typeof value === 'object' && value !== null;
|
||||
};
|
||||
|
||||
const readString = (obj: Record<string, unknown>, key: string): string | null => {
|
||||
const value = obj[key];
|
||||
return typeof value === 'string' ? value : null;
|
||||
};
|
||||
|
||||
const readNumber = (obj: Record<string, unknown>, key: string): number | null => {
|
||||
const value = obj[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
};
|
||||
|
||||
const readBoolean = (obj: Record<string, unknown>, key: string): boolean | null => {
|
||||
const value = obj[key];
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
};
|
||||
|
||||
const asStringArray = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
};
|
||||
|
||||
const getInvoke = (): TauriInvoke | null => {
|
||||
if (!isTauriShell()) return null;
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null;
|
||||
};
|
||||
|
||||
const parseStoredSecret = (value: unknown): DesktopSshStoredSecret | undefined => {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const enabled = readBoolean(value, 'enabled') ?? false;
|
||||
const rawStore = readString(value, 'store')?.toLowerCase();
|
||||
const store: DesktopSshSecretStore = rawStore === 'settings' ? 'settings' : 'never';
|
||||
const rawValue = readString(value, 'value');
|
||||
return {
|
||||
enabled,
|
||||
store,
|
||||
...(rawValue ? { value: rawValue } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const parseForwardType = (value: unknown): DesktopSshPortForwardType => {
|
||||
return value === 'remote' || value === 'dynamic' ? value : 'local';
|
||||
};
|
||||
|
||||
const parseForward = (value: unknown): DesktopSshPortForward | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = readString(value, 'id');
|
||||
if (!id) return null;
|
||||
const enabled = readBoolean(value, 'enabled') ?? true;
|
||||
const type = parseForwardType(readString(value, 'type'));
|
||||
const localHost = readString(value, 'localHost') || readString(value, 'local_host') || undefined;
|
||||
const localPort = readNumber(value, 'localPort') ?? readNumber(value, 'local_port') ?? undefined;
|
||||
const remoteHost = readString(value, 'remoteHost') || readString(value, 'remote_host') || undefined;
|
||||
const remotePort = readNumber(value, 'remotePort') ?? readNumber(value, 'remote_port') ?? undefined;
|
||||
return {
|
||||
id,
|
||||
enabled,
|
||||
type,
|
||||
...(localHost ? { localHost } : {}),
|
||||
...(typeof localPort === 'number' ? { localPort } : {}),
|
||||
...(remoteHost ? { remoteHost } : {}),
|
||||
...(typeof remotePort === 'number' ? { remotePort } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const parseInstance = (value: unknown): DesktopSshInstance | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = readString(value, 'id');
|
||||
const sshCommand = readString(value, 'sshCommand') || readString(value, 'ssh_command');
|
||||
if (!id || !sshCommand) return null;
|
||||
const nickname = readString(value, 'nickname');
|
||||
|
||||
const parsedRaw = value.sshParsed;
|
||||
const parsed = isRecord(parsedRaw)
|
||||
? {
|
||||
destination: readString(parsedRaw, 'destination') || '',
|
||||
args: asStringArray(parsedRaw.args),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const remoteRaw = isRecord(value.remoteOpenchamber)
|
||||
? value.remoteOpenchamber
|
||||
: isRecord(value.remote_openchamber)
|
||||
? value.remote_openchamber
|
||||
: {};
|
||||
|
||||
const localRaw = isRecord(value.localForward)
|
||||
? value.localForward
|
||||
: isRecord(value.local_forward)
|
||||
? value.local_forward
|
||||
: {};
|
||||
|
||||
const authRaw = isRecord(value.auth) ? value.auth : {};
|
||||
|
||||
const rawMode = readString(remoteRaw, 'mode')?.toLowerCase();
|
||||
const mode: DesktopSshRemoteMode = rawMode === 'external' ? 'external' : 'managed';
|
||||
|
||||
const rawInstallMethod = readString(remoteRaw, 'installMethod') || readString(remoteRaw, 'install_method');
|
||||
const installMethod: DesktopSshInstallMethod =
|
||||
rawInstallMethod === 'npm' ||
|
||||
rawInstallMethod === 'download_release' ||
|
||||
rawInstallMethod === 'upload_bundle'
|
||||
? rawInstallMethod
|
||||
: 'bun';
|
||||
|
||||
const bindHostRaw =
|
||||
readString(localRaw, 'bindHost') ||
|
||||
readString(localRaw, 'bind_host') ||
|
||||
'127.0.0.1';
|
||||
const bindHost: '127.0.0.1' | 'localhost' | '0.0.0.0' =
|
||||
bindHostRaw === 'localhost' || bindHostRaw === '0.0.0.0' ? bindHostRaw : '127.0.0.1';
|
||||
|
||||
const forwardsRaw = Array.isArray(value.portForwards)
|
||||
? value.portForwards
|
||||
: Array.isArray(value.port_forwards)
|
||||
? value.port_forwards
|
||||
: [];
|
||||
|
||||
const portForwards = forwardsRaw
|
||||
.map((item) => parseForward(item))
|
||||
.filter((item): item is DesktopSshPortForward => Boolean(item));
|
||||
|
||||
const preferredPort = readNumber(remoteRaw, 'preferredPort') ?? readNumber(remoteRaw, 'preferred_port');
|
||||
const preferredLocalPort =
|
||||
readNumber(localRaw, 'preferredLocalPort') ?? readNumber(localRaw, 'preferred_local_port');
|
||||
const sshPassword = parseStoredSecret(authRaw.sshPassword || authRaw.ssh_password);
|
||||
const openchamberPassword = parseStoredSecret(authRaw.openchamberPassword || authRaw.openchamber_password);
|
||||
|
||||
return {
|
||||
id,
|
||||
...(nickname ? { nickname } : {}),
|
||||
sshCommand,
|
||||
...(parsed && parsed.destination ? { sshParsed: parsed } : {}),
|
||||
connectionTimeoutSec:
|
||||
readNumber(value, 'connectionTimeoutSec') ??
|
||||
readNumber(value, 'connection_timeout_sec') ??
|
||||
60,
|
||||
remoteOpenchamber: {
|
||||
mode,
|
||||
keepRunning: readBoolean(remoteRaw, 'keepRunning') ?? readBoolean(remoteRaw, 'keep_running') ?? true,
|
||||
...(preferredPort ? { preferredPort } : {}),
|
||||
installMethod,
|
||||
uploadBundleOverSsh:
|
||||
readBoolean(remoteRaw, 'uploadBundleOverSsh') ??
|
||||
readBoolean(remoteRaw, 'upload_bundle_over_ssh') ??
|
||||
false,
|
||||
},
|
||||
localForward: {
|
||||
...(preferredLocalPort ? { preferredLocalPort } : {}),
|
||||
bindHost,
|
||||
},
|
||||
auth: {
|
||||
...(sshPassword ? { sshPassword } : {}),
|
||||
...(openchamberPassword ? { openchamberPassword } : {}),
|
||||
},
|
||||
portForwards,
|
||||
};
|
||||
};
|
||||
|
||||
const parsePhase = (value: unknown): DesktopSshPhase => {
|
||||
switch (value) {
|
||||
case 'config_resolved':
|
||||
case 'auth_check':
|
||||
case 'master_connecting':
|
||||
case 'remote_probe':
|
||||
case 'installing':
|
||||
case 'updating':
|
||||
case 'server_detecting':
|
||||
case 'server_starting':
|
||||
case 'forwarding':
|
||||
case 'ready':
|
||||
case 'degraded':
|
||||
case 'error':
|
||||
return value;
|
||||
default:
|
||||
return 'idle';
|
||||
}
|
||||
};
|
||||
|
||||
const parseStatus = (value: unknown): DesktopSshInstanceStatus | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const id = readString(value, 'id');
|
||||
if (!id) return null;
|
||||
return {
|
||||
id,
|
||||
phase: parsePhase(readString(value, 'phase')),
|
||||
...(readString(value, 'detail') ? { detail: readString(value, 'detail') || undefined } : {}),
|
||||
...(readString(value, 'localUrl') || readString(value, 'local_url')
|
||||
? { localUrl: readString(value, 'localUrl') || readString(value, 'local_url') || undefined }
|
||||
: {}),
|
||||
...(typeof (readNumber(value, 'localPort') ?? readNumber(value, 'local_port')) === 'number'
|
||||
? { localPort: readNumber(value, 'localPort') ?? readNumber(value, 'local_port') ?? undefined }
|
||||
: {}),
|
||||
...(typeof (readNumber(value, 'remotePort') ?? readNumber(value, 'remote_port')) === 'number'
|
||||
? {
|
||||
remotePort: readNumber(value, 'remotePort') ?? readNumber(value, 'remote_port') ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
startedByUs: readBoolean(value, 'startedByUs') ?? readBoolean(value, 'started_by_us') ?? false,
|
||||
retryAttempt: readNumber(value, 'retryAttempt') ?? readNumber(value, 'retry_attempt') ?? 0,
|
||||
requiresUserAction:
|
||||
readBoolean(value, 'requiresUserAction') ?? readBoolean(value, 'requires_user_action') ?? false,
|
||||
updatedAtMs: readNumber(value, 'updatedAtMs') ?? readNumber(value, 'updated_at_ms') ?? Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
const parseImportCandidate = (value: unknown): DesktopSshImportCandidate | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const host = readString(value, 'host');
|
||||
const source = readString(value, 'source');
|
||||
const sshCommand = readString(value, 'sshCommand') || readString(value, 'ssh_command');
|
||||
if (!host || !source || !sshCommand) return null;
|
||||
return {
|
||||
host,
|
||||
source,
|
||||
sshCommand,
|
||||
pattern: readBoolean(value, 'pattern') ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
export const createDesktopSshInstance = (id: string, sshCommand: string): DesktopSshInstance => {
|
||||
return {
|
||||
id,
|
||||
sshCommand,
|
||||
connectionTimeoutSec: 60,
|
||||
remoteOpenchamber: {
|
||||
mode: 'managed',
|
||||
keepRunning: true,
|
||||
installMethod: 'bun',
|
||||
uploadBundleOverSsh: false,
|
||||
},
|
||||
localForward: {
|
||||
bindHost: '127.0.0.1',
|
||||
},
|
||||
auth: {},
|
||||
portForwards: [],
|
||||
};
|
||||
};
|
||||
|
||||
export const desktopSshInstancesGet = async (): Promise<DesktopSshInstancesConfig> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) {
|
||||
return { instances: [] };
|
||||
}
|
||||
|
||||
const raw = await invoke('desktop_ssh_instances_get');
|
||||
if (!isRecord(raw)) {
|
||||
return { instances: [] };
|
||||
}
|
||||
|
||||
const listRaw = Array.isArray(raw.instances)
|
||||
? raw.instances
|
||||
: Array.isArray(raw.desktopSshInstances)
|
||||
? raw.desktopSshInstances
|
||||
: [];
|
||||
|
||||
const instances = listRaw
|
||||
.map((item) => parseInstance(item))
|
||||
.filter((item): item is DesktopSshInstance => Boolean(item));
|
||||
|
||||
return { instances };
|
||||
};
|
||||
|
||||
export const desktopSshInstancesSet = async (config: DesktopSshInstancesConfig): Promise<void> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('desktop_ssh_instances_set', {
|
||||
config: {
|
||||
instances: config.instances,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const desktopSshImportHosts = async (): Promise<DesktopSshImportCandidate[]> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return [];
|
||||
const raw = await invoke('desktop_ssh_import_hosts');
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((item) => parseImportCandidate(item))
|
||||
.filter((item): item is DesktopSshImportCandidate => Boolean(item));
|
||||
};
|
||||
|
||||
export const desktopSshConnect = async (id: string): Promise<void> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('desktop_ssh_connect', { id });
|
||||
};
|
||||
|
||||
export const desktopSshDisconnect = async (id: string): Promise<void> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('desktop_ssh_disconnect', { id });
|
||||
};
|
||||
|
||||
export const desktopSshStatus = async (id?: string): Promise<DesktopSshInstanceStatus[]> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return [];
|
||||
const raw = await invoke('desktop_ssh_status', {
|
||||
...(id ? { id } : {}),
|
||||
});
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((item) => parseStatus(item))
|
||||
.filter((item): item is DesktopSshInstanceStatus => Boolean(item));
|
||||
};
|
||||
|
||||
export const desktopSshLogs = async (id: string, limit?: number): Promise<string[]> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return [];
|
||||
const raw = await invoke('desktop_ssh_logs', {
|
||||
id,
|
||||
...(typeof limit === 'number' ? { limit } : {}),
|
||||
});
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((line): line is string => typeof line === 'string');
|
||||
};
|
||||
|
||||
export const desktopSshLogsClear = async (id: string): Promise<void> => {
|
||||
const invoke = getInvoke();
|
||||
if (!invoke) return;
|
||||
await invoke('desktop_ssh_logs_clear', { id });
|
||||
};
|
||||
|
||||
export const listenDesktopSshStatus = async (
|
||||
listener: (status: DesktopSshInstanceStatus) => void,
|
||||
): Promise<() => Promise<void>> => {
|
||||
if (!isTauriShell()) {
|
||||
return async () => {};
|
||||
}
|
||||
|
||||
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
|
||||
const listen = tauri?.event?.listen;
|
||||
if (typeof listen !== 'function') {
|
||||
return async () => {};
|
||||
}
|
||||
|
||||
const unlisten = await listen('openchamber:ssh-instance-status', (event) => {
|
||||
const status = parseStatus(event?.payload);
|
||||
if (!status) return;
|
||||
listener(status);
|
||||
});
|
||||
|
||||
return async () => {
|
||||
await unlisten();
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user