Add support for push pam users

This commit is contained in:
Owen
2026-05-22 12:12:55 -07:00
parent fe67e8e384
commit 454449ec8a
5 changed files with 175 additions and 24 deletions

View File

@@ -160,7 +160,13 @@ export const resources = pgTable("resources", {
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false), wildcard: boolean("wildcard").notNull().default(false),
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc browserAccessType: text("browserAccessType").default("http"), // rdp, ssh, http, vnc
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site")
}); });
export const labels = pgTable("labels", { export const labels = pgTable("labels", {

View File

@@ -181,7 +181,13 @@ export const resources = sqliteTable("resources", {
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false), wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
browserAccessType: text("browserAccessType").default("http") // rdp, ssh, http, vnc browserAccessType: text("browserAccessType").default("http"), // rdp, ssh, http, vnc
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site")
}); });
export const labels = sqliteTable("labels", { export const labels = sqliteTable("labels", {

View File

@@ -21,6 +21,11 @@ export type GetBrowserTargetResponse = {
ip: string; ip: string;
port: number; port: number;
authToken: string; authToken: string;
orgId: string;
resourceId: number;
niceId: string;
pamMode: "passthrough" | "push" | null;
authDaemonMode: "site" | "remote" | "native" | null;
}; };
export async function getBrowserTarget( export async function getBrowserTarget(
@@ -47,7 +52,12 @@ export async function getBrowserTarget(
.select({ .select({
destination: browserGatewayTarget.destination, destination: browserGatewayTarget.destination,
destinationPort: browserGatewayTarget.destinationPort, destinationPort: browserGatewayTarget.destinationPort,
authToken: browserGatewayTarget.authToken authToken: browserGatewayTarget.authToken,
resourceId: resources.resourceId,
niceId: resources.niceId,
orgId: resources.orgId,
pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode
}) })
.from(browserGatewayTarget) .from(browserGatewayTarget)
.innerJoin( .innerJoin(
@@ -57,7 +67,7 @@ export async function getBrowserTarget(
.where(eq(resources.fullDomain, fullDomain)) .where(eq(resources.fullDomain, fullDomain))
.limit(1); .limit(1);
const decryptAuthToken = decrypt( const decryptedAuthToken = decrypt(
browserTarget.authToken, browserTarget.authToken,
config.getRawConfig().server.secret! config.getRawConfig().server.secret!
); );
@@ -75,7 +85,12 @@ export async function getBrowserTarget(
data: { data: {
ip: browserTarget.destination, ip: browserTarget.destination,
port: browserTarget.destinationPort, port: browserTarget.destinationPort,
authToken: decryptAuthToken authToken: decryptedAuthToken,
pamMode: browserTarget.pamMode,
authDaemonMode: browserTarget.authDaemonMode,
orgId: browserTarget.orgId,
resourceId: browserTarget.resourceId,
niceId: browserTarget.niceId
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -6,12 +6,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import type { SignSshKeyResponse } from "@server/private/routers/ssh";
type Target = { import { GetBrowserTargetResponse } from "@server/routers/resource";
ip: string;
port: number;
authToken: string;
};
type FormState = { type FormState = {
username: string; username: string;
@@ -19,12 +15,23 @@ type FormState = {
privateKey: string; privateKey: string;
}; };
type ConnectCredentials = {
username: string;
password?: string;
privateKey?: string;
certificate?: string;
};
export default function SshClient({ export default function SshClient({
target, target,
error error,
signedKeyData,
privateKey: signedPrivateKey
}: { }: {
target: Target | null; target: GetBrowserTargetResponse | null;
error: string | null; error: string | null;
signedKeyData?: SignSshKeyResponse | null;
privateKey?: string | null;
}) { }) {
const STORAGE_KEY = "pangolin_ssh_credentials"; const STORAGE_KEY = "pangolin_ssh_credentials";
@@ -148,7 +155,19 @@ export default function SshClient({
}; };
}, []); }, []);
function connect() { // Auto-connect when signed key data is provided (push PAM mode).
useEffect(() => {
if (signedKeyData && signedPrivateKey && target) {
connect({
username: signedKeyData.sshUsername,
privateKey: signedPrivateKey,
certificate: signedKeyData.certificate
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function connect(override?: ConnectCredentials) {
setConnectError(null); setConnectError(null);
setConnecting(true); setConnecting(true);
@@ -158,11 +177,16 @@ export default function SshClient({
return; return;
} }
const username = override?.username ?? form.username;
const password = override?.password ?? form.password;
const privateKey = override?.privateKey ?? form.privateKey;
const certificate = override?.certificate;
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
const url = new URL(proxyAddress); const url = new URL(proxyAddress);
url.searchParams.set("host", target.ip ?? ""); url.searchParams.set("host", target.ip ?? "");
url.searchParams.set("port", String(target.port ?? 22)); url.searchParams.set("port", String(target.port ?? 22));
url.searchParams.set("username", form.username); url.searchParams.set("username", username);
url.searchParams.set("authToken", target.authToken ?? ""); url.searchParams.set("authToken", target.authToken ?? "");
const ws = new WebSocket(url.toString(), ["ssh"]); const ws = new WebSocket(url.toString(), ["ssh"]);
@@ -174,15 +198,18 @@ export default function SshClient({
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "auth", type: "auth",
password: form.password, password,
privateKey: form.privateKey privateKey,
certificate
}) })
); );
if (!override) {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
} catch { } catch {
// ignore // ignore
} }
}
setConnecting(false); setConnecting(false);
setConnected(true); setConnected(true);
}; };
@@ -240,6 +267,43 @@ export default function SshClient({
); );
} }
// In push mode, show a connecting/connected state without the login form.
if (signedKeyData && signedPrivateKey) {
return (
<div className="min-h-screen bg-background">
{!connected && (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground">
{connectError
? connectError
: connecting
? "Connecting…"
: "Initializing…"}
</p>
</div>
)}
{connected && (
<div className="flex h-screen flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
onClick={disconnect}
>
Terminate
</Button>
</div>
<div
ref={terminalRef}
className="flex-1 overflow-hidden"
style={{ minHeight: 0 }}
/>
</div>
)}
</div>
);
}
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{!connected && ( {!connected && (
@@ -335,7 +399,7 @@ export default function SshClient({
)} )}
<Button <Button
onClick={connect} onClick={() => connect()}
loading={connecting} loading={connecting}
disabled={ disabled={
!form.username || !form.username ||

View File

@@ -3,6 +3,42 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { GetBrowserTargetResponse } from "@server/routers/resource"; import { GetBrowserTargetResponse } from "@server/routers/resource";
import SshClient from "./SshClient"; import SshClient from "./SshClient";
import { SignSshKeyResponse } from "@server/private/routers/ssh";
import crypto from "crypto";
function generateEphemeralKeyPair(): {
privateKeyPem: string;
publicKeyOpenSSH: string;
} {
const { publicKey: pubKeyObj, privateKey: privKeyObj } =
crypto.generateKeyPairSync("ed25519");
const privateKeyPem = privKeyObj.export({
type: "pkcs8",
format: "pem"
}) as string;
// Build OpenSSH wire format: uint32-length-prefixed strings
const pubKeyDer = pubKeyObj.export({
type: "spki",
format: "der"
}) as Buffer;
const rawPubKey = pubKeyDer.subarray(pubKeyDer.length - 32); // last 32 bytes are the Ed25519 key
function encodeField(b: Buffer): Buffer {
const len = Buffer.allocUnsafe(4);
len.writeUInt32BE(b.length, 0);
return Buffer.concat([len, b]);
}
const keyBlob = Buffer.concat([
encodeField(Buffer.from("ssh-ed25519")),
encodeField(rawPubKey)
]);
const publicKeyOpenSSH = `ssh-ed25519 ${keyBlob.toString("base64")}`;
return { privateKeyPem, publicKeyOpenSSH };
}
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -15,7 +51,9 @@ export default async function SshPage() {
const host = headersList.get("host") || ""; const host = headersList.get("host") || "";
const hostname = host.split(":")[0]; const hostname = host.split(":")[0];
let target: { ip: string; port: number; authToken: string } | null = null; let target: GetBrowserTargetResponse | null = null;
let signedKeyData: SignSshKeyResponse | null = null;
let privateKey: string | null = null;
let error: string | null = null; let error: string | null = null;
try { try {
@@ -23,10 +61,32 @@ export default async function SshPage() {
`/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}` `/resource/browser-target?fullDomain=${encodeURIComponent(hostname)}`
); );
target = res.data.data; target = res.data.data;
if (target.pamMode === "push") {
const { privateKeyPem, publicKeyOpenSSH } =
generateEphemeralKeyPair();
privateKey = privateKeyPem;
const res = await priv.post<AxiosResponse<SignSshKeyResponse>>(
`/org/${target.orgId}/ssh/sign-key`,
{
publicKey: publicKeyOpenSSH,
resource: target.niceId
}
);
signedKeyData = res.data.data;
console.log("Received signed SSH key:", signedKeyData);
}
} catch (error) { } catch (error) {
console.error("Error fetching browser target:", error); console.error("Error fetching browser target:", error);
error = "No resource found for this domain"; error = "No resource found for this domain";
} }
return <SshClient target={target} error={error} />; return (
<SshClient
target={target}
error={error}
signedKeyData={signedKeyData}
privateKey={privateKey}
/>
);
} }