Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/components/connections/ConnectionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { getSecret } from "@/services/vault";
import { sshExecCommand } from "@/services/ssh";
import { isCustomProxyMode, resolveDirectHop } from "@/services/proxy";
import { useStoredSecrets } from "@/hooks/useStoredSecrets";
import { useServerLacksSecretType } from "@/services/serverCapabilities";
import { resolveTeamIdForVaultId } from "@/services/teamVaultSecrets";
import { StoredSecretsNote } from "@/components/shared/VaultUnavailableNote";
import { useAutosave } from "@/hooks/useAutosave";
import { auditContextForVaultId } from "@/services/auditContextResolver";
Expand Down Expand Up @@ -208,8 +210,10 @@ const ConnectionFormEditor = forwardRef<ConnectionFormHandle, Props & EditAccess

const selectedIdentity = relevantIdentities.find((i) => i.id === identityId) ?? null;

const knockServerTooOld = useServerLacksSecretType("connection_knock_sequence") && resolveTeamIdForVaultId(vaultId) !== null;

const editedKnockSequence = () => {
if (!knockDirty.current) return null;
if (!knockDirty.current || knockServerTooOld) return null;
const issues = knockPanelIssues({ enabled: true }, knockSteps, "direct");
if (issues.includes("port") || (portKnock.enabled && issues.includes("empty"))) return null;
return formatKnockSequence(knockSteps);
Expand Down Expand Up @@ -299,6 +303,13 @@ const ConnectionFormEditor = forwardRef<ConnectionFormHandle, Props & EditAccess
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => schedule(), [name, host, port, username, protocol, ftpSecure, webdavUrl, password, privateKey, passphrase, identityId, keyId, folderId, tags, vaultId, jumpHosts, envVars, agentForwarding, legacyAlgorithms, hostCommands.preCommand, hostCommands.postCommand, hostCommands.preSnippetId, hostCommands.postSnippetId, hostCommands.askVarsEachTime, hostCommands.terminalEncoding, distro, icon, pingDisabled, shellIntegration, keepalivePreset, persistSession, proxyOverride, proxyPassword, portKnock, knockSteps, notes]);

// Left on, a switch whose sequence the server refused fails every teammate's connect.
useEffect(() => {
if (!knockServerTooOld || !knockDirty.current) return;
markDirty();
setPortKnock((current) => (current.enabled ? { ...current, enabled: false } : current));
}, [knockServerTooOld, markDirty]);

useImperativeHandle(ref, () => ({ flush, isDirty: () => userEditedRef.current }), [flush]);

const handleClose = () => flushAndClose(onClose);
Expand Down Expand Up @@ -790,6 +801,7 @@ const ConnectionFormEditor = forwardRef<ConnectionFormHandle, Props & EditAccess
settings={portKnock}
steps={knockSteps}
sequenceState={storedSecrets}
serverTooOld={knockServerTooOld}
onSettingsChange={(next) => { markDirty(); setPortKnock(next); }}
onStepsChange={(next) => { markDirty(); knockDirty.current = true; setKnockSteps(next); }}
effectiveProxyMode={proxyOverride?.mode ?? globalProxy.mode}
Expand Down
13 changes: 13 additions & 0 deletions src/components/connections/PortKnockPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,16 @@ test("timing inputs cap their length", () => {
expect(screen.getByLabelText("connections.knock.settle").getAttribute("maxlength")).toBe("6");
expect(screen.getByLabelText("connections.knock.window").getAttribute("maxlength")).toBe("5");
});

test("a server too old for knock sequences shows why and offers nothing to fill in", () => {
renderPanel({ settings: { enabled: false }, steps: [s(666)], serverTooOld: true });
expect(screen.getByText("connections.knock.serverTooOld")).toBeTruthy();
expect(screen.queryByText("connections.knock.addPort")).toBeNull();
expect((screen.getByRole("switch") as HTMLButtonElement).disabled).toBe(true);
});

