add site resource modes and alias

This commit is contained in:
miloschwartz
2025-11-05 15:24:07 -08:00
parent e51b6b545e
commit 85892c30b2
16 changed files with 711 additions and 382 deletions

View File

@@ -1545,6 +1545,17 @@
"editInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format",
"editInternalResourceDialogDestinationPortMin": "Destination port must be at least 1",
"editInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536",
"editInternalResourceDialogPortModeRequired": "Protocol, proxy port, and destination port are required for port mode",
"editInternalResourceDialogMode": "Mode",
"editInternalResourceDialogModePort": "Port",
"editInternalResourceDialogModeHost": "Host",
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogDestination": "Destination",
"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.",
"editInternalResourceDialogAlias": "Alias",
"editInternalResourceDialogAliasDescription": "An optional alias for this resource.",
"createInternalResourceDialogNoSitesAvailable": "No Sites Available",
"createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.",
"createInternalResourceDialogClose": "Close",
@@ -1578,6 +1589,16 @@
"createInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format",
"createInternalResourceDialogDestinationPortMin": "Destination port must be at least 1",
"createInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536",
"createInternalResourceDialogPortModeRequired": "Protocol, proxy port, and destination port are required for port mode",
"createInternalResourceDialogMode": "Mode",
"createInternalResourceDialogModePort": "Port",
"createInternalResourceDialogModeHost": "Host",
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogDestination": "Destination",
"createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
"createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "An optional alias for this resource.",
"siteConfiguration": "Configuration",
"siteAcceptClientConnections": "Accept Client Connections",
"siteAcceptClientConnectionsDescription": "Allow other devices to connect through this Newt instance as a gateway using clients.",

View File

