mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-29 06:10:47 +00:00
Add UI, update API, send to newt
This commit is contained in:
@@ -2275,5 +2275,12 @@
|
||||
"agent": "Agent",
|
||||
"personalUseOnly": "Personal Use Only",
|
||||
"loginPageLicenseWatermark": "This instance is licensed for personal use only.",
|
||||
"instanceIsUnlicensed": "This instance is unlicensed."
|
||||
"instanceIsUnlicensed": "This instance is unlicensed.",
|
||||
"portRestrictions": "Port Restrictions",
|
||||
"allPorts": "All",
|
||||
"custom": "Custom",
|
||||
"allPortsAllowed": "All Ports Allowed",
|
||||
"allPortsBlocked": "All Ports Blocked",
|
||||
"tcpPortsDescription": "Specify which TCP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 80,443,8000-9000).",
|
||||
"udpPortsDescription": "Specify which UDP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 53,123,500-600)."
|
||||
}
|
||||
|
||||
@@ -213,7 +213,9 @@ export const siteResources = pgTable("siteResources", {
|
||||
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
alias: varchar("alias"),
|
||||
aliasAddress: varchar("aliasAddress")
|
||||
aliasAddress: varchar("aliasAddress"),
|
||||
tcpPortRangeString: varchar("tcpPortRangeString"),
|
||||
udpPortRangeString: varchar("udpPortRangeString")
|
||||
});
|
||||
|
||||
export const clientSiteResources = pgTable("clientSiteResources", {
|
||||
|
||||
@@ -234,7 +234,9 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
destination: text("destination").notNull(), // ip, cidr, hostname
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
alias: text("alias"),
|
||||
aliasAddress: text("aliasAddress")
|
||||
aliasAddress: text("aliasAddress"),
|
||||
tcpPortRangeString: text("tcpPortRangeString"),
|
||||
udpPortRangeString: text("udpPortRangeString")
|
||||
});
|
||||
|
||||
export const clientSiteResources = sqliteTable("clientSiteResources", {
|
||||
|
||||
136
server/lib/ip.ts
136
server/lib/ip.ts
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
SiteResource,
|
||||
siteResources,
|
||||
Transaction
|
||||
} from "@server/db";
|
||||
import { db, SiteResource, siteResources, Transaction } from "@server/db";
|
||||
import { clients, orgs, sites } from "@server/db";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
@@ -476,6 +470,7 @@ export type SubnetProxyTarget = {
|
||||
portRange?: {
|
||||
min: number;
|
||||
max: number;
|
||||
protocol: "tcp" | "udp";
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -505,6 +500,10 @@ export function generateSubnetProxyTargets(
|
||||
}
|
||||
|
||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||
const portRange = [
|
||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||
...parsePortRangeString(siteResource.udpPortRangeString, "udp")
|
||||
];
|
||||
|
||||
if (siteResource.mode == "host") {
|
||||
let destination = siteResource.destination;
|
||||
@@ -515,7 +514,8 @@ export function generateSubnetProxyTargets(
|
||||
|
||||
targets.push({
|
||||
sourcePrefix: clientPrefix,
|
||||
destPrefix: destination
|
||||
destPrefix: destination,
|
||||
portRange
|
||||
});
|
||||
}
|
||||
|
||||
@@ -524,13 +524,15 @@ export function generateSubnetProxyTargets(
|
||||
targets.push({
|
||||
sourcePrefix: clientPrefix,
|
||||
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||
rewriteTo: destination
|
||||
rewriteTo: destination,
|
||||
portRange
|
||||
});
|
||||
}
|
||||
} else if (siteResource.mode == "cidr") {
|
||||
targets.push({
|
||||
sourcePrefix: clientPrefix,
|
||||
destPrefix: siteResource.destination
|
||||
destPrefix: siteResource.destination,
|
||||
portRange
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -542,3 +544,117 @@ export function generateSubnetProxyTargets(
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
// Custom schema for validating port range strings
|
||||
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
|
||||
export const portRangeStringSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val || val.trim() === "" || val.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Split by comma and validate each part
|
||||
const parts = val.split(",").map((p) => p.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === "") {
|
||||
return false; // empty parts not allowed
|
||||
}
|
||||
|
||||
// Check if it's a range (contains dash)
|
||||
if (part.includes("-")) {
|
||||
const [start, end] = part.split("-").map((p) => p.trim());
|
||||
|
||||
// Both parts must be present
|
||||
if (!start || !end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startPort = parseInt(start, 10);
|
||||
const endPort = parseInt(end, 10);
|
||||
|
||||
// Must be valid numbers
|
||||
if (isNaN(startPort) || isNaN(endPort)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be valid port range (1-65535)
|
||||
if (
|
||||
startPort < 1 ||
|
||||
startPort > 65535 ||
|
||||
endPort < 1 ||
|
||||
endPort > 65535
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start must be <= end
|
||||
if (startPort > endPort) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Single port
|
||||
const port = parseInt(part, 10);
|
||||
|
||||
// Must be a valid number
|
||||
if (isNaN(port)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be valid port range (1-65535)
|
||||
if (port < 1 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Parses a port range string into an array of port range objects
|
||||
* @param portRangeStr - Port range string (e.g., "80,443,8000-9000", "*", or "")
|
||||
* @param protocol - Protocol to use for all ranges (default: "tcp")
|
||||
* @returns Array of port range objects with min, max, and protocol fields
|
||||
*/
|
||||
export function parsePortRangeString(
|
||||
portRangeStr: string | undefined | null,
|
||||
protocol: "tcp" | "udp" = "tcp"
|
||||
): { min: number; max: number; protocol: "tcp" | "udp" }[] {
|
||||
// Handle undefined or empty string - insert dummy value with port 0
|
||||
if (!portRangeStr || portRangeStr.trim() === "") {
|
||||
return [{ min: 0, max: 0, protocol }];
|
||||
}
|
||||
|
||||
// Handle wildcard - return empty array (all ports allowed)
|
||||
if (portRangeStr.trim() === "*") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: { min: number; max: number; protocol: "tcp" | "udp" }[] = [];
|
||||
const parts = portRangeStr.split(",").map((p) => p.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.includes("-")) {
|
||||
// Range
|
||||
const [start, end] = part.split("-").map((p) => p.trim());
|
||||
const startPort = parseInt(start, 10);
|
||||
const endPort = parseInt(end, 10);
|
||||
result.push({ min: startPort, max: endPort, protocol });
|
||||
} else {
|
||||
// Single port
|
||||
const port = parseInt(part, 10);
|
||||
result.push({ min: port, max: port, protocol });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { getUniqueSiteResourceName } from "@server/db/names";
|
||||
import { getNextAvailableAliasAddress } from "@server/lib/ip";
|
||||
import { getNextAvailableAliasAddress, portRangeStringSchema } from "@server/lib/ip";
|
||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
@@ -45,7 +45,9 @@ const createSiteResourceSchema = z
|
||||
.optional(),
|
||||
userIds: z.array(z.string()),
|
||||
roleIds: z.array(z.int()),
|
||||
clientIds: z.array(z.int())
|
||||
clientIds: z.array(z.int()),
|
||||
tcpPortRangeString: portRangeStringSchema,
|
||||
udpPortRangeString: portRangeStringSchema
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
@@ -154,7 +156,9 @@ export async function createSiteResource(
|
||||
alias,
|
||||
userIds,
|
||||
roleIds,
|
||||
clientIds
|
||||
clientIds,
|
||||
tcpPortRangeString,
|
||||
udpPortRangeString
|
||||
} = parsedBody.data;
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
@@ -239,7 +243,9 @@ export async function createSiteResource(
|
||||
destination,
|
||||
enabled,
|
||||
alias,
|
||||
aliasAddress
|
||||
aliasAddress,
|
||||
tcpPortRangeString,
|
||||
udpPortRangeString
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@ export async function listAllSiteResourcesByOrg(
|
||||
destination: siteResources.destination,
|
||||
enabled: siteResources.enabled,
|
||||
alias: siteResources.alias,
|
||||
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||
udpPortRangeString: siteResources.udpPortRangeString,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteAddress: sites.address
|
||||
|
||||
@@ -23,7 +23,8 @@ import { updatePeerData, updateTargets } from "@server/routers/client/targets";
|
||||
import {
|
||||
generateAliasConfig,
|
||||
generateRemoteSubnets,
|
||||
generateSubnetProxyTargets
|
||||
generateSubnetProxyTargets,
|
||||
portRangeStringSchema
|
||||
} from "@server/lib/ip";
|
||||
import {
|
||||
getClientSiteResourceAccess,
|
||||
@@ -55,7 +56,9 @@ const updateSiteResourceSchema = z
|
||||
.nullish(),
|
||||
userIds: z.array(z.string()),
|
||||
roleIds: z.array(z.int()),
|
||||
clientIds: z.array(z.int())
|
||||
clientIds: z.array(z.int()),
|
||||
tcpPortRangeString: portRangeStringSchema,
|
||||
udpPortRangeString: portRangeStringSchema
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
@@ -160,7 +163,9 @@ export async function updateSiteResource(
|
||||
enabled,
|
||||
userIds,
|
||||
roleIds,
|
||||
clientIds
|
||||
clientIds,
|
||||
tcpPortRangeString,
|
||||
udpPortRangeString
|
||||
} = parsedBody.data;
|
||||
|
||||
const [site] = await db
|
||||
@@ -226,7 +231,9 @@ export async function updateSiteResource(
|
||||
mode: mode,
|
||||
destination: destination,
|
||||
enabled: enabled,
|
||||
alias: alias && alias.trim() ? alias : null
|
||||
alias: alias && alias.trim() ? alias : null,
|
||||
tcpPortRangeString: tcpPortRangeString,
|
||||
udpPortRangeString: udpPortRangeString
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
|
||||
@@ -67,7 +67,9 @@ export default async function ClientResourcesPage(
|
||||
// destinationPort: siteResource.destinationPort,
|
||||
alias: siteResource.alias || null,
|
||||
siteNiceId: siteResource.siteNiceId,
|
||||
niceId: siteResource.niceId
|
||||
niceId: siteResource.niceId,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -41,6 +41,8 @@ export type InternalResourceRow = {
|
||||
// destinationPort: number | null;
|
||||
alias: string | null;
|
||||
niceId: string;
|
||||
tcpPortRangeString: string | null;
|
||||
udpPortRangeString: string | null;
|
||||
};
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
|
||||
@@ -59,6 +59,82 @@ import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
// Helper to validate port range string format
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Port range string schema for client-side validation
|
||||
const portRangeStringSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(
|
||||
(val) => isValidPortRangeString(val),
|
||||
{
|
||||
message:
|
||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to determine the port mode from a port range string
|
||||
type PortMode = "all" | "blocked" | "custom";
|
||||
const getPortModeFromString = (val: string | undefined | null): PortMode => {
|
||||
if (val === "*") return "all";
|
||||
if (!val || val.trim() === "") return "blocked";
|
||||
return "custom";
|
||||
};
|
||||
|
||||
// Helper to get the port string for API from mode and custom value
|
||||
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
|
||||
if (mode === "all") return "*";
|
||||
if (mode === "blocked") return "";
|
||||
return customValue;
|
||||
};
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
@@ -103,6 +179,8 @@ export default function CreateInternalResourceDialog({
|
||||
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
||||
// .nullish(),
|
||||
alias: z.string().nullish(),
|
||||
tcpPortRangeString: portRangeStringSchema,
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -209,6 +287,12 @@ export default function CreateInternalResourceDialog({
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// Port restriction UI state - default to "all" (*) for new resources
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>("all");
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>("all");
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>("");
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
);
|
||||
@@ -224,6 +308,8 @@ export default function CreateInternalResourceDialog({
|
||||
destination: "",
|
||||
// destinationPort: undefined,
|
||||
alias: "",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
@@ -232,6 +318,17 @@ export default function CreateInternalResourceDialog({
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
// Update form values when port mode or custom ports change
|
||||
useEffect(() => {
|
||||
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
|
||||
form.setValue("tcpPortRangeString", tcpValue);
|
||||
}, [tcpPortMode, tcpCustomPorts, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
|
||||
form.setValue("udpPortRangeString", udpValue);
|
||||
}, [udpPortMode, udpCustomPorts, form]);
|
||||
|
||||
// Helper function to check if destination contains letters (hostname vs IP)
|
||||
const isHostname = (destination: string): boolean => {
|
||||
return /[a-zA-Z]/.test(destination);
|
||||
@@ -258,10 +355,17 @@ export default function CreateInternalResourceDialog({
|
||||
destination: "",
|
||||
// destinationPort: undefined,
|
||||
alias: "",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode("all");
|
||||
setUdpPortMode("all");
|
||||
setTcpCustomPorts("");
|
||||
setUdpCustomPorts("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -304,6 +408,8 @@ export default function CreateInternalResourceDialog({
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
roleIds: data.roles
|
||||
? data.roles.map((r) => parseInt(r.id))
|
||||
: [],
|
||||
@@ -727,6 +833,142 @@ export default function CreateInternalResourceDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port Restrictions Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("portRestrictions")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* TCP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tcpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-[50px]">
|
||||
TCP
|
||||
</FormLabel>
|
||||
<InfoPopup
|
||||
info={t("tcpPortsDescription")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setTcpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* UDP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="udpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-[50px]">
|
||||
UDP
|
||||
</FormLabel>
|
||||
<InfoPopup
|
||||
info={t("udpPortsDescription")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setUdpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
|
||||
@@ -47,6 +47,82 @@ import { AxiosResponse } from "axios";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
// Helper to validate port range string format
|
||||
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)) {
|
||||
return false;
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Port range string schema for client-side validation
|
||||
const portRangeStringSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(
|
||||
(val) => isValidPortRangeString(val),
|
||||
{
|
||||
message:
|
||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to determine the port mode from a port range string
|
||||
type PortMode = "all" | "blocked" | "custom";
|
||||
const getPortModeFromString = (val: string | undefined | null): PortMode => {
|
||||
if (val === "*") return "all";
|
||||
if (!val || val.trim() === "") return "blocked";
|
||||
return "custom";
|
||||
};
|
||||
|
||||
// Helper to get the port string for API from mode and custom value
|
||||
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
|
||||
if (mode === "all") return "*";
|
||||
if (mode === "blocked") return "";
|
||||
return customValue;
|
||||
};
|
||||
|
||||
type InternalResourceData = {
|
||||
id: number;
|
||||
@@ -61,6 +137,8 @@ type InternalResourceData = {
|
||||
destination: string;
|
||||
// destinationPort?: number | null;
|
||||
alias?: string | null;
|
||||
tcpPortRangeString?: string | null;
|
||||
udpPortRangeString?: string | null;
|
||||
};
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
@@ -94,6 +172,8 @@ export default function EditInternalResourceDialog({
|
||||
destination: z.string().min(1),
|
||||
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
||||
alias: z.string().nullish(),
|
||||
tcpPortRangeString: portRangeStringSchema,
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -255,6 +335,24 @@ export default function EditInternalResourceDialog({
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// Port restriction UI state
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(
|
||||
getPortModeFromString(resource.tcpPortRangeString)
|
||||
);
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>(
|
||||
getPortModeFromString(resource.udpPortRangeString)
|
||||
);
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -265,6 +363,8 @@ export default function EditInternalResourceDialog({
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
@@ -273,6 +373,17 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
// Update form values when port mode or custom ports change
|
||||
useEffect(() => {
|
||||
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
|
||||
form.setValue("tcpPortRangeString", tcpValue);
|
||||
}, [tcpPortMode, tcpCustomPorts, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
|
||||
form.setValue("udpPortRangeString", udpValue);
|
||||
}, [udpPortMode, udpCustomPorts, form]);
|
||||
|
||||
// Helper function to check if destination contains letters (hostname vs IP)
|
||||
const isHostname = (destination: string): boolean => {
|
||||
return /[a-zA-Z]/.test(destination);
|
||||
@@ -327,6 +438,8 @@ export default function EditInternalResourceDialog({
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||
@@ -396,10 +509,25 @@ export default function EditInternalResourceDialog({
|
||||
mode: resource.mode || "host",
|
||||
destination: resource.destination || "",
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
|
||||
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
|
||||
setTcpCustomPorts(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
setUdpCustomPorts(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
previousResourceId.current = resource.id;
|
||||
}
|
||||
|
||||
@@ -438,10 +566,25 @@ export default function EditInternalResourceDialog({
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
|
||||
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
|
||||
setTcpCustomPorts(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
setUdpCustomPorts(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
// Reset previous resource ID to ensure clean state on next open
|
||||
previousResourceId.current = null;
|
||||
}
|
||||
@@ -674,6 +817,142 @@ export default function EditInternalResourceDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port Restrictions Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("portRestrictions")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* TCP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tcpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-[50px]">
|
||||
TCP
|
||||
</FormLabel>
|
||||
<InfoPopup
|
||||
info={t("tcpPortsDescription")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setTcpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* UDP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="udpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-[50px]">
|
||||
UDP
|
||||
</FormLabel>
|
||||
<InfoPopup
|
||||
info={t("udpPortsDescription")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setUdpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
|
||||
Reference in New Issue
Block a user