[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
322
components/KeychainExportPanel.tsx
Normal file
322
components/KeychainExportPanel.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import React from "react";
|
||||
import { ChevronRight, Info } from "lucide-react";
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type KeychainExportPanelProps = Record<string, any>;
|
||||
|
||||
export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
panel,
|
||||
t,
|
||||
getKeyIcon,
|
||||
getKeyTypeDisplay,
|
||||
setShowHostSelector,
|
||||
exportHost,
|
||||
exportLocation,
|
||||
setExportLocation,
|
||||
exportFilename,
|
||||
setExportFilename,
|
||||
exportAdvancedOpen,
|
||||
setExportAdvancedOpen,
|
||||
exportScript,
|
||||
setExportScript,
|
||||
isExporting,
|
||||
setIsExporting,
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs,
|
||||
execCommand,
|
||||
onSaveIdentity,
|
||||
onSaveHost,
|
||||
closePanel,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Key info card */}
|
||||
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-md flex items-center justify-center",
|
||||
panel.key.certificate
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: "bg-primary/15 text-primary",
|
||||
)}
|
||||
>
|
||||
{getKeyIcon(panel.key)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{panel.key.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("auth.keyType", { type: getKeyTypeDisplay(panel.key) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export to field */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.exportTo")}
|
||||
</Label>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-primary text-sm"
|
||||
onClick={() => setShowHostSelector(true)}
|
||||
>
|
||||
{t("keychain.export.selectHost")}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={exportHost?.label || ""}
|
||||
readOnly
|
||||
placeholder={t("common.selectAHostPlaceholder")}
|
||||
className="bg-muted/50 cursor-pointer"
|
||||
onClick={() => setShowHostSelector(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.location")}
|
||||
</Label>
|
||||
<Input
|
||||
value={exportLocation}
|
||||
onChange={(e) => setExportLocation(e.target.value)}
|
||||
placeholder=".ssh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filename field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.filename")}
|
||||
</Label>
|
||||
<Input
|
||||
value={exportFilename}
|
||||
onChange={(e) => setExportFilename(e.target.value)}
|
||||
placeholder="authorized_keys"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
|
||||
<Info
|
||||
size={14}
|
||||
className="mt-0.5 text-muted-foreground shrink-0"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("keychain.export.note", {
|
||||
unix: "UNIX",
|
||||
advanced: t("common.advanced"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced collapsible */}
|
||||
<Collapsible
|
||||
open={exportAdvancedOpen}
|
||||
onOpenChange={setExportAdvancedOpen}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current"
|
||||
>
|
||||
<span className="font-medium">{t("common.advanced")}</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={cn(
|
||||
"transition-transform",
|
||||
exportAdvancedOpen && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
<Label className="text-muted-foreground">
|
||||
{t("keychain.export.script")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={exportScript}
|
||||
onChange={(e) => setExportScript(e.target.value)}
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
placeholder={t("keychain.export.scriptPlaceholder")}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Export button */}
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
disabled={
|
||||
!exportHost ||
|
||||
!exportLocation ||
|
||||
!exportFilename ||
|
||||
isExporting
|
||||
}
|
||||
onClick={async () => {
|
||||
if (!exportHost || !panel.key.publicKey) return;
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
// Apply group defaults before resolving authentication
|
||||
// and connection settings for this one-off SSH command.
|
||||
const effectiveExportHost = exportHost.group
|
||||
? applyGroupDefaults(
|
||||
exportHost,
|
||||
resolveGroupDefaults(exportHost.group, groupConfigs),
|
||||
)
|
||||
: applyGroupDefaults(exportHost, {});
|
||||
const connectionTimeouts = resolveHostSshConnectionTimeouts(effectiveExportHost);
|
||||
const exportAuth = resolveHostAuth({
|
||||
host: effectiveExportHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const exportKeyAuth = resolveBridgeKeyAuth({
|
||||
key: exportAuth.key,
|
||||
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
|
||||
? undefined
|
||||
: effectiveExportHost.identityFilePaths,
|
||||
passphrase: exportAuth.passphrase,
|
||||
});
|
||||
const exportPassword = sanitizeCredentialValue(exportAuth.password);
|
||||
const exportAgentAuth = resolveBridgeSshAgentAuth(
|
||||
effectiveExportHost,
|
||||
exportAuth.key,
|
||||
exportAuth.authMethod,
|
||||
);
|
||||
|
||||
// Need either password or a usable key to run remote command.
|
||||
if (!hasBridgeSshCredentials({
|
||||
authMethod: exportAuth.authMethod,
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
useSshAgent: exportAgentAuth.useSshAgent,
|
||||
})) {
|
||||
throw new Error(
|
||||
t("keychain.export.missingCredentials"),
|
||||
);
|
||||
}
|
||||
|
||||
// Escape the public key for shell (single quotes, escape existing quotes)
|
||||
const escapedPublicKey = panel.key.publicKey.replace(
|
||||
/'/g,
|
||||
"'\\''",
|
||||
);
|
||||
|
||||
// Build the command by replacing $1, $2, $3
|
||||
const scriptWithVars = exportScript
|
||||
.replace(/\$1/g, exportLocation)
|
||||
.replace(/\$2/g, exportFilename)
|
||||
.replace(/\$3/g, `'${escapedPublicKey}'`);
|
||||
|
||||
// Execute the script directly - SSH exec handles multiline commands
|
||||
const command = scriptWithVars;
|
||||
|
||||
// Execute via SSH
|
||||
const result = await execCommand({
|
||||
hostname: effectiveExportHost.hostname,
|
||||
hostId: effectiveExportHost.id,
|
||||
username: exportAuth.username || "root",
|
||||
port: effectiveExportHost.port || 22,
|
||||
authMethod: exportAuth.authMethod,
|
||||
password: exportPassword,
|
||||
privateKey: exportKeyAuth.privateKey,
|
||||
certificate: exportAuth.key?.certificate,
|
||||
publicKey: exportAuth.key?.publicKey,
|
||||
keyId: exportAuth.keyId,
|
||||
keySource: exportAuth.key?.source,
|
||||
passphrase: exportKeyAuth.passphrase,
|
||||
identityFilePaths: exportKeyAuth.identityFilePaths,
|
||||
...exportAgentAuth,
|
||||
// Carry the effective host's algorithm settings
|
||||
// (host value falling back to its group default)
|
||||
// so the one-off SSH exec honors them just like
|
||||
// the interactive terminal does.
|
||||
legacyAlgorithms: effectiveExportHost.legacyAlgorithms,
|
||||
skipEcdsaHostKey: effectiveExportHost.skipEcdsaHostKey,
|
||||
algorithmOverrides: effectiveExportHost.algorithms,
|
||||
command,
|
||||
timeout: 30000,
|
||||
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
|
||||
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
enableKeyboardInteractive: true,
|
||||
requiresMfa: !!effectiveExportHost.requiresMfa,
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
// Check result - code 0, null, or undefined with no stderr is success
|
||||
const exitCode = result?.code;
|
||||
const hasError = result?.stderr?.trim();
|
||||
if (exitCode === 0 || (exitCode == null && !hasError)) {
|
||||
// Update identity (preferred) or host to use this key for authentication
|
||||
if (exportHost.identityId && onSaveIdentity) {
|
||||
const existing = identities.find(
|
||||
(i) => i.id === exportHost.identityId,
|
||||
);
|
||||
if (existing) {
|
||||
onSaveIdentity({
|
||||
...existing,
|
||||
authMethod: "key",
|
||||
keyId: panel.key.id,
|
||||
});
|
||||
}
|
||||
} else if (onSaveHost) {
|
||||
onSaveHost({
|
||||
...exportHost,
|
||||
identityFileId: panel.key.id,
|
||||
authMethod: "key",
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("keychain.export.successMessage", {
|
||||
host: exportHost.label,
|
||||
}),
|
||||
t("keychain.export.successTitle"),
|
||||
);
|
||||
closePanel();
|
||||
} else {
|
||||
const errorMsg =
|
||||
hasError ||
|
||||
result?.stdout?.trim() ||
|
||||
t("keychain.export.exitCode", { code: exitCode });
|
||||
toast.error(
|
||||
t("keychain.export.failedMessage", { error: errorMsg }),
|
||||
t("keychain.export.failedTitle"),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
toast.error(
|
||||
t("keychain.export.failedPrefix", { error: message }),
|
||||
t("keychain.export.failedTitle"),
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExporting
|
||||
? t("keychain.export.exporting")
|
||||
: t("keychain.export.exportAndAttach")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user