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
124 changes: 124 additions & 0 deletions client/src/components/BackendsEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Plus, Trash2 } from "lucide-react";
import {
parseBackends,
serializeBackends,
backendProtocol,
EMPTY_BACKEND,
type BackendRow,
} from "@/lib/backends";

/**
* Edit backends as rows instead of the `Name=url;flags` string the gateway
* stores.
*
* Rows are held here rather than derived from the string on each render. A row
* being filled in is not yet a valid backend, and the string cannot represent
* one — serializing drops it — so deriving would delete a new row the moment it
* was added. The string is still what gets saved: every edit serializes the
* complete rows back out through onChange.
*/
export function BackendsEditor({
value,
onChange,
}: {
value: string;
onChange: (backends: string) => void;
}) {
const [rows, setRows] = useState<BackendRow[]>(() => parseBackends(value));
const [raw, setRaw] = useState(false);

const commit = (next: BackendRow[]) => {
setRows(next);
onChange(serializeBackends(next));
};

const update = (index: number, patch: Partial<BackendRow>) =>
commit(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));

const add = () => commit([...rows, { ...EMPTY_BACKEND }]);
const remove = (index: number) => commit(rows.filter((_, i) => i !== index));

return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">Backends <span className="text-muted-foreground font-normal">(optional)</span></Label>
<button
type="button"
onClick={() => {
if (raw) setRows(parseBackends(value)); // adopt whatever was typed
setRaw(!raw);
}}
className="text-[10px] text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{raw ? "Edit as rows" : "Edit as text"}
</button>
</div>

{raw ? (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Name=rdp://host:3389;eddsa"
className="font-mono text-xs"
/>
) : (
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="rounded-md border border-border p-2 space-y-2">
<div className="flex gap-2">
<Input
value={row.name}
onChange={(e) => update(i, { name: e.target.value })}
placeholder="Name"
className="h-8 text-xs w-1/3"
/>
<Input
value={row.url}
onChange={(e) => update(i, { url: e.target.value })}
placeholder="ssh://10.0.0.9:22"
className="h-8 text-xs flex-1 font-mono"
/>
<Badge variant="outline" className="h-8 px-2 text-[10px] uppercase shrink-0 flex items-center">
{backendProtocol(row.url)}
</Badge>
<Button type="button" size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={() => remove(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-1">
{([
["eddsa", "EdDSA", "Passwordless RDP using EdDSA certificates"],
["noAuth", "No auth", "Skip JWT verification for this backend"],
["stripAuth", "Strip auth", "Remove auth headers before forwarding"],
] as const).map(([key, label, title]) => (
<label key={key} className="flex items-center gap-1.5 text-[11px] text-muted-foreground" title={title}>
<input
type="checkbox"
checked={row[key]}
onChange={(e) => update(i, { [key]: e.target.checked })}
className="h-3 w-3 accent-[hsl(var(--neon-cyan))]"
/>
{label}
</label>
))}
</div>
</div>
))}

<Button type="button" variant="outline" size="sm" className="w-full gap-1.5 h-8 text-xs" onClick={add}>
<Plus className="h-3.5 w-3.5" /> Add backend
</Button>
</div>
)}

<p className="text-[10px] text-muted-foreground">
Pre-configured endpoints. Leave empty to use custom IP connections only.
</p>
</div>
);
}
4 changes: 3 additions & 1 deletion client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,9 @@ export interface DiscoveredGateway {
turnServer: string | null;
}

export type ListedGateway = GatewayConfigSummary | DiscoveredGateway;
// Stored records are annotated with whether the gateway is currently registered
// — a record means configured, not connected.
export type ListedGateway = (GatewayConfigSummary & { online?: boolean }) | DiscoveredGateway;

