[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:
273
components/host-details/AlgorithmOverridesPanel.tsx
Normal file
273
components/host-details/AlgorithmOverridesPanel.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import {
|
||||
effectiveDefaultAlgorithms,
|
||||
SSH_ALGORITHM_CATEGORIES,
|
||||
SSHAlgorithmCategory,
|
||||
SUPPORTED_ALGORITHMS_BY_CATEGORY,
|
||||
} from "../../domain/sshAlgorithmList";
|
||||
import type { HostAlgorithmOverrides } from "../../domain/models";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
interface Props {
|
||||
value: HostAlgorithmOverrides | undefined;
|
||||
onChange: (next: HostAlgorithmOverrides | undefined) => void;
|
||||
/**
|
||||
* The host's current `legacyAlgorithms` value, used to seed the very
|
||||
* first customization in each category with the *effective* default
|
||||
* list (modern-only vs modern+legacy) rather than the full SUPPORTED
|
||||
* set. Without this, unchecking a single algorithm in modern mode
|
||||
* would silently start advertising CBC / arcfour / MD5 algorithms.
|
||||
*/
|
||||
legacyEnabled: boolean;
|
||||
/**
|
||||
* Algorithm overrides this host would inherit from its group when its
|
||||
* own field is unset. Used purely for display: an `undefined` value
|
||||
* here means the host can freely use NetCatty defaults by resetting
|
||||
* a category; a populated value means the host would inherit those
|
||||
* lists, and resetting locally falls back to them — the panel
|
||||
* surfaces that so the user knows the local Reset button doesn't
|
||||
* jump them to NetCatty's defaults in that case.
|
||||
*/
|
||||
inheritedFromGroup?: HostAlgorithmOverrides;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL_KEY: Record<SSHAlgorithmCategory, string> = {
|
||||
kex: "hostDetails.algorithms.category.kex",
|
||||
cipher: "hostDetails.algorithms.category.cipher",
|
||||
hmac: "hostDetails.algorithms.category.hmac",
|
||||
serverHostKey: "hostDetails.algorithms.category.serverHostKey",
|
||||
compress: "hostDetails.algorithms.category.compress",
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-category SSH algorithm override editor.
|
||||
*
|
||||
* When a category's array is `undefined`, that category uses NetCatty's
|
||||
* negotiated default list. When it's a non-empty array, that array fully
|
||||
* replaces the offered list for the category.
|
||||
*
|
||||
* Picking zero algorithms in a category is equivalent to "use default" —
|
||||
* an empty array would make ssh2 fail negotiation, so we normalize it
|
||||
* back to `undefined` on save.
|
||||
*/
|
||||
export const AlgorithmOverridesPanel: React.FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
legacyEnabled,
|
||||
inheritedFromGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const effectiveDefault = useMemo(
|
||||
() => effectiveDefaultAlgorithms(legacyEnabled),
|
||||
[legacyEnabled],
|
||||
);
|
||||
// What the runtime *actually* inherits from the group for display
|
||||
// purposes. `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing boundary: once the host carries any local
|
||||
// `algorithms` object the group's overrides stop being applied — even
|
||||
// for categories the host didn't override. So as soon as `value` is
|
||||
// non-undefined we must stop *displaying* inherited categories,
|
||||
// otherwise the UI lies about what will be negotiated.
|
||||
//
|
||||
// The write-side (`updateCategory` / `toggleAlgorithm` / Reset) still
|
||||
// consults the unconditional `inheritedFromGroup` so that the first
|
||||
// user edit on an unset host carries the inherited categories into
|
||||
// the host object, preventing the runtime's silent widening that
|
||||
// motivated those write-side fixes.
|
||||
const inheritedForDisplay = useMemo(
|
||||
() => (value === undefined ? inheritedFromGroup : undefined),
|
||||
[value, inheritedFromGroup],
|
||||
);
|
||||
const inheritedCategories = useMemo(() => {
|
||||
if (!inheritedForDisplay) return [] as SSHAlgorithmCategory[];
|
||||
return SSH_ALGORITHM_CATEGORIES.filter((category) => {
|
||||
const list = inheritedForDisplay[category];
|
||||
return Array.isArray(list) && list.length > 0;
|
||||
});
|
||||
}, [inheritedForDisplay]);
|
||||
|
||||
const updateCategory = useCallback(
|
||||
(category: SSHAlgorithmCategory, selected: string[]) => {
|
||||
// Start from the inherited group overrides so that touching one
|
||||
// category doesn't silently drop inheritance for the others.
|
||||
// `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing inherit boundary: once the host carries any
|
||||
// explicit object, the host's `algorithms` shadows the group's
|
||||
// `algorithms` entirely. If the user customized cipher locally
|
||||
// and the group restricted serverHostKey, simply storing
|
||||
// `{ cipher: [...] }` on the host would lose the group's
|
||||
// serverHostKey restriction. Persisting the inherited categories
|
||||
// alongside keeps the effective offer intact.
|
||||
const base: HostAlgorithmOverrides = inheritedFromGroup
|
||||
? { ...inheritedFromGroup }
|
||||
: {};
|
||||
const next: HostAlgorithmOverrides = { ...base, ...(value ?? {}) };
|
||||
if (selected.length === 0) {
|
||||
delete next[category];
|
||||
} else {
|
||||
next[category] = selected;
|
||||
}
|
||||
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
|
||||
onChange(hasAny ? next : undefined);
|
||||
},
|
||||
[value, onChange, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const toggleAlgorithm = useCallback(
|
||||
(category: SSHAlgorithmCategory, algo: string) => {
|
||||
const current = value?.[category];
|
||||
if (!current) {
|
||||
// First click in this category — seed with the *effective* offer
|
||||
// for this category. If the group has set a list for this
|
||||
// category, use that (so customizing one entry doesn't lose the
|
||||
// group's narrowing). Otherwise seed from NetCatty's effective
|
||||
// default, which already accounts for legacy mode. Seeding from
|
||||
// SUPPORTED_ALGORITHMS_BY_CATEGORY would silently introduce
|
||||
// legacy algorithms (CBC, arcfour, MD5) into the offered list.
|
||||
const baseline = inheritedFromGroup?.[category] ?? effectiveDefault[category];
|
||||
if (baseline.includes(algo)) {
|
||||
updateCategory(category, baseline.filter((a) => a !== algo));
|
||||
} else {
|
||||
// The user clicked an algorithm not in the baseline — they
|
||||
// want to opt INTO it. Start the override with the baseline
|
||||
// plus this extra entry.
|
||||
updateCategory(category, [...baseline, algo]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current.includes(algo)) {
|
||||
updateCategory(category, current.filter((a) => a !== algo));
|
||||
} else {
|
||||
updateCategory(category, [...current, algo]);
|
||||
}
|
||||
},
|
||||
[value, updateCategory, effectiveDefault, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const resetCategory = useCallback(
|
||||
(category: SSHAlgorithmCategory) => {
|
||||
const inherited = inheritedFromGroup?.[category];
|
||||
const next: HostAlgorithmOverrides = { ...(value ?? {}) };
|
||||
if (Array.isArray(inherited) && inherited.length > 0) {
|
||||
// The group has an override for this category. Just deleting
|
||||
// `next[category]` would *widen* the effective offer: because
|
||||
// `applyGroupDefaults` treats `host.algorithms` as an
|
||||
// all-or-nothing inherit boundary, once any other category
|
||||
// remains on the host the group's `algorithms` object stops
|
||||
// being inherited as a whole, and the missing category falls
|
||||
// back to NetCatty defaults — not the group's narrower list.
|
||||
// Persist the inherited list verbatim instead, so Reset means
|
||||
// "use what this host would otherwise inherit" rather than
|
||||
// "silently switch to NetCatty defaults".
|
||||
next[category] = inherited.slice();
|
||||
} else {
|
||||
delete next[category];
|
||||
}
|
||||
const hasAny = Object.values(next).some((arr) => Array.isArray(arr) && arr.length > 0);
|
||||
onChange(hasAny ? next : undefined);
|
||||
},
|
||||
[value, onChange, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const isCustomized = useCallback(
|
||||
(category: SSHAlgorithmCategory) => {
|
||||
const local = value?.[category];
|
||||
if (!Array.isArray(local) || local.length === 0) return false;
|
||||
// If the host's list is identical (order + contents) to the
|
||||
// inherited list, the user hasn't really customized it — they
|
||||
// either reset to the upstream value or never touched it directly.
|
||||
// Suppressing the "customized" badge in that case keeps the UI
|
||||
// honest about what the user actually changed.
|
||||
const inherited = inheritedFromGroup?.[category];
|
||||
if (Array.isArray(inherited)
|
||||
&& inherited.length === local.length
|
||||
&& inherited.every((a, i) => a === local[i])) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[value, inheritedFromGroup],
|
||||
);
|
||||
|
||||
const isChecked = useCallback(
|
||||
(category: SSHAlgorithmCategory, algo: string) => {
|
||||
const current = value?.[category];
|
||||
if (current) return current.includes(algo);
|
||||
// No host-local override for this category: reflect what the host
|
||||
// would actually advertise. Uses `inheritedForDisplay` (the same
|
||||
// gating the inherited notice uses) so that a host that already
|
||||
// has any local override stops pretending its empty categories
|
||||
// still come from the group — `applyGroupDefaults` won't apply
|
||||
// them, and the runtime falls back to NetCatty defaults.
|
||||
const baseline = inheritedForDisplay?.[category] ?? effectiveDefault[category];
|
||||
return baseline.includes(algo);
|
||||
},
|
||||
[value, effectiveDefault, inheritedForDisplay],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.algorithms.advanced.desc")}
|
||||
</p>
|
||||
{inheritedCategories.length > 0 && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 break-words">
|
||||
{t("hostDetails.algorithms.inheritedNotice")
|
||||
.replace(
|
||||
"{categories}",
|
||||
inheritedCategories.map((c) => t(CATEGORY_LABEL_KEY[c])).join(", "),
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{SSH_ALGORITHM_CATEGORIES.map((category) => {
|
||||
const supported = SUPPORTED_ALGORITHMS_BY_CATEGORY[category];
|
||||
const customized = isCustomized(category);
|
||||
return (
|
||||
<Card key={category} className="p-2 space-y-1.5 bg-background border-border/60">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-medium">
|
||||
{t(CATEGORY_LABEL_KEY[category])}
|
||||
{customized && (
|
||||
<span className="ml-1.5 text-[10px] text-yellow-600 dark:text-yellow-400">
|
||||
{t("hostDetails.algorithms.customized")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{customized && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => resetCategory(category)}
|
||||
>
|
||||
{t("hostDetails.algorithms.reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{supported.map((algo) => (
|
||||
<label
|
||||
key={algo}
|
||||
className="flex items-center gap-2 text-[11px] cursor-pointer select-none hover:bg-accent/40 rounded px-1 py-0.5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3"
|
||||
checked={isChecked(category, algo)}
|
||||
onChange={() => toggleAlgorithm(category, algo)}
|
||||
/>
|
||||
<span className="font-mono truncate" title={algo}>{algo}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user