feat(connection): add reusable tunnel profiles

This commit is contained in:
rainhan authored and GitHub committed 2026-07-12 22:03:46 +08:00
1 parent 4dcee1aca0
commit 977e7f78ca
34 files changed
+1335 -22

No files matched your search

@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { createTunnelProfile, detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles";
import type { TunnelProfile } from "@/types/database";
function sshProfile(overrides: Partial<TunnelProfile> = {}): TunnelProfile {
return {
...createTunnelProfile("ssh"),
id: "profile-1",
name: "Bastion",
host: "bastion.example.com",
user: "deploy",
password: "s3cret",
...overrides,
} as TunnelProfile;
}
describe("tunnelProfileSummary", () => {
it("formats ssh profiles as user@host:port", () => {
expect(tunnelProfileSummary(sshProfile())).toBe("deploy@bastion.example.com:22");
});
it("formats proxy profiles as scheme://host:port", () => {
const proxy = { ...createTunnelProfile("proxy"), host: "127.0.0.1", port: 1080 } as TunnelProfile;
expect(tunnelProfileSummary(proxy)).toBe("socks5://127.0.0.1:1080");
});
it("returns the url for http tunnel profiles", () => {
const http = { ...createTunnelProfile("http_tunnel"), url: "https://example.com/dbx_tunnel.php" } as TunnelProfile;
expect(tunnelProfileSummary(http)).toBe("https://example.com/dbx_tunnel.php");
});
it("returns an empty string when the target is not configured yet", () => {
expect(tunnelProfileSummary(createTunnelProfile("ssh"))).toBe("");
});
});
describe("tunnelProfileReferenceLayer", () => {
it("keeps only identity, enabled state, and the reference", () => {
const profile = sshProfile();
const stub = tunnelProfileReferenceLayer(profile, { id: "layer-1", enabled: false });
expect(stub.id).toBe("layer-1");
expect(stub.enabled).toBe(false);
expect(stub.profile_id).toBe("profile-1");
expect(stub.name).toBe("Bastion");
// Credentials must not be copied into the stub stored on the connection.
expect(stub.type).toBe("ssh");
if (stub.type === "ssh") {
expect(stub.host).toBe("");
expect(stub.password).toBe("");
}
});
it("generates a fresh id when there is no previous layer", () => {
const stub = tunnelProfileReferenceLayer(sshProfile());
expect(stub.id).toBeTruthy();
expect(stub.enabled).toBe(true);
});
});
describe("detachTunnelProfileLayer", () => {
it("copies the profile configuration onto the layer and drops the reference", () => {
const profile = sshProfile();
const stub = tunnelProfileReferenceLayer(profile, { id: "layer-1", enabled: true });
const detached = detachTunnelProfileLayer(stub, profile);
expect(detached.id).toBe("layer-1");
expect(detached.profile_id).toBeUndefined();
if (detached.type === "ssh") {
expect(detached.host).toBe("bastion.example.com");
expect(detached.password).toBe("s3cret");
} else {
throw new Error("expected ssh layer");
}
});
it("only drops the reference when the profile no longer exists", () => {
const stub = tunnelProfileReferenceLayer(sshProfile(), { id: "layer-1", enabled: true });
const detached = detachTunnelProfileLayer(stub, undefined);
expect(detached.profile_id).toBeUndefined();
expect(detached.id).toBe("layer-1");
});
});
+2
View File
@@ -59,6 +59,8 @@ export const closeDatabaseConnection = forward("closeDatabaseConnection");
export const refreshConnections = forward("refreshConnections");
export const saveConnections = forward("saveConnections");
export const loadConnections = forward("loadConnections");
export const loadTunnelProfiles = forward("loadTunnelProfiles");
export const saveTunnelProfiles = forward("saveTunnelProfiles");
export const readKeychainPassword = forward("readKeychainPassword");
export const readKeychainPasswords = forward("readKeychainPasswords");
export const decryptConfig = forward("decryptConfig");
+9
View File
@@ -33,6 +33,7 @@ import type {
SavedSqlFolder,
SavedSqlLibrary,
SshConfigHostEntry,
TunnelProfile,
} from "@/types/database";
import type { CollectionInfo } from "@/types/database";
import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, TableDiff, FunctionDiff, SequenceDiff, RuleDiff, OwnerDiff } from "@/lib/schema/schemaDiff";
@@ -236,6 +237,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return get("/api/connection/list");
}
export async function loadTunnelProfiles(): Promise<TunnelProfile[]> {
return get("/api/tunnel-profiles/list");
}
export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise<void> {
return post("/api/tunnel-profiles/save", { profiles });
}
export async function readKeychainPassword(_service: string): Promise<string> {
return ""; // Not available in web backend
}
+9
View File
@@ -34,6 +34,7 @@ import type {
SavedSqlFolder,
SavedSqlLibrary,
SshConfigHostEntry,
TunnelProfile,
} from "@/types/database";
import type { CollectionInfo } from "@/types/database";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
@@ -1109,6 +1110,14 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return invoke("load_connections");
}
export async function loadTunnelProfiles(): Promise<TunnelProfile[]> {
return invoke("load_tunnel_profiles");
}
export async function saveTunnelProfiles(profiles: TunnelProfile[]): Promise<void> {
return invoke("save_tunnel_profiles", { profiles });
}
export async function readKeychainPassword(service: string): Promise<string> {
return invoke("read_keychain_password", { service, account: null });
}
@@ -0,0 +1,95 @@
import { uuid } from "@/lib/common/utils";
import type { TransportLayerConfig, TunnelProfile } from "@/types/database";
export type TunnelProfileType = TunnelProfile["type"];
export function createTunnelProfile(type: TunnelProfileType): TunnelProfile {
if (type === "proxy") {
return {
type: "proxy",
id: uuid(),
name: "",
enabled: true,
proxy_type: "socks5",
host: "",
port: 1080,
username: "",
password: "",
};
}
if (type === "http_tunnel") {
return {
type: "http_tunnel",
id: uuid(),
name: "",
enabled: true,
url: "",
token: "",
connect_timeout_secs: 10,
};
}
return {
type: "ssh",
id: uuid(),
name: "",
enabled: true,
host: "",
port: 22,
user: "root",
password: "",
key_path: "",
key_passphrase: "",
connect_timeout_secs: 5,
expose_lan: false,
use_ssh_agent: false,
ssh_agent_sock_path: "",
auth_method: "password",
};
}
export function tunnelProfileSummary(profile: TunnelProfile): string {
if (profile.type === "ssh") {
if (!profile.host) return "";
const user = profile.user ? `${profile.user}@` : "";
return `${user}${profile.host}:${profile.port || 22}`;
}
if (profile.type === "proxy") {
if (!profile.host) return "";
return `${profile.proxy_type || "socks5"}://${profile.host}:${profile.port || 1080}`;
}
return profile.url || "";
}
export function layerReferencesProfile(layer: TransportLayerConfig): boolean {
return !!layer.profile_id;
}
/**
* Builds the reference stub stored on a connection for a layer that uses a
* shared profile: only identity, enabled state, and the reference survive —
* the backend swaps in the profile's full configuration at connect time, and
* keeping credentials out of the stub avoids stale copies.
*/
export function tunnelProfileReferenceLayer(profile: TunnelProfile, previous?: Pick<TransportLayerConfig, "id" | "enabled">): TransportLayerConfig {
const stub = createTunnelProfile(profile.type);
stub.id = previous?.id || stub.id;
stub.enabled = previous?.enabled !== false;
stub.name = profile.name || "";
stub.profile_id = profile.id;
return stub;
}
/**
* Detaches a profile-referencing layer back into a self-contained custom
* layer by copying the profile's full configuration onto it.
*/
export function detachTunnelProfileLayer(layer: TransportLayerConfig, profile: TunnelProfile | undefined): TransportLayerConfig {
if (!profile) {
const detached = { ...layer };
delete detached.profile_id;
return detached;
}
const detached = { ...profile, id: layer.id, enabled: layer.enabled !== false };
delete detached.profile_id;
return detached;
}