@@ -204,11 +204,13 @@ export const siteResources = pgTable("siteResources", {
.references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
protocol: varchar("protocol").notNull(),
proxyPort: integer("proxyPort").notNull(),
destinationPort: integer("destinationPort").notNull(),
destinationIp: varchar("destinationIp").notNull(),
enabled: boolean("enabled").notNull().default(true)
mode: varchar("mode").notNull(), // "host" | "cidr" | "port"
protocol: varchar("protocol"), // only for port mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias")
});
export const roleSiteResources = pgTable("roleSiteResources", {

View File

@@ -225,11 +225,13 @@ export const siteResources = sqliteTable("siteResources", {
.references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort").notNull(),
destinationPort: integer("destinationPort").notNull(),
destinationIp: text("destinationIp").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
mode: text("mode").notNull(), // "host" | "cidr" | "port"
protocol: text("protocol"), // only for port mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: text("destination").notNull(), // ip, cidr, hostname
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
alias: text("alias")
});
export const roleSiteResources = sqliteTable("roleSiteResources", {

View File

@@ -122,14 +122,14 @@ export async function applyBlueprint({
)
.limit(1);
if (site) {
if (site && result.resource.mode === "port" && result.resource.protocol && result.resource.proxyPort && result.resource.destinationPort) {
logger.debug(
`Updating client resource ${result.resource.siteResourceId} on site ${site.sites.siteId}`
);
await addClientTargets(
site.newt.newtId,
result.resource.destinationIp,
result.resource.destination,
result.resource.destinationPort,
result.resource.protocol,
result.resource.proxyPort

View File

@@ -75,8 +75,9 @@ export async function updateClientResources(
.set({
name: resourceData.name || resourceNiceId,
siteId: site.siteId,
mode: "port",
proxyPort: resourceData["proxy-port"]!,
destinationIp: resourceData.hostname,
destination: resourceData.hostname,
destinationPort: resourceData["internal-port"],
protocol: resourceData.protocol
})
@@ -98,8 +99,9 @@ export async function updateClientResources(
siteId: site.siteId,
niceId: resourceNiceId,
name: resourceData.name || resourceNiceId,
mode: "port",
proxyPort: resourceData["proxy-port"]!,
destinationIp: resourceData.hostname,
destination: resourceData.hostname,
destinationPort: resourceData["internal-port"],
protocol: resourceData.protocol
})

View File

@@ -53,8 +53,6 @@ export async function verifyOrgAccess(
session: req.session
});
logger.debug("Org check policy result", { policyCheck });
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(

View File

@@ -216,13 +216,18 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
const { tcpTargets, udpTargets } = allSiteResources.reduce(
(acc, resource) => {
// Only process port mode resources
if (resource.mode !== "port") {
return acc;
}
// Filter out invalid targets
if (!resource.proxyPort || !resource.destinationIp || !resource.destinationPort) {
if (!resource.proxyPort || !resource.destination || !resource.destinationPort || !resource.protocol) {
return acc;
}
// Format target into string
const formattedTarget = `${resource.proxyPort}:${resource.destinationIp}:${resource.destinationPort}`;
const formattedTarget = `${resource.proxyPort}:${resource.destination}:${resource.destinationPort}`;
// Add to the appropriate protocol array
if (resource.protocol === "tcp") {

View File

@@ -22,13 +22,30 @@ const createSiteResourceParamsSchema = z
const createSiteResourceSchema = z
.object({
name: z.string().min(1).max(255),
protocol: z.enum(["tcp", "udp"]),
proxyPort: z.number().int().positive(),
destinationPort: z.number().int().positive(),
destinationIp: z.string(),
enabled: z.boolean().default(true)
})
.strict();
mode: z.enum(["host", "cidr", "port"]),
protocol: z.enum(["tcp", "udp"]).optional(),
proxyPort: z.number().int().positive().optional(),
destinationPort: z.number().int().positive().optional(),
destination: z.string().min(1),
enabled: z.boolean().default(true),
alias: z.string().optional()
}).strict()
.refine(
(data) => {
if (data.mode === "port") {
return (
data.protocol !== undefined &&
data.proxyPort !== undefined &&
data.destinationPort !== undefined
);
}
return true;
},
{
message:
"Protocol, proxy port, and destination port are required for port mode"
}
);
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
export type CreateSiteResourceResponse = SiteResource;
@@ -82,11 +99,13 @@ export async function createSiteResource(
const { siteId, orgId } = parsedParams.data;
const {
name,
mode,
protocol,
proxyPort,
destinationPort,
destinationIp,
enabled
destination,
enabled,
alias
} = parsedBody.data;
// Verify the site exists and belongs to the org
@@ -100,26 +119,28 @@ export async function createSiteResource(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
// check if resource with same protocol and proxy port already exists
const [existingResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId),
eq(siteResources.protocol, protocol),
eq(siteResources.proxyPort, proxyPort)
// check if resource with same protocol and proxy port already exists (only for port mode)
if (mode === "port" && protocol && proxyPort) {
const [existingResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId),
eq(siteResources.protocol, protocol),
eq(siteResources.proxyPort, proxyPort)
)
)
)
.limit(1);
if (existingResource && existingResource.siteResourceId) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A resource with the same protocol and proxy port already exists"
)
);
.limit(1);
if (existingResource && existingResource.siteResourceId) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A resource with the same protocol and proxy port already exists"
)
);
}
}
const niceId = await getUniqueSiteResourceName(orgId);
@@ -132,11 +153,13 @@ export async function createSiteResource(
niceId,
orgId,
name,
protocol,
proxyPort,
destinationPort,
destinationIp,
enabled
mode,
protocol: mode === "port" ? protocol : null,
proxyPort: mode === "port" ? proxyPort : null,
destinationPort: mode === "port" ? destinationPort : null,
destination,
enabled,
alias: alias || null
})
.returning();
@@ -157,24 +180,29 @@ export async function createSiteResource(
siteResourceId: newSiteResource.siteResourceId
});
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
// Only add targets for port mode
if (mode === "port" && protocol && proxyPort && destinationPort) {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (!newt) {
return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found"));
if (!newt) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
);
}
await addTargets(
newt.newtId,
destination,
destinationPort,
protocol,
proxyPort
);
}
await addTargets(
newt.newtId,
destinationIp,
destinationPort,
protocol,
proxyPort
);
logger.info(
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
);

View File

@@ -91,24 +91,27 @@ export async function deleteSiteResource(
eq(siteResources.orgId, orgId)
));
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
// Only remove targets for port mode
if (existingSiteResource.mode === "port" && existingSiteResource.protocol && existingSiteResource.proxyPort && existingSiteResource.destinationPort) {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (!newt) {
return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found"));
if (!newt) {
return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found"));
}
await removeTargets(
newt.newtId,
existingSiteResource.destination,
existingSiteResource.destinationPort,
existingSiteResource.protocol,
existingSiteResource.proxyPort
);
}
await removeTargets(
newt.newtId,
existingSiteResource.destinationIp,
existingSiteResource.destinationPort,
existingSiteResource.protocol,
existingSiteResource.proxyPort
);
logger.info(`Deleted site resource ${siteResourceId} for site ${siteId}`);
return response(res, {

View File

@@ -32,7 +32,7 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
});
export type ListAllSiteResourcesByOrgResponse = {
siteResources: (SiteResource & { siteName: string, siteNiceId: string })[];
siteResources: (SiteResource & { siteName: string, siteNiceId: string, siteAddress: string | null })[];
};
registry.registerPath({
@@ -82,14 +82,18 @@ export async function listAllSiteResourcesByOrg(
siteResourceId: siteResources.siteResourceId,
siteId: siteResources.siteId,
orgId: siteResources.orgId,
niceId: siteResources.niceId,
name: siteResources.name,
mode: siteResources.mode,
protocol: siteResources.protocol,
proxyPort: siteResources.proxyPort,
destinationPort: siteResources.destinationPort,
destinationIp: siteResources.destinationIp,
destination: siteResources.destination,
enabled: siteResources.enabled,
alias: siteResources.alias,
siteName: sites.name,
siteNiceId: sites.niceId
siteNiceId: sites.niceId,
siteAddress: sites.address
})
.from(siteResources)
.innerJoin(sites, eq(siteResources.siteId, sites.siteId))

View File

@@ -25,11 +25,13 @@ const updateSiteResourceParamsSchema = z
const updateSiteResourceSchema = z
.object({
name: z.string().min(1).max(255).optional(),
protocol: z.enum(["tcp", "udp"]).optional(),
proxyPort: z.number().int().positive().optional(),
destinationPort: z.number().int().positive().optional(),
destinationIp: z.string().optional(),
enabled: z.boolean().optional()
mode: z.enum(["host", "cidr", "port"]).optional(),
protocol: z.enum(["tcp", "udp"]).nullish(),
proxyPort: z.number().int().positive().nullish(),
destinationPort: z.number().int().positive().nullish(),
destination: z.string().min(1).optional(),
enabled: z.boolean().optional(),
alias: z.string().nullish()
})
.strict();
@@ -114,39 +116,77 @@ export async function updateSiteResource(
);
}
const protocol = updateData.protocol || existingSiteResource.protocol;
const proxyPort =
updateData.proxyPort || existingSiteResource.proxyPort;
// Determine the final mode and validate port mode requirements
const finalMode = updateData.mode || existingSiteResource.mode;
const finalProtocol = updateData.protocol !== undefined ? updateData.protocol : existingSiteResource.protocol;
const finalProxyPort = updateData.proxyPort !== undefined ? updateData.proxyPort : existingSiteResource.proxyPort;
const finalDestinationPort = updateData.destinationPort !== undefined ? updateData.destinationPort : existingSiteResource.destinationPort;
// check if resource with same protocol and proxy port already exists
const [existingResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId),
eq(siteResources.protocol, protocol),
eq(siteResources.proxyPort, proxyPort)
if (finalMode === "port") {
if (!finalProtocol || !finalProxyPort || !finalDestinationPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Protocol, proxy port, and destination port are required for port mode"
)
);
}
// check if resource with same protocol and proxy port already exists
const [existingResource] = await db
.select()
.from(siteResources)
.where(
and(
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId),
eq(siteResources.protocol, finalProtocol),
eq(siteResources.proxyPort, finalProxyPort)
)
)
)
.limit(1);
if (
existingResource &&
existingResource.siteResourceId !== siteResourceId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A resource with the same protocol and proxy port already exists"
)
);
.limit(1);
if (
existingResource &&
existingResource.siteResourceId !== siteResourceId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
"A resource with the same protocol and proxy port already exists"
)
);
}
}
// Prepare update data
const updateValues: any = {};
if (updateData.name !== undefined) updateValues.name = updateData.name;
if (updateData.mode !== undefined) updateValues.mode = updateData.mode;
if (updateData.destination !== undefined) updateValues.destination = updateData.destination;
if (updateData.enabled !== undefined) updateValues.enabled = updateData.enabled;
// Handle nullish fields (can be undefined, null, or a value)
if (updateData.alias !== undefined) {
updateValues.alias = updateData.alias && updateData.alias.trim() ? updateData.alias : null;
}
// Handle port mode fields - include in update if explicitly provided (null or value) or if mode changed
const isModeChangingFromPort = existingSiteResource.mode === "port" && updateData.mode && updateData.mode !== "port";
if (updateData.protocol !== undefined || isModeChangingFromPort) {
updateValues.protocol = finalMode === "port" ? finalProtocol : null;
}
if (updateData.proxyPort !== undefined || isModeChangingFromPort) {
updateValues.proxyPort = finalMode === "port" ? finalProxyPort : null;
}
if (updateData.destinationPort !== undefined || isModeChangingFromPort) {
updateValues.destinationPort = finalMode === "port" ? finalDestinationPort : null;
}
// Update the site resource
const [updatedSiteResource] = await db
.update(siteResources)
.set(updateData)
.set(updateValues)
.where(
and(
eq(siteResources.siteResourceId, siteResourceId),
@@ -156,24 +196,27 @@ export async function updateSiteResource(
)
.returning();
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
// Only add targets for port mode
if (updatedSiteResource.mode === "port" && updatedSiteResource.protocol && updatedSiteResource.proxyPort && updatedSiteResource.destinationPort) {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (!newt) {
return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found"));
if (!newt) {
return next(createHttpError(HttpCode.NOT_FOUND, "Newt not found"));
}
await addTargets(
newt.newtId,
updatedSiteResource.destination,
updatedSiteResource.destinationPort,
updatedSiteResource.protocol,
updatedSiteResource.proxyPort
);
}
await addTargets(
newt.newtId,
updatedSiteResource.destinationIp,
updatedSiteResource.destinationPort,
updatedSiteResource.protocol,
updatedSiteResource.proxyPort
);
logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
);

View File

@@ -103,11 +103,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
name: siteResource.name,
orgId: params.orgId,
siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null,
mode: siteResource.mode || "port" as any,
protocol: siteResource.protocol,
proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId,
destinationIp: siteResource.destinationIp,
destination: siteResource.destination,
destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null,
siteNiceId: siteResource.siteNiceId
};
}

