Compare commits

..

1 Commits

Author SHA1 Message Date
Owen 3670d295fa Adding gateway resources 2026-09-23 10:41:00 -04:00
12 changed files with 247 additions and 45 deletions
+1 -1
View File
@@ -492,7 +492,7 @@ export const siteResources = pgTable(
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode")
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">()
.notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
+1 -1
View File
@@ -513,7 +513,7 @@ export const siteResources = sqliteTable("siteResources", {
name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode")
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
.$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">()
.notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
@@ -17,7 +17,6 @@ import {
performDeleteSiteResources,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
import logger from "@server/logger";
export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250;
@@ -53,7 +53,7 @@ const createSiteResourceSchema = z
name: z.string().min(1).max(255),
niceId: z.string().optional(),
// protocol: z.enum(["tcp", "udp"]).optional(),
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
mode: z.enum(["host", "cidr", "http", "ssh", "inference", "gateway"]),
ssl: z.boolean().optional(), // only used for http mode
scheme: z.enum(["http", "https"]).optional(),
siteIds: z.array(z.int()).optional(),
@@ -165,10 +165,11 @@ const createSiteResourceSchema = z
)
.refine(
(data) => {
// destination is only optional for ssh mode with native authDaemonMode or inference
// destination is only optional for ssh mode with native authDaemonMode, inference, or gateway
if (
(data.mode === "ssh" && data.authDaemonMode === "native") ||
data.mode == "inference"
data.mode == "inference" ||
data.mode == "gateway"
) {
return true;
}
@@ -179,7 +180,7 @@ const createSiteResourceSchema = z
},
{
message:
"Destination is required unless mode is ssh with authDaemonMode native or inference"
"Destination is required unless mode is ssh with authDaemonMode native, inference, or gateway"
}
)
.refine(
@@ -447,14 +448,18 @@ export async function createSiteResource(
);
}
// gateway resources always route the whole subnet with everything open
const effectiveDestination =
mode === "gateway" ? "0.0.0.0/0" : destination;
// Only check if destination is an IP address
const isIp = z
.union([z.ipv4(), z.ipv6()])
.safeParse(destination).success;
.safeParse(effectiveDestination).success;
if (
isIp &&
(isIpInCidr(destination!, org.subnet) ||
isIpInCidr(destination!, org.utilitySubnet))
(isIpInCidr(effectiveDestination!, org.subnet) ||
isIpInCidr(effectiveDestination!, org.utilitySubnet))
) {
return next(
createHttpError(
@@ -584,6 +589,32 @@ export async function createSiteResource(
tcpPortRangeStringAdjusted = destinationPort
? destinationPort.toString()
: "22";
} else if (mode === "gateway") {
tcpPortRangeStringAdjusted = "*";
}
let udpPortRangeStringAdjusted = udpPortRangeString;
if (mode === "gateway") {
udpPortRangeStringAdjusted = "*";
} else if (
mode === "http" ||
mode === "ssh" ||
mode === "inference"
) {
udpPortRangeStringAdjusted = "";
}
// default to true for http/ssh/inference, false otherwise;
// gateway always allows icmp
let disableIcmpAdjusted = disableIcmp ?? false;
if (mode === "gateway") {
disableIcmpAdjusted = false;
} else if (
mode === "http" ||
mode === "ssh" ||
mode === "inference"
) {
disableIcmpAdjusted = true;
}
// Create the site resource
@@ -594,21 +625,14 @@ export async function createSiteResource(
mode,
ssl,
networkId: network ? network.networkId : null,
destination: destination, // the ssh can be null
destination: effectiveDestination, // the ssh can be null
scheme,
destinationPort,
alias: alias ? alias.trim() : null,
aliasAddress,
tcpPortRangeString: tcpPortRangeStringAdjusted,
udpPortRangeString:
mode == "http" || mode == "ssh" || mode == "inference"
? ""
: udpPortRangeString,
disableIcmp:
disableIcmp ||
(mode == "http" || mode == "ssh" || mode == "inference"
? true
: false), // default to true for http resources, otherwise false
udpPortRangeString: udpPortRangeStringAdjusted,
disableIcmp: disableIcmpAdjusted,
domainId,
subdomain: finalSubdomain,
fullDomain,
@@ -51,7 +51,9 @@ const updateSiteResourceSchema = z
)
.optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]).optional(),
mode: z
.enum(["host", "cidr", "http", "ssh", "inference", "gateway"])
.optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).nullish(),
destinationPort: z.int().positive().nullish(),
@@ -158,10 +160,11 @@ const updateSiteResourceSchema = z
if (data.mode === undefined && data.destination === undefined) {
return true;
}
// destination is only optional for ssh mode with native authDaemonMode or inference
// destination is only optional for ssh mode with native authDaemonMode, inference, or gateway
if (
(data.mode === "ssh" && data.authDaemonMode === "native") ||
data.mode == "inference"
data.mode == "inference" ||
data.mode == "gateway"
) {
return true;
}
@@ -172,7 +175,7 @@ const updateSiteResourceSchema = z
},
{
message:
"Destination is required unless mode is ssh with authDaemonMode native or inference"
"Destination is required unless mode is ssh with authDaemonMode native, inference, or gateway"
}
)
.refine(
@@ -409,14 +412,18 @@ export async function updateSiteResource(
}
}
// gateway resources always route the whole subnet with everything open
const effectiveDestination =
mode === "gateway" ? "0.0.0.0/0" : destination;
// Only check if destination is an IP address
const isIp = z
.union([z.ipv4(), z.ipv6()])
.safeParse(destination).success;
.safeParse(effectiveDestination).success;
if (
isIp &&
(isIpInCidr(destination!, org.subnet) ||
isIpInCidr(destination!, org.utilitySubnet))
(isIpInCidr(effectiveDestination!, org.subnet) ||
isIpInCidr(effectiveDestination!, org.utilitySubnet))
) {
return next(
createHttpError(
@@ -542,6 +549,34 @@ export async function updateSiteResource(
tcpPortRangeStringAdjusted = destinationPort
? destinationPort.toString()
: "22";
} else if (mode === "gateway") {
tcpPortRangeStringAdjusted = "*";
}
// undefined means "leave unchanged" (partial update); only
// adjusted when the mode is explicitly being changed
let udpPortRangeStringAdjusted = udpPortRangeString;
if (mode === "gateway") {
udpPortRangeStringAdjusted = "*";
} else if (
mode === "http" ||
mode === "ssh" ||
mode === "inference"
) {
udpPortRangeStringAdjusted = "";
}
let disableIcmpAdjusted = disableIcmp;
if (mode === "gateway") {
disableIcmpAdjusted = false;
} else if (
mode === "http" ||
mode === "ssh" ||
mode === "inference"
) {
disableIcmpAdjusted = true;
} else if (mode !== undefined) {
disableIcmpAdjusted = disableIcmp ?? false;
}
[updatedSiteResource] = await trx
@@ -552,7 +587,8 @@ export async function updateSiteResource(
mode: mode,
scheme,
ssl,
destination: destination,
destination:
mode === "gateway" ? effectiveDestination : destination,
destinationPort: destinationPort,
enabled: enabled,
alias:
@@ -562,19 +598,8 @@ export async function updateSiteResource(
: null
: undefined,
tcpPortRangeString: tcpPortRangeStringAdjusted,
udpPortRangeString:
mode == "http" || mode == "ssh" || mode == "inference"
? ""
: udpPortRangeString,
disableIcmp:
mode !== undefined
? disableIcmp ||
(mode == "http" ||
mode == "ssh" ||
mode == "inference"
? true
: false)
: disableIcmp,
udpPortRangeString: udpPortRangeStringAdjusted,
disableIcmp: disableIcmpAdjusted,
domainId,
subdomain: finalSubdomain,
fullDomain,
@@ -0,0 +1,106 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { createGatewayFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
export default function PrivateResourceGatewayPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createGatewayFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "gateway"
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
siteIds: data.siteIds,
mode: "gateway"
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("gatewaySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationGatewayDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-gateway-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-gateway-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -53,7 +53,8 @@ export default async function PrivateResourceLayout(
| "cidrSettings"
| "httpSettings"
| "sshSettings"
| "inferenceSettings";
| "inferenceSettings"
| "gatewaySettings";
const navItems = [
{
@@ -164,6 +164,11 @@ export default function CreatePrivateResourcePage() {
value: "inference" as const,
title: t("createInternalResourceDialogModeInference"),
description: t("resourceTypeInferenceDescription")
},
{
value: "gateway" as const,
title: t("createInternalResourceDialogModeGateway"),
description: t("resourceTypeGatewayDescription")
}
];
@@ -560,6 +565,38 @@ export default function CreatePrivateResourcePage() {
</SettingsSection>
)}
{/* Gateway destination */}
{mode === "gateway" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("gatewaySettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationGatewayDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* HTTP configuration */}
{mode === "http" && (
<SettingsSection>
+2 -1
View File
@@ -93,7 +93,8 @@ export function PrivateResourceInfoSections({
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh"),
inference: t("editInternalResourceDialogModeInference")
inference: t("editInternalResourceDialogModeInference"),
gateway: t("editInternalResourceDialogModeGateway")
};
const destination = formatSiteResourceDestinationDisplay({
+2 -1
View File
@@ -376,7 +376,8 @@ export default function PrivateResourcesTable({
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh"),
inference: t("editInternalResourceDialogModeInference")
inference: t("editInternalResourceDialogModeInference"),
gateway: t("editInternalResourceDialogModeGateway")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
+2 -1
View File
@@ -71,7 +71,8 @@ function PrivateResourceMeta({ row }: { row: SiteResourceRow }) {
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh"),
inference: t("editInternalResourceDialogModeInference")
inference: t("editInternalResourceDialogModeInference"),
gateway: t("editInternalResourceDialogModeGateway")
};
const dest = formatSiteResourceDestinationDisplay({
mode: row.mode,
+7
View File
@@ -652,6 +652,13 @@ export function createCidrFormSchema(t: TranslateFn) {
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
}
export function createGatewayFormSchema(t: TranslateFn) {
return z.object({
siteIds: z.array(z.number().int().positive()).min(1),
mode: z.literal("gateway")
});
}
export function createHttpFormSchema(t: TranslateFn) {
return z
.object({