mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-16 18:46:35 +02:00
538b57941c
z#
659 lines
22 KiB
TypeScript
659 lines
22 KiB
TypeScript
import { z } from "zod";
|
|
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
|
|
|
export type PrivateResourceMode = "host" | "cidr" | "http" | "ssh";
|
|
|
|
export type SiteResourceData = InternalResourceRow & {
|
|
enabled: boolean;
|
|
};
|
|
|
|
export type PortMode = "all" | "blocked" | "custom";
|
|
|
|
const tagSchema = z.object({ id: z.string(), text: z.string() });
|
|
|
|
export type PrivateResourceAccessTag = z.infer<typeof tagSchema>;
|
|
|
|
export type PrivateResourceClient = {
|
|
clientId: number;
|
|
name: string;
|
|
};
|
|
|
|
export type PrivateResourceFormValues = {
|
|
name: string;
|
|
siteIds: number[];
|
|
mode: PrivateResourceMode;
|
|
destination: string | null;
|
|
alias?: string | null;
|
|
niceId?: string;
|
|
enabled?: boolean;
|
|
tcpPortRangeString?: string | null;
|
|
udpPortRangeString?: string | null;
|
|
disableIcmp?: boolean;
|
|
authDaemonMode?: "site" | "remote" | "native" | null;
|
|
authDaemonPort?: number | null;
|
|
pamMode?: "passthrough" | "push" | null;
|
|
destinationPort?: number | null;
|
|
scheme?: "http" | "https";
|
|
ssl?: boolean;
|
|
httpConfigSubdomain?: string | null;
|
|
httpConfigDomainId?: string | null;
|
|
httpConfigFullDomain?: string | null;
|
|
roles?: PrivateResourceAccessTag[];
|
|
users?: PrivateResourceAccessTag[];
|
|
clients?: PrivateResourceClient[];
|
|
};
|
|
|
|
export type SiteResourceAccess = {
|
|
roleIds: number[];
|
|
userIds: string[];
|
|
clientIds: number[];
|
|
};
|
|
|
|
type TranslateFn = (key: string) => string;
|
|
|
|
export const isValidPortRangeString = (
|
|
val: string | undefined | null
|
|
): boolean => {
|
|
if (!val || val.trim() === "" || val.trim() === "*") return true;
|
|
const parts = val.split(",").map((p) => p.trim());
|
|
for (const part of parts) {
|
|
if (part === "") return false;
|
|
if (part.includes("-")) {
|
|
const [start, end] = part.split("-").map((p) => p.trim());
|
|
if (!start || !end) return false;
|
|
const startPort = parseInt(start, 10);
|
|
const endPort = parseInt(end, 10);
|
|
if (isNaN(startPort) || isNaN(endPort)) return false;
|
|
if (
|
|
startPort < 1 ||
|
|
startPort > 65535 ||
|
|
endPort < 1 ||
|
|
endPort > 65535
|
|
)
|
|
return false;
|
|
if (startPort > endPort) return false;
|
|
} else {
|
|
const port = parseInt(part, 10);
|
|
if (isNaN(port) || port < 1 || port > 65535) return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const getPortRangeValidationMessage = (t: TranslateFn) =>
|
|
t("editInternalResourceDialogPortRangeValidationError");
|
|
|
|
export const createPortRangeStringSchema = (t: TranslateFn) =>
|
|
z
|
|
.string()
|
|
.optional()
|
|
.nullable()
|
|
.refine((val) => isValidPortRangeString(val), {
|
|
message: getPortRangeValidationMessage(t)
|
|
});
|
|
|
|
export const getPortModeFromString = (
|
|
val: string | undefined | null
|
|
): PortMode => {
|
|
if (val === "*") return "all";
|
|
if (!val || val.trim() === "") return "blocked";
|
|
return "custom";
|
|
};
|
|
|
|
export const getPortStringFromMode = (
|
|
mode: PortMode,
|
|
customValue: string
|
|
): string | undefined => {
|
|
if (mode === "all") return "*";
|
|
if (mode === "blocked") return "";
|
|
return customValue;
|
|
};
|
|
|
|
export const isHostname = (destination: string | null): boolean =>
|
|
!!destination && /[a-zA-Z]/.test(destination);
|
|
|
|
export const cleanForFQDN = (name: string): string =>
|
|
name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9.-]/g, "-")
|
|
.replace(/[-]+/g, "-")
|
|
.replace(/^-|-$/g, "")
|
|
.replace(/^\.|\.$/g, "");
|
|
|
|
export function applyAliasAutoGeneration(
|
|
values: PrivateResourceFormValues
|
|
): PrivateResourceFormValues {
|
|
const data = { ...values };
|
|
if (
|
|
(data.mode === "host" ||
|
|
data.mode === "http" ||
|
|
(data.mode === "ssh" && data.authDaemonMode !== "native")) &&
|
|
isHostname(data.destination)
|
|
) {
|
|
const currentAlias = data.alias?.trim() || "";
|
|
if (!currentAlias) {
|
|
let aliasValue = data.destination!;
|
|
if (data.destination?.toLowerCase() === "localhost") {
|
|
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
|
}
|
|
data.alias = aliasValue;
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
|
|
export function accessTagsToIds(access: {
|
|
roles?: PrivateResourceAccessTag[];
|
|
users?: PrivateResourceAccessTag[];
|
|
clients?: PrivateResourceClient[];
|
|
}): SiteResourceAccess {
|
|
return {
|
|
roleIds: (access.roles ?? []).map((r) => parseInt(r.id)),
|
|
userIds: (access.users ?? []).map((u) => u.id),
|
|
clientIds: (access.clients ?? []).map((c) => c.clientId)
|
|
};
|
|
}
|
|
|
|
export function buildCreateSiteResourcePayload(
|
|
values: PrivateResourceFormValues
|
|
) {
|
|
const data = applyAliasAutoGeneration(values);
|
|
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
|
|
|
|
return {
|
|
name: data.name,
|
|
siteIds: data.siteIds,
|
|
mode: data.mode,
|
|
destination: isNativeSsh ? undefined : (data.destination ?? undefined),
|
|
...(data.mode === "http" && {
|
|
scheme: data.scheme,
|
|
ssl: data.ssl ?? false,
|
|
destinationPort: data.destinationPort ?? undefined,
|
|
domainId: data.httpConfigDomainId
|
|
? data.httpConfigDomainId
|
|
: undefined,
|
|
subdomain: data.httpConfigSubdomain
|
|
? data.httpConfigSubdomain
|
|
: undefined
|
|
}),
|
|
...(data.mode === "host" && {
|
|
alias:
|
|
data.alias &&
|
|
typeof data.alias === "string" &&
|
|
data.alias.trim()
|
|
? data.alias
|
|
: undefined,
|
|
...(data.authDaemonMode != null && {
|
|
authDaemonMode: data.authDaemonMode
|
|
}),
|
|
...(data.authDaemonMode === "remote" &&
|
|
data.authDaemonPort != null && {
|
|
authDaemonPort: data.authDaemonPort
|
|
})
|
|
}),
|
|
...(data.mode === "ssh" && {
|
|
alias:
|
|
data.alias &&
|
|
typeof data.alias === "string" &&
|
|
data.alias.trim()
|
|
? data.alias
|
|
: undefined,
|
|
...(!isNativeSsh && {
|
|
destinationPort: data.destinationPort ?? 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 === "cidr") && {
|
|
tcpPortRangeString: data.tcpPortRangeString,
|
|
udpPortRangeString: data.udpPortRangeString,
|
|
disableIcmp: data.disableIcmp ?? false
|
|
}),
|
|
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
|
|
userIds: data.users ? data.users.map((u) => u.id) : [],
|
|
clientIds: data.clients ? data.clients.map((c) => c.clientId) : []
|
|
};
|
|
}
|
|
|
|
export function buildUpdateSiteResourcePayload(
|
|
values: PrivateResourceFormValues,
|
|
access: SiteResourceAccess
|
|
) {
|
|
const data = applyAliasAutoGeneration(values);
|
|
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
|
|
|
|
return {
|
|
name: data.name,
|
|
siteIds: data.siteIds,
|
|
mode: data.mode,
|
|
niceId: data.niceId,
|
|
enabled: data.enabled,
|
|
...(isNativeSsh
|
|
? { destination: null, destinationPort: null }
|
|
: { destination: data.destination ?? undefined }),
|
|
...(data.mode === "http" && {
|
|
scheme: data.scheme,
|
|
ssl: data.ssl ?? false,
|
|
destinationPort: data.destinationPort ?? null,
|
|
domainId: data.httpConfigDomainId
|
|
? data.httpConfigDomainId
|
|
: undefined,
|
|
subdomain: data.httpConfigSubdomain
|
|
? data.httpConfigSubdomain
|
|
: undefined
|
|
}),
|
|
...(data.mode === "host" && {
|
|
alias:
|
|
data.alias &&
|
|
typeof data.alias === "string" &&
|
|
data.alias.trim()
|
|
? data.alias
|
|
: null,
|
|
...(data.authDaemonMode != null && {
|
|
authDaemonMode: data.authDaemonMode
|
|
}),
|
|
...(data.authDaemonMode === "remote" && {
|
|
authDaemonPort: data.authDaemonPort || null
|
|
})
|
|
}),
|
|
...(data.mode === "ssh" && {
|
|
alias:
|
|
data.alias &&
|
|
typeof data.alias === "string" &&
|
|
data.alias.trim()
|
|
? data.alias
|
|
: null,
|
|
...(!isNativeSsh && {
|
|
destinationPort: data.destinationPort ?? null
|
|
}),
|
|
pamMode: data.pamMode ?? undefined,
|
|
...(data.authDaemonMode != null && {
|
|
authDaemonMode: data.authDaemonMode
|
|
}),
|
|
...(data.authDaemonMode === "remote" && {
|
|
authDaemonPort: data.authDaemonPort || null
|
|
})
|
|
}),
|
|
...((data.mode === "host" || data.mode === "cidr") && {
|
|
tcpPortRangeString: data.tcpPortRangeString,
|
|
udpPortRangeString: data.udpPortRangeString,
|
|
disableIcmp: data.disableIcmp ?? false
|
|
}),
|
|
roleIds: access.roleIds,
|
|
userIds: access.userIds,
|
|
clientIds: access.clientIds
|
|
};
|
|
}
|
|
|
|
export function inferSshPamMode(
|
|
authDaemonMode?: string | null,
|
|
pamMode?: "passthrough" | "push" | null
|
|
): "passthrough" | "push" {
|
|
if (pamMode === "passthrough" || pamMode === "push") {
|
|
return pamMode;
|
|
}
|
|
|
|
return authDaemonMode === "remote" ? "push" : "passthrough";
|
|
}
|
|
|
|
export function siteResourceToFormValues(
|
|
resource: SiteResourceData
|
|
): PrivateResourceFormValues {
|
|
return {
|
|
name: resource.name,
|
|
siteIds: resource.siteIds,
|
|
mode: resource.mode ?? "host",
|
|
destination: resource.destination ?? "",
|
|
alias: resource.alias ?? null,
|
|
destinationPort: resource.destinationPort ?? null,
|
|
scheme: resource.scheme ?? "http",
|
|
ssl: resource.ssl ?? false,
|
|
httpConfigSubdomain: resource.subdomain ?? null,
|
|
httpConfigDomainId: resource.domainId ?? null,
|
|
httpConfigFullDomain: resource.fullDomain ?? null,
|
|
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
|
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
|
disableIcmp: resource.disableIcmp ?? false,
|
|
authDaemonMode:
|
|
resource.authDaemonMode === "native"
|
|
? "native"
|
|
: (resource.authDaemonMode ?? "site"),
|
|
authDaemonPort: resource.authDaemonPort ?? null,
|
|
pamMode: inferSshPamMode(resource.authDaemonMode, resource.pamMode),
|
|
niceId: resource.niceId,
|
|
enabled: resource.enabled
|
|
};
|
|
}
|
|
|
|
export function createGeneralFormSchema(t: TranslateFn) {
|
|
return z.object({
|
|
name: z
|
|
.string()
|
|
.min(1, t("editInternalResourceDialogNameRequired"))
|
|
.max(255, t("editInternalResourceDialogNameMaxLength")),
|
|
niceId: z
|
|
.string()
|
|
.min(1)
|
|
.max(255)
|
|
.regex(/^[a-zA-Z0-9-]+$/),
|
|
enabled: z.boolean()
|
|
});
|
|
}
|
|
|
|
export function createAccessFormSchema() {
|
|
return z.object({
|
|
roles: z.array(tagSchema).optional(),
|
|
users: z.array(tagSchema).optional(),
|
|
clients: z
|
|
.array(
|
|
z.object({
|
|
clientId: z.number(),
|
|
name: z.string()
|
|
})
|
|
)
|
|
.optional()
|
|
});
|
|
}
|
|
|
|
export function createCreateFormSchema(t: TranslateFn) {
|
|
const destinationRequired = t(
|
|
"createInternalResourceDialogDestinationRequired"
|
|
);
|
|
|
|
return z
|
|
.object({
|
|
name: z
|
|
.string()
|
|
.min(1, t("createInternalResourceDialogNameRequired"))
|
|
.max(255, t("createInternalResourceDialogNameMaxLength")),
|
|
siteIds: z
|
|
.array(z.number().int().positive())
|
|
.min(1, t("createInternalResourceDialogPleaseSelectSite")),
|
|
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
|
destination: z.string().nullish(),
|
|
alias: z.string().nullish(),
|
|
destinationPort: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.max(65535)
|
|
.optional()
|
|
.nullable(),
|
|
scheme: z.enum(["http", "https"]).optional(),
|
|
ssl: z.boolean().optional(),
|
|
httpConfigSubdomain: z.string().nullish(),
|
|
httpConfigDomainId: z.string().nullish(),
|
|
httpConfigFullDomain: z.string().nullish(),
|
|
authDaemonMode: z
|
|
.enum(["site", "remote", "native"])
|
|
.optional()
|
|
.nullable(),
|
|
standardDaemonLocation: z
|
|
.enum(["site", "remote"])
|
|
.optional()
|
|
.nullable(),
|
|
authDaemonPort: z.number().int().positive().optional().nullable(),
|
|
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
|
|
tcpPortRangeString: createPortRangeStringSchema(t),
|
|
udpPortRangeString: createPortRangeStringSchema(t),
|
|
disableIcmp: z.boolean().optional()
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
const isNativeSsh =
|
|
data.mode === "ssh" && data.authDaemonMode === "native";
|
|
const trimmedDestination = data.destination?.trim();
|
|
if (
|
|
data.mode !== "ssh" &&
|
|
(!trimmedDestination || trimmedDestination.length < 1)
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: destinationRequired,
|
|
path: ["destination"]
|
|
});
|
|
}
|
|
if (data.mode === "ssh" && !isNativeSsh) {
|
|
if (!trimmedDestination || trimmedDestination.length < 1) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: destinationRequired,
|
|
path: ["destination"]
|
|
});
|
|
}
|
|
if (
|
|
data.destinationPort == null ||
|
|
!Number.isFinite(data.destinationPort) ||
|
|
data.destinationPort < 1
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("internalResourceHttpPortRequired"),
|
|
path: ["destinationPort"]
|
|
});
|
|
}
|
|
}
|
|
if (data.mode === "http") {
|
|
if (!data.scheme) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("internalResourceDownstreamSchemeRequired"),
|
|
path: ["scheme"]
|
|
});
|
|
}
|
|
if (
|
|
data.destinationPort == null ||
|
|
!Number.isFinite(data.destinationPort) ||
|
|
data.destinationPort < 1
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("internalResourceHttpPortRequired"),
|
|
path: ["destinationPort"]
|
|
});
|
|
}
|
|
if (!data.httpConfigDomainId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("domainRequired"),
|
|
path: ["httpConfigDomainId"]
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function destinationRefine(
|
|
data: {
|
|
mode: PrivateResourceMode;
|
|
destination?: string | null;
|
|
authDaemonMode?: string | null;
|
|
destinationPort?: number | null;
|
|
scheme?: string;
|
|
httpConfigDomainId?: string | null;
|
|
},
|
|
ctx: z.RefinementCtx,
|
|
t: TranslateFn,
|
|
destinationRequired?: string
|
|
) {
|
|
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
|
|
const trimmedDestination = data.destination?.trim();
|
|
if (
|
|
!isNativeSsh &&
|
|
(!trimmedDestination || trimmedDestination.length < 1)
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: destinationRequired ?? "Destination is required",
|
|
path: ["destination"]
|
|
});
|
|
}
|
|
if (data.mode === "ssh" && !isNativeSsh) {
|
|
if (
|
|
data.destinationPort == null ||
|
|
!Number.isFinite(data.destinationPort) ||
|
|
data.destinationPort < 1
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("internalResourceHttpPortRequired"),
|
|
path: ["destinationPort"]
|
|
});
|
|
}
|
|
}
|
|
if (data.mode === "http") {
|
|
if (!data.scheme) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("internalResourceDownstreamSchemeRequired"),
|
|
path: ["scheme"]
|
|
});
|
|
}
|
|
if (
|
|
data.destinationPort == null ||
|
|
!Number.isFinite(data.destinationPort) ||
|
|
data.destinationPort < 1
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("internalResourceHttpPortRequired"),
|
|
path: ["destinationPort"]
|
|
});
|
|
}
|
|
if (!data.httpConfigDomainId) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("domainRequired"),
|
|
path: ["httpConfigDomainId"]
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createHostFormSchema(t: TranslateFn) {
|
|
return z
|
|
.object({
|
|
siteIds: z.array(z.number().int().positive()).min(1),
|
|
mode: z.literal("host"),
|
|
destination: z.string().nullish(),
|
|
alias: z.string().nullish(),
|
|
tcpPortRangeString: createPortRangeStringSchema(t),
|
|
udpPortRangeString: createPortRangeStringSchema(t),
|
|
disableIcmp: z.boolean().optional(),
|
|
authDaemonMode: z
|
|
.enum(["site", "remote", "native"])
|
|
.optional()
|
|
.nullable(),
|
|
authDaemonPort: z.number().int().positive().optional().nullable()
|
|
})
|
|
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
|
}
|
|
|
|
export function createCidrFormSchema(t: TranslateFn) {
|
|
return z
|
|
.object({
|
|
siteIds: z.array(z.number().int().positive()).min(1),
|
|
mode: z.literal("cidr"),
|
|
destination: z.string().nullish(),
|
|
tcpPortRangeString: createPortRangeStringSchema(t),
|
|
udpPortRangeString: createPortRangeStringSchema(t),
|
|
disableIcmp: z.boolean().optional()
|
|
})
|
|
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
|
}
|
|
|
|
export function createHttpFormSchema(t: TranslateFn) {
|
|
return z
|
|
.object({
|
|
siteIds: z.array(z.number().int().positive()).min(1),
|
|
mode: z.literal("http"),
|
|
destination: z.string().nullish(),
|
|
destinationPort: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.max(65535)
|
|
.optional()
|
|
.nullable(),
|
|
scheme: z.enum(["http", "https"]).optional(),
|
|
ssl: z.boolean().optional(),
|
|
httpConfigSubdomain: z.string().nullish(),
|
|
httpConfigDomainId: z.string().nullish(),
|
|
httpConfigFullDomain: z.string().nullish()
|
|
})
|
|
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
|
}
|
|
|
|
export function createSshFormSchema(
|
|
t: TranslateFn,
|
|
options?: { isNative?: boolean }
|
|
) {
|
|
const isNative = options?.isNative ?? false;
|
|
|
|
return z
|
|
.object({
|
|
siteIds: z.array(z.number().int().positive()).min(1),
|
|
mode: z.literal("ssh"),
|
|
destination: z.string().nullish(),
|
|
alias: z.string().nullish(),
|
|
destinationPort: z
|
|
.number()
|
|
.int()
|
|
.min(1)
|
|
.max(65535)
|
|
.optional()
|
|
.nullable(),
|
|
pamMode: z.enum(["passthrough", "push"]),
|
|
standardDaemonLocation: z.enum(["site", "remote"]),
|
|
authDaemonPort: z.string()
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
destinationRefine(
|
|
{
|
|
...data,
|
|
authDaemonMode: isNative
|
|
? "native"
|
|
: data.standardDaemonLocation
|
|
},
|
|
ctx,
|
|
t
|
|
);
|
|
|
|
const showDaemonPort =
|
|
!isNative &&
|
|
data.pamMode === "push" &&
|
|
data.standardDaemonLocation === "remote";
|
|
|
|
if (showDaemonPort) {
|
|
const port = Number(data.authDaemonPort);
|
|
if (
|
|
!data.authDaemonPort.trim() ||
|
|
!Number.isInteger(port) ||
|
|
port < 1 ||
|
|
port > 65535
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: t("healthCheckPortInvalid"),
|
|
path: ["authDaemonPort"]
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export function mergeFormValuesWithResource(
|
|
resource: SiteResourceData,
|
|
partial: Partial<PrivateResourceFormValues>
|
|
): PrivateResourceFormValues {
|
|
return {
|
|
...siteResourceToFormValues(resource),
|
|
...partial
|
|
};
|
|
}
|