feat(connection): add reusable tunnel profiles
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user