Add new ssh config for private resources

This commit is contained in:
Owen
2026-05-26 17:50:46 -07:00
parent eca87b66f0
commit aa7004b2ff
22 changed files with 399 additions and 144 deletions

View File

@@ -77,7 +77,7 @@ export type InternalResourceRow = {
siteIds: number[];
siteNiceIds: string[];
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http";
mode: "host" | "cidr" | "http" | "ssh";
scheme: "http" | "https" | null;
ssl: boolean;
// protocol: string | null;
@@ -90,8 +90,9 @@ export type InternalResourceRow = {
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
authDaemonMode?: "site" | "remote" | null;
authDaemonMode?: "site" | "remote" | "native" | null;
authDaemonPort?: number | null;
pamMode?: "passthrough" | "push" | null;
subdomain?: string | null;
domainId?: string | null;
fullDomain?: string | null;
@@ -410,6 +411,10 @@ export default function ClientResourcesTable({
{
value: "http",
label: t("editInternalResourceDialogModeHttp")
},
{
value: "ssh",
label: t("editInternalResourceDialogModeSsh")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
@@ -425,13 +430,14 @@ export default function ClientResourcesTable({
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<
"host" | "cidr" | "port" | "http",
"host" | "cidr" | "port" | "http" | "ssh",
string
> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort"),
http: t("editInternalResourceDialogModeHttp")
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}

View File

@@ -47,7 +47,9 @@ export default function CreateInternalResourceDialog({
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
(data.mode === "host" ||
data.mode === "http" ||
data.mode === "ssh") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
@@ -60,12 +62,15 @@ export default function CreateInternalResourceDialog({
}
}
// "ssh" mode maps to "host" in the backend with SSH settings
const backendMode = data.mode === "ssh" ? "host" : data.mode;
await api.put<
AxiosResponse<{ data: { siteResourceId: number } }>
>(`/org/${orgId}/site-resource`, {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
mode: backendMode,
destination: data.destination,
enabled: true,
...(data.mode === "http" && {
@@ -94,7 +99,25 @@ export default function CreateInternalResourceDialog({
authDaemonPort: data.authDaemonPort
})
}),
...((data.mode === "host" || data.mode == "cidr") && {
...(data.mode === "ssh" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" &&
data.authDaemonPort != null && {
authDaemonPort: data.authDaemonPort
})
}),
...((data.mode === "host" ||
data.mode === "ssh" ||
data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false

View File

@@ -51,7 +51,9 @@ export default function EditInternalResourceDialog({
try {
let data = { ...values };
if (
(data.mode === "host" || data.mode === "http") &&
(data.mode === "host" ||
data.mode === "http" ||
data.mode === "ssh") &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
@@ -64,10 +66,13 @@ export default function EditInternalResourceDialog({
}
}
// "ssh" mode maps to "host" in the backend with SSH settings
const backendMode = data.mode === "ssh" ? "host" : data.mode;
await api.post(`/site-resource/${resource.id}`, {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
mode: backendMode,
niceId: data.niceId,
destination: data.destination,
...(data.mode === "http" && {
@@ -95,7 +100,24 @@ export default function EditInternalResourceDialog({
authDaemonPort: data.authDaemonPort || null
})
}),
...((data.mode === "host" || data.mode === "cidr") && {
...(data.mode === "ssh" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" && {
authDaemonPort: data.authDaemonPort || null
})
}),
...((data.mode === "host" ||
data.mode === "ssh" ||
data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false

View File

@@ -136,7 +136,7 @@ export const cleanForFQDN = (name: string): string =>
// --- Types ---
export type InternalResourceMode = "host" | "cidr" | "http";
export type InternalResourceMode = "host" | "cidr" | "http" | "ssh";
export type InternalResourceData = {
id: number;
@@ -151,8 +151,9 @@ export type InternalResourceData = {
tcpPortRangeString?: string | null;
udpPortRangeString?: string | null;
disableIcmp?: boolean;
authDaemonMode?: "site" | "remote" | null;
authDaemonMode?: "site" | "remote" | "native" | null;
authDaemonPort?: number | null;
pamMode?: "passthrough" | "push" | null;
httpHttpsPort?: number | null;
scheme?: "http" | "https" | null;
ssl?: boolean;
@@ -183,8 +184,9 @@ export type InternalResourceFormValues = {
tcpPortRangeString?: string | null;
udpPortRangeString?: string | null;
disableIcmp?: boolean;
authDaemonMode?: "site" | "remote" | null;
authDaemonMode?: "site" | "remote" | "native" | null;
authDaemonPort?: number | null;
pamMode?: "passthrough" | "push" | null;
httpHttpsPort?: number | null;
scheme?: "http" | "https";
ssl?: boolean;
@@ -256,6 +258,10 @@ export function InternalResourceForm({
variant === "create"
? "createInternalResourceDialogModeHttp"
: "editInternalResourceDialogModeHttp";
const modeSshKey =
variant === "create"
? "createInternalResourceDialogModeSsh"
: "editInternalResourceDialogModeSsh";
const schemeLabelKey =
variant === "create"
? "createInternalResourceDialogScheme"
@@ -301,7 +307,7 @@ export function InternalResourceForm({
.object({
name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)),
siteIds: siteIdsSchema,
mode: z.enum(["host", "cidr", "http"]),
mode: z.enum(["host", "cidr", "http", "ssh"]),
destination: z
.string()
.min(
@@ -332,8 +338,12 @@ export function InternalResourceForm({
tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional(),
authDaemonMode: z.enum(["site", "remote"]).optional().nullable(),
authDaemonMode: z
.enum(["site", "remote", "native"])
.optional()
.nullable(),
authDaemonPort: z.number().int().positive().optional().nullable(),
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
roles: z.array(tagSchema).optional(),
users: z.array(tagSchema).optional(),
clients: z
@@ -456,6 +466,19 @@ export function InternalResourceForm({
number | null
>(null);
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
() => {
if (
variant === "edit" &&
resource &&
resource.authDaemonMode === "native"
) {
return "native";
}
return "standard";
}
);
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
variant === "edit" && resource
? getPortModeFromString(resource.tcpPortRangeString)
@@ -494,8 +517,12 @@ export function InternalResourceForm({
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
authDaemonMode: resource.authDaemonMode ?? "site",
authDaemonMode:
resource.authDaemonMode === "native"
? "native"
: (resource.authDaemonMode ?? "site"),
authDaemonPort: resource.authDaemonPort ?? null,
pamMode: resource.pamMode ?? "passthrough",
httpHttpsPort: resource.httpHttpsPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
@@ -524,6 +551,7 @@ export function InternalResourceForm({
disableIcmp: false,
authDaemonMode: "site",
authDaemonPort: null,
pamMode: "passthrough",
roles: [],
users: [],
clients: []
@@ -546,6 +574,15 @@ export function InternalResourceForm({
const httpConfigFullDomain = form.watch("httpConfigFullDomain");
const isHttpMode = mode === "http";
const authDaemonMode = form.watch("authDaemonMode") ?? "site";
const pamMode = form.watch("pamMode") ?? "passthrough";
const isNative = sshServerMode === "native";
const showDaemonLocation =
mode === "ssh" && !isNative && pamMode === "push";
const showDaemonPort =
mode === "ssh" &&
!isNative &&
pamMode === "push" &&
authDaemonMode === "remote";
const hasInitialized = useRef(false);
const previousResourceId = useRef<number | null>(null);
@@ -579,11 +616,13 @@ export function InternalResourceForm({
disableIcmp: false,
authDaemonMode: "site",
authDaemonPort: null,
pamMode: "passthrough",
roles: [],
users: [],
clients: []
});
setSelectedSites([]);
setSshServerMode("standard");
setTcpPortMode("all");
setUdpPortMode("all");
setTcpCustomPorts("");
@@ -611,12 +650,19 @@ export function InternalResourceForm({
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
authDaemonMode: resource.authDaemonMode ?? "site",
authDaemonMode:
resource.authDaemonMode === "native"
? "native"
: (resource.authDaemonMode ?? "site"),
authDaemonPort: resource.authDaemonPort ?? null,
pamMode: resource.pamMode ?? "passthrough",
roles: [],
users: [],
clients: []
});
setSshServerMode(
resource.authDaemonMode === "native" ? "native" : "standard"
);
setSelectedSites(buildSelectedSitesForResource(resource));
setTcpPortMode(
getPortModeFromString(resource.tcpPortRangeString)
@@ -736,7 +782,7 @@ export function InternalResourceForm({
title: t("editInternalResourceDialogAccessPolicy"),
href: "#"
},
...(disableEnterpriseFeatures || mode !== "host"
...(disableEnterpriseFeatures || mode !== "ssh"
? []
: [{ title: t("sshAccess"), href: "#" }])
]}
@@ -847,6 +893,12 @@ export function InternalResourceForm({
label: t(
modeHttpKey
)
},
{
value: "ssh" as const,
label: t(
modeSshKey
)
}
]
: [])
@@ -1574,152 +1626,256 @@ export function InternalResourceForm({
)}
</div>
{/* SSH Access tab (host mode only) */}
{!disableEnterpriseFeatures && mode === "host" && (
{/* SSH Access tab (ssh mode only) */}
{!disableEnterpriseFeatures && mode === "ssh" && (
<div className="space-y-4 mt-4 p-1">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8">
<label className="font-medium block">
{t("internalResourceAuthDaemonStrategy")}
</label>
<div className="text-sm text-muted-foreground">
{t.rich(
"internalResourceAuthDaemonDescription",
{/* Mode */}
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshServerMode")}
</p>
<StrategySelect<"standard" | "native">
value={sshServerMode}
options={[
{
docsLink: (chunks) => (
<a
href={
"https://docs.pangolin.net/manage/ssh#setup-choose-your-architecture"
}
target="_blank"
rel="noopener noreferrer"
className={
"text-primary inline-flex items-center gap-1"
}
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
id: "native",
title: t("sshServerModePangolin"),
description: t(
"sshServerModeNativeDescription"
),
disabled: sshSectionDisabled
},
{
id: "standard",
title: t("sshServerModeStandard"),
description: t(
"sshServerModeStandardDescription"
),
disabled: sshSectionDisabled
}
)}
</div>
</div>
<div className="space-y-4">
<FormField
control={form.control}
name="authDaemonMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonStrategyLabel"
)}
</FormLabel>
<FormControl>
<StrategySelect<
"site" | "remote"
>
value={
field.value ?? undefined
}
options={[
{
id: "site",
title: t(
"internalResourceAuthDaemonSite"
),
description: t(
"internalResourceAuthDaemonSiteDescription"
),
disabled:
sshSectionDisabled
},
{
id: "remote",
title: t(
"internalResourceAuthDaemonRemote"
),
description: t(
"internalResourceAuthDaemonRemoteDescription"
),
disabled:
sshSectionDisabled
}
]}
onChange={(v) => {
if (sshSectionDisabled)
return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null
);
}
}}
cols={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
]}
onChange={(v) => {
if (sshSectionDisabled) return;
setSshServerMode(v);
if (v === "native") {
form.setValue(
"authDaemonMode",
"native"
);
form.setValue(
"authDaemonPort",
null
);
} else {
form.setValue(
"authDaemonMode",
"site"
);
}
}}
cols={2}
/>
{authDaemonMode === "remote" && (
</div>
{/* Auth Method (standard only) */}
{!isNative && (
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshAuthenticationMethod")}
</p>
<FormField
control={form.control}
name="authDaemonPort"
name="pamMode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"internalResourceAuthDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
{...field}
disabled={
sshSectionDisabled
}
<StrategySelect<
"passthrough" | "push"
>
value={
field.value ?? ""
field.value ??
"passthrough"
}
onChange={(e) => {
options={[
{
id: "passthrough",
title: t(
"sshAuthMethodManual"
),
description: t(
"sshAuthMethodManualDescription"
),
disabled:
sshSectionDisabled
},
{
id: "push",
title: t(
"sshAuthMethodAutomated"
),
description: t(
"sshAuthMethodAutomatedDescription"
),
disabled:
sshSectionDisabled
}
]}
onChange={(v) => {
if (
sshSectionDisabled
)
return;
const v =
e.target.value;
if (v === "") {
field.onChange(
field.onChange(v);
if (
v ===
"passthrough"
) {
form.setValue(
"authDaemonPort",
null
);
return;
}
const num =
parseInt(v, 10);
field.onChange(
Number.isNaN(
num
)
? null
: num
);
}}
cols={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div>
)}
{/* Daemon Location (standard + push) */}
{showDaemonLocation && (
<div className="space-y-3">
<p className="text-sm font-semibold">
{t("sshAuthDaemonLocation")}
</p>
<FormField
control={form.control}
name="authDaemonMode"
render={({ field }) => (
<FormItem>
<FormControl>
<StrategySelect<
"site" | "remote"
>
value={
(field.value as
| "site"
| "remote") ??
"site"
}
options={[
{
id: "site",
title: t(
"internalResourceAuthDaemonSite"
),
description: t(
"sshDaemonLocationSiteDescription"
),
disabled:
sshSectionDisabled
},
{
id: "remote",
title: t(
"sshDaemonLocationRemote"
),
description: t(
"sshDaemonLocationRemoteDescription"
),
disabled:
sshSectionDisabled
}
]}
onChange={(v) => {
if (
sshSectionDisabled
)
return;
field.onChange(v);
if (v === "site") {
form.setValue(
"authDaemonPort",
null
);
}
}}
cols={2}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/private/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
)}
{/* Daemon Port (standard + push + remote) */}
{showDaemonPort && (
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
placeholder="22123"
disabled={
sshSectionDisabled
}
value={field.value ?? ""}
onChange={(e) => {
if (sshSectionDisabled)
return;
const v =
e.target.value;
if (v === "") {
field.onChange(
null
);
return;
}
const num = parseInt(
v,
10
);
field.onChange(
Number.isNaN(num)
? null
: num
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
)}
</HorizontalTabs>