View File

@@ -86,20 +86,24 @@ export default function CreateInternalResourceDialog({
.min(1, t("createInternalResourceDialogNameRequired"))
.max(255, t("createInternalResourceDialogNameMaxLength")),
siteId: z.number().int().positive(t("createInternalResourceDialogPleaseSelectSite")),
protocol: z.enum(["tcp", "udp"]),
mode: z.enum(["host", "cidr", "port"]),
protocol: z.enum(["tcp", "udp"]).nullish(),
proxyPort: z
.number()
.int()
.positive()
.min(1, t("createInternalResourceDialogProxyPortMin"))
.max(65535, t("createInternalResourceDialogProxyPortMax")),
destinationIp: z.string(),
.max(65535, t("createInternalResourceDialogProxyPortMax"))
.nullish(),
destination: z.string().min(1),
destinationPort: z
.number()
.int()
.positive()
.min(1, t("createInternalResourceDialogDestinationPortMin"))
.max(65535, t("createInternalResourceDialogDestinationPortMax")),
.max(65535, t("createInternalResourceDialogDestinationPortMax"))
.nullish(),
alias: z.string().nullish(),
roles: z.array(
z.object({
id: z.string(),
@@ -112,8 +116,44 @@ export default function CreateInternalResourceDialog({
text: z.string()
})
).optional()
});
})
.refine(
(data) => {
if (data.mode === "port") {
return data.protocol !== undefined && data.protocol !== null;
}
return true;
},
{
message: t("createInternalResourceDialogProtocol") + " is required for port mode",
path: ["protocol"]
}
)
.refine(
(data) => {
if (data.mode === "port") {
return data.proxyPort !== undefined && data.proxyPort !== null;
}
return true;
},
{
message: t("createInternalResourceDialogSitePort") + " is required for port mode",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.mode === "port") {
return data.destinationPort !== undefined && data.destinationPort !== null;
}
return true;
},
{
message: t("targetPort") + " is required for port mode",
path: ["destinationPort"]
}
);
type FormData = z.infer<typeof formSchema>;
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]);
@@ -130,24 +170,30 @@ export default function CreateInternalResourceDialog({
defaultValues: {
name: "",
siteId: availableSites[0]?.siteId || 0,
mode: "host",
protocol: "tcp",
proxyPort: undefined,
destinationIp: "",
destination: "",
destinationPort: undefined,
alias: "",
roles: [],
users: []
}
});
const mode = form.watch("mode");
useEffect(() => {
if (open && availableSites.length > 0) {
form.reset({
name: "",
siteId: availableSites[0].siteId,
mode: "host",
protocol: "tcp",
proxyPort: undefined,
destinationIp: "",
destination: "",
destinationPort: undefined,
alias: "",
roles: [],
users: []
});
@@ -194,11 +240,13 @@ export default function CreateInternalResourceDialog({
`/org/${orgId}/site/${data.siteId}/resource`,
{
name: data.name,
protocol: data.protocol,
proxyPort: data.proxyPort,
destinationIp: data.destinationIp,
destinationPort: data.destinationPort,
enabled: true
mode: data.mode,
protocol: data.mode === "port" ? data.protocol : undefined,
proxyPort: data.mode === "port" ? data.proxyPort : undefined,
destinationPort: data.mode === "port" ? data.destinationPort : undefined,
destination: data.destination,
enabled: true,
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined
}
);
@@ -294,126 +342,151 @@ export default function CreateInternalResourceDialog({
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t("createInternalResourceDialogSite")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground"
)}
>
{field.value
? availableSites.find(
(site) => site.siteId === field.value
)?.name
: t("createInternalResourceDialogSelectSite")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={t("createInternalResourceDialogSearchSites")} />
<CommandList>
<CommandEmpty>{t("createInternalResourceDialogNoSitesFound")}</CommandEmpty>
<CommandGroup>
{availableSites.map((site) => (
<CommandItem
key={site.siteId}
value={site.name}
onSelect={() => {
field.onChange(site.siteId);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === site.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogProtocol")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
{t("createInternalResourceDialogTcp")}
</SelectItem>
<SelectItem value="udp">
{t("createInternalResourceDialogUdp")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="proxyPort"
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogSitePortDescription")}
</FormDescription>
<FormItem className="flex flex-col">
<FormLabel>{t("createInternalResourceDialogSite")}</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground"
)}
>
{field.value
? availableSites.find(
(site) => site.siteId === field.value
)?.name
: t("createInternalResourceDialogSelectSite")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder={t("createInternalResourceDialogSearchSites")} />
<CommandList>
<CommandEmpty>{t("createInternalResourceDialogNoSitesFound")}</CommandEmpty>
<CommandGroup>
{availableSites.map((site) => (
<CommandItem
key={site.siteId}
value={site.name}
onSelect={() => {
field.onChange(site.siteId);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value === site.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogMode")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="port">{t("createInternalResourceDialogModePort")}</SelectItem>
<SelectItem value="host">{t("createInternalResourceDialogModeHost")}</SelectItem>
<SelectItem value="cidr">{t("createInternalResourceDialogModeCidr")}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{mode === "port" && (
<>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogProtocol")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
{t("createInternalResourceDialogTcp")}
</SelectItem>
<SelectItem value="udp">
{t("createInternalResourceDialogUdp")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)}
</div>
</div>
@@ -423,28 +496,28 @@ export default function CreateInternalResourceDialog({
{t("createInternalResourceDialogTargetConfiguration")}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="destinationIp"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("targetAddr")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormDescription>
{t("createInternalResourceDialogDestinationIPDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogDestination")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" && t("createInternalResourceDialogDestinationHostDescription")}
{mode === "cidr" && t("createInternalResourceDialogDestinationCidrDescription")}
{mode === "port" && t("createInternalResourceDialogDestinationIPDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{mode === "port" && (
<FormField
control={form.control}
name="destinationPort"
@@ -471,12 +544,33 @@ export default function CreateInternalResourceDialog({
</FormItem>
)}
/>
</div>
)}
</div>
</div>
{/* Alias */}
{mode !== "cidr" && (
<div>
<FormField
control={form.control}
name="alias"
render={({ field }) => (
<FormItem>
<FormLabel>{t("createInternalResourceDialogAlias")}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
</FormControl>
<FormDescription>
{t("createInternalResourceDialogAliasDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* Access Control Section */}
<Separator />
<div>
<h3 className="text-lg font-semibold mb-4">
{t("resourceUsersRoles")}

View File

@@ -50,11 +50,13 @@ type InternalResourceData = {
name: string;
orgId: string;
siteName: string;
protocol: string;
mode: "host" | "cidr" | "port";
protocol: string | null;
proxyPort: number | null;
siteId: number;
destinationIp?: string;
destinationPort?: number;
destination: string;
destinationPort?: number | null;
alias?: string | null;
};
type EditInternalResourceDialogProps = {
@@ -78,10 +80,12 @@ export default function EditInternalResourceDialog({
const formSchema = z.object({
name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")),
protocol: z.enum(["tcp", "udp"]),
proxyPort: z.number().int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")),
destinationIp: z.string(),
destinationPort: z.number().int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")),
mode: z.enum(["host", "cidr", "port"]),
protocol: z.enum(["tcp", "udp"]).nullish(),
proxyPort: z.number().int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
destination: z.string().min(1),
destinationPort: z.number().int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
alias: z.string().nullish(),
roles: z.array(
z.object({
id: z.string(),
@@ -94,7 +98,43 @@ export default function EditInternalResourceDialog({
text: z.string()
})
).optional()
});
})
.refine(
(data) => {
if (data.mode === "port") {
return data.protocol !== undefined && data.protocol !== null;
}
return true;
},
{
message: t("editInternalResourceDialogProtocol") + " is required for port mode",
path: ["protocol"]
}
)
.refine(
(data) => {
if (data.mode === "port") {
return data.proxyPort !== undefined && data.proxyPort !== null;
}
return true;
},
{
message: t("editInternalResourceDialogSitePort") + " is required for port mode",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.mode === "port") {
return data.destinationPort !== undefined && data.destinationPort !== null;
}
return true;
},
{
message: t("targetPort") + " is required for port mode",
path: ["destinationPort"]
}
);
type FormData = z.infer<typeof formSchema>;
@@ -108,15 +148,19 @@ export default function EditInternalResourceDialog({
resolver: zodResolver(formSchema),
defaultValues: {
name: resource.name,
protocol: resource.protocol as "tcp" | "udp",
proxyPort: resource.proxyPort || undefined,
destinationIp: resource.destinationIp || "",
destinationPort: resource.destinationPort || undefined,
mode: resource.mode || "host",
protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
proxyPort: resource.proxyPort ?? undefined,
destination: resource.destination || "",
destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
roles: [],
users: []
}
});
const mode = form.watch("mode");
const fetchRolesAndUsers = async () => {
setLoadingRolesUsers(true);
try {
@@ -180,10 +224,12 @@ export default function EditInternalResourceDialog({
if (open) {
form.reset({
name: resource.name,
protocol: resource.protocol as "tcp" | "udp",
proxyPort: resource.proxyPort || undefined,
destinationIp: resource.destinationIp || "",
destinationPort: resource.destinationPort || undefined,
mode: resource.mode || "host",
protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
proxyPort: resource.proxyPort ?? undefined,
destination: resource.destination || "",
destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
roles: [],
users: []
});
@@ -198,10 +244,12 @@ export default function EditInternalResourceDialog({
// Update the site resource
await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, {
name: data.name,
protocol: data.protocol,
proxyPort: data.proxyPort,
destinationIp: data.destinationIp,
destinationPort: data.destinationPort
mode: data.mode,
protocol: data.mode === "port" ? data.protocol : null,
proxyPort: data.mode === "port" ? data.proxyPort : null,
destinationPort: data.mode === "port" ? data.destinationPort : null,
destination: data.destination,
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : null
});
// Update roles and users
@@ -264,50 +312,78 @@ export default function EditInternalResourceDialog({
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogMode")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<SelectContent>
<SelectItem value="port">{t("editInternalResourceDialogModePort")}</SelectItem>
<SelectItem value="host">{t("editInternalResourceDialogModeHost")}</SelectItem>
<SelectItem value="cidr">{t("editInternalResourceDialogModeCidr")}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{mode === "port" && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
@@ -315,21 +391,26 @@ export default function EditInternalResourceDialog({
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogTargetConfiguration")}</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="destinationIp"
render={({ field }) => (
<FormItem>
<FormLabel>{t("targetAddr")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogDestination")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" && t("editInternalResourceDialogDestinationHostDescription")}
{mode === "cidr" && t("editInternalResourceDialogDestinationCidrDescription")}
{mode === "port" && t("editInternalResourceDialogDestinationIPDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{mode === "port" && (
<FormField
control={form.control}
name="destinationPort"
@@ -339,20 +420,41 @@ export default function EditInternalResourceDialog({
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
</div>
{/* Alias */}
{mode !== "cidr" && (
<div>
<FormField
control={form.control}
name="alias"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogAlias")}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
</FormControl>
<FormDescription>
{t("editInternalResourceDialogAliasDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* Access Control Section */}
<Separator />
<div>
<h3 className="text-lg font-semibold mb-4">
{t("resourceUsersRoles")}

View File

@@ -90,12 +90,15 @@ export type InternalResourceRow = {
name: string;
orgId: string;
siteName: string;
protocol: string;
siteAddress: string | null;
mode: "host" | "cidr" | "port";
protocol: string | null;
proxyPort: number | null;
siteId: number;
siteNiceId: string;
destinationIp: string;
destinationPort: number;
destination: string;
destinationPort: number | null;
alias: string | null;
};
type Site = ListSitesResponse["sites"][0];
@@ -571,24 +574,16 @@ export default function ResourcesTable({
}
},
{
accessorKey: "protocol",
header: () => (<span className="p-3">{t("protocol")}</span>),
accessorKey: "mode",
header: () => (<span className="p-3">{t("editInternalResourceDialogMode")}</span>),
cell: ({ row }) => {
const resourceRow = row.original;
return <span>{resourceRow.protocol.toUpperCase()}</span>;
}
},
{
accessorKey: "proxyPort",
header: () => (<span className="p-3">{t("proxyPort")}</span>),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<CopyToClipboard
text={resourceRow.proxyPort?.toString() || ""}
isLink={false}
/>
);
const modeLabels: Record<"host" | "cidr" | "port", string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
@@ -596,8 +591,35 @@ export default function ResourcesTable({
header: () => (<span className="p-3">{t("resourcesTableDestination")}</span>),
cell: ({ row }) => {
const resourceRow = row.original;
const destination = `${resourceRow.destinationIp}:${resourceRow.destinationPort}`;
return <CopyToClipboard text={destination} isLink={false} />;
let displayText: string;
let copyText: string;
if (resourceRow.mode === "port" && resourceRow.protocol && resourceRow.proxyPort && resourceRow.destinationPort) {
const protocol = resourceRow.protocol.toUpperCase();
// For port mode: site part uses alias or site address, destination part uses destination IP
// If site address has CIDR notation, extract just the IP address
let siteAddress = resourceRow.siteAddress;
if (siteAddress && siteAddress.includes("/")) {
siteAddress = siteAddress.split("/")[0];
}
const siteDisplay = resourceRow.alias || siteAddress;
displayText = `${protocol} ${siteDisplay}:${resourceRow.proxyPort} -> ${resourceRow.destination}:${resourceRow.destinationPort}`;
copyText = `${siteDisplay}:${resourceRow.proxyPort}`;
} else if (resourceRow.mode === "host") {
// For host mode: use alias if available, otherwise use destination
const destinationDisplay = resourceRow.alias || resourceRow.destination;
displayText = destinationDisplay;
copyText = destinationDisplay;
} else if (resourceRow.mode === "cidr") {
displayText = resourceRow.destination;
copyText = resourceRow.destination;
} else {
const destinationDisplay = resourceRow.alias || resourceRow.destination;
displayText = destinationDisplay;
copyText = destinationDisplay;
}
return <CopyToClipboard text={copyText} isLink={false} displayText={displayText} />;
}
},

View File

@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-sm duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:scale-95 data-[state=open]:scale-100 sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:scale-95 data-[state=open]:scale-100 sm:rounded-lg",
className
)}
{...props}