mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-26 10:43:09 +00:00
Add support for push pam users
This commit is contained in:
@@ -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", {
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,14 +198,17 @@ 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
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
try {
|
if (!override) {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
try {
|
||||||
} catch {
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||||
// ignore
|
} catch {
|
||||||
|
// 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 ||
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user