diff --git a/messages/en-US.json b/messages/en-US.json index 895c8b3f..3f20f7e6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1309,7 +1309,7 @@ "documentation": "Documentation", "saveAllSettings": "Save All Settings", "settingsUpdated": "Settings updated", - "settingsUpdatedDescription": "All settings have been updated successfully", + "settingsUpdatedDescription": "Settings updated successfully", "settingsErrorUpdate": "Failed to update settings", "settingsErrorUpdateDescription": "An error occurred while updating settings", "sidebarCollapse": "Collapse", diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 450c43fa..f9c8c8c2 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -67,6 +67,7 @@ import { TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; +import type { ResourceContextType } from "@app/contexts/resourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { toast } from "@app/hooks/useToast"; @@ -79,11 +80,12 @@ import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { tlsNameSchema } from "@server/lib/schemas"; -import { isTargetValid } from "@server/lib/validators"; +import { type GetResourceResponse } from "@server/routers/resource"; +import type { ListSitesResponse } from "@server/routers/site"; import { CreateTargetResponse } from "@server/routers/target"; import { ListTargetsResponse } from "@server/routers/target/listTargets"; import { ArrayElement } from "@server/types/ArrayElement"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ColumnDef, flexRender, @@ -105,80 +107,10 @@ import { } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { use, useEffect, useState } from "react"; +import { use, useActionState, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -const addTargetSchema = z - .object({ - ip: z.string().refine(isTargetValid), - method: z.string().nullable(), - port: z.coerce.number().int().positive(), - siteId: z.int().positive({ - error: "You must select a site for a target." - }), - path: z.string().optional().nullable(), - pathMatchType: z - .enum(["exact", "prefix", "regex"]) - .optional() - .nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z - .enum(["exact", "prefix", "regex", "stripPrefix"]) - .optional() - .nullable(), - priority: z.int().min(1).max(1000).optional() - }) - .refine( - (data) => { - // If path is provided, pathMatchType must be provided - if (data.path && !data.pathMatchType) { - return false; - } - // If pathMatchType is provided, path must be provided - if (data.pathMatchType && !data.path) { - return false; - } - // Validate path based on pathMatchType - if (data.path && data.pathMatchType) { - switch (data.pathMatchType) { - case "exact": - case "prefix": - // Path should start with / - return data.path.startsWith("/"); - case "regex": - // Validate regex - try { - new RegExp(data.path); - return true; - } catch { - return false; - } - } - } - return true; - }, - { - error: "Invalid path configuration" - } - ) - .refine( - (data) => { - // If rewritePath is provided, rewritePathType must be provided - if (data.rewritePath && !data.rewritePathType) { - return false; - } - // If rewritePathType is provided, rewritePath must be provided - if (data.rewritePathType && !data.rewritePath) { - return false; - } - return true; - }, - { - error: "Invalid rewrite path configuration" - } - ); - const targetsSettingsSchema = z.object({ stickySession: z.boolean() }); @@ -196,14 +128,9 @@ export default function ReverseProxyTargetsPage(props: { params: Promise<{ resourceId: number; orgId: string }>; }) { const params = use(props.params); - const t = useTranslations(); - const { env } = useEnvContext(); - const { resource, updateResource } = useResourceContext(); - const api = createApiClient(useEnvContext()); - - const { data: remoteTargets, isLoading: isLoadingTargets } = useQuery( + const { data: remoteTargets = [], isLoading: isLoadingTargets } = useQuery( resourceQueries.resourceTargets({ resourceId: resource.resourceId }) @@ -214,11 +141,55 @@ export default function ReverseProxyTargetsPage(props: { }) ); - const [targets, setTargets] = useState([]); + if (isLoadingSites || isLoadingTargets) { + return null; + } + + return ( + + + + {resource.http && ( + + )} + + {!resource.http && resource.protocol == "tcp" && ( + + )} + + ); +} + +function ProxyResourceTargetsForm({ + sites, + initialTargets, + resource +}: { + initialTargets: LocalTarget[]; + sites: ListSitesResponse["sites"]; + resource: GetResourceResponse; +}) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + + const [targets, setTargets] = useState(initialTargets); const [targetsToRemove, setTargetsToRemove] = useState([]); const [dockerStates, setDockerStates] = useState>( new Map() ); + const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); + const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = + useState(null); const initializeDockerForSite = async (siteId: number) => { if (dockerStates.has(siteId)) { @@ -255,10 +226,6 @@ export default function ReverseProxyTargetsPage(props: { ); }; - const [httpsTlsLoading, setHttpsTlsLoading] = useState(false); - const [targetsLoading, setTargetsLoading] = useState(false); - const [proxySettingsLoading, setProxySettingsLoading] = useState(false); - const [isAdvancedMode, setIsAdvancedMode] = useState(() => { if (typeof window !== "undefined") { const saved = localStorage.getItem("proxy-advanced-mode"); @@ -266,495 +233,8 @@ export default function ReverseProxyTargetsPage(props: { } return false; }); - const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); - const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = - useState(null); - const router = useRouter(); - - const proxySettingsSchema = z.object({ - setHostHeader: z - .string() - .optional() - .refine( - (data) => { - if (data) { - return tlsNameSchema.safeParse(data).success; - } - return true; - }, - { - message: t("proxyErrorInvalidHeader") - } - ), - headers: z - .array(z.object({ name: z.string(), value: z.string() })) - .nullable(), - proxyProtocol: z.boolean().optional(), - proxyProtocolVersion: z.int().min(1).max(2).optional() - }); - - const tlsSettingsSchema = z.object({ - ssl: z.boolean(), - tlsServerName: z - .string() - .optional() - .refine( - (data) => { - if (data) { - return tlsNameSchema.safeParse(data).success; - } - return true; - }, - { - message: t("proxyErrorTls") - } - ) - }); - - const tlsSettingsForm = useForm({ - resolver: zodResolver(tlsSettingsSchema), - defaultValues: { - ssl: resource.ssl, - tlsServerName: resource.tlsServerName || "" - } - }); - - const proxySettingsForm = useForm({ - resolver: zodResolver(proxySettingsSchema), - defaultValues: { - setHostHeader: resource.setHostHeader || "", - headers: resource.headers, - proxyProtocol: resource.proxyProtocol || false, - proxyProtocolVersion: resource.proxyProtocolVersion || 1 - } - }); - - const targetsSettingsForm = useForm({ - resolver: zodResolver(targetsSettingsSchema), - defaultValues: { - stickySession: resource.stickySession - } - }); - - useEffect(() => { - if (!isLoadingSites && sites) { - const newtSites = sites.filter((site) => site.type === "newt"); - for (const site of newtSites) { - initializeDockerForSite(site.siteId); - } - } - }, [isLoadingSites, sites]); - - useEffect(() => { - if (!isLoadingTargets && remoteTargets) { - setTargets(remoteTargets); - } - }, [isLoadingTargets, remoteTargets]); - - // Save advanced mode preference to localStorage - useEffect(() => { - if (typeof window !== "undefined") { - localStorage.setItem( - "proxy-advanced-mode", - isAdvancedMode.toString() - ); - } - }, [isAdvancedMode]); - - function addNewTarget() { - const isHttp = resource.http; - - const newTarget: LocalTarget = { - targetId: -Date.now(), // Use negative timestamp as temporary ID - ip: "", - method: isHttp ? "http" : null, - port: 0, - siteId: sites.length > 0 ? sites[0].siteId : 0, - path: isHttp ? null : null, - pathMatchType: isHttp ? null : null, - rewritePath: isHttp ? null : null, - rewritePathType: isHttp ? null : null, - priority: isHttp ? 100 : 100, - enabled: true, - resourceId: resource.resourceId, - hcEnabled: false, - hcPath: null, - hcMethod: null, - hcInterval: null, - hcTimeout: null, - hcHeaders: null, - hcScheme: null, - hcHostname: null, - hcPort: null, - hcFollowRedirects: null, - hcHealth: "unknown", - hcStatus: null, - hcMode: null, - hcUnhealthyInterval: null, - hcTlsServerName: null, - siteType: sites.length > 0 ? sites[0].type : null, - new: true, - updated: false - }; - - setTargets((prev) => [...prev, newTarget]); - } - - async function saveNewTarget(target: LocalTarget) { - // Validate the target - if (!isTargetValid(target.ip)) { - toast({ - variant: "destructive", - title: t("targetErrorInvalidIp"), - description: t("targetErrorInvalidIpDescription") - }); - return; - } - - if (!target.port || target.port <= 0) { - toast({ - variant: "destructive", - title: t("targetErrorInvalidPort"), - description: t("targetErrorInvalidPortDescription") - }); - return; - } - - if (!target.siteId) { - toast({ - variant: "destructive", - title: t("targetErrorNoSite"), - description: t("targetErrorNoSiteDescription") - }); - return; - } - - try { - setTargetsLoading(true); - - const data: any = { - resourceId: resource.resourceId, - siteId: target.siteId, - ip: target.ip, - method: target.method, - port: target.port, - enabled: target.enabled, - hcEnabled: target.hcEnabled, - hcPath: target.hcPath || null, - hcScheme: target.hcScheme || null, - hcHostname: target.hcHostname || null, - hcPort: target.hcPort || null, - hcInterval: target.hcInterval || null, - hcTimeout: target.hcTimeout || null, - hcHeaders: target.hcHeaders || null, - hcFollowRedirects: target.hcFollowRedirects || null, - hcMethod: target.hcMethod || null, - hcStatus: target.hcStatus || null, - hcUnhealthyInterval: target.hcUnhealthyInterval || null, - hcMode: target.hcMode || null - }; - - // Only include path-related fields for HTTP resources - if (resource.http) { - data.path = target.path; - data.pathMatchType = target.pathMatchType; - data.rewritePath = target.rewritePath; - data.rewritePathType = target.rewritePathType; - data.priority = target.priority; - } - - const response = await api.post< - AxiosResponse - >(`/target`, data); - - if (response.status === 200) { - // Update the target with the new ID and remove the new flag - setTargets((prev) => - prev.map((t) => - t.targetId === target.targetId - ? { - ...t, - targetId: response.data.data.targetId, - new: false, - updated: false - } - : t - ) - ); - - toast({ - title: t("targetCreated"), - description: t("targetCreatedDescription") - }); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("targetErrorCreate"), - description: formatAxiosError( - err, - t("targetErrorCreateDescription") - ) - }); - } finally { - setTargetsLoading(false); - } - } - - async function addTarget(data: z.infer) { - // if (site && site.type == "wireguard" && site.subnet) { - // // make sure that the target IP is within the site subnet - // const targetIp = data.ip; - // const subnet = site.subnet; - // try { - // if (!isIPInSubnet(targetIp, subnet)) { - // toast({ - // variant: "destructive", - // title: t("targetWireGuardErrorInvalidIp"), - // description: t( - // "targetWireGuardErrorInvalidIpDescription" - // ) - // }); - // return; - // } - // } catch (error) { - // console.error(error); - // toast({ - // variant: "destructive", - // title: t("targetWireGuardErrorInvalidIp"), - // description: t("targetWireGuardErrorInvalidIpDescription") - // }); - // return; - // } - // } - - const site = sites.find((site) => site.siteId === data.siteId); - const isHttp = resource.http; - - const newTarget: LocalTarget = { - ...data, - path: isHttp ? data.path || null : null, - pathMatchType: isHttp ? data.pathMatchType || null : null, - rewritePath: isHttp ? data.rewritePath || null : null, - rewritePathType: isHttp ? data.rewritePathType || null : null, - siteType: site?.type || null, - enabled: true, - targetId: new Date().getTime(), - new: true, - resourceId: resource.resourceId, - priority: isHttp ? data.priority || 100 : 100, - hcEnabled: false, - hcPath: null, - hcMethod: null, - hcInterval: null, - hcTimeout: null, - hcHeaders: null, - hcScheme: null, - hcHostname: null, - hcPort: null, - hcFollowRedirects: null, - hcHealth: "unknown", - hcStatus: null, - hcMode: null, - hcUnhealthyInterval: null, - hcTlsServerName: null - }; - - setTargets([...targets, newTarget]); - } - - const removeTarget = (targetId: number) => { - setTargets([ - ...targets.filter((target) => target.targetId !== targetId) - ]); - - if (!targets.find((target) => target.targetId === targetId)?.new) { - setTargetsToRemove([...targetsToRemove, targetId]); - } - }; - - async function updateTarget(targetId: number, data: Partial) { - const site = sites.find((site) => site.siteId === data.siteId); - setTargets( - targets.map((target) => - target.targetId === targetId - ? { - ...target, - ...data, - updated: true, - siteType: site ? site.type : target.siteType - } - : target - ) - ); - } - - function updateTargetHealthCheck(targetId: number, config: any) { - setTargets( - targets.map((target) => - target.targetId === targetId - ? { - ...target, - ...config, - updated: true - } - : target - ) - ); - } - - const openHealthCheckDialog = (target: LocalTarget) => { - console.log(target); - setSelectedTargetForHealthCheck(target); - setHealthCheckDialogOpen(true); - }; - - async function saveAllSettings() { - // Validate that no targets have blank IPs or invalid ports - const targetsWithInvalidFields = targets.filter( - (target) => - !target.ip || - target.ip.trim() === "" || - !target.port || - target.port <= 0 || - isNaN(target.port) - ); - console.log(targetsWithInvalidFields); - if (targetsWithInvalidFields.length > 0) { - toast({ - variant: "destructive", - title: t("targetErrorInvalidIp"), - description: t("targetErrorInvalidIpDescription") - }); - return; - } - - try { - setTargetsLoading(true); - setHttpsTlsLoading(true); - setProxySettingsLoading(true); - - for (const targetId of targetsToRemove) { - await api.delete(`/target/${targetId}`); - } - - // Save targets - for (const target of targets) { - const data: any = { - ip: target.ip, - port: target.port, - method: target.method, - enabled: target.enabled, - siteId: target.siteId, - hcEnabled: target.hcEnabled, - hcPath: target.hcPath || null, - hcScheme: target.hcScheme || null, - hcHostname: target.hcHostname || null, - hcPort: target.hcPort || null, - hcInterval: target.hcInterval || null, - hcTimeout: target.hcTimeout || null, - hcHeaders: target.hcHeaders || null, - hcFollowRedirects: target.hcFollowRedirects || null, - hcMethod: target.hcMethod || null, - hcStatus: target.hcStatus || null, - hcUnhealthyInterval: target.hcUnhealthyInterval || null, - hcMode: target.hcMode || null, - hcTlsServerName: target.hcTlsServerName - }; - - // Only include path-related fields for HTTP resources - if (resource.http) { - data.path = target.path; - data.pathMatchType = target.pathMatchType; - data.rewritePath = target.rewritePath; - data.rewritePathType = target.rewritePathType; - data.priority = target.priority; - } - - if (target.new) { - const res = await api.put< - AxiosResponse - >(`/resource/${resource.resourceId}/target`, data); - target.targetId = res.data.data.targetId; - target.new = false; - } else if (target.updated) { - await api.post(`/target/${target.targetId}`, data); - target.updated = false; - } - } - - if (resource.http) { - // Gather all settings - const stickySessionData = targetsSettingsForm.getValues(); - const tlsData = tlsSettingsForm.getValues(); - const proxyData = proxySettingsForm.getValues(); - - // Combine into one payload - const payload = { - stickySession: stickySessionData.stickySession, - ssl: tlsData.ssl, - tlsServerName: tlsData.tlsServerName || null, - setHostHeader: proxyData.setHostHeader || null, - headers: proxyData.headers || null - }; - - // Single API call to update all settings - await api.post(`/resource/${resource.resourceId}`, payload); - - // Update local resource context - updateResource({ - ...resource, - stickySession: stickySessionData.stickySession, - ssl: tlsData.ssl, - tlsServerName: tlsData.tlsServerName || null, - setHostHeader: proxyData.setHostHeader || null, - headers: proxyData.headers || null - }); - } else { - // For TCP/UDP resources, save proxy protocol settings - const proxyData = proxySettingsForm.getValues(); - - const payload = { - proxyProtocol: proxyData.proxyProtocol || false, - proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 - }; - - await api.post(`/resource/${resource.resourceId}`, payload); - - updateResource({ - ...resource, - proxyProtocol: proxyData.proxyProtocol || false, - proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 - }); - } - - toast({ - title: t("settingsUpdated"), - description: t("settingsUpdatedDescription") - }); - - setTargetsToRemove([]); - router.refresh(); - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("settingsErrorUpdate"), - description: formatAxiosError( - err, - t("settingsErrorUpdateDescription") - ) - }); - } finally { - setTargetsLoading(false); - setHttpsTlsLoading(false); - setProxySettingsLoading(false); - } - } const getColumns = (): ColumnDef[] => { - const baseColumns: ColumnDef[] = []; const isHttp = resource.http; const priorityColumn: ColumnDef = { @@ -1288,6 +768,91 @@ export default function ReverseProxyTargetsPage(props: { } }; + function addNewTarget() { + const isHttp = resource.http; + + const newTarget: LocalTarget = { + targetId: -Date.now(), // Use negative timestamp as temporary ID + ip: "", + method: isHttp ? "http" : null, + port: 0, + siteId: sites.length > 0 ? sites[0].siteId : 0, + path: isHttp ? null : null, + pathMatchType: isHttp ? null : null, + rewritePath: isHttp ? null : null, + rewritePathType: isHttp ? null : null, + priority: isHttp ? 100 : 100, + enabled: true, + resourceId: resource.resourceId, + hcEnabled: false, + hcPath: null, + hcMethod: null, + hcInterval: null, + hcTimeout: null, + hcHeaders: null, + hcScheme: null, + hcHostname: null, + hcPort: null, + hcFollowRedirects: null, + hcHealth: "unknown", + hcStatus: null, + hcMode: null, + hcUnhealthyInterval: null, + hcTlsServerName: null, + siteType: sites.length > 0 ? sites[0].type : null, + new: true, + updated: false + }; + + setTargets((prev) => [...prev, newTarget]); + } + + const removeTarget = (targetId: number) => { + setTargets([ + ...targets.filter((target) => target.targetId !== targetId) + ]); + + if (!targets.find((target) => target.targetId === targetId)?.new) { + setTargetsToRemove([...targetsToRemove, targetId]); + } + }; + + async function updateTarget(targetId: number, data: Partial) { + const site = sites.find((site) => site.siteId === data.siteId); + setTargets( + targets.map((target) => + target.targetId === targetId + ? { + ...target, + ...data, + updated: true, + siteType: site ? site.type : target.siteType + } + : target + ) + ); + } + + function updateTargetHealthCheck(targetId: number, config: any) { + setTargets( + targets.map((target) => + target.targetId === targetId + ? { + ...target, + ...config, + updated: true + } + : target + ) + ); + } + + const openHealthCheckDialog = (target: LocalTarget) => { + console.log(target); + setSelectedTargetForHealthCheck(target); + setHealthCheckDialogOpen(true); + }; + const columns = getColumns(); const table = useReactTable({ @@ -1305,12 +870,128 @@ export default function ReverseProxyTargetsPage(props: { } }); - if (isLoadingSites || isLoadingTargets) { - return <>; + const router = useRouter(); + + const queryClient = useQueryClient(); + + useEffect(() => { + const newtSites = sites.filter((site) => site.type === "newt"); + for (const site of newtSites) { + initializeDockerForSite(site.siteId); + } + }, [sites]); + + // Save advanced mode preference to localStorage + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem( + "proxy-advanced-mode", + isAdvancedMode.toString() + ); + } + }, [isAdvancedMode]); + + const [, formAction, isSubmitting] = useActionState(saveTargets, null); + + async function saveTargets() { + // Validate that no targets have blank IPs or invalid ports + const targetsWithInvalidFields = targets.filter( + (target) => + !target.ip || + target.ip.trim() === "" || + !target.port || + target.port <= 0 || + isNaN(target.port) + ); + console.log(targetsWithInvalidFields); + if (targetsWithInvalidFields.length > 0) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidIp"), + description: t("targetErrorInvalidIpDescription") + }); + return; + } + + try { + await Promise.all( + targetsToRemove.map((targetId) => + api.delete(`/target/${targetId}`) + ) + ); + + // Save targets + for (const target of targets) { + const data: any = { + ip: target.ip, + port: target.port, + method: target.method, + enabled: target.enabled, + siteId: target.siteId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath || null, + hcScheme: target.hcScheme || null, + hcHostname: target.hcHostname || null, + hcPort: target.hcPort || null, + hcInterval: target.hcInterval || null, + hcTimeout: target.hcTimeout || null, + hcHeaders: target.hcHeaders || null, + hcFollowRedirects: target.hcFollowRedirects || null, + hcMethod: target.hcMethod || null, + hcStatus: target.hcStatus || null, + hcUnhealthyInterval: target.hcUnhealthyInterval || null, + hcMode: target.hcMode || null, + hcTlsServerName: target.hcTlsServerName + }; + + // Only include path-related fields for HTTP resources + if (resource.http) { + data.path = target.path; + data.pathMatchType = target.pathMatchType; + data.rewritePath = target.rewritePath; + data.rewritePathType = target.rewritePathType; + data.priority = target.priority; + } + + if (target.new) { + const res = await api.put< + AxiosResponse + >(`/resource/${resource.resourceId}/target`, data); + target.targetId = res.data.data.targetId; + target.new = false; + } else if (target.updated) { + await api.post(`/target/${target.targetId}`, data); + target.updated = false; + } + } + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") + }); + + setTargetsToRemove([]); + router.refresh(); + await queryClient.invalidateQueries( + resourceQueries.resourceTargets({ + resourceId: resource.resourceId + }) + ); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } } return ( - + <> {t("targets")} @@ -1450,330 +1131,18 @@ export default function ReverseProxyTargetsPage(props: { )} + +
+ +
- {resource.http && ( - - - - {t("proxyAdditional")} - - - {t("proxyAdditionalDescription")} - - - - -
- - {!env.flags.usePangolinDns && ( - ( - - - { - field.onChange( - val - ); - }} - /> - - - )} - /> - )} - ( - - - {t("targetTlsSni")} - - - - - - {t( - "targetTlsSniDescription" - )} - - - - )} - /> - - -
- - -
- - ( - - - { - field.onChange(val); - }} - /> - - - )} - /> - - -
- - -
- - ( - - - {t("proxyCustomHeader")} - - - - - - {t( - "proxyCustomHeaderDescription" - )} - - - - )} - /> - ( - - - {t("customHeaders")} - - - { - field.onChange( - value - ); - }} - rows={4} - /> - - - {t( - "customHeadersDescription" - )} - - - - )} - /> - - -
-
-
- )} - - {!resource.http && resource.protocol == "tcp" && ( - - - - {t("proxyProtocol")} - - - {t("proxyProtocolDescription")} - - - - -
- - ( - - - { - field.onChange(val); - }} - /> - - - )} - /> - - {proxySettingsForm.watch( - "proxyProtocol" - ) && ( - <> - ( - - - {t( - "proxyProtocolVersion" - )} - - - - - - {t( - "versionDescription" - )} - - - )} - /> - - - - - - {t("warning")}: - {" "} - {t("proxyProtocolWarning")} - - - - )} - - -
-
-
- )} - -
- -
- {selectedTargetForHealthCheck && ( )} -
+ ); } -function isIPInSubnet(subnet: string, ip: string): boolean { - const [subnetIP, maskBits] = subnet.split("/"); - const mask = parseInt(maskBits); +function ProxyResourceHttpForm({ + resource, + updateResource +}: Pick) { + const t = useTranslations(); - if (mask < 0 || mask > 32) { - throw new Error("subnetMaskErrorInvalid"); - } + const tlsSettingsSchema = z.object({ + ssl: z.boolean(), + tlsServerName: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: t("proxyErrorTls") + } + ) + }); - // Convert IP addresses to binary numbers - const subnetNum = ipToNumber(subnetIP); - const ipNum = ipToNumber(ip); - - // Calculate subnet mask - const maskNum = mask === 32 ? -1 : ~((1 << (32 - mask)) - 1); - - // Check if the IP is in the subnet - return (subnetNum & maskNum) === (ipNum & maskNum); -} - -function ipToNumber(ip: string): number { - // Validate IP address format - const parts = ip.split("."); - - if (parts.length !== 4) { - throw new Error("ipAddressErrorInvalidFormat"); - } - - // Convert IP octets to 32-bit number - return parts.reduce((num, octet) => { - const oct = parseInt(octet); - if (isNaN(oct) || oct < 0 || oct > 255) { - throw new Error("ipAddressErrorInvalidOctet"); + const tlsSettingsForm = useForm({ + resolver: zodResolver(tlsSettingsSchema), + defaultValues: { + ssl: resource.ssl, + tlsServerName: resource.tlsServerName || "" } - return (num << 8) + oct; - }, 0); + }); + + const proxySettingsSchema = z.object({ + setHostHeader: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: t("proxyErrorInvalidHeader") + } + ), + headers: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.int().min(1).max(2).optional() + }); + + const proxySettingsForm = useForm({ + resolver: zodResolver(proxySettingsSchema), + defaultValues: { + setHostHeader: resource.setHostHeader || "", + headers: resource.headers, + proxyProtocol: resource.proxyProtocol || false, + proxyProtocolVersion: resource.proxyProtocolVersion || 1 + } + }); + + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const targetsSettingsForm = useForm({ + resolver: zodResolver(targetsSettingsSchema), + defaultValues: { + stickySession: resource.stickySession + } + }); + + const router = useRouter(); + const [, formAction, isSubmitting] = useActionState( + saveResourceHttpSettings, + null + ); + + async function saveResourceHttpSettings() { + const isValidTLS = await tlsSettingsForm.trigger(); + const isValidProxy = await proxySettingsForm.trigger(); + const targetSettingsForm = await targetsSettingsForm.trigger(); + if (!isValidTLS || !isValidProxy || !targetSettingsForm) return; + + try { + // Gather all settings + const stickySessionData = targetsSettingsForm.getValues(); + const tlsData = tlsSettingsForm.getValues(); + const proxyData = proxySettingsForm.getValues(); + + // Combine into one payload + const payload = { + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null, + headers: proxyData.headers || null + }; + + // Single API call to update all settings + await api.post(`/resource/${resource.resourceId}`, payload); + + // Update local resource context + updateResource({ + ...resource, + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null, + headers: proxyData.headers || null + }); + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") + }); + + router.refresh(); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } + } + + return ( + + + + {t("proxyAdditional")} + + + {t("proxyAdditionalDescription")} + + + + +
+ + {!env.flags.usePangolinDns && ( + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + )} + ( + + + {t("targetTlsSni")} + + + + + + {t("targetTlsSniDescription")} + + + + )} + /> + + +
+ + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + +
+ + +
+ + ( + + + {t("proxyCustomHeader")} + + + + + + {t("proxyCustomHeaderDescription")} + + + + )} + /> + ( + + + {t("customHeaders")} + + + { + field.onChange(value); + }} + rows={4} + /> + + + {t("customHeadersDescription")} + + + + )} + /> + + +
+
+ +
+
+
+ ); +} + +function ProxyResourceProtocolForm({ + resource, + updateResource +}: Pick) { + const t = useTranslations(); + + const api = createApiClient(useEnvContext()); + + const proxySettingsSchema = z.object({ + setHostHeader: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: t("proxyErrorInvalidHeader") + } + ), + headers: z + .array(z.object({ name: z.string(), value: z.string() })) + .nullable(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.int().min(1).max(2).optional() + }); + + const proxySettingsForm = useForm({ + resolver: zodResolver(proxySettingsSchema), + defaultValues: { + setHostHeader: resource.setHostHeader || "", + headers: resource.headers, + proxyProtocol: resource.proxyProtocol || false, + proxyProtocolVersion: resource.proxyProtocolVersion || 1 + } + }); + + const router = useRouter(); + + const [, formAction, isSubmitting] = useActionState( + saveProtocolSettings, + null + ); + + async function saveProtocolSettings() { + const isValid = proxySettingsForm.trigger(); + if (!isValid) return; + + try { + // For TCP/UDP resources, save proxy protocol settings + const proxyData = proxySettingsForm.getValues(); + + const payload = { + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }; + + await api.post(`/resource/${resource.resourceId}`, payload); + + updateResource({ + ...resource, + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }); + + toast({ + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") + }); + + router.refresh(); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("settingsErrorUpdate"), + description: formatAxiosError( + err, + t("settingsErrorUpdateDescription") + ) + }); + } + } + + return ( + + + + {t("proxyProtocol")} + + + {t("proxyProtocolDescription")} + + + + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + {proxySettingsForm.watch("proxyProtocol") && ( + <> + ( + + + {t("proxyProtocolVersion")} + + + + + + {t("versionDescription")} + + + )} + /> + + + + + {t("warning")}:{" "} + {t("proxyProtocolWarning")} + + + + )} + + +
+
+ +
+
+
+ ); } diff --git a/src/contexts/resourceContext.ts b/src/contexts/resourceContext.ts index bb5501a6..84fa31ac 100644 --- a/src/contexts/resourceContext.ts +++ b/src/contexts/resourceContext.ts @@ -2,7 +2,7 @@ import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceResponse } from "@server/routers/resource/getResource"; import { createContext } from "react"; -interface ResourceContextType { +export interface ResourceContextType { resource: GetResourceResponse; authInfo: GetResourceAuthInfoResponse; updateResource: (updatedResource: Partial) => void;