mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-29 09:19:04 +02:00
Merge branch 'site-exit-node' into dev
This commit is contained in:
+7
-1
@@ -26,7 +26,7 @@
|
||||
"trialBannerMessage": "Your trial expires in {countdown}. Upgrade to keep access.",
|
||||
"trialBannerExpired": "Your trial has expired. Upgrade now to restore access.",
|
||||
"billingTrialBannerTitle": "Free Trial Active",
|
||||
"billingTrialBannerDescription": "You're currently on a free trial on the business tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.",
|
||||
"billingTrialBannerDescription": "You're currently on a free trial on the {tier} tier. When the trial ends, your account will automatically revert to the Basic tier features and limits. Upgrade anytime to keep access to your current plan's features.",
|
||||
"billingTrialBannerUpgrade": "Upgrade Now",
|
||||
"billingTrialBadge": "Free Trial",
|
||||
"trialActive": "Free Trial Active",
|
||||
@@ -307,6 +307,8 @@
|
||||
"privateResourceTypeCidrDescription": "Expose a CIDR range on the site network to connected clients",
|
||||
"privateResourceTypeHttpDescription": "Access an HTTP or HTTPS service through a domain",
|
||||
"privateResourceTypeSshDescription": "Access an SSH server from connected clients",
|
||||
"privateResourceTypeGatewayDescription": "Send all internet traffic to exit through the site network.",
|
||||
"resourceTypeGatewayDescription": "Send all internet traffic to exit through the site network.",
|
||||
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
|
||||
"resourceHTTPSSettings": "HTTPS Settings",
|
||||
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
|
||||
@@ -2927,6 +2929,7 @@
|
||||
"editInternalResourceDialogModePort": "Port",
|
||||
"editInternalResourceDialogModeHost": "Host",
|
||||
"editInternalResourceDialogModeCidr": "CIDR",
|
||||
"editInternalResourceDialogModeGateway": "Exit Node",
|
||||
"editInternalResourceDialogModeHttp": "HTTP",
|
||||
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||
"editInternalResourceDialogModeInference": "AI Gateway",
|
||||
@@ -2938,6 +2941,7 @@
|
||||
"editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
|
||||
"editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||
"editInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
|
||||
"editInternalResourceDialogDestinationGatewayDescription": "The sites to uses as exit nodes for this resource",
|
||||
"editInternalResourceDialogAlias": "Alias",
|
||||
"editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
||||
"createInternalResourceDialogNoSitesAvailable": "No Sites Available",
|
||||
@@ -2952,6 +2956,7 @@
|
||||
"privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.",
|
||||
"hostSettings": "Host",
|
||||
"cidrSettings": "CIDR",
|
||||
"gatewaySettings": "Exit Node",
|
||||
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
||||
"createInternalResourceDialogName": "Name",
|
||||
"createInternalResourceDialogSite": "Site",
|
||||
@@ -2990,6 +2995,7 @@
|
||||
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||
"createInternalResourceDialogModeSsh": "SSH",
|
||||
"createInternalResourceDialogModeInference": "AI Gateway",
|
||||
"createInternalResourceDialogModeGateway": "Exit Node",
|
||||
"scheme": "Scheme",
|
||||
"createInternalResourceDialogScheme": "Scheme",
|
||||
"createInternalResourceDialogEnableSsl": "Enable TLS",
|
||||
|
||||
@@ -492,8 +492,8 @@ export const siteResources = pgTable(
|
||||
name: varchar("name").notNull(),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
mode: varchar("mode")
|
||||
.$type<"host" | "cidr" | "http" | "ssh" | "inference">()
|
||||
.notNull(), // "host" | "cidr" | "http"
|
||||
.$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">()
|
||||
.notNull(),
|
||||
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
|
||||
@@ -513,8 +513,8 @@ 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">()
|
||||
.notNull(), // "host" | "cidr" | "http"
|
||||
.$type<"host" | "cidr" | "http" | "ssh" | "inference" | "gateway">()
|
||||
.notNull(),
|
||||
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||
proxyPort: integer("proxyPort"), // only for port mode
|
||||
destinationPort: integer("destinationPort"), // only for port mode
|
||||
|
||||
@@ -259,6 +259,11 @@ export async function updatePrivateResources(
|
||||
}
|
||||
|
||||
const isInference = resourceData.mode === "inference";
|
||||
const isGateway = resourceData.mode === "gateway";
|
||||
// gateway resources always route the whole subnet with everything open
|
||||
const effectiveDestination = isGateway
|
||||
? "0.0.0.0/0"
|
||||
: resourceData.destination;
|
||||
|
||||
// Update existing resource
|
||||
const [updatedResource] = await trx
|
||||
@@ -268,23 +273,28 @@ export async function updatePrivateResources(
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceSsl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destination: effectiveDestination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: resourceEnabled,
|
||||
alias: resourceData.alias || null,
|
||||
disableIcmp:
|
||||
resourceData["disable-icmp"] ||
|
||||
(resourceData.mode == "http" || isInference
|
||||
? true
|
||||
: false), // default to true for http/inference resources, otherwise false
|
||||
disableIcmp: isGateway
|
||||
? false // gateway always allows icmp
|
||||
: resourceData["disable-icmp"] ||
|
||||
(resourceData.mode == "http" || isInference
|
||||
? true
|
||||
: false), // default to true for http/inference resources, otherwise false
|
||||
tcpPortRangeString:
|
||||
resourceData.mode == "http" || isInference
|
||||
? "443,80"
|
||||
: resourceData["tcp-ports"],
|
||||
: isGateway
|
||||
? "*"
|
||||
: resourceData["tcp-ports"],
|
||||
udpPortRangeString:
|
||||
resourceData.mode == "http" || isInference
|
||||
? ""
|
||||
: resourceData["udp-ports"],
|
||||
: isGateway
|
||||
? "*"
|
||||
: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||
domainId: domainInfo ? domainInfo.domainId : null,
|
||||
@@ -529,6 +539,11 @@ export async function updatePrivateResources(
|
||||
}
|
||||
|
||||
const isInference = resourceData.mode === "inference";
|
||||
const isGateway = resourceData.mode === "gateway";
|
||||
// gateway resources always route the whole subnet with everything open
|
||||
const effectiveDestination = isGateway
|
||||
? "0.0.0.0/0"
|
||||
: resourceData.destination;
|
||||
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
@@ -590,24 +605,29 @@ export async function updatePrivateResources(
|
||||
mode: resourceData.mode,
|
||||
ssl: resourceSsl,
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destination: effectiveDestination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: resourceEnabled,
|
||||
alias: resourceData.alias || null,
|
||||
aliasAddress: aliasAddress,
|
||||
disableIcmp:
|
||||
resourceData["disable-icmp"] ||
|
||||
(resourceData.mode == "http" || isInference
|
||||
? true
|
||||
: false), // default to true for http/inference resources, otherwise false
|
||||
disableIcmp: isGateway
|
||||
? false // gateway always allows icmp
|
||||
: resourceData["disable-icmp"] ||
|
||||
(resourceData.mode == "http" || isInference
|
||||
? true
|
||||
: false), // default to true for http/inference resources, otherwise false
|
||||
tcpPortRangeString:
|
||||
resourceData.mode == "http" || isInference
|
||||
? "443,80"
|
||||
: resourceData["tcp-ports"],
|
||||
: isGateway
|
||||
? "*"
|
||||
: resourceData["tcp-ports"],
|
||||
udpPortRangeString:
|
||||
resourceData.mode == "http" || isInference
|
||||
? ""
|
||||
: resourceData["udp-ports"],
|
||||
: isGateway
|
||||
? "*"
|
||||
: resourceData["udp-ports"],
|
||||
fullDomain: resourceData["full-domain"] || null,
|
||||
subdomain: domainInfo ? domainInfo.subdomain : null,
|
||||
domainId: domainInfo ? domainInfo.domainId : null,
|
||||
|
||||
@@ -612,7 +612,7 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
||||
export const PrivateResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh", "inference", "gateway"]),
|
||||
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
|
||||
sites: z.array(z.string()).optional().default([]),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
@@ -652,13 +652,15 @@ export const PrivateResourceSchema = z
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// destination is optional only for ssh+native or inference; required for everything else
|
||||
// destination is optional only for ssh+native, inference, or gateway
|
||||
// (gateway always routes the whole subnet, so destination is ignored); required for everything else
|
||||
const isNativeSSH =
|
||||
data.mode === "ssh" &&
|
||||
(data["auth-daemon"] === undefined ||
|
||||
data["auth-daemon"].mode === "native");
|
||||
if (
|
||||
data.mode !== "inference" &&
|
||||
data.mode !== "gateway" &&
|
||||
!isNativeSSH &&
|
||||
!data.destination
|
||||
) {
|
||||
@@ -669,7 +671,7 @@ export const PrivateResourceSchema = z
|
||||
{
|
||||
path: ["destination"],
|
||||
message:
|
||||
"destination is required unless mode is 'ssh' with auth-daemon mode 'native', or mode is 'inference'"
|
||||
"destination is required unless mode is 'ssh' with auth-daemon mode 'native', 'inference', or 'gateway'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+2
-1
@@ -500,6 +500,7 @@ export function generateRemoteSubnets(
|
||||
if (!sr.enabled) return false;
|
||||
if (!sr.destination) return false;
|
||||
|
||||
// we purposely filter out the gateway resource here because we add it manually on the client
|
||||
if (sr.mode === "cidr") {
|
||||
// check if its a valid CIDR using zod
|
||||
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
|
||||
@@ -808,7 +809,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
resourceId: siteResource.siteResourceId
|
||||
});
|
||||
}
|
||||
} else if (siteResource.mode == "cidr") {
|
||||
} else if (siteResource.mode == "cidr" || siteResource.mode == "gateway") {
|
||||
targets.push({
|
||||
sourcePrefixes: [],
|
||||
destPrefix: siteResource.destination!,
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
import { and, count, eq, inArray, isNotNull, ne } from "drizzle-orm";
|
||||
|
||||
import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers";
|
||||
import {
|
||||
sendGatewayDisable,
|
||||
sendGatewaySitesUpdate
|
||||
} from "@server/routers/olm/gateway";
|
||||
import {
|
||||
initPeerAddHandshakeBatch,
|
||||
deletePeersBatch as olmDeletePeersBatch
|
||||
@@ -536,6 +540,19 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||
);
|
||||
}
|
||||
|
||||
// A client that loses access to a gateway resource (it was deleted, or the
|
||||
// client's roles/users/machines no longer include it) can't keep using it
|
||||
// as its gateway. The olm ignores this unless it selected this resource.
|
||||
if (
|
||||
siteResource.mode === "gateway" &&
|
||||
clientSiteResourcesToRemove.length > 0
|
||||
) {
|
||||
await sendGatewayDisable(
|
||||
clientSiteResourcesToRemove,
|
||||
siteResource.siteResourceId
|
||||
);
|
||||
}
|
||||
|
||||
/////////// process the client-site associations ///////////
|
||||
|
||||
logger.debug(
|
||||
@@ -2056,6 +2073,29 @@ export async function handleMessagingForUpdatedSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
// The olm only knows which gateway resource it selected and the sites it
|
||||
// is currently using for it, so tell the clients that have access to this
|
||||
// one what changed. Clients that lost access are handled by the rebuild.
|
||||
if (existingSiteResource?.mode === "gateway") {
|
||||
const clientIds = mergedAllClients.map((c) => c.clientId);
|
||||
if (
|
||||
updatedSiteResource.mode !== "gateway" ||
|
||||
!updatedSiteResource.enabled
|
||||
) {
|
||||
await sendGatewayDisable(
|
||||
clientIds,
|
||||
updatedSiteResource.siteResourceId
|
||||
);
|
||||
} else {
|
||||
await sendGatewaySitesUpdate(
|
||||
clientIds,
|
||||
updatedSiteResource.siteResourceId,
|
||||
addedSiteIds,
|
||||
removedSiteIds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
|
||||
);
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function buildClientConfigurationForNewtClient(
|
||||
await updatePeer(client.clients.clientId, {
|
||||
siteId: site.siteId,
|
||||
endpoint: site.endpoint!,
|
||||
relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`,
|
||||
relayEndpoint: formatEndpoint(exitNode.endpoint, config.getRawConfig().gerbil.clients_start_port),
|
||||
publicKey: site.publicKey!,
|
||||
serverIP: site.address,
|
||||
serverPort: site.listenPort
|
||||
|
||||
@@ -15,6 +15,7 @@ import { fetchContainers } from "./dockerSocket";
|
||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import { NewtErrorCodes, sendNewtError } from "./error";
|
||||
import { formatEndpoint } from "@server/lib/ip";
|
||||
|
||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
@@ -224,7 +225,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
message: {
|
||||
type: "newt/wg/connect",
|
||||
data: {
|
||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||
endpoint: formatEndpoint(exitNode.endpoint, exitNode.listenPort),
|
||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||
publicKey: exitNode.publicKey,
|
||||
serverIP: exitNode.address.split("/")[0],
|
||||
|
||||
@@ -4,6 +4,7 @@ import logger from "@server/logger";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { formatEndpoint } from "@server/lib/ip";
|
||||
|
||||
export async function addTargets(
|
||||
newtId: string,
|
||||
@@ -14,9 +15,7 @@ export async function addTargets(
|
||||
) {
|
||||
//create a list of udp and tcp targets
|
||||
const payloadTargets = targets.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${formatEndpoint(target.ip, target.port)}`;
|
||||
});
|
||||
|
||||
if (payloadTargets.length > 0) {
|
||||
@@ -208,9 +207,7 @@ export async function removeTargets(
|
||||
) {
|
||||
//create a list of udp and tcp targets
|
||||
const payloadTargets = targets.map((target) => {
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${
|
||||
target.ip
|
||||
}:${target.port}`;
|
||||
return `${target.internalPort ? target.internalPort + ":" : ""}${formatEndpoint(target.ip, target.port)}`;
|
||||
});
|
||||
|
||||
if (payloadTargets.length > 0) {
|
||||
|
||||
@@ -231,7 +231,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
publicKey: site.publicKey,
|
||||
serverIP: site.address,
|
||||
serverPort: site.listenPort,
|
||||
remoteSubnets: generateRemoteSubnets(allSiteResources),
|
||||
remoteSubnets: generateRemoteSubnets(allSiteResources), // we dont add the gateway resources here
|
||||
aliases: generateAliasConfig(allSiteResources)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { sendToClientsBatch } from "#dynamic/routers/ws";
|
||||
import { db, olms } from "@server/db";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import logger from "@server/logger";
|
||||
import { inArray } from "drizzle-orm";
|
||||
|
||||
// The olm only tracks which gateway (exit node) site resource it selected, by
|
||||
// its numeric siteResourceId, and the site IDs that resource currently
|
||||
// resolves to. So all the server has to push is what changed for that one
|
||||
// resource; the olm ignores a message whose siteResourceId isn't the one it
|
||||
// selected, which stops a site added to some other gateway resource from
|
||||
// being pulled into the client's gateway set. (Creates aren't pushed: a
|
||||
// client has to select a gateway resource before it can be using it.)
|
||||
|
||||
async function sendGatewayMessageToClients(
|
||||
clientIds: number[],
|
||||
type: string,
|
||||
data: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
const uniqueClientIds = Array.from(new Set(clientIds));
|
||||
if (uniqueClientIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const olmRows = await db
|
||||
.select({
|
||||
olmId: olms.olmId,
|
||||
version: olms.version
|
||||
})
|
||||
.from(olms)
|
||||
.where(inArray(olms.clientId, uniqueClientIds));
|
||||
|
||||
const payloads = olmRows.map((olm) => ({
|
||||
clientId: olm.olmId,
|
||||
message: { type, data },
|
||||
options: {
|
||||
compress: canCompress(olm.version, "olm"),
|
||||
incrementConfigVersion: true
|
||||
}
|
||||
}));
|
||||
|
||||
if (payloads.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendToClientsBatch(payloads).catch((error) => {
|
||||
logger.error(`Error sending ${type} messages to olms:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
// Tells the olms of the given clients that sites were added to / removed from
|
||||
// the gateway site resource, so those that selected it can adjust the set of
|
||||
// sites they use as the gateway.
|
||||
export async function sendGatewaySitesUpdate(
|
||||
clientIds: number[],
|
||||
siteResourceId: number,
|
||||
addedSiteIds: number[],
|
||||
removedSiteIds: number[]
|
||||
): Promise<void> {
|
||||
if (addedSiteIds.length === 0 && removedSiteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendGatewayMessageToClients(
|
||||
clientIds,
|
||||
"olm/wg/gateway/sites/update",
|
||||
{ siteResourceId, addedSiteIds, removedSiteIds }
|
||||
);
|
||||
}
|
||||
|
||||
// Tells the olms of the given clients that the gateway site resource can no
|
||||
// longer be used as a gateway (it was deleted, disabled, changed to another
|
||||
// mode, or the client lost access to it), so those that selected it drop out
|
||||
// of gateway mode.
|
||||
export async function sendGatewayDisable(
|
||||
clientIds: number[],
|
||||
siteResourceId: number
|
||||
): Promise<void> {
|
||||
await sendGatewayMessageToClients(clientIds, "olm/wg/gateway/disable", {
|
||||
siteResourceId
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "#dynamic/lib/exitNodes";
|
||||
import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||
import { formatEndpoint } from "@server/lib/ip";
|
||||
|
||||
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
|
||||
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
|
||||
@@ -508,7 +509,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
? {
|
||||
aliases: exitNodeAliases,
|
||||
connect: exitNodeAliases.length > 0, // we do not need to connect to the exit node if we do not have inference resources and right now all site resources on the exit node have an alias
|
||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||
endpoint: formatEndpoint(exitNode.endpoint, exitNode.listenPort),
|
||||
publicKey: exitNode.publicKey,
|
||||
serverIP: exitNode.address.split("/")[0],
|
||||
tunnelIP: `${clientSubnet.split("/")[0]}/${exitNode.address.split("/")[1]}` // we need to use the exit node's subnet mask here because the client will be using the exit node's subnet mask for its routing table so we can address it
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -524,6 +524,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
const currentPlanId = getCurrentPlanId();
|
||||
const currentPlan = planOptions.find((p) => p.id === currentPlanId);
|
||||
|
||||
const visiblePlanOptions = planOptions.filter(
|
||||
(plan) => plan.id !== "home" || currentPlanId === "home"
|
||||
@@ -873,10 +874,8 @@ export default function BillingPage() {
|
||||
{/* Trial Banner */}
|
||||
{isTrial && (
|
||||
<TrialBillingBanner
|
||||
tierName={currentPlan?.name ?? "Business"}
|
||||
onUpgrade={() => {
|
||||
const currentPlan = planOptions.find(
|
||||
(p) => p.id === currentPlanId
|
||||
);
|
||||
if (currentPlan?.tierType) {
|
||||
handleStartSubscription(currentPlan.tierType);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
@@ -107,15 +108,19 @@ export function PrivateResourceInfoSections({
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: siteResource.udpPortRangeString ?? "*"
|
||||
});
|
||||
const showAccess = siteResource.mode !== "gateway";
|
||||
const showAlias =
|
||||
siteResource.mode !== "cidr" &&
|
||||
siteResource.mode !== "http" &&
|
||||
siteResource.mode !== "inference";
|
||||
siteResource.mode !== "inference" &&
|
||||
siteResource.mode !== "gateway";
|
||||
const showDestination =
|
||||
!(
|
||||
siteResource.mode === "ssh" &&
|
||||
siteResource.authDaemonMode === "native"
|
||||
) && siteResource.mode !== "inference";
|
||||
) &&
|
||||
siteResource.mode !== "inference" &&
|
||||
siteResource.mode !== "gateway";
|
||||
const showCertificate = !!(
|
||||
(siteResource.mode === "http" || siteResource.mode === "inference") &&
|
||||
siteResource.ssl &&
|
||||
@@ -128,7 +133,8 @@ export function PrivateResourceInfoSections({
|
||||
siteResource.mode !== "inference";
|
||||
|
||||
const numSections =
|
||||
2 +
|
||||
1 +
|
||||
(showAccess ? 1 : 0) +
|
||||
(showDestination ? 1 : 0) +
|
||||
(showAlias ? 1 : 0) +
|
||||
(showCertificate ? 1 : 0) +
|
||||
@@ -143,17 +149,19 @@ export function PrivateResourceInfoSections({
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<AccessMethodContent
|
||||
accessDisplay={access.accessDisplay}
|
||||
accessCopyValue={access.accessCopyValue}
|
||||
accessUrl={access.accessUrl}
|
||||
className={accessClassName}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{showAccess ? (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<AccessMethodContent
|
||||
accessDisplay={access.accessDisplay}
|
||||
accessCopyValue={access.accessCopyValue}
|
||||
accessUrl={access.accessUrl}
|
||||
className={accessClassName}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
) : null}
|
||||
|
||||
{showDestination ? (
|
||||
<InfoSection>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -392,7 +393,11 @@ export default function PrivateResourcesTable({
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
const display = formatDestinationDisplay(resourceRow);
|
||||
if (resourceRow.destination) {
|
||||
if (
|
||||
resourceRow.destination &&
|
||||
resourceRow.mode !== "gateway"
|
||||
) {
|
||||
// don't show the gateway resource destination which is 0.0.0.0/0 to not confuse people
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={display}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ClockIcon, ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -8,9 +7,13 @@ import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
type TrialBillingBannerProps = {
|
||||
onUpgrade: () => void;
|
||||
tierName: string;
|
||||
};
|
||||
|
||||
export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => {
|
||||
export const TrialBillingBanner = ({
|
||||
onUpgrade,
|
||||
tierName
|
||||
}: TrialBillingBannerProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -19,7 +22,9 @@ export const TrialBillingBanner = ({ onUpgrade }: TrialBillingBannerProps) => {
|
||||
version={1}
|
||||
title={t("billingTrialBannerTitle")}
|
||||
titleIcon={<ClockIcon className="w-5 h-5 text-primary" />}
|
||||
description={t("billingTrialBannerDescription")}
|
||||
description={t("billingTrialBannerDescription", {
|
||||
tier: tierName
|
||||
})}
|
||||
dismissable={false}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -421,7 +421,14 @@ export function createCreateFormSchema(t: TranslateFn) {
|
||||
.min(1, t("createInternalResourceDialogNameRequired"))
|
||||
.max(255, t("createInternalResourceDialogNameMaxLength")),
|
||||
siteIds: z.array(z.number().int().positive()).optional(),
|
||||
mode: z.enum(["host", "cidr", "http", "ssh", "inference"]),
|
||||
mode: z.enum([
|
||||
"host",
|
||||
"cidr",
|
||||
"http",
|
||||
"ssh",
|
||||
"inference",
|
||||
"gateway"
|
||||
]),
|
||||
destination: z.string().nullish(),
|
||||
alias: z.string().nullish(),
|
||||
destinationPort: z
|
||||
@@ -468,6 +475,7 @@ export function createCreateFormSchema(t: TranslateFn) {
|
||||
if (
|
||||
data.mode !== "ssh" &&
|
||||
data.mode !== "inference" &&
|
||||
data.mode !== "gateway" &&
|
||||
(!trimmedDestination || trimmedDestination.length < 1)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
@@ -652,6 +660,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({
|
||||
|
||||
Reference in New Issue
Block a user