feat: 1Password Connect secret sources for SSH credentials (#1341)

* feat: 1Password Connect secret sources for SSH credentials

Hosts and credentials can hold op://vault/item/field references instead
of secrets; they are resolved at connect time from the user's secret
source (1Password Connect) at the single point where every subsystem
receives plaintext credentials, so terminal, SFTP, Docker, metrics and
tunnels all work without per-subsystem changes. Sources are per user,
optionally shared, with the access token encrypted under the owner's
data key; resolved values are cached briefly in memory.

* style: format secret source changes
This commit is contained in:
ZacharyZcR
2026-08-25 03:06:37 +08:00
committed by GitHub
parent 32d77fc6d0
commit 5f55289e00
34 changed files with 28148 additions and 0 deletions
+21
View File
@@ -245,6 +245,27 @@ export async function setStepCaPrivateEndpoints(
}
}
export async function getSecretSourcePrivateEndpoints(): Promise<string[]> {
try {
return (await authApi.get("/users/secret-source-private-endpoints")).data
.hosts;
} catch (error) {
throw handleApiError(error, "get secret source endpoint allowlist");
}
}
export async function setSecretSourcePrivateEndpoints(
hosts: string[],
): Promise<string[]> {
try {
return (
await authApi.patch("/users/secret-source-private-endpoints", { hosts })
).data.hosts;
} catch (error) {
throw handleApiError(error, "update secret source endpoint allowlist");
}
}
export async function setNotificationPrivateEndpoints(
hosts: string[],
): Promise<string[]> {
+69
View File
@@ -0,0 +1,69 @@
import { authApi, handleApiError } from "@/main-axios";
export interface SecretSource {
id: string;
userId: string;
name: string;
kind: "onepassword-connect";
baseUrl: string;
shared: boolean;
hasToken: boolean;
owned: boolean;
createdAt: string;
updatedAt: string;
}
export interface SecretSourcePayload {
name: string;
kind?: "onepassword-connect";
baseUrl: string;
token?: string;
shared?: boolean;
}
export async function listSecretSources(): Promise<SecretSource[]> {
try {
return (await authApi.get("/secret-sources")).data.sources;
} catch (error) {
throw handleApiError(error, "list secret sources");
}
}
export async function createSecretSource(
payload: SecretSourcePayload,
): Promise<SecretSource> {
try {
return (await authApi.post("/secret-sources", payload)).data.source;
} catch (error) {
throw handleApiError(error, "create secret source");
}
}
export async function updateSecretSource(
id: string,
payload: Partial<SecretSourcePayload>,
): Promise<void> {
try {
await authApi.put(`/secret-sources/${id}`, payload);
} catch (error) {
throw handleApiError(error, "update secret source");
}
}
export async function deleteSecretSource(id: string): Promise<void> {
try {
await authApi.delete(`/secret-sources/${id}`);
} catch (error) {
throw handleApiError(error, "delete secret source");
}
}
export async function testSecretSource(
id: string,
): Promise<{ ok: boolean; vaults?: number; error?: string }> {
try {
return (await authApi.post(`/secret-sources/${id}/test`)).data;
} catch (error) {
throw handleApiError(error, "test secret source");
}
}
+19
View File
@@ -888,6 +888,22 @@
"selectAVaultProfile": "Select a Vault profile...",
"vaultProfileHint": "Settings come from the shared profile; you'll sign in to Vault via OIDC when you connect. No secrets are stored.",
"vaultNewProfile": "New profile",
"secretRefHint": "You can paste a 1Password reference (op://vault/item/field) instead of the secret; it is fetched from your secret source when connecting.",
"secretSourcesManage": "Manage secret sources",
"secretSourcesTitle": "Secret sources",
"secretSourcesDesc": "External password managers Termix reads secrets from at connect time. Currently 1Password Connect (self-hosted). The access token is stored encrypted with your data key.",
"secretSourceNew": "New source",
"secretSourceUrlLabel": "Connect server URL",
"secretSourceTokenLabel": "Connect access token",
"secretSourceTokenKeep": "Leave empty to keep the current token",
"secretSourceSharedLabel": "Share with all users (admins only; resolves while you are signed in)",
"secretSourceShared": "shared",
"secretSourceTest": "Test",
"secretSourceTestOk": "Connected — {{count}} vault(s) visible",
"secretSourceTestFailed": "Connection test failed",
"secretSourceRequired": "Name, server URL and token are required",
"secretSourceSaved": "Secret source saved",
"secretSourceDeleted": "Secret source deleted",
"vaultManageProfiles": "Manage Vault profiles",
"vaultAddrLabel": "Vault Address",
"vaultNamespaceLabel": "Namespace",
@@ -3700,6 +3716,9 @@
"stepCaProvisioner": "OIDC provisioner name",
"stepCaSaved": "Step CA settings saved",
"stepCaSaveFailed": "Failed to save Step CA settings",
"secretSourcePrivateEndpoints": "Allowed private secret source hosts",
"secretSourcePrivateEndpointsDesc": "Private hosts that secret sources (1Password Connect) may contact. Separate them with commas.",
"updateSecretSourceEndpointsFailed": "Failed to update the secret source endpoint allowlist",
"stepCaPrivateEndpoints": "Allowed private Step CA hosts",
"stepCaPrivateEndpointsDesc": "Private hosts the Step CA certificate flow may contact: the CA itself and, if internal, your identity provider. Separate them with commas.",
"updateStepCaEndpointsFailed": "Failed to update the Step CA endpoint allowlist",
+26
View File
@@ -7,6 +7,8 @@ import {
getNotificationPrivateEndpoints,
getStepCaPrivateEndpoints,
setStepCaPrivateEndpoints as setStepCaPrivateEndpointsApi,
getSecretSourcePrivateEndpoints,
setSecretSourcePrivateEndpoints as setSecretSourcePrivateEndpointsApi,
setAiGloballyEnabled as setAiGloballyEnabledApi,
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
@@ -188,6 +190,8 @@ export function AdminSettingsPanel({
const [stepCaPrivateEndpoints, setStepCaPrivateEndpoints] = useState<
string[]
>([]);
const [secretSourcePrivateEndpoints, setSecretSourcePrivateEndpoints] =
useState<string[]>([]);
const [stepCaSettings, setStepCaSettings] = useState({
caUrl: "",
fingerprint: "",
@@ -404,6 +408,7 @@ export function AdminSettingsPanel({
aiEndpoints,
notificationEndpoints,
stepCaEndpoints,
secretSourceEndpoints,
imageStorage,
] = await Promise.allSettled([
getRegistrationAllowed(),
@@ -424,6 +429,7 @@ export function AdminSettingsPanel({
getAiPrivateEndpoints(),
getNotificationPrivateEndpoints(),
getStepCaPrivateEndpoints(),
getSecretSourcePrivateEndpoints(),
getTerminalImageStorageSettings(),
]);
@@ -479,6 +485,9 @@ export function AdminSettingsPanel({
if (stepCaEndpoints.status === "fulfilled") {
setStepCaPrivateEndpoints(stepCaEndpoints.value);
}
if (secretSourceEndpoints.status === "fulfilled") {
setSecretSourcePrivateEndpoints(secretSourceEndpoints.value);
}
if (notificationEndpoints.status === "fulfilled") {
setNotificationPrivateEndpoints(notificationEndpoints.value);
}
@@ -652,6 +661,19 @@ export function AdminSettingsPanel({
}
}
async function handleSaveSecretSourcePrivateEndpoints(hosts: string[]) {
const previous = secretSourcePrivateEndpoints;
setSecretSourcePrivateEndpoints(hosts);
try {
setSecretSourcePrivateEndpoints(
await setSecretSourcePrivateEndpointsApi(hosts),
);
} catch {
setSecretSourcePrivateEndpoints(previous);
toast.error(t("admin.updateSecretSourceEndpointsFailed"));
}
}
async function handleSaveStepCaPrivateEndpoints(hosts: string[]) {
const previous = stepCaPrivateEndpoints;
setStepCaPrivateEndpoints(hosts);
@@ -1218,6 +1240,10 @@ export function AdminSettingsPanel({
notificationPrivateEndpoints={notificationPrivateEndpoints}
stepCaPrivateEndpoints={stepCaPrivateEndpoints}
onSaveStepCaPrivateEndpoints={handleSaveStepCaPrivateEndpoints}
secretSourcePrivateEndpoints={secretSourcePrivateEndpoints}
onSaveSecretSourcePrivateEndpoints={
handleSaveSecretSourcePrivateEndpoints
}
stepCaSettings={stepCaSettings}
setStepCaSettings={setStepCaSettings}
handleSaveStepCaSettings={handleSaveStepCaSettings}
+26
View File
@@ -35,6 +35,8 @@ type GeneralSettingsSectionProps = {
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
stepCaPrivateEndpoints: string[];
onSaveStepCaPrivateEndpoints: (hosts: string[]) => void;
secretSourcePrivateEndpoints: string[];
onSaveSecretSourcePrivateEndpoints: (hosts: string[]) => void;
stepCaSettings: { caUrl: string; fingerprint: string; provisioner: string };
setStepCaSettings: Dispatch<
SetStateAction<{ caUrl: string; fingerprint: string; provisioner: string }>
@@ -96,6 +98,8 @@ export function AdminGeneralSettingsSection({
onSaveNotificationPrivateEndpoints,
stepCaPrivateEndpoints,
onSaveStepCaPrivateEndpoints,
secretSourcePrivateEndpoints,
onSaveSecretSourcePrivateEndpoints,
stepCaSettings,
setStepCaSettings,
handleSaveStepCaSettings,
@@ -249,6 +253,28 @@ export function AdminGeneralSettingsSection({
/>
</div>
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.secretSourcePrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.secretSourcePrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={secretSourcePrivateEndpoints.join(", ")}
placeholder="connect.internal, 10.0.0.5"
onBlur={(event) =>
onSaveSecretSourcePrivateEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.stepCa")}
+13
View File
@@ -1,4 +1,8 @@
import { useRef, useState } from "react";
import {
SecretReferenceHint,
SecretSourceManager,
} from "./SecretSourceManager";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { Copy, Info, Lock, Upload, X } from "lucide-react";
@@ -42,6 +46,7 @@ export function CredentialEditorView({
// shows a "Save as New" action that clones it and reassigns the host.
saveAsNewHost?: Host | "new";
}) {
const [showSecretSources, setShowSecretSources] = useState(false);
const [credForm, setCredForm] = useState(() => ({
name: credential?.name ?? "",
username: credential?.username ?? "",
@@ -291,7 +296,15 @@ export function CredentialEditorView({
value={credForm.password}
onChange={(e) => setCredField("password", e.target.value)}
/>
<SecretReferenceHint
onManage={() => setShowSecretSources((v) => !v)}
/>
</div>
{showSecretSources && (
<SecretSourceManager
onClose={() => setShowSecretSources(false)}
/>
)}
<div className="flex flex-col gap-4">
<div className="p-3 border border-border bg-muted/20">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
+14
View File
@@ -94,6 +94,10 @@ import {
} from "./HostEditorGuacamoleTabs";
import { HostStatsTab } from "./HostEditorStatsTab";
import { VaultProfileManager } from "./VaultProfileManager";
import {
SecretReferenceHint,
SecretSourceManager,
} from "./SecretSourceManager";
import { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints";
import {
toCredentialOption,
@@ -190,6 +194,7 @@ export function HostEditor({
const [isOidcUser, setIsOidcUser] = useState(false);
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
const [showVaultManager, setShowVaultManager] = useState(false);
const [showSecretSources, setShowSecretSources] = useState(false);
const [quickCredentialName, setQuickCredentialName] = useState("");
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
@@ -697,8 +702,17 @@ export function HostEditor({
}}
onChange={(e) => setField("password", e.target.value)}
/>
<SecretReferenceHint
onManage={() => setShowSecretSources((v) => !v)}
/>
</div>
)}
{(authMethod === "password" || authMethod === "key") &&
showSecretSources && (
<SecretSourceManager
onClose={() => setShowSecretSources(false)}
/>
)}
{authMethod === "key" && (
<>
<div className="flex flex-col gap-1.5 col-span-2">
+298
View File
@@ -0,0 +1,298 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, Pencil, Plus, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { getErrorMessage } from "@/lib/error-message";
import {
createSecretSource,
deleteSecretSource,
listSecretSources,
testSecretSource,
updateSecretSource,
type SecretSource,
} from "@/api/secret-sources-api";
/** One line under a secret field: references are allowed, here is where to set them up. */
export function SecretReferenceHint({ onManage }: { onManage: () => void }) {
const { t } = useTranslation();
return (
<p className="text-[10px] text-muted-foreground">
{t("hosts.secretRefHint")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={onManage}
>
{t("hosts.secretSourcesManage")}
</button>
</p>
);
}
type FormState = {
id?: string;
name: string;
baseUrl: string;
token: string;
shared: boolean;
};
const emptyForm: FormState = {
name: "",
baseUrl: "",
token: "",
shared: false,
};
export function SecretSourceManager({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const [sources, setSources] = useState<SecretSource[]>([]);
const [form, setForm] = useState<FormState | null>(null);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const reload = useCallback(async () => {
try {
setSources(await listSecretSources());
} catch (e) {
toast.error(getErrorMessage(e));
}
}, []);
useEffect(() => {
void reload();
}, [reload]);
const setField = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
const handleSave = async () => {
if (!form) return;
if (
!form.name.trim() ||
!form.baseUrl.trim() ||
(!form.id && !form.token)
) {
toast.error(t("hosts.secretSourceRequired"));
return;
}
setSaving(true);
try {
if (form.id) {
await updateSecretSource(form.id, {
name: form.name,
baseUrl: form.baseUrl,
shared: form.shared,
...(form.token ? { token: form.token } : {}),
});
} else {
await createSecretSource({
name: form.name,
baseUrl: form.baseUrl,
token: form.token,
shared: form.shared,
});
}
toast.success(t("hosts.secretSourceSaved"));
setForm(null);
await reload();
} catch (e) {
toast.error(getErrorMessage(e));
} finally {
setSaving(false);
}
};
const handleDelete = async (source: SecretSource) => {
try {
await deleteSecretSource(source.id);
toast.success(t("hosts.secretSourceDeleted"));
await reload();
} catch (e) {
toast.error(getErrorMessage(e));
}
};
const handleTest = async (source: SecretSource) => {
setTesting(source.id);
try {
const result = await testSecretSource(source.id);
if (result.ok) {
toast.success(
t("hosts.secretSourceTestOk", { count: result.vaults ?? 0 }),
);
} else {
toast.error(result.error ?? t("hosts.secretSourceTestFailed"));
}
} catch (e) {
toast.error(getErrorMessage(e));
} finally {
setTesting(null);
}
};
return (
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.secretSourcesTitle")}
</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
<p className="text-[10px] text-muted-foreground">
{t("hosts.secretSourcesDesc")}
</p>
{!form && (
<>
{sources.map((source) => (
<div
key={source.id}
className="flex items-center gap-2 border border-border bg-background px-2 py-1.5 text-xs"
>
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="truncate">
{source.name}
{source.shared && (
<span className="ml-1 text-[9px] uppercase text-muted-foreground">
{t("hosts.secretSourceShared")}
</span>
)}
</div>
<div className="truncate text-[10px] text-muted-foreground">
{source.baseUrl}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-[10px]"
disabled={testing === source.id}
onClick={() => void handleTest(source)}
>
{t("hosts.secretSourceTest")}
</Button>
{source.owned && (
<>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() =>
setForm({
id: source.id,
name: source.name,
baseUrl: source.baseUrl,
token: "",
shared: source.shared,
})
}
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(source)}
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="self-start border-accent-brand/40 text-accent-brand"
onClick={() => setForm(emptyForm)}
>
<Plus className="size-3 mr-1" /> {t("hosts.secretSourceNew")}
</Button>
</>
)}
{form && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.friendlyNameLabel")}
</label>
<Input
className="h-8 text-xs"
placeholder="Team 1Password"
value={form.name}
onChange={(e) => setField("name", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.secretSourceUrlLabel")}
</label>
<Input
className="h-8 text-xs"
placeholder="https://connect.internal:8080"
value={form.baseUrl}
onChange={(e) => setField("baseUrl", e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.secretSourceTokenLabel")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
form.id ? t("hosts.secretSourceTokenKeep") : "eyJhbGciOi..."
}
value={form.token}
onChange={(e) => setField("token", e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={form.shared}
onChange={(e) => setField("shared", e.target.checked)}
/>
{t("hosts.secretSourceSharedLabel")}
</label>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setForm(null)}
disabled={saving}
>
{t("hosts.cancelBtn")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand"
onClick={() => void handleSave()}
disabled={saving}
>
{form.id ? t("common.save") : t("common.create")}
</Button>
</div>
</div>
)}
</div>
);
}