test("a switch left on against a server too old for knock sequences can still be turned off", () => {
const { onSettingsChange } = renderPanel({ settings: { enabled: true }, serverTooOld: true });
fireEvent.click(screen.getByRole("switch"));
expect(onSettingsChange).toHaveBeenCalledWith({ enabled: false });
});
15 changes: 11 additions & 4 deletions src/components/connections/PortKnockPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { StoredSecretsState } from "@/hooks/useStoredSecrets";
import { useListReorder } from "@/hooks/useListReorder";
import { ReorderableRow } from "@/components/shared/reorder";
import { SlideOverHeader, DashedAddButton } from "@/components/shared/slideOver";
import { StoredSecretsNote } from "@/components/shared/VaultUnavailableNote";
import { StoredSecretsNote, WarningNote } from "@/components/shared/VaultUnavailableNote";
import { Toggle } from "@/components/shared/Toggle";
import { FormSelect } from "@/components/shared/FormSelect";
import { formInputClass, formInputStyle } from "@/components/shared/Panel";
Expand Down Expand Up @@ -45,17 +45,18 @@ interface Props {
settings: PortKnockSettings;
steps: KnockStepRow[];
sequenceState: StoredSecretsState;
serverTooOld?: boolean;
onSettingsChange: (next: PortKnockSettings) => void;
onStepsChange: (next: KnockStepRow[]) => void;
effectiveProxyMode: KnockProxyMode;
bastionName: string | null;
onBack: () => void;
}

export default function PortKnockPanel({ settings, steps, sequenceState, onSettingsChange, onStepsChange, effectiveProxyMode, bastionName, onBack }: Props) {
export default function PortKnockPanel({ settings, steps, sequenceState, serverTooOld = false, onSettingsChange, onStepsChange, effectiveProxyMode, bastionName, onBack }: Props) {
const { t } = useTranslation();
const dnd = useListReorder(steps, onStepsChange);
const sequenceShown = sequenceState === "ok";
const sequenceShown = sequenceState === "ok" && !serverTooOld;
const issues = sequenceShown ? knockPanelIssues(settings, steps, effectiveProxyMode) : [];
const updateStep = (id: string, patch: Partial<KnockStep>) =>
onStepsChange(steps.map((s) => (s.id === id ? { ...s, ...patch } : s)));
Expand All @@ -66,7 +67,11 @@ export default function PortKnockPanel({ settings, steps, sequenceState, onSetti

<div className="flex-1 overflow-y-auto px-4 py-4 space-y-2" {...dnd.containerProps}>
<SettingRow icon="lucide:power" label={t("connections.knock.enable")}>
<Toggle checked={settings.enabled} onChange={(enabled) => onSettingsChange({ ...settings, enabled })} />
<Toggle
checked={settings.enabled}
disabled={serverTooOld && !settings.enabled}
onChange={(enabled) => onSettingsChange({ ...settings, enabled })}
/>
</SettingRow>
<FormHint>{t("connections.knock.hint")}</FormHint>
{bastionName && <FormHint>{t("connections.knock.viaBastion", { name: bastionName })}</FormHint>}
Expand Down Expand Up @@ -110,6 +115,8 @@ export default function PortKnockPanel({ settings, steps, sequenceState, onSetti
{t("connections.knock.addPort")}
</DashedAddButton>
</>
) : serverTooOld ? (
<WarningNote icon="lucide:server-off" text={t("connections.knock.serverTooOld")} />
) : (
<StoredSecretsNote state={sequenceState} />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,38 @@ test("without View secrets the knock ports stay hidden and a save leaves the seq
useTeamObjectAccessStore.getState().clearAll();
}));

test("a team host on a server too old for knock sequences withholds the sequence and switches knocking back off", async () => {
const { noteRefusedSecretType, resetServerCapabilities } = await import("@/services/serverCapabilities");
h.teams = [{ id: "team-1" }];
grantOnC1(PERM_BITS.VIEW | PERM_BITS.VIEW_SECRETS | PERM_BITS.EDIT_CONNECTIONS);
const { onSubmit, ref } = renderSsh({ initial: conn({ vault_id: "team-1", port_knock: { enabled: true } }) });
await act(async () => { await Promise.resolve(); });
fireEvent.click(screen.getByText("connections.knock.addPort"));
fireEvent.change(screen.getByPlaceholderText("connections.knock.port"), { target: { value: "666" } });
await act(async () => { ref.current!.flush(); });
expect(onSubmit.mock.calls[0][0]).toMatchObject({ port_knock: { enabled: true } });
expect(onSubmit.mock.calls[0][1].knock_sequence).toBe("666/tcp");

act(() => { noteRefusedSecretType("connection_knock_sequence"); });

expect(screen.getByText("connections.knock.serverTooOld")).toBeTruthy();
expect(screen.queryByDisplayValue("666")).toBeNull();
await act(async () => { ref.current!.flush(); });
expect(onSubmit.mock.calls[1][0]).toMatchObject({ port_knock: { enabled: false } });
expect(onSubmit.mock.calls[1][1].knock_sequence).toBeNull();
resetServerCapabilities();
useTeamObjectAccessStore.getState().clearAll();
});

test("a personal host keeps its knock ports whatever the server supports", async () => {
const { noteRefusedSecretType, resetServerCapabilities } = await import("@/services/serverCapabilities");
act(() => { noteRefusedSecretType("connection_knock_sequence"); });
renderSsh({ initial: conn({ port_knock: { enabled: true } }) });
expect(screen.getByText("connections.knock.addPort")).toBeTruthy();
expect(screen.queryByText("connections.knock.serverTooOld")).toBeNull();
resetServerCapabilities();
});

test("a new ssh host keeps its secret fields in a vault whose secrets the caller cannot view", () => {
h.teams = [{ id: "team-1" }];
h.defaultVaultId = "team-1";
Expand Down
2 changes: 1 addition & 1 deletion src/components/shared/VaultUnavailableNote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { StoredSecretsState } from "@/hooks/useStoredSecrets";
import type { TeamCredentialsReason } from "@/hooks/useBlockedTeamVault";

/** One warning row: an icon and a sentence on the shared warning surface. */
function WarningNote({ icon, text, className }: { icon: string; text: string; className?: string }) {
export function WarningNote({ icon, text, className }: { icon: string; text: string; className?: string }) {
return (
<div
role="status"
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/cs/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@
"secretsLeftInSourceVault": "Přesunuto, ale přihlašovací údaje se nepodařilo odebrat ze zdrojového trezoru a jsou tam stále čitelné: {{error}}",
"secretsUploadPending": "Přesunuto, ale přihlašovací údaje se zatím nepodařilo nahrát. Zůstávají na tomto zařízení a pokus o nahrání se zopakuje při příštím načtení trezoru: {{error}}",
"failedToSaveTeamSecret": "Týmové tajemství se nepodařilo uložit: {{status}}",
"serverLacksTeamSecretType": "Tento server Voltius je příliš starý na to, aby toto uložil do týmového trezoru. Požádejte jeho správce o aktualizaci serveru a pak uložte znovu.",
"failedToLoadServerCapabilities": "Nepodařilo se ověřit, co server podporuje ({{status}})",
"keyFetchFailed": "Načtení klíče se nezdařilo: {{error}}",
"failedToUploadVaultKeys": "Klíče trezoru se nepodařilo nahrát: {{status}}",
"failedToDistributeVaultKey": "Klíč trezoru se nepodařilo distribuovat: {{status}}",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/cs/connections.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
"udp-proxy": "Klepnutí přes UDP nelze odeslat přes proxy nastavenou pro tento host. Použijte kroky TCP nebo přímé připojení.",
"udp-system": "Při použití systémové proxy klepnutí přes UDP selžou."
},
"serverTooOld": "Tento server Voltius je příliš starý na to, aby ukládal sekvence klepání pro hosty v týmovém trezoru. Požádejte jeho správce o aktualizaci serveru, abyste zde mohli klepání na porty používat.",
"sequenceUnavailable": "Sekvence klepání pro tento host není na tomto zařízení k dispozici.",
"sequenceInvalid": "Sekvence klepání pro tento host je neplatná. Opravte ji v nastavení tohoto hostu v části Klepání na porty."
}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@
"secretsLeftInSourceVault": "Moved, but its credentials could not be removed from the source vault and are still readable there: {{error}}",
"secretsUploadPending": "Moved, but its credentials could not be uploaded yet. They stay on this device and are retried the next time the vault loads: {{error}}",
"failedToSaveTeamSecret": "Failed to save team secret: {{status}}",
"serverLacksTeamSecretType": "This Voltius server is too old to store this in a team vault. Ask its administrator to update the server, then save again.",
"failedToLoadServerCapabilities": "Couldn't check what the server supports ({{status}})",
"keyFetchFailed": "Key fetch failed: {{error}}",
"failedToUploadVaultKeys": "Failed to upload vault keys: {{status}}",
"failedToDistributeVaultKey": "Failed to distribute vault key: {{status}}",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/connections.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
"udp-proxy": "UDP knocks can't go through this host's proxy. Use TCP steps or a direct connection.",
"udp-system": "UDP knocks fail if the system proxy is in use."
},
"serverTooOld": "This Voltius server is too old to store knock sequences for team vault hosts. Ask its administrator to update the server to use port knocking here.",
"sequenceUnavailable": "The knock sequence for this host isn't available on this device.",
"sequenceInvalid": "This host's knock sequence is invalid. Fix it in the host's Port Knocking settings."
}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@
"secretsLeftInSourceVault": "Déplacé, mais ses identifiants n'ont pas pu être retirés du coffre source et y restent lisibles : {{error}}",
"secretsUploadPending": "Déplacé, mais ses identifiants n'ont pas encore pu être envoyés. Ils restent sur cet appareil et seront renvoyés au prochain chargement du coffre : {{error}}",
"failedToSaveTeamSecret": "Échec de l'enregistrement du secret d'équipe : {{status}}",
"serverLacksTeamSecretType": "Ce serveur Voltius est trop ancien pour stocker cet élément dans un coffre d'équipe. Demandez à son administrateur de mettre le serveur à jour, puis enregistrez à nouveau.",
"failedToLoadServerCapabilities": "Impossible de vérifier ce que le serveur prend en charge ({{status}})",
"keyFetchFailed": "Échec de la récupération de la clé : {{error}}",
"failedToUploadVaultKeys": "Échec de l'envoi des clés de coffre : {{status}}",
"failedToDistributeVaultKey": "Échec de la distribution de la clé de coffre : {{status}}",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr/connections.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
"udp-proxy": "Les knocks UDP ne passent pas par le proxy de cet hôte. Utilisez des étapes TCP ou une connexion directe.",
"udp-system": "Les knocks UDP échouent si le proxy système est utilisé."
},
"serverTooOld": "Ce serveur Voltius est trop ancien pour stocker les séquences de port knocking des hôtes d'un coffre d'équipe. Demandez à son administrateur de mettre le serveur à jour pour utiliser le port knocking ici.",
"sequenceUnavailable": "La séquence de knock de cet hôte n'est pas disponible sur cet appareil.",
"sequenceInvalid": "La séquence de knock de cet hôte n'est pas valide. Corrigez-la dans les réglages Port knocking de l'hôte."
}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/ru/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@
"secretsLeftInSourceVault": "Перемещено, но учётные данные не удалось удалить из исходного хранилища и они всё ещё доступны там: {{error}}",
"secretsUploadPending": "Перемещено, но учётные данные пока не удалось отправить. Они остаются на этом устройстве и будут отправлены повторно при следующей загрузке хранилища: {{error}}",
"failedToSaveTeamSecret": "Не удалось сохранить секрет команды: {{status}}",
"serverLacksTeamSecretType": "Этот сервер Voltius слишком старый, чтобы хранить это в командном хранилище. Попросите администратора обновить сервер, затем сохраните снова.",
"failedToLoadServerCapabilities": "Не удалось проверить, что поддерживает сервер ({{status}})",
"keyFetchFailed": "Не удалось получить ключ: {{error}}",
"failedToUploadVaultKeys": "Не удалось загрузить ключи хранилища: {{status}}",
"failedToDistributeVaultKey": "Не удалось распространить ключ хранилища: {{status}}",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ru/connections.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
"udp-proxy": "UDP-стук не проходит через прокси этого хоста. Используйте шаги TCP или прямое подключение.",
"udp-system": "UDP-стук не сработает, если используется системный прокси."
},
"serverTooOld": "Этот сервер Voltius слишком старый, чтобы хранить последовательности стука для хостов командного хранилища. Попросите администратора обновить сервер, чтобы использовать здесь стук в порты.",
"sequenceUnavailable": "Последовательность стука для этого хоста недоступна на этом устройстве.",
"sequenceInvalid": "Последовательность стука для этого хоста недействительна. Исправьте её в настройках «Стук в порты» этого хоста."
}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/tr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@
"secretsLeftInSourceVault": "Taşındı, ancak kimlik bilgileri kaynak kasadan kaldırılamadı ve orada hâlâ okunabilir: {{error}}",
"secretsUploadPending": "Taşındı, ancak kimlik bilgileri henüz yüklenemedi. Bu cihazda kalıyor ve kasa bir sonraki yüklendiğinde yeniden denenecek: {{error}}",
"failedToSaveTeamSecret": "Ekibin gizli bilgisi kaydedilemedi: {{status}}",
"serverLacksTeamSecretType": "Bu Voltius sunucusu, bunu bir ekip kasasında saklamak için çok eski. Yöneticisinden sunucuyu güncellemesini isteyin, ardından yeniden kaydedin.",
"failedToLoadServerCapabilities": "Sunucunun neleri desteklediği denetlenemedi ({{status}})",
"keyFetchFailed": "Anahtar alınamadı: {{error}}",
"failedToUploadVaultKeys": "Kasa anahtarları yüklenemedi: {{status}}",
"failedToDistributeVaultKey": "Kasa anahtarı dağıtılamadı: {{status}}",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/tr/connections.json
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@
"udp-proxy": "UDP vuruşları bu sunucunun proxy'sinden geçemez. TCP adımları ya da doğrudan bağlantı kullanın.",
"udp-system": "Sistem proxy'si kullanılıyorsa UDP vuruşları başarısız olur."
},
"serverTooOld": "Bu Voltius sunucusu, ekip kasasındaki sunucuların port vuruşu dizilerini saklamak için çok eski. Burada port vuruşunu kullanmak için yöneticisinden Voltius sunucusunu güncellemesini isteyin.",
"sequenceUnavailable": "Bu sunucunun vuruş dizisi bu cihazda kullanılamıyor.",
"sequenceInvalid": "Bu sunucunun vuruş dizisi geçersiz. Sunucunun Port vuruşu ayarlarından düzeltin."
}
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@
"secretsLeftInSourceVault": "已移动,但无法从源保险库中删除其凭据,凭据在该保险库中仍可读取:{{error}}",
"secretsUploadPending": "已移动,但其凭据暂时无法上传。它们会保留在此设备上,并在下次加载保险库时重试:{{error}}",
"failedToSaveTeamSecret": "保存团队密钥失败:{{status}}",
"serverLacksTeamSecretType": "此 Voltius 服务器版本过旧,无法在团队保险库中存储此内容。请让管理员更新服务器,然后重新保存。",
"failedToLoadServerCapabilities": "无法检查服务器支持的功能({{status}})",
"keyFetchFailed": "获取密钥失败:{{error}}",
"failedToUploadVaultKeys": "上传保险库密钥失败:{{status}}",
"failedToDistributeVaultKey": "分发保险库密钥失败:{{status}}",
Expand Down
Loading
Loading