export function isDiscovered(g: ListedGateway): g is DiscoveredGateway {
return (g as DiscoveredGateway).discovered === true;
Expand Down
137 changes: 31 additions & 106 deletions client/src/pages/AdminGateways.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,7 @@ import {
import { useToast } from "@/hooks/use-toast";
import { queryClient } from "@/lib/queryClient";
import { api, isDiscovered, type GatewayConfigSummary, type GatewayPushResult, type ListedGateway } from "@/lib/api";
import {
parseBackends,
serializeBackends,
backendProtocol,
EMPTY_BACKEND,
type BackendRow,
} from "@/lib/backends";
import { BackendsEditor } from "@/components/BackendsEditor";
import { useAutoRefresh } from "@/hooks/useAutoRefresh";
import { RefreshButton } from "@/components/RefreshButton";
import {
Expand Down Expand Up @@ -82,100 +76,6 @@ function SshPublicKeyBanner() {
);
}

/**
* Edit backends as rows instead of the `Name=url;flags` string the gateway
* stores. The string stays the source of truth — rows are derived on each
* render — so anything hand-written elsewhere still round-trips.
*/
function BackendsEditor({ value, onChange }: { value: string; onChange: (backends: string) => void }) {
const rows = useMemo(() => parseBackends(value), [value]);
const [raw, setRaw] = useState(false);

const update = (index: number, patch: Partial<BackendRow>) => {
const next = rows.map((row, i) => (i === index ? { ...row, ...patch } : row));
onChange(serializeBackends(next));
};

const add = () => onChange(serializeBackends([...rows, { ...EMPTY_BACKEND, name: "", url: "http://" }]));
const remove = (index: number) => onChange(serializeBackends(rows.filter((_, i) => i !== index)));

return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">Backends <span className="text-muted-foreground font-normal">(optional)</span></Label>
<button
type="button"
onClick={() => setRaw(!raw)}
className="text-[10px] text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{raw ? "Edit as rows" : "Edit as text"}
</button>
</div>

{raw ? (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Name=rdp://host:3389;eddsa"
className="font-mono text-xs"
/>
) : (
<div className="space-y-2">
{rows.map((row, i) => (
<div key={i} className="rounded-md border border-border p-2 space-y-2">
<div className="flex gap-2">
<Input
value={row.name}
onChange={(e) => update(i, { name: e.target.value })}
placeholder="Name"
className="h-8 text-xs w-1/3"
/>
<Input
value={row.url}
onChange={(e) => update(i, { url: e.target.value })}
placeholder="rdp://10.0.0.9:3389"
className="h-8 text-xs flex-1 font-mono"
/>
<Badge variant="outline" className="h-8 px-2 text-[10px] uppercase shrink-0 flex items-center">
{backendProtocol(row.url)}
</Badge>
<Button type="button" size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={() => remove(i)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 pl-1">
{([
["eddsa", "EdDSA", "Passwordless RDP using EdDSA certificates"],
["noAuth", "No auth", "Skip JWT verification for this backend"],
["stripAuth", "Strip auth", "Remove auth headers before forwarding"],
] as const).map(([key, label, title]) => (
<label key={key} className="flex items-center gap-1.5 text-[11px] text-muted-foreground" title={title}>
<input
type="checkbox"
checked={row[key]}
onChange={(e) => update(i, { [key]: e.target.checked })}
className="h-3 w-3 accent-[hsl(var(--neon-cyan))]"
/>
{label}
</label>
))}
</div>
</div>
))}

<Button type="button" variant="outline" size="sm" className="w-full gap-1.5 h-8 text-xs" onClick={add}>
<Plus className="h-3.5 w-3.5" /> Add backend
</Button>
</div>
)}

<p className="text-[10px] text-muted-foreground">
Pre-configured endpoints. Leave empty to use custom IP connections only.
</p>
</div>
);
}

/** Turn a push result into something worth reading in a toast. */
function describePush(push: GatewayPushResult): { title: string; description?: string; variant?: "destructive" } {
if (push.error) {
Expand Down Expand Up @@ -365,10 +265,26 @@ export default function AdminGateways() {
</div>

<BackendsEditor
key={editing?.id ?? "new"}
value={form.backends || ""}
onChange={(backends) => setForm({ ...form, backends })}
/>

{editing && isDiscovered(editing) && editing.issuer && (
<div className="rounded-md border border-border bg-muted/40 p-3 space-y-1">
<p className="text-xs font-medium flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5 text-[hsl(var(--neon-cyan))]" />
This gateway trusts
</p>
<p className="text-[11px] font-mono break-all">{editing.issuer}</p>
<p className="text-[10px] text-muted-foreground">
Reported by the gateway. The tidecloak.json itself is never sent over the
wire and is not pushable — paste it below to let the console generate that
file for this gateway.
</p>
</div>
)}

<div className="space-y-1">
<Label className="text-xs">Punchd Client TideCloak Config</Label>
<Textarea
Expand Down Expand Up @@ -476,7 +392,8 @@ export default function AdminGateways() {
<TableHead>Gateway</TableHead>
<TableHead className="hidden md:table-cell">STUN Server</TableHead>
<TableHead className="hidden lg:table-cell">Backends</TableHead>
<TableHead>VPN</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden sm:table-cell">VPN</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
Expand Down Expand Up @@ -512,10 +429,18 @@ export default function AdminGateways() {
<p className="text-xs text-muted-foreground truncate max-w-[200px]">{config.backends || "—"}</p>
</TableCell>
<TableCell>
{isDiscovered(config) ? (
<Badge variant="outline" className="text-muted-foreground">
{config.online ? "Online" : "Offline"}
{config.online ? (
<Badge variant="outline" className="gap-1 bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-400">
<span className="h-1.5 w-1.5 rounded-full bg-current" /> Online
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">Offline</Badge>
)}
</TableCell>
<TableCell className="hidden sm:table-cell">
{isDiscovered(config) ? (
// Not reported by the gateway, so it is unknown rather than off.
<span className="text-xs text-muted-foreground">—</span>
) : config.vpnEnabled ? (
<Badge variant="outline" className="gap-1 bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/30 dark:text-blue-400">
<Wifi className="h-3 w-3" /> On
Expand Down Expand Up @@ -565,7 +490,7 @@ export default function AdminGateways() {
))}
{(!configs || configs.length === 0) && !isLoading && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No gateway configs. Click "Add Gateway" to create one.
</TableCell>
</TableRow>
Expand Down
26 changes: 24 additions & 2 deletions server/lib/gatewayDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface LiveGateway {
}

/** A stored config, or a live gateway that has no record yet. */
export type ListedGateway = (GatewayConfig & { discovered?: false }) | DiscoveredGateway;
export type ListedGateway = (GatewayConfig & { discovered?: false; online?: boolean }) | DiscoveredGateway;

export interface DiscoveredGateway {
/** Synthetic id — no database row exists yet. */
Expand All @@ -53,6 +53,23 @@ export interface DiscoveredGateway {
turnServer: string | null;
}


/**
* Whether a gateway belongs to this deployment.
*
* Strict: a gateway must report an issuer, and it must match. A gateway that
* reports none is not listed anywhere — during the rollout these were shown on
* every console, which is exactly the cross-tenant leakage the issuer is meant
* to prevent, so the tolerance ends once gateways are reporting.
*
* Comparison ignores a trailing slash and case, since the same realm can be
* written either way in a gateway's tidecloak.json.
*/
export function belongsToTenant(issuer: string | null | undefined, ownIssuer: string): boolean {
if (!issuer) return false;
return issuer.replace(/\/+$/, "").toLowerCase() === ownIssuer.replace(/\/+$/, "").toLowerCase();
}

/** Prefix marking an id as not-yet-adopted, so the UI and API can tell. */
export const DISCOVERED_PREFIX = "discovered:";

Expand Down Expand Up @@ -112,6 +129,7 @@ export function mergeDiscovered(
live: LiveGateway[]
): ListedGateway[] {
const known = new Set(stored.map((c) => c.gatewayId.toLowerCase()));
const liveIds = new Set(live.filter((g) => g.online !== false).map((g) => g.id.toLowerCase()));
const seen = new Set<string>();
const discovered: DiscoveredGateway[] = [];

Expand All @@ -123,7 +141,11 @@ export function mergeDiscovered(
discovered.push(toDiscovered(gw));
}

return [...stored, ...discovered];
// A stored record says a gateway is configured, not that it is connected.
// Whether it is currently registered is what an admin actually wants to see.
const annotated = stored.map((c) => ({ ...c, online: liveIds.has(c.gatewayId.toLowerCase()) }));

return [...annotated, ...discovered];
}

/**
Expand Down
Loading